From 3a2c51ed6797d33e7b9f6471c3669fe2c99a999e 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:54:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= 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 | 5 + .../src/composition/run-registry.ts | 11 + apps/web-server/src/http/error-handler.ts | 7 +- apps/web-server/src/index.ts | 18 +- apps/web-server/src/routes/interactions.ts | 47 ++++ apps/web-server/src/routes/runs.test.ts | 3 + apps/web-server/src/routes/runs.ts | 19 +- apps/web/src/api/interactions.ts | 38 +++ apps/web/src/api/runs.ts | 4 + apps/web/src/app/app-controller.ts | 91 ++++++- .../conversations/conversation-pane.ts | 28 +- .../interactions/interaction-card.css | 125 +++++++++ .../features/interactions/interaction-card.ts | 188 +++++++++++++ docs/FEATURES.md | 8 +- packages/agent-core/src/domain/agent-run.ts | 13 +- .../agent-core/src/domain/user-interaction.ts | 35 +++ packages/agent-core/src/index.ts | 19 +- .../src/ports/interaction-repository.ts | 11 + packages/agent-core/src/ports/model-port.ts | 19 ++ .../src/use-cases/agent-run-service.test.ts | 10 +- .../src/use-cases/agent-run-service.ts | 257 ++++++++++++++---- .../src/use-cases/interaction-run.test.ts | 212 +++++++++++++++ .../src/use-cases/interaction-service.test.ts | 127 +++++++++ .../src/use-cases/interaction-service.ts | 215 +++++++++++++++ packages/local-data/src/index.ts | 1 + .../file-interaction-repository.test.ts | 45 +++ .../file-interaction-repository.ts | 84 ++++++ .../src/repositories/file-run-repository.ts | 4 +- .../src/adapter/deepseek-model-adapter.ts | 70 ++++- .../web-contracts/src/events/run-event.ts | 4 + packages/web-contracts/src/index.ts | 8 + .../src/responses/interaction.ts | 48 ++++ 33 files changed, 1698 insertions(+), 80 deletions(-) create mode 100644 apps/web-server/src/routes/interactions.ts create mode 100644 apps/web/src/api/interactions.ts create mode 100644 apps/web/src/features/interactions/interaction-card.css create mode 100644 apps/web/src/features/interactions/interaction-card.ts create mode 100644 packages/agent-core/src/domain/user-interaction.ts create mode 100644 packages/agent-core/src/ports/interaction-repository.ts create mode 100644 packages/agent-core/src/use-cases/interaction-run.test.ts create mode 100644 packages/agent-core/src/use-cases/interaction-service.test.ts create mode 100644 packages/agent-core/src/use-cases/interaction-service.ts create mode 100644 packages/local-data/src/repositories/file-interaction-repository.test.ts create mode 100644 packages/local-data/src/repositories/file-interaction-repository.ts create mode 100644 packages/web-contracts/src/responses/interaction.ts diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index abb3eda..a1708ad 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -35,10 +35,10 @@ 状态: "已完成" - 编号: "F-004" 名称: "用户交互卡片" - 状态: "进行中" + 状态: "已完成" - 编号: "F-005" 名称: "停止、失败与重试" - 状态: "待开始" + 状态: "进行中" - 编号: "F-006" 名称: "附件、文件列表、读取与搜索" 状态: "待开始" diff --git a/apps/web-server/src/composition/create-app.ts b/apps/web-server/src/composition/create-app.ts index bab9843..fc07348 100644 --- a/apps/web-server/src/composition/create-app.ts +++ b/apps/web-server/src/composition/create-app.ts @@ -8,10 +8,12 @@ import type { AgentRunService, ConversationService, ProjectService, + InteractionService, } 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"; export function createApp( logger: Logger, @@ -19,6 +21,7 @@ export function createApp( runs?: AgentRunService, runRegistry?: RunRegistry, projects?: ProjectService, + interactions?: InteractionService, ): Hono { const app = new Hono(); app.use("*", requestId); @@ -29,5 +32,7 @@ export function createApp( 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)); return app; } diff --git a/apps/web-server/src/composition/run-registry.ts b/apps/web-server/src/composition/run-registry.ts index 241f3fa..031a092 100644 --- a/apps/web-server/src/composition/run-registry.ts +++ b/apps/web-server/src/composition/run-registry.ts @@ -6,6 +6,7 @@ export class RunRegistry { private readonly history = new Map(); private readonly listeners = new Map>(); private readonly finished = new Set(); + private readonly paused = new Set(); async publish(event: RunEvent): Promise { const events = this.history.get(event.runId) ?? []; @@ -13,6 +14,13 @@ export class RunRegistry { this.history.set(event.runId, events); if (event.type === "run.completed" || event.type === "run.failed") this.finished.add(event.runId); + if (event.type === "run.cancelled") this.finished.add(event.runId); + if (event.type === "interaction.requested") this.paused.add(event.runId); + if ( + event.type === "interaction.resolved" || + event.type === "interaction.cancelled" + ) + this.paused.delete(event.runId); await Promise.all( [...(this.listeners.get(event.runId) ?? [])].map((listener) => listener(event), @@ -26,6 +34,9 @@ export class RunRegistry { isFinished(runId: string): boolean { return this.finished.has(runId); } + isPaused(runId: string): boolean { + return this.paused.has(runId); + } subscribe(runId: string, listener: Listener): () => void { const values = this.listeners.get(runId) ?? new Set(); diff --git a/apps/web-server/src/http/error-handler.ts b/apps/web-server/src/http/error-handler.ts index 06ec226..d1af787 100644 --- a/apps/web-server/src/http/error-handler.ts +++ b/apps/web-server/src/http/error-handler.ts @@ -21,9 +21,12 @@ export function createErrorHandler(logger: Logger): ErrorHandler { if (error instanceof CoreError) { const status = error.code === "CONVERSATION_NOT_FOUND" || - error.code === "PROJECT_NOT_FOUND" + error.code === "PROJECT_NOT_FOUND" || + error.code === "RUN_NOT_FOUND" || + error.code === "INTERACTION_NOT_FOUND" ? 404 - : error.code === "PROJECT_RUN_ACTIVE" + : error.code === "PROJECT_RUN_ACTIVE" || + error.code === "INTERACTION_ALREADY_RESOLVED" ? 409 : 400; return context.json( diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index 027c279..5412e9d 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -3,6 +3,7 @@ import { FileConversationRepository, FileRunRepository, FileProjectRepository, + FileInteractionRepository, createDataLayout, ensureDataLayout, } from "@great-agent/local-data"; @@ -10,6 +11,7 @@ import { AgentRunService, ConversationService, ProjectService, + InteractionService, CoreError, type ModelPort, } from "@great-agent/agent-core"; @@ -30,6 +32,7 @@ const clock = { now: () => new Date() }; const ids = { create: () => crypto.randomUUID() }; const conversationRepository = new FileConversationRepository(layout); const runRepository = new FileRunRepository(layout); +const interactionRepository = new FileInteractionRepository(layout); const conversations = new ConversationService({ conversations: conversationRepository, clock, @@ -43,6 +46,11 @@ const projects = new ProjectService({ clock, ids, }); +const interactions = new InteractionService({ + interactions: interactionRepository, + clock, + ids, +}); const model: ModelPort = environment.deepSeekApiKey ? new DeepSeekModelAdapter({ apiKey: environment.deepSeekApiKey, @@ -65,8 +73,16 @@ const runs = new AgentRunService( clock, ids, projects, + interactions, +); +const app = createApp( + logger, + conversations, + runs, + new RunRegistry(), + projects, + interactions, ); -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/interactions.ts b/apps/web-server/src/routes/interactions.ts new file mode 100644 index 0000000..cb9e615 --- /dev/null +++ b/apps/web-server/src/routes/interactions.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono"; +import type { + AgentRunService, + InteractionService, +} from "@great-agent/agent-core"; +import { interactionAnswerSchema } from "@great-agent/web-contracts"; +import type { RunRegistry } from "../composition/run-registry"; +import { consume } from "./runs"; + +export function createInteractionRoutes( + interactions: InteractionService, + runs: AgentRunService, + registry: RunRegistry, +): Hono { + const routes = new Hono(); + routes.get("/conversations/:conversationId/interactions", async (context) => + context.json( + await interactions.listByConversation( + context.req.param("conversationId"), + ), + ), + ); + routes.get("/interactions/:id", async (context) => + context.json(await interactions.get(context.req.param("id"))), + ); + routes.post("/interactions/:id/response", async (context) => { + const answer = interactionAnswerSchema.parse(await context.req.json()); + const current = await interactions.get(context.req.param("id")); + const resumeFromSequence = await runs.lastSequence(current.runId); + const resolved = await interactions.resolve(current.id, answer); + const run = await runs.getRun(current.runId); + const shouldResume = resolved.changed || run.status === "waiting_user"; + if (shouldResume) { + const started = await runs.resume( + resolved.interaction, + new AbortController().signal, + ); + void consume(started.events, registry); + } + return context.json({ + interaction: resolved.interaction, + resumed: shouldResume, + resumeFromSequence, + }); + }); + return routes; +} diff --git a/apps/web-server/src/routes/runs.test.ts b/apps/web-server/src/routes/runs.test.ts index 31981e0..a23a962 100644 --- a/apps/web-server/src/routes/runs.test.ts +++ b/apps/web-server/src/routes/runs.test.ts @@ -12,6 +12,7 @@ import { AgentRunService, ConversationService, type ProjectService, + type InteractionService, } from "@great-agent/agent-core"; import { createApp } from "../composition/create-app"; import { RunRegistry } from "../composition/run-registry"; @@ -21,6 +22,7 @@ const projects = { throw new Error("unused"); }, } as unknown as ProjectService; +const interactions = {} as InteractionService; class Conversations implements ConversationRepository { value: Conversation | null = null; @@ -92,6 +94,7 @@ describe("Run HTTP 与 SSE", () => { { now: () => new Date("2026-08-12T00:00:00.000Z") }, { create: () => `id_${++id}` }, projects, + interactions, ); const app = createApp( pino({ enabled: false }), diff --git a/apps/web-server/src/routes/runs.ts b/apps/web-server/src/routes/runs.ts index c74fe9d..524700e 100644 --- a/apps/web-server/src/routes/runs.ts +++ b/apps/web-server/src/routes/runs.ts @@ -23,12 +23,20 @@ export function createRunRoutes( 202, ); }); + routes.post("/runs/:runId/cancel", async (context) => { + const events = await service.cancelWaiting(context.req.param("runId")); + for (const event of events) await registry.publish(event); + return context.json({ + runId: context.req.param("runId"), + status: "cancelled" as const, + }); + }); routes.get("/runs/:runId/events", (context) => streamSSE(context, async (stream) => { const runId = context.req.param("runId"); for (const event of registry.events(runId)) await writeEvent(stream, event); - if (registry.isFinished(runId)) { + if (registry.isFinished(runId) || registry.isPaused(runId)) { // EventSource needs the terminal event to reach the browser before the // server closes a replay-only stream. await stream.sleep(100); @@ -37,7 +45,12 @@ export function createRunRoutes( await new Promise((resolve) => { const unsubscribe = registry.subscribe(runId, async (event) => { await writeEvent(stream, event); - if (event.type === "run.completed" || event.type === "run.failed") { + if ( + event.type === "run.completed" || + event.type === "run.failed" || + event.type === "run.cancelled" || + event.type === "interaction.requested" + ) { unsubscribe(); resolve(); } @@ -52,7 +65,7 @@ export function createRunRoutes( return routes; } -async function consume( +export async function consume( events: AsyncIterable, registry: RunRegistry, ): Promise { diff --git a/apps/web/src/api/interactions.ts b/apps/web/src/api/interactions.ts new file mode 100644 index 0000000..9ecb79b --- /dev/null +++ b/apps/web/src/api/interactions.ts @@ -0,0 +1,38 @@ +import { + interactionSchema, + resolvedInteractionSchema, + type InteractionAnswerRequest, + type InteractionResponse, + type ResolvedInteractionResponse, +} from "@great-agent/web-contracts"; +import { request } from "./request"; + +export async function listInteractions( + conversationId: string, +): Promise { + return interactionSchema + .array() + .parse( + await request( + `/api/conversations/${encodeURIComponent(conversationId)}/interactions`, + ), + ); +} + +export async function answerInteraction( + id: string, + answer: InteractionAnswerRequest, +): Promise { + return resolvedInteractionSchema.parse( + await request(`/api/interactions/${encodeURIComponent(id)}/response`, { + method: "POST", + body: JSON.stringify(answer), + }), + ); +} + +export async function cancelWaitingRun(runId: string): Promise { + await request(`/api/runs/${encodeURIComponent(runId)}/cancel`, { + method: "POST", + }); +} diff --git a/apps/web/src/api/runs.ts b/apps/web/src/api/runs.ts index 3e51485..c24cbb6 100644 --- a/apps/web/src/api/runs.ts +++ b/apps/web/src/api/runs.ts @@ -26,6 +26,7 @@ export async function startRun( export async function streamRun( runId: string, onEvent: (event: RunEventResponse) => void, + afterSequence = 0, ): Promise { const response = await fetch( `/api/runs/${encodeURIComponent(runId)}/events`, @@ -49,8 +50,11 @@ export async function streamRun( .join("\n"); if (!data) continue; const event = runEventSchema.parse(JSON.parse(data)); + if (event.sequence <= afterSequence) continue; onEvent(event); if (event.type === "run.completed") return; + if (event.type === "interaction.requested") return; + if (event.type === "run.cancelled") return; if (event.type === "run.failed") throw new Error(readPayloadMessage(event.payload)); } diff --git a/apps/web/src/app/app-controller.ts b/apps/web/src/app/app-controller.ts index c8ab408..1e99ef9 100644 --- a/apps/web/src/app/app-controller.ts +++ b/apps/web/src/app/app-controller.ts @@ -3,6 +3,8 @@ import type { ConversationResponse, ConversationSummaryResponse, ProjectResponse, + InteractionAnswerRequest, + InteractionResponse, } from "@great-agent/web-contracts"; import { getConversation, @@ -16,6 +18,11 @@ import { renameProject, } from "../api/projects"; import { startRun, streamRun } from "../api/runs"; +import { + answerInteraction as submitInteractionAnswer, + cancelWaitingRun, + listInteractions, +} from "../api/interactions"; export type Selection = | { kind: "none" } @@ -32,6 +39,7 @@ export type AppController = Readonly<{ projects: State; projectConversations: State>; active: State; + interactions: State; loading: State; sending: State; assistantDraft: State; @@ -48,6 +56,11 @@ export type AppController = Readonly<{ deleteProject(id: string): Promise; openConversation(id: string): Promise; send(content: string): Promise; + answerInteraction( + id: string, + answer: InteractionAnswerRequest, + ): Promise; + cancelInteraction(runId: string): Promise; }>; export function createAppController(): AppController { @@ -58,6 +71,7 @@ export function createAppController(): AppController { Record >({}); const active = van.state(null); + const interactions = van.state([]); const loading = van.state(false); const sending = van.state(false); const assistantDraft = van.state(""); @@ -81,6 +95,7 @@ export function createAppController(): AppController { function select(next: Selection) { selection.val = next; active.val = null; + interactions.val = []; error.val = ""; } function startNewTask() { @@ -147,6 +162,7 @@ export function createAppController(): AppController { async function openConversation(id: string) { await perform(async () => { active.val = await getConversation(id); + interactions.val = await listInteractions(id); selection.val = { kind: "conversation", id }; }); } @@ -176,15 +192,8 @@ export function createAppController(): AppController { active.val = await getConversation(started.conversationId); selection.val = { kind: "conversation", id: started.conversationId }; 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 = ""; + await streamRun(started.runId, collectDelta); + await refreshActive(started.conversationId); } catch (cause) { error.val = readMessage(cause); } finally { @@ -192,6 +201,67 @@ export function createAppController(): AppController { } } + async function answerInteraction( + id: string, + answer: InteractionAnswerRequest, + ) { + if (sending.val) return; + sending.val = true; + assistantDraft.val = ""; + error.val = ""; + try { + const resolved = await submitInteractionAnswer(id, answer); + interactions.val = interactions.val.map((item) => + item.id === id ? resolved.interaction : item, + ); + if (resolved.resumed) { + await streamRun( + resolved.interaction.runId, + collectDelta, + resolved.resumeFromSequence, + ); + } + await refreshActive(resolved.interaction.conversationId); + } catch (cause) { + error.val = readMessage(cause); + } finally { + sending.val = false; + } + } + + async function cancelInteraction(runId: string) { + if (sending.val) return; + sending.val = true; + error.val = ""; + try { + await cancelWaitingRun(runId); + if (active.val) interactions.val = await listInteractions(active.val.id); + } catch (cause) { + error.val = readMessage(cause); + } finally { + sending.val = false; + } + } + + function collectDelta(event: { + type: string; + payload: Record; + }) { + if ( + event.type === "message.delta" && + typeof event.payload.delta === "string" + ) + assistantDraft.val += event.payload.delta; + } + + async function refreshActive(conversationId: string) { + [active.val, interactions.val] = await Promise.all([ + getConversation(conversationId), + listInteractions(conversationId), + ]); + assistantDraft.val = ""; + } + async function refreshLists(projectId: string | null) { if (!projectId) recent.val = await listConversations(); else { @@ -222,6 +292,7 @@ export function createAppController(): AppController { projects, projectConversations, active, + interactions, loading, sending, assistantDraft, @@ -238,6 +309,8 @@ export function createAppController(): AppController { deleteProject: deleteExistingProject, openConversation, send, + answerInteraction, + cancelInteraction, }; } diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts index 3d5c92f..f0fa165 100644 --- a/apps/web/src/features/conversations/conversation-pane.ts +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -2,6 +2,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"; +import { InteractionCard } from "../interactions/interaction-card"; const { article, button, div, h1, header, main, p, span, textarea } = van.tags; @@ -29,13 +30,23 @@ export function ConversationPane(controller: AppController): HTMLElement { 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", + div( + { class: "timeline-entry" }, + article( + { class: `message ${message.role}` }, + span( + { class: `message-role ${message.role}-role` }, + message.role === "user" ? "你" : "G", + ), + message.content ? p(message.content) : div(), ), - p(message.content), + controller.interactions.val + .filter( + (interaction) => interaction.messageId === message.id, + ) + .map((interaction) => + InteractionCard(controller, interaction), + ), ), ), () => @@ -132,7 +143,10 @@ function isProjectConversation(controller: AppController): boolean { function canCompose(controller: AppController): boolean { const selection = controller.selection.val; - if (selection.kind === "conversation") return true; + if (selection.kind === "conversation") + return !controller.interactions.val.some( + (interaction) => interaction.status === "pending", + ); if (selection.kind === "ordinary-draft") return true; if (selection.kind === "project-draft") { return ( diff --git a/apps/web/src/features/interactions/interaction-card.css b/apps/web/src/features/interactions/interaction-card.css new file mode 100644 index 0000000..000fdeb --- /dev/null +++ b/apps/web/src/features/interactions/interaction-card.css @@ -0,0 +1,125 @@ +.interaction-card { + margin: 12px 0 0 37px; + padding: 15px; + border: 1px solid #47443f; + border-radius: 12px; + background: #282725; +} +.interaction-card.pending { + border-color: #665047; +} +.interaction-header { + display: flex; + justify-content: space-between; + gap: 16px; +} +.interaction-header > div { + display: flex; + gap: 10px; +} +.interaction-mark { + display: grid; + flex: 0 0 auto; + width: 25px; + height: 25px; + place-items: center; + border-radius: 7px; + color: #f0b39f; + background: #59362c; + font-weight: 700; +} +.interaction-card h3 { + margin: 1px 0 0; + color: #e6e2dc; + font-size: 13px; +} +.interaction-card p { + margin: 4px 0 0; + color: #8f8b84; + font-size: 11px; + line-height: 1.45; +} +.interaction-status { + color: #a07d70; + font-size: 9px; + white-space: nowrap; +} +.interaction-options { + display: grid; + gap: 6px; + margin-top: 13px; +} +.interaction-options label { + display: flex; + gap: 9px; + align-items: center; + padding: 8px 10px; + border: 1px solid #3f3d39; + border-radius: 8px; + color: #c7c3bc; + font-size: 11px; + cursor: pointer; +} +.interaction-options label:has(input:checked) { + border-color: #8f5d4f; + background: #352925; +} +.interaction-options input { + accent-color: #b26855; +} +.interaction-card textarea { + width: 100%; + min-height: 84px; + margin-top: 13px; + padding: 10px; + resize: vertical; + border: 1px solid #45423e; + border-radius: 8px; + outline: 0; + color: #e8e4dd; + background: #1c1c1b; + font-size: 11px; +} +.interaction-card textarea:focus { + border-color: #926050; +} +.interaction-actions, +.confirmation-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 13px; +} +.interaction-card button { + padding: 7px 11px; + border-radius: 7px; + font-size: 10px; +} +.interaction-card .primary { + color: #fff; + background: #a95e4d; +} +.interaction-card .secondary { + color: #ccc8c1; + background: #403e3a; +} +.interaction-card .cancel { + margin-right: auto; + color: #a59f98; + background: transparent; +} +.confirmation-cancel { + margin-top: 8px; +} +.interaction-answer { + margin-top: 12px; + padding: 9px 11px; + border-radius: 8px; + color: #d2cec7; + background: #1d1d1b; + font-size: 11px; + white-space: pre-wrap; +} +.interaction-card.cancelled { + opacity: 0.68; +} diff --git a/apps/web/src/features/interactions/interaction-card.ts b/apps/web/src/features/interactions/interaction-card.ts new file mode 100644 index 0000000..9d2a35b --- /dev/null +++ b/apps/web/src/features/interactions/interaction-card.ts @@ -0,0 +1,188 @@ +import van from "vanjs-core"; +import type { + InteractionAnswerRequest, + InteractionResponse, +} from "@great-agent/web-contracts"; +import type { AppController } from "../../app/app-controller"; +import "./interaction-card.css"; + +const { button, div, form, h3, input, label, p, span, textarea } = van.tags; + +export function InteractionCard( + controller: AppController, + interaction: InteractionResponse, +): HTMLElement { + if (interaction.status === "resolved") + return ReadonlyCard(interaction, "已回答"); + if (interaction.status === "cancelled") + return ReadonlyCard(interaction, "已取消"); + const selected = van.state([]); + const text = van.state(""); + + async function submit(answer: InteractionAnswerRequest) { + await controller.answerInteraction(interaction.id, answer); + } + + return form( + { + class: "interaction-card pending", + onsubmit: (event: Event) => { + event.preventDefault(); + if (interaction.kind === "free_text") + void submit({ kind: "free_text", text: text.val }); + else void submit({ kind: "choice", selectedOptionIds: selected.val }); + }, + }, + Header(interaction, "等待回答"), + interaction.kind === "single_choice" || + interaction.kind === "multiple_choice" + ? div( + { class: "interaction-options" }, + (interaction.options ?? []).map((option) => + label( + input({ + type: + interaction.kind === "single_choice" ? "radio" : "checkbox", + name: interaction.id, + value: option.id, + checked: () => selected.val.includes(option.id), + onchange: (event: Event) => { + const checked = (event.target as HTMLInputElement).checked; + selected.val = + interaction.kind === "single_choice" + ? checked + ? [option.id] + : [] + : checked + ? [...selected.val, option.id] + : selected.val.filter((id) => id !== option.id); + }, + }), + span(option.label), + ), + ), + ) + : null, + interaction.kind === "free_text" + ? textarea({ + "aria-label": "填写意见", + maxlength: 8_192, + placeholder: "输入你的意见…", + value: text, + oninput: (event: Event) => { + text.val = (event.target as HTMLTextAreaElement).value; + }, + }) + : null, + interaction.kind === "confirmation" + ? div( + { class: "confirmation-actions" }, + button( + { + type: "button", + class: "secondary", + disabled: controller.sending, + onclick: () => + void submit({ kind: "confirmation", confirmed: false }), + }, + "否", + ), + button( + { + type: "button", + class: "primary", + disabled: controller.sending, + onclick: () => + void submit({ kind: "confirmation", confirmed: true }), + }, + "确认", + ), + ) + : div( + { class: "interaction-actions" }, + button( + { + type: "button", + class: "cancel", + disabled: controller.sending, + onclick: () => + void controller.cancelInteraction(interaction.runId), + }, + "停止任务", + ), + button( + { + type: "submit", + class: "primary", + disabled: () => + controller.sending.val || + !canSubmit(interaction, selected.val, text.val), + }, + "提交回答", + ), + ), + interaction.kind === "confirmation" + ? button( + { + type: "button", + class: "cancel confirmation-cancel", + disabled: controller.sending, + onclick: () => void controller.cancelInteraction(interaction.runId), + }, + "停止任务", + ) + : null, + ); +} + +function Header(interaction: InteractionResponse, status: string): HTMLElement { + return div( + { class: "interaction-header" }, + div( + span({ class: "interaction-mark" }, "?"), + div( + h3(interaction.question), + interaction.description ? p(interaction.description) : null, + ), + ), + span({ class: "interaction-status" }, status), + ); +} + +function ReadonlyCard( + interaction: InteractionResponse, + status: string, +): HTMLElement { + return div( + { class: `interaction-card ${interaction.status}` }, + Header(interaction, status), + interaction.answer + ? div({ class: "interaction-answer" }, answerText(interaction)) + : null, + ); +} + +function answerText(interaction: InteractionResponse): string { + const answer = interaction.answer; + if (!answer) return ""; + if (answer.kind === "confirmation") + return answer.confirmed ? "已确认" : "已否决"; + if (answer.kind === "free_text") return answer.text || "(未填写)"; + const labels = new Map( + interaction.options?.map((option) => [option.id, option.label]), + ); + return answer.selectedOptionIds.map((id) => labels.get(id) ?? id).join("、"); +} + +function canSubmit( + interaction: InteractionResponse, + selected: readonly string[], + text: string, +): boolean { + if (interaction.kind === "free_text") + return interaction.required ? Boolean(text.trim()) : true; + return ( + selected.length >= (interaction.minSelections ?? 1) && + selected.length <= (interaction.maxSelections ?? 1) + ); +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e8f237b..f83615b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -57,13 +57,17 @@ ### F-004——用户交互卡片 -- 状态:进行中 +- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核) - 用户可见结果:Agent 请求单选、多选、确认或意见输入,回答后继续同一 Run。 - 主要验收:AC-034 至 AC-038。 +- 实现结果:DeepSeek 工具调用被转换为独立 `interaction.*` 领域事件;交互请求按 Run 持久化,回答后使用原 `runId` 和 `toolCallId` 继续模型请求,不创建新 Run 或用户消息。支持单选、多选、确认和自由文本,包含选项数量、选择上下限、未知选项、重复回答及 8 KiB 文本校验。 +- 页面结果:消息时间线按交互类型显示四类 VanJS 卡片;等待回答时禁用普通输入;回答后保留只读问题和答案;等待中的任务可从卡片停止并显示取消状态。 +- 自动化验证结果:2026-08-14 通过全仓类型检查、21 项测试和 75 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;Fake Model 验证暂停和恢复前后 Run 标识不变、事件序列连续。 +- 人工操作结果:2026-08-14 使用隔离数据目录和本机应用内浏览器验证四类卡片、提交条件、只读答案、取消状态及刷新恢复;页面控制台无错误。 ### F-005——停止、失败与重试 -- 状态:待开始 +- 状态:进行中 - 用户可见结果:停止当前生成并从失败状态重试。 - 主要验收:AC-005、AC-010、AC-011。 diff --git a/packages/agent-core/src/domain/agent-run.ts b/packages/agent-core/src/domain/agent-run.ts index dd789f2..6ca255a 100644 --- a/packages/agent-core/src/domain/agent-run.ts +++ b/packages/agent-core/src/domain/agent-run.ts @@ -1,4 +1,9 @@ -export type AgentRunStatus = "running" | "completed" | "failed"; +export type AgentRunStatus = + | "running" + | "waiting_user" + | "completed" + | "failed" + | "cancelled"; export type AgentRun = Readonly<{ id: string; @@ -19,7 +24,11 @@ export type RunEvent = Readonly<{ | "message.started" | "message.delta" | "message.completed" + | "interaction.requested" + | "interaction.resolved" + | "interaction.cancelled" | "run.completed" - | "run.failed"; + | "run.failed" + | "run.cancelled"; payload: Record; }>; diff --git a/packages/agent-core/src/domain/user-interaction.ts b/packages/agent-core/src/domain/user-interaction.ts new file mode 100644 index 0000000..8823ad5 --- /dev/null +++ b/packages/agent-core/src/domain/user-interaction.ts @@ -0,0 +1,35 @@ +export type InteractionKind = + | "single_choice" + | "multiple_choice" + | "confirmation" + | "free_text"; + +export type InteractionStatus = "pending" | "resolved" | "cancelled"; + +export type InteractionOption = Readonly<{ id: string; label: string }>; + +export type InteractionAnswer = + | Readonly<{ kind: "choice"; selectedOptionIds: readonly string[] }> + | Readonly<{ kind: "confirmation"; confirmed: boolean }> + | Readonly<{ kind: "free_text"; text: string }>; + +export type UserInteraction = Readonly<{ + id: string; + runId: string; + conversationId: string; + messageId: string; + toolCallId: string; + toolArguments: string; + kind: InteractionKind; + question: string; + description?: string; + options?: readonly InteractionOption[]; + required: boolean; + minSelections?: number; + maxSelections?: number; + status: InteractionStatus; + answer?: InteractionAnswer; + createdAt: string; + resolvedAt?: string; + cancelledAt?: string; +}>; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 27ddbd9..17b3593 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -7,10 +7,23 @@ export type { } from "./domain/conversation"; export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run"; export type { Project, ProjectView } from "./domain/project"; +export type { + InteractionAnswer, + InteractionKind, + InteractionOption, + InteractionStatus, + UserInteraction, +} from "./domain/user-interaction"; export type { ConversationRepository } from "./ports/conversation-repository"; -export type { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port"; +export type { + ModelEvent, + ModelPort, + ModelRequest, + ModelTool, +} from "./ports/model-port"; export type { RunRepository } from "./ports/run-repository"; export type { ProjectRepository } from "./ports/project-repository"; +export type { InteractionRepository } from "./ports/interaction-repository"; export type { ClockPort, IdPort, @@ -29,3 +42,7 @@ export { ProjectService, type ProjectServiceDependencies, } from "./use-cases/project-service"; +export { + InteractionService, + type InteractionServiceDependencies, +} from "./use-cases/interaction-service"; diff --git a/packages/agent-core/src/ports/interaction-repository.ts b/packages/agent-core/src/ports/interaction-repository.ts new file mode 100644 index 0000000..e18f70d --- /dev/null +++ b/packages/agent-core/src/ports/interaction-repository.ts @@ -0,0 +1,11 @@ +import type { UserInteraction } from "../domain/user-interaction"; + +export interface InteractionRepository { + create(interaction: UserInteraction): Promise; + update(interaction: UserInteraction): Promise; + getById(id: string): Promise; + listByConversation( + conversationId: string, + ): Promise; + findPendingByRun(runId: string): Promise; +} diff --git a/packages/agent-core/src/ports/model-port.ts b/packages/agent-core/src/ports/model-port.ts index 9a714fe..b6caeda 100644 --- a/packages/agent-core/src/ports/model-port.ts +++ b/packages/agent-core/src/ports/model-port.ts @@ -3,10 +3,29 @@ import type { Message } from "../domain/conversation"; export type ModelRequest = Readonly<{ model: string; messages: readonly Message[]; + tools?: readonly ModelTool[]; + continuation?: Readonly<{ + toolCallId: string; + toolName: string; + toolArguments: string; + result: string; + }>; +}>; + +export type ModelTool = Readonly<{ + name: string; + description: string; + parameters: Record; }>; export type ModelEvent = | Readonly<{ type: "text.delta"; delta: string }> + | Readonly<{ + type: "tool.requested"; + toolCallId: string; + name: string; + arguments: string; + }> | Readonly<{ type: "response.completed" }>; export interface ModelPort { 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 99c23a1..6792c77 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,19 @@ import type { RunEvent, RunRepository, } from ".."; -import { AgentRunService, ConversationService, type ProjectService } from ".."; +import { + AgentRunService, + ConversationService, + type InteractionService, + type ProjectService, +} from ".."; const projects = { requireAvailable: async () => { throw new Error("unused"); }, } as unknown as ProjectService; +const interactions = {} as InteractionService; class MemoryConversationRepository implements ConversationRepository { readonly values = new Map(); @@ -96,6 +102,7 @@ describe("AgentRunService", () => { { now: () => new Date("2026-08-12T00:00:00.000Z") }, { create: () => `id_${++nextId}` }, projects, + interactions, ); const started = await service.start( { kind: "ordinary", message: "开始" }, @@ -148,6 +155,7 @@ describe("AgentRunService", () => { { now: () => new Date() }, { create: () => `id_${++id}` }, projects, + interactions, ); 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 74929d4..2854781 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.ts @@ -1,10 +1,12 @@ 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 } from "../ports/model-port"; +import type { ModelPort, ModelRequest, ModelTool } 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 { InteractionService } from "./interaction-service"; import type { ProjectService } from "./project-service"; export type StartRunInput = @@ -17,6 +19,36 @@ export type StartedRun = Readonly<{ events: AsyncIterable; }>; +const interactionTool: ModelTool = { + name: "request_user_interaction", + description: "需要用户选择、确认或补充意见时调用。调用后等待用户回答。", + parameters: { + type: "object", + required: ["kind", "question"], + properties: { + kind: { + type: "string", + enum: ["single_choice", "multiple_choice", "confirmation", "free_text"], + }, + question: { type: "string" }, + description: { type: "string" }, + required: { type: "boolean" }, + options: { + type: "array", + minItems: 2, + maxItems: 10, + items: { + type: "object", + required: ["id", "label"], + properties: { id: { type: "string" }, label: { type: "string" } }, + }, + }, + minSelections: { type: "integer", minimum: 0 }, + maxSelections: { type: "integer", minimum: 1 }, + }, + }, +}; + export class AgentRunService { constructor( private readonly conversations: ConversationService, @@ -25,35 +57,12 @@ export class AgentRunService { private readonly clock: ClockPort, private readonly ids: IdPort, private readonly projects: ProjectService, + private readonly interactions: InteractionService, ) {} async start(input: StartRunInput, signal: AbortSignal): Promise { const runId = this.ids.create(); - 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 conversation = await this.prepareConversation(input, runId); const timestamp = this.clock.now().toISOString(); const triggerMessage = conversation.messages.at(-1); if (triggerMessage?.role !== "user") @@ -67,30 +76,123 @@ export class AgentRunService { updatedAt: timestamp, }; await this.runs.create(run); - return { run, events: this.execute(run, signal) }; + return { run, events: this.execute(run, signal, 0) }; + } + + async resume( + interaction: UserInteraction, + signal: AbortSignal, + ): Promise { + const current = await this.requireRun(interaction.runId); + if (current.status !== "waiting_user") + throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答"); + if (!interaction.answer) + throw new CoreError("INTERACTION_INVALID", "交互答案不存在"); + const run = { + ...current, + status: "running" as const, + updatedAt: this.clock.now().toISOString(), + }; + await this.runs.update(run); + const sequence = lastSequence(await this.runs.listEvents(run.id)); + const continuation: NonNullable = { + toolCallId: interaction.toolCallId, + toolName: "request_user_interaction", + toolArguments: interaction.toolArguments, + result: JSON.stringify(interaction.answer), + }; + return { + run, + events: this.execute(run, signal, sequence, continuation, interaction), + }; + } + + async cancelWaiting(runId: string): Promise { + const run = await this.requireRun(runId); + if (run.status === "cancelled") return []; + if (run.status !== "waiting_user") + throw new CoreError("RUN_NOT_WAITING", "该任务当前不在等待用户回答"); + const interaction = await this.interactions.cancelPending(runId); + if (!interaction) + throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在"); + let sequence = lastSequence(await this.runs.listEvents(run.id)); + const cancelled = await this.persistEvent( + run.id, + ++sequence, + "interaction.cancelled", + { interactionId: interaction.id }, + ); + await this.runs.update({ + ...run, + status: "cancelled", + updatedAt: this.clock.now().toISOString(), + }); + const finished = await this.persistEvent( + run.id, + ++sequence, + "run.cancelled", + {}, + ); + return [cancelled, finished]; + } + + async lastSequence(runId: string): Promise { + await this.requireRun(runId); + return lastSequence(await this.runs.listEvents(runId)); + } + + getRun(runId: string): Promise { + return this.requireRun(runId); + } + + 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, + resolvedInteraction?: UserInteraction, ): AsyncIterable { - let sequence = 0; + let sequence = initialSequence; const event = async ( type: RunEvent["type"], payload: Record, - ) => { - const value: RunEvent = { - runId: run.id, - sequence: ++sequence, - timestamp: this.clock.now().toISOString(), - type, - payload, - }; - await this.runs.appendEvent(value); - return value; - }; + ) => this.persistEvent(run.id, ++sequence, type, payload); try { - yield await event("run.started", { conversationId: run.conversationId }); + if (!continuation) + yield await event("run.started", { + conversationId: run.conversationId, + }); + if (resolvedInteraction) + yield await event("interaction.resolved", { + interactionId: resolvedInteraction.id, + answer: resolvedInteraction.answer, + }); const messageId = this.ids.create(); yield await event("message.started", { messageId }); const conversation = await this.conversations.getConversation( @@ -98,15 +200,50 @@ export class AgentRunService { ); let content = ""; for await (const modelEvent of this.model.stream( - { model: "default", messages: conversation.messages }, + { + model: "default", + messages: conversation.messages, + tools: [interactionTool], + ...(continuation ? { continuation } : {}), + }, signal, )) { - if (modelEvent.type !== "text.delta") continue; - content += modelEvent.delta; - yield await event("message.delta", { - messageId, - delta: modelEvent.delta, - }); + 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", + "模型请求了尚未支持的工具", + ); + await this.conversations.appendAssistantMessage( + run.conversationId, + messageId, + run.id, + content, + ); + 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 (!content) throw new CoreError("MODEL_RESPONSE_INVALID", "模型没有返回有效内容"); @@ -138,8 +275,34 @@ export class AgentRunService { }); } } + + 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; + } } +function lastSequence(events: readonly RunEvent[]): number { + return events.at(-1)?.sequence ?? 0; +} function safeErrorMessage(cause: unknown): string { return cause instanceof CoreError ? cause.message : "模型服务暂时不可用"; } diff --git a/packages/agent-core/src/use-cases/interaction-run.test.ts b/packages/agent-core/src/use-cases/interaction-run.test.ts new file mode 100644 index 0000000..ad31e85 --- /dev/null +++ b/packages/agent-core/src/use-cases/interaction-run.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; +import type { + AgentRun, + Conversation, + ConversationRepository, + InteractionRepository, + ModelPort, + ProjectService, + RunEvent, + RunRepository, + UserInteraction, +} from ".."; +import { AgentRunService, ConversationService, InteractionService } from ".."; + +class Conversations implements ConversationRepository { + values = new Map(); + async listRecent() { + return [...this.values.values()]; + } + 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]) { + const current = this.values.get(id); + if (!current) throw new Error("not found"); + const next = { + ...current, + messages: [...current.messages, message], + updatedAt: message.createdAt, + }; + this.values.set(id, next); + return next; + } + async deleteByProject() {} +} +class Runs implements RunRepository { + values = new Map(); + events: RunEvent[] = []; + async create(value: AgentRun) { + this.values.set(value.id, value); + } + async update(value: AgentRun) { + this.values.set(value.id, value); + } + async getById(id: string) { + return this.values.get(id) ?? null; + } + async appendEvent(event: RunEvent) { + this.events.push(event); + } + async listEvents(id: string) { + return this.events.filter((event) => event.runId === id); + } + async hasActiveForConversations() { + return false; + } + async deleteByConversations() {} +} +class Interactions implements InteractionRepository { + values = new Map(); + async create(value: UserInteraction) { + this.values.set(value.id, value); + } + async update(value: UserInteraction) { + this.values.set(value.id, value); + } + async getById(id: string) { + return this.values.get(id) ?? null; + } + async listByConversation(id: string) { + return [...this.values.values()].filter( + (value) => value.conversationId === id, + ); + } + async findPendingByRun(id: string) { + return ( + [...this.values.values()].find( + (value) => value.runId === id && value.status === "pending", + ) ?? null + ); + } +} + +describe("等待用户的 Run", () => { + test("回答交互后使用同一个 Run 和 toolCallId 继续生成", async () => { + const fixture = createFixture(); + const started = await fixture.service.start( + { kind: "ordinary", message: "请让我选择" }, + new AbortController().signal, + ); + const firstEvents = await collect(started.events); + expect(firstEvents.at(-1)?.type).toBe("interaction.requested"); + expect(fixture.runs.values.get(started.run.id)?.status).toBe( + "waiting_user", + ); + const interaction = [...fixture.interactions.values.values()][0]; + expect(interaction?.status).toBe("pending"); + if (!interaction) throw new Error("interaction missing"); + const resolved = await fixture.interactionService.resolve(interaction.id, { + kind: "choice", + selectedOptionIds: ["simple"], + }); + const resumed = await fixture.service.resume( + resolved.interaction, + new AbortController().signal, + ); + const nextEvents = await collect(resumed.events); + expect(resumed.run.id).toBe(started.run.id); + expect(nextEvents[0]?.type).toBe("interaction.resolved"); + expect(nextEvents.at(-1)?.type).toBe("run.completed"); + expect( + new Set(fixture.runs.events.map((event) => event.sequence)).size, + ).toBe(fixture.runs.events.length); + expect( + ( + await fixture.conversationService.getConversation( + started.run.conversationId, + ) + ).messages.at(-1)?.content, + ).toBe("已按你的选择继续完成"); + }); + + test("等待回答时可以取消,卡片和 Run 都进入取消终态", async () => { + const fixture = createFixture(); + const started = await fixture.service.start( + { kind: "ordinary", message: "请让我选择" }, + new AbortController().signal, + ); + await collect(started.events); + const events = await fixture.service.cancelWaiting(started.run.id); + expect(events.map((event) => event.type)).toEqual([ + "interaction.cancelled", + "run.cancelled", + ]); + expect(fixture.runs.values.get(started.run.id)?.status).toBe("cancelled"); + expect([...fixture.interactions.values.values()][0]?.status).toBe( + "cancelled", + ); + }); +}); + +function createFixture() { + let id = 0; + const clock = { now: () => new Date("2026-08-14T00:00:00Z") }; + const ids = { create: () => `id_${++id}` }; + const conversations = new Conversations(); + const runs = new Runs(); + const interactions = new Interactions(); + const conversationService = new ConversationService({ + conversations, + clock, + ids, + }); + const interactionService = new InteractionService({ + interactions, + clock, + ids, + }); + const model: ModelPort = { + async *stream(request) { + if (!request.continuation) { + yield { + type: "tool.requested", + toolCallId: "tool_choice", + name: "request_user_interaction", + arguments: JSON.stringify({ + kind: "single_choice", + question: "选择实现方式", + options: [ + { id: "simple", label: "简化" }, + { id: "full", label: "完整" }, + ], + }), + }; + } else { + expect(request.continuation.toolCallId).toBe("tool_choice"); + expect(request.continuation.result).toContain("simple"); + yield { type: "text.delta", delta: "已按你的选择继续完成" }; + yield { type: "response.completed" }; + } + }, + }; + const service = new AgentRunService( + conversationService, + model, + runs, + clock, + ids, + {} as ProjectService, + interactionService, + ); + return { + service, + runs, + interactions, + conversationService, + interactionService, + }; +} +async function collect(events: AsyncIterable) { + const values: RunEvent[] = []; + for await (const event of events) values.push(event); + return values; +} diff --git a/packages/agent-core/src/use-cases/interaction-service.test.ts b/packages/agent-core/src/use-cases/interaction-service.test.ts new file mode 100644 index 0000000..922233d --- /dev/null +++ b/packages/agent-core/src/use-cases/interaction-service.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import type { InteractionRepository, UserInteraction } from ".."; +import { InteractionService } from ".."; + +class Interactions implements InteractionRepository { + values = new Map(); + async create(value: UserInteraction) { + this.values.set(value.id, value); + } + async update(value: UserInteraction) { + this.values.set(value.id, value); + } + async getById(id: string) { + return this.values.get(id) ?? null; + } + async listByConversation(id: string) { + return [...this.values.values()].filter( + (value) => value.conversationId === id, + ); + } + async findPendingByRun(id: string) { + return ( + [...this.values.values()].find( + (value) => value.runId === id && value.status === "pending", + ) ?? null + ); + } +} + +describe("InteractionService", () => { + test("校验多选限制并保证相同答案幂等", async () => { + const service = createService(); + const interaction = await service.create( + input( + JSON.stringify({ + kind: "multiple_choice", + question: "选择两个方向", + options: [ + { id: "a", label: "方向 A" }, + { id: "b", label: "方向 B" }, + { id: "c", label: "方向 C" }, + ], + minSelections: 2, + maxSelections: 2, + }), + ), + ); + expect( + service.resolve(interaction.id, { + kind: "choice", + selectedOptionIds: ["a"], + }), + ).rejects.toMatchObject({ code: "INTERACTION_INVALID" }); + expect( + service.resolve(interaction.id, { + kind: "choice", + selectedOptionIds: ["a", "unknown"], + }), + ).rejects.toMatchObject({ code: "INTERACTION_INVALID" }); + const answer = { kind: "choice" as const, selectedOptionIds: ["a", "b"] }; + expect((await service.resolve(interaction.id, answer)).changed).toBe(true); + expect((await service.resolve(interaction.id, answer)).changed).toBe(false); + expect( + service.resolve(interaction.id, { + kind: "choice", + selectedOptionIds: ["b", "c"], + }), + ).rejects.toMatchObject({ code: "INTERACTION_ALREADY_RESOLVED" }); + }); + + test("支持单选、确认和最多 8 KiB 的自由文本", async () => { + const service = createService(); + const single = await service.create( + input( + JSON.stringify({ + kind: "single_choice", + question: "选一个", + options: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ], + }), + ), + ); + const confirmation = await service.create({ + ...input(JSON.stringify({ kind: "confirmation", question: "确认吗" })), + toolCallId: "tool_2", + }); + const freeText = await service.create({ + ...input(JSON.stringify({ kind: "free_text", question: "有什么意见" })), + toolCallId: "tool_3", + }); + await expect( + service.resolve(single.id, { kind: "choice", selectedOptionIds: ["a"] }), + ).resolves.toBeDefined(); + await expect( + service.resolve(confirmation.id, { + kind: "confirmation", + confirmed: true, + }), + ).resolves.toBeDefined(); + await expect( + service.resolve(freeText.id, { + kind: "free_text", + text: "x".repeat(8_193), + }), + ).rejects.toMatchObject({ code: "INTERACTION_INVALID" }); + }); +}); + +function createService() { + let id = 0; + return new InteractionService({ + interactions: new Interactions(), + clock: { now: () => new Date("2026-08-14T00:00:00Z") }, + ids: { create: () => `interaction_${++id}` }, + }); +} +function input(toolArguments: string) { + return { + runId: "run_1", + conversationId: "conversation_1", + messageId: "message_1", + toolCallId: `tool_${toolArguments.length}`, + toolArguments, + }; +} diff --git a/packages/agent-core/src/use-cases/interaction-service.ts b/packages/agent-core/src/use-cases/interaction-service.ts new file mode 100644 index 0000000..ad11b26 --- /dev/null +++ b/packages/agent-core/src/use-cases/interaction-service.ts @@ -0,0 +1,215 @@ +import type { + InteractionAnswer, + InteractionKind, + InteractionOption, + UserInteraction, +} from "../domain/user-interaction"; +import { CoreError } from "../errors/core-error"; +import type { InteractionRepository } from "../ports/interaction-repository"; +import type { ClockPort, IdPort } from "../ports/system-ports"; + +export type InteractionServiceDependencies = Readonly<{ + interactions: InteractionRepository; + clock: ClockPort; + ids: IdPort; +}>; + +export class InteractionService { + constructor(private readonly dependencies: InteractionServiceDependencies) {} + + listByConversation(conversationId: string) { + return this.dependencies.interactions.listByConversation(conversationId); + } + + async get(id: string): Promise { + const interaction = await this.dependencies.interactions.getById(id); + if (!interaction) + throw new CoreError("INTERACTION_NOT_FOUND", "交互请求不存在"); + return interaction; + } + + async create(input: { + runId: string; + conversationId: string; + messageId: string; + toolCallId: string; + toolArguments: string; + }): Promise { + const interaction: UserInteraction = { + id: this.dependencies.ids.create(), + ...input, + ...parseSpecification(input.toolArguments), + status: "pending", + createdAt: this.dependencies.clock.now().toISOString(), + }; + await this.dependencies.interactions.create(interaction); + return interaction; + } + + async resolve( + id: string, + answer: InteractionAnswer, + ): Promise<{ interaction: UserInteraction; changed: boolean }> { + const current = await this.get(id); + if (current.status === "cancelled") + throw new CoreError("INTERACTION_CANCELLED", "该交互请求已取消"); + validateAnswer(current, answer); + if (current.status === "resolved") { + if (JSON.stringify(current.answer) === JSON.stringify(answer)) + return { interaction: current, changed: false }; + throw new CoreError( + "INTERACTION_ALREADY_RESOLVED", + "该交互请求已经回答,不能修改答案", + ); + } + const interaction: UserInteraction = { + ...current, + status: "resolved", + answer, + resolvedAt: this.dependencies.clock.now().toISOString(), + }; + await this.dependencies.interactions.update(interaction); + return { interaction, changed: true }; + } + + async cancelPending(runId: string): Promise { + const current = + await this.dependencies.interactions.findPendingByRun(runId); + if (!current) return null; + const interaction: UserInteraction = { + ...current, + status: "cancelled", + cancelledAt: this.dependencies.clock.now().toISOString(), + }; + await this.dependencies.interactions.update(interaction); + return interaction; + } +} + +type Specification = Pick< + UserInteraction, + | "kind" + | "question" + | "description" + | "options" + | "required" + | "minSelections" + | "maxSelections" +>; + +function parseSpecification(argumentsText: string): Specification { + let value: unknown; + try { + value = JSON.parse(argumentsText); + } catch { + throw invalid("交互参数不是有效 JSON"); + } + if (!isRecord(value)) throw invalid("交互参数必须是对象"); + const kind = value.kind; + if (!isKind(kind)) throw invalid("交互类型无效"); + const question = readText(value.question, "问题", 500); + const description = readOptionalText(value.description, "说明", 2_000); + const required = value.required === undefined ? true : value.required; + if (typeof required !== "boolean") throw invalid("必填标记无效"); + if (kind === "single_choice" || kind === "multiple_choice") { + const options = readOptions(value.options); + const minSelections = + kind === "single_choice" ? 1 : readInteger(value.minSelections, 1); + const maxSelections = + kind === "single_choice" + ? 1 + : readInteger(value.maxSelections, options.length); + if ( + minSelections < 0 || + maxSelections < minSelections || + maxSelections > options.length + ) + throw invalid("多选数量限制无效"); + return { + kind, + question, + ...(description ? { description } : {}), + options, + required, + minSelections, + maxSelections, + }; + } + return { kind, question, ...(description ? { description } : {}), required }; +} + +function validateAnswer( + interaction: UserInteraction, + answer: InteractionAnswer, +) { + if (interaction.kind === "confirmation") { + if (answer.kind !== "confirmation") throw invalid("确认答案格式无效"); + return; + } + if (interaction.kind === "free_text") { + if (answer.kind !== "free_text") throw invalid("意见答案格式无效"); + const text = answer.text.trim(); + if (text.length > 8_192) throw invalid("意见内容不能超过 8 KiB"); + if (interaction.required && !text) throw invalid("请填写意见"); + return; + } + if (answer.kind !== "choice") throw invalid("选项答案格式无效"); + const selected = [...new Set(answer.selectedOptionIds)]; + if (selected.length !== answer.selectedOptionIds.length) + throw invalid("不能重复选择同一选项"); + const optionIds = new Set(interaction.options?.map((option) => option.id)); + if (selected.some((id) => !optionIds.has(id))) + throw invalid("答案包含未知选项"); + if (selected.length < (interaction.minSelections ?? 0)) + throw invalid("选择数量不足"); + if (selected.length > (interaction.maxSelections ?? 1)) + throw invalid("选择数量过多"); +} + +function readOptions(value: unknown): readonly InteractionOption[] { + if (!Array.isArray(value) || value.length < 2 || value.length > 10) + throw invalid("单选和多选必须提供 2 至 10 个选项"); + const options = value.map((item) => { + if (!isRecord(item)) throw invalid("选项格式无效"); + return { + id: readText(item.id, "选项标识", 100), + label: readText(item.label, "选项文字", 300), + }; + }); + if (new Set(options.map((option) => option.id)).size !== options.length) + throw invalid("选项标识不能重复"); + return options; +} +function readText(value: unknown, name: string, max: number): string { + if (typeof value !== "string" || !value.trim() || value.trim().length > max) + throw invalid(`${name}无效`); + return value.trim(); +} +function readOptionalText( + value: unknown, + name: string, + max: number, +): string | undefined { + return value === undefined ? undefined : readText(value, name, max); +} +function readInteger(value: unknown, fallback: number): number { + return value === undefined + ? fallback + : typeof value === "number" && Number.isInteger(value) + ? value + : Number.NaN; +} +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isKind(value: unknown): value is InteractionKind { + return ( + value === "single_choice" || + value === "multiple_choice" || + value === "confirmation" || + value === "free_text" + ); +} +function invalid(message: string): CoreError { + return new CoreError("INTERACTION_INVALID", message); +} diff --git a/packages/local-data/src/index.ts b/packages/local-data/src/index.ts index b9d14d7..3e1cb78 100644 --- a/packages/local-data/src/index.ts +++ b/packages/local-data/src/index.ts @@ -9,3 +9,4 @@ export { export { FileConversationRepository } from "./repositories/file-conversation-repository"; export { FileRunRepository } from "./repositories/file-run-repository"; export { FileProjectRepository } from "./repositories/file-project-repository"; +export { FileInteractionRepository } from "./repositories/file-interaction-repository"; diff --git a/packages/local-data/src/repositories/file-interaction-repository.test.ts b/packages/local-data/src/repositories/file-interaction-repository.test.ts new file mode 100644 index 0000000..246c959 --- /dev/null +++ b/packages/local-data/src/repositories/file-interaction-repository.test.ts @@ -0,0 +1,45 @@ +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 type { UserInteraction } from "@great-agent/agent-core"; +import { createDataLayout, ensureDataLayout } from "../layout/data-layout"; +import { FileInteractionRepository } from "./file-interaction-repository"; + +describe("FileInteractionRepository", () => { + test("在所属 Run 下持久化并恢复交互状态", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-interactions-")); + try { + const layout = createDataLayout(root); + await ensureDataLayout(layout); + const repository = new FileInteractionRepository(layout); + const pending: UserInteraction = { + id: "interaction_1", + runId: "run_1", + conversationId: "conversation_1", + messageId: "message_1", + toolCallId: "tool_1", + toolArguments: "{}", + kind: "confirmation", + question: "确认吗?", + required: true, + status: "pending", + createdAt: "2026-08-14T00:00:00Z", + }; + await repository.create(pending); + expect(await repository.findPendingByRun("run_1")).toEqual(pending); + await repository.update({ + ...pending, + status: "resolved", + answer: { kind: "confirmation", confirmed: true }, + resolvedAt: "2026-08-14T00:01:00Z", + }); + expect( + (await repository.listByConversation("conversation_1"))[0]?.status, + ).toBe("resolved"); + expect(await repository.findPendingByRun("run_1")).toBeNull(); + } finally { + await rm(root, { recursive: true }); + } + }); +}); diff --git a/packages/local-data/src/repositories/file-interaction-repository.ts b/packages/local-data/src/repositories/file-interaction-repository.ts new file mode 100644 index 0000000..88326d1 --- /dev/null +++ b/packages/local-data/src/repositories/file-interaction-repository.ts @@ -0,0 +1,84 @@ +import { mkdir, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { + InteractionRepository, + UserInteraction, +} from "@great-agent/agent-core"; +import { writeJsonAtomically } from "../atomic-writes/write-json-atomically"; +import type { DataLayout } from "../layout/data-layout"; + +export class FileInteractionRepository implements InteractionRepository { + constructor(private readonly layout: DataLayout) {} + + async create(interaction: UserInteraction): Promise { + if (await this.getById(interaction.id)) throw new Error("交互标识已经存在"); + await this.update(interaction); + } + + async update(interaction: UserInteraction): Promise { + const directory = this.directory(interaction.runId); + await mkdir(directory, { recursive: true }); + await writeJsonAtomically( + join(directory, `${interaction.id}.json`), + interaction, + ); + } + + async getById(id: string): Promise { + for (const interaction of await this.readAll()) + if (interaction.id === id) return interaction; + return null; + } + + async listByConversation( + conversationId: string, + ): Promise { + return (await this.readAll()) + .filter((interaction) => interaction.conversationId === conversationId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + + async findPendingByRun(runId: string): Promise { + const values = await this.readRun(runId); + return ( + values.find((interaction) => interaction.status === "pending") ?? null + ); + } + + private async readAll(): Promise { + await mkdir(this.layout.runs, { recursive: true }); + const entries = await readdir(this.layout.runs, { withFileTypes: true }); + return ( + await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => this.readRun(entry.name)), + ) + ).flat(); + } + + private async readRun(runId: string): Promise { + const directory = this.directory(runId); + try { + const names = await readdir(directory); + return Promise.all( + names + .filter((name) => name.endsWith(".json")) + .map( + async (name) => + JSON.parse( + await readFile(join(directory, name), "utf8"), + ) as UserInteraction, + ), + ); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return []; + throw error; + } + } + + private directory(runId: string): string { + return join(this.layout.runs, runId, "interactions"); + } +} diff --git a/packages/local-data/src/repositories/file-run-repository.ts b/packages/local-data/src/repositories/file-run-repository.ts index c378fd1..2eb5202 100644 --- a/packages/local-data/src/repositories/file-run-repository.ts +++ b/packages/local-data/src/repositories/file-run-repository.ts @@ -58,7 +58,9 @@ export class FileRunRepository implements RunRepository { if (conversationIds.length === 0) return false; const targets = new Set(conversationIds); return (await this.readAll()).some( - (run) => targets.has(run.conversationId) && run.status === "running", + (run) => + targets.has(run.conversationId) && + (run.status === "running" || run.status === "waiting_user"), ); } diff --git a/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts b/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts index e7cdb16..04ea66d 100644 --- a/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts +++ b/packages/model-deepseek/src/adapter/deepseek-model-adapter.ts @@ -29,19 +29,85 @@ export class DeepSeekModelAdapter implements ModelPort { const response = await this.client.chat.completions.create( { model: request.model === "default" ? this.options.model : request.model, - messages: request.messages.map(toModelMessage), + messages: toModelMessages(request), + ...(request.tools + ? { + tools: request.tools.map((tool) => ({ + type: "function" as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })), + } + : {}), stream: true, }, { signal }, ); + const toolCalls = new Map< + number, + { id: string; name: string; arguments: string } + >(); for await (const chunk of response) { - const delta = chunk.choices[0]?.delta.content; + const choice = chunk.choices[0]?.delta; + const delta = choice?.content; if (delta) yield { type: "text.delta", delta }; + for (const call of choice?.tool_calls ?? []) { + const current = toolCalls.get(call.index) ?? { + id: call.id ?? "", + name: call.function?.name ?? "", + arguments: "", + }; + toolCalls.set(call.index, { + id: call.id ?? current.id, + name: call.function?.name ?? current.name, + arguments: current.arguments + (call.function?.arguments ?? ""), + }); + } + } + for (const call of toolCalls.values()) { + yield { + type: "tool.requested", + toolCallId: call.id, + name: call.name, + arguments: call.arguments, + }; } yield { type: "response.completed" }; } } +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, + }, + }, + ], + }, + { + role: "tool", + tool_call_id: request.continuation.toolCallId, + content: request.continuation.result, + }, + ]; +} + function toModelMessage( message: Message, ): OpenAI.Chat.Completions.ChatCompletionMessageParam { diff --git a/packages/web-contracts/src/events/run-event.ts b/packages/web-contracts/src/events/run-event.ts index af5f06d..bd705d3 100644 --- a/packages/web-contracts/src/events/run-event.ts +++ b/packages/web-contracts/src/events/run-event.ts @@ -9,8 +9,12 @@ export const runEventSchema = z.object({ "message.started", "message.delta", "message.completed", + "interaction.requested", + "interaction.resolved", + "interaction.cancelled", "run.completed", "run.failed", + "run.cancelled", ]), payload: z.record(z.string(), z.unknown()), }); diff --git a/packages/web-contracts/src/index.ts b/packages/web-contracts/src/index.ts index 02f9abb..3855c6e 100644 --- a/packages/web-contracts/src/index.ts +++ b/packages/web-contracts/src/index.ts @@ -17,3 +17,11 @@ export { messageSchema, } from "./responses/conversation"; export { projectSchema, type ProjectResponse } from "./responses/project"; +export { + interactionAnswerSchema, + interactionSchema, + resolvedInteractionSchema, + type InteractionAnswerRequest, + type InteractionResponse, + type ResolvedInteractionResponse, +} from "./responses/interaction"; diff --git a/packages/web-contracts/src/responses/interaction.ts b/packages/web-contracts/src/responses/interaction.ts new file mode 100644 index 0000000..c28ddb4 --- /dev/null +++ b/packages/web-contracts/src/responses/interaction.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const interactionAnswerSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("choice"), + selectedOptionIds: z.array(z.string().min(1)).max(10), + }), + z.object({ kind: z.literal("confirmation"), confirmed: z.boolean() }), + z.object({ kind: z.literal("free_text"), text: z.string().max(8_192) }), +]); + +export const interactionSchema = z.object({ + id: z.string(), + runId: z.string(), + conversationId: z.string(), + messageId: z.string(), + toolCallId: z.string(), + toolArguments: z.string(), + kind: z.enum([ + "single_choice", + "multiple_choice", + "confirmation", + "free_text", + ]), + question: z.string(), + description: z.string().optional(), + options: z.array(z.object({ id: z.string(), label: z.string() })).optional(), + required: z.boolean(), + minSelections: z.number().int().optional(), + maxSelections: z.number().int().optional(), + status: z.enum(["pending", "resolved", "cancelled"]), + answer: interactionAnswerSchema.optional(), + createdAt: z.string(), + resolvedAt: z.string().optional(), + cancelledAt: z.string().optional(), +}); + +export const resolvedInteractionSchema = z.object({ + interaction: interactionSchema, + resumed: z.boolean(), + resumeFromSequence: z.number().int().nonnegative(), +}); + +export type InteractionAnswerRequest = z.infer; +export type InteractionResponse = z.infer; +export type ResolvedInteractionResponse = z.infer< + typeof resolvedInteractionSchema +>;