feat: 用户交互卡片

This commit is contained in:
李岩岩 2026-08-14 14:54:59 +08:00
parent d4a4ec8001
commit 3a2c51ed67
33 changed files with 1698 additions and 80 deletions

View File

@ -35,10 +35,10 @@
状态: "已完成"
- 编号: "F-004"
名称: "用户交互卡片"
状态: "进行中"
状态: "已完成"
- 编号: "F-005"
名称: "停止、失败与重试"
状态: "待开始"
状态: "进行中"
- 编号: "F-006"
名称: "附件、文件列表、读取与搜索"
状态: "待开始"

View File

@ -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;
}

View File

@ -6,6 +6,7 @@ export class RunRegistry {
private readonly history = new Map<string, RunEvent[]>();
private readonly listeners = new Map<string, Set<Listener>>();
private readonly finished = new Set<string>();
private readonly paused = new Set<string>();
async publish(event: RunEvent): Promise<void> {
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<Listener>();

View File

@ -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(

View File

@ -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(

View File

@ -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;
}

View File

@ -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 }),

View File

@ -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<void>((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<RunEvent>,
registry: RunRegistry,
): Promise<void> {

View File

@ -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<InteractionResponse[]> {
return interactionSchema
.array()
.parse(
await request(
`/api/conversations/${encodeURIComponent(conversationId)}/interactions`,
),
);
}
export async function answerInteraction(
id: string,
answer: InteractionAnswerRequest,
): Promise<ResolvedInteractionResponse> {
return resolvedInteractionSchema.parse(
await request(`/api/interactions/${encodeURIComponent(id)}/response`, {
method: "POST",
body: JSON.stringify(answer),
}),
);
}
export async function cancelWaitingRun(runId: string): Promise<void> {
await request(`/api/runs/${encodeURIComponent(runId)}/cancel`, {
method: "POST",
});
}

View File

@ -26,6 +26,7 @@ export async function startRun(
export async function streamRun(
runId: string,
onEvent: (event: RunEventResponse) => void,
afterSequence = 0,
): Promise<void> {
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));
}

View File

@ -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<ProjectResponse[]>;
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
active: State<ConversationResponse | null>;
interactions: State<InteractionResponse[]>;
loading: State<boolean>;
sending: State<boolean>;
assistantDraft: State<string>;
@ -48,6 +56,11 @@ export type AppController = Readonly<{
deleteProject(id: string): Promise<void>;
openConversation(id: string): Promise<void>;
send(content: string): Promise<void>;
answerInteraction(
id: string,
answer: InteractionAnswerRequest,
): Promise<void>;
cancelInteraction(runId: string): Promise<void>;
}>;
export function createAppController(): AppController {
@ -58,6 +71,7 @@ export function createAppController(): AppController {
Record<string, ConversationSummaryResponse[]>
>({});
const active = van.state<ConversationResponse | null>(null);
const interactions = van.state<InteractionResponse[]>([]);
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<string, unknown>;
}) {
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,
};
}

View File

@ -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 (

View File

@ -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;
}

View File

@ -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<string[]>([]);
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)
);
}

View File

@ -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。

View File

@ -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<string, unknown>;
}>;

View File

@ -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;
}>;

View File

@ -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";

View File

@ -0,0 +1,11 @@
import type { UserInteraction } from "../domain/user-interaction";
export interface InteractionRepository {
create(interaction: UserInteraction): Promise<void>;
update(interaction: UserInteraction): Promise<void>;
getById(id: string): Promise<UserInteraction | null>;
listByConversation(
conversationId: string,
): Promise<readonly UserInteraction[]>;
findPendingByRun(runId: string): Promise<UserInteraction | null>;
}

View File

@ -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<string, unknown>;
}>;
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 {

View File

@ -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<string, Conversation>();
@ -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: "开始" },

View File

@ -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<RunEvent>;
}>;
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<StartedRun> {
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<StartedRun> {
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<ModelRequest["continuation"]> = {
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<readonly RunEvent[]> {
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<number> {
await this.requireRun(runId);
return lastSequence(await this.runs.listEvents(runId));
}
getRun(runId: string): Promise<AgentRun> {
return this.requireRun(runId);
}
private async prepareConversation(
input: StartRunInput,
runId: string,
): Promise<Conversation> {
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<ModelRequest["continuation"]>,
resolvedInteraction?: UserInteraction,
): AsyncIterable<RunEvent> {
let sequence = 0;
let sequence = initialSequence;
const event = async (
type: RunEvent["type"],
payload: Record<string, unknown>,
) => {
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<AgentRun> {
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<string, unknown>,
) {
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 : "模型服务暂时不可用";
}

View File

@ -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<string, Conversation>();
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<string, AgentRun>();
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<string, UserInteraction>();
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<RunEvent>) {
const values: RunEvent[] = [];
for await (const event of events) values.push(event);
return values;
}

View File

@ -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<string, UserInteraction>();
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,
};
}

View File

@ -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<UserInteraction> {
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<UserInteraction> {
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<UserInteraction | null> {
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<string, unknown> {
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);
}

View File

@ -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";

View File

@ -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 });
}
});
});

View File

@ -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<void> {
if (await this.getById(interaction.id)) throw new Error("交互标识已经存在");
await this.update(interaction);
}
async update(interaction: UserInteraction): Promise<void> {
const directory = this.directory(interaction.runId);
await mkdir(directory, { recursive: true });
await writeJsonAtomically(
join(directory, `${interaction.id}.json`),
interaction,
);
}
async getById(id: string): Promise<UserInteraction | null> {
for (const interaction of await this.readAll())
if (interaction.id === id) return interaction;
return null;
}
async listByConversation(
conversationId: string,
): Promise<readonly UserInteraction[]> {
return (await this.readAll())
.filter((interaction) => interaction.conversationId === conversationId)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
async findPendingByRun(runId: string): Promise<UserInteraction | null> {
const values = await this.readRun(runId);
return (
values.find((interaction) => interaction.status === "pending") ?? null
);
}
private async readAll(): Promise<UserInteraction[]> {
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<UserInteraction[]> {
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");
}
}

View File

@ -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"),
);
}

View File

@ -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 {

View File

@ -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()),
});

View File

@ -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";

View File

@ -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<typeof interactionAnswerSchema>;
export type InteractionResponse = z.infer<typeof interactionSchema>;
export type ResolvedInteractionResponse = z.infer<
typeof resolvedInteractionSchema
>;