From 9197da2eb425ca58274a04bb8e074600dd15df58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Wed, 12 Aug 2026 17:35:19 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E5=BA=94=E7=94=A8=E5=A4=96=E5=A3=B3?= =?UTF-8?q?=E3=80=81=E9=A6=96=E6=AC=A1=E7=8A=B6=E6=80=81=E4=B8=8E=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai-project/state.yaml | 40 +++- AGENTS.md | 2 + apps/web-server/src/composition/create-app.ts | 8 +- apps/web-server/src/http/error-handler.ts | 21 +++ apps/web-server/src/index.ts | 14 +- .../src/routes/conversations.test.ts | 70 +++++++ apps/web-server/src/routes/conversations.ts | 27 +++ apps/web/src/api/conversations.ts | 69 +++++++ apps/web/src/app/app-controller.ts | 106 +++++++++++ apps/web/src/app/app-shell.css | 12 ++ apps/web/src/app/app-shell.ts | 14 +- .../conversations/conversation-pane.css | 172 ++++++++++++++++++ .../conversations/conversation-pane.ts | 96 ++++++++++ .../src/features/conversations/sidebar.css | 140 ++++++++++++++ .../web/src/features/conversations/sidebar.ts | 45 +++++ apps/web/src/styles/global.css | 44 ++--- apps/web/src/styles/tokens.css | 17 ++ bun.lock | 3 + docs/FEATURES.md | 84 ++++++++- docs/IMPLEMENTATION.md | 2 +- docs/visual-reference/claude-new-task.png | Bin 0 -> 63360 bytes docs/visual-reference/f001-conversation.png | Bin 0 -> 39911 bytes .../agent-core/src/domain/conversation.ts | 22 +++ packages/agent-core/src/index.ts | 11 ++ .../src/ports/conversation-repository.ts | 15 ++ .../use-cases/conversation-service.test.ts | 60 ++++++ .../src/use-cases/conversation-service.ts | 78 ++++++++ packages/local-data/package.json | 3 + packages/local-data/src/index.ts | 1 + .../file-conversation-repository.test.ts | 43 +++++ .../file-conversation-repository.ts | 77 ++++++++ packages/web-contracts/src/index.ts | 8 + .../web-contracts/src/requests/message.ts | 6 + .../src/responses/conversation.ts | 24 +++ scripts/check-architecture.ts | 1 + 35 files changed, 1297 insertions(+), 38 deletions(-) create mode 100644 apps/web-server/src/routes/conversations.test.ts create mode 100644 apps/web-server/src/routes/conversations.ts create mode 100644 apps/web/src/api/conversations.ts create mode 100644 apps/web/src/app/app-controller.ts create mode 100644 apps/web/src/app/app-shell.css create mode 100644 apps/web/src/features/conversations/conversation-pane.css create mode 100644 apps/web/src/features/conversations/conversation-pane.ts create mode 100644 apps/web/src/features/conversations/sidebar.css create mode 100644 apps/web/src/features/conversations/sidebar.ts create mode 100644 apps/web/src/styles/tokens.css create mode 100644 docs/visual-reference/claude-new-task.png create mode 100644 docs/visual-reference/f001-conversation.png create mode 100644 packages/agent-core/src/domain/conversation.ts create mode 100644 packages/agent-core/src/ports/conversation-repository.ts create mode 100644 packages/agent-core/src/use-cases/conversation-service.test.ts create mode 100644 packages/agent-core/src/use-cases/conversation-service.ts create mode 100644 packages/local-data/src/repositories/file-conversation-repository.test.ts create mode 100644 packages/local-data/src/repositories/file-conversation-repository.ts create mode 100644 packages/web-contracts/src/requests/message.ts create mode 100644 packages/web-contracts/src/responses/conversation.ts 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 0000000000000000000000000000000000000000..8fa18f60adb9858d421e6cb72d838ad24c11568f GIT binary patch literal 63360 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 literal 0 HcmV?d00001 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) {