diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index 66bfc26..5e36c2f 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -1,7 +1,7 @@ 工作流: "idea-to-product" 项目: "Great Agent 2" 版本: "0.3.0" -当前阶段: "骨架待确认" +当前阶段: "纵向功能开发" 更新时间: "2026-08-12" 阻塞: null @@ -15,12 +15,42 @@ 确认人: "用户" 确认时间: "2026-08-12" 项目骨架: - 状态: "待确认" - 确认人: null - 确认时间: null + 状态: "已确认" + 确认人: "用户" + 确认时间: "2026-08-12" 功能验收: 状态: "未开始" 确认人: null 确认时间: null -功能: [] +功能: + - 编号: "F-001" + 名称: "应用外壳、首次状态与普通会话" + 状态: "待用户查看" + - 编号: "F-002" + 名称: "Agent Core 与 DeepSeek 流式回复" + 状态: "待开始" + - 编号: "F-003" + 名称: "项目管理与项目聊天" + 状态: "待开始" + - 编号: "F-004" + 名称: "用户交互卡片" + 状态: "待开始" + - 编号: "F-005" + 名称: "停止、失败与重试" + 状态: "待开始" + - 编号: "F-006" + 名称: "附件、文件列表、读取与搜索" + 状态: "待开始" + - 编号: "F-007" + 名称: "文件创建与安全修改" + 状态: "待开始" + - 编号: "F-008" + 名称: "会话管理与个人设置" + 状态: "待开始" + - 编号: "F-009" + 名称: "Claude Desktop 视觉与交互收口" + 状态: "待开始" + - 编号: "F-010" + 名称: "恢复、边界与发布前加固" + 状态: "待开始" diff --git a/AGENTS.md b/AGENTS.md index 0a3c1c7..a2296e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ - 不得扩大已经冻结的当前版本范围。 - 新想法记录到后续版本。 - 每次只开发一个完整的纵向功能切片。 +- 每个纵向功能切片完成并验证后必须暂停,通知用户查看;只有用户明确确认当前切片后,才能把下一切片标记为“进行中”或开始实现。 - 每个切片覆盖所有适用的界面、接口或服务、数据持久化、权限、校验、异常状态、日志和测试。 - 修改前先检查现有代码。 - 实际运行项目规定的格式或静态检查、类型检查、测试和生产构建。 @@ -34,5 +35,6 @@ - 保持 `local file -> agent core -> web serve -> web` 的依赖方向。 - Agent Core 不得依赖 Web 层,为后续 `local file -> agent core -> cli` 保留扩展边界。 - 避免大文件;模块按单一职责拆分。出现同时承担协议、业务和存储职责的文件时必须拆分。 +- Web 组件私有样式与对应组件文件放在同一目录,例如 `sidebar.ts` 对应 `sidebar.css`;`styles/` 只保存设计令牌、全局 reset 和确实跨组件的基础样式,不创建与功能目录重复的样式镜像树。 - 当前版本为单人使用,不引入多租户、多用户或分布式并发设计。 - 访问保护由部署环境中的外部反向代理承担;应用内不得实现登录页面、身份校验中间件、用户会话或访问凭据配置。 diff --git a/apps/web-server/src/composition/create-app.ts b/apps/web-server/src/composition/create-app.ts index a0d16be..910ff58 100644 --- a/apps/web-server/src/composition/create-app.ts +++ b/apps/web-server/src/composition/create-app.ts @@ -3,11 +3,17 @@ import type { Logger } from "pino"; import { createErrorHandler } from "../http/error-handler"; import { requestId } from "../http/request-id"; import { createHealthRoutes } from "../routes/health"; +import { createConversationRoutes } from "../routes/conversations"; +import type { ConversationService } from "@great-agent/agent-core"; -export function createApp(logger: Logger): Hono { +export function createApp( + logger: Logger, + conversations?: ConversationService, +): Hono { const app = new Hono(); app.use("*", requestId); app.onError(createErrorHandler(logger)); app.route("/api", createHealthRoutes()); + if (conversations) app.route("/api", createConversationRoutes(conversations)); return app; } diff --git a/apps/web-server/src/http/error-handler.ts b/apps/web-server/src/http/error-handler.ts index 8bf92ff..fafc944 100644 --- a/apps/web-server/src/http/error-handler.ts +++ b/apps/web-server/src/http/error-handler.ts @@ -1,9 +1,30 @@ import type { ErrorHandler } from "hono"; import type { Logger } from "pino"; +import { CoreError } from "@great-agent/agent-core"; +import { ZodError } from "zod"; export function createErrorHandler(logger: Logger): ErrorHandler { return (error, context) => { const requestId = context.get("requestId") ?? crypto.randomUUID(); + if (error instanceof ZodError) { + return context.json( + { + error: { + code: "REQUEST_INVALID", + message: "请求内容无效", + requestId, + }, + }, + 400, + ); + } + if (error instanceof CoreError) { + const status = error.code === "CONVERSATION_NOT_FOUND" ? 404 : 400; + return context.json( + { error: { code: error.code, message: error.message, requestId } }, + status, + ); + } logger.error({ error, requestId }, "请求处理失败"); return context.json( { diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index 4e3c316..ed3fe1f 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -1,5 +1,10 @@ import { resolve } from "node:path"; -import { createDataLayout, ensureDataLayout } from "@great-agent/local-data"; +import { + FileConversationRepository, + createDataLayout, + ensureDataLayout, +} from "@great-agent/local-data"; +import { ConversationService } from "@great-agent/agent-core"; import { createApp } from "./composition/create-app"; import { createLogger } from "./composition/create-logger"; import { mountStaticWeb } from "./composition/static-web"; @@ -10,7 +15,12 @@ const logger = createLogger(environment.logLevel); const layout = createDataLayout(environment.dataDir); await ensureDataLayout(layout); -const app = createApp(logger); +const conversations = new ConversationService({ + conversations: new FileConversationRepository(layout), + clock: { now: () => new Date() }, + ids: { create: () => crypto.randomUUID() }, +}); +const app = createApp(logger, conversations); mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist")); logger.info( diff --git a/apps/web-server/src/routes/conversations.test.ts b/apps/web-server/src/routes/conversations.test.ts new file mode 100644 index 0000000..9521c7e --- /dev/null +++ b/apps/web-server/src/routes/conversations.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import pino from "pino"; +import { + ConversationService, + type Conversation, + type ConversationRepository, +} from "@great-agent/agent-core"; +import { createApp } from "../composition/create-app"; + +class MemoryRepository implements ConversationRepository { + private readonly values = new Map(); + async listRecent() { + return [...this.values.values()]; + } + async getById(id: string) { + return this.values.get(id) ?? null; + } + async create(value: Conversation) { + this.values.set(value.id, value); + } + async appendMessage(id: string, message: Conversation["messages"][number]) { + const current = this.values.get(id); + if (!current) throw new Error("not found"); + const next = { + ...current, + messages: [...current.messages, message], + updatedAt: message.createdAt, + }; + this.values.set(id, next); + return next; + } +} + +function createTestApp() { + let id = 0; + const service = new ConversationService({ + conversations: new MemoryRepository(), + clock: { now: () => new Date("2026-08-12T00:00:00.000Z") }, + ids: { create: () => `id_${++id}` }, + }); + return createApp(pino({ enabled: false }), service); +} + +describe("普通会话路由", () => { + test("首条消息创建会话并可读取", async () => { + const app = createTestApp(); + const created = await app.request("/api/conversations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "你好" }), + }); + expect(created.status).toBe(201); + const conversation = (await created.json()) as Conversation; + expect( + (await app.request(`/api/conversations/${conversation.id}`)).status, + ).toBe(200); + expect(await (await app.request("/api/conversations")).json()).toHaveLength( + 1, + ); + }); + + test("拒绝空消息", async () => { + const response = await createTestApp().request("/api/conversations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: " " }), + }); + expect(response.status).toBe(400); + }); +}); diff --git a/apps/web-server/src/routes/conversations.ts b/apps/web-server/src/routes/conversations.ts new file mode 100644 index 0000000..25fa298 --- /dev/null +++ b/apps/web-server/src/routes/conversations.ts @@ -0,0 +1,27 @@ +import { Hono } from "hono"; +import type { ConversationService } from "@great-agent/agent-core"; +import { messageInputSchema } from "@great-agent/web-contracts"; + +export function createConversationRoutes(service: ConversationService): Hono { + const routes = new Hono(); + routes.get("/conversations", async (context) => + context.json(await service.listRecent()), + ); + routes.get("/conversations/:id", async (context) => + context.json(await service.getConversation(context.req.param("id"))), + ); + routes.post("/conversations", async (context) => { + const input = messageInputSchema.parse(await context.req.json()); + return context.json( + await service.createWithFirstMessage(input.content), + 201, + ); + }); + routes.post("/conversations/:id/messages", async (context) => { + const input = messageInputSchema.parse(await context.req.json()); + return context.json( + await service.appendUserMessage(context.req.param("id"), input.content), + ); + }); + return routes; +} diff --git a/apps/web/src/api/conversations.ts b/apps/web/src/api/conversations.ts new file mode 100644 index 0000000..fef81b2 --- /dev/null +++ b/apps/web/src/api/conversations.ts @@ -0,0 +1,69 @@ +import { + conversationSchema, + conversationSummarySchema, + type ConversationResponse, + type ConversationSummaryResponse, +} from "@great-agent/web-contracts"; + +export async function listConversations(): Promise< + ConversationSummaryResponse[] +> { + return conversationSummarySchema + .array() + .parse(await request("/api/conversations")); +} + +export async function getConversation( + id: string, +): Promise { + return conversationSchema.parse( + await request(`/api/conversations/${encodeURIComponent(id)}`), + ); +} + +export async function createConversation( + content: string, +): Promise { + return conversationSchema.parse( + await request("/api/conversations", { + method: "POST", + body: JSON.stringify({ content }), + }), + ); +} + +export async function appendMessage( + id: string, + content: string, +): Promise { + return conversationSchema.parse( + await request(`/api/conversations/${encodeURIComponent(id)}/messages`, { + method: "POST", + body: JSON.stringify({ content }), + }), + ); +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + const value = await response.json(); + if (!response.ok) throw new Error(readErrorMessage(value)); + return value; +} + +function readErrorMessage(value: unknown): string { + if (typeof value === "object" && value && "error" in value) { + const error = value.error; + if ( + typeof error === "object" && + error && + "message" in error && + typeof error.message === "string" + ) + return error.message; + } + return "请求失败,请稍后重试"; +} diff --git a/apps/web/src/app/app-controller.ts b/apps/web/src/app/app-controller.ts new file mode 100644 index 0000000..ad61e96 --- /dev/null +++ b/apps/web/src/app/app-controller.ts @@ -0,0 +1,106 @@ +import van, { type State } from "vanjs-core"; +import type { + ConversationResponse, + ConversationSummaryResponse, +} from "@great-agent/web-contracts"; +import { + appendMessage, + createConversation, + getConversation, + listConversations, +} from "../api/conversations"; + +export type Selection = + | { kind: "none" } + | { kind: "ordinary-draft" } + | { kind: "conversation"; id: string }; + +export type AppController = Readonly<{ + selection: State; + recent: State; + active: State; + loading: State; + sending: State; + error: State; + initialize(): Promise; + startNewTask(): void; + openConversation(id: string): Promise; + send(content: string): Promise; +}>; + +export function createAppController(): AppController { + const selection = van.state({ kind: "none" }); + const recent = van.state([]); + const active = van.state(null); + const loading = van.state(false); + const sending = van.state(false); + const error = van.state(""); + + async function initialize() { + loading.val = true; + error.val = ""; + try { + recent.val = await listConversations(); + } catch (cause) { + error.val = readMessage(cause); + } finally { + loading.val = false; + } + } + + function startNewTask() { + selection.val = { kind: "ordinary-draft" }; + active.val = null; + error.val = ""; + } + + async function openConversation(id: string) { + loading.val = true; + error.val = ""; + try { + active.val = await getConversation(id); + selection.val = { kind: "conversation", id }; + } catch (cause) { + error.val = readMessage(cause); + } finally { + loading.val = false; + } + } + + async function send(content: string) { + if (!content.trim() || sending.val) return; + sending.val = true; + error.val = ""; + try { + const current = selection.val; + const conversation = + current.kind === "conversation" + ? await appendMessage(current.id, content) + : await createConversation(content); + active.val = conversation; + selection.val = { kind: "conversation", id: conversation.id }; + recent.val = await listConversations(); + } catch (cause) { + error.val = readMessage(cause); + } finally { + sending.val = false; + } + } + + return { + selection, + recent, + active, + loading, + sending, + error, + initialize, + startNewTask, + openConversation, + send, + }; +} + +function readMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : "操作失败,请重试"; +} diff --git a/apps/web/src/app/app-shell.css b/apps/web/src/app/app-shell.css new file mode 100644 index 0000000..b3f0662 --- /dev/null +++ b/apps/web/src/app/app-shell.css @@ -0,0 +1,12 @@ +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 100vh; + background: var(--color-canvas); +} + +@media (max-width: 700px) { + .app-shell { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/app/app-shell.ts b/apps/web/src/app/app-shell.ts index 0a047dd..84d94bd 100644 --- a/apps/web/src/app/app-shell.ts +++ b/apps/web/src/app/app-shell.ts @@ -1,11 +1,17 @@ import van from "vanjs-core"; +import { createAppController } from "./app-controller"; +import { Sidebar } from "../features/conversations/sidebar"; +import { ConversationPane } from "../features/conversations/conversation-pane"; +import "./app-shell.css"; -const { h1, main, p } = van.tags; +const { div } = van.tags; export function AppShell(): HTMLElement { - return main( + const controller = createAppController(); + void controller.initialize(); + return div( { class: "app-shell" }, - h1("Great Agent 2"), - p("项目骨架运行正常,产品界面将在纵向功能阶段实现。"), + Sidebar(controller), + ConversationPane(controller), ); } diff --git a/apps/web/src/features/conversations/conversation-pane.css b/apps/web/src/features/conversations/conversation-pane.css new file mode 100644 index 0000000..e79732a --- /dev/null +++ b/apps/web/src/features/conversations/conversation-pane.css @@ -0,0 +1,172 @@ +.main-pane { + position: relative; + min-width: 0; + min-height: 100vh; + padding-bottom: 170px; + background-color: var(--color-main); + background-image: radial-gradient(#2e2e2c 0.65px, transparent 0.65px); + background-size: 9px 9px; +} +.mobile-header { + display: none; +} +.onboarding { + position: absolute; + top: 50%; + left: 50%; + width: min(var(--composer-width), calc(100% - 40px)); + transform: translate(-50%, -67%); +} +.onboarding h1 { + margin: 0; + font-family: var(--font-serif); + font-size: 25px; + font-weight: 600; + letter-spacing: -0.02em; +} +.onboarding p { + margin: 7px 0 0 29px; + color: #87837d; + font-size: 11px; +} +.spark { + color: var(--color-accent); + font-family: sans-serif; + font-size: 23px; +} +.conversation-view { + width: min(720px, calc(100% - 48px)); + margin: 0 auto; + padding: 55px 0 30px; +} +.conversation-title { + margin: 0 0 34px; + font-family: var(--font-serif); + font-size: 24px; +} +.message-list { + display: grid; + gap: 24px; +} +.message { + display: grid; + grid-template-columns: 27px 1fr; + gap: 10px; +} +.message-role { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border-radius: 50%; + color: #302a27; + background: #d08a72; + font-size: 11px; + font-weight: 700; +} +.message p { + margin: 2px 0 0; + color: #e1ded8; + font-size: 14px; + line-height: 1.55; + white-space: pre-wrap; +} +.composer-wrap { + position: fixed; + right: 0; + bottom: 18px; + left: var(--sidebar-width); + width: min(var(--composer-width), calc(100% - 236px)); + margin: 0 auto; +} +.composer { + min-height: 84px; + padding: 12px 13px 8px; + border: 1px solid var(--color-border); + border-radius: 15px 15px 0 0; + background: var(--color-panel); + box-shadow: 0 14px 40px #0005; +} +.composer textarea { + display: block; + width: 100%; + min-height: 43px; + padding: 0; + resize: none; + border: 0; + outline: 0; + color: #e9e6e0; + background: transparent; + font-size: 12px; +} +.composer textarea::placeholder { + color: #8d8982; +} +.composer-actions { + display: flex; + justify-content: space-between; + align-items: center; +} +.attach, +.send { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border-radius: 7px; + background: transparent; + font-size: 18px; +} +.attach { + color: #a29e97; +} +.send { + color: #fff; + background: #a95e4d; + font-weight: 700; +} +.composer-meta { + display: flex; + justify-content: space-between; + align-items: center; + height: 31px; + padding: 0 13px; + border: 1px solid #363532; + border-top: 0; + border-radius: 0 0 15px 15px; + color: var(--color-text-secondary); + background: var(--color-panel-subtle); + font-size: 10px; +} +.composer-meta button { + padding: 0; + color: var(--color-text-secondary); + background: transparent; + font-size: 10px; +} +.error-banner { + margin-bottom: 7px; + padding: 8px 11px; + border: 1px solid #7a4038; + border-radius: 8px; + color: #e4aaa0; + background: #3a2522; + font-size: 11px; +} + +@media (max-width: 700px) { + .mobile-header { + display: block; + padding: 14px 16px; + border-bottom: 1px solid #323230; + font-size: 13px; + } + .composer-wrap { + left: 0; + width: calc(100% - 24px); + } + .conversation-view { + width: calc(100% - 28px); + padding-top: 30px; + } +} diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts new file mode 100644 index 0000000..7d3fe98 --- /dev/null +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -0,0 +1,96 @@ +import van, { type State } from "vanjs-core"; +import type { AppController } from "../../app/app-controller"; +import "./conversation-pane.css"; + +const { article, button, div, h1, header, main, p, span, textarea } = van.tags; + +export function ConversationPane(controller: AppController): HTMLElement { + const draft = van.state(""); + + async function submit() { + const content = draft.val; + if (!content.trim()) return; + await controller.send(content); + if (!controller.error.val) draft.val = ""; + } + + return main( + { class: "main-pane" }, + header({ class: "mobile-header" }, "Great Agent 2"), + () => + controller.selection.val.kind === "conversation" && controller.active.val + ? div( + { class: "conversation-view" }, + h1({ class: "conversation-title" }, controller.active.val.title), + div( + { class: "message-list" }, + controller.active.val.messages.map((message) => + article( + { class: `message ${message.role}` }, + span({ class: "message-role" }, "你"), + p(message.content), + ), + ), + ), + ) + : div( + { class: "onboarding" }, + h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"), + p("从一个问题、想法或具体任务开始。"), + ), + Composer(controller, draft, submit), + ); +} + +function Composer( + controller: AppController, + draft: State, + submit: () => Promise, +): HTMLElement { + return div( + { class: "composer-wrap" }, + () => + controller.error.val + ? div({ class: "error-banner", role: "alert" }, controller.error.val) + : null, + div( + { class: "composer" }, + textarea({ + "aria-label": "输入消息", + placeholder: "我能帮你做些什么?", + value: draft, + disabled: controller.sending, + oninput: (event: Event) => { + draft.val = (event.target as HTMLTextAreaElement).value; + }, + onkeydown: (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void submit(); + } + }, + }), + div( + { class: "composer-actions" }, + button( + { class: "attach", disabled: true, "aria-label": "添加附件" }, + "+", + ), + button( + { + class: "send", + disabled: () => controller.sending.val || !draft.val.trim(), + onclick: () => void submit(), + "aria-label": "发送消息", + }, + () => (controller.sending.val ? "…" : "↑"), + ), + ), + ), + div( + { class: "composer-meta" }, + button({ disabled: true }, "普通对话"), + span("DeepSeek"), + ), + ); +} diff --git a/apps/web/src/features/conversations/sidebar.css b/apps/web/src/features/conversations/sidebar.css new file mode 100644 index 0000000..bf1d9f9 --- /dev/null +++ b/apps/web/src/features/conversations/sidebar.css @@ -0,0 +1,140 @@ +.sidebar { + position: relative; + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 10px 7px; + border-right: 1px solid #323230; + background: var(--color-sidebar); +} + +.window-controls { + display: flex; + gap: 8px; + height: 19px; + padding: 1px 4px; +} +.window-controls span { + width: 9px; + height: 9px; + border-radius: 50%; + background: #55534f; +} +.mode-switch { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + margin: 6px 3px 8px; + padding: 2px; + border-radius: 7px; + background: #2d2d2b; +} +.mode-switch button { + min-height: 23px; + border-radius: 5px; + color: #8e8b85; + background: transparent; + font-size: 11px; +} +.mode-switch button.active { + color: #e7e4de; + background: #42413e; +} +.new-task, +.nav-item { + width: 100%; + min-height: 29px; + padding: 0 9px; + border-radius: 6px; + text-align: left; + background: transparent; + font-size: 12px; +} +.new-task { + background: #1c1c1b; +} +.new-task:hover, +.recent-list button:hover { + background: #323230; +} +.plus { + display: inline-block; + width: 19px; + font-size: 17px; + vertical-align: -1px; +} +.nav-item { + color: #c8c5bf; +} +.sidebar h2 { + margin: 14px 7px 6px; + color: #77746f; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.sidebar-empty { + padding: 5px 8px; + color: #716e69; + font-size: 11px; +} +.recent-list { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + list-style: none; +} +.recent-list button { + overflow: hidden; + width: 100%; + padding: 6px 10px 6px 20px; + border-radius: 5px; + color: #b8b5af; + background: transparent; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} +.recent-list button::before { + content: "○"; + margin-left: -13px; + margin-right: 6px; + color: #5d5a56; +} +.sidebar-footer { + display: flex; + gap: 9px; + align-items: center; + margin-top: auto; + padding: 9px 8px 3px; + border-top: 1px solid #302f2e; + color: #c8c5bf; + font-size: 11px; +} +.sidebar-footer div { + display: grid; + gap: 1px; +} +.sidebar-footer div span { + color: #77746f; + font-size: 9px; +} +.avatar { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border-radius: 50%; + color: #1f1e1c; + background: #ce7c64; + font-weight: 700; +} + +@media (max-width: 700px) { + .sidebar { + display: none; + } +} diff --git a/apps/web/src/features/conversations/sidebar.ts b/apps/web/src/features/conversations/sidebar.ts new file mode 100644 index 0000000..693af0c --- /dev/null +++ b/apps/web/src/features/conversations/sidebar.ts @@ -0,0 +1,45 @@ +import van from "vanjs-core"; +import type { AppController } from "../../app/app-controller"; +import "./sidebar.css"; + +const { aside, button, div, h2, li, span, ul } = van.tags; + +export function Sidebar(controller: AppController): HTMLElement { + return aside( + { class: "sidebar" }, + div({ class: "window-controls" }, span(), span(), span()), + div( + { class: "mode-switch" }, + button({ class: "active" }, "对话"), + button({ disabled: true }, "代码"), + ), + button( + { class: "new-task", onclick: controller.startNewTask }, + span({ class: "plus" }, "+"), + "新任务", + ), + button({ class: "nav-item", disabled: true }, "▣", " 项目"), + h2("最近"), + () => + controller.loading.val && controller.recent.val.length === 0 + ? div({ class: "sidebar-empty" }, "正在加载…") + : controller.recent.val.length === 0 + ? div({ class: "sidebar-empty" }, "暂无对话") + : ul( + { class: "recent-list" }, + controller.recent.val.map((item) => + li( + button( + { onclick: () => controller.openConversation(item.id) }, + item.title, + ), + ), + ), + ), + div( + { class: "sidebar-footer" }, + span({ class: "avatar" }, "G"), + div("Great Agent 2", span("本地个人助手")), + ), + ); +} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 5552612..899af1d 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -1,31 +1,31 @@ -:root { - color: #272522; - background: #f7f6f2; - font-family: ui-sans-serif, system-ui, sans-serif; -} +@import "./tokens.css"; * { box-sizing: border-box; } body { - margin: 0; -} - -.app-shell { - display: grid; + min-width: 320px; min-height: 100vh; - place-content: center; - padding: 2rem; - text-align: center; -} - -.app-shell h1 { - margin: 0 0 0.75rem; - font-size: 2rem; -} - -.app-shell p { margin: 0; - color: #6b6760; + color: var(--color-text-primary); + background: var(--color-canvas); + font-family: var(--font-sans); + font-synthesis: none; +} + +button, +textarea { + color: inherit; + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.46; } diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css new file mode 100644 index 0000000..798f115 --- /dev/null +++ b/apps/web/src/styles/tokens.css @@ -0,0 +1,17 @@ +:root { + --color-canvas: #191918; + --color-main: #1b1b1a; + --color-sidebar: #242423; + --color-panel: #2c2c2a; + --color-panel-subtle: #222221; + --color-border: #3a3936; + --color-text-primary: #e8e5df; + --color-text-secondary: #8a8781; + --color-accent: #cf785e; + --sidebar-width: 196px; + --composer-width: 460px; + --font-sans: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + --font-serif: Georgia, serif; +} diff --git a/bun.lock b/bun.lock index 4ddb3eb..adf8fb0 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,9 @@ "packages/local-data": { "name": "@great-agent/local-data", "version": "0.1.0", + "dependencies": { + "@great-agent/agent-core": "workspace:*", + }, }, "packages/local-files": { "name": "@great-agent/local-files", diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5dd5649..87cefba 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,7 +1,85 @@ # 纵向功能 -状态:未开始 -需求版本:0.1.0(待确认) +状态:开发中 +需求版本:0.3.0(已确认、已冻结) +实施方案版本:0.4.0(已确认) +项目骨架:已确认(2026-08-12) -实施方案和项目骨架获得人工确认前,不生成纵向功能开发清单。 +允许的状态:“待开始”“进行中”“待用户查看”“已完成”“已阻塞”。每个切片自动化验证完成后先进入“待用户查看”并暂停;用户明确确认后才标记“已完成”,再开始下一切片。“已完成”仍不等于最终功能验收已经通过。 +## 当前版本功能切片 + +### F-001——应用外壳、首次状态与普通会话 + +- 状态:待用户查看(自动化开发验证已完成) +- 用户可见结果:首次进入看到“最近”和“项目”空状态及右侧对话引导;点击“新任务”不会创建空会话,发送首条有效消息后普通会话出现,选择历史会话可还原消息并继续输入。 +- 页面与交互:Claude Desktop 风格三栏外壳、空状态、对话引导、新任务草稿、普通历史会话、输入校验、加载与保存失败状态。 +- 服务或接口:普通会话列表、会话详情、首条消息原子创建会话、已有会话追加用户消息。 +- 数据持久化:Conversation 和 Message 实体 JSON、普通会话可重建索引、首条消息失败不暴露空会话。 +- 权限与校验:应用内无身份层;普通会话强制 `projectId = null`;空文本拒绝;客户端不能指定工作区。 +- 异常状态:列表加载失败、会话不存在、输入无效、持久化失败、重复提交期间禁用。 +- 自动化测试:Core 首条消息和空消息用例、本地文件 Repository、Web DTO/路由及骨架回归共 10 个测试、24 个断言,全部通过。 +- 验证命令与结果:`bun run format:check`、`bun run lint`、`bun run typecheck`、`bun run check:file-size`、`bun run check:architecture`、`bun test`、`bun run build` 全部通过。 +- 人工验证步骤:在空数据目录启动生产服务;确认首次引导与空最近列表;点击“新任务”确认列表不新增;发送“这是第一条本地消息”确认会话和消息出现;刷新确认不自动选择;点击历史会话确认消息恢复。 +- 人工操作结果:2026-08-12 使用本机浏览器完成上述步骤,首次与点击新任务后的会话数均为 0,发送后为 1,刷新后仍显示引导,选择历史后消息恢复。 +- 视觉参考:已只读采集本机 Claude Desktop 新任务页并保存为 `docs/visual-reference/claude-new-task.png`;F-001 实现截图保存为 `docs/visual-reference/f001-conversation.png`。历史任务采集超时,留待 F-009 补齐。 +- 查看阶段修改:根据用户反馈将集中式 `global.css` 拆为全局令牌/reset、应用外壳样式和 conversations 组件共置样式;后续组件继续遵循相同约定。 +- 已知限制:本切片不调用模型;用户消息可持久化,但助手回复从 F-002 开始。 + +### F-002——Agent Core 与 DeepSeek 流式回复 + +- 状态:待开始;F-001 获得用户确认后才能开始 +- 用户可见结果:普通会话获得真实 DeepSeek 流式回复并可连续聊天。 +- 主要验收:AC-004、AC-010、AC-014、AC-016。 + +### F-003——项目管理与项目聊天 + +- 状态:待开始 +- 用户可见结果:创建、选择、重命名和删除项目,在项目中维护多个独立会话。 +- 主要验收:AC-026 至 AC-033。 + +### F-004——用户交互卡片 + +- 状态:待开始 +- 用户可见结果:Agent 请求单选、多选、确认或意见输入,回答后继续同一 Run。 +- 主要验收:AC-034 至 AC-038。 + +### F-005——停止、失败与重试 + +- 状态:待开始 +- 用户可见结果:停止当前生成并从失败状态重试。 +- 主要验收:AC-005、AC-010、AC-011。 + +### F-006——附件、文件列表、读取与搜索 + +- 状态:待开始 +- 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。 +- 主要验收:AC-007 至 AC-009、AC-021、AC-022、AC-028、AC-030。 + +### F-007——文件创建与安全修改 + +- 状态:待开始 +- 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。 +- 主要验收:AC-008、AC-009、AC-023、AC-028。 + +### F-008——会话管理与个人设置 + +- 状态:待开始 +- 用户可见结果:重命名和删除会话,查看并修改非敏感设置。 +- 主要验收:AC-003、AC-018。 + +### F-009——Claude Desktop 视觉与交互收口 + +- 状态:待开始 +- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。 +- 主要验收:AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。 + +### F-010——恢复、边界与发布前加固 + +- 状态:待开始 +- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。 +- 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。 + +## 后续版本想法 + +当前无。新增想法只记录在这里,不扩大 0.3.0 冻结范围。 diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index ad6dc71..7467f7d 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,6 +1,6 @@ # 实施方案 -状态:已确认,项目骨架待确认 +状态:已确认,项目骨架已确认,纵向功能开发中 方案版本:0.4.0 需求版本:0.3.0(已确认、已冻结) 确认人:用户 diff --git a/docs/visual-reference/claude-new-task.png b/docs/visual-reference/claude-new-task.png new file mode 100644 index 0000000..8fa18f6 Binary files /dev/null and b/docs/visual-reference/claude-new-task.png differ diff --git a/docs/visual-reference/f001-conversation.png b/docs/visual-reference/f001-conversation.png new file mode 100644 index 0000000..debd2c8 Binary files /dev/null and b/docs/visual-reference/f001-conversation.png differ diff --git a/packages/agent-core/src/domain/conversation.ts b/packages/agent-core/src/domain/conversation.ts new file mode 100644 index 0000000..eb8c131 --- /dev/null +++ b/packages/agent-core/src/domain/conversation.ts @@ -0,0 +1,22 @@ +export type MessageRole = "user" | "assistant"; + +export type Message = Readonly<{ + id: string; + role: MessageRole; + content: string; + createdAt: string; +}>; + +export type Conversation = Readonly<{ + id: string; + projectId: null; + title: string; + messages: readonly Message[]; + createdAt: string; + updatedAt: string; +}>; + +export type ConversationSummary = Pick< + Conversation, + "id" | "projectId" | "title" | "createdAt" | "updatedAt" +>; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 648ac12..60d43d9 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -1,7 +1,18 @@ export { CoreError } from "./errors/core-error"; +export type { + Conversation, + ConversationSummary, + Message, + MessageRole, +} from "./domain/conversation"; +export type { ConversationRepository } from "./ports/conversation-repository"; export type { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port"; export type { ClockPort, IdPort, WorkspaceResolverPort, } from "./ports/system-ports"; +export { + ConversationService, + type ConversationServiceDependencies, +} from "./use-cases/conversation-service"; diff --git a/packages/agent-core/src/ports/conversation-repository.ts b/packages/agent-core/src/ports/conversation-repository.ts new file mode 100644 index 0000000..5b1fd83 --- /dev/null +++ b/packages/agent-core/src/ports/conversation-repository.ts @@ -0,0 +1,15 @@ +import type { + Conversation, + ConversationSummary, + Message, +} from "../domain/conversation"; + +export interface ConversationRepository { + listRecent(): Promise; + getById(id: string): Promise; + create(conversation: Conversation): Promise; + appendMessage( + conversationId: string, + message: Message, + ): Promise; +} diff --git a/packages/agent-core/src/use-cases/conversation-service.test.ts b/packages/agent-core/src/use-cases/conversation-service.test.ts new file mode 100644 index 0000000..7947905 --- /dev/null +++ b/packages/agent-core/src/use-cases/conversation-service.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import type { Conversation, ConversationSummary } from "../domain/conversation"; +import type { ConversationRepository } from "../ports/conversation-repository"; +import { ConversationService } from "./conversation-service"; + +class MemoryConversationRepository implements ConversationRepository { + readonly values = new Map(); + async listRecent(): Promise { + return [...this.values.values()]; + } + async getById(id: string): Promise { + return this.values.get(id) ?? null; + } + async create(conversation: Conversation): Promise { + this.values.set(conversation.id, conversation); + } + async appendMessage( + id: string, + message: Conversation["messages"][number], + ): Promise { + const current = this.values.get(id); + if (!current) throw new Error("not found"); + const next = { + ...current, + messages: [...current.messages, message], + updatedAt: message.createdAt, + }; + this.values.set(id, next); + return next; + } +} + +describe("ConversationService", () => { + test("首条有效消息才创建普通会话", async () => { + const conversations = new MemoryConversationRepository(); + let nextId = 0; + const service = new ConversationService({ + conversations, + clock: { now: () => new Date("2026-08-12T00:00:00.000Z") }, + ids: { create: () => `id_${++nextId}` }, + }); + const created = await service.createWithFirstMessage(" 第一次对话 "); + expect(created.projectId).toBeNull(); + expect(created.messages[0]?.content).toBe("第一次对话"); + expect(await service.listRecent()).toHaveLength(1); + }); + + test("拒绝空消息且不创建会话", async () => { + const conversations = new MemoryConversationRepository(); + const service = new ConversationService({ + conversations, + clock: { now: () => new Date() }, + ids: { create: () => "unused" }, + }); + expect(service.createWithFirstMessage(" \n ")).rejects.toMatchObject({ + code: "MESSAGE_EMPTY", + }); + expect(conversations.values.size).toBe(0); + }); +}); diff --git a/packages/agent-core/src/use-cases/conversation-service.ts b/packages/agent-core/src/use-cases/conversation-service.ts new file mode 100644 index 0000000..9221809 --- /dev/null +++ b/packages/agent-core/src/use-cases/conversation-service.ts @@ -0,0 +1,78 @@ +import type { + Conversation, + ConversationSummary, + Message, +} from "../domain/conversation"; +import { CoreError } from "../errors/core-error"; +import type { ConversationRepository } from "../ports/conversation-repository"; +import type { ClockPort, IdPort } from "../ports/system-ports"; + +export type ConversationServiceDependencies = Readonly<{ + conversations: ConversationRepository; + clock: ClockPort; + ids: IdPort; +}>; + +export class ConversationService { + constructor(private readonly dependencies: ConversationServiceDependencies) {} + + listRecent(): Promise { + return this.dependencies.conversations.listRecent(); + } + + async getConversation(id: string): Promise { + const conversation = await this.dependencies.conversations.getById(id); + if (!conversation) + throw new CoreError("CONVERSATION_NOT_FOUND", "会话不存在"); + return conversation; + } + + async createWithFirstMessage(content: string): Promise { + const normalized = normalizeContent(content); + const timestamp = this.dependencies.clock.now().toISOString(); + const conversation: Conversation = { + id: this.dependencies.ids.create(), + projectId: null, + title: createTitle(normalized), + messages: [this.createUserMessage(normalized, timestamp)], + createdAt: timestamp, + updatedAt: timestamp, + }; + await this.dependencies.conversations.create(conversation); + return conversation; + } + + async appendUserMessage(id: string, content: string): Promise { + const normalized = normalizeContent(content); + await this.getConversation(id); + return this.dependencies.conversations.appendMessage( + id, + this.createUserMessage( + normalized, + this.dependencies.clock.now().toISOString(), + ), + ); + } + + private createUserMessage(content: string, createdAt: string): Message { + return { + id: this.dependencies.ids.create(), + role: "user", + content, + createdAt, + }; + } +} + +function normalizeContent(content: string): string { + const normalized = content.trim(); + if (!normalized) throw new CoreError("MESSAGE_EMPTY", "请输入消息内容"); + if (normalized.length > 32_000) + throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长"); + return normalized; +} + +function createTitle(content: string): string { + const firstLine = content.split("\n", 1)[0] ?? content; + return firstLine.length > 36 ? `${firstLine.slice(0, 36)}…` : firstLine; +} diff --git a/packages/local-data/package.json b/packages/local-data/package.json index 04db524..b95d97c 100644 --- a/packages/local-data/package.json +++ b/packages/local-data/package.json @@ -7,5 +7,8 @@ }, "scripts": { "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@great-agent/agent-core": "workspace:*" } } diff --git a/packages/local-data/src/index.ts b/packages/local-data/src/index.ts index 885ede3..9809766 100644 --- a/packages/local-data/src/index.ts +++ b/packages/local-data/src/index.ts @@ -6,3 +6,4 @@ export { ensureDataLayout, type DataLayout, } from "./layout/data-layout"; +export { FileConversationRepository } from "./repositories/file-conversation-repository"; diff --git a/packages/local-data/src/repositories/file-conversation-repository.test.ts b/packages/local-data/src/repositories/file-conversation-repository.test.ts new file mode 100644 index 0000000..af67202 --- /dev/null +++ b/packages/local-data/src/repositories/file-conversation-repository.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { Conversation } from "@great-agent/agent-core"; +import { createDataLayout, ensureDataLayout } from "../layout/data-layout"; +import { FileConversationRepository } from "./file-conversation-repository"; + +let root: string | undefined; +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +describe("FileConversationRepository", () => { + test("持久化并按更新时间列出普通会话", async () => { + root = await mkdtemp(join(tmpdir(), "great-agent2-conversations-")); + const layout = createDataLayout(root); + await ensureDataLayout(layout); + const repository = new FileConversationRepository(layout); + const conversation: Conversation = { + id: "conversation_1", + projectId: null, + title: "测试会话", + messages: [], + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + await repository.create(conversation); + await repository.appendMessage(conversation.id, { + id: "message_1", + role: "user", + content: "你好", + createdAt: "2026-08-12T01:00:00.000Z", + }); + expect((await repository.listRecent())[0]?.updatedAt).toBe( + "2026-08-12T01:00:00.000Z", + ); + expect((await repository.getById(conversation.id))?.messages).toHaveLength( + 1, + ); + }); +}); diff --git a/packages/local-data/src/repositories/file-conversation-repository.ts b/packages/local-data/src/repositories/file-conversation-repository.ts new file mode 100644 index 0000000..1e3897d --- /dev/null +++ b/packages/local-data/src/repositories/file-conversation-repository.ts @@ -0,0 +1,77 @@ +import { mkdir, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { + Conversation, + ConversationRepository, + ConversationSummary, + Message, +} from "@great-agent/agent-core"; +import { writeJsonAtomically } from "../atomic-writes/write-json-atomically"; +import type { DataLayout } from "../layout/data-layout"; + +export class FileConversationRepository implements ConversationRepository { + constructor(private readonly layout: DataLayout) {} + + async listRecent(): Promise { + const conversations = await this.readAll(); + return conversations + .filter((conversation) => conversation.projectId === null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map(({ messages: _messages, ...summary }) => summary); + } + + async getById(id: string): Promise { + try { + return JSON.parse( + await readFile(this.pathFor(id), "utf8"), + ) as Conversation; + } catch (error) { + if (isFileNotFound(error)) return null; + throw error; + } + } + + async create(conversation: Conversation): Promise { + await mkdir(this.layout.conversations, { recursive: true }); + if (await this.getById(conversation.id)) + throw new Error("会话标识已经存在"); + await writeJsonAtomically(this.pathFor(conversation.id), conversation); + } + + async appendMessage( + conversationId: string, + message: Message, + ): Promise { + const current = await this.getById(conversationId); + if (!current) throw new Error("会话不存在"); + const next: Conversation = { + ...current, + messages: [...current.messages, message], + updatedAt: message.createdAt, + }; + await writeJsonAtomically(this.pathFor(conversationId), next); + return next; + } + + private pathFor(id: string): string { + return join(this.layout.conversations, `${id}.json`); + } + + private async readAll(): Promise { + await mkdir(this.layout.conversations, { recursive: true }); + const names = await readdir(this.layout.conversations); + return Promise.all( + names + .filter((name) => name.endsWith(".json")) + .map(async (name) => { + return JSON.parse( + await readFile(join(this.layout.conversations, name), "utf8"), + ) as Conversation; + }), + ); + } +} + +function isFileNotFound(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/web-contracts/src/index.ts b/packages/web-contracts/src/index.ts index bcda54c..97ae02e 100644 --- a/packages/web-contracts/src/index.ts +++ b/packages/web-contracts/src/index.ts @@ -1 +1,9 @@ export { healthResponseSchema, type HealthResponse } from "./responses/health"; +export { messageInputSchema, type MessageInput } from "./requests/message"; +export { + conversationSchema, + conversationSummarySchema, + type ConversationResponse, + type ConversationSummaryResponse, + messageSchema, +} from "./responses/conversation"; diff --git a/packages/web-contracts/src/requests/message.ts b/packages/web-contracts/src/requests/message.ts new file mode 100644 index 0000000..abf9d63 --- /dev/null +++ b/packages/web-contracts/src/requests/message.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const messageInputSchema = z.object({ + content: z.string().trim().min(1).max(32_000), +}); +export type MessageInput = z.infer; diff --git a/packages/web-contracts/src/responses/conversation.ts b/packages/web-contracts/src/responses/conversation.ts new file mode 100644 index 0000000..7367aac --- /dev/null +++ b/packages/web-contracts/src/responses/conversation.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const messageSchema = z.object({ + id: z.string(), + role: z.enum(["user", "assistant"]), + content: z.string(), + createdAt: z.string(), +}); + +export const conversationSummarySchema = z.object({ + id: z.string(), + projectId: z.null(), + title: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const conversationSchema = conversationSummarySchema.extend({ + messages: z.array(messageSchema), +}); +export type ConversationResponse = z.infer; +export type ConversationSummaryResponse = z.infer< + typeof conversationSummarySchema +>; diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts index 6f7ebfa..53f57e5 100644 --- a/scripts/check-architecture.ts +++ b/scripts/check-architecture.ts @@ -25,6 +25,7 @@ const violations: string[] = []; for (const rule of rules) { const glob = new Bun.Glob("**/*.ts"); for await (const relative of glob.scan({ cwd: rule.root, onlyFiles: true })) { + if (relative.endsWith(".test.ts")) continue; const path = `${rule.root}/${relative}`; const source = await Bun.file(path).text(); for (const forbidden of rule.forbidden) {