From 944a09ce2b7e18d56b5eee00b59265ee888998bf 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 15:41:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E6=96=87=E4=BB=B6=E8=AF=BB=E5=8F=96=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai-project/state.yaml | 4 +- apps/web-server/src/composition/create-app.ts | 7 +- apps/web-server/src/index.ts | 15 +- apps/web-server/src/routes/conversations.ts | 21 +- apps/web-server/src/routes/workspace.ts | 48 ++++ apps/web/src/api/runs.ts | 16 +- apps/web/src/api/workspace.ts | 38 +++ apps/web/src/app/app-controller-types.ts | 10 +- apps/web/src/app/app-controller.ts | 39 ++- apps/web/src/app/controller-run-events.ts | 55 ++++ .../conversations/conversation-pane.css | 55 ++++ .../conversations/conversation-pane.ts | 154 ++++++++++- apps/web/src/features/files/file-picker.css | 94 +++++++ apps/web/src/features/files/file-picker.ts | 156 +++++++++++ docs/FEATURES.md | 9 +- packages/agent-core/src/domain/agent-run.ts | 3 + .../agent-core/src/domain/conversation.ts | 12 + packages/agent-core/src/index.ts | 19 +- packages/agent-core/src/ports/model-port.ts | 4 +- .../agent-core/src/ports/workspace-port.ts | 28 ++ .../src/use-cases/agent-run-service.test.ts | 53 +++- .../src/use-cases/agent-run-service.ts | 250 +++++++++--------- .../use-cases/conversation-service.test.ts | 21 ++ .../src/use-cases/conversation-service.ts | 36 ++- .../src/use-cases/interaction-run.test.ts | 6 +- .../src/use-cases/project-service.ts | 4 + .../src/use-cases/run-service-helpers.ts | 35 +++ .../agent-core/src/use-cases/run-types.ts | 20 +- .../agent-core/src/use-cases/run-workspace.ts | 99 +++++++ .../src/use-cases/workspace-service.ts | 186 +++++++++++++ .../src/use-cases/workspace-tools.ts | 44 +++ .../file-conversation-repository.test.ts | 1 + .../file-conversation-repository.ts | 24 +- packages/local-files/src/index.ts | 189 ++++++++++++- .../src/local-workspace-resolver.test.ts | 71 ++++- .../src/adapter/deepseek-model-adapter.ts | 53 ++-- .../web-contracts/src/events/run-event.ts | 3 + packages/web-contracts/src/index.ts | 6 + packages/web-contracts/src/requests/run.ts | 40 +-- .../src/responses/conversation.ts | 10 + .../web-contracts/src/responses/workspace.ts | 20 ++ 41 files changed, 1720 insertions(+), 238 deletions(-) create mode 100644 apps/web-server/src/routes/workspace.ts create mode 100644 apps/web/src/api/workspace.ts create mode 100644 apps/web/src/app/controller-run-events.ts create mode 100644 apps/web/src/features/files/file-picker.css create mode 100644 apps/web/src/features/files/file-picker.ts create mode 100644 packages/agent-core/src/ports/workspace-port.ts create mode 100644 packages/agent-core/src/use-cases/run-workspace.ts create mode 100644 packages/agent-core/src/use-cases/workspace-service.ts create mode 100644 packages/agent-core/src/use-cases/workspace-tools.ts create mode 100644 packages/web-contracts/src/responses/workspace.ts diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index f220323..4c6f751 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -41,10 +41,10 @@ 状态: "已完成" - 编号: "F-006" 名称: "附件、文件列表、读取与搜索" - 状态: "进行中" + 状态: "已完成" - 编号: "F-007" 名称: "文件创建与安全修改" - 状态: "待开始" + 状态: "进行中" - 编号: "F-008" 名称: "会话管理与个人设置" 状态: "待开始" diff --git a/apps/web-server/src/composition/create-app.ts b/apps/web-server/src/composition/create-app.ts index fc07348..66d0226 100644 --- a/apps/web-server/src/composition/create-app.ts +++ b/apps/web-server/src/composition/create-app.ts @@ -9,11 +9,13 @@ import type { ConversationService, ProjectService, InteractionService, + WorkspaceService, } from "@great-agent/agent-core"; import type { RunRegistry } from "./run-registry"; import { createRunRoutes } from "../routes/runs"; import { createProjectRoutes } from "../routes/projects"; import { createInteractionRoutes } from "../routes/interactions"; +import { createWorkspaceRoutes } from "../routes/workspace"; export function createApp( logger: Logger, @@ -22,17 +24,20 @@ export function createApp( runRegistry?: RunRegistry, projects?: ProjectService, interactions?: InteractionService, + workspace?: WorkspaceService, ): Hono { const app = new Hono(); app.use("*", requestId); app.onError(createErrorHandler(logger)); app.route("/api", createHealthRoutes()); - if (conversations) app.route("/api", createConversationRoutes(conversations)); + if (conversations) + app.route("/api", createConversationRoutes(conversations, workspace)); if (runs && runRegistry) app.route("/api", createRunRoutes(runs, runRegistry)); if (projects && conversations) app.route("/api", createProjectRoutes(projects, conversations)); if (interactions && runs && runRegistry) app.route("/api", createInteractionRoutes(interactions, runs, runRegistry)); + if (workspace) app.route("/api", createWorkspaceRoutes(workspace)); return app; } diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index 851e231..0727e0e 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -14,6 +14,7 @@ import { InteractionService, CoreError, type ModelPort, + WorkspaceService, } from "@great-agent/agent-core"; import { DeepSeekModelAdapter } from "@great-agent/model-deepseek"; import { createApp } from "./composition/create-app"; @@ -21,7 +22,10 @@ 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"; +import { + LocalWorkspaceFiles, + LocalWorkspaceResolver, +} from "@great-agent/local-files"; const environment = loadEnvironment(); const logger = createLogger(environment.logLevel); @@ -51,6 +55,13 @@ const interactions = new InteractionService({ clock, ids, }); +const workspace = new WorkspaceService( + new LocalWorkspaceFiles(), + conversations, + projects, + environment.defaultWorkspaceRoot, + clock, +); const model: ModelPort = environment.deepSeekApiKey ? new DeepSeekModelAdapter({ apiKey: environment.deepSeekApiKey, @@ -75,6 +86,7 @@ const runs = new AgentRunService( ids, projects, interactions, + workspace, ); const app = createApp( logger, @@ -83,6 +95,7 @@ const app = createApp( new RunRegistry(), projects, interactions, + workspace, ); mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist")); diff --git a/apps/web-server/src/routes/conversations.ts b/apps/web-server/src/routes/conversations.ts index d46f3f9..4a55bb7 100644 --- a/apps/web-server/src/routes/conversations.ts +++ b/apps/web-server/src/routes/conversations.ts @@ -1,13 +1,24 @@ import { Hono } from "hono"; -import type { ConversationService } from "@great-agent/agent-core"; +import type { + ConversationService, + WorkspaceService, +} from "@great-agent/agent-core"; -export function createConversationRoutes(service: ConversationService): Hono { +export function createConversationRoutes( + service: ConversationService, + workspace?: WorkspaceService, +): 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.get("/conversations/:id", async (context) => { + const conversation = await service.getConversation(context.req.param("id")); + return context.json( + workspace + ? await workspace.refreshConversation(conversation) + : conversation, + ); + }); return routes; } diff --git a/apps/web-server/src/routes/workspace.ts b/apps/web-server/src/routes/workspace.ts new file mode 100644 index 0000000..8123f0c --- /dev/null +++ b/apps/web-server/src/routes/workspace.ts @@ -0,0 +1,48 @@ +import { Hono } from "hono"; +import { + CoreError, + type WorkspaceContext, + type WorkspaceService, +} from "@great-agent/agent-core"; + +export function createWorkspaceRoutes(service: WorkspaceService): Hono { + const routes = new Hono(); + routes.get("/workspace/tree", async (context) => + context.json( + await service.list( + readContext(context.req.query()), + context.req.query("path") ?? "", + ), + ), + ); + routes.get("/workspace/search", async (context) => + context.json( + await service.search( + readContext(context.req.query()), + context.req.query("query") ?? "", + ), + ), + ); + routes.get("/workspace/file", async (context) => + context.json({ + content: await service.read( + readContext(context.req.query()), + context.req.query("path") ?? "", + ), + }), + ); + return routes; +} + +function readContext(query: Record): WorkspaceContext { + const values = [query.conversationId, query.projectId, query.ordinary].filter( + Boolean, + ); + if (values.length !== 1) + throw new CoreError("WORKSPACE_CONTEXT_INVALID", "工作区上下文无效"); + if (query.conversationId) + return { kind: "conversation", conversationId: query.conversationId }; + if (query.projectId) return { kind: "project", projectId: query.projectId }; + if (query.ordinary === "true") return { kind: "ordinary" }; + throw new CoreError("WORKSPACE_CONTEXT_INVALID", "工作区上下文无效"); +} diff --git a/apps/web/src/api/runs.ts b/apps/web/src/api/runs.ts index 1626346..b760d81 100644 --- a/apps/web/src/api/runs.ts +++ b/apps/web/src/api/runs.ts @@ -9,9 +9,19 @@ import { import { request } from "./request"; export type StartRunInput = - | { kind: "ordinary"; message: string } - | { kind: "project"; projectId: string; message: string } - | { kind: "existing"; conversationId: string; message: string }; + | { kind: "ordinary"; message: string; attachments: readonly string[] } + | { + kind: "project"; + projectId: string; + message: string; + attachments: readonly string[]; + } + | { + kind: "existing"; + conversationId: string; + message: string; + attachments: readonly string[]; + }; export async function startRun( input: StartRunInput, diff --git a/apps/web/src/api/workspace.ts b/apps/web/src/api/workspace.ts new file mode 100644 index 0000000..5b68673 --- /dev/null +++ b/apps/web/src/api/workspace.ts @@ -0,0 +1,38 @@ +import { + workspaceEntrySchema, + workspaceSearchMatchSchema, + type WorkspaceEntryResponse, + type WorkspaceSearchMatchResponse, +} from "@great-agent/web-contracts"; +import type { Selection } from "../app/app-controller-types"; +import { request } from "./request"; + +export async function listWorkspace( + selection: Selection, + path = "", +): Promise { + const value = await request( + `/api/workspace/tree?${query(selection, { path })}`, + ); + return workspaceEntrySchema.array().parse(value); +} + +export async function searchWorkspace( + selection: Selection, + search: string, +): Promise { + const value = await request( + `/api/workspace/search?${query(selection, { query: search })}`, + ); + return workspaceSearchMatchSchema.array().parse(value); +} + +function query(selection: Selection, extra: Record): string { + const params = new URLSearchParams(extra); + if (selection.kind === "conversation") + params.set("conversationId", selection.id); + else if (selection.kind === "project-draft") + params.set("projectId", selection.projectId); + else params.set("ordinary", "true"); + return params.toString(); +} diff --git a/apps/web/src/app/app-controller-types.ts b/apps/web/src/app/app-controller-types.ts index 0cd5373..784c31d 100644 --- a/apps/web/src/app/app-controller-types.ts +++ b/apps/web/src/app/app-controller-types.ts @@ -30,6 +30,7 @@ export type AppController = Readonly<{ sending: State; assistantDraft: State; error: State; + toolActivities: State; initialize(): Promise; startNewTask(): void; startProjectCreation(): void; @@ -41,7 +42,7 @@ export type AppController = Readonly<{ startProjectDelete(id: string): void; deleteProject(id: string): Promise; openConversation(id: string): Promise; - send(content: string): Promise; + send(content: string, attachments?: readonly string[]): Promise; answerInteraction( id: string, answer: InteractionAnswerRequest, @@ -50,3 +51,10 @@ export type AppController = Readonly<{ stopGenerating(): Promise; retryRun(runId: string): Promise; }>; + +export type ToolActivity = Readonly<{ + id: string; + name: string; + status: "running" | "completed" | "failed"; + detail: string; +}>; diff --git a/apps/web/src/app/app-controller.ts b/apps/web/src/app/app-controller.ts index a399561..82f8b0c 100644 --- a/apps/web/src/app/app-controller.ts +++ b/apps/web/src/app/app-controller.ts @@ -29,7 +29,12 @@ import { answerInteraction as submitInteractionAnswer, listInteractions, } from "../api/interactions"; -import type { AppController, Selection } from "./app-controller-types"; +import type { + AppController, + Selection, + ToolActivity, +} from "./app-controller-types"; +import { collectRunEvent } from "./controller-run-events"; export type { AppController, Selection } from "./app-controller-types"; export function createAppController(): AppController { @@ -47,10 +52,12 @@ export function createAppController(): AppController { const sending = van.state(false); const assistantDraft = van.state(""); const error = van.state(""); + const toolActivities = van.state([]); async function initialize() { loading.val = true; error.val = ""; + toolActivities.val = []; try { [recent.val, projects.val] = await Promise.all([ listConversations(), @@ -140,12 +147,14 @@ export function createAppController(): AppController { }); } - async function send(content: string) { - if (!content.trim() || sending.val) return false; + async function send(content: string, attachments: readonly string[] = []) { + if ((!content.trim() && attachments.length === 0) || sending.val) + return false; let accepted = false; sending.val = true; assistantDraft.val = ""; error.val = ""; + toolActivities.val = []; try { const current = selection.val; const input = @@ -154,14 +163,16 @@ export function createAppController(): AppController { kind: "existing" as const, conversationId: current.id, message: content, + attachments, } : current.kind === "project-draft" ? { kind: "project" as const, projectId: current.projectId, message: content, + attachments, } - : { kind: "ordinary" as const, message: content }; + : { kind: "ordinary" as const, message: content, attachments }; const started = await startRun(input); accepted = true; activeRunId.val = started.runId; @@ -279,24 +290,7 @@ export function createAppController(): AppController { type: string; payload: Record; }) { - if ( - event.type === "message.delta" && - typeof event.payload.delta === "string" - ) - assistantDraft.val += event.payload.delta; - if (currentRun.val && event.type === "run.failed") - currentRun.val = { - ...currentRun.val, - status: "failed", - errorCode: - typeof event.payload.code === "string" - ? event.payload.code - : "MODEL_UNAVAILABLE", - }; - if (currentRun.val && event.type === "run.cancelled") - currentRun.val = { ...currentRun.val, status: "cancelled" }; - if (currentRun.val && event.type === "run.completed") - currentRun.val = { ...currentRun.val, status: "completed" }; + collectRunEvent({ assistantDraft, currentRun, toolActivities }, event); } async function refreshActive(conversationId: string) { @@ -345,6 +339,7 @@ export function createAppController(): AppController { sending, assistantDraft, error, + toolActivities, initialize, startNewTask, startProjectCreation, diff --git a/apps/web/src/app/controller-run-events.ts b/apps/web/src/app/controller-run-events.ts new file mode 100644 index 0000000..d3c67ca --- /dev/null +++ b/apps/web/src/app/controller-run-events.ts @@ -0,0 +1,55 @@ +import type { State } from "vanjs-core"; +import type { AgentRunResponse } from "@great-agent/web-contracts"; +import type { ToolActivity } from "./app-controller-types"; + +export function collectRunEvent( + states: Readonly<{ + assistantDraft: State; + currentRun: State; + toolActivities: State; + }>, + event: Readonly<{ type: string; payload: Record }>, +): void { + if (event.type.startsWith("tool.")) updateTool(states.toolActivities, event); + if (event.type === "message.delta" && typeof event.payload.delta === "string") + states.assistantDraft.val += event.payload.delta; + if (states.currentRun.val && event.type === "run.failed") + states.currentRun.val = { + ...states.currentRun.val, + status: "failed", + errorCode: + typeof event.payload.code === "string" + ? event.payload.code + : "MODEL_UNAVAILABLE", + }; + if (states.currentRun.val && event.type === "run.cancelled") + states.currentRun.val = { ...states.currentRun.val, status: "cancelled" }; + if (states.currentRun.val && event.type === "run.completed") + states.currentRun.val = { ...states.currentRun.val, status: "completed" }; +} + +function updateTool( + activities: State, + event: Readonly<{ type: string; payload: Record }>, +): void { + const id = read(event.payload.toolCallId, "tool"); + const name = read(event.payload.name, "文件工具"); + const status = + event.type === "tool.started" + ? "running" + : event.type === "tool.completed" + ? "completed" + : "failed"; + const detail = read( + event.payload.summary ?? event.payload.message, + status === "running" ? "正在执行" : "执行完成", + ); + activities.val = [ + ...activities.val.filter((item) => item.id !== id), + { id, name, status, detail }, + ]; +} + +function read(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} diff --git a/apps/web/src/features/conversations/conversation-pane.css b/apps/web/src/features/conversations/conversation-pane.css index 9ef9920..6b9a14d 100644 --- a/apps/web/src/features/conversations/conversation-pane.css +++ b/apps/web/src/features/conversations/conversation-pane.css @@ -96,6 +96,61 @@ line-height: 1.55; white-space: pre-wrap; } +.message-attachments, +.selected-attachments { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 7px; +} +.attachment-chip, +.selected-attachments button { + max-width: 260px; + overflow: hidden; + padding: 5px 8px; + border: 1px solid #4a4742; + border-radius: 7px; + color: #bdb8b0; + background: #302f2c; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.attachment-chip.unavailable, +.attachment-chip.unsupported { + color: #d69b91; + border-color: #6b4841; +} +.selected-attachments { + grid-column: auto; + margin: 0 0 8px; +} +.selected-attachments > div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.tool-activity { + display: grid; + grid-template-columns: 18px auto 1fr; + gap: 7px; + align-items: center; + padding: 8px 10px; + border: 1px solid #41403c; + border-radius: 8px; + color: #aaa59d; + background: #2b2a28; + font-size: 10px; +} +.tool-activity span:last-child { + text-align: right; + color: #7f7b75; +} +.tool-activity.failed { + color: #d69b91; + border-color: #6b4841; +} .composer-wrap { z-index: 1; flex: 0 0 auto; diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts index a2ea328..d82f806 100644 --- a/apps/web/src/features/conversations/conversation-pane.ts +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -4,16 +4,25 @@ import "./conversation-pane.css"; import { ProjectPanel } from "../projects/project-panel"; import { InteractionCard } from "../interactions/interaction-card"; import { RunStatus } from "../runs/run-status"; +import { FilePicker } from "../files/file-picker"; const { article, button, div, h1, header, main, p, span, textarea } = van.tags; export function ConversationPane(controller: AppController): HTMLElement { const draft = van.state(""); + const attachments = van.state([]); + const attachmentContext = van.state(""); + const pickerOpen = van.state(false); async function submit() { const content = draft.val; - if (!content.trim()) return; - if (await controller.send(content)) draft.val = ""; + const selected = + attachmentContext.val === contextKey(controller) ? attachments.val : []; + if (!content.trim() && selected.length === 0) return; + if (await controller.send(content, selected)) { + draft.val = ""; + attachments.val = []; + } } return main( @@ -39,6 +48,26 @@ export function ConversationPane(controller: AppController): HTMLElement { message.role === "user" ? "你" : "G", ), message.content ? p(message.content) : div(), + message.attachments.length + ? div( + { class: "message-attachments" }, + message.attachments.map((attachment) => + span( + { + class: `attachment-chip ${attachment.status}`, + title: attachment.reason ?? attachment.path, + }, + "▤ ", + attachment.name, + attachment.status === "unavailable" + ? " · 不可用" + : attachment.status === "unsupported" + ? " · 不支持读取" + : "", + ), + ), + ) + : null, ), controller.interactions.val .filter( @@ -57,6 +86,24 @@ export function ConversationPane(controller: AppController): HTMLElement { p(controller.assistantDraft), ) : null, + () => + div( + { class: "tool-activities" }, + ...controller.toolActivities.val.map((tool) => + div( + { class: `tool-activity ${tool.status}` }, + span( + tool.status === "running" + ? "◌" + : tool.status === "completed" + ? "✓" + : "!", + ), + span(toolName(tool.name)), + span(tool.detail), + ), + ), + ), ), () => RunStatus(controller), ) @@ -68,7 +115,21 @@ export function ConversationPane(controller: AppController): HTMLElement { ), div( { class: "composer-slot", hidden: () => !canCompose(controller) }, - Composer(controller, draft, submit), + Composer( + controller, + draft, + attachments, + attachmentContext, + pickerOpen, + submit, + ), + ), + div({ class: "file-picker-host", hidden: () => !pickerOpen.val }, () => + pickerOpen.val + ? FilePicker(controller.selection.val, attachments, () => { + pickerOpen.val = false; + }) + : div(), ), ); } @@ -76,6 +137,9 @@ export function ConversationPane(controller: AppController): HTMLElement { function Composer( controller: AppController, draft: State, + attachments: State, + attachmentContext: State, + pickerOpen: State, submit: () => Promise, ): HTMLElement { return div( @@ -90,6 +154,30 @@ function Composer( ), div( { class: "composer" }, + div( + { + class: "selected-attachments", + hidden: () => + attachmentContext.val !== contextKey(controller) || + attachments.val.length === 0, + }, + () => + div( + ...attachments.val.map((path) => + button( + { + onclick: () => { + attachments.val = attachments.val.filter( + (item) => item !== path, + ); + }, + title: path, + }, + `▤ ${path.split("/").at(-1) ?? path} ×`, + ), + ), + ), + ), textarea({ "aria-label": "输入消息", placeholder: "我能帮你做些什么?", @@ -108,13 +196,34 @@ function Composer( div( { class: "composer-actions" }, button( - { class: "attach", disabled: true, "aria-label": "添加附件" }, + { + class: "attach", + disabled: () => + !workspaceAvailable(controller) || controller.sending.val, + onclick: () => { + const key = contextKey(controller); + if (attachmentContext.val !== key) attachments.val = []; + attachmentContext.val = key; + pickerOpen.val = true; + }, + "aria-label": "添加附件", + title: () => + workspaceAvailable(controller) + ? "添加工作区文件" + : "当前工作区不可用", + }, "+", ), button( { class: "send", - disabled: () => !controller.sending.val && !draft.val.trim(), + disabled: () => + !controller.sending.val && + !draft.val.trim() && + !( + attachmentContext.val === contextKey(controller) && + attachments.val.length + ), onclick: () => controller.sending.val ? void controller.stopGenerating() @@ -137,6 +246,41 @@ function Composer( ); } +function contextKey(controller: AppController): string { + const selection = controller.selection.val; + if (selection.kind === "conversation") return `conversation:${selection.id}`; + if (selection.kind === "project-draft") + return `project:${selection.projectId}`; + return "ordinary"; +} + +function workspaceAvailable(controller: AppController): boolean { + const selection = controller.selection.val; + const projectId = + selection.kind === "project-draft" + ? selection.projectId + : selection.kind === "conversation" + ? controller.active.val?.projectId + : null; + if (!projectId) return true; + return ( + controller.projects.val.find((project) => project.id === projectId) + ?.workspaceAvailable ?? false + ); +} + +function toolName(name: string): string { + return ( + ( + { + list_directory: "浏览文件", + search_files: "搜索文件", + read_text_file: "读取文件", + } as Record + )[name] ?? name + ); +} + function isProjectConversation(controller: AppController): boolean { const selection = controller.selection.val; return ( diff --git a/apps/web/src/features/files/file-picker.css b/apps/web/src/features/files/file-picker.css new file mode 100644 index 0000000..14b537b --- /dev/null +++ b/apps/web/src/features/files/file-picker.css @@ -0,0 +1,94 @@ +.file-picker-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + background: #0009; +} +.file-picker { + display: grid; + grid-template-rows: auto auto minmax(220px, 50vh) auto; + width: min(560px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid #494641; + border-radius: 14px; + background: #282725; + box-shadow: 0 24px 70px #000a; +} +.file-picker-header, +.file-picker-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; +} +.file-picker-header h2 { + margin: 0; + font-size: 14px; +} +.file-picker-header p { + margin: 4px 0 0; + color: #8d8982; + font-size: 10px; +} +.file-picker-close { + color: #aaa49c; + background: transparent; + font-size: 22px; +} +.file-search { + margin: 0 14px 10px; + padding: 9px 11px; + border: 1px solid #45423e; + border-radius: 8px; + outline: none; + color: #e7e2db; + background: #1f1e1d; +} +.file-picker-list { + overflow-y: auto; + border-block: 1px solid #3b3936; +} +.file-entry { + display: grid; + grid-template-columns: 24px 1fr 24px; + width: 100%; + padding: 9px 15px; + color: #d9d5ce; + background: transparent; + text-align: left; +} +.file-entry:hover { + background: #34322f; +} +.file-entry.selected { + background: #513a33; +} +.file-entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-entry-check { + color: #d08a72; + text-align: right; +} +.file-picker-state { + padding: 16px; + color: #918c84; + font-size: 11px; +} +.file-picker-state.error { + color: #e4aaa0; +} +.file-picker-footer { + color: #9d9890; + font-size: 10px; +} +.file-picker-done { + padding: 7px 14px; + border-radius: 7px; + color: #fff; + background: #a95e4d; +} diff --git a/apps/web/src/features/files/file-picker.ts b/apps/web/src/features/files/file-picker.ts new file mode 100644 index 0000000..8c10aa3 --- /dev/null +++ b/apps/web/src/features/files/file-picker.ts @@ -0,0 +1,156 @@ +import van, { type State } from "vanjs-core"; +import type { WorkspaceEntryResponse } from "@great-agent/web-contracts"; +import type { Selection } from "../../app/app-controller-types"; +import { listWorkspace, searchWorkspace } from "../../api/workspace"; +import "./file-picker.css"; + +const { button, div, h2, input, p, span } = van.tags; + +export function FilePicker( + selection: Selection, + selected: State, + close: () => void, +): HTMLElement { + const path = van.state(""); + const entries = van.state([]); + const loading = van.state(true); + const error = van.state(""); + + async function load(nextPath = "") { + loading.val = true; + error.val = ""; + try { + entries.val = await listWorkspace(selection, nextPath); + path.val = nextPath; + } catch (cause) { + error.val = cause instanceof Error ? cause.message : "文件列表加载失败"; + } finally { + loading.val = false; + } + } + + async function search(value: string) { + if (!value.trim()) return load(path.val); + loading.val = true; + try { + const matches = await searchWorkspace(selection, value); + const paths = [...new Set(matches.map((match) => match.path))]; + entries.val = paths.map((item) => ({ + path: item, + name: item.split("/").at(-1) ?? item, + kind: "file" as const, + })); + } catch (cause) { + error.val = cause instanceof Error ? cause.message : "搜索失败"; + } finally { + loading.val = false; + } + } + + function toggle(item: string) { + selected.val = selected.val.includes(item) + ? selected.val.filter((path) => path !== item) + : selected.val.length < 10 + ? [...selected.val, item] + : selected.val; + } + + void load(); + return div( + { + class: "file-picker-backdrop", + onclick: (event: Event) => + event.target === event.currentTarget && close(), + }, + div( + { + class: "file-picker", + role: "dialog", + "aria-modal": "true", + "aria-label": "添加附件", + }, + div( + { class: "file-picker-header" }, + div( + h2("添加工作区文件"), + p(() => path.val || "工作区根目录"), + ), + button( + { class: "file-picker-close", onclick: close, "aria-label": "关闭" }, + "×", + ), + ), + input({ + class: "file-search", + type: "search", + placeholder: "按文件名或内容搜索", + oninput: debounce( + (event: Event) => + void search((event.target as HTMLInputElement).value), + ), + }), + div( + { class: "file-picker-list" }, + () => + loading.val + ? p({ class: "file-picker-state" }, "正在读取文件…") + : null, + () => + error.val ? p({ class: "file-picker-state error" }, error.val) : null, + () => + path.val + ? button( + { + class: "file-entry directory", + onclick: () => void load(parent(path.val)), + }, + span("↩"), + span("返回上一级"), + ) + : null, + () => + div( + ...entries.val.map((entry) => + button( + { + class: () => + `file-entry ${entry.kind}${selected.val.includes(entry.path) ? " selected" : ""}`, + onclick: () => + entry.kind === "directory" + ? void load(entry.path) + : toggle(entry.path), + }, + span( + { class: "file-entry-icon" }, + entry.kind === "directory" ? "▸" : "▤", + ), + span({ class: "file-entry-name" }, entry.name), + entry.kind === "file" + ? span({ class: "file-entry-check" }, () => + selected.val.includes(entry.path) ? "✓" : "", + ) + : null, + ), + ), + ), + ), + div( + { class: "file-picker-footer" }, + span(() => `已选择 ${selected.val.length}/10`), + button({ class: "file-picker-done", onclick: close }, "完成"), + ), + ), + ); +} + +function parent(path: string): string { + return path.split("/").slice(0, -1).join("/"); +} + +function debounce(action: (event: Event) => void) { + let timer: ReturnType | undefined; + return (event: Event) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => action(event), 250); + }; +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a43c38e..8415150 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -78,13 +78,18 @@ ### F-006——附件、文件列表、读取与搜索 -- 状态:进行中 +- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核) - 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。 - 主要验收:AC-007 至 AC-009、AC-021、AC-022、AC-028、AC-030。 +- 实现结果:消息新增必填附件元数据,支持一次选择最多 10 个工作区文件和仅附件消息;普通草稿、项目草稿与已有会话均由服务端解析可信工作区,浏览器和模型不能传入根目录。文件层支持目录浏览、文件名与 UTF-8 内容搜索、2 MiB 内文本读取、二进制识别,并拒绝绝对路径、`..`、空字节和符号链接逃逸。 +- Agent 工具:新增 `list_directory`、`search_files` 和 `read_text_file`;普通文件工具使用独立 `tool.started`、`tool.completed`、`tool.failed` 事件,结果交回同一次模型请求链继续生成,与用户交互事件保持区分。 +- 页面结果:VanJS 输入区提供工作区文件选择器、目录导航、搜索、最多 10 项选择、待发送附件和历史附件状态;只有附件时发送按钮可用。附件在发送后消失或不可访问时,历史会话仍可打开并明确显示“不可用”;项目目录离线时附件入口禁用且不会回退到默认工作区。 +- 自动化验证结果:2026-08-14 通过全仓类型检查、29 项测试和 99 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;覆盖读取/搜索、二进制拒绝、绝对路径、父目录跳转、符号链接逃逸、仅附件消息和文件工具续跑。 +- 人工操作结果:2026-08-14 使用隔离工作区、隔离数据目录和未配置模型密钥的本机应用内浏览器完成;验证选择 `README.md`、仅附件发送、会话标题与附件历史恢复;删除测试文件后刷新,历史附件显示“不可用”,隔离页面控制台无错误。 ### F-007——文件创建与安全修改 -- 状态:待开始 +- 状态:进行中 - 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。 - 主要验收:AC-008、AC-009、AC-023、AC-028。 diff --git a/packages/agent-core/src/domain/agent-run.ts b/packages/agent-core/src/domain/agent-run.ts index 83c9361..b1865f6 100644 --- a/packages/agent-core/src/domain/agent-run.ts +++ b/packages/agent-core/src/domain/agent-run.ts @@ -28,6 +28,9 @@ export type RunEvent = Readonly<{ | "interaction.requested" | "interaction.resolved" | "interaction.cancelled" + | "tool.started" + | "tool.completed" + | "tool.failed" | "run.completed" | "run.failed" | "run.cancelled"; diff --git a/packages/agent-core/src/domain/conversation.ts b/packages/agent-core/src/domain/conversation.ts index 1b67239..a7fa427 100644 --- a/packages/agent-core/src/domain/conversation.ts +++ b/packages/agent-core/src/domain/conversation.ts @@ -1,9 +1,21 @@ export type MessageRole = "user" | "assistant"; +export type AttachmentStatus = "available" | "unavailable" | "unsupported"; + +export type AttachmentRef = Readonly<{ + name: string; + path: string; + mediaType: string; + status: AttachmentStatus; + checkedAt: string; + reason?: string; +}>; + export type Message = Readonly<{ id: string; role: MessageRole; content: string; + attachments: readonly AttachmentRef[]; createdAt: string; runId: string; }>; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 17b3593..aff66b1 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -4,6 +4,8 @@ export type { ConversationSummary, Message, MessageRole, + AttachmentRef, + AttachmentStatus, } from "./domain/conversation"; export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run"; export type { Project, ProjectView } from "./domain/project"; @@ -29,15 +31,22 @@ export type { IdPort, WorkspaceResolverPort, } from "./ports/system-ports"; +export type { + WorkspaceEntry, + WorkspaceFileInfo, + WorkspacePort, + WorkspaceSearchMatch, +} from "./ports/workspace-port"; +export { + WorkspaceService, + type WorkspaceContext, +} from "./use-cases/workspace-service"; export { ConversationService, type ConversationServiceDependencies, } from "./use-cases/conversation-service"; -export { - AgentRunService, - type StartedRun, - type StartRunInput, -} from "./use-cases/agent-run-service"; +export { AgentRunService } from "./use-cases/agent-run-service"; +export type { StartedRun, StartRunInput } from "./use-cases/run-types"; export { ProjectService, type ProjectServiceDependencies, diff --git a/packages/agent-core/src/ports/model-port.ts b/packages/agent-core/src/ports/model-port.ts index b6caeda..ddb6deb 100644 --- a/packages/agent-core/src/ports/model-port.ts +++ b/packages/agent-core/src/ports/model-port.ts @@ -4,12 +4,12 @@ export type ModelRequest = Readonly<{ model: string; messages: readonly Message[]; tools?: readonly ModelTool[]; - continuation?: Readonly<{ + continuations?: readonly Readonly<{ toolCallId: string; toolName: string; toolArguments: string; result: string; - }>; + }>[]; }>; export type ModelTool = Readonly<{ diff --git a/packages/agent-core/src/ports/workspace-port.ts b/packages/agent-core/src/ports/workspace-port.ts new file mode 100644 index 0000000..4b23e1d --- /dev/null +++ b/packages/agent-core/src/ports/workspace-port.ts @@ -0,0 +1,28 @@ +export type WorkspaceEntry = Readonly<{ + path: string; + name: string; + kind: "file" | "directory"; + size?: number; +}>; + +export type WorkspaceSearchMatch = Readonly<{ + path: string; + line?: number; + preview?: string; + matchedBy: "name" | "content"; +}>; + +export type WorkspaceFileInfo = Readonly<{ + path: string; + name: string; + size: number; + mediaType: string; + readableAsText: boolean; +}>; + +export interface WorkspacePort { + list(root: string, path?: string): Promise; + search(root: string, query: string): Promise; + inspect(root: string, path: string): Promise; + readText(root: string, 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 78f48ed..ed96463 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 @@ -272,6 +272,50 @@ describe("AgentRunService", () => { ).toHaveLength(1); expect(conversation.messages.at(-1)?.content).toBe("重试成功"); }); + + test("执行文件工具后把结果交回模型并继续同一次 Run", async () => { + let calls = 0; + const fixture = createFixture({ + async *stream(request) { + calls++; + if (calls === 1) { + yield { + type: "tool.requested", + toolCallId: "tool_read", + name: "read_text_file", + arguments: JSON.stringify({ path: "README.md" }), + }; + return; + } + expect(request.continuations?.[0]?.result).toBe("本地文件内容"); + yield { type: "text.delta", delta: "已经读取文件" }; + yield { type: "response.completed" }; + }, + }); + const workspace = { + createAttachments: async () => [], + executeTool: async () => "本地文件内容", + } as unknown as import("..").WorkspaceService; + fixture.service = new AgentRunService( + fixture.conversationService, + fixture.model, + fixture.runs, + { now: () => new Date("2026-08-14T00:00:00Z") }, + { create: () => `tool_${++fixture.nextId}` }, + projects, + interactions, + workspace, + ); + const started = await fixture.service.start( + { kind: "ordinary", message: "读取说明" }, + new AbortController().signal, + ); + const events: RunEvent[] = []; + for await (const event of started.events) events.push(event); + expect(events.map((event) => event.type)).toContain("tool.started"); + expect(events.map((event) => event.type)).toContain("tool.completed"); + expect(events.at(-1)?.type).toBe("run.completed"); + }); }); function createFixture(model: ModelPort) { @@ -292,5 +336,12 @@ function createFixture(model: ModelPort) { projects, interactions, ); - return { service, runs, conversations, conversationService }; + return { + service, + model, + runs, + conversations, + conversationService, + nextId: id, + }; } 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 c6adab9..412027b 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.ts @@ -1,5 +1,4 @@ import type { AgentRun, RunEvent } from "../domain/agent-run"; -import type { Conversation } from "../domain/conversation"; import type { UserInteraction } from "../domain/user-interaction"; import { CoreError } from "../errors/core-error"; import type { ModelPort, ModelRequest } from "../ports/model-port"; @@ -8,14 +7,19 @@ import type { ClockPort, IdPort } from "../ports/system-ports"; import type { ConversationService } from "./conversation-service"; import type { InteractionService } from "./interaction-service"; import type { ProjectService } from "./project-service"; +import type { WorkspaceService } from "./workspace-service"; +import { isWorkspaceTool, workspaceTools } from "./workspace-tools"; import { interactionTool } from "./interaction-tool"; import { + ensureNoActiveRun, lastSequence, messagesThroughTrigger, + persistRunEvent, + requireRun, safeErrorMessage, } from "./run-service-helpers"; import type { StartedRun, StartRunInput } from "./run-types"; -export type { StartedRun, StartRunInput } from "./run-types"; +import { executeWorkspaceTool, prepareRunConversation } from "./run-workspace"; export class AgentRunService { private readonly controllers = new Map(); @@ -29,13 +33,20 @@ export class AgentRunService { private readonly ids: IdPort, private readonly projects: ProjectService, private readonly interactions: InteractionService, + private readonly workspace?: WorkspaceService, ) {} async start(input: StartRunInput, signal: AbortSignal): Promise { return this.withStartLock(async () => { - await this.ensureNoActiveRun(); + await ensureNoActiveRun(this.runs); const runId = this.ids.create(); - const conversation = await this.prepareConversation(input, runId); + const conversation = await prepareRunConversation( + input, + runId, + this.conversations, + this.projects, + this.workspace, + ); const timestamp = this.clock.now().toISOString(); const triggerMessage = conversation.messages.at(-1); if (triggerMessage?.role !== "user") @@ -60,7 +71,7 @@ export class AgentRunService { interaction: UserInteraction, signal: AbortSignal, ): Promise { - const current = await this.requireRun(interaction.runId); + const current = await requireRun(this.runs, interaction.runId); if (current.status !== "waiting_user") throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答"); if (!interaction.answer) @@ -72,17 +83,17 @@ export class AgentRunService { }; await this.runs.update(run); const sequence = lastSequence(await this.runs.listEvents(run.id)); - const continuation: NonNullable = { + const continuation: NonNullable[number] = { toolCallId: interaction.toolCallId, toolName: "request_user_interaction", toolArguments: interaction.toolArguments, result: JSON.stringify(interaction.answer), }; - return this.started(run, signal, sequence, continuation, interaction); + return this.started(run, signal, sequence, [continuation], interaction); } async cancel(runId: string): Promise { - const run = await this.requireRun(runId); + const run = await requireRun(this.runs, runId); if ( run.status === "cancelled" || run.status === "completed" || @@ -103,7 +114,9 @@ export class AgentRunService { if (!interaction) throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在"); let sequence = lastSequence(await this.runs.listEvents(run.id)); - const cancelled = await this.persistEvent( + const cancelled = await persistRunEvent( + this.runs, + this.clock, run.id, ++sequence, "interaction.cancelled", @@ -114,7 +127,9 @@ export class AgentRunService { status: "cancelled", updatedAt: this.clock.now().toISOString(), }); - const finished = await this.persistEvent( + const finished = await persistRunEvent( + this.runs, + this.clock, run.id, ++sequence, "run.cancelled", @@ -125,8 +140,8 @@ export class AgentRunService { async retry(runId: string, signal: AbortSignal): Promise { return this.withStartLock(async () => { - await this.ensureNoActiveRun(); - const original = await this.requireRun(runId); + await ensureNoActiveRun(this.runs); + const original = await requireRun(this.runs, runId); if (original.status !== "failed" && original.status !== "cancelled") throw new CoreError( "RUN_NOT_RETRYABLE", @@ -156,19 +171,19 @@ export class AgentRunService { } async lastSequence(runId: string): Promise { - await this.requireRun(runId); + await requireRun(this.runs, runId); return lastSequence(await this.runs.listEvents(runId)); } getRun(runId: string): Promise { - return this.requireRun(runId); + return requireRun(this.runs, runId); } private started( run: AgentRun, externalSignal: AbortSignal, sequence: number, - continuation?: NonNullable, + continuations?: NonNullable, interaction?: UserInteraction, ): StartedRun { const controller = new AbortController(); @@ -176,19 +191,10 @@ export class AgentRunService { const signal = AbortSignal.any([externalSignal, controller.signal]); return { run, - events: this.execute(run, signal, sequence, continuation, interaction), + events: this.execute(run, signal, sequence, continuations, interaction), }; } - private async ensureNoActiveRun(): Promise { - const active = await this.runs.findActive(); - if (active) - throw new CoreError( - "RUN_ALREADY_ACTIVE", - "已有任务正在运行,请先停止或完成当前任务", - ); - } - private async withStartLock(operation: () => Promise): Promise { const previous = this.startQueue; let release = () => {}; @@ -203,37 +209,11 @@ export class AgentRunService { } } - private async prepareConversation( - input: StartRunInput, - runId: string, - ): Promise { - if (input.kind === "ordinary") - return this.conversations.createWithFirstMessage(input.message, runId); - if (input.kind === "project") { - await this.projects.requireAvailable(input.projectId); - return this.conversations.createProjectWithFirstMessage( - input.projectId, - input.message, - runId, - ); - } - const existing = await this.conversations.getConversation( - input.conversationId, - ); - if (existing.projectId) - await this.projects.requireAvailable(existing.projectId); - return this.conversations.appendUserMessage( - input.conversationId, - input.message, - runId, - ); - } - private async *execute( run: AgentRun, signal: AbortSignal, initialSequence: number, - continuation?: NonNullable, + initialContinuations: NonNullable = [], resolvedInteraction?: UserInteraction, ): AsyncIterable { let sequence = initialSequence; @@ -243,9 +223,10 @@ export class AgentRunService { const event = async ( type: RunEvent["type"], payload: Record, - ) => this.persistEvent(run.id, ++sequence, type, payload); + ) => + persistRunEvent(this.runs, this.clock, run.id, ++sequence, type, payload); try { - if (!continuation) + if (initialContinuations.length === 0) yield await event("run.started", { conversationId: run.conversationId, }); @@ -263,53 +244,101 @@ export class AgentRunService { ? messagesThroughTrigger(conversation, run.triggerMessageId) : conversation.messages; signal.throwIfAborted(); - for await (const modelEvent of this.model.stream( - { - model: "default", - messages: modelMessages, - tools: [interactionTool], - ...(continuation ? { continuation } : {}), - }, - signal, - )) { - signal.throwIfAborted(); - if (modelEvent.type === "text.delta") { - content += modelEvent.delta; - yield await event("message.delta", { - messageId, - delta: modelEvent.delta, - }); - continue; - } - if (modelEvent.type === "tool.requested") { - if (modelEvent.name !== interactionTool.name) - throw new CoreError( - "TOOL_NOT_SUPPORTED", - "模型请求了尚未支持的工具", + const continuations = [...initialContinuations]; + let toolCount = 0; + modelLoop: while (true) { + let requestedTool = false; + for await (const modelEvent of this.model.stream( + { + model: "default", + messages: modelMessages, + tools: [interactionTool, ...workspaceTools], + ...(continuations.length ? { continuations } : {}), + }, + signal, + )) { + signal.throwIfAborted(); + if (modelEvent.type === "text.delta") { + content += modelEvent.delta; + yield await event("message.delta", { + messageId, + delta: modelEvent.delta, + }); + continue; + } + if (modelEvent.type === "tool.requested") { + requestedTool = true; + if (isWorkspaceTool(modelEvent.name)) { + if (++toolCount > 12) + throw new CoreError( + "TOOL_LIMIT_EXCEEDED", + "文件工具调用次数过多", + ); + yield await event("tool.started", { + messageId, + toolCallId: modelEvent.toolCallId, + name: modelEvent.name, + }); + const tool = await executeWorkspaceTool( + this.workspace, + run.conversationId, + modelEvent.name, + modelEvent.arguments, + ); + if (!tool.failed) { + yield await event("tool.completed", { + messageId, + toolCallId: modelEvent.toolCallId, + name: modelEvent.name, + summary: tool.summary, + }); + } else { + yield await event("tool.failed", { + messageId, + toolCallId: modelEvent.toolCallId, + name: modelEvent.name, + code: tool.failed.code, + message: tool.failed.message, + }); + } + continuations.push({ + toolCallId: modelEvent.toolCallId, + toolName: modelEvent.name, + toolArguments: modelEvent.arguments, + result: tool.result, + }); + continue modelLoop; + } + if (modelEvent.name !== interactionTool.name) + throw new CoreError( + "TOOL_NOT_SUPPORTED", + "模型请求了尚未支持的工具", + ); + await this.conversations.appendAssistantMessage( + run.conversationId, + messageId, + run.id, + content, ); - await this.conversations.appendAssistantMessage( - run.conversationId, - messageId, - run.id, - content, - ); - messagePersisted = true; - yield await event("message.completed", { messageId, content }); - const interaction = await this.interactions.create({ - runId: run.id, - conversationId: run.conversationId, - messageId, - toolCallId: modelEvent.toolCallId, - toolArguments: modelEvent.arguments, - }); - await this.runs.update({ - ...run, - status: "waiting_user", - updatedAt: this.clock.now().toISOString(), - }); - yield await event("interaction.requested", { interaction }); - return; + messagePersisted = true; + yield await event("message.completed", { messageId, content }); + const interaction = await this.interactions.create({ + runId: run.id, + conversationId: run.conversationId, + messageId, + toolCallId: modelEvent.toolCallId, + toolArguments: modelEvent.arguments, + }); + await this.runs.update({ + ...run, + status: "waiting_user", + updatedAt: this.clock.now().toISOString(), + }); + yield await event("interaction.requested", { interaction }); + return; + } } + if (!requestedTool) break; } signal.throwIfAborted(); if (!content) @@ -367,27 +396,4 @@ export class AgentRunService { this.controllers.delete(run.id); } } - - private async requireRun(id: string): Promise { - const run = await this.runs.getById(id); - if (!run) throw new CoreError("RUN_NOT_FOUND", "任务不存在"); - return run; - } - - private async persistEvent( - runId: string, - sequence: number, - type: RunEvent["type"], - payload: Record, - ) { - const value: RunEvent = { - runId, - sequence, - timestamp: this.clock.now().toISOString(), - type, - payload, - }; - await this.runs.appendEvent(value); - return value; - } } 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 4cb5f91..01cf412 100644 --- a/packages/agent-core/src/use-cases/conversation-service.test.ts +++ b/packages/agent-core/src/use-cases/conversation-service.test.ts @@ -70,4 +70,25 @@ describe("ConversationService", () => { ).rejects.toMatchObject({ code: "MESSAGE_EMPTY" }); expect(conversations.values.size).toBe(0); }); + + test("允许只有附件而没有文本的消息", async () => { + const conversations = new MemoryConversationRepository(); + const service = new ConversationService({ + conversations, + clock: { now: () => new Date("2026-08-14T00:00:00Z") }, + ids: { create: () => "attachment_message" }, + }); + const created = await service.createWithFirstMessage("", "run_1", [ + { + name: "说明.md", + path: "docs/说明.md", + mediaType: "text/markdown", + status: "available", + checkedAt: "2026-08-14T00:00:00Z", + }, + ]); + expect(created.title).toBe("说明.md"); + expect(created.messages[0]?.content).toBe(""); + expect(created.messages[0]?.attachments[0]?.path).toBe("docs/说明.md"); + }); }); diff --git a/packages/agent-core/src/use-cases/conversation-service.ts b/packages/agent-core/src/use-cases/conversation-service.ts index 0a0c361..abcb47f 100644 --- a/packages/agent-core/src/use-cases/conversation-service.ts +++ b/packages/agent-core/src/use-cases/conversation-service.ts @@ -34,14 +34,17 @@ export class ConversationService { async createWithFirstMessage( content: string, runId: string, + attachments: Message["attachments"] = [], ): Promise { - const normalized = normalizeContent(content); + const normalized = normalizeContent(content, attachments.length); 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, runId)], + title: createTitle(normalized, attachments), + messages: [ + this.createUserMessage(normalized, timestamp, runId, attachments), + ], createdAt: timestamp, updatedAt: timestamp, }; @@ -53,14 +56,17 @@ export class ConversationService { projectId: string, content: string, runId: string, + attachments: Message["attachments"] = [], ): Promise { - const normalized = normalizeContent(content); + const normalized = normalizeContent(content, attachments.length); 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)], + title: createTitle(normalized, attachments), + messages: [ + this.createUserMessage(normalized, timestamp, runId, attachments), + ], createdAt: timestamp, updatedAt: timestamp, }; @@ -72,8 +78,9 @@ export class ConversationService { id: string, content: string, runId: string, + attachments: Message["attachments"] = [], ): Promise { - const normalized = normalizeContent(content); + const normalized = normalizeContent(content, attachments.length); await this.getConversation(id); return this.dependencies.conversations.appendMessage( id, @@ -81,6 +88,7 @@ export class ConversationService { normalized, this.dependencies.clock.now().toISOString(), runId, + attachments, ), ); } @@ -96,6 +104,7 @@ export class ConversationService { id: messageId, role: "assistant", content, + attachments: [], createdAt: this.dependencies.clock.now().toISOString(), runId, }); @@ -105,26 +114,33 @@ export class ConversationService { content: string, createdAt: string, runId: string, + attachments: Message["attachments"], ): Message { return { id: this.dependencies.ids.create(), role: "user", content, + attachments, createdAt, runId, }; } } -function normalizeContent(content: string): string { +function normalizeContent(content: string, attachmentCount: number): string { const normalized = content.trim(); - if (!normalized) throw new CoreError("MESSAGE_EMPTY", "请输入消息内容"); + if (!normalized && attachmentCount === 0) + throw new CoreError("MESSAGE_EMPTY", "请输入消息内容或添加附件"); if (normalized.length > 32_000) throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长"); return normalized; } -function createTitle(content: string): string { +function createTitle( + content: string, + attachments: Message["attachments"], +): string { + if (!content) return attachments[0]?.name ?? "附件对话"; const firstLine = content.split("\n", 1)[0] ?? content; return firstLine.length > 36 ? `${firstLine.slice(0, 36)}…` : firstLine; } diff --git a/packages/agent-core/src/use-cases/interaction-run.test.ts b/packages/agent-core/src/use-cases/interaction-run.test.ts index 3c48c88..9b190a2 100644 --- a/packages/agent-core/src/use-cases/interaction-run.test.ts +++ b/packages/agent-core/src/use-cases/interaction-run.test.ts @@ -173,7 +173,7 @@ function createFixture() { }); const model: ModelPort = { async *stream(request) { - if (!request.continuation) { + if (!request.continuations?.length) { yield { type: "tool.requested", toolCallId: "tool_choice", @@ -188,8 +188,8 @@ function createFixture() { }), }; } else { - expect(request.continuation.toolCallId).toBe("tool_choice"); - expect(request.continuation.result).toContain("simple"); + expect(request.continuations[0]?.toolCallId).toBe("tool_choice"); + expect(request.continuations[0]?.result).toContain("simple"); yield { type: "text.delta", delta: "已按你的选择继续完成" }; yield { type: "response.completed" }; } diff --git a/packages/agent-core/src/use-cases/project-service.ts b/packages/agent-core/src/use-cases/project-service.ts index 4a1407c..aa47985 100644 --- a/packages/agent-core/src/use-cases/project-service.ts +++ b/packages/agent-core/src/use-cases/project-service.ts @@ -87,6 +87,10 @@ export class ProjectService { return project; } + async getEntity(id: string): Promise { + return this.require(id); + } + private async require(id: string): Promise { const project = await this.dependencies.projects.getById(id); if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在"); diff --git a/packages/agent-core/src/use-cases/run-service-helpers.ts b/packages/agent-core/src/use-cases/run-service-helpers.ts index 3a84313..e4cb77e 100644 --- a/packages/agent-core/src/use-cases/run-service-helpers.ts +++ b/packages/agent-core/src/use-cases/run-service-helpers.ts @@ -1,6 +1,8 @@ import type { RunEvent } from "../domain/agent-run"; import type { Conversation } from "../domain/conversation"; import { CoreError } from "../errors/core-error"; +import type { RunRepository } from "../ports/run-repository"; +import type { ClockPort } from "../ports/system-ports"; export function lastSequence(events: readonly RunEvent[]): number { return events.at(-1)?.sequence ?? 0; @@ -21,3 +23,36 @@ export function messagesThroughTrigger( ? conversation.messages : conversation.messages.slice(0, index + 1); } + +export async function ensureNoActiveRun(runs: RunRepository): Promise { + if (await runs.findActive()) + throw new CoreError( + "RUN_ALREADY_ACTIVE", + "已有任务正在运行,请先停止或完成当前任务", + ); +} + +export async function requireRun(runs: RunRepository, id: string) { + const run = await runs.getById(id); + if (!run) throw new CoreError("RUN_NOT_FOUND", "任务不存在"); + return run; +} + +export async function persistRunEvent( + runs: RunRepository, + clock: ClockPort, + runId: string, + sequence: number, + type: RunEvent["type"], + payload: Record, +): Promise { + const value: RunEvent = { + runId, + sequence, + timestamp: clock.now().toISOString(), + type, + payload, + }; + await runs.appendEvent(value); + return value; +} diff --git a/packages/agent-core/src/use-cases/run-types.ts b/packages/agent-core/src/use-cases/run-types.ts index 8b25441..eb6e168 100644 --- a/packages/agent-core/src/use-cases/run-types.ts +++ b/packages/agent-core/src/use-cases/run-types.ts @@ -1,9 +1,23 @@ import type { AgentRun, RunEvent } from "../domain/agent-run"; export type StartRunInput = - | Readonly<{ kind: "ordinary"; message: string }> - | Readonly<{ kind: "project"; projectId: string; message: string }> - | Readonly<{ kind: "existing"; conversationId: string; message: string }>; + | Readonly<{ + kind: "ordinary"; + message: string; + attachments?: readonly string[]; + }> + | Readonly<{ + kind: "project"; + projectId: string; + message: string; + attachments?: readonly string[]; + }> + | Readonly<{ + kind: "existing"; + conversationId: string; + message: string; + attachments?: readonly string[]; + }>; export type StartedRun = Readonly<{ run: AgentRun; diff --git a/packages/agent-core/src/use-cases/run-workspace.ts b/packages/agent-core/src/use-cases/run-workspace.ts new file mode 100644 index 0000000..e48f7e6 --- /dev/null +++ b/packages/agent-core/src/use-cases/run-workspace.ts @@ -0,0 +1,99 @@ +import type { Conversation } from "../domain/conversation"; +import { CoreError } from "../errors/core-error"; +import type { ConversationService } from "./conversation-service"; +import type { ProjectService } from "./project-service"; +import type { StartRunInput } from "./run-types"; +import type { WorkspaceService } from "./workspace-service"; + +export async function prepareRunConversation( + input: StartRunInput, + runId: string, + conversations: ConversationService, + projects: ProjectService, + workspace?: WorkspaceService, +): Promise { + const context = + input.kind === "ordinary" + ? ({ kind: "ordinary" } as const) + : input.kind === "project" + ? ({ kind: "project", projectId: input.projectId } as const) + : ({ + kind: "conversation", + conversationId: input.conversationId, + } as const); + const attachments = input.attachments?.length + ? await requireWorkspace(workspace).createAttachments( + context, + input.attachments, + ) + : []; + if (input.kind === "ordinary") + return conversations.createWithFirstMessage( + input.message, + runId, + attachments, + ); + if (input.kind === "project") { + await projects.requireAvailable(input.projectId); + return conversations.createProjectWithFirstMessage( + input.projectId, + input.message, + runId, + attachments, + ); + } + const existing = await conversations.getConversation(input.conversationId); + if (existing.projectId) await projects.requireAvailable(existing.projectId); + return conversations.appendUserMessage( + input.conversationId, + input.message, + runId, + attachments, + ); +} + +export type WorkspaceToolResult = Readonly<{ + result: string; + failed?: Readonly<{ code: string; message: string }>; + summary?: string; +}>; + +export async function executeWorkspaceTool( + workspace: WorkspaceService | undefined, + conversationId: string, + name: string, + argumentsValue: string, +): Promise { + try { + const result = await requireWorkspace(workspace).executeTool( + conversationId, + name, + argumentsValue, + ); + return { result, summary: summarize(name, result) }; + } catch (cause) { + const error = + cause instanceof CoreError + ? cause + : new CoreError("TOOL_FAILED", "文件工具执行失败"); + return { + result: JSON.stringify({ error: error.code, message: error.message }), + failed: { code: error.code, message: error.message }, + }; + } +} + +function requireWorkspace(workspace?: WorkspaceService): WorkspaceService { + if (!workspace) + throw new CoreError("WORKSPACE_UNAVAILABLE", "文件能力尚未配置"); + return workspace; +} + +function summarize(name: string, result: string): string { + if (name === "read_text_file") return `已读取 ${result.length} 个字符`; + try { + const value: unknown = JSON.parse(result); + if (Array.isArray(value)) return `找到 ${value.length} 项`; + } catch {} + return "已完成"; +} diff --git a/packages/agent-core/src/use-cases/workspace-service.ts b/packages/agent-core/src/use-cases/workspace-service.ts new file mode 100644 index 0000000..a3a761a --- /dev/null +++ b/packages/agent-core/src/use-cases/workspace-service.ts @@ -0,0 +1,186 @@ +import type { AttachmentRef, Conversation } from "../domain/conversation"; +import { CoreError } from "../errors/core-error"; +import type { ClockPort } from "../ports/system-ports"; +import type { WorkspacePort } from "../ports/workspace-port"; +import type { ConversationService } from "./conversation-service"; +import type { ProjectService } from "./project-service"; + +export type WorkspaceContext = + | Readonly<{ kind: "ordinary" }> + | Readonly<{ kind: "project"; projectId: string }> + | Readonly<{ kind: "conversation"; conversationId: string }>; + +export class WorkspaceService { + constructor( + private readonly files: WorkspacePort, + private readonly conversations: ConversationService, + private readonly projects: ProjectService, + private readonly defaultRoot: string, + private readonly clock: ClockPort, + ) {} + + async list(context: WorkspaceContext, path = "") { + return this.files.list(await this.resolveRoot(context), path); + } + + async search(context: WorkspaceContext, query: string) { + const normalized = query.trim(); + if (!normalized) + throw new CoreError("SEARCH_QUERY_EMPTY", "请输入搜索内容"); + return this.files.search(await this.resolveRoot(context), normalized); + } + + async read(context: WorkspaceContext, path: string) { + return this.files.readText(await this.resolveRoot(context), path); + } + + async createAttachments( + context: WorkspaceContext, + paths: readonly string[], + ): Promise { + const unique = [...new Set(paths)]; + if (unique.length > 10) + throw new CoreError( + "ATTACHMENT_LIMIT_EXCEEDED", + "一次最多添加 10 个附件", + ); + const root = await this.resolveRoot(context); + return Promise.all( + unique.map(async (path) => { + const info = await this.files.inspect(root, path); + return { + name: info.name, + path: info.path, + mediaType: info.mediaType, + status: info.readableAsText ? "available" : "unsupported", + checkedAt: this.clock.now().toISOString(), + ...(info.readableAsText ? {} : { reason: "暂不支持读取该文件类型" }), + } as const; + }), + ); + } + + async refreshConversation(conversation: Conversation): Promise { + if (!conversation.messages.some((message) => message.attachments.length)) + return conversation; + let root: string; + try { + root = await this.resolveRoot({ + kind: "conversation", + conversationId: conversation.id, + }); + } catch (cause) { + return mapAttachments(conversation, (attachment) => ({ + ...attachment, + ...unavailable(cause, this.clock), + })); + } + return mapAttachments(conversation, async (attachment) => { + try { + const info = await this.files.inspect(root, attachment.path); + const { reason: _reason, ...current } = attachment; + return { + ...current, + mediaType: info.mediaType, + status: info.readableAsText ? "available" : "unsupported", + checkedAt: this.clock.now().toISOString(), + ...(info.readableAsText ? {} : { reason: "暂不支持读取该文件类型" }), + }; + } catch (cause) { + return { ...attachment, ...unavailable(cause, this.clock) }; + } + }); + } + + async executeTool( + conversationId: string, + name: string, + rawArguments: string, + ): Promise { + const input = parseArguments(rawArguments); + const context: WorkspaceContext = { kind: "conversation", conversationId }; + if (name === "list_directory") + return JSON.stringify( + await this.list(context, readString(input, "path", true)), + ); + if (name === "search_files") + return JSON.stringify( + await this.search(context, readString(input, "query")), + ); + if (name === "read_text_file") + return await this.read(context, readString(input, "path")); + throw new CoreError("TOOL_NOT_SUPPORTED", "模型请求了尚未支持的工具"); + } + + private async resolveRoot(context: WorkspaceContext): Promise { + if (context.kind === "ordinary") return this.requireDefaultRoot(); + if (context.kind === "project") + return (await this.projects.requireAvailable(context.projectId)) + .workspaceRoot; + const conversation = await this.conversations.getConversation( + context.conversationId, + ); + if (!conversation.projectId) return this.requireDefaultRoot(); + return (await this.projects.requireAvailable(conversation.projectId)) + .workspaceRoot; + } + + private async requireDefaultRoot(): Promise { + try { + await this.files.list(this.defaultRoot); + return this.defaultRoot; + } catch { + throw new CoreError( + "DEFAULT_WORKSPACE_UNAVAILABLE", + "默认工作区当前不可用", + ); + } + } +} + +async function mapAttachments( + conversation: Conversation, + transform: ( + attachment: AttachmentRef, + ) => AttachmentRef | Promise, +): Promise { + return { + ...conversation, + messages: await Promise.all( + conversation.messages.map(async (message) => ({ + ...message, + attachments: await Promise.all(message.attachments.map(transform)), + })), + ), + }; +} + +function unavailable(cause: unknown, clock: ClockPort) { + return { + status: "unavailable" as const, + checkedAt: clock.now().toISOString(), + reason: + cause instanceof CoreError ? cause.message : "文件已移动、删除或无法访问", + }; +} + +function parseArguments(value: string): Record { + try { + const parsed: unknown = JSON.parse(value); + if (parsed && typeof parsed === "object") + return parsed as Record; + } catch {} + throw new CoreError("TOOL_ARGUMENTS_INVALID", "文件工具参数格式无效"); +} + +function readString( + input: Record, + key: string, + optional = false, +): string { + const value = input[key]; + if (optional && value === undefined) return ""; + if (typeof value !== "string") + throw new CoreError("TOOL_ARGUMENTS_INVALID", `文件工具缺少 ${key} 参数`); + return value; +} diff --git a/packages/agent-core/src/use-cases/workspace-tools.ts b/packages/agent-core/src/use-cases/workspace-tools.ts new file mode 100644 index 0000000..c24eb7b --- /dev/null +++ b/packages/agent-core/src/use-cases/workspace-tools.ts @@ -0,0 +1,44 @@ +import type { ModelTool } from "../ports/model-port"; + +export const workspaceTools: readonly ModelTool[] = [ + { + name: "list_directory", + description: + "列出当前会话工作区内某个目录的直接子项。路径必须是工作区相对路径。", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "目录相对路径,根目录使用空字符串", + }, + }, + required: ["path"], + additionalProperties: false, + }, + }, + { + name: "search_files", + description: "按文件名和 UTF-8 文本内容搜索当前会话工作区。", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, + }, + { + name: "read_text_file", + description: "读取当前会话工作区内不超过 2 MiB 的 UTF-8 文本文件。", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + additionalProperties: false, + }, + }, +]; + +export function isWorkspaceTool(name: string): boolean { + return workspaceTools.some((tool) => tool.name === name); +} diff --git a/packages/local-data/src/repositories/file-conversation-repository.test.ts b/packages/local-data/src/repositories/file-conversation-repository.test.ts index 6730c53..f870b13 100644 --- a/packages/local-data/src/repositories/file-conversation-repository.test.ts +++ b/packages/local-data/src/repositories/file-conversation-repository.test.ts @@ -31,6 +31,7 @@ describe("FileConversationRepository", () => { id: "message_1", role: "user", content: "你好", + attachments: [], createdAt: "2026-08-12T01:00:00.000Z", runId: "run_1", }); diff --git a/packages/local-data/src/repositories/file-conversation-repository.ts b/packages/local-data/src/repositories/file-conversation-repository.ts index 846c0e4..14f7f24 100644 --- a/packages/local-data/src/repositories/file-conversation-repository.ts +++ b/packages/local-data/src/repositories/file-conversation-repository.ts @@ -31,9 +31,9 @@ export class FileConversationRepository implements ConversationRepository { async getById(id: string): Promise { try { - return JSON.parse( - await readFile(this.pathFor(id), "utf8"), - ) as Conversation; + return normalizeConversation( + JSON.parse(await readFile(this.pathFor(id), "utf8")) as Conversation, + ); } catch (error) { if (isFileNotFound(error)) return null; throw error; @@ -82,14 +82,26 @@ export class FileConversationRepository implements ConversationRepository { names .filter((name) => name.endsWith(".json")) .map(async (name) => { - return JSON.parse( - await readFile(join(this.layout.conversations, name), "utf8"), - ) as Conversation; + return normalizeConversation( + JSON.parse( + await readFile(join(this.layout.conversations, name), "utf8"), + ) as Conversation, + ); }), ); } } +function normalizeConversation(conversation: Conversation): Conversation { + return { + ...conversation, + messages: conversation.messages.map((message) => ({ + ...message, + attachments: message.attachments ?? [], + })), + }; +} + function isFileNotFound(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } diff --git a/packages/local-files/src/index.ts b/packages/local-files/src/index.ts index d2c4719..b1c3333 100644 --- a/packages/local-files/src/index.ts +++ b/packages/local-files/src/index.ts @@ -1,7 +1,21 @@ -import { access, realpath, stat } from "node:fs/promises"; +import { access, readFile, readdir, 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"; +import { + basename, + extname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; +import { + CoreError, + type WorkspaceEntry, + type WorkspaceFileInfo, + type WorkspacePort, + type WorkspaceResolverPort, + type WorkspaceSearchMatch, +} from "@great-agent/agent-core"; export class LocalWorkspaceResolver implements WorkspaceResolverPort { async resolveForCreation(path: string): Promise { @@ -32,3 +46,172 @@ export class LocalWorkspaceResolver implements WorkspaceResolverPort { } } } + +const MAX_READ_BYTES = 2 * 1024 * 1024; +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]); + +export class LocalWorkspaceFiles implements WorkspacePort { + async list(root: string, path = ""): Promise { + const realRoot = await realpath(root); + const target = await safeExistingPath(root, path); + if (!(await stat(target)).isDirectory()) + throw new CoreError("WORKSPACE_NOT_DIRECTORY", "目标不是文件夹"); + const names = await readdir(target, { withFileTypes: true }); + return Promise.all( + names + .filter((entry) => !entry.isSymbolicLink()) + .sort((left, right) => left.name.localeCompare(right.name)) + .map(async (entry) => { + const absolute = resolve(target, entry.name); + const info = await stat(absolute); + return { + path: toRelative(realRoot, absolute), + name: entry.name, + kind: entry.isDirectory() ? "directory" : "file", + ...(entry.isFile() ? { size: info.size } : {}), + } as const; + }), + ); + } + + async search( + root: string, + query: string, + ): Promise { + const normalized = query.toLocaleLowerCase(); + const paths = await collectFiles(root); + const matches: WorkspaceSearchMatch[] = []; + for (const path of paths) { + if (matches.length >= 200) break; + if (basename(path).toLocaleLowerCase().includes(normalized)) + matches.push({ path, matchedBy: "name" }); + if (matches.length >= 200) break; + try { + const content = await this.readText(root, path); + const lines = content.split("\n"); + let count = 0; + for (let index = 0; index < lines.length && count < 20; index++) { + if (!lines[index]?.toLocaleLowerCase().includes(normalized)) continue; + matches.push({ + path, + line: index + 1, + preview: (lines[index] ?? "").trim().slice(0, 240), + matchedBy: "content", + }); + count++; + if (matches.length >= 200) break; + } + } catch (cause) { + if (!(cause instanceof CoreError)) throw cause; + } + } + return matches; + } + + async inspect(root: string, path: string): Promise { + const realRoot = await realpath(root); + const target = await safeExistingPath(root, path); + const info = await stat(target); + if (!info.isFile()) + throw new CoreError("FILE_NOT_REGULAR", "目标不是普通文件"); + const mediaType = mediaTypeFor(path); + return { + path: toRelative(realRoot, target), + name: basename(target), + size: info.size, + mediaType, + readableAsText: info.size <= MAX_READ_BYTES && (await isText(target)), + }; + } + + async readText(root: string, path: string): Promise { + const info = await this.inspect(root, path); + if (info.size > MAX_READ_BYTES) + throw new CoreError("FILE_TOO_LARGE", "文件超过 2 MiB,暂不支持读取"); + if (!info.readableAsText) + throw new CoreError( + "FILE_TYPE_UNSUPPORTED", + "该文件不是支持的 UTF-8 文本", + ); + return readFile(await safeExistingPath(root, path), "utf8"); + } +} + +async function safeExistingPath(root: string, input: string): Promise { + validateRelativePath(input); + try { + const realRoot = await realpath(root); + const target = await realpath(resolve(realRoot, input || ".")); + if (!within(realRoot, target)) + throw new CoreError( + "WORKSPACE_PATH_FORBIDDEN", + "不能访问工作区之外的路径", + ); + return target; + } catch (cause) { + if (cause instanceof CoreError) throw cause; + throw new CoreError("FILE_UNAVAILABLE", "文件不存在或无法访问"); + } +} + +function validateRelativePath(path: string): void { + if (path.includes("\0") || isAbsolute(path)) + throw new CoreError( + "WORKSPACE_PATH_FORBIDDEN", + "只能使用工作区内的相对路径", + ); + if (path.split(/[\\/]/u).includes("..")) + throw new CoreError("WORKSPACE_PATH_FORBIDDEN", "路径不能包含父目录跳转"); +} + +function within(root: string, target: string): boolean { + return target === root || target.startsWith(`${root}${sep}`); +} + +function toRelative(root: string, target: string): string { + return relative(root, target).split(sep).join("/"); +} + +async function collectFiles(root: string): Promise { + const realRoot = await safeExistingPath(root, ""); + const result: string[] = []; + const visit = async (directory: string) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const absolute = resolve(directory, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) await visit(absolute); + } else if (entry.isFile()) result.push(toRelative(realRoot, absolute)); + if (result.length >= 5_000) return; + } + }; + await visit(realRoot); + return result; +} + +async function isText(path: string): Promise { + const buffer = await readFile(path); + if (buffer.includes(0)) return false; + try { + new TextDecoder("utf-8", { fatal: true }).decode(buffer); + return true; + } catch { + return false; + } +} + +function mediaTypeFor(path: string): string { + const types: Record = { + ".css": "text/css", + ".csv": "text/csv", + ".html": "text/html", + ".js": "text/javascript", + ".json": "application/json", + ".md": "text/markdown", + ".ts": "text/typescript", + ".txt": "text/plain", + ".yaml": "application/yaml", + ".yml": "application/yaml", + }; + return types[extname(path).toLocaleLowerCase()] ?? "application/octet-stream"; +} diff --git a/packages/local-files/src/local-workspace-resolver.test.ts b/packages/local-files/src/local-workspace-resolver.test.ts index 85cd674..fc4cfac 100644 --- a/packages/local-files/src/local-workspace-resolver.test.ts +++ b/packages/local-files/src/local-workspace-resolver.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { LocalWorkspaceResolver } from "."; +import { LocalWorkspaceFiles, LocalWorkspaceResolver } from "."; describe("LocalWorkspaceResolver", () => { test("只接受实际存在且可读写的绝对目录", async () => { @@ -21,3 +21,70 @@ describe("LocalWorkspaceResolver", () => { } }); }); + +describe("LocalWorkspaceFiles", () => { + test("列出、搜索并读取工作区文本", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-files-")); + try { + await mkdir(join(root, "docs")); + await writeFile( + join(root, "docs", "说明.md"), + "第一行\n需要搜索的内容\n", + ); + const files = new LocalWorkspaceFiles(); + expect(await files.list(root)).toEqual([ + { path: "docs", name: "docs", kind: "directory" }, + ]); + expect((await files.search(root, "搜索"))[0]).toMatchObject({ + path: "docs/说明.md", + line: 2, + matchedBy: "content", + }); + expect(await files.readText(root, "docs/说明.md")).toContain("第一行"); + } finally { + await rm(root, { recursive: true }); + } + }); + + test("拒绝绝对路径、父目录跳转和符号链接逃逸", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-safe-")); + const outside = await mkdtemp(join(tmpdir(), "great-agent-outside-")); + try { + await writeFile(join(outside, "secret.txt"), "secret"); + await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt")); + const files = new LocalWorkspaceFiles(); + await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject( + { + code: "WORKSPACE_PATH_FORBIDDEN", + }, + ); + await expect( + files.readText(root, join(outside, "secret.txt")), + ).rejects.toMatchObject({ + code: "WORKSPACE_PATH_FORBIDDEN", + }); + await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({ + code: "WORKSPACE_PATH_FORBIDDEN", + }); + } finally { + await rm(root, { recursive: true }); + await rm(outside, { recursive: true }); + } + }); + + test("二进制文件返回明确的不支持状态", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-binary-")); + try { + await writeFile(join(root, "image.bin"), new Uint8Array([1, 0, 2])); + const files = new LocalWorkspaceFiles(); + expect((await files.inspect(root, "image.bin")).readableAsText).toBe( + false, + ); + await expect(files.readText(root, "image.bin")).rejects.toMatchObject({ + code: "FILE_TYPE_UNSUPPORTED", + }); + } finally { + await rm(root, { recursive: true }); + } + }); +}); diff --git a/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts b/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts index a87ea2a..5f64bfb 100644 --- a/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts +++ b/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts @@ -94,33 +94,40 @@ function toModelMessages( request: ModelRequest, ): OpenAI.Chat.Completions.ChatCompletionMessageParam[] { const messages = request.messages.map(toModelMessage); - if (!request.continuation) return messages; - return [ - ...messages, - { - role: "assistant", - content: null, - tool_calls: [ - { - id: request.continuation.toolCallId, - type: "function", - function: { - name: request.continuation.toolName, - arguments: request.continuation.toolArguments, + for (const continuation of request.continuations ?? []) { + messages.push( + { + role: "assistant", + content: null, + tool_calls: [ + { + id: continuation.toolCallId, + type: "function", + function: { + name: continuation.toolName, + arguments: continuation.toolArguments, + }, }, - }, - ], - }, - { - role: "tool", - tool_call_id: request.continuation.toolCallId, - content: request.continuation.result, - }, - ]; + ], + }, + { + role: "tool", + tool_call_id: continuation.toolCallId, + content: continuation.result, + }, + ); + } + return messages; } function toModelMessage( message: Message, ): OpenAI.Chat.Completions.ChatCompletionMessageParam { - return { role: message.role, content: message.content }; + const attachments = message.attachments + .map((attachment) => `- ${attachment.path}(${attachment.status})`) + .join("\n"); + const content = attachments + ? `${message.content}${message.content ? "\n\n" : ""}[附件]\n${attachments}` + : message.content; + return { role: message.role, content }; } diff --git a/packages/web-contracts/src/events/run-event.ts b/packages/web-contracts/src/events/run-event.ts index bd705d3..26a8cba 100644 --- a/packages/web-contracts/src/events/run-event.ts +++ b/packages/web-contracts/src/events/run-event.ts @@ -12,6 +12,9 @@ export const runEventSchema = z.object({ "interaction.requested", "interaction.resolved", "interaction.cancelled", + "tool.started", + "tool.completed", + "tool.failed", "run.completed", "run.failed", "run.cancelled", diff --git a/packages/web-contracts/src/index.ts b/packages/web-contracts/src/index.ts index 2e8d0e6..7752317 100644 --- a/packages/web-contracts/src/index.ts +++ b/packages/web-contracts/src/index.ts @@ -30,3 +30,9 @@ export { type InteractionResponse, type ResolvedInteractionResponse, } from "./responses/interaction"; +export { + workspaceEntrySchema, + workspaceSearchMatchSchema, + type WorkspaceEntryResponse, + type WorkspaceSearchMatchResponse, +} from "./responses/workspace"; diff --git a/packages/web-contracts/src/requests/run.ts b/packages/web-contracts/src/requests/run.ts index 81ef7c0..c5aa746 100644 --- a/packages/web-contracts/src/requests/run.ts +++ b/packages/web-contracts/src/requests/run.ts @@ -1,20 +1,28 @@ 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), - }), - z.object({ - kind: z.literal("existing"), - conversationId: z.string().min(1), - message: z.string().trim().min(1).max(32_000), - }), -]); +export const startRunRequestSchema = z + .discriminatedUnion("kind", [ + z.object({ + kind: z.literal("project"), + projectId: z.string().min(1), + message: z.string().trim().max(32_000), + attachments: z.array(z.string().min(1)).max(10).default([]), + }), + z.object({ + kind: z.literal("ordinary"), + message: z.string().trim().max(32_000), + attachments: z.array(z.string().min(1)).max(10).default([]), + }), + z.object({ + kind: z.literal("existing"), + conversationId: z.string().min(1), + message: z.string().trim().max(32_000), + attachments: z.array(z.string().min(1)).max(10).default([]), + }), + ]) + .superRefine((value, context) => { + if (!value.message && value.attachments.length === 0) + context.addIssue({ code: "custom", message: "请输入消息内容或添加附件" }); + }); export type StartRunRequest = z.infer; diff --git a/packages/web-contracts/src/responses/conversation.ts b/packages/web-contracts/src/responses/conversation.ts index 34e98ce..f5d1b9b 100644 --- a/packages/web-contracts/src/responses/conversation.ts +++ b/packages/web-contracts/src/responses/conversation.ts @@ -4,6 +4,16 @@ export const messageSchema = z.object({ id: z.string(), role: z.enum(["user", "assistant"]), content: z.string(), + attachments: z.array( + z.object({ + name: z.string(), + path: z.string(), + mediaType: z.string(), + status: z.enum(["available", "unavailable", "unsupported"]), + checkedAt: z.string(), + reason: z.string().optional(), + }), + ), createdAt: z.string(), runId: z.string(), }); diff --git a/packages/web-contracts/src/responses/workspace.ts b/packages/web-contracts/src/responses/workspace.ts new file mode 100644 index 0000000..0d0238a --- /dev/null +++ b/packages/web-contracts/src/responses/workspace.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const workspaceEntrySchema = z.object({ + path: z.string(), + name: z.string(), + kind: z.enum(["file", "directory"]), + size: z.number().optional(), +}); + +export const workspaceSearchMatchSchema = z.object({ + path: z.string(), + line: z.number().optional(), + preview: z.string().optional(), + matchedBy: z.enum(["name", "content"]), +}); + +export type WorkspaceEntryResponse = z.infer; +export type WorkspaceSearchMatchResponse = z.infer< + typeof workspaceSearchMatchSchema +>;