From 61e38510f9cdd7a8479e7dc03af6bfb27d080563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Fri, 14 Aug 2026 14:34:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai-project/state.yaml | 6 +- apps/web-server/src/composition/create-app.ts | 5 + apps/web-server/src/http/error-handler.ts | 8 +- apps/web-server/src/index.ts | 28 ++- .../src/routes/conversations.test.ts | 9 + apps/web-server/src/routes/projects.ts | 43 +++++ apps/web-server/src/routes/runs.test.ts | 23 ++- apps/web/src/api/conversations.ts | 33 ++-- apps/web/src/api/projects.ts | 39 ++++ apps/web/src/api/request.ts | 27 +++ apps/web/src/api/runs.ts | 1 + apps/web/src/app/app-controller.ts | 167 ++++++++++++++++-- .../conversations/conversation-pane.css | 3 + .../conversations/conversation-pane.ts | 91 +++++++--- .../src/features/conversations/sidebar.css | 63 ++++++- .../web/src/features/conversations/sidebar.ts | 122 +++++++++++-- .../src/features/projects/project-panel.css | 94 ++++++++++ .../src/features/projects/project-panel.ts | 129 ++++++++++++++ docs/FEATURES.md | 6 +- .../agent-core/src/domain/conversation.ts | 2 +- packages/agent-core/src/domain/project.ts | 13 ++ packages/agent-core/src/index.ts | 6 + .../src/ports/conversation-repository.ts | 2 + .../src/ports/project-repository.ts | 9 + .../agent-core/src/ports/run-repository.ts | 4 + packages/agent-core/src/ports/system-ports.ts | 3 +- .../src/use-cases/agent-run-service.test.ts | 23 ++- .../src/use-cases/agent-run-service.ts | 37 +++- .../use-cases/conversation-service.test.ts | 9 + .../src/use-cases/conversation-service.ts | 23 +++ .../src/use-cases/project-service.test.ts | 133 ++++++++++++++ .../src/use-cases/project-service.ts | 116 ++++++++++++ packages/local-data/src/index.ts | 1 + .../file-conversation-repository.ts | 20 ++- .../repositories/file-project-repository.ts | 62 +++++++ .../src/repositories/file-run-repository.ts | 38 +++- packages/local-files/src/index.ts | 35 +++- .../src/local-workspace-resolver.test.ts | 23 +++ packages/web-contracts/src/index.ts | 7 + .../web-contracts/src/requests/project.ts | 13 ++ packages/web-contracts/src/requests/run.ts | 5 + .../src/responses/conversation.ts | 2 +- .../web-contracts/src/responses/project.ts | 13 ++ 43 files changed, 1383 insertions(+), 113 deletions(-) create mode 100644 apps/web-server/src/routes/projects.ts create mode 100644 apps/web/src/api/projects.ts create mode 100644 apps/web/src/api/request.ts create mode 100644 apps/web/src/features/projects/project-panel.css create mode 100644 apps/web/src/features/projects/project-panel.ts create mode 100644 packages/agent-core/src/domain/project.ts create mode 100644 packages/agent-core/src/ports/project-repository.ts create mode 100644 packages/agent-core/src/use-cases/project-service.test.ts create mode 100644 packages/agent-core/src/use-cases/project-service.ts create mode 100644 packages/local-data/src/repositories/file-project-repository.ts create mode 100644 packages/local-files/src/local-workspace-resolver.test.ts create mode 100644 packages/web-contracts/src/requests/project.ts create mode 100644 packages/web-contracts/src/responses/project.ts diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index abe99a4..4ce4ec3 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -2,7 +2,7 @@ 项目: "Great Agent 2" 版本: "0.3.0" 当前阶段: "纵向功能开发" -更新时间: "2026-08-12" +更新时间: "2026-08-13" 阻塞: null 阶段门: @@ -29,10 +29,10 @@ 状态: "已完成" - 编号: "F-002" 名称: "Agent Core 与 DeepSeek 流式回复" - 状态: "待用户查看" + 状态: "已完成" - 编号: "F-003" 名称: "项目管理与项目聊天" - 状态: "待开始" + 状态: "待用户查看" - 编号: "F-004" 名称: "用户交互卡片" 状态: "待开始" diff --git a/apps/web-server/src/composition/create-app.ts b/apps/web-server/src/composition/create-app.ts index c119b6b..bab9843 100644 --- a/apps/web-server/src/composition/create-app.ts +++ b/apps/web-server/src/composition/create-app.ts @@ -7,15 +7,18 @@ import { createConversationRoutes } from "../routes/conversations"; import type { AgentRunService, ConversationService, + ProjectService, } from "@great-agent/agent-core"; import type { RunRegistry } from "./run-registry"; import { createRunRoutes } from "../routes/runs"; +import { createProjectRoutes } from "../routes/projects"; export function createApp( logger: Logger, conversations?: ConversationService, runs?: AgentRunService, runRegistry?: RunRegistry, + projects?: ProjectService, ): Hono { const app = new Hono(); app.use("*", requestId); @@ -24,5 +27,7 @@ export function createApp( if (conversations) app.route("/api", createConversationRoutes(conversations)); if (runs && runRegistry) app.route("/api", createRunRoutes(runs, runRegistry)); + if (projects && conversations) + app.route("/api", createProjectRoutes(projects, conversations)); return app; } diff --git a/apps/web-server/src/http/error-handler.ts b/apps/web-server/src/http/error-handler.ts index fafc944..06ec226 100644 --- a/apps/web-server/src/http/error-handler.ts +++ b/apps/web-server/src/http/error-handler.ts @@ -19,7 +19,13 @@ export function createErrorHandler(logger: Logger): ErrorHandler { ); } if (error instanceof CoreError) { - const status = error.code === "CONVERSATION_NOT_FOUND" ? 404 : 400; + const status = + error.code === "CONVERSATION_NOT_FOUND" || + error.code === "PROJECT_NOT_FOUND" + ? 404 + : error.code === "PROJECT_RUN_ACTIVE" + ? 409 + : 400; return context.json( { error: { code: error.code, message: error.message, requestId } }, status, diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index 637660c..027c279 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -2,12 +2,14 @@ import { resolve } from "node:path"; import { FileConversationRepository, FileRunRepository, + FileProjectRepository, createDataLayout, ensureDataLayout, } from "@great-agent/local-data"; import { AgentRunService, ConversationService, + ProjectService, CoreError, type ModelPort, } from "@great-agent/agent-core"; @@ -17,19 +19,30 @@ import { createLogger } from "./composition/create-logger"; import { mountStaticWeb } from "./composition/static-web"; import { loadEnvironment } from "./config/environment"; import { RunRegistry } from "./composition/run-registry"; +import { LocalWorkspaceResolver } from "@great-agent/local-files"; const environment = loadEnvironment(); const logger = createLogger(environment.logLevel); const layout = createDataLayout(environment.dataDir); await ensureDataLayout(layout); -const conversations = new ConversationService({ - conversations: new FileConversationRepository(layout), - clock: { now: () => new Date() }, - ids: { create: () => crypto.randomUUID() }, -}); const clock = { now: () => new Date() }; const ids = { create: () => crypto.randomUUID() }; +const conversationRepository = new FileConversationRepository(layout); +const runRepository = new FileRunRepository(layout); +const conversations = new ConversationService({ + conversations: conversationRepository, + clock, + ids, +}); +const projects = new ProjectService({ + projects: new FileProjectRepository(layout), + conversations: conversationRepository, + runs: runRepository, + workspaces: new LocalWorkspaceResolver(), + clock, + ids, +}); const model: ModelPort = environment.deepSeekApiKey ? new DeepSeekModelAdapter({ apiKey: environment.deepSeekApiKey, @@ -48,11 +61,12 @@ const model: ModelPort = environment.deepSeekApiKey const runs = new AgentRunService( conversations, model, - new FileRunRepository(layout), + runRepository, clock, ids, + projects, ); -const app = createApp(logger, conversations, runs, new RunRegistry()); +const app = createApp(logger, conversations, runs, new RunRegistry(), projects); 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 index a3509a1..432bdb4 100644 --- a/apps/web-server/src/routes/conversations.test.ts +++ b/apps/web-server/src/routes/conversations.test.ts @@ -12,6 +12,15 @@ class MemoryRepository implements ConversationRepository { async listRecent() { return [...this.values.values()]; } + async listByProject(projectId: string) { + return [...this.values.values()].filter( + (value) => value.projectId === projectId, + ); + } + async deleteByProject(projectId: string) { + for (const [id, value] of this.values) + if (value.projectId === projectId) this.values.delete(id); + } async getById(id: string) { return this.values.get(id) ?? null; } diff --git a/apps/web-server/src/routes/projects.ts b/apps/web-server/src/routes/projects.ts new file mode 100644 index 0000000..5a3f7d7 --- /dev/null +++ b/apps/web-server/src/routes/projects.ts @@ -0,0 +1,43 @@ +import { Hono } from "hono"; +import type { + ConversationService, + ProjectService, +} from "@great-agent/agent-core"; +import { + createProjectRequestSchema, + renameProjectRequestSchema, +} from "@great-agent/web-contracts"; + +export function createProjectRoutes( + projects: ProjectService, + conversations: ConversationService, +): Hono { + const routes = new Hono(); + routes.get("/projects", async (context) => + context.json(await projects.list()), + ); + routes.post("/projects", async (context) => { + const input = createProjectRequestSchema.parse(await context.req.json()); + return context.json( + await projects.create(input.name, input.workspaceRoot), + 201, + ); + }); + routes.get("/projects/:id/conversations", async (context) => { + await projects.get(context.req.param("id")); + return context.json( + await conversations.listByProject(context.req.param("id")), + ); + }); + routes.patch("/projects/:id", async (context) => { + const input = renameProjectRequestSchema.parse(await context.req.json()); + return context.json( + await projects.rename(context.req.param("id"), input.name), + ); + }); + routes.delete("/projects/:id", async (context) => { + await projects.delete(context.req.param("id")); + return context.body(null, 204); + }); + return routes; +} diff --git a/apps/web-server/src/routes/runs.test.ts b/apps/web-server/src/routes/runs.test.ts index a4927d4..31981e0 100644 --- a/apps/web-server/src/routes/runs.test.ts +++ b/apps/web-server/src/routes/runs.test.ts @@ -8,15 +8,31 @@ import type { RunEvent, RunRepository, } from "@great-agent/agent-core"; -import { AgentRunService, ConversationService } from "@great-agent/agent-core"; +import { + AgentRunService, + ConversationService, + type ProjectService, +} from "@great-agent/agent-core"; import { createApp } from "../composition/create-app"; import { RunRegistry } from "../composition/run-registry"; +const projects = { + requireAvailable: async () => { + throw new Error("unused"); + }, +} as unknown as ProjectService; + class Conversations implements ConversationRepository { value: Conversation | null = null; async listRecent() { return this.value ? [this.value] : []; } + async listByProject(projectId: string) { + return this.value?.projectId === projectId ? [this.value] : []; + } + async deleteByProject(projectId: string) { + if (this.value?.projectId === projectId) this.value = null; + } async getById(id: string) { return this.value?.id === id ? this.value : null; } @@ -48,6 +64,10 @@ class Runs implements RunRepository { async listEvents(runId: string) { return this.events.filter((event) => event.runId === runId); } + async hasActiveForConversations() { + return false; + } + async deleteByConversations() {} } describe("Run HTTP 与 SSE", () => { @@ -71,6 +91,7 @@ describe("Run HTTP 与 SSE", () => { new Runs(), { now: () => new Date("2026-08-12T00:00:00.000Z") }, { create: () => `id_${++id}` }, + projects, ); const app = createApp( pino({ enabled: false }), diff --git a/apps/web/src/api/conversations.ts b/apps/web/src/api/conversations.ts index f8e2595..b6f57ea 100644 --- a/apps/web/src/api/conversations.ts +++ b/apps/web/src/api/conversations.ts @@ -4,6 +4,7 @@ import { type ConversationResponse, type ConversationSummaryResponse, } from "@great-agent/web-contracts"; +import { request } from "./request"; export async function listConversations(): Promise< ConversationSummaryResponse[] @@ -21,26 +22,14 @@ export async function getConversation( ); } -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 "请求失败,请稍后重试"; +export async function listProjectConversations( + projectId: string, +): Promise { + return conversationSummarySchema + .array() + .parse( + await request( + `/api/projects/${encodeURIComponent(projectId)}/conversations`, + ), + ); } diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts new file mode 100644 index 0000000..ac08c20 --- /dev/null +++ b/apps/web/src/api/projects.ts @@ -0,0 +1,39 @@ +import { + projectSchema, + type ProjectResponse, +} from "@great-agent/web-contracts"; +import { request } from "./request"; + +export async function listProjects(): Promise { + return projectSchema.array().parse(await request("/api/projects")); +} + +export async function createProject( + name: string, + workspaceRoot: string, +): Promise { + return projectSchema.parse( + await request("/api/projects", { + method: "POST", + body: JSON.stringify({ name, workspaceRoot }), + }), + ); +} + +export async function renameProject( + id: string, + name: string, +): Promise { + return projectSchema.parse( + await request(`/api/projects/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ name }), + }), + ); +} + +export async function deleteProject(id: string): Promise { + await request(`/api/projects/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} diff --git a/apps/web/src/api/request.ts b/apps/web/src/api/request.ts new file mode 100644 index 0000000..9dff45d --- /dev/null +++ b/apps/web/src/api/request.ts @@ -0,0 +1,27 @@ +export async function request( + path: string, + init?: RequestInit, +): Promise { + const response = await fetch(path, { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + if (response.status === 204) return null; + 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/api/runs.ts b/apps/web/src/api/runs.ts index e8803eb..3e51485 100644 --- a/apps/web/src/api/runs.ts +++ b/apps/web/src/api/runs.ts @@ -7,6 +7,7 @@ import { export type StartRunInput = | { kind: "ordinary"; message: string } + | { kind: "project"; projectId: string; message: string } | { kind: "existing"; conversationId: string; message: string }; export async function startRun( diff --git a/apps/web/src/app/app-controller.ts b/apps/web/src/app/app-controller.ts index b05db1f..c8ab408 100644 --- a/apps/web/src/app/app-controller.ts +++ b/apps/web/src/app/app-controller.ts @@ -2,18 +2,35 @@ import van, { type State } from "vanjs-core"; import type { ConversationResponse, ConversationSummaryResponse, + ProjectResponse, } from "@great-agent/web-contracts"; -import { getConversation, listConversations } from "../api/conversations"; +import { + getConversation, + listConversations, + listProjectConversations, +} from "../api/conversations"; +import { + createProject, + deleteProject, + listProjects, + renameProject, +} from "../api/projects"; import { startRun, streamRun } from "../api/runs"; export type Selection = | { kind: "none" } | { kind: "ordinary-draft" } + | { kind: "project-create" } + | { kind: "project-draft"; projectId: string } + | { kind: "project-rename"; projectId: string } + | { kind: "project-delete"; projectId: string } | { kind: "conversation"; id: string }; export type AppController = Readonly<{ selection: State; recent: State; + projects: State; + projectConversations: State>; active: State; loading: State; sending: State; @@ -21,6 +38,14 @@ export type AppController = Readonly<{ error: State; initialize(): Promise; startNewTask(): void; + startProjectCreation(): void; + createProject(name: string, workspaceRoot: string): Promise; + openProject(id: string): Promise; + startProjectTask(id: string): void; + startProjectRename(id: string): void; + renameProject(id: string, name: string): Promise; + startProjectDelete(id: string): void; + deleteProject(id: string): Promise; openConversation(id: string): Promise; send(content: string): Promise; }>; @@ -28,6 +53,10 @@ export type AppController = Readonly<{ export function createAppController(): AppController { const selection = van.state({ kind: "none" }); const recent = van.state([]); + const projects = van.state([]); + const projectConversations = van.state< + Record + >({}); const active = van.state(null); const loading = van.state(false); const sending = van.state(false); @@ -38,7 +67,10 @@ export function createAppController(): AppController { loading.val = true; error.val = ""; try { - recent.val = await listConversations(); + [recent.val, projects.val] = await Promise.all([ + listConversations(), + listProjects(), + ]); } catch (cause) { error.val = readMessage(cause); } finally { @@ -46,23 +78,77 @@ export function createAppController(): AppController { } } - function startNewTask() { - selection.val = { kind: "ordinary-draft" }; + function select(next: Selection) { + selection.val = next; active.val = null; error.val = ""; } + function startNewTask() { + select({ kind: "ordinary-draft" }); + } + function startProjectCreation() { + select({ kind: "project-create" }); + } + function startProjectTask(projectId: string) { + select({ kind: "project-draft", projectId }); + } + function startProjectRename(projectId: string) { + select({ kind: "project-rename", projectId }); + } + function startProjectDelete(projectId: string) { + select({ kind: "project-delete", projectId }); + } + + async function createNewProject(name: string, workspaceRoot: string) { + await perform(async () => { + const created = await createProject(name, workspaceRoot); + projects.val = [created, ...projects.val]; + projectConversations.val = { + ...projectConversations.val, + [created.id]: [], + }; + select({ kind: "project-draft", projectId: created.id }); + }); + } + + async function openProject(id: string) { + await perform(async () => { + const conversations = await listProjectConversations(id); + projectConversations.val = { + ...projectConversations.val, + [id]: conversations, + }; + if (conversations[0]) await openConversation(conversations[0].id); + else select({ kind: "project-draft", projectId: id }); + }); + } + + async function renameExistingProject(id: string, name: string) { + await perform(async () => { + const updated = await renameProject(id, name); + projects.val = projects.val.map((project) => + project.id === id ? updated : project, + ); + select({ kind: "project-draft", projectId: id }); + }); + } + + async function deleteExistingProject(id: string) { + await perform(async () => { + await deleteProject(id); + projects.val = projects.val.filter((project) => project.id !== id); + const next = { ...projectConversations.val }; + delete next[id]; + projectConversations.val = next; + select({ kind: "none" }); + }); + } async function openConversation(id: string) { - loading.val = true; - error.val = ""; - try { + await perform(async () => { 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) { @@ -72,21 +158,30 @@ export function createAppController(): AppController { error.val = ""; try { const current = selection.val; - const started = await startRun( + const input = current.kind === "conversation" - ? { kind: "existing", conversationId: current.id, message: content } - : { kind: "ordinary", message: content }, - ); + ? { + kind: "existing" as const, + conversationId: current.id, + message: content, + } + : current.kind === "project-draft" + ? { + kind: "project" as const, + projectId: current.projectId, + message: content, + } + : { kind: "ordinary" as const, message: content }; + const started = await startRun(input); active.val = await getConversation(started.conversationId); selection.val = { kind: "conversation", id: started.conversationId }; - recent.val = await listConversations(); + await refreshLists(active.val.projectId); await streamRun(started.runId, (event) => { if ( event.type === "message.delta" && typeof event.payload.delta === "string" - ) { + ) assistantDraft.val += event.payload.delta; - } }); active.val = await getConversation(started.conversationId); assistantDraft.val = ""; @@ -97,9 +192,35 @@ export function createAppController(): AppController { } } + async function refreshLists(projectId: string | null) { + if (!projectId) recent.val = await listConversations(); + else { + const conversations = await listProjectConversations(projectId); + projectConversations.val = { + ...projectConversations.val, + [projectId]: conversations, + }; + projects.val = await listProjects(); + } + } + + async function perform(action: () => Promise) { + loading.val = true; + error.val = ""; + try { + await action(); + } catch (cause) { + error.val = readMessage(cause); + } finally { + loading.val = false; + } + } + return { selection, recent, + projects, + projectConversations, active, loading, sending, @@ -107,6 +228,14 @@ export function createAppController(): AppController { error, initialize, startNewTask, + startProjectCreation, + createProject: createNewProject, + openProject, + startProjectTask, + startProjectRename, + renameProject: renameExistingProject, + startProjectDelete, + deleteProject: deleteExistingProject, openConversation, send, }; diff --git a/apps/web/src/features/conversations/conversation-pane.css b/apps/web/src/features/conversations/conversation-pane.css index 8d47dee..9ef9920 100644 --- a/apps/web/src/features/conversations/conversation-pane.css +++ b/apps/web/src/features/conversations/conversation-pane.css @@ -102,6 +102,9 @@ width: min(var(--composer-width), calc(100% - 40px)); margin: 0 auto 18px; } +.composer-slot { + flex: 0 0 auto; +} .composer { min-height: 84px; padding: 12px 13px 8px; diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts index 6eda657..3d5c92f 100644 --- a/apps/web/src/features/conversations/conversation-pane.ts +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -1,6 +1,7 @@ import van, { type State } from "vanjs-core"; import type { AppController } from "../../app/app-controller"; import "./conversation-pane.css"; +import { ProjectPanel } from "../projects/project-panel"; const { article, button, div, h1, header, main, p, span, textarea } = van.tags; @@ -18,39 +19,45 @@ export function ConversationPane(controller: AppController): HTMLElement { { class: "main-pane" }, header({ class: "mobile-header" }, "Great Agent 2"), div({ class: "pane-content" }, () => - 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 ${message.role}-role` }, - message.role === "user" ? "你" : "G", + controller.selection.val.kind.startsWith("project-") + ? ProjectPanel(controller) + : 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 ${message.role}-role` }, + message.role === "user" ? "你" : "G", + ), + p(message.content), ), - p(message.content), ), + () => + controller.assistantDraft.val + ? article( + { class: "message assistant streaming" }, + span({ class: "message-role assistant-role" }, "G"), + p(controller.assistantDraft), + ) + : null, ), - () => - controller.assistantDraft.val - ? article( - { class: "message assistant streaming" }, - span({ class: "message-role assistant-role" }, "G"), - p(controller.assistantDraft), - ) - : null, + ) + : div( + { class: "onboarding" }, + h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"), + p("从一个问题、想法或具体任务开始。"), ), - ) - : div( - { class: "onboarding" }, - h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"), - p("从一个问题、想法或具体任务开始。"), - ), ), - Composer(controller, draft, submit), + div( + { class: "composer-slot", hidden: () => !canCompose(controller) }, + Composer(controller, draft, submit), + ), ); } @@ -105,8 +112,34 @@ function Composer( ), div( { class: "composer-meta" }, - button({ disabled: true }, "普通对话"), + button({ disabled: true }, () => + isProjectConversation(controller) ? "项目对话" : "普通对话", + ), + span("DeepSeek"), ), ); } + +function isProjectConversation(controller: AppController): boolean { + const selection = controller.selection.val; + return ( + selection.kind === "project-draft" || + (selection.kind === "conversation" && + controller.active.val?.projectId !== null) + ); +} + +function canCompose(controller: AppController): boolean { + const selection = controller.selection.val; + if (selection.kind === "conversation") return true; + if (selection.kind === "ordinary-draft") return true; + if (selection.kind === "project-draft") { + return ( + controller.projects.val.find( + (project) => project.id === selection.projectId, + )?.workspaceAvailable ?? false + ); + } + return selection.kind === "none"; +} diff --git a/apps/web/src/features/conversations/sidebar.css b/apps/web/src/features/conversations/sidebar.css index 0fb4d0e..3b31676 100644 --- a/apps/web/src/features/conversations/sidebar.css +++ b/apps/web/src/features/conversations/sidebar.css @@ -64,9 +64,70 @@ background: #1c1c1b; } .new-task:hover, -.recent-list button:hover { +.recent-list button:hover, +.project-open:hover { background: #323230; } +.project-list, +.project-conversations { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + list-style: none; +} +.project-item { + position: relative; + border-radius: 6px; +} +.project-item.selected { + background: #2d2d2b; +} +.project-open { + overflow: hidden; + width: 100%; + padding: 7px 8px; + color: #c3c0ba; + background: transparent; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} +.project-open span { + margin-right: 7px; + color: #77736e; +} +.project-actions { + position: absolute; + top: 3px; + right: 4px; + display: flex; + background: #2d2d2b; +} +.project-actions button { + width: 24px; + height: 24px; + color: #96928c; + background: transparent; +} +.project-conversations button { + overflow: hidden; + width: 100%; + padding: 5px 8px 5px 27px; + color: #98958f; + background: transparent; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; +} +.workspace-warning { + display: block; + padding: 0 8px 6px 25px; + color: #c17769; + font-size: 9px; +} .plus { display: inline-block; width: 19px; diff --git a/apps/web/src/features/conversations/sidebar.ts b/apps/web/src/features/conversations/sidebar.ts index fb74d59..203a4ec 100644 --- a/apps/web/src/features/conversations/sidebar.ts +++ b/apps/web/src/features/conversations/sidebar.ts @@ -18,23 +18,40 @@ export function Sidebar(controller: AppController): HTMLElement { span({ class: "plus" }, "+"), "新任务", ), - button({ class: "nav-item", disabled: true }, "▣", " 项目"), - div({ class: "sidebar-scroll" }, h2("最近"), () => - controller.loading.val && controller.recent.val.length === 0 - ? div({ class: "sidebar-empty" }, "正在加载…") - : controller.recent.val.length === 0 - ? div({ class: "sidebar-empty" }, "暂无对话") + button( + { class: "nav-item", onclick: controller.startProjectCreation }, + "▣", + " 新建项目", + ), + div( + { class: "sidebar-scroll" }, + h2("项目"), + () => + controller.projects.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, + { class: "project-list" }, + controller.projects.val.map((project) => + ProjectItem(controller, project.id), + ), + ), + 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" }, @@ -43,3 +60,82 @@ export function Sidebar(controller: AppController): HTMLElement { ), ); } + +function ProjectItem( + controller: AppController, + projectId: string, +): HTMLElement { + return li(() => { + const project = controller.projects.val.find( + (item) => item.id === projectId, + ); + if (!project) return null; + const selected = selectedProjectId(controller) === project.id; + return div( + { class: `project-item${selected ? " selected" : ""}` }, + button( + { + class: "project-open", + onclick: () => controller.openProject(project.id), + }, + span("▣"), + project.name, + ), + selected + ? div( + { class: "project-actions" }, + button( + { + title: "新建项目对话", + onclick: () => controller.startProjectTask(project.id), + }, + "+", + ), + button( + { + title: "重命名项目", + onclick: () => controller.startProjectRename(project.id), + }, + "✎", + ), + button( + { + title: "删除项目", + onclick: () => controller.startProjectDelete(project.id), + }, + "×", + ), + ) + : null, + !project.workspaceAvailable + ? span({ class: "workspace-warning" }, "目录不可用") + : null, + selected && + (controller.projectConversations.val[project.id]?.length ?? 0) > 0 + ? ul( + { class: "project-conversations" }, + (controller.projectConversations.val[project.id] ?? []).map( + (conversation) => + li( + button( + { + onclick: () => + controller.openConversation(conversation.id), + }, + conversation.title, + ), + ), + ), + ) + : null, + ); + }); +} + +function selectedProjectId(controller: AppController): string | null { + const selection = controller.selection.val; + if ("projectId" in selection) return selection.projectId; + return selection.kind === "conversation" + ? (controller.active.val?.projectId ?? null) + : null; +} diff --git a/apps/web/src/features/projects/project-panel.css b/apps/web/src/features/projects/project-panel.css new file mode 100644 index 0000000..1763a3b --- /dev/null +++ b/apps/web/src/features/projects/project-panel.css @@ -0,0 +1,94 @@ +.project-panel, +.project-welcome { + width: min(520px, calc(100% - 40px)); + margin: 70px auto; + padding: 30px; + border: 1px solid var(--color-border); + border-radius: 15px; + background: #242422; + box-shadow: 0 20px 50px #0004; +} +.project-panel h1, +.project-welcome h1 { + margin: 12px 0 6px; + font-family: var(--font-serif); + font-size: 24px; +} +.project-panel > p, +.project-welcome > p { + margin: 0 0 22px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.5; +} +.project-icon { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 10px; + color: #e3a18b; + background: #553329; +} +.project-icon.danger { + color: #efaaa0; + background: #582d29; +} +.project-panel label { + display: grid; + gap: 7px; + margin-top: 17px; + color: #aaa69f; + font-size: 11px; +} +.project-panel input { + height: 38px; + padding: 0 11px; + border: 1px solid #484641; + border-radius: 8px; + outline: 0; + color: #eeeae3; + background: #1b1b1a; +} +.project-panel input:focus { + border-color: #9d6252; +} +.form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +} +.form-actions button { + padding: 8px 13px; + border-radius: 7px; +} +.primary { + color: white; + background: #a95e4d; +} +.secondary { + background: #393836; +} +.danger-button { + color: white; + background: #a4433b; +} +.delete-summary { + padding: 10px 13px; + border-radius: 9px; + background: #1b1b1a; +} +.delete-summary p { + display: flex; + justify-content: space-between; + margin: 6px 0; + color: #89857f; + font-size: 11px; +} +.delete-summary span { + color: #d6d2cb; +} +.project-welcome > .project-unavailable { + color: #dc8f82; +} diff --git a/apps/web/src/features/projects/project-panel.ts b/apps/web/src/features/projects/project-panel.ts new file mode 100644 index 0000000..1affc13 --- /dev/null +++ b/apps/web/src/features/projects/project-panel.ts @@ -0,0 +1,129 @@ +import van from "vanjs-core"; +import type { AppController } from "../../app/app-controller"; +import "./project-panel.css"; + +const { button, div, form, h1, input, label, p, span } = van.tags; + +export function ProjectPanel(controller: AppController): HTMLElement { + const selection = controller.selection.val; + if (selection.kind === "project-create") + return ProjectForm("新建项目", "创建项目", "", "", (name, root) => + controller.createProject(name, root), + ); + if (!("projectId" in selection)) return div(); + const project = controller.projects.val.find( + (item) => item.id === selection.projectId, + ); + if (!project) return div({ class: "project-panel" }, p("项目不存在")); + if (selection.kind === "project-rename") + return ProjectForm( + "重命名项目", + "保存", + project.name, + project.workspaceRoot, + (name) => controller.renameProject(project.id, name), + true, + ); + if (selection.kind === "project-delete") + return div( + { class: "project-panel" }, + span({ class: "project-icon danger" }, "×"), + h1("删除项目?"), + p("将删除应用内的项目记录,以及该项目下的对话、消息和运行记录。"), + div( + { class: "delete-summary" }, + p("项目", span(project.name)), + p("项目对话", span(`${project.conversationCount} 条`)), + p("本地工作目录", span("不会删除、移动或写入")), + ), + div( + { class: "form-actions" }, + button( + { + class: "secondary", + onclick: () => controller.startProjectTask(project.id), + }, + "取消", + ), + button( + { + class: "danger-button", + onclick: () => void controller.deleteProject(project.id), + }, + "确认删除", + ), + ), + ); + return div( + { class: "project-welcome" }, + span({ class: "project-icon" }, "▣"), + h1(project.name), + p(project.workspaceRoot), + !project.workspaceAvailable + ? p( + { class: "project-unavailable" }, + "项目目录当前不可用。历史对话仍可查看,但无法开始新任务。", + ) + : p("开始一段新的项目对话,第一条消息发送后才会创建会话。"), + ); +} + +function ProjectForm( + title: string, + submitLabel: string, + initialName: string, + initialRoot: string, + submit: (name: string, root: string) => Promise, + rename = false, +): HTMLElement { + const name = van.state(initialName); + const root = van.state(initialRoot); + return form( + { + class: "project-panel", + onsubmit: (event: Event) => { + event.preventDefault(); + void submit(name.val, root.val); + }, + }, + span({ class: "project-icon" }, "▣"), + h1(title), + p( + rename + ? "修改侧栏中显示的项目名称。" + : "选择一个已存在、可读写的本地文件夹作为项目目录。", + ), + label( + "项目名称", + input({ + value: name, + maxlength: 80, + autofocus: true, + oninput: (event: Event) => + (name.val = (event.target as HTMLInputElement).value), + }), + ), + rename + ? null + : label( + "本地目录(绝对路径)", + input({ + value: root, + placeholder: "/Users/你的名字/Projects/项目名", + oninput: (event: Event) => + (root.val = (event.target as HTMLInputElement).value), + }), + ), + div( + { class: "form-actions" }, + button( + { + class: "primary", + type: "submit", + disabled: () => !name.val.trim() || (!rename && !root.val.trim()), + }, + submitLabel, + ), + ), + ); +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 68b4bc3..2c1d38f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -28,7 +28,7 @@ ### F-002——Agent Core 与 DeepSeek 流式回复 -- 状态:待用户查看 +- 状态:已完成(用户于 2026-08-13 审核通过) - 用户可见结果:普通会话可启动 Agent Run,DeepSeek 回复增量显示,完成后的助手消息写入本地会话并可继续聊天;未配置模型密钥时保留用户消息并显示明确错误。 - Agent Core:新增独立于 HTTP 的 `AgentRunService`、Run/RunEvent 领域对象、`ModelPort` 与 `RunRepository`;模型增量、完成和失败均转为稳定的领域事件。 - DeepSeek 接入:`model-deepseek` 在适配器内部映射 OpenAI 兼容协议,Core 只依赖自身 Message/ModelEvent 类型;首个模型固定为 DeepSeek。 @@ -49,9 +49,11 @@ ### F-003——项目管理与项目聊天 -- 状态:待开始 +- 状态:待用户查看 - 用户可见结果:创建、选择、重命名和删除项目,在项目中维护多个独立会话。 - 主要验收:AC-026 至 AC-033。 +- 自动化验证结果:2026-08-13 通过全仓类型检查、16 项测试、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查。 +- 人工操作结果:2026-08-13 使用本机应用内浏览器完成;验证了真实目录创建项目、首条消息才创建项目会话、项目历史恢复并继续聊天、重命名、删除确认信息,以及删除后本地工作目录仍然存在。 ### F-004——用户交互卡片 diff --git a/packages/agent-core/src/domain/conversation.ts b/packages/agent-core/src/domain/conversation.ts index 4075e17..1b67239 100644 --- a/packages/agent-core/src/domain/conversation.ts +++ b/packages/agent-core/src/domain/conversation.ts @@ -10,7 +10,7 @@ export type Message = Readonly<{ export type Conversation = Readonly<{ id: string; - projectId: null; + projectId: string | null; title: string; messages: readonly Message[]; createdAt: string; diff --git a/packages/agent-core/src/domain/project.ts b/packages/agent-core/src/domain/project.ts new file mode 100644 index 0000000..910b54b --- /dev/null +++ b/packages/agent-core/src/domain/project.ts @@ -0,0 +1,13 @@ +export type Project = Readonly<{ + id: string; + name: string; + workspaceRoot: string; + createdAt: string; + updatedAt: string; +}>; + +export type ProjectView = Project & + Readonly<{ + workspaceAvailable: boolean; + conversationCount: number; + }>; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 82e9b61..27ddbd9 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -6,9 +6,11 @@ export type { MessageRole, } from "./domain/conversation"; export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run"; +export type { Project, ProjectView } from "./domain/project"; export type { ConversationRepository } from "./ports/conversation-repository"; export type { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port"; export type { RunRepository } from "./ports/run-repository"; +export type { ProjectRepository } from "./ports/project-repository"; export type { ClockPort, IdPort, @@ -23,3 +25,7 @@ export { type StartedRun, type StartRunInput, } from "./use-cases/agent-run-service"; +export { + ProjectService, + type ProjectServiceDependencies, +} from "./use-cases/project-service"; diff --git a/packages/agent-core/src/ports/conversation-repository.ts b/packages/agent-core/src/ports/conversation-repository.ts index 5b1fd83..f497e43 100644 --- a/packages/agent-core/src/ports/conversation-repository.ts +++ b/packages/agent-core/src/ports/conversation-repository.ts @@ -6,10 +6,12 @@ import type { export interface ConversationRepository { listRecent(): Promise; + listByProject(projectId: string): Promise; getById(id: string): Promise; create(conversation: Conversation): Promise; appendMessage( conversationId: string, message: Message, ): Promise; + deleteByProject(projectId: string): Promise; } diff --git a/packages/agent-core/src/ports/project-repository.ts b/packages/agent-core/src/ports/project-repository.ts new file mode 100644 index 0000000..a9f4be1 --- /dev/null +++ b/packages/agent-core/src/ports/project-repository.ts @@ -0,0 +1,9 @@ +import type { Project } from "../domain/project"; + +export interface ProjectRepository { + list(): Promise; + getById(id: string): Promise; + create(project: Project): Promise; + update(project: Project): Promise; + delete(id: string): Promise; +} diff --git a/packages/agent-core/src/ports/run-repository.ts b/packages/agent-core/src/ports/run-repository.ts index f1144d1..8bf77e3 100644 --- a/packages/agent-core/src/ports/run-repository.ts +++ b/packages/agent-core/src/ports/run-repository.ts @@ -6,4 +6,8 @@ export interface RunRepository { getById(id: string): Promise; appendEvent(event: RunEvent): Promise; listEvents(runId: string): Promise; + hasActiveForConversations( + conversationIds: readonly string[], + ): Promise; + deleteByConversations(conversationIds: readonly string[]): Promise; } diff --git a/packages/agent-core/src/ports/system-ports.ts b/packages/agent-core/src/ports/system-ports.ts index b7a2162..bdf5900 100644 --- a/packages/agent-core/src/ports/system-ports.ts +++ b/packages/agent-core/src/ports/system-ports.ts @@ -7,5 +7,6 @@ export interface IdPort { } export interface WorkspaceResolverPort { - resolve(conversationId: string): Promise; + resolveForCreation(path: string): Promise; + isAvailable(path: string): Promise; } diff --git a/packages/agent-core/src/use-cases/agent-run-service.test.ts b/packages/agent-core/src/use-cases/agent-run-service.test.ts index 64c72bd..99c23a1 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.test.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.test.ts @@ -7,13 +7,28 @@ import type { RunEvent, RunRepository, } from ".."; -import { AgentRunService, ConversationService } from ".."; +import { AgentRunService, ConversationService, type ProjectService } from ".."; + +const projects = { + requireAvailable: async () => { + throw new Error("unused"); + }, +} as unknown as ProjectService; class MemoryConversationRepository implements ConversationRepository { readonly values = new Map(); async listRecent() { return [...this.values.values()]; } + async listByProject(projectId: string) { + return [...this.values.values()].filter( + (value) => value.projectId === projectId, + ); + } + async deleteByProject(projectId: string) { + for (const [id, value] of this.values) + if (value.projectId === projectId) this.values.delete(id); + } async getById(id: string) { return this.values.get(id) ?? null; } @@ -51,6 +66,10 @@ class MemoryRunRepository implements RunRepository { async listEvents(runId: string) { return this.events.filter((event) => event.runId === runId); } + async hasActiveForConversations() { + return false; + } + async deleteByConversations() {} } describe("AgentRunService", () => { @@ -76,6 +95,7 @@ describe("AgentRunService", () => { runs, { now: () => new Date("2026-08-12T00:00:00.000Z") }, { create: () => `id_${++nextId}` }, + projects, ); const started = await service.start( { kind: "ordinary", message: "开始" }, @@ -127,6 +147,7 @@ describe("AgentRunService", () => { runs, { now: () => new Date() }, { create: () => `id_${++id}` }, + projects, ); const started = await service.start( { kind: "ordinary", message: "开始" }, diff --git a/packages/agent-core/src/use-cases/agent-run-service.ts b/packages/agent-core/src/use-cases/agent-run-service.ts index c511250..74929d4 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.ts @@ -1,12 +1,15 @@ import type { AgentRun, RunEvent } from "../domain/agent-run"; +import type { Conversation } from "../domain/conversation"; import { CoreError } from "../errors/core-error"; import type { ModelPort } from "../ports/model-port"; import type { RunRepository } from "../ports/run-repository"; import type { ClockPort, IdPort } from "../ports/system-ports"; import type { ConversationService } from "./conversation-service"; +import type { ProjectService } from "./project-service"; export type StartRunInput = | Readonly<{ kind: "ordinary"; message: string }> + | Readonly<{ kind: "project"; projectId: string; message: string }> | Readonly<{ kind: "existing"; conversationId: string; message: string }>; export type StartedRun = Readonly<{ @@ -21,18 +24,36 @@ export class AgentRunService { private readonly runs: RunRepository, private readonly clock: ClockPort, private readonly ids: IdPort, + private readonly projects: ProjectService, ) {} async start(input: StartRunInput, signal: AbortSignal): Promise { const runId = this.ids.create(); - const conversation = - input.kind === "ordinary" - ? await this.conversations.createWithFirstMessage(input.message, runId) - : await this.conversations.appendUserMessage( - input.conversationId, - input.message, - runId, - ); + let conversation: Conversation; + if (input.kind === "ordinary") { + conversation = await this.conversations.createWithFirstMessage( + input.message, + runId, + ); + } else if (input.kind === "project") { + await this.projects.requireAvailable(input.projectId); + conversation = await this.conversations.createProjectWithFirstMessage( + input.projectId, + input.message, + runId, + ); + } else { + const existing = await this.conversations.getConversation( + input.conversationId, + ); + if (existing.projectId) + await this.projects.requireAvailable(existing.projectId); + conversation = await this.conversations.appendUserMessage( + input.conversationId, + input.message, + runId, + ); + } const timestamp = this.clock.now().toISOString(); const triggerMessage = conversation.messages.at(-1); if (triggerMessage?.role !== "user") diff --git a/packages/agent-core/src/use-cases/conversation-service.test.ts b/packages/agent-core/src/use-cases/conversation-service.test.ts index d111ad2..4cb5f91 100644 --- a/packages/agent-core/src/use-cases/conversation-service.test.ts +++ b/packages/agent-core/src/use-cases/conversation-service.test.ts @@ -8,6 +8,15 @@ class MemoryConversationRepository implements ConversationRepository { async listRecent(): Promise { return [...this.values.values()]; } + async listByProject(projectId: string) { + return [...this.values.values()].filter( + (value) => value.projectId === projectId, + ); + } + async deleteByProject(projectId: string) { + for (const [id, value] of this.values) + if (value.projectId === projectId) this.values.delete(id); + } async getById(id: string): Promise { return this.values.get(id) ?? null; } diff --git a/packages/agent-core/src/use-cases/conversation-service.ts b/packages/agent-core/src/use-cases/conversation-service.ts index 58111ec..0a0c361 100644 --- a/packages/agent-core/src/use-cases/conversation-service.ts +++ b/packages/agent-core/src/use-cases/conversation-service.ts @@ -20,6 +20,10 @@ export class ConversationService { return this.dependencies.conversations.listRecent(); } + listByProject(projectId: string): Promise { + return this.dependencies.conversations.listByProject(projectId); + } + async getConversation(id: string): Promise { const conversation = await this.dependencies.conversations.getById(id); if (!conversation) @@ -45,6 +49,25 @@ export class ConversationService { return conversation; } + async createProjectWithFirstMessage( + projectId: string, + content: string, + runId: string, + ): Promise { + const normalized = normalizeContent(content); + const timestamp = this.dependencies.clock.now().toISOString(); + const conversation: Conversation = { + id: this.dependencies.ids.create(), + projectId, + title: createTitle(normalized), + messages: [this.createUserMessage(normalized, timestamp, runId)], + createdAt: timestamp, + updatedAt: timestamp, + }; + await this.dependencies.conversations.create(conversation); + return conversation; + } + async appendUserMessage( id: string, content: string, diff --git a/packages/agent-core/src/use-cases/project-service.test.ts b/packages/agent-core/src/use-cases/project-service.test.ts new file mode 100644 index 0000000..b2ba352 --- /dev/null +++ b/packages/agent-core/src/use-cases/project-service.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import type { + AgentRun, + Conversation, + ConversationRepository, + Project, + ProjectRepository, + RunEvent, + RunRepository, +} from ".."; +import { ProjectService } from ".."; + +class Projects implements ProjectRepository { + values = new Map(); + async list() { + return [...this.values.values()]; + } + async getById(id: string) { + return this.values.get(id) ?? null; + } + async create(project: Project) { + this.values.set(project.id, project); + } + async update(project: Project) { + this.values.set(project.id, project); + } + async delete(id: string) { + this.values.delete(id); + } +} +class Conversations implements ConversationRepository { + values = new Map(); + async listRecent() { + return [...this.values.values()].filter((value) => !value.projectId); + } + async listByProject(projectId: string) { + return [...this.values.values()].filter( + (value) => value.projectId === projectId, + ); + } + 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], + ): Promise { + throw new Error("unused"); + } + async deleteByProject(projectId: string) { + for (const [id, value] of this.values) + if (value.projectId === projectId) this.values.delete(id); + } +} +class Runs implements RunRepository { + active = false; + async create(_run: AgentRun) {} + async update(_run: AgentRun) {} + async getById(_id: string) { + return null; + } + async appendEvent(_event: RunEvent) {} + async listEvents(_id: string) { + return []; + } + async hasActiveForConversations() { + return this.active; + } + async deleteByConversations() {} +} + +describe("ProjectService", () => { + test("创建项目时固化真实目录,重命名后仍保留目录", async () => { + const projects = new Projects(); + const service = createService(projects, new Conversations(), new Runs()); + const created = await service.create(" 我的项目 ", "/tmp/link"); + expect(created).toMatchObject({ + name: "我的项目", + workspaceRoot: "/tmp/real", + conversationCount: 0, + workspaceAvailable: true, + }); + const renamed = await service.rename(created.id, "新名字"); + expect(renamed.name).toBe("新名字"); + expect(renamed.workspaceRoot).toBe("/tmp/real"); + }); + + test("有运行中的项目任务时拒绝删除", async () => { + const projects = new Projects(); + const conversations = new Conversations(); + const runs = new Runs(); + const service = createService(projects, conversations, runs); + const created = await service.create("项目", "/tmp/real"); + conversations.values.set("conversation", { + id: "conversation", + projectId: created.id, + title: "对话", + messages: [], + createdAt: "now", + updatedAt: "now", + }); + runs.active = true; + expect(service.delete(created.id)).rejects.toMatchObject({ + code: "PROJECT_RUN_ACTIVE", + }); + expect(await projects.getById(created.id)).not.toBeNull(); + }); +}); + +function createService( + projects: Projects, + conversations: Conversations, + runs: Runs, +) { + return new ProjectService({ + projects, + conversations, + runs, + workspaces: { + async resolveForCreation() { + return "/tmp/real"; + }, + async isAvailable() { + return true; + }, + }, + clock: { now: () => new Date("2026-08-13T00:00:00Z") }, + ids: { create: () => "project_1" }, + }); +} diff --git a/packages/agent-core/src/use-cases/project-service.ts b/packages/agent-core/src/use-cases/project-service.ts new file mode 100644 index 0000000..4a1407c --- /dev/null +++ b/packages/agent-core/src/use-cases/project-service.ts @@ -0,0 +1,116 @@ +import type { Project, ProjectView } from "../domain/project"; +import { CoreError } from "../errors/core-error"; +import type { ConversationRepository } from "../ports/conversation-repository"; +import type { ProjectRepository } from "../ports/project-repository"; +import type { RunRepository } from "../ports/run-repository"; +import type { + ClockPort, + IdPort, + WorkspaceResolverPort, +} from "../ports/system-ports"; + +export type ProjectServiceDependencies = Readonly<{ + projects: ProjectRepository; + conversations: ConversationRepository; + runs: RunRepository; + workspaces: WorkspaceResolverPort; + clock: ClockPort; + ids: IdPort; +}>; + +export class ProjectService { + constructor(private readonly dependencies: ProjectServiceDependencies) {} + + async list(): Promise { + return Promise.all( + (await this.dependencies.projects.list()).map((project) => + this.view(project), + ), + ); + } + + async get(id: string): Promise { + return this.view(await this.require(id)); + } + + async create(name: string, workspaceRoot: string): Promise { + const normalizedName = normalizeName(name); + const resolvedRoot = + await this.dependencies.workspaces.resolveForCreation(workspaceRoot); + const timestamp = this.dependencies.clock.now().toISOString(); + const project: Project = { + id: this.dependencies.ids.create(), + name: normalizedName, + workspaceRoot: resolvedRoot, + createdAt: timestamp, + updatedAt: timestamp, + }; + await this.dependencies.projects.create(project); + return this.view(project); + } + + async rename(id: string, name: string): Promise { + const project = await this.require(id); + const updated = { + ...project, + name: normalizeName(name), + updatedAt: this.dependencies.clock.now().toISOString(), + }; + await this.dependencies.projects.update(updated); + return this.view(updated); + } + + async delete(id: string): Promise { + await this.require(id); + const conversations = + await this.dependencies.conversations.listByProject(id); + const ids = conversations.map((conversation) => conversation.id); + if (await this.dependencies.runs.hasActiveForConversations(ids)) + throw new CoreError( + "PROJECT_RUN_ACTIVE", + "项目中仍有任务正在运行,暂时无法删除", + ); + await this.dependencies.runs.deleteByConversations(ids); + await this.dependencies.conversations.deleteByProject(id); + await this.dependencies.projects.delete(id); + } + + async requireAvailable(id: string): Promise { + const project = await this.require(id); + if ( + !(await this.dependencies.workspaces.isAvailable(project.workspaceRoot)) + ) + throw new CoreError( + "PROJECT_WORKSPACE_UNAVAILABLE", + "项目目录当前不可用,无法开始新任务", + ); + return project; + } + + private async require(id: string): Promise { + const project = await this.dependencies.projects.getById(id); + if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在"); + return project; + } + + private async view(project: Project): Promise { + const conversations = await this.dependencies.conversations.listByProject( + project.id, + ); + return { + ...project, + workspaceAvailable: await this.dependencies.workspaces.isAvailable( + project.workspaceRoot, + ), + conversationCount: conversations.length, + }; + } +} + +function normalizeName(name: string): string { + const normalized = name.trim(); + if (!normalized) throw new CoreError("PROJECT_NAME_EMPTY", "请输入项目名称"); + if (normalized.length > 80) + throw new CoreError("PROJECT_NAME_TOO_LONG", "项目名称过长"); + return normalized; +} diff --git a/packages/local-data/src/index.ts b/packages/local-data/src/index.ts index 2265e0d..b9d14d7 100644 --- a/packages/local-data/src/index.ts +++ b/packages/local-data/src/index.ts @@ -8,3 +8,4 @@ export { } from "./layout/data-layout"; export { FileConversationRepository } from "./repositories/file-conversation-repository"; export { FileRunRepository } from "./repositories/file-run-repository"; +export { FileProjectRepository } from "./repositories/file-project-repository"; diff --git a/packages/local-data/src/repositories/file-conversation-repository.ts b/packages/local-data/src/repositories/file-conversation-repository.ts index 1e3897d..846c0e4 100644 --- a/packages/local-data/src/repositories/file-conversation-repository.ts +++ b/packages/local-data/src/repositories/file-conversation-repository.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir } from "node:fs/promises"; +import { mkdir, readFile, readdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import type { Conversation, @@ -20,6 +20,15 @@ export class FileConversationRepository implements ConversationRepository { .map(({ messages: _messages, ...summary }) => summary); } + async listByProject( + projectId: string, + ): Promise { + return (await this.readAll()) + .filter((conversation) => conversation.projectId === projectId) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map(({ messages: _messages, ...summary }) => summary); + } + async getById(id: string): Promise { try { return JSON.parse( @@ -53,6 +62,15 @@ export class FileConversationRepository implements ConversationRepository { return next; } + async deleteByProject(projectId: string): Promise { + const conversations = await this.readAll(); + await Promise.all( + conversations + .filter((conversation) => conversation.projectId === projectId) + .map((conversation) => unlink(this.pathFor(conversation.id))), + ); + } + private pathFor(id: string): string { return join(this.layout.conversations, `${id}.json`); } diff --git a/packages/local-data/src/repositories/file-project-repository.ts b/packages/local-data/src/repositories/file-project-repository.ts new file mode 100644 index 0000000..dba4c1d --- /dev/null +++ b/packages/local-data/src/repositories/file-project-repository.ts @@ -0,0 +1,62 @@ +import { mkdir, readFile, readdir, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import type { Project, ProjectRepository } from "@great-agent/agent-core"; +import { writeJsonAtomically } from "../atomic-writes/write-json-atomically"; +import type { DataLayout } from "../layout/data-layout"; + +export class FileProjectRepository implements ProjectRepository { + constructor(private readonly layout: DataLayout) {} + + async list(): Promise { + await mkdir(this.layout.projects, { recursive: true }); + const names = await readdir(this.layout.projects); + const projects = await Promise.all( + names + .filter((name) => name.endsWith(".json")) + .map( + async (name) => + JSON.parse( + await readFile(join(this.layout.projects, name), "utf8"), + ) as Project, + ), + ); + return projects.sort((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + ); + } + + async getById(id: string): Promise { + try { + return JSON.parse(await readFile(this.pathFor(id), "utf8")) as Project; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return null; + throw error; + } + } + + async create(project: Project): Promise { + await mkdir(this.layout.projects, { recursive: true }); + if (await this.getById(project.id)) throw new Error("项目标识已经存在"); + await writeJsonAtomically(this.pathFor(project.id), project); + } + + update(project: Project): Promise { + return writeJsonAtomically(this.pathFor(project.id), project); + } + + async delete(id: string): Promise { + try { + await unlink(this.pathFor(id)); + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; + } + } + + private pathFor(id: string): string { + return join(this.layout.projects, `${id}.json`); + } +} diff --git a/packages/local-data/src/repositories/file-run-repository.ts b/packages/local-data/src/repositories/file-run-repository.ts index f5f5bc4..c378fd1 100644 --- a/packages/local-data/src/repositories/file-run-repository.ts +++ b/packages/local-data/src/repositories/file-run-repository.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import type { AgentRun, @@ -52,6 +52,42 @@ export class FileRunRepository implements RunRepository { } } + async hasActiveForConversations( + conversationIds: readonly string[], + ): Promise { + if (conversationIds.length === 0) return false; + const targets = new Set(conversationIds); + return (await this.readAll()).some( + (run) => targets.has(run.conversationId) && run.status === "running", + ); + } + + async deleteByConversations( + conversationIds: readonly string[], + ): Promise { + if (conversationIds.length === 0) return; + const targets = new Set(conversationIds); + const runs = await this.readAll(); + await Promise.all( + runs + .filter((run) => targets.has(run.conversationId)) + .map((run) => + rm(this.runDirectory(run.id), { recursive: true, force: true }), + ), + ); + } + + private async readAll(): Promise { + await mkdir(this.layout.runs, { recursive: true }); + const names = await readdir(this.layout.runs, { withFileTypes: true }); + const values = await Promise.all( + names + .filter((entry) => entry.isDirectory()) + .map((entry) => this.getById(entry.name)), + ); + return values.filter((run): run is AgentRun => run !== null); + } + private runDirectory(id: string) { return join(this.layout.runs, id); } diff --git a/packages/local-files/src/index.ts b/packages/local-files/src/index.ts index e226c70..d2c4719 100644 --- a/packages/local-files/src/index.ts +++ b/packages/local-files/src/index.ts @@ -1 +1,34 @@ -export const localFilesPackage = "@great-agent/local-files"; +import { access, realpath, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { isAbsolute } from "node:path"; +import { CoreError, type WorkspaceResolverPort } from "@great-agent/agent-core"; + +export class LocalWorkspaceResolver implements WorkspaceResolverPort { + async resolveForCreation(path: string): Promise { + const normalized = path.trim(); + if (!isAbsolute(normalized)) + throw new CoreError( + "WORKSPACE_PATH_NOT_ABSOLUTE", + "项目目录必须使用绝对路径", + ); + try { + const resolved = await realpath(normalized); + if (!(await stat(resolved)).isDirectory()) + throw new CoreError("WORKSPACE_NOT_DIRECTORY", "项目目录不是文件夹"); + await access(resolved, constants.R_OK | constants.W_OK); + return resolved; + } catch (error) { + if (error instanceof CoreError) throw error; + throw new CoreError("WORKSPACE_UNAVAILABLE", "项目目录不存在或无法读写"); + } + } + + async isAvailable(path: string): Promise { + try { + await this.resolveForCreation(path); + return true; + } catch { + return false; + } + } +} diff --git a/packages/local-files/src/local-workspace-resolver.test.ts b/packages/local-files/src/local-workspace-resolver.test.ts new file mode 100644 index 0000000..85cd674 --- /dev/null +++ b/packages/local-files/src/local-workspace-resolver.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { LocalWorkspaceResolver } from "."; + +describe("LocalWorkspaceResolver", () => { + test("只接受实际存在且可读写的绝对目录", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-workspace-")); + try { + const resolver = new LocalWorkspaceResolver(); + expect(await resolver.resolveForCreation(root)).toContain( + "great-agent-workspace-", + ); + expect(await resolver.isAvailable(root)).toBe(true); + expect( + resolver.resolveForCreation("relative/path"), + ).rejects.toMatchObject({ code: "WORKSPACE_PATH_NOT_ABSOLUTE" }); + } finally { + await rm(root, { recursive: true }); + } + }); +}); diff --git a/packages/web-contracts/src/index.ts b/packages/web-contracts/src/index.ts index 8c43541..02f9abb 100644 --- a/packages/web-contracts/src/index.ts +++ b/packages/web-contracts/src/index.ts @@ -1,6 +1,12 @@ export { healthResponseSchema, type HealthResponse } from "./responses/health"; export { messageInputSchema, type MessageInput } from "./requests/message"; export { startRunRequestSchema, type StartRunRequest } from "./requests/run"; +export { + createProjectRequestSchema, + renameProjectRequestSchema, + type CreateProjectRequest, + type RenameProjectRequest, +} from "./requests/project"; export { runEventSchema, type RunEventResponse } from "./events/run-event"; export { startedRunSchema, type StartedRunResponse } from "./responses/run"; export { @@ -10,3 +16,4 @@ export { type ConversationSummaryResponse, messageSchema, } from "./responses/conversation"; +export { projectSchema, type ProjectResponse } from "./responses/project"; diff --git a/packages/web-contracts/src/requests/project.ts b/packages/web-contracts/src/requests/project.ts new file mode 100644 index 0000000..4257cd5 --- /dev/null +++ b/packages/web-contracts/src/requests/project.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const createProjectRequestSchema = z.object({ + name: z.string().trim().min(1).max(80), + workspaceRoot: z.string().trim().min(1), +}); + +export const renameProjectRequestSchema = z.object({ + name: z.string().trim().min(1).max(80), +}); + +export type CreateProjectRequest = z.infer; +export type RenameProjectRequest = z.infer; diff --git a/packages/web-contracts/src/requests/run.ts b/packages/web-contracts/src/requests/run.ts index 3eda103..81ef7c0 100644 --- a/packages/web-contracts/src/requests/run.ts +++ b/packages/web-contracts/src/requests/run.ts @@ -1,6 +1,11 @@ import { z } from "zod"; export const startRunRequestSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("project"), + projectId: z.string().min(1), + message: z.string().trim().min(1).max(32_000), + }), z.object({ kind: z.literal("ordinary"), message: z.string().trim().min(1).max(32_000), diff --git a/packages/web-contracts/src/responses/conversation.ts b/packages/web-contracts/src/responses/conversation.ts index 8ad7d92..34e98ce 100644 --- a/packages/web-contracts/src/responses/conversation.ts +++ b/packages/web-contracts/src/responses/conversation.ts @@ -10,7 +10,7 @@ export const messageSchema = z.object({ export const conversationSummarySchema = z.object({ id: z.string(), - projectId: z.null(), + projectId: z.string().nullable(), title: z.string(), createdAt: z.string(), updatedAt: z.string(), diff --git a/packages/web-contracts/src/responses/project.ts b/packages/web-contracts/src/responses/project.ts new file mode 100644 index 0000000..92f22e6 --- /dev/null +++ b/packages/web-contracts/src/responses/project.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const projectSchema = z.object({ + id: z.string(), + name: z.string(), + workspaceRoot: z.string(), + workspaceAvailable: z.boolean(), + conversationCount: z.number().int().nonnegative(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type ProjectResponse = z.infer;