feat(chat): stream and persist local answer UI with final report references
This commit is contained in:
@@ -99,3 +99,10 @@ bun test tests/frontendAction/callIdPlugin.test.ts tests/runtime/opencode.test.t
|
||||
MCP 策略变更使用 MCP 相关测试,实际连接联调可选择根目录 README 列出的本地 smoke 脚本。模型配置修改使用 `bun run check:models`。这些命令从仓库根目录运行;仅修改描述或说明时检查路径、JSON 和 `git diff --check` 即可。
|
||||
|
||||
运行时确认以 Agent 的启动预热和 `/health` 为准。client 模式要同时更新外部 OpenCode 使用的资产。服务启动、工作区保留和容器挂载参见 [根目录 README](../README.md),代码修改约束见 [AGENTS.md](../AGENTS.md)。
|
||||
|
||||
## 本地交互式回答
|
||||
|
||||
`tools/present_answer.ts` 注册本地交互式回答工具。组件契约来自前端导出的
|
||||
`../src/chat/answer-ui-catalog.json`,由服务与前端同时校验;注册入口仍是
|
||||
`plugins/tjwater-tools.ts`。更新后需要重新加载 Agent/OpenCode 的可信插件资产。
|
||||
发现验证使用 `bun run tests/integration/answer-ui-smoke.ts`,从 Agent 仓库根目录运行。
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"id": "tjwater.tools",
|
||||
"description": "注册业务工具和待办工具,安装会话 Shell 沙箱与 MCP 权限策略,并提供模型校验和工具发现 RPC。",
|
||||
"description": "注册业务工具、本地交互式回答 present_answer 和待办工具,安装会话 Shell 沙箱与 MCP 权限策略,并提供模型校验和工具发现 RPC。",
|
||||
"package": "tjwater-plugin",
|
||||
"entrypoint": "tjwater-plugin/index.ts",
|
||||
"implementation": "plugins/tjwater-tools.ts"
|
||||
|
||||
@@ -34,9 +34,21 @@ Agent 负责:
|
||||
| 发布精简调查报告和关键证据 | `publish_task_report` |
|
||||
| 提议需人工审批的工单或命令 | `propose_task_action` |
|
||||
| 记录用户明确提供的任务证据 | `add_task_human_evidence` |
|
||||
| 发布真实调查进度与当前路线 | `publish_task_progress` |
|
||||
| 发布任务地图、图表和证据图层展示请求 | `publish_task_presentation` |
|
||||
|
||||
## UI 约束
|
||||
|
||||
回答可通过 `present_answer` 组合本地交互界面,与简短文字交错呈现。按问题选择组织方式:比较资料用 Stack 双列或 Table,真实监测数据用 Chart,证据依据用 Evidence,补充资料用 FollowUpForm;普通短问答可只用文字。组件参数以工具描述中的 schema 为准,不输出 HTML、JSX、脚本或 JSONL 到聊天正文。
|
||||
|
||||
一个界面块发布后可以继续查询或解释,再发布下一块;相同 block_id 更新本轮同一块。不要重复在文字中抄写整张表或图。每个 Chart 和 Metric 保留单位或来源;查询事实、用户事实、推断和演练数据分别标注。MapLocation 仅提供用户主动点击后的定位入口。FollowUpForm 只收集输入,用户提交后形成新的对话消息,不能在未提交时当作已确认事实。后台 task_execution 使用原任务进度与展示渠道,不调用 present_answer。
|
||||
|
||||
已发布本地交互界面的普通/协作聊天在 `final_answer` 完成后结束本轮,不能在最终答复之后继续调用 `present_answer`。需要补充界面应先更新完再提交最终答复。后台调查执行仍按原有任务运行和完成接口收尾。
|
||||
|
||||
`final_answer.answer` 是完整的最后报告,保留结论和必要边界;在 ui_block_ids 中明确引用应保留在报告内的已发布 block_id(按报告顺序)。其余流式文字、界面与轨迹在最终报告到达后统一折叠为处理过程,不能把报告必需的图表、证据或资料表单遗漏在折叠过程里。
|
||||
|
||||
`final_answer` 保留完整报告和必要边界,不抹掉已发布界面,也不把资料记录成功说成浏览器已展示、业务处置完成或现场核验完成。
|
||||
|
||||
1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。
|
||||
2. 每次普通工具调用必须填写具体 `reason`。
|
||||
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
|
||||
|
||||
@@ -2,6 +2,7 @@ import query_project_data from "../tools/query_project_data.js";
|
||||
import web_search from "../tools/web_search.js";
|
||||
import geocode from "../tools/geocode.js";
|
||||
import final_answer from "../tools/final_answer.js";
|
||||
import present_answer from "../tools/present_answer.js";
|
||||
import locate_features from "../tools/locate_features.js";
|
||||
import zoom_to_map from "../tools/zoom_to_map.js";
|
||||
import show_chart from "../tools/show_chart.js";
|
||||
@@ -31,6 +32,7 @@ const businessTools = {
|
||||
geocode,
|
||||
query_project_data,
|
||||
final_answer,
|
||||
present_answer,
|
||||
locate_features,
|
||||
zoom_to_map,
|
||||
show_chart,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { tool } from "../tool.js";
|
||||
|
||||
export default tool({
|
||||
description: "提交本轮完整的最终答复。只在全部工具结果、浏览器回执和业务写回已核实后调用;阶段说明留在过程记录。此工具不停止执行,也不代表任务、工单或运行已完成。",
|
||||
args: { answer: tool.schema.string().min(1).max(30000) },
|
||||
async execute({ answer }) { return JSON.stringify({ answer }); },
|
||||
description: "提交本轮完整的最终答复。全部工具结果与需要的业务写回已核实后调用;需要浏览器动作回执时先核实回执,本地回答界面发布不等待浏览器回执;阶段说明留在过程记录。answer 是最后报告的完整文字;ui_block_ids 只列已经用 present_answer 发布且应保留在最终报告中的 block_id,按报告顺序排列。收到最终报告后,其他过程文字、界面与轨迹自动折叠。已发布本地交互界面的聊天会在此工具完成后结束本轮,之后不能继续更新界面;需要补充内容应先更新再提交最终答复。后台任务仍以 Server 的完成/失败接口为准,此工具不代表任务、工单或运行已完成。",
|
||||
args: {
|
||||
answer: tool.schema.string().min(1).max(30000),
|
||||
ui_block_ids: tool.schema.array(tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/)).max(48).optional(),
|
||||
},
|
||||
async execute({ answer, ui_block_ids }) { return JSON.stringify({ answer, ui_block_ids: ui_block_ids ?? [] }); },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { tool } from "../tool.js";
|
||||
import { answerUiInputSchema } from "../../src/chat/answer-ui.js";
|
||||
import catalog from "../../src/chat/answer-ui-catalog.json" with { type: "json" };
|
||||
|
||||
export default tool({
|
||||
description: "把回答的一段内容发布为本地交互界面。spec 是 root + elements 的扁平树,每个元素包括 type、props、children。只组织真实查询资料或用户事实;无脚本、URL 或任意代码。不执行地图或业务动作,不等待浏览器,不代表业务完成。相同 block_id 更新本轮同一内容块。组件参数的 JSON Schema:" + JSON.stringify(catalog.props),
|
||||
args: {
|
||||
block_id: tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/),
|
||||
fallback_text: tool.schema.string().min(1).max(4000),
|
||||
spec: tool.schema.object({ root: tool.schema.string(), elements: tool.schema.record(tool.schema.string(), tool.schema.object({
|
||||
type: tool.schema.string(), props: tool.schema.record(tool.schema.string(), tool.schema.unknown()), children: tool.schema.array(tool.schema.string()).max(48),
|
||||
}).strict()) }).strict(),
|
||||
},
|
||||
async execute(args) {
|
||||
const value = answerUiInputSchema.parse(args);
|
||||
return JSON.stringify({ version: "answer-ui@1", ...value, recorded: true });
|
||||
},
|
||||
});
|
||||
@@ -341,3 +341,21 @@ volumes:
|
||||
[CI 工作流](.gitea/workflows/package.yml) 在推送 `v*` 标签或手动触发时调用共享容器流水线。当前镜像名为 `gitea.waternetwork.cn/orgtjwater/next-tjwater-agent`,部署服务名为 `next-agent`;仓库需设置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD`、`DEV_DEPLOY_SSH_KEY`。具体候选验证和发布步骤由共享模板定义。
|
||||
|
||||
代码修改约束见 [AGENTS.md](AGENTS.md),前端协议见 [Agent UI 协议](docs/agent-ui-protocol.md),任务计划规则见 [工况执行计划](docs/execution-plan.md)。
|
||||
|
||||
### 本地交互式聊天回答
|
||||
|
||||
原生插件通过 `present_answer` 发布 `answer-ui@1` 内容块,现有聊天消息流输出
|
||||
`data-answer_ui`,允许文字与本地指标、表格、图表、证据、地图定位和补充资料表单交替出现。
|
||||
相同 block_id 更新本轮内容;消息持久化与历史恢复保留结构化部分。最终答复包含
|
||||
完整报告文字及 ui_block_ids 引用的已发布组件,其余过程默认折叠。已发布界面的
|
||||
聊天在最终答复完成且无待完成工具时收尾,不代替后台任务的完成接口。后台调查执行
|
||||
不发布聊天界面块,继续使用原任务报告和展示通道。
|
||||
|
||||
组件参数契约由供水前端 catalog 导出到 `src/chat/answer-ui-catalog.json`,
|
||||
本服务按该契约校验组件参数及树结构。前端与 Agent 需同步更新,插件变化需要重启
|
||||
Agent,客户端模式也需更新外部 OpenCode 的可信资产。无需新增 domain key、
|
||||
UI 托管服务配置或数据库迁移。详见
|
||||
[前端接入与扩展说明](../next-tjwater-frontend/docs/agent-answer-ui.md)。
|
||||
|
||||
原生工具发现验证:`bun run tests/integration/answer-ui-smoke.ts`,使用独立临时数据库,
|
||||
不调用模型推理,不接管既有服务。
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@opencode/client": "2.0.24",
|
||||
"ai": "7.0.8",
|
||||
"ajv": "^8.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
@@ -98,6 +99,8 @@
|
||||
|
||||
"ai": ["ai@7.0.8", "https://registry.npmmirror.com/ai/-/ai-7.0.8.tgz", { "dependencies": { "@ai-sdk/gateway": "4.0.6", "@ai-sdk/provider": "4.0.1", "@ai-sdk/provider-utils": "5.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vTEKl6fDBZ2IxBXTRaZOajf9W2Ev57Ju8iKtUvqlmDk8Z9BrEP4c22SWJsg1RcWHSFmJMSBa/s5dlUBHUq3YwA=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "https://registry.npmmirror.com/ansi-colors/-/ansi-colors-4.1.3.tgz", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -180,6 +183,8 @@
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
@@ -267,6 +267,11 @@
|
||||
"resource": "*",
|
||||
"effect": "allow"
|
||||
},
|
||||
{
|
||||
"action": "present_answer",
|
||||
"resource": "*",
|
||||
"effect": "allow"
|
||||
},
|
||||
{
|
||||
"action": "locate_features",
|
||||
"resource": "*",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@opencode/client": "2.0.24",
|
||||
"ai": "7.0.8",
|
||||
"ajv": "^8.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { UIMessageChunk } from "ai";
|
||||
import { createAnswerStreamProjection } from "../../src/chat/answer-stream-projection.js";
|
||||
|
||||
describe("canonical answer stream projection", () => {
|
||||
it("splits text around UI blocks, updates them in place and finishes each segment exactly once", () => {
|
||||
const chunks: UIMessageChunk[] = [];
|
||||
const stream = createAnswerStreamProjection({ messageId: "a", sessionId: "s", write: (chunk) => chunks.push(chunk), onChange() {} });
|
||||
stream.appendText("查询资料。");
|
||||
stream.appendData("answer_ui", { block_id: "chart", fallback_text: "初始" });
|
||||
stream.appendData("answer_ui", { block_id: "chart", fallback_text: "更新" });
|
||||
stream.appendText("待现场核验。");
|
||||
stream.finish(); stream.finish(); stream.appendText("不应追加");
|
||||
expect(stream.parts.map((part) => part.type)).toEqual(["text", "data-answer_ui", "text"]);
|
||||
expect(stream.parts[1].data).toEqual({ block_id: "chart", fallback_text: "更新" });
|
||||
expect(stream.parts[0].text).toBe("查询资料。");
|
||||
expect(stream.parts[2].text).toBe("待现场核验。");
|
||||
expect(chunks.filter((chunk) => chunk.type === "text-start").map((chunk) => chunk.id)).toEqual(["answer", "answer-1"]);
|
||||
expect(chunks.filter((chunk) => chunk.type === "text-end").map((chunk) => chunk.id)).toEqual(["answer", "answer-1"]);
|
||||
expect(chunks.filter((chunk) => chunk.type === "finish")).toHaveLength(1);
|
||||
});
|
||||
it("continues canonical parts for persistence after the browser disconnects", () => {
|
||||
let connected = true;
|
||||
const written: UIMessageChunk[] = [];
|
||||
const snapshots: unknown[] = [];
|
||||
const stream = createAnswerStreamProjection({ messageId: "a", sessionId: "s", write: (chunk) => { if (connected) written.push(chunk); }, onChange: () => snapshots.push(structuredClone(stream.parts)) });
|
||||
stream.appendText("第一段");
|
||||
connected = false;
|
||||
const count = written.length;
|
||||
stream.appendData("answer_ui", { block_id: "evidence", fallback_text: "依据" });
|
||||
stream.appendText("第二段"); stream.finish();
|
||||
expect(written).toHaveLength(count);
|
||||
expect(snapshots.at(-1)).toEqual([
|
||||
{ type: "text", text: "第一段", state: "done" },
|
||||
{ type: "data-answer_ui", id: "evidence", data: { block_id: "evidence", fallback_text: "依据" } },
|
||||
{ type: "text", text: "第二段", state: "done" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
|
||||
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
||||
import { toCollaborationMessages } from "../../src/routes/chatStreamRoute.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
const input = { block_id: "pressure", fallback_text: "演练压力 0.3 MPa", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
|
||||
describe("locally rendered answers", () => {
|
||||
it("validates catalog props instead of accepting arbitrary model components", () => {
|
||||
expect(parseAnswerUiInput(input)?.version).toBe("answer-ui@1");
|
||||
for (const metric of [
|
||||
{ ...input.spec.elements.metric, type: "Script" },
|
||||
{ ...input.spec.elements.metric, props: { ...input.spec.elements.metric.props, execute: "alert(1)" } },
|
||||
{ ...input.spec.elements.metric, children: ["metric"] },
|
||||
]) expect(parseAnswerUiInput({ ...input, spec: { root: "metric", elements: { metric } } })).toBeNull();
|
||||
});
|
||||
it("rejects chart misalignment, nonfinite numbers and unreachable nodes", () => {
|
||||
const node = { type: "Chart", props: { title: "演练", chart_type: "line", source: "演练数据", x_data: ["08:00"], series: [{ name: "压力", data: [0.3] }] }, children: [] };
|
||||
const withNode = (value: unknown) => ({ ...input, spec: { root: "chart", elements: { chart: value } } });
|
||||
expect(parseAnswerUiInput(withNode(node))).not.toBeNull();
|
||||
for (const data of [[0.3, 0.4], [Infinity], [NaN]]) expect(parseAnswerUiInput(withNode({ ...node, props: { ...node.props, series: [{ name: "压力", data }] } }))).toBeNull();
|
||||
expect(parseAnswerUiInput({ ...input, spec: { ...input.spec, elements: { ...input.spec.elements, unused: node } } })).toBeNull();
|
||||
});
|
||||
it("flushes prose before the interface, publishes once per tool call, and suppresses background UI", async () => {
|
||||
const part = { id: "ui-call", type: "tool", tool: "present_answer", sessionID: "s", messageID: "m", state: { status: "completed", input, output: "{}", time: { start: 1, end: 2 } } };
|
||||
const events = [
|
||||
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "先看演练资料。", sessionID: "s", messageID: "m" } } },
|
||||
...[part, part].map((value) => ({ type: "message.part.updated", properties: { sessionID: "s", part: value } })),
|
||||
{ type: "session.idle", properties: { sessionID: "s" } },
|
||||
];
|
||||
const runtime = { prompt: async () => undefined, messages: async () => [], subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
|
||||
for (const suppressAnswerUi of [false, true]) {
|
||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", suppressAnswerUi, write: (event, data) => emitted.push({ event, data }) });
|
||||
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(suppressAnswerUi ? 0 : 1);
|
||||
if (!suppressAnswerUi) expect(emitted.findIndex((event) => event.event === "token")).toBeLessThan(emitted.findIndex((event) => event.event === "answer_ui"));
|
||||
}
|
||||
});
|
||||
it("preserves UI-only answers and ordered rich parts in task collaboration history", () => {
|
||||
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍需核验。" }, { type: "data-final_answer", data: { content: "结论待确认。" } }];
|
||||
const messages = [{ id: "a", role: "assistant", parts: [...parts, { type: "data-progress", data: { title: "查询" } }] }, { id: "b", role: "assistant", parts: [parts[1]] }] as unknown as UIMessage[];
|
||||
const restored = toCollaborationMessages(messages);
|
||||
expect(restored[0].parts).toEqual(parts);
|
||||
expect(restored[1].parts).toEqual([parts[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
it("finishes a rich chat turn at final_answer and prevents later automatic UI revisions", async () => {
|
||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const interrupted: string[] = [];
|
||||
const toolPart = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } });
|
||||
const events = [toolPart("ui", "present_answer", input), toolPart("final", "final_answer", { answer: "演练结论仍待核验。" }, JSON.stringify({ answer: "演练结论仍待核验。", ui_block_ids: ["pressure", "unknown", "pressure"] })), toolPart("late", "present_answer", { ...input, block_id: "late" }), { type: "session.idle", properties: { sessionID: "s" } }];
|
||||
const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async (id: string) => { interrupted.push(id); }, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) });
|
||||
expect(result).toMatchObject({ failed: false, aborted: false });
|
||||
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(1);
|
||||
expect(interrupted).toEqual(["s"]);
|
||||
expect(emitted.find((event) => event.event === "final_answer")?.data.content).toBe("演练结论仍待核验。");
|
||||
expect(emitted.find((event) => event.event === "final_answer")?.data.ui_block_ids).toEqual(["pressure"]);
|
||||
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
|
||||
interrupted.length = 0;
|
||||
emitted.length = 0;
|
||||
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "后台执行", suppressAnswerUi: true, write: (event, data) => emitted.push({ event, data }) });
|
||||
expect(interrupted).toEqual([]);
|
||||
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(0);
|
||||
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Opt-in native plugin discovery in an isolated database. No inference requests.
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "tjwater-answer-ui-smoke-"));
|
||||
const directory = join(root, "workspace");
|
||||
await mkdir(directory);
|
||||
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
|
||||
let sessionID: string | undefined;
|
||||
try {
|
||||
native = await startOpencode2({
|
||||
command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
|
||||
config: serviceOpencodeConfig(JSON.parse(await readFile("opencode.json", "utf8")), undefined, root),
|
||||
env: { AGENT_INTERNAL_TOKEN: randomBytes(32).toString("hex"), TJWATER_AGENT_WORKSPACE_ROOT: root,
|
||||
WATERNETWORK_API_KEY: "DISCOVERY_TEST_ONLY", XDG_DATA_HOME: join(root, "native-data"),
|
||||
OPENCODE_DB: join(root, "native-data", "opencode", "opencode.db"), XDG_CONFIG_HOME: join(root, "native-config"),
|
||||
XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") },
|
||||
});
|
||||
sessionID = (await native.client.session.create({ agent: "instruction", location: { directory } })).id;
|
||||
const response = await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } });
|
||||
const result = response.output as Array<{ id: string; direct: boolean }>;
|
||||
assert.ok(result.some((tool) => tool.id === "present_answer" && tool.direct), "present_answer must be registered as a direct native tool");
|
||||
assert.equal((await native.client.permission.create({ sessionID, action: "present_answer", resources: ["*"], save: [] })).effect, "allow");
|
||||
console.log("Native present_answer discovery and permission passed. No inference requests.");
|
||||
} finally {
|
||||
if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {});
|
||||
native?.close();
|
||||
if (native) await native.exited;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -141,7 +141,7 @@ describe("resolveEmbeddedOpencodePort", () => {
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
const toolIds = ["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 toolIds = ["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"];
|
||||
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string) {
|
||||
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
|
||||
Reference in New Issue
Block a user