feat(chat): stream and persist local answer UI with final report references
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { UIMessageChunk } from "ai";
|
||||
|
||||
/** Canonical parts are projected independently of an attached browser response. */
|
||||
export function createAnswerStreamProjection(options: {
|
||||
messageId: string;
|
||||
sessionId: string;
|
||||
write: (chunk: UIMessageChunk) => void;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const parts: Record<string, unknown>[] = [];
|
||||
let segment = 0;
|
||||
let textId = "answer";
|
||||
let currentText: Record<string, unknown> | undefined;
|
||||
let finished = false;
|
||||
const endText = () => {
|
||||
if (!currentText) return;
|
||||
currentText.state = "done";
|
||||
options.write({ type: "text-end", id: textId });
|
||||
currentText = undefined;
|
||||
textId = `answer-${++segment}`;
|
||||
};
|
||||
const appendText = (delta: string) => {
|
||||
if (!delta || finished) return;
|
||||
if (!currentText) {
|
||||
currentText = { type: "text", text: "", state: "streaming" };
|
||||
parts.push(currentText);
|
||||
options.write({ type: "text-start", id: textId });
|
||||
}
|
||||
currentText.text = `${String(currentText.text)}${delta}`;
|
||||
options.write({ type: "text-delta", id: textId, delta });
|
||||
options.write({ type: "data-stream_token", transient: true, data: {
|
||||
session_id: options.sessionId, message_id: options.messageId, content: delta,
|
||||
} } as UIMessageChunk);
|
||||
options.onChange();
|
||||
};
|
||||
const appendData = (event: string, data: Record<string, unknown>) => {
|
||||
if (finished) return;
|
||||
if (event === "answer_ui") endText();
|
||||
const type = `data-${event}`;
|
||||
const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string");
|
||||
const existing = id === undefined ? -1 : parts.findIndex((part) => part.type === type && part.id === id);
|
||||
const part = id ? { type, id, data } : { type, data };
|
||||
if (existing >= 0) parts[existing] = part;
|
||||
else parts.push(part);
|
||||
options.write(part as UIMessageChunk);
|
||||
options.onChange();
|
||||
};
|
||||
const finish = (finishReason: "stop" | "error" = "stop") => {
|
||||
if (finished) return;
|
||||
if (!parts.length) appendText("Agent 已完成处理。");
|
||||
endText();
|
||||
finished = true;
|
||||
options.onChange();
|
||||
options.write({ type: "finish", finishReason });
|
||||
};
|
||||
return { parts, appendText, appendData, finish, get finished() { return finished; } };
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"version": "answer-ui@1",
|
||||
"description": "Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"root": {
|
||||
"type": "string"
|
||||
},
|
||||
"elements": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"Stack",
|
||||
"Section",
|
||||
"Text",
|
||||
"Metric",
|
||||
"Table",
|
||||
"Chart",
|
||||
"Evidence",
|
||||
"MapLocation",
|
||||
"FollowUpForm"
|
||||
]
|
||||
},
|
||||
"props": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"slots": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visible": {},
|
||||
"repeat": {}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"props",
|
||||
"children"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"root",
|
||||
"elements"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"components": [
|
||||
"Stack",
|
||||
"Section",
|
||||
"Text",
|
||||
"Metric",
|
||||
"Table",
|
||||
"Chart",
|
||||
"Evidence",
|
||||
"MapLocation",
|
||||
"FollowUpForm"
|
||||
],
|
||||
"props": {
|
||||
"Stack": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"columns": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"one",
|
||||
"two"
|
||||
]
|
||||
},
|
||||
"gap": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"compact",
|
||||
"normal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"columns",
|
||||
"gap"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"Section": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"Text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"tone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"neutral",
|
||||
"info",
|
||||
"warning"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"tone"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"Metric": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"unit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"value",
|
||||
"unit",
|
||||
"source"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"Table": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 8
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
},
|
||||
"maxItems": 8
|
||||
},
|
||||
"maxItems": 50
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"columns",
|
||||
"rows"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"Chart": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"chart_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"line",
|
||||
"bar"
|
||||
]
|
||||
},
|
||||
"x_data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 100
|
||||
},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 100
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 8
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"chart_type",
|
||||
"x_data",
|
||||
"series",
|
||||
"source"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"Evidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"evidence_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"observed_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"observed",
|
||||
"human_input",
|
||||
"inference",
|
||||
"demo"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"summary",
|
||||
"source",
|
||||
"evidence_id",
|
||||
"observed_at",
|
||||
"kind"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"MapLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"layer": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"junctions",
|
||||
"pipes",
|
||||
"valves",
|
||||
"pumps",
|
||||
"tanks",
|
||||
"reservoirs",
|
||||
"scada"
|
||||
]
|
||||
},
|
||||
"feature_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 100
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"layer",
|
||||
"feature_ids",
|
||||
"source"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"FollowUpForm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]{0,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text",
|
||||
"datetime",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"label",
|
||||
"kind",
|
||||
"required"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 12
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"description",
|
||||
"fields"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Ajv } from "ajv";
|
||||
import { z } from "zod";
|
||||
import catalog from "./answer-ui-catalog.json" with { type: "json" };
|
||||
|
||||
const ajv = new Ajv({ allErrors: false, strict: true });
|
||||
const propertyValidators = Object.fromEntries(Object.entries(catalog.props).map(([name, schema]) => [name, ajv.compile(schema)]));
|
||||
const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
|
||||
export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.object({
|
||||
type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48),
|
||||
}).strict()) }).strict().superRefine((spec, context) => {
|
||||
const fail = () => context.addIssue({ code: z.ZodIssueCode.custom, message: "界面必须使用已注册组件、合法参数和至多 48 节点的无重复树结构" });
|
||||
const names = Object.keys(spec.elements);
|
||||
if (!names.length || names.length > 48 || JSON.stringify(spec).length > 100000 || names.some((id) => !key.safeParse(id).success)) { fail(); return; }
|
||||
for (const node of Object.values(spec.elements)) {
|
||||
const validate = Object.hasOwn(propertyValidators, node.type) ? propertyValidators[node.type] : undefined;
|
||||
if (!validate?.(node.props) || (!["Stack", "Section"].includes(node.type) && node.children.length)) { fail(); return; }
|
||||
if (node.type === "Chart") {
|
||||
const props = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
|
||||
if (props.series.some((series) => series.data.length !== props.x_data.length)) { fail(); return; }
|
||||
}
|
||||
if (node.type === "Table") {
|
||||
const props = node.props as { columns: unknown[]; rows: unknown[][] };
|
||||
if (props.rows.some((row) => row.length !== props.columns.length)) { fail(); return; }
|
||||
}
|
||||
if (node.type === "FollowUpForm") {
|
||||
const fields = node.props.fields as Array<{ name: string }>;
|
||||
if (new Set(fields.map((field) => field.name)).size !== fields.length) { fail(); return; }
|
||||
}
|
||||
}
|
||||
const visited = new Set<string>();
|
||||
const visit = (id: string, depth: number): boolean => {
|
||||
if (depth > 12 || visited.has(id) || !Object.hasOwn(spec.elements, id)) return false;
|
||||
visited.add(id);
|
||||
return spec.elements[id].children.every((child) => visit(child, depth + 1));
|
||||
};
|
||||
if (!visit(spec.root, 0) || visited.size !== names.length) fail();
|
||||
});
|
||||
|
||||
export const answerUiInputSchema = z.object({ block_id: key, fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict();
|
||||
export const parseAnswerUiInput = (value: unknown) => {
|
||||
const parsed = answerUiInputSchema.safeParse(value);
|
||||
return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data } : null;
|
||||
};
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
import { createTokenSmoother } from "./chatTokenSmoother.js";
|
||||
import { parseAnswerUiInput } from "../chat/answer-ui.js";
|
||||
|
||||
export {
|
||||
createTokenSmoother,
|
||||
@@ -79,6 +80,7 @@ type StreamPromptOptions = {
|
||||
projectId?: string;
|
||||
signal?: AbortSignal;
|
||||
suppressLegacyFrontendActions?: boolean;
|
||||
suppressAnswerUi?: boolean;
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
@@ -142,6 +144,7 @@ export const streamPromptResponse = async ({
|
||||
projectId,
|
||||
signal,
|
||||
suppressLegacyFrontendActions = false,
|
||||
suppressAnswerUi = false,
|
||||
write,
|
||||
}: StreamPromptOptions): Promise<{
|
||||
aborted: boolean;
|
||||
@@ -177,6 +180,8 @@ export const streamPromptResponse = async ({
|
||||
let aborted = signal?.aborted ?? false;
|
||||
let failed = false;
|
||||
let finalAnswer: string | undefined;
|
||||
const presentedAnswerIds = new Set<string>();
|
||||
let finalAnswerUiBlockIds: string[] = [];
|
||||
const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId }));
|
||||
const debugContext = {
|
||||
sessionId,
|
||||
@@ -722,11 +727,36 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
if (part.tool === "present_answer") {
|
||||
if (suppressAnswerUi) continue;
|
||||
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
|
||||
emittedToolParts.add(part.id);
|
||||
const value = parseAnswerUiInput(part.state.input);
|
||||
if (value) {
|
||||
presentedAnswerIds.add(value.block_id);
|
||||
await tokenSmoother.flush();
|
||||
write("answer_ui", { ...value, id: value.block_id, session_id: clientSessionId });
|
||||
} else {
|
||||
write("progress", { id: part.id, phase: "visualization", status: "error", title: "回答界面未通过校验,保留文字说明" });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.tool === "final_answer") {
|
||||
if (part.state.status === "completed") {
|
||||
const output = parseToolOutput(part.state.output);
|
||||
if (output && typeof output === "object" && typeof (output as Record<string, unknown>).answer === "string") {
|
||||
finalAnswer = (output as { answer: string }).answer;
|
||||
const refs = (output as { ui_block_ids?: unknown }).ui_block_ids;
|
||||
finalAnswerUiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string" && presentedAnswerIds.has(id)))].slice(0, 48) : [];
|
||||
const hasPendingTool = [...toolStatuses.values()].some((status) => status !== "completed" && status !== "error");
|
||||
if (presentedAnswerIds.size > 0 && !suppressAnswerUi && !hasPendingTool) {
|
||||
// A final rich-chat answer closes this turn; native interruption stops
|
||||
// automatic revisions without marking a background task complete.
|
||||
await runtime.abortSession(sessionId);
|
||||
await runtime.waitForSessionIdle(sessionId);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -985,7 +1015,7 @@ export const streamPromptResponse = async ({
|
||||
await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken);
|
||||
}
|
||||
await tokenSmoother.flush();
|
||||
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer });
|
||||
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer, ui_block_ids: finalAnswerUiBlockIds });
|
||||
emitProgress({
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
import { type Router } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -153,13 +154,16 @@ export const toCollaborationMessages = (messages: UIMessage[]) =>
|
||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||
const content = readUiMessageText(message);
|
||||
const reference = message.parts.filter((part) => part.type === "data-task_context");
|
||||
return content ? [{ ...message, parts: [{ type: "text" as const, text: content }, ...reference] } as UIMessage] : [];
|
||||
const rich = message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer");
|
||||
const parts = rich ? message.parts.filter((part) => ["text", "data-answer_ui", "data-final_answer", "data-task_context"].includes(part.type)) : [{ type: "text" as const, text: content }, ...reference];
|
||||
return content || rich ? [{ ...message, parts } as UIMessage] : [];
|
||||
});
|
||||
|
||||
const toLegacyMessages = (messages: UIMessage[]) =>
|
||||
messages.flatMap((message) => {
|
||||
const content = readUiMessageText(message);
|
||||
if (!content || (message.role !== "user" && message.role !== "assistant")) {
|
||||
const rich = message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer");
|
||||
if ((!content && !rich) || (message.role !== "user" && message.role !== "assistant")) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
@@ -167,6 +171,7 @@ const toLegacyMessages = (messages: UIMessage[]) =>
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content,
|
||||
...(rich ? { parts: message.parts } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -482,12 +487,6 @@ export const registerChatStreamRoute = (
|
||||
lastRunStatuses.set(clientSessionId, "running");
|
||||
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
|
||||
const assistantUiMessageId = `assistant-${Date.now().toString(36)}`;
|
||||
const textPartId = "answer";
|
||||
const assistantParts: Record<string, unknown>[] = [];
|
||||
let responseText = "";
|
||||
let textStarted = false;
|
||||
let textEnded = false;
|
||||
let uiFinished = false;
|
||||
const syncLatestUiMessages = () => {
|
||||
latestUiMessages = [
|
||||
...requestMessages,
|
||||
@@ -499,90 +498,16 @@ export const registerChatStreamRoute = (
|
||||
];
|
||||
};
|
||||
const writeUiChunk = (chunk: UIMessageChunk) => {
|
||||
pipeUiChunk(res, chunk);
|
||||
};
|
||||
const writeTextDelta = (delta: string) => {
|
||||
if (!delta || uiFinished) {
|
||||
return;
|
||||
}
|
||||
if (!textStarted) {
|
||||
textStarted = true;
|
||||
assistantParts.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
state: "streaming",
|
||||
});
|
||||
writeUiChunk({ type: "text-start", id: textPartId });
|
||||
}
|
||||
responseText += delta;
|
||||
const textPart = assistantParts.find(
|
||||
(part) => part.type === "text",
|
||||
);
|
||||
if (textPart) {
|
||||
textPart.text = responseText;
|
||||
textPart.state = "streaming";
|
||||
}
|
||||
writeUiChunk({ type: "text-delta", id: textPartId, delta });
|
||||
writeUiChunk({
|
||||
type: "data-stream_token",
|
||||
data: {
|
||||
session_id: clientSessionId,
|
||||
message_id: assistantUiMessageId,
|
||||
content: delta,
|
||||
},
|
||||
transient: true,
|
||||
} as UIMessageChunk);
|
||||
syncLatestUiMessages();
|
||||
};
|
||||
const writeDataPart = (event: string, data: Record<string, unknown>) => {
|
||||
if (uiFinished) {
|
||||
return;
|
||||
}
|
||||
const type = `data-${event}`;
|
||||
const id =
|
||||
typeof data.id === "string"
|
||||
? data.id
|
||||
: typeof data.request_id === "string"
|
||||
? data.request_id
|
||||
: typeof data.envelope_id === "string"
|
||||
? data.envelope_id
|
||||
: undefined;
|
||||
const existingIndex =
|
||||
id === undefined
|
||||
? -1
|
||||
: assistantParts.findIndex(
|
||||
(part) => part.type === type && part.id === id,
|
||||
);
|
||||
const part = id ? { type, id, data } : { type, data };
|
||||
if (existingIndex >= 0) {
|
||||
assistantParts[existingIndex] = part;
|
||||
} else {
|
||||
assistantParts.push(part);
|
||||
}
|
||||
writeUiChunk(part as UIMessageChunk);
|
||||
syncLatestUiMessages();
|
||||
};
|
||||
const finishUiMessage = (finishReason: "stop" | "error" = "stop") => {
|
||||
if (uiFinished) {
|
||||
return;
|
||||
}
|
||||
if (!textStarted && assistantParts.length === 0) {
|
||||
writeTextDelta("Agent 已完成处理。");
|
||||
}
|
||||
if (textStarted && !textEnded) {
|
||||
textEnded = true;
|
||||
const textPart = assistantParts.find(
|
||||
(part) => part.type === "text",
|
||||
);
|
||||
if (textPart) {
|
||||
textPart.state = "done";
|
||||
}
|
||||
writeUiChunk({ type: "text-end", id: textPartId });
|
||||
}
|
||||
syncLatestUiMessages();
|
||||
writeUiChunk({ type: "finish", finishReason });
|
||||
uiFinished = true;
|
||||
if (!streamClosed && !res.writableEnded && !res.destroyed) pipeUiChunk(res, chunk);
|
||||
};
|
||||
const projection = createAnswerStreamProjection({
|
||||
messageId: assistantUiMessageId, sessionId: clientSessionId,
|
||||
write: writeUiChunk, onChange: syncLatestUiMessages,
|
||||
});
|
||||
const assistantParts = projection.parts;
|
||||
const writeTextDelta = projection.appendText;
|
||||
const writeDataPart = projection.appendData;
|
||||
const finishUiMessage = projection.finish;
|
||||
writeUiChunk({ type: "start", messageId: assistantUiMessageId });
|
||||
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
|
||||
executionPlans: executionPlanHistory,
|
||||
@@ -609,9 +534,6 @@ export const registerChatStreamRoute = (
|
||||
};
|
||||
const primarySubscriber: StreamSubscriber = {
|
||||
write: (event, data) => {
|
||||
if (streamClosed || res.writableEnded || res.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (event === "token") {
|
||||
writeTextDelta(typeof data.content === "string" ? data.content : "");
|
||||
return;
|
||||
@@ -642,7 +564,8 @@ export const registerChatStreamRoute = (
|
||||
activeRun.subscribers.add(primarySubscriber);
|
||||
const handleClientClose = () => {
|
||||
streamClosed = true;
|
||||
activeRun.subscribers.delete(primarySubscriber);
|
||||
// Keep projecting and persisting parts after the browser disconnects.
|
||||
// writeUiChunk guards the closed response; replay subscribers remain independent.
|
||||
};
|
||||
|
||||
req.on("close", handleClientClose);
|
||||
@@ -837,6 +760,7 @@ export const registerChatStreamRoute = (
|
||||
subscriber.write(event, subscriberData);
|
||||
}
|
||||
}
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, id: assistantUiMessageId, parts: assistantParts.map((part) => ({ ...part })) }));
|
||||
void queueSessionUiStatePersist().catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
@@ -939,6 +863,7 @@ export const registerChatStreamRoute = (
|
||||
projectId: requestContext.projectId,
|
||||
signal: abortController.signal,
|
||||
suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"),
|
||||
suppressAnswerUi: sessionPurpose === "task_execution",
|
||||
write: (event, data) => {
|
||||
publish(event, data);
|
||||
},
|
||||
@@ -1110,7 +1035,7 @@ export const registerChatStreamRoute = (
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
|
||||
if (!projection.finished) {
|
||||
writeUiChunk({
|
||||
type: "error",
|
||||
errorText: "请求已中断",
|
||||
@@ -1121,10 +1046,10 @@ export const registerChatStreamRoute = (
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
|
||||
});
|
||||
}
|
||||
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
|
||||
if (!projection.finished) {
|
||||
finishUiMessage(activeRun.status === "error" ? "error" : "stop");
|
||||
}
|
||||
await persistQueue.catch((error) => {
|
||||
await queueSessionUiStatePersist().catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
sessionBridge.finalizeRequest(clientSessionId);
|
||||
|
||||
@@ -150,7 +150,7 @@ export class OpencodeRuntimeAdapter {
|
||||
location: { directory: session.location.directory },
|
||||
});
|
||||
const tools = response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
|
||||
const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
|
||||
const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
|
||||
const missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
|
||||
if (missing.length) throw new Error(`OpenCode 2 business tools missing: ${missing.join(", ")}`);
|
||||
if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing");
|
||||
|
||||
Reference in New Issue
Block a user