feat: 用户交互卡片
This commit is contained in:
parent
d4a4ec8001
commit
3a2c51ed67
@ -35,10 +35,10 @@
|
|||||||
状态: "已完成"
|
状态: "已完成"
|
||||||
- 编号: "F-004"
|
- 编号: "F-004"
|
||||||
名称: "用户交互卡片"
|
名称: "用户交互卡片"
|
||||||
状态: "进行中"
|
状态: "已完成"
|
||||||
- 编号: "F-005"
|
- 编号: "F-005"
|
||||||
名称: "停止、失败与重试"
|
名称: "停止、失败与重试"
|
||||||
状态: "待开始"
|
状态: "进行中"
|
||||||
- 编号: "F-006"
|
- 编号: "F-006"
|
||||||
名称: "附件、文件列表、读取与搜索"
|
名称: "附件、文件列表、读取与搜索"
|
||||||
状态: "待开始"
|
状态: "待开始"
|
||||||
|
|||||||
@ -8,10 +8,12 @@ import type {
|
|||||||
AgentRunService,
|
AgentRunService,
|
||||||
ConversationService,
|
ConversationService,
|
||||||
ProjectService,
|
ProjectService,
|
||||||
|
InteractionService,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
import type { RunRegistry } from "./run-registry";
|
import type { RunRegistry } from "./run-registry";
|
||||||
import { createRunRoutes } from "../routes/runs";
|
import { createRunRoutes } from "../routes/runs";
|
||||||
import { createProjectRoutes } from "../routes/projects";
|
import { createProjectRoutes } from "../routes/projects";
|
||||||
|
import { createInteractionRoutes } from "../routes/interactions";
|
||||||
|
|
||||||
export function createApp(
|
export function createApp(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
@ -19,6 +21,7 @@ export function createApp(
|
|||||||
runs?: AgentRunService,
|
runs?: AgentRunService,
|
||||||
runRegistry?: RunRegistry,
|
runRegistry?: RunRegistry,
|
||||||
projects?: ProjectService,
|
projects?: ProjectService,
|
||||||
|
interactions?: InteractionService,
|
||||||
): Hono {
|
): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.use("*", requestId);
|
app.use("*", requestId);
|
||||||
@ -29,5 +32,7 @@ export function createApp(
|
|||||||
app.route("/api", createRunRoutes(runs, runRegistry));
|
app.route("/api", createRunRoutes(runs, runRegistry));
|
||||||
if (projects && conversations)
|
if (projects && conversations)
|
||||||
app.route("/api", createProjectRoutes(projects, conversations));
|
app.route("/api", createProjectRoutes(projects, conversations));
|
||||||
|
if (interactions && runs && runRegistry)
|
||||||
|
app.route("/api", createInteractionRoutes(interactions, runs, runRegistry));
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export class RunRegistry {
|
|||||||
private readonly history = new Map<string, RunEvent[]>();
|
private readonly history = new Map<string, RunEvent[]>();
|
||||||
private readonly listeners = new Map<string, Set<Listener>>();
|
private readonly listeners = new Map<string, Set<Listener>>();
|
||||||
private readonly finished = new Set<string>();
|
private readonly finished = new Set<string>();
|
||||||
|
private readonly paused = new Set<string>();
|
||||||
|
|
||||||
async publish(event: RunEvent): Promise<void> {
|
async publish(event: RunEvent): Promise<void> {
|
||||||
const events = this.history.get(event.runId) ?? [];
|
const events = this.history.get(event.runId) ?? [];
|
||||||
@ -13,6 +14,13 @@ export class RunRegistry {
|
|||||||
this.history.set(event.runId, events);
|
this.history.set(event.runId, events);
|
||||||
if (event.type === "run.completed" || event.type === "run.failed")
|
if (event.type === "run.completed" || event.type === "run.failed")
|
||||||
this.finished.add(event.runId);
|
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(
|
await Promise.all(
|
||||||
[...(this.listeners.get(event.runId) ?? [])].map((listener) =>
|
[...(this.listeners.get(event.runId) ?? [])].map((listener) =>
|
||||||
listener(event),
|
listener(event),
|
||||||
@ -26,6 +34,9 @@ export class RunRegistry {
|
|||||||
isFinished(runId: string): boolean {
|
isFinished(runId: string): boolean {
|
||||||
return this.finished.has(runId);
|
return this.finished.has(runId);
|
||||||
}
|
}
|
||||||
|
isPaused(runId: string): boolean {
|
||||||
|
return this.paused.has(runId);
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(runId: string, listener: Listener): () => void {
|
subscribe(runId: string, listener: Listener): () => void {
|
||||||
const values = this.listeners.get(runId) ?? new Set<Listener>();
|
const values = this.listeners.get(runId) ?? new Set<Listener>();
|
||||||
|
|||||||
@ -21,9 +21,12 @@ export function createErrorHandler(logger: Logger): ErrorHandler {
|
|||||||
if (error instanceof CoreError) {
|
if (error instanceof CoreError) {
|
||||||
const status =
|
const status =
|
||||||
error.code === "CONVERSATION_NOT_FOUND" ||
|
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
|
? 404
|
||||||
: error.code === "PROJECT_RUN_ACTIVE"
|
: error.code === "PROJECT_RUN_ACTIVE" ||
|
||||||
|
error.code === "INTERACTION_ALREADY_RESOLVED"
|
||||||
? 409
|
? 409
|
||||||
: 400;
|
: 400;
|
||||||
return context.json(
|
return context.json(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
FileConversationRepository,
|
FileConversationRepository,
|
||||||
FileRunRepository,
|
FileRunRepository,
|
||||||
FileProjectRepository,
|
FileProjectRepository,
|
||||||
|
FileInteractionRepository,
|
||||||
createDataLayout,
|
createDataLayout,
|
||||||
ensureDataLayout,
|
ensureDataLayout,
|
||||||
} from "@great-agent/local-data";
|
} from "@great-agent/local-data";
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
AgentRunService,
|
AgentRunService,
|
||||||
ConversationService,
|
ConversationService,
|
||||||
ProjectService,
|
ProjectService,
|
||||||
|
InteractionService,
|
||||||
CoreError,
|
CoreError,
|
||||||
type ModelPort,
|
type ModelPort,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
@ -30,6 +32,7 @@ const clock = { now: () => new Date() };
|
|||||||
const ids = { create: () => crypto.randomUUID() };
|
const ids = { create: () => crypto.randomUUID() };
|
||||||
const conversationRepository = new FileConversationRepository(layout);
|
const conversationRepository = new FileConversationRepository(layout);
|
||||||
const runRepository = new FileRunRepository(layout);
|
const runRepository = new FileRunRepository(layout);
|
||||||
|
const interactionRepository = new FileInteractionRepository(layout);
|
||||||
const conversations = new ConversationService({
|
const conversations = new ConversationService({
|
||||||
conversations: conversationRepository,
|
conversations: conversationRepository,
|
||||||
clock,
|
clock,
|
||||||
@ -43,6 +46,11 @@ const projects = new ProjectService({
|
|||||||
clock,
|
clock,
|
||||||
ids,
|
ids,
|
||||||
});
|
});
|
||||||
|
const interactions = new InteractionService({
|
||||||
|
interactions: interactionRepository,
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
});
|
||||||
const model: ModelPort = environment.deepSeekApiKey
|
const model: ModelPort = environment.deepSeekApiKey
|
||||||
? new DeepSeekModelAdapter({
|
? new DeepSeekModelAdapter({
|
||||||
apiKey: environment.deepSeekApiKey,
|
apiKey: environment.deepSeekApiKey,
|
||||||
@ -65,8 +73,16 @@ const runs = new AgentRunService(
|
|||||||
clock,
|
clock,
|
||||||
ids,
|
ids,
|
||||||
projects,
|
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"));
|
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
47
apps/web-server/src/routes/interactions.ts
Normal file
47
apps/web-server/src/routes/interactions.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
AgentRunService,
|
AgentRunService,
|
||||||
ConversationService,
|
ConversationService,
|
||||||
type ProjectService,
|
type ProjectService,
|
||||||
|
type InteractionService,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
import { createApp } from "../composition/create-app";
|
import { createApp } from "../composition/create-app";
|
||||||
import { RunRegistry } from "../composition/run-registry";
|
import { RunRegistry } from "../composition/run-registry";
|
||||||
@ -21,6 +22,7 @@ const projects = {
|
|||||||
throw new Error("unused");
|
throw new Error("unused");
|
||||||
},
|
},
|
||||||
} as unknown as ProjectService;
|
} as unknown as ProjectService;
|
||||||
|
const interactions = {} as InteractionService;
|
||||||
|
|
||||||
class Conversations implements ConversationRepository {
|
class Conversations implements ConversationRepository {
|
||||||
value: Conversation | null = null;
|
value: Conversation | null = null;
|
||||||
@ -92,6 +94,7 @@ describe("Run HTTP 与 SSE", () => {
|
|||||||
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||||
{ create: () => `id_${++id}` },
|
{ create: () => `id_${++id}` },
|
||||||
projects,
|
projects,
|
||||||
|
interactions,
|
||||||
);
|
);
|
||||||
const app = createApp(
|
const app = createApp(
|
||||||
pino({ enabled: false }),
|
pino({ enabled: false }),
|
||||||
|
|||||||
@ -23,12 +23,20 @@ export function createRunRoutes(
|
|||||||
202,
|
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) =>
|
routes.get("/runs/:runId/events", (context) =>
|
||||||
streamSSE(context, async (stream) => {
|
streamSSE(context, async (stream) => {
|
||||||
const runId = context.req.param("runId");
|
const runId = context.req.param("runId");
|
||||||
for (const event of registry.events(runId))
|
for (const event of registry.events(runId))
|
||||||
await writeEvent(stream, event);
|
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
|
// EventSource needs the terminal event to reach the browser before the
|
||||||
// server closes a replay-only stream.
|
// server closes a replay-only stream.
|
||||||
await stream.sleep(100);
|
await stream.sleep(100);
|
||||||
@ -37,7 +45,12 @@ export function createRunRoutes(
|
|||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const unsubscribe = registry.subscribe(runId, async (event) => {
|
const unsubscribe = registry.subscribe(runId, async (event) => {
|
||||||
await writeEvent(stream, 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();
|
unsubscribe();
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
@ -52,7 +65,7 @@ export function createRunRoutes(
|
|||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function consume(
|
export async function consume(
|
||||||
events: AsyncIterable<RunEvent>,
|
events: AsyncIterable<RunEvent>,
|
||||||
registry: RunRegistry,
|
registry: RunRegistry,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
38
apps/web/src/api/interactions.ts
Normal file
38
apps/web/src/api/interactions.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ export async function startRun(
|
|||||||
export async function streamRun(
|
export async function streamRun(
|
||||||
runId: string,
|
runId: string,
|
||||||
onEvent: (event: RunEventResponse) => void,
|
onEvent: (event: RunEventResponse) => void,
|
||||||
|
afterSequence = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/runs/${encodeURIComponent(runId)}/events`,
|
`/api/runs/${encodeURIComponent(runId)}/events`,
|
||||||
@ -49,8 +50,11 @@ export async function streamRun(
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
const event = runEventSchema.parse(JSON.parse(data));
|
const event = runEventSchema.parse(JSON.parse(data));
|
||||||
|
if (event.sequence <= afterSequence) continue;
|
||||||
onEvent(event);
|
onEvent(event);
|
||||||
if (event.type === "run.completed") return;
|
if (event.type === "run.completed") return;
|
||||||
|
if (event.type === "interaction.requested") return;
|
||||||
|
if (event.type === "run.cancelled") return;
|
||||||
if (event.type === "run.failed")
|
if (event.type === "run.failed")
|
||||||
throw new Error(readPayloadMessage(event.payload));
|
throw new Error(readPayloadMessage(event.payload));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import type {
|
|||||||
ConversationResponse,
|
ConversationResponse,
|
||||||
ConversationSummaryResponse,
|
ConversationSummaryResponse,
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
|
InteractionAnswerRequest,
|
||||||
|
InteractionResponse,
|
||||||
} from "@great-agent/web-contracts";
|
} from "@great-agent/web-contracts";
|
||||||
import {
|
import {
|
||||||
getConversation,
|
getConversation,
|
||||||
@ -16,6 +18,11 @@ import {
|
|||||||
renameProject,
|
renameProject,
|
||||||
} from "../api/projects";
|
} from "../api/projects";
|
||||||
import { startRun, streamRun } from "../api/runs";
|
import { startRun, streamRun } from "../api/runs";
|
||||||
|
import {
|
||||||
|
answerInteraction as submitInteractionAnswer,
|
||||||
|
cancelWaitingRun,
|
||||||
|
listInteractions,
|
||||||
|
} from "../api/interactions";
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
| { kind: "none" }
|
| { kind: "none" }
|
||||||
@ -32,6 +39,7 @@ export type AppController = Readonly<{
|
|||||||
projects: State<ProjectResponse[]>;
|
projects: State<ProjectResponse[]>;
|
||||||
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
|
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
|
||||||
active: State<ConversationResponse | null>;
|
active: State<ConversationResponse | null>;
|
||||||
|
interactions: State<InteractionResponse[]>;
|
||||||
loading: State<boolean>;
|
loading: State<boolean>;
|
||||||
sending: State<boolean>;
|
sending: State<boolean>;
|
||||||
assistantDraft: State<string>;
|
assistantDraft: State<string>;
|
||||||
@ -48,6 +56,11 @@ export type AppController = Readonly<{
|
|||||||
deleteProject(id: string): Promise<void>;
|
deleteProject(id: string): Promise<void>;
|
||||||
openConversation(id: string): Promise<void>;
|
openConversation(id: string): Promise<void>;
|
||||||
send(content: string): Promise<void>;
|
send(content: string): Promise<void>;
|
||||||
|
answerInteraction(
|
||||||
|
id: string,
|
||||||
|
answer: InteractionAnswerRequest,
|
||||||
|
): Promise<void>;
|
||||||
|
cancelInteraction(runId: string): Promise<void>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function createAppController(): AppController {
|
export function createAppController(): AppController {
|
||||||
@ -58,6 +71,7 @@ export function createAppController(): AppController {
|
|||||||
Record<string, ConversationSummaryResponse[]>
|
Record<string, ConversationSummaryResponse[]>
|
||||||
>({});
|
>({});
|
||||||
const active = van.state<ConversationResponse | null>(null);
|
const active = van.state<ConversationResponse | null>(null);
|
||||||
|
const interactions = van.state<InteractionResponse[]>([]);
|
||||||
const loading = van.state(false);
|
const loading = van.state(false);
|
||||||
const sending = van.state(false);
|
const sending = van.state(false);
|
||||||
const assistantDraft = van.state("");
|
const assistantDraft = van.state("");
|
||||||
@ -81,6 +95,7 @@ export function createAppController(): AppController {
|
|||||||
function select(next: Selection) {
|
function select(next: Selection) {
|
||||||
selection.val = next;
|
selection.val = next;
|
||||||
active.val = null;
|
active.val = null;
|
||||||
|
interactions.val = [];
|
||||||
error.val = "";
|
error.val = "";
|
||||||
}
|
}
|
||||||
function startNewTask() {
|
function startNewTask() {
|
||||||
@ -147,6 +162,7 @@ export function createAppController(): AppController {
|
|||||||
async function openConversation(id: string) {
|
async function openConversation(id: string) {
|
||||||
await perform(async () => {
|
await perform(async () => {
|
||||||
active.val = await getConversation(id);
|
active.val = await getConversation(id);
|
||||||
|
interactions.val = await listInteractions(id);
|
||||||
selection.val = { kind: "conversation", id };
|
selection.val = { kind: "conversation", id };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -176,15 +192,8 @@ export function createAppController(): AppController {
|
|||||||
active.val = await getConversation(started.conversationId);
|
active.val = await getConversation(started.conversationId);
|
||||||
selection.val = { kind: "conversation", id: started.conversationId };
|
selection.val = { kind: "conversation", id: started.conversationId };
|
||||||
await refreshLists(active.val.projectId);
|
await refreshLists(active.val.projectId);
|
||||||
await streamRun(started.runId, (event) => {
|
await streamRun(started.runId, collectDelta);
|
||||||
if (
|
await refreshActive(started.conversationId);
|
||||||
event.type === "message.delta" &&
|
|
||||||
typeof event.payload.delta === "string"
|
|
||||||
)
|
|
||||||
assistantDraft.val += event.payload.delta;
|
|
||||||
});
|
|
||||||
active.val = await getConversation(started.conversationId);
|
|
||||||
assistantDraft.val = "";
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.val = readMessage(cause);
|
error.val = readMessage(cause);
|
||||||
} finally {
|
} 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) {
|
async function refreshLists(projectId: string | null) {
|
||||||
if (!projectId) recent.val = await listConversations();
|
if (!projectId) recent.val = await listConversations();
|
||||||
else {
|
else {
|
||||||
@ -222,6 +292,7 @@ export function createAppController(): AppController {
|
|||||||
projects,
|
projects,
|
||||||
projectConversations,
|
projectConversations,
|
||||||
active,
|
active,
|
||||||
|
interactions,
|
||||||
loading,
|
loading,
|
||||||
sending,
|
sending,
|
||||||
assistantDraft,
|
assistantDraft,
|
||||||
@ -238,6 +309,8 @@ export function createAppController(): AppController {
|
|||||||
deleteProject: deleteExistingProject,
|
deleteProject: deleteExistingProject,
|
||||||
openConversation,
|
openConversation,
|
||||||
send,
|
send,
|
||||||
|
answerInteraction,
|
||||||
|
cancelInteraction,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import van, { type State } from "vanjs-core";
|
|||||||
import type { AppController } from "../../app/app-controller";
|
import type { AppController } from "../../app/app-controller";
|
||||||
import "./conversation-pane.css";
|
import "./conversation-pane.css";
|
||||||
import { ProjectPanel } from "../projects/project-panel";
|
import { ProjectPanel } from "../projects/project-panel";
|
||||||
|
import { InteractionCard } from "../interactions/interaction-card";
|
||||||
|
|
||||||
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
||||||
|
|
||||||
@ -29,13 +30,23 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
div(
|
div(
|
||||||
{ class: "message-list" },
|
{ class: "message-list" },
|
||||||
controller.active.val.messages.map((message) =>
|
controller.active.val.messages.map((message) =>
|
||||||
article(
|
div(
|
||||||
{ class: `message ${message.role}` },
|
{ class: "timeline-entry" },
|
||||||
span(
|
article(
|
||||||
{ class: `message-role ${message.role}-role` },
|
{ class: `message ${message.role}` },
|
||||||
message.role === "user" ? "你" : "G",
|
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 {
|
function canCompose(controller: AppController): boolean {
|
||||||
const selection = controller.selection.val;
|
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 === "ordinary-draft") return true;
|
||||||
if (selection.kind === "project-draft") {
|
if (selection.kind === "project-draft") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
125
apps/web/src/features/interactions/interaction-card.css
Normal file
125
apps/web/src/features/interactions/interaction-card.css
Normal 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;
|
||||||
|
}
|
||||||
188
apps/web/src/features/interactions/interaction-card.ts
Normal file
188
apps/web/src/features/interactions/interaction-card.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -57,13 +57,17 @@
|
|||||||
|
|
||||||
### F-004——用户交互卡片
|
### F-004——用户交互卡片
|
||||||
|
|
||||||
- 状态:进行中
|
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||||
- 用户可见结果:Agent 请求单选、多选、确认或意见输入,回答后继续同一 Run。
|
- 用户可见结果:Agent 请求单选、多选、确认或意见输入,回答后继续同一 Run。
|
||||||
- 主要验收:AC-034 至 AC-038。
|
- 主要验收: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——停止、失败与重试
|
### F-005——停止、失败与重试
|
||||||
|
|
||||||
- 状态:待开始
|
- 状态:进行中
|
||||||
- 用户可见结果:停止当前生成并从失败状态重试。
|
- 用户可见结果:停止当前生成并从失败状态重试。
|
||||||
- 主要验收:AC-005、AC-010、AC-011。
|
- 主要验收:AC-005、AC-010、AC-011。
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
export type AgentRunStatus = "running" | "completed" | "failed";
|
export type AgentRunStatus =
|
||||||
|
| "running"
|
||||||
|
| "waiting_user"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
export type AgentRun = Readonly<{
|
export type AgentRun = Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -19,7 +24,11 @@ export type RunEvent = Readonly<{
|
|||||||
| "message.started"
|
| "message.started"
|
||||||
| "message.delta"
|
| "message.delta"
|
||||||
| "message.completed"
|
| "message.completed"
|
||||||
|
| "interaction.requested"
|
||||||
|
| "interaction.resolved"
|
||||||
|
| "interaction.cancelled"
|
||||||
| "run.completed"
|
| "run.completed"
|
||||||
| "run.failed";
|
| "run.failed"
|
||||||
|
| "run.cancelled";
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
35
packages/agent-core/src/domain/user-interaction.ts
Normal file
35
packages/agent-core/src/domain/user-interaction.ts
Normal 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;
|
||||||
|
}>;
|
||||||
@ -7,10 +7,23 @@ export type {
|
|||||||
} from "./domain/conversation";
|
} from "./domain/conversation";
|
||||||
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
||||||
export type { Project, ProjectView } from "./domain/project";
|
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 { 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 { RunRepository } from "./ports/run-repository";
|
||||||
export type { ProjectRepository } from "./ports/project-repository";
|
export type { ProjectRepository } from "./ports/project-repository";
|
||||||
|
export type { InteractionRepository } from "./ports/interaction-repository";
|
||||||
export type {
|
export type {
|
||||||
ClockPort,
|
ClockPort,
|
||||||
IdPort,
|
IdPort,
|
||||||
@ -29,3 +42,7 @@ export {
|
|||||||
ProjectService,
|
ProjectService,
|
||||||
type ProjectServiceDependencies,
|
type ProjectServiceDependencies,
|
||||||
} from "./use-cases/project-service";
|
} from "./use-cases/project-service";
|
||||||
|
export {
|
||||||
|
InteractionService,
|
||||||
|
type InteractionServiceDependencies,
|
||||||
|
} from "./use-cases/interaction-service";
|
||||||
|
|||||||
11
packages/agent-core/src/ports/interaction-repository.ts
Normal file
11
packages/agent-core/src/ports/interaction-repository.ts
Normal 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>;
|
||||||
|
}
|
||||||
@ -3,10 +3,29 @@ import type { Message } from "../domain/conversation";
|
|||||||
export type ModelRequest = Readonly<{
|
export type ModelRequest = Readonly<{
|
||||||
model: string;
|
model: string;
|
||||||
messages: readonly Message[];
|
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 =
|
export type ModelEvent =
|
||||||
| Readonly<{ type: "text.delta"; delta: string }>
|
| Readonly<{ type: "text.delta"; delta: string }>
|
||||||
|
| Readonly<{
|
||||||
|
type: "tool.requested";
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}>
|
||||||
| Readonly<{ type: "response.completed" }>;
|
| Readonly<{ type: "response.completed" }>;
|
||||||
|
|
||||||
export interface ModelPort {
|
export interface ModelPort {
|
||||||
|
|||||||
@ -7,13 +7,19 @@ import type {
|
|||||||
RunEvent,
|
RunEvent,
|
||||||
RunRepository,
|
RunRepository,
|
||||||
} from "..";
|
} from "..";
|
||||||
import { AgentRunService, ConversationService, type ProjectService } from "..";
|
import {
|
||||||
|
AgentRunService,
|
||||||
|
ConversationService,
|
||||||
|
type InteractionService,
|
||||||
|
type ProjectService,
|
||||||
|
} from "..";
|
||||||
|
|
||||||
const projects = {
|
const projects = {
|
||||||
requireAvailable: async () => {
|
requireAvailable: async () => {
|
||||||
throw new Error("unused");
|
throw new Error("unused");
|
||||||
},
|
},
|
||||||
} as unknown as ProjectService;
|
} as unknown as ProjectService;
|
||||||
|
const interactions = {} as InteractionService;
|
||||||
|
|
||||||
class MemoryConversationRepository implements ConversationRepository {
|
class MemoryConversationRepository implements ConversationRepository {
|
||||||
readonly values = new Map<string, Conversation>();
|
readonly values = new Map<string, Conversation>();
|
||||||
@ -96,6 +102,7 @@ describe("AgentRunService", () => {
|
|||||||
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||||
{ create: () => `id_${++nextId}` },
|
{ create: () => `id_${++nextId}` },
|
||||||
projects,
|
projects,
|
||||||
|
interactions,
|
||||||
);
|
);
|
||||||
const started = await service.start(
|
const started = await service.start(
|
||||||
{ kind: "ordinary", message: "开始" },
|
{ kind: "ordinary", message: "开始" },
|
||||||
@ -148,6 +155,7 @@ describe("AgentRunService", () => {
|
|||||||
{ now: () => new Date() },
|
{ now: () => new Date() },
|
||||||
{ create: () => `id_${++id}` },
|
{ create: () => `id_${++id}` },
|
||||||
projects,
|
projects,
|
||||||
|
interactions,
|
||||||
);
|
);
|
||||||
const started = await service.start(
|
const started = await service.start(
|
||||||
{ kind: "ordinary", message: "开始" },
|
{ kind: "ordinary", message: "开始" },
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
||||||
import type { Conversation } from "../domain/conversation";
|
import type { Conversation } from "../domain/conversation";
|
||||||
|
import type { UserInteraction } from "../domain/user-interaction";
|
||||||
import { CoreError } from "../errors/core-error";
|
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 { RunRepository } from "../ports/run-repository";
|
||||||
import type { ClockPort, IdPort } from "../ports/system-ports";
|
import type { ClockPort, IdPort } from "../ports/system-ports";
|
||||||
import type { ConversationService } from "./conversation-service";
|
import type { ConversationService } from "./conversation-service";
|
||||||
|
import type { InteractionService } from "./interaction-service";
|
||||||
import type { ProjectService } from "./project-service";
|
import type { ProjectService } from "./project-service";
|
||||||
|
|
||||||
export type StartRunInput =
|
export type StartRunInput =
|
||||||
@ -17,6 +19,36 @@ export type StartedRun = Readonly<{
|
|||||||
events: AsyncIterable<RunEvent>;
|
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 {
|
export class AgentRunService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly conversations: ConversationService,
|
private readonly conversations: ConversationService,
|
||||||
@ -25,35 +57,12 @@ export class AgentRunService {
|
|||||||
private readonly clock: ClockPort,
|
private readonly clock: ClockPort,
|
||||||
private readonly ids: IdPort,
|
private readonly ids: IdPort,
|
||||||
private readonly projects: ProjectService,
|
private readonly projects: ProjectService,
|
||||||
|
private readonly interactions: InteractionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
||||||
const runId = this.ids.create();
|
const runId = this.ids.create();
|
||||||
let conversation: Conversation;
|
const conversation = await this.prepareConversation(input, runId);
|
||||||
if (input.kind === "ordinary") {
|
|
||||||
conversation = await this.conversations.createWithFirstMessage(
|
|
||||||
input.message,
|
|
||||||
runId,
|
|
||||||
);
|
|
||||||
} else if (input.kind === "project") {
|
|
||||||
await this.projects.requireAvailable(input.projectId);
|
|
||||||
conversation = await this.conversations.createProjectWithFirstMessage(
|
|
||||||
input.projectId,
|
|
||||||
input.message,
|
|
||||||
runId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const existing = await this.conversations.getConversation(
|
|
||||||
input.conversationId,
|
|
||||||
);
|
|
||||||
if (existing.projectId)
|
|
||||||
await this.projects.requireAvailable(existing.projectId);
|
|
||||||
conversation = await this.conversations.appendUserMessage(
|
|
||||||
input.conversationId,
|
|
||||||
input.message,
|
|
||||||
runId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const timestamp = this.clock.now().toISOString();
|
const timestamp = this.clock.now().toISOString();
|
||||||
const triggerMessage = conversation.messages.at(-1);
|
const triggerMessage = conversation.messages.at(-1);
|
||||||
if (triggerMessage?.role !== "user")
|
if (triggerMessage?.role !== "user")
|
||||||
@ -67,30 +76,123 @@ export class AgentRunService {
|
|||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
await this.runs.create(run);
|
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(
|
private async *execute(
|
||||||
run: AgentRun,
|
run: AgentRun,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
initialSequence: number,
|
||||||
|
continuation?: NonNullable<ModelRequest["continuation"]>,
|
||||||
|
resolvedInteraction?: UserInteraction,
|
||||||
): AsyncIterable<RunEvent> {
|
): AsyncIterable<RunEvent> {
|
||||||
let sequence = 0;
|
let sequence = initialSequence;
|
||||||
const event = async (
|
const event = async (
|
||||||
type: RunEvent["type"],
|
type: RunEvent["type"],
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
) => {
|
) => this.persistEvent(run.id, ++sequence, type, payload);
|
||||||
const value: RunEvent = {
|
|
||||||
runId: run.id,
|
|
||||||
sequence: ++sequence,
|
|
||||||
timestamp: this.clock.now().toISOString(),
|
|
||||||
type,
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
await this.runs.appendEvent(value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
try {
|
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();
|
const messageId = this.ids.create();
|
||||||
yield await event("message.started", { messageId });
|
yield await event("message.started", { messageId });
|
||||||
const conversation = await this.conversations.getConversation(
|
const conversation = await this.conversations.getConversation(
|
||||||
@ -98,15 +200,50 @@ export class AgentRunService {
|
|||||||
);
|
);
|
||||||
let content = "";
|
let content = "";
|
||||||
for await (const modelEvent of this.model.stream(
|
for await (const modelEvent of this.model.stream(
|
||||||
{ model: "default", messages: conversation.messages },
|
{
|
||||||
|
model: "default",
|
||||||
|
messages: conversation.messages,
|
||||||
|
tools: [interactionTool],
|
||||||
|
...(continuation ? { continuation } : {}),
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
)) {
|
)) {
|
||||||
if (modelEvent.type !== "text.delta") continue;
|
if (modelEvent.type === "text.delta") {
|
||||||
content += modelEvent.delta;
|
content += modelEvent.delta;
|
||||||
yield await event("message.delta", {
|
yield await event("message.delta", {
|
||||||
messageId,
|
messageId,
|
||||||
delta: modelEvent.delta,
|
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)
|
if (!content)
|
||||||
throw new CoreError("MODEL_RESPONSE_INVALID", "模型没有返回有效内容");
|
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 {
|
function safeErrorMessage(cause: unknown): string {
|
||||||
return cause instanceof CoreError ? cause.message : "模型服务暂时不可用";
|
return cause instanceof CoreError ? cause.message : "模型服务暂时不可用";
|
||||||
}
|
}
|
||||||
|
|||||||
212
packages/agent-core/src/use-cases/interaction-run.test.ts
Normal file
212
packages/agent-core/src/use-cases/interaction-run.test.ts
Normal 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;
|
||||||
|
}
|
||||||
127
packages/agent-core/src/use-cases/interaction-service.test.ts
Normal file
127
packages/agent-core/src/use-cases/interaction-service.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
215
packages/agent-core/src/use-cases/interaction-service.ts
Normal file
215
packages/agent-core/src/use-cases/interaction-service.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -9,3 +9,4 @@ export {
|
|||||||
export { FileConversationRepository } from "./repositories/file-conversation-repository";
|
export { FileConversationRepository } from "./repositories/file-conversation-repository";
|
||||||
export { FileRunRepository } from "./repositories/file-run-repository";
|
export { FileRunRepository } from "./repositories/file-run-repository";
|
||||||
export { FileProjectRepository } from "./repositories/file-project-repository";
|
export { FileProjectRepository } from "./repositories/file-project-repository";
|
||||||
|
export { FileInteractionRepository } from "./repositories/file-interaction-repository";
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,7 +58,9 @@ export class FileRunRepository implements RunRepository {
|
|||||||
if (conversationIds.length === 0) return false;
|
if (conversationIds.length === 0) return false;
|
||||||
const targets = new Set(conversationIds);
|
const targets = new Set(conversationIds);
|
||||||
return (await this.readAll()).some(
|
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"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,19 +29,85 @@ export class DeepSeekModelAdapter implements ModelPort {
|
|||||||
const response = await this.client.chat.completions.create(
|
const response = await this.client.chat.completions.create(
|
||||||
{
|
{
|
||||||
model: request.model === "default" ? this.options.model : request.model,
|
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,
|
stream: true,
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
const toolCalls = new Map<
|
||||||
|
number,
|
||||||
|
{ id: string; name: string; arguments: string }
|
||||||
|
>();
|
||||||
for await (const chunk of response) {
|
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 };
|
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" };
|
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(
|
function toModelMessage(
|
||||||
message: Message,
|
message: Message,
|
||||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam {
|
): OpenAI.Chat.Completions.ChatCompletionMessageParam {
|
||||||
|
|||||||
@ -9,8 +9,12 @@ export const runEventSchema = z.object({
|
|||||||
"message.started",
|
"message.started",
|
||||||
"message.delta",
|
"message.delta",
|
||||||
"message.completed",
|
"message.completed",
|
||||||
|
"interaction.requested",
|
||||||
|
"interaction.resolved",
|
||||||
|
"interaction.cancelled",
|
||||||
"run.completed",
|
"run.completed",
|
||||||
"run.failed",
|
"run.failed",
|
||||||
|
"run.cancelled",
|
||||||
]),
|
]),
|
||||||
payload: z.record(z.string(), z.unknown()),
|
payload: z.record(z.string(), z.unknown()),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,3 +17,11 @@ export {
|
|||||||
messageSchema,
|
messageSchema,
|
||||||
} from "./responses/conversation";
|
} from "./responses/conversation";
|
||||||
export { projectSchema, type ProjectResponse } from "./responses/project";
|
export { projectSchema, type ProjectResponse } from "./responses/project";
|
||||||
|
export {
|
||||||
|
interactionAnswerSchema,
|
||||||
|
interactionSchema,
|
||||||
|
resolvedInteractionSchema,
|
||||||
|
type InteractionAnswerRequest,
|
||||||
|
type InteractionResponse,
|
||||||
|
type ResolvedInteractionResponse,
|
||||||
|
} from "./responses/interaction";
|
||||||
|
|||||||
48
packages/web-contracts/src/responses/interaction.ts
Normal file
48
packages/web-contracts/src/responses/interaction.ts
Normal 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
|
||||||
|
>;
|
||||||
Loading…
x
Reference in New Issue
Block a user