Compare commits

..
12 Commits
59 changed files with 3697 additions and 2804 deletions
+4 -1
View File
@@ -8,7 +8,10 @@ OPENCODE_HOSTNAME=127.0.0.1
OPENCODE_PORT=4096
AGENT_LOCAL_USER_ID=local-user
AGENT_LOCAL_PROJECT_ID=local-project
# 必须填写 Server /api/v1/projects 返回的项目 UUID。
AGENT_LOCAL_PROJECT_ID=00000000-0000-0000-0000-000000000000
AGENT_LOCAL_NETWORK=local-network
TJWATER_API_BASE_URL=http://127.0.0.1:8000
TJWATER_API_ENABLED=true
TJWATER_AUTH_MODE=disabled
+25 -20
View File
@@ -4,7 +4,7 @@ mode: primary
model: deepseek/deepseek-v4-flash
temperature: 0.2
---
你是 TJWater 实验性供水管网分析 Agent。回复用户时使用简体中文,内容简洁准确。
你是 TJWater 实验性排水数据 Agent。回复用户时使用简体中文,内容简洁准确。
## 核心定位
@@ -15,46 +15,51 @@ Agent 负责:
1. 理解用户意图。
2. 调用受控工具获取有限上下文或表达展示意图。
3. 输出文字摘要、结论、追问和建议。
4. 通过前端展示工具表达 UI 意图
服务端负责把前端展示工具转换成 `agent-ui@1` 的 UIEnvelope;前端只渲染可信组件、图表和地图动作。
4. 在分析结论完成后,可将 SCADA 等级结果作为受控地图动作发送到浏览器
## 工具选择
| 场景 | 工具 |
|------|------|
| 查询实时公开网页信息 | `web_search` |
| 地址/地点转经纬度 | `geocode` |
| 地图定位 | `locate_features``zoom_to_map` |
| 图表展示 | `show_chart` |
| 面板展示 | `view_history``view_scada` |
| 地图渲染/样式 | `render_junctions``apply_layer_style` |
| 持久化大渲染数据 | 先准备 `{ node_area_map }` JSON 文件,再用 `store_render_ref` 存为受控 `render_ref`,最后调用 `render_junctions` |
| 历史会话检索 | `session_search` |
| 用户偏好和项目事实 | `memory_manager` |
| 可复用流程沉淀 | `skill_manager` |
当前实验版没有通用 TJWater 数据查询工具;如果缺少业务数据,应明确说明数据能力尚未接入,不得编造结果。
| 渲染 SCADA 分析结果 | `render_scada_analysis` |
| 清除 SCADA 分析结果 | `clear_scada_analysis` |
## UI 约束
1. 前端工具仅表达展示意图,不返回业务数据
2. 每次工具调用必须填写具体 `reason`
3. 图表使用 `show_chart`,折线图/柱状图必须用 `x_data` 作为横轴标签,`series[].data` 作为同长度数值数组
4. 大型 junction 渲染必须使用 `render_ref`,不要直接把完整 payload 传给前端
5. 不生成 JS、JSX、HTML、CSS 或可执行前端代码
1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级
2. 每次普通工具调用必须填写具体 `reason`
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码
4. SCADA 地图结果只允许使用可信 `sensor_id``high | medium | low | unrated` 等级,不得传入自定义标签、颜色或 HTML
5. 同一批结果不得包含重复 `sensor_id`,每次最多 100 个点位
6. 只有 SCADA 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。
## 执行约束
1. 无可用数据时不得编造结论。
2. 不要调用 shell、Python 或 CLI 来拼装业务数据流程。
2. 不要自由拼接 shell、Python 或 CLI 业务数据流程。仅可对 `source/` 数据执行已验证 Skill 内明确指定的只读分析脚本。
3. 不要读取或写入 token、password、secret、API key、system prompt。
4. 尽量不用子代理,保持过程可观测。
## 工作流沉淀
只有在当前对话流程已经验证有效、可被复用且用户明确需要保存时,才使用 `skill_manager` 沉淀 workflow。实验阶段 workflow 不应记录 CLI、shell pipe 或认证上下文。
只有在当前对话流程已经验证有效、可被复用且用户明确需要保存时,才使用 `skill_manager` 沉淀 workflow。实验阶段 workflow 不应记录自由拼接的 CLI、shell pipe 或认证上下文;可引用 Skill 内已验证的固定只读分析脚本
## 记忆持久化
`memory_manager` 只保存长期有效的稳定事实或用户偏好。修改 memory 前先 list 当前 scope,再决定 add、replace 或 remove。
## TJWater Server
当前不提供依赖 TJWater Server 的工具。不得自行构造 URL、HTTP header、SQL、命令或项目 ID 访问 Server。监测数据统一从项目 `source/` 目录读取。
## 本地数据分析
1. 先读取 `source/catalog.json` 选择数据源,再读取对应数据包的 `manifest.json` 和所需 JSONL。
2. 用户明确指定编号或场景时,只使用对应数据包。例如只分析污染源接入时选择 `scenario_code=07`
3. 用户要求雨污混接、雨水混接、地下水入渗或外来水稀释分析,且未指定其他数据源时,默认选择 `scenario_code=01`
4. 用户未明确场景时,先用 `scenario_code=00` 总览判断匹配场景;除非用户要求综合对比,不得默认混合多个场景数据。
5. 使用与任务匹配的 Skill 执行数据准入、计算和结论约束。
6. 只读取与分析数据;不覆盖、删除或回写 `source/` 原始数据。
7. 数值结论必须说明数据集、时间范围、粒度、指标和计算口径。
@@ -0,0 +1,13 @@
import type { Plugin } from "@opencode-ai/plugin";
const FRONTEND_ACTION_TOOLS = new Set([
"render_scada_analysis",
"clear_scada_analysis",
]);
export const frontendActionCallIdPlugin: Plugin = async () => ({
"tool.execute.before": async (input, output) => {
if (!FRONTEND_ACTION_TOOLS.has(input.tool)) return;
output.args.__frontend_action_call_id = input.callID;
},
});
-94
View File
@@ -1,94 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
args: {
reason: tool.schema
.string()
.describe(
"Why this style action is needed for the current user request.",
),
layer_id: tool.schema
.enum(["junctions", "pipes"])
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
reset_to_default: tool.schema
.boolean()
.optional()
.describe("Whether to reset the target layer to its default style."),
style_config: tool.schema
.object({
property: tool.schema
.string()
.optional()
.describe("Data property to render."),
classification_method: tool.schema
.enum(["pretty_breaks", "custom_breaks"])
.optional()
.describe("Classification method."),
segments: tool.schema
.number()
.optional()
.describe("Number of segments."),
min_size: tool.schema
.number()
.optional()
.describe("Minimum point radius."),
max_size: tool.schema
.number()
.optional()
.describe("Maximum point radius."),
min_stroke_width: tool.schema
.number()
.optional()
.describe("Minimum line width."),
max_stroke_width: tool.schema
.number()
.optional()
.describe("Maximum line width."),
fixed_stroke_width: tool.schema
.number()
.optional()
.describe("Fixed line width when width is not data-driven."),
color_type: tool.schema
.enum(["single", "gradient", "rainbow", "custom"])
.optional()
.describe("Color strategy."),
single_palette_index: tool.schema.number().optional(),
gradient_palette_index: tool.schema.number().optional(),
rainbow_palette_index: tool.schema.number().optional(),
show_labels: tool.schema
.boolean()
.optional()
.describe("Whether to show labels."),
show_id: tool.schema
.boolean()
.optional()
.describe("Whether to show ids."),
opacity: tool.schema.number().optional().describe("Opacity in [0, 1]."),
adjust_width_by_property: tool.schema
.boolean()
.optional()
.describe("Whether line width is driven by the rendered property."),
custom_breaks: tool.schema
.array(tool.schema.number())
.optional()
.describe("Custom break values."),
custom_colors: tool.schema
.array(tool.schema.string())
.optional()
.describe("Custom rgba colors."),
})
.optional()
.describe(
"Optional style config overrides. Omit when reset_to_default is true.",
),
},
async execute(args) {
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
if (args.reset_to_default) {
return `已将${layerLabel}图层重置为默认样式。`;
}
return `已对${layerLabel}图层应用样式。`;
},
});
+17
View File
@@ -0,0 +1,17 @@
import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({
description: "清除当前浏览器地图上的 Agent SCADA 分析结果覆盖层。",
args: {
reason: tool.schema
.string()
.trim()
.min(1)
.describe("Why the current SCADA analysis overlay should be cleared."),
},
async execute(args, context) {
return executeFrontendAction("clear_scada_analysis", args, context);
},
});
+76
View File
@@ -0,0 +1,76 @@
type FrontendActionArgs = Record<string, unknown> & {
__frontend_action_call_id?: string;
reason?: string;
};
type FrontendActionBridgeResult = {
version: "frontend-action-result@1";
status: "succeeded" | "failed" | "rejected" | "cancelled" | "expired";
output?: unknown;
error?: { code?: string; message?: string };
};
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export const executeFrontendAction = async (
name: "render_scada_analysis" | "clear_scada_analysis",
args: FrontendActionArgs,
context: { sessionID: string },
) => {
const callId = args.__frontend_action_call_id;
if (!callId) {
throw new Error("frontend action bridge did not inject the OpenCode call ID");
}
const { __frontend_action_call_id: _callId, reason, ...params } = args;
let response: Response;
try {
response = await fetch(`${internalBaseUrl}/internal/frontend-actions/request`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
call_id: callId,
name,
params,
fallback_text: reason,
}),
});
} catch (error) {
throw new Error(
`frontend action bridge unavailable: ${error instanceof Error ? error.message : String(error)}`,
);
}
const text = await response.text();
if (!response.ok) {
throw new Error(
`frontend action bridge rejected request (${response.status}): ${text}`,
);
}
return unwrapFrontendActionResult(text);
};
export const unwrapFrontendActionResult = (text: string) => {
let result: FrontendActionBridgeResult;
try {
result = JSON.parse(text) as FrontendActionBridgeResult;
} catch {
throw new Error("frontend action bridge returned invalid JSON");
}
if (result.version !== "frontend-action-result@1" || typeof result.status !== "string") {
throw new Error("frontend action bridge returned an invalid result");
}
if (result.status !== "succeeded") {
const code = result.error?.code?.trim() || `ACTION_${result.status.toUpperCase()}`;
const message = result.error?.message?.trim() || `browser action ${result.status}`;
throw new Error(`${code}: ${message}`);
}
if (result.output === undefined) {
throw new Error("frontend action bridge returned no output");
}
return JSON.stringify(result.output);
};
-37
View File
@@ -1,37 +0,0 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
args: {
reason: tool.schema
.string()
.describe("Why geocoding is required for the current user request."),
keyword: tool.schema
.string()
.describe("Address or place name to geocode, such as 北京市人民政府."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/geocode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
keyword: args.keyword,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-22
View File
@@ -1,22 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description: "在前端地图上定位并高亮指定的管网要素。",
args: {
reason: tool.schema
.string()
.describe(
"Why this map positioning action is needed for the user request.",
),
ids: tool.schema
.array(tool.schema.string())
.describe("Feature ids to locate."),
feature_type: tool.schema
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
.describe("Type of feature to locate."),
},
async execute() {
// 前端工具只负责生成 tool part,真正的地图动作由 Agent SSE 适配层转发给浏览器执行。
return "已在地图上定位到指定要素。";
},
});
-22
View File
@@ -1,22 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。",
args: {
reason: tool.schema
.string()
.describe(
"Why this junction rendering action is needed for the user request.",
),
render_ref: tool.schema
.string()
.describe(
"上一步通过 store_render_ref 获得的渲染引用 ID(res-...)。前端会按该引用拉取完整 payload 并渲染。不可直接传入本地文件路径或完整 JSON 数据。",
),
},
async execute() {
// 工具参数里只需要 render_ref;浏览器端会再用该引用回读完整 payload.data 并完成渲染。
return "已在地图上应用节点分区渲染。";
},
});
+36
View File
@@ -0,0 +1,36 @@
import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
const sensorId = tool.schema.string().trim().min(1).max(128);
export default tool({
description:
"将已经完成分析并确定等级的 SCADA 点位结果高亮、编号并定位到前端地图。等级只能来自已完成的分析结论。",
args: {
reason: tool.schema
.string()
.trim()
.min(1)
.describe("Why the completed SCADA analysis should be rendered on the map."),
items: tool.schema
.array(
tool.schema.object({
sensor_id: sensorId.describe("Trusted SCADA sensor identifier."),
level: tool.schema
.enum(["high", "medium", "low", "unrated"])
.describe("Completed analysis level; use unrated only when no level was concluded."),
}),
)
.min(1)
.max(100)
.refine(
(items) => new Set(items.map((item) => item.sensor_id)).size === items.length,
"sensor_id values must be unique",
)
.describe("One to one hundred unique SCADA analysis results."),
},
async execute(args, context) {
return executeFrontendAction("render_scada_analysis", args, context);
},
});
-42
View File
@@ -1,42 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
args: {
reason: tool.schema
.string()
.describe("Why this chart should be rendered for the user request."),
title: tool.schema.string().optional().describe("Chart title."),
chart_type: tool.schema
.enum(["line", "bar", "pie"])
.optional()
.describe("Chart type."),
x_data: tool.schema
.array(tool.schema.string())
.describe("X-axis labels. For line charts, put time/category labels here."),
series: tool.schema
.array(
tool.schema.object({
name: tool.schema.string(),
data: tool.schema
.array(tool.schema.number())
.describe("Y values only. Must align by index with x_data."),
type: tool.schema.enum(["line", "bar"]).optional(),
}),
)
.describe("Series data."),
x_axis_name: tool.schema
.string()
.optional()
.describe("X-axis display name."),
y_axis_name: tool.schema
.string()
.optional()
.describe("Y-axis display name."),
},
async execute() {
// 图表数据已经在工具参数里,前端收到 tool_call 后直接渲染,不再二次请求后端。
return "图表将在对话中显示。";
},
});
-44
View File
@@ -1,44 +0,0 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_refres-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。",
args: {
reason: tool.schema
.string()
.describe(
"为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
),
file_path: tool.schema
.string()
.describe(
"本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。",
),
},
async execute(args, context) {
const response = await fetch(
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
file_path: args.file_path,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-30
View File
@@ -1,30 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
args: {
reason: tool.schema
.string()
.describe(
"Why this history panel should be opened for the current task.",
),
feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."),
data_type: tool.schema
.enum(["realtime", "scheme", "none"])
.describe("History data source type."),
start_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
},
async execute() {
// 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。
return "已打开计算结果面板。";
},
});
-30
View File
@@ -1,30 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description: "打开前端的 SCADA 监测数据历史面板。",
args: {
reason: tool.schema
.string()
.describe("Why SCADA panel interaction is required for this request."),
device_ids: tool.schema
.array(tool.schema.string())
.optional()
.describe("Preferred SCADA device ids."),
device_id: tool.schema
.string()
.optional()
.describe("Single SCADA device id."),
start_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
},
async execute() {
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。
return "已打开 SCADA 监测面板。";
},
});
-62
View File
@@ -1,62 +0,0 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: {
reason: tool.schema
.string()
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."),
freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
.optional()
.describe("Optional freshness filter. Defaults to no_limit."),
summary: tool.schema
.boolean()
.optional()
.describe("Whether the backend should include page summaries."),
count: tool.schema
.number()
.int()
.positive()
.optional()
.describe("Optional result count, backend accepts 1 to 50."),
include: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to include."),
exclude: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to exclude."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/web-search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
query: args.query,
freshness: args.freshness,
summary: args.summary,
count: args.count,
include: args.include,
exclude: args.exclude,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-32
View File
@@ -1,32 +0,0 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
args: {
reason: tool.schema
.string()
.describe("Why this map zoom action is needed for the current request."),
x: tool.schema
.number()
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
y: tool.schema
.number()
.describe("Y coordinate. For EPSG:4326 this is latitude; for EPSG:3857 this is meters."),
source_crs: tool.schema
.enum(["EPSG:3857", "EPSG:4326"])
.optional()
.describe("Input coordinate CRS. Defaults to EPSG:3857."),
zoom: tool.schema
.number()
.optional()
.describe("Optional OpenLayers zoom level. Defaults to 18."),
duration_ms: tool.schema
.number()
.optional()
.describe("Optional animation duration in milliseconds. Defaults to 1000."),
},
async execute() {
return "已缩放到指定地图坐标。";
},
});
+56 -47
View File
@@ -1,53 +1,52 @@
# TJWaterAgentExperimental
# TJWater Agent 实验服务
Experimental opencode-based Agent service for TJWater UI orchestration.
基于 OpenCode 的实验性 Agent 服务,用于编排 TJWater 前端交互。
This repository is intentionally separate from the production `TJWaterAgent`
history. It does not include user authentication or the old `tjwater-cli`
bridge. Runtime requests use a local default context and can later be wired to a
real auth provider through `src/context/`.
本仓库与生产环境中的 `TJWaterAgent` 历史代码相互独立,不包含用户认证,也不包含旧版 `tjwater-cli` 桥接层。运行时请求默认使用本地上下文,后续可通过 `src/context/` 接入实际的认证提供方。
## Current Boundaries
## 当前边界
- No Keycloak / metadata auth context.
- No required `Authorization` or `X-Project-Id` headers.
- No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge.
- Agent visual output is expressed through `agent-ui@1` UIEnvelope events.
- Backend data tools are not fully reintroduced yet; missing business data must
be reported honestly by the Agent.
- 不包含 Keycloak 或元数据认证上下文。
- 不强制要求 `Authorization` `X-Project-Id` 请求头。
- 不包含 `cli/``tjwater_cli` OpenCode 工具或自由形式的 CLI 命令桥接。
- Agent 的结构化界面输出使用 `agent-ui@1` UIEnvelope 事件;受控浏览器操作使用 `frontend-action@1` 前端动作。
- 依赖 TJWater Server 的工具处于禁用状态,Agent 不能访问任意 URL、SQL、Shell 命令或文件系统路径。
## Main API
## 主要 API
```text
GET /health
GET /api/v1/agent/chat/models
GET /api/v1/agent/chat/ui-registry
POST /api/v1/agent/chat/session
GET /api/v1/agent/chat/sessions
GET /api/v1/agent/chat/session/:session_id
GET /api/v1/agent/chat/session/:session_id/stream
POST /api/v1/agent/chat/stream
POST /api/v1/agent/chat/abort
GET /health
GET /api/v1/agent/chat/models
GET /api/v1/agent/chat/ui-registry
GET /api/v1/agent/chat/frontend-action-registry
POST /api/v1/agent/chat/frontend-actions/:action_id/result
POST /api/v1/agent/chat/session
GET /api/v1/agent/chat/sessions
GET /api/v1/agent/chat/session/:session_id
GET /api/v1/agent/chat/session/:session_id/stream
POST /api/v1/agent/chat/stream
POST /api/v1/agent/chat/abort
```
`POST /api/v1/agent/chat/stream` returns SSE. Important events:
`POST /api/v1/agent/chat/stream` 通过 SSE 返回事件流。主要事件如下:
| event | Purpose |
| 事件 | 用途 |
| --- | --- |
| `progress` | Agent planning/tool progress |
| `token` | Assistant text delta |
| `ui_envelope` | Structured UI description for trusted frontend rendering |
| `tool_call` | Deprecated compatibility/debug event |
| `permission_request` | Runtime permission request |
| `question_request` | Runtime/user clarification request |
| `done` | Run completed |
| `error` | Run failed or aborted |
| `progress` | Agent 规划和工具执行进度 |
| `token` | 助手回复的增量文本 |
| `ui_envelope` | 供可信前端渲染的结构化界面描述 |
| `frontend_action` | 请求可信前端执行受控动作 |
| `tool_call` | 用于兼容和调试的旧版工具调用事件 |
| `permission_request` | 运行时权限请求 |
| `question_request` | 运行时向用户发起的澄清请求 |
| `todo_update` | Agent 待办事项更新 |
| `session_title` | 会话标题更新 |
| `done` | 本次运行完成 |
| `error` | 本次运行失败或被中止 |
## Local Context
## 本地上下文
The service uses `src/context/localContext.ts`.
Defaults:
服务通过 `src/context/localContext.ts` 提供本地上下文,默认值如下:
```text
AGENT_LOCAL_USER_ID=local-user
@@ -55,7 +54,9 @@ AGENT_LOCAL_PROJECT_ID=local-project
AGENT_LOCAL_NETWORK=local-network
```
Optional request headers can override the local context during experiments:
监测时序数据从 Agent 项目的 `source/` 目录读取,并通过经过校验的本地技能进行分析。`get_project_context``list_scada_assets``query_scada_readings``web_search``geocode` 依赖 TJWater Server,因此当前不对外开放。
实验期间可以通过以下请求头覆盖本地上下文:
```text
X-Agent-Local-User
@@ -64,23 +65,31 @@ X-Agent-Local-Network
X-Trace-Id
```
## UIEnvelope
## UIEnvelope 与前端动作
The registry is exposed at:
UIEnvelope 注册表通过以下接口提供:
```text
GET /api/v1/agent/chat/ui-registry
```
Visual opencode tools are mapped by the service:
该接口用于兼容 `agent-ui@1` 协议。目前没有注册图表语法、界面组件或 UIEnvelope 动作。
- `show_chart` -> `chart`
- `locate_features`, `zoom_to_map`, `render_junctions`, `apply_layer_style` -> `map_action`
- `view_history`, `view_scada` -> `registered_component`
前端动作使用独立的注册表:
See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md).
```text
GET /api/v1/agent/chat/frontend-action-registry
```
## Development
当前提供 `render_scada_analysis``clear_scada_analysis` 两个地图叠加层动作。浏览器需要在流式请求中声明 `frontend-action@1` 能力,执行动作后再通过以下接口回传结果:
```text
POST /api/v1/agent/chat/frontend-actions/:action_id/result
```
协议详情参见 [Agent UI 协议](docs/agent-ui-protocol.md)。
## 本地开发
```bash
bun install
@@ -89,4 +98,4 @@ bun run check
bun test tests/**/*.test.ts
```
`bun run check` typechecks the service and `.opencode` tools.
`bun run check` 会检查服务代码和 `.opencode` 工具的类型。
+65
View File
@@ -19,6 +19,7 @@
"@types/express": "^5.0.3",
"@types/node": "^24.7.2",
"bun-types": "^1.3.3",
"openapi-typescript": "^7.10.1",
"typescript": "^5.9.3",
},
},
@@ -30,10 +31,20 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.2", "", { "dependencies": { "@ai-sdk/provider": "4.0.1", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EcmdjJb7yggsZPCbS3MFBpvAUnKaPW+QvanU5GzF00XCq0bqqAmvJ3MN19ejlmOETbW8sJNiq6qam48wTcbUNw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
"@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.17", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.2.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
@@ -64,14 +75,24 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@7.0.8", "", { "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=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -80,6 +101,8 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
@@ -128,6 +151,8 @@
"fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"finalhandler": ["finalhandler@1.3.2", "", { "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=="],
@@ -152,8 +177,12 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -162,8 +191,16 @@
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
@@ -178,6 +215,8 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -194,12 +233,18 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
@@ -208,6 +253,8 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -224,6 +271,8 @@
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
@@ -258,10 +307,14 @@
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -270,6 +323,8 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -278,12 +333,22 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
+5 -15
View File
@@ -77,22 +77,12 @@ is valid only on `map_overlay`; every action and parameter set must be validated
by the frontend before it can affect MapLibre state. Invalid map actions should
fall back to `fallbackText`.
Initial mappings:
There are currently no Agent tool mappings for charts, components, or map
actions. The protocol types remain documented for compatibility and future
allowlisted actions.
- `show_chart` -> `chart`
- `locate_features` -> `map_action`
- `zoom_to_map` -> `map_action`
- `render_junctions` -> `map_action`
- `apply_layer_style` -> `map_action`
- `view_history` -> `registered_component: HistoryPanel`
- `view_scada` -> `registered_component: ScadaPanel`
`render_junctions` accepts only a `render_ref`. The frontend must resolve the
full payload through:
```text
GET /api/v1/agent/chat/render-ref/:render_ref?session_id=:session_id
```
Historical monitoring values for analysis come from the project `source/`
directory.
## State Persistence
+703
View File
@@ -0,0 +1,703 @@
{
"openapi": "3.1.0",
"info": {
"title": "Next TJWater Data API",
"version": "0.1.0"
},
"paths": {
"/api/v1/projects": {
"get": {
"tags": [
"projects"
],
"summary": "Projects",
"operationId": "projects_api_v1_projects_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/ProjectResponse"
},
"type": "array",
"title": "Response Projects Api V1 Projects Get"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
}
},
"/api/v1/scada/assets": {
"get": {
"tags": [
"scada"
],
"summary": "Assets",
"operationId": "assets_api_v1_scada_assets_get",
"security": [
{
"OAuth2PasswordBearer": []
}
],
"parameters": [
{
"name": "X-Project-Id",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "X-Project-Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScadaAssetsResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/scada/readings/query": {
"post": {
"tags": [
"scada"
],
"summary": "Readings",
"operationId": "readings_api_v1_scada_readings_query_post",
"security": [
{
"OAuth2PasswordBearer": []
}
],
"parameters": [
{
"name": "X-Project-Id",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "X-Project-Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScadaReadingQuery"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScadaReadingQueryResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/geocode": {
"post": {
"tags": [
"geocode"
],
"summary": "Geocode",
"operationId": "geocode_api_v1_geocode_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GeocodeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GeocodeResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
}
},
"/health/live": {
"get": {
"summary": "Live",
"operationId": "live_health_live_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Live Health Live Get"
}
}
}
}
}
}
},
"/health/ready": {
"get": {
"summary": "Ready",
"operationId": "ready_health_ready_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Ready Health Ready Get"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"GeocodeLocation": {
"properties": {
"lon": {
"type": "number",
"title": "Lon"
},
"lat": {
"type": "number",
"title": "Lat"
},
"level": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Level"
},
"score": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Score"
},
"keyWord": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Keyword"
}
},
"type": "object",
"required": [
"lon",
"lat"
],
"title": "GeocodeLocation"
},
"GeocodeRequest": {
"properties": {
"keyword": {
"type": "string",
"maxLength": 200,
"minLength": 1,
"title": "Keyword"
}
},
"type": "object",
"required": [
"keyword"
],
"title": "GeocodeRequest"
},
"GeocodeResponse": {
"properties": {
"status": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
],
"title": "Status"
},
"msg": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Msg"
},
"location": {
"anyOf": [
{
"$ref": "#/components/schemas/GeocodeLocation"
},
{
"type": "null"
}
]
},
"searchVersion": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Searchversion"
}
},
"type": "object",
"required": [
"status"
],
"title": "GeocodeResponse"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"MapExtent": {
"properties": {
"xmin": {
"type": "number",
"title": "Xmin"
},
"ymin": {
"type": "number",
"title": "Ymin"
},
"xmax": {
"type": "number",
"title": "Xmax"
},
"ymax": {
"type": "number",
"title": "Ymax"
}
},
"type": "object",
"required": [
"xmin",
"ymin",
"xmax",
"ymax"
],
"title": "MapExtent"
},
"ProjectResponse": {
"properties": {
"project_id": {
"type": "string",
"format": "uuid",
"title": "Project Id"
},
"code": {
"type": "string",
"title": "Code"
},
"name": {
"type": "string",
"title": "Name"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"status": {
"type": "string",
"enum": [
"active",
"inactive"
],
"title": "Status"
},
"project_role": {
"type": "string",
"title": "Project Role"
},
"gs_workspace": {
"type": "string",
"title": "Gs Workspace"
},
"map_extent": {
"anyOf": [
{
"$ref": "#/components/schemas/MapExtent"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"project_id",
"code",
"name",
"description",
"status",
"project_role",
"gs_workspace",
"map_extent"
],
"title": "ProjectResponse"
},
"ScadaAssetOut": {
"properties": {
"scada_id": {
"type": "string",
"format": "uuid",
"title": "Scada Id"
},
"external_id": {
"type": "string",
"title": "External Id"
},
"site_external_id": {
"type": "string",
"title": "Site External Id"
},
"name": {
"type": "string",
"title": "Name"
},
"kind": {
"type": "string",
"enum": [
"water_quality",
"radar_level",
"ultrasonic_flow"
],
"title": "Kind"
},
"active": {
"type": "boolean",
"title": "Active"
}
},
"type": "object",
"required": [
"scada_id",
"external_id",
"site_external_id",
"name",
"kind",
"active"
],
"title": "ScadaAssetOut"
},
"ScadaAssetsResponse": {
"properties": {
"assets": {
"items": {
"$ref": "#/components/schemas/ScadaAssetOut"
},
"type": "array",
"title": "Assets"
}
},
"type": "object",
"required": [
"assets"
],
"title": "ScadaAssetsResponse"
},
"ScadaReadingQuery": {
"properties": {
"asset_ids": {
"items": {
"type": "string",
"format": "uuid"
},
"type": "array",
"maxItems": 100,
"minItems": 1,
"title": "Asset Ids"
},
"observed_from": {
"type": "string",
"format": "date-time",
"title": "Observed From"
},
"observed_to": {
"type": "string",
"format": "date-time",
"title": "Observed To"
},
"granularity": {
"type": "string",
"enum": [
"5m",
"1h",
"1d"
],
"title": "Granularity",
"default": "5m"
},
"metrics": {
"items": {
"type": "string",
"enum": [
"conductivity_mean",
"level_mean",
"flow_mean",
"temperature_mean"
]
},
"type": "array",
"maxItems": 4,
"minItems": 1,
"title": "Metrics"
}
},
"type": "object",
"required": [
"asset_ids",
"observed_from",
"observed_to",
"metrics"
],
"title": "ScadaReadingQuery"
},
"ScadaReadingQueryResponse": {
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/ScadaReadingRow"
},
"type": "array",
"title": "Data"
},
"meta": {
"additionalProperties": true,
"type": "object",
"title": "Meta"
}
},
"type": "object",
"required": [
"data",
"meta"
],
"title": "ScadaReadingQueryResponse"
},
"ScadaReadingRow": {
"properties": {
"asset_id": {
"type": "string",
"format": "uuid",
"title": "Asset Id"
},
"observed_at": {
"type": "string",
"format": "date-time",
"title": "Observed At"
},
"values": {
"additionalProperties": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"type": "object",
"title": "Values"
}
},
"type": "object",
"required": [
"asset_id",
"observed_at",
"values"
],
"title": "ScadaReadingRow"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
},
"input": {
"title": "Input"
},
"ctx": {
"type": "object",
"title": "Context"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
}
},
"securitySchemes": {
"OAuth2PasswordBearer": {
"type": "oauth2",
"flows": {
"password": {
"scopes": {},
"tokenUrl": "unused"
}
}
}
}
}
}
+2 -1
View File
@@ -26,7 +26,8 @@
"curl *": "ask",
"wget *": "ask"
},
"edit": "ask"
"edit": "ask",
"task": "deny"
},
"default_agent": "instruction"
}
+3
View File
@@ -10,6 +10,8 @@
"typecheck:opencode": "bun run --cwd .opencode typecheck",
"dev": "bun --watch src/server.ts",
"build": "bun run check",
"generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts",
"check:openapi": "bun run generate:openapi && git diff --exit-code -- openapi/openapi.json src/generated/server-api.d.ts",
"check": "bun run typecheck && bun run typecheck:opencode",
"start": "bun src/server.ts",
"start:prod": "bun run check && bun src/server.ts"
@@ -30,5 +32,6 @@
"@types/node": "^24.7.2",
"bun-types": "^1.3.3",
"typescript": "^5.9.3"
,"openapi-typescript": "^7.10.1"
}
}
-4
View File
@@ -82,10 +82,6 @@ const envSchema = z
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
OPENCODE_BASE_URL: z.string().url().optional(),
// TJWater 后端 API 的基础地址。
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
// 后端结果在直接内联返回给模型前允许的最大字节数。
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
// 生成结果 preview 时最多抽样的条目数。
+176
View File
@@ -0,0 +1,176 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { logger } from "../logger.js";
import { getFrontendActionDefinition } from "./registry.js";
import type { FrontendActionRequest, FrontendActionResult } from "./types.js";
export const frontendActionResultSchema = z.object({
version: z.literal("frontend-action-result@1"),
actionId: z.string().trim().min(1).max(128),
status: z.enum(["succeeded", "failed", "rejected", "cancelled", "expired"]),
output: z.unknown().optional(),
error: z.object({
code: z.string().trim().min(1).max(128),
message: z.string().trim().min(1).max(2_000),
}).strict().optional(),
completedAt: z.number().finite().positive(),
}).strict().superRefine((value, context) => {
if (value.status !== "succeeded" && !value.error) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "non-success results require an error" });
}
});
type PendingAction = {
request: FrontendActionRequest;
promise: Promise<FrontendActionResult>;
resolve: (result: FrontendActionResult) => void;
timer: ReturnType<typeof setTimeout>;
};
export type SubmitResultOutcome = { duplicate: boolean; result: FrontendActionResult };
export type FrontendActionSink = (request: FrontendActionRequest) => void;
export class FrontendActionError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
export class FrontendActionCoordinator {
private readonly pending = new Map<string, PendingAction>();
private readonly completed = new Map<string, { sessionId: string; result: FrontendActionResult }>();
private readonly sinks = new Map<string, FrontendActionSink>();
constructor(private readonly completedLimit = 512) {}
registerSink(sessionId: string, sink: FrontendActionSink) {
this.sinks.set(sessionId, sink);
const now = Date.now();
for (const pending of this.pending.values()) {
if (
pending.request.sessionId === sessionId &&
pending.request.replayPolicy === "explicit" &&
pending.request.expiresAt > now
) sink(pending.request);
}
return () => {
if (this.sinks.get(sessionId) === sink) this.sinks.delete(sessionId);
};
}
request(options: {
sessionId: string;
toolCallId: string;
name: string;
params: unknown;
fallbackText?: string;
signal?: AbortSignal;
}): Promise<FrontendActionResult> {
if (!options.toolCallId.trim()) throw new FrontendActionError(400, "frontend action call ID is required");
const definition = getFrontendActionDefinition(options.name);
if (!definition) throw new FrontendActionError(400, "unknown frontend action");
const parsedParams = definition.inputSchema.safeParse(options.params);
if (!parsedParams.success) throw new FrontendActionError(400, "invalid frontend action params");
const sink = this.sinks.get(options.sessionId);
if (!sink) throw new FrontendActionError(409, "frontend action session has no active browser sink");
const actionId = createHash("sha256")
.update(`${options.sessionId}\0${options.toolCallId}\0${options.name}`)
.digest("hex");
const completed = this.completed.get(actionId);
if (completed) return Promise.resolve(completed.result);
const existing = this.pending.get(actionId);
if (existing) return existing.promise;
const issuedAt = Date.now();
const request: FrontendActionRequest = {
version: "frontend-action@1",
actionId,
toolCallId: options.toolCallId,
sessionId: options.sessionId,
name: definition.manifest.id,
params: parsedParams.data,
risk: definition.manifest.risk,
replayPolicy: definition.manifest.risk === "view" ? "never" : "explicit",
issuedAt,
expiresAt: issuedAt + definition.manifest.timeoutMs,
fallbackText: options.fallbackText?.trim() || undefined,
};
let resolvePending!: (result: FrontendActionResult) => void;
const promise = new Promise<FrontendActionResult>((resolve) => { resolvePending = resolve; });
const timer = setTimeout(() => this.finish(request, {
version: "frontend-action-result@1",
actionId,
status: "expired",
error: { code: "ACTION_TIMEOUT", message: "browser action result was not received before expiry" },
completedAt: Date.now(),
}), definition.manifest.timeoutMs);
this.pending.set(actionId, { request, promise, resolve: resolvePending, timer });
options.signal?.addEventListener("abort", () => this.cancelAction(actionId), { once: true });
this.audit("proposed", request);
this.audit("validated", request);
sink(request);
this.audit("executing", request);
return promise;
}
submitResult(sessionId: string, value: unknown): SubmitResultOutcome {
const parsed = frontendActionResultSchema.safeParse(value);
if (!parsed.success) throw new FrontendActionError(400, "invalid frontend action result");
const result = parsed.data as FrontendActionResult;
const completed = this.completed.get(result.actionId);
if (completed) {
if (sessionId !== completed.sessionId) throw new FrontendActionError(403, "frontend action session mismatch");
if (JSON.stringify(completed.result) !== JSON.stringify(result)) throw new FrontendActionError(409, "frontend action result conflicts with terminal result");
return { duplicate: true, result: completed.result };
}
const pending = this.pending.get(result.actionId);
if (!pending) throw new FrontendActionError(404, "frontend action not found");
if (pending.request.sessionId !== sessionId) throw new FrontendActionError(403, "frontend action session mismatch");
const definition = getFrontendActionDefinition(pending.request.name);
if (result.status === "succeeded" && definition && !definition.outputSchema.safeParse(result.output).success) {
throw new FrontendActionError(400, "invalid frontend action output");
}
this.finish(pending.request, result);
return { duplicate: false, result };
}
cancelSession(sessionId: string) {
this.sinks.delete(sessionId);
for (const [actionId, pending] of this.pending) {
if (pending.request.sessionId === sessionId) this.cancelAction(actionId);
}
}
private cancelAction(actionId: string) {
const pending = this.pending.get(actionId);
if (!pending) return;
this.finish(pending.request, {
version: "frontend-action-result@1", actionId, status: "cancelled",
error: { code: "SESSION_CANCELLED", message: "session ended before the browser action completed" },
completedAt: Date.now(),
});
}
private finish(request: FrontendActionRequest, result: FrontendActionResult) {
const pending = this.pending.get(request.actionId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(request.actionId);
this.completed.set(request.actionId, { sessionId: request.sessionId, result });
while (this.completed.size > this.completedLimit) {
const oldest = this.completed.keys().next().value as string | undefined;
if (!oldest) break;
this.completed.delete(oldest);
}
this.audit(result.status, request, result.error?.code);
pending.resolve(result);
}
private audit(stage: string, request: FrontendActionRequest, errorCode?: string) {
logger.info({ actionId: request.actionId, action: request.name, stage, sessionId: request.sessionId,
toolCallId: request.toolCallId, paramKeys: typeof request.params === "object" && request.params ? Object.keys(request.params) : [], errorCode },
"frontend action lifecycle");
}
}
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod";
import type { FrontendActionManifest } from "./types.js";
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
const sensorId = z.string().trim().min(1).max(128);
const scadaLevel = z.enum(["high", "medium", "low", "unrated"]);
const levelCounts = z.object({
high: z.number().int().nonnegative(),
medium: z.number().int().nonnegative(),
low: z.number().int().nonnegative(),
unrated: z.number().int().nonnegative(),
}).strict();
const renderScadaAnalysisInput = z.object({
items: z.array(z.object({ sensor_id: sensorId, level: scadaLevel }).strict()).min(1).max(100),
}).strict().superRefine((value, context) => {
const seen = new Set<string>();
value.items.forEach((item, index) => {
if (seen.has(item.sensor_id)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "sensor_id values must be unique",
path: ["items", index, "sensor_id"],
});
}
seen.add(item.sensor_id);
});
});
const definitions: Definition[] = [
{
manifest: {
id: "render_scada_analysis",
version: "frontend-action@1",
risk: "view",
timeoutMs: 15_000,
supportedSurfaces: ["map_overlay"],
},
inputSchema: renderScadaAnalysisInput,
outputSchema: z.object({
rendered_ids: z.array(sensorId),
missing_ids: z.array(sensorId),
level_counts: levelCounts,
fitted: z.literal(true),
}).strict(),
},
{
manifest: {
id: "clear_scada_analysis",
version: "frontend-action@1",
risk: "view",
timeoutMs: 15_000,
supportedSurfaces: ["map_overlay"],
},
inputSchema: z.object({}).strict(),
outputSchema: z.object({ cleared: z.literal(true) }).strict(),
},
];
export const frontendActionRegistry = definitions.map((item) => item.manifest);
export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name);
export const frontendActionRegistryResponse = { schema_version: "frontend-action-registry@1" as const, actions: frontendActionRegistry };
+37
View File
@@ -0,0 +1,37 @@
export const FRONTEND_ACTION_VERSION = "frontend-action@1" as const;
export const FRONTEND_ACTION_RESULT_VERSION = "frontend-action-result@1" as const;
export type FrontendActionRisk = "view" | "reversible" | "side_effect";
export type FrontendActionStatus = "succeeded" | "failed" | "rejected" | "cancelled" | "expired";
export type FrontendActionRequest = {
version: typeof FRONTEND_ACTION_VERSION;
actionId: string;
toolCallId: string;
sessionId: string;
name: string;
params: unknown;
risk: FrontendActionRisk;
replayPolicy: "never" | "explicit";
issuedAt: number;
expiresAt: number;
fallbackText?: string;
replayOf?: string;
};
export type FrontendActionResult = {
version: typeof FRONTEND_ACTION_RESULT_VERSION;
actionId: string;
status: FrontendActionStatus;
output?: unknown;
error?: { code: string; message: string };
completedAt: number;
};
export type FrontendActionManifest = {
id: string;
version: typeof FRONTEND_ACTION_VERSION;
risk: FrontendActionRisk;
timeoutMs: number;
supportedSurfaces: ["map_overlay"] | ["side_panel", "canvas"];
};
+441
View File
@@ -0,0 +1,441 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/v1/projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Projects */
get: operations["projects_api_v1_projects_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/scada/assets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Assets */
get: operations["assets_api_v1_scada_assets_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/scada/readings/query": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Readings */
post: operations["readings_api_v1_scada_readings_query_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/geocode": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Geocode */
post: operations["geocode_api_v1_geocode_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health/live": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Live */
get: operations["live_health_live_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health/ready": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Ready */
get: operations["ready_health_ready_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** GeocodeLocation */
GeocodeLocation: {
/** Lon */
lon: number;
/** Lat */
lat: number;
/** Level */
level?: string | null;
/** Score */
score?: number | null;
/** Keyword */
keyWord?: string | null;
};
/** GeocodeRequest */
GeocodeRequest: {
/** Keyword */
keyword: string;
};
/** GeocodeResponse */
GeocodeResponse: {
/** Status */
status: string | number;
/** Msg */
msg?: string | null;
location?: components["schemas"]["GeocodeLocation"] | null;
/** Searchversion */
searchVersion?: string | null;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** MapExtent */
MapExtent: {
/** Xmin */
xmin: number;
/** Ymin */
ymin: number;
/** Xmax */
xmax: number;
/** Ymax */
ymax: number;
};
/** ProjectResponse */
ProjectResponse: {
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Code */
code: string;
/** Name */
name: string;
/** Description */
description: string | null;
/**
* Status
* @enum {string}
*/
status: "active" | "inactive";
/** Project Role */
project_role: string;
/** Gs Workspace */
gs_workspace: string;
map_extent: components["schemas"]["MapExtent"] | null;
};
/** ScadaAssetOut */
ScadaAssetOut: {
/**
* Scada Id
* Format: uuid
*/
scada_id: string;
/** External Id */
external_id: string;
/** Site External Id */
site_external_id: string;
/** Name */
name: string;
/**
* Kind
* @enum {string}
*/
kind: "water_quality" | "radar_level" | "ultrasonic_flow";
/** Active */
active: boolean;
};
/** ScadaAssetsResponse */
ScadaAssetsResponse: {
/** Assets */
assets: components["schemas"]["ScadaAssetOut"][];
};
/** ScadaReadingQuery */
ScadaReadingQuery: {
/** Asset Ids */
asset_ids: string[];
/**
* Observed From
* Format: date-time
*/
observed_from: string;
/**
* Observed To
* Format: date-time
*/
observed_to: string;
/**
* Granularity
* @default 5m
* @enum {string}
*/
granularity: "5m" | "1h" | "1d";
/** Metrics */
metrics: ("conductivity_mean" | "level_mean" | "flow_mean" | "temperature_mean")[];
};
/** ScadaReadingQueryResponse */
ScadaReadingQueryResponse: {
/** Data */
data: components["schemas"]["ScadaReadingRow"][];
/** Meta */
meta: {
[key: string]: unknown;
};
};
/** ScadaReadingRow */
ScadaReadingRow: {
/**
* Asset Id
* Format: uuid
*/
asset_id: string;
/**
* Observed At
* Format: date-time
*/
observed_at: string;
/** Values */
values: {
[key: string]: number | null;
};
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
/** Input */
input?: unknown;
/** Context */
ctx?: Record<string, never>;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
projects_api_v1_projects_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectResponse"][];
};
};
};
};
assets_api_v1_scada_assets_get: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ScadaAssetsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
readings_api_v1_scada_readings_query_post: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ScadaReadingQuery"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ScadaReadingQueryResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
geocode_api_v1_geocode_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["GeocodeRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeocodeResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
live_health_live_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
ready_health_ready_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
}
+1 -108
View File
@@ -1,11 +1,8 @@
import { readJsonFile } from "../utils/fileStore.js";
import {
type ResultReferenceKind,
type ResultReferenceRecord,
type ResultReferenceSource,
type RetrievalContext,
RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
type ResultReferenceStore,
} from "./store.js";
@@ -26,12 +23,6 @@ type RegisterResultReferenceInput = {
traceId: string;
};
export type RenderJunctionPayload = {
node_area_map: Record<string, string>;
area_ids?: string[];
area_colors?: Record<string, string>;
};
export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {}
@@ -59,34 +50,6 @@ export class ResultReferenceResolver {
});
}
async registerRenderPayloadFile(
filePath: string,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
) {
const raw = await readJsonFile<unknown>(filePath);
if (raw === null) {
throw new Error(`render payload file not found: ${filePath}`);
}
const wrapper = normalizeRenderPayloadFile(raw, filePath);
if (!wrapper) {
throw new Error("render payload file must use the wrapped { metadata, location, data } format");
}
const payload = extractRenderJunctionPayload(wrapper.data);
if (!payload) {
throw new Error("render payload file does not contain a valid junction render payload");
}
return this.register({
...input,
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
});
}
async getFullAuthorized(
resultRef: string,
context: RetrievalContext,
@@ -136,83 +99,13 @@ export class ResultReferenceResolver {
}
}
export const extractRenderJunctionPayload = (
value: unknown,
): RenderJunctionPayload | null => {
const candidate = unwrapReferencePayload(value);
if (!candidate || !isRecord(candidate.node_area_map)) {
return null;
}
// 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
if (Object.keys(nodeAreaMap).length === 0) {
return null;
}
const areaIds = Array.isArray(candidate.area_ids)
? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean)
: undefined;
const areaColors = isRecord(candidate.area_colors)
? normalizeStringRecord(candidate.area_colors)
: undefined;
return {
node_area_map: nodeAreaMap,
...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}),
...(areaColors && Object.keys(areaColors).length > 0
? { area_colors: areaColors }
: {}),
};
};
const normalizeDataForKind = (
kind: ResultReferenceKind,
_kind: ResultReferenceKind,
data: unknown,
schemaVersion: number,
): unknown | null => {
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
return null;
}
if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
return extractRenderJunctionPayload(data);
}
return data;
};
const normalizeRenderPayloadFile = (
value: unknown,
filePath: string,
): { data: unknown } | null => {
if (!isRecord(value) || !("data" in value)) {
return null;
}
if (!isRecord(value.metadata) || !isRecord(value.location)) {
return null;
}
if (value.location.file_path !== filePath) {
return null;
}
return { data: value.data };
};
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
if (!isRecord(value)) {
return null;
}
if ("data" in value && value.data !== undefined && value.data !== null) {
return isRecord(value.data) ? value.data : null;
}
return value;
};
const normalizeStringRecord = (value: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(value)
.map(([key, entry]) => [String(key), String(entry ?? "").trim()])
.filter(([, entry]) => entry.length > 0),
);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
+2 -1
View File
@@ -16,11 +16,12 @@ export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
export const RESULT_REFERENCE_KIND = {
renderJunctionsPayload: "render-junctions-payload",
serverApiPayload: "server_api_payload",
} as const;
export const RESULT_REFERENCE_SOURCE = {
agentGenerated: "agent_generated",
serverApi: "server_api",
} as const;
export type ResultReferenceKind =
+86 -1213
View File
File diff suppressed because it is too large Load Diff
+22 -30
View File
@@ -8,6 +8,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
@@ -28,6 +29,7 @@ type RegisterAuxiliaryRoutesOptions = {
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
frontendActionCoordinator: FrontendActionCoordinator;
};
const toSessionUiStateContext = (sessionId: string) => ({
@@ -43,43 +45,32 @@ export const registerChatAuxiliaryRoutes = (
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
frontendActionCoordinator,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
const renderRef = req.params.render_ref?.trim();
const authContext = getLocalAgentContext(req);
const userId = authContext.userId;
const projectId = authContext.projectId;
const clientSessionId =
typeof req.query.session_id === "string"
? req.query.session_id.trim()
: undefined;
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
});
chatRouter.get("/result-ref/:result_ref", async (req, res) => {
const resultRef = req.params.result_ref?.trim();
const context = getLocalAgentContext(req);
const clientSessionId = typeof req.query.session_id === "string" ? req.query.session_id.trim() : undefined;
const offset = Math.max(0, Number.parseInt(String(req.query.offset ?? "0"), 10) || 0);
const limit = Math.min(500, Math.max(1, Number.parseInt(String(req.query.limit ?? "100"), 10) || 100));
if (!resultRef || !clientSessionId) {
res.status(400).json({ message: "result_ref and session_id are required" });
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
const result = await resultReferenceResolver.getFullAuthorized(resultRef, {
actorKey: toActorKey(context.userId), clientSessionId, projectId: context.projectId,
}, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
if (!result) {
res.status(404).json({ message: "render_ref not found" });
res.status(404).json({ message: "result_ref not found" });
return;
}
res.json(result);
const rows = Array.isArray(result.data)
? result.data
: result.data && typeof result.data === "object" && Array.isArray((result.data as { data?: unknown }).data)
? (result.data as { data: unknown[] }).data
: [result.data];
res.json({ ...result, data: rows.slice(offset, offset + limit), page: { offset, limit, total: rows.length, has_more: offset + limit < rows.length } });
});
chatRouter.post("/abort", async (req, res) => {
@@ -93,6 +84,7 @@ export const registerChatAuxiliaryRoutes = (
}
try {
frontendActionCoordinator.cancelSession(parsed.data.session_id);
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
+22
View File
@@ -0,0 +1,22 @@
import {
type ActiveRun,
type RunStatus,
} from "./chatUiState.js";
export type ChatRunState = {
activeRuns: Map<string, ActiveRun>;
lastRunStatuses: Map<string, RunStatus>;
getStatus: (sessionId: string) => RunStatus | undefined;
};
export const createChatRunState = (): ChatRunState => {
const activeRuns = new Map<string, ActiveRun>();
const lastRunStatuses = new Map<string, RunStatus>();
return {
activeRuns,
lastRunStatuses,
getStatus: (sessionId) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId),
};
};
+26
View File
@@ -217,6 +217,32 @@ export const shouldRestoreConversationForRuntime = (options: {
runtimeHasConversation: boolean;
}) => !options.hadExistingSessionRecord || !options.runtimeHasConversation;
export const buildForkedSessionUiState = (
sourceState: { messages?: unknown[] } | null | undefined,
input: {
keepMessageCount: number;
targetSessionId: string;
},
) => ({
sessionId: input.targetSessionId,
isTitleManuallyEdited: false,
messages: Array.isArray(sourceState?.messages)
? sourceState.messages.slice(0, input.keepMessageCount)
: [],
});
export const resolveRequestedStreamSessionId = (input: {
sessionId?: string;
promptText: string;
createClientSessionId: () => string;
}) => {
const sessionId = input.sessionId?.trim();
if (sessionId) {
return sessionId;
}
return undefined;
};
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
const formattedTurns = recentTurns
.slice(-RESTORE_TURN_LIMIT)
+426
View File
@@ -0,0 +1,426 @@
import { type Router } from "express";
import { z } from "zod";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { getLocalAgentContext } from "../context/localContext.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
type SessionMetadataStore,
type SessionRecord,
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
import { buildForkedSessionUiState } from "./chatSession.js";
import { type StreamSubscriber } from "./chatUiState.js";
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
});
const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
keep_message_count: z.coerce.number().int().min(0),
});
type RegisterChatSessionRoutesOptions = {
frontendActionCoordinator: FrontendActionCoordinator;
runState: ChatRunState;
runtime: OpencodeRuntimeAdapter;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionTranscriptStore: SessionTranscriptStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
export const registerChatSessionRoutes = (
chatRouter: Router,
options: RegisterChatSessionRoutesOptions,
) => {
const {
frontendActionCoordinator,
runState,
runtime,
sessionBridge,
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
} = options;
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
chatRouter.post("/session", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
let sessionId = requestedSessionId;
if (!sessionId) {
try {
sessionId = (await runtime.createSession()).id;
} catch (error) {
sessionId = sessionBridge.createClientSessionId();
logger.warn(
{ err: error, sessionId },
"runtime unavailable while creating chat session; using local degraded session",
);
}
}
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId,
userId,
});
res.status(created ? 201 : 200).json({
session_id: record.sessionId,
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
});
});
chatRouter.get("/sessions", async (req, res) => {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
userId,
});
res.json({
sessions: records.map((record) => ({
id: record.sessionId,
title: record.title ?? "新对话",
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
parent_session_id: record.parentSessionId,
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
run_status: getSessionRunStatus(record.sessionId),
})),
});
});
chatRouter.get("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
res.json({
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: sessionRecord.createdAt,
updated_at: sessionRecord.updatedAt,
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
});
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: state?.messages ?? run?.messages ?? [],
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed",
}),
);
if (!run || run.status !== "running") {
res.end();
return;
}
const subscriber: StreamSubscriber = {
write: (event, data) => {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
run.subscribers.add(subscriber);
const cleanup = () => {
run.subscribers.delete(subscriber);
};
req.on("close", cleanup);
res.on("close", cleanup);
});
chatRouter.patch("/session/:session_id/title", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
const isTitleManuallyEdited =
typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited
: undefined;
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId || !title) {
res.status(400).json({ message: "session_id and title are required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, {
title,
});
const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
);
if (state) {
await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
{
...state,
isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
},
);
}
res.json({
id: nextSessionRecord.sessionId,
title: nextSessionRecord.title,
updated_at: nextSessionRecord.updatedAt,
});
});
chatRouter.delete("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(204).end();
return;
}
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
});
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
await sessionMetadataStore.remove(sessionRecord);
res.status(204).end();
});
chatRouter.post("/fork", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sourceSessionId,
)
: null;
const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
projectId,
projectKey,
sessionId: forkSession.id,
userId,
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
}
const sourceState = sourceSessionRecord
? await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord),
)
: null;
const forkTitle = sourceSessionRecord?.title
? `${sourceSessionRecord.title} 副本`
: "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch(
targetSessionRecord,
{ title: forkTitle },
);
await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId,
}),
);
logger.info(
{
sourceSessionId: parsed.data.session_id,
sessionId: nextSessionId,
traceId,
projectId,
keepMessageCount: parsed.data.keep_message_count,
},
"forked chat session",
);
res.status(200).json({
session_id: nextSessionId,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat fork failed");
res.status(500).json({
message: "chat fork failed",
detail,
});
}
});
};
+8 -174
View File
@@ -1,5 +1,4 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { smoothStream, type TextStreamPart } from "ai";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
@@ -46,10 +45,14 @@ import {
normalizeToolParams,
normalizeToolStatus,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
import { createTokenSmoother } from "./chatTokenSmoother.js";
export {
createTokenSmoother,
detectTokenSmoothingChunk,
} from "./chatTokenSmoother.js";
export {
collectTextContent,
@@ -71,6 +74,7 @@ type StreamPromptOptions = {
traceId?: string;
projectId?: string;
signal?: AbortSignal;
suppressLegacyFrontendActions?: boolean;
write: (event: string, data: Record<string, unknown>) => void;
};
@@ -84,177 +88,6 @@ type ProgressPayload = {
detail?: string;
};
type TokenSmootherOptions = {
enabled: boolean;
delayMs: number;
locale: string;
sessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
};
type SmoothTextStreamPart = TextStreamPart<Record<string, never>>;
type SmoothTextDeltaPart = Extract<SmoothTextStreamPart, { type: "text-delta" }>;
type SmoothTextEndPart = Extract<SmoothTextStreamPart, { type: "text-end" }>;
const segmenters = new Map<string, Intl.Segmenter | null>();
const CJK_SCRIPT_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
const CJK_PUNCTUATION_PATTERN = /[!?,;:]/u;
const CJK_FALLBACK_TRIGGER_CHARS = 24;
const CJK_FALLBACK_MIN_CHARS = 12;
const CJK_FALLBACK_TARGET_CHARS = 18;
function getTokenSmoothingSegmenter(locale: string) {
if (segmenters.has(locale)) {
return segmenters.get(locale) ?? null;
}
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(locale, { granularity: "word" })
: null;
segmenters.set(locale, segmenter);
return segmenter;
}
function sliceCodePoints(value: string, count: number) {
return Array.from(value).slice(0, count).join("");
}
function codePointLength(value: string) {
return Array.from(value).length;
}
function isCjkSmoothingContent(content: string, locale: string) {
return (
CJK_SCRIPT_PATTERN.test(content) ||
(/^(zh|ja|ko)(?:-|$)/i.test(locale) && CJK_PUNCTUATION_PATTERN.test(content))
);
}
function detectCjkFallbackChunk(content: string, locale: string) {
if (codePointLength(content) < CJK_FALLBACK_TRIGGER_CHARS) {
return null;
}
const segmenter = getTokenSmoothingSegmenter(locale);
if (segmenter) {
let candidate: string | null = null;
for (const segment of segmenter.segment(content)) {
const nextCandidate = content.slice(0, segment.index + segment.segment.length);
const nextLength = codePointLength(nextCandidate);
if (nextLength > CJK_FALLBACK_TARGET_CHARS) {
break;
}
if (nextLength >= CJK_FALLBACK_MIN_CHARS) {
candidate = nextCandidate;
}
}
if (candidate) {
return candidate;
}
}
return sliceCodePoints(content, CJK_FALLBACK_TARGET_CHARS);
}
export function detectTokenSmoothingChunk(content: string, locale = "zh") {
if (!content) {
return null;
}
if (isCjkSmoothingContent(content, locale)) {
return detectCjkFallbackChunk(content, locale);
}
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk;
}
return sliceCodePoints(content, 1);
}
export function createTokenSmoother({
enabled,
delayMs,
locale,
sessionId,
write,
}: TokenSmootherOptions) {
const emitChunk = (content: string) => {
write("token", {
session_id: sessionId,
content,
});
};
if (!enabled) {
return {
writeToken(content: string) {
if (content) {
emitChunk(content);
}
},
async flush() {
return;
},
};
}
const textPartId = `smooth-text-${sessionId}`;
const stream = smoothStream<Record<string, never>>({
delayInMs: delayMs,
chunking: (buffer) => detectTokenSmoothingChunk(buffer, locale),
})({ tools: {} });
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
let closed = false;
let writeChain = Promise.resolve();
const readPromise = (async () => {
while (true) {
const result = await reader.read();
if (result.done) {
return;
}
if (result.value.type === "text-delta") {
emitChunk(result.value.text);
}
}
})();
return {
writeToken(content: string) {
if (!content) {
return;
}
if (closed) {
return;
}
writeChain = writeChain.then(() =>
writer.write({
type: "text-delta",
id: textPartId,
text: content,
} satisfies SmoothTextDeltaPart),
);
},
async flush() {
if (closed) {
await readPromise;
return;
}
closed = true;
await writeChain;
await writer.write({
type: "text-end",
id: textPartId,
} satisfies SmoothTextEndPart);
await writer.close();
await readPromise;
},
};
}
const getPermissionTarget = (metadata: unknown) => {
if (!isObjectRecord(metadata)) {
return undefined;
@@ -309,6 +142,7 @@ export const streamPromptResponse = async ({
traceId,
projectId,
signal,
suppressLegacyFrontendActions = false,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
+2 -8
View File
@@ -60,17 +60,11 @@ export type TodoUpdatePayload = {
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
clear_scada_analysis: "清除 SCADA 地图分析",
memory_manager: "记忆写入",
geocode: "地理编码",
render_scada_analysis: "渲染 SCADA 地图分析",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
web_search: "网页搜索",
locate_features: "地图定位",
zoom_to_map: "地图缩放",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
};
export const logDevelopmentDebug = (
+811
View File
@@ -0,0 +1,811 @@
import { type Router } from "express";
import {
UI_MESSAGE_STREAM_HEADERS,
type UIMessage,
type UIMessageChunk,
} from "ai";
import { z } from "zod";
import { isSupportedModel } from "../chat/models.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { getLocalAgentContext } from "../context/localContext.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { logger } from "../logger.js";
import { type MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import {
type SessionMetadataStore,
type SessionRecord,
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle,
resolveRequestedStreamSessionId,
shouldGenerateSessionTitle,
shouldRestoreConversationForRuntime,
} from "./chatSession.js";
import {
collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
type TodoUpdatePayload,
} from "./chatStream.js";
import { type ChatRunState } from "./chatRunState.js";
import {
type ActiveRun,
type StreamSubscriber,
appendBackendToolArtifact,
appendBackendUiEnvelope,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages,
isObjectRecord,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js";
const payloadSchema = z.object({
id: z.string().max(128).optional(),
messages: z.array(z.unknown()).max(200).optional(),
message: z.string().min(1).max(10000).optional(),
session_id: z.string().max(128).optional(),
model: z.string().refine(isSupportedModel, {
message: "unsupported model",
}).optional(),
approval_mode: z.enum(["request", "always"]).optional().default("request"),
capabilities: z.array(z.string().max(64)).max(20).optional().default([]),
});
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
const runtimeHasConversation = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, 1);
return messages.some(
(message) =>
message.info.role === "user" || message.info.role === "assistant",
);
};
const isUiMessage = (value: unknown): value is UIMessage =>
isObjectRecord(value) &&
typeof value.id === "string" &&
(value.role === "system" || value.role === "user" || value.role === "assistant") &&
Array.isArray(value.parts);
const readUiMessageText = (message: UIMessage | undefined) =>
message?.parts
.flatMap((part) =>
isObjectRecord(part) && part.type === "text" && typeof part.text === "string"
? [part.text]
: [],
)
.join("")
.trim() ?? "";
const readLatestUserText = (messages: UIMessage[]) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== "user") {
continue;
}
const text = readUiMessageText(message);
if (text) {
return text;
}
}
return "";
};
const toUiMessages = (messages: unknown[] | undefined) =>
Array.isArray(messages) ? messages.filter(isUiMessage) : [];
const toLegacyMessages = (messages: UIMessage[]) =>
messages.flatMap((message) => {
const content = readUiMessageText(message);
if (!content || (message.role !== "user" && message.role !== "assistant")) {
return [];
}
return [
{
id: message.id,
role: message.role,
content,
},
];
});
const createFallbackUserUiMessage = (content: string): UIMessage => ({
id: `user-${Date.now().toString(36)}`,
role: "user",
parts: [{ type: "text", text: content }],
});
const pipeUiChunk = (
res: {
write: (chunk: string) => void;
writableEnded?: boolean;
destroyed?: boolean;
},
chunk: UIMessageChunk,
) => {
if (!res.writableEnded && !res.destroyed) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
};
type RegisterChatStreamRouteOptions = {
frontendActionCoordinator: FrontendActionCoordinator;
learningOrchestrator: LearningOrchestrator;
memoryStore: MemoryStore;
runState: ChatRunState;
runtime: OpencodeRuntimeAdapter;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionTranscriptStore: SessionTranscriptStore;
sessionUiStateStore: SessionUiStateStore;
};
export const registerChatStreamRoute = (
chatRouter: Router,
{
frontendActionCoordinator,
learningOrchestrator,
memoryStore,
runState,
runtime,
sessionBridge,
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
}: RegisterChatStreamRouteOptions,
) => {
const { activeRuns, lastRunStatuses } = runState;
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestUiMessages = toUiMessages(parsed.data.messages);
const promptText =
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
if (!promptText) {
res.status(400).json({
message: "message or messages with latest user text is required",
});
return;
}
const requestedSessionId = resolveRequestedStreamSessionId({
sessionId: parsed.data.session_id,
promptText,
createClientSessionId: () => sessionBridge.createClientSessionId(),
});
const existingSessionRecord = requestedSessionId
? await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
requestedSessionId,
)
: null;
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
const { binding, requestContext, created } = await sessionBridge.resolve({
sessionId: requestedSessionId,
network: authContext.network,
projectId,
traceId,
userId,
});
setRuntimeSessionContext({
...(getRuntimeSessionContext(binding.sessionId)!),
capabilities: parsed.data.capabilities,
});
const { record: ensuredSessionRecord, created: sessionCreated } =
await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId: binding.sessionId,
userId,
});
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
const hasRuntimeConversation = hadExistingRuntimeSession
? await runtimeHasConversation(runtime, binding.sessionId)
: false;
const shouldRestoreConversation = shouldRestoreConversationForRuntime({
hadExistingSessionRecord: hadExistingRuntimeSession,
runtimeHasConversation: hasRuntimeConversation,
});
const historyContext = {
actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId,
};
const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord),
);
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
const requestMessages =
requestUiMessages.length > 0
? requestUiMessages
: [
...persistedUiMessages,
createFallbackUserUiMessage(promptText),
];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages);
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
res.status(409).json({
message: "session is already streaming",
session_id: activeSessionRecord.sessionId,
});
return;
}
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
logger.info(
{
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
created: created || sessionCreated,
model: parsed.data.model,
approvalMode: parsed.data.approval_mode,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
},
"processing chat request",
);
res.status(200);
for (const [header, value] of Object.entries(UI_MESSAGE_STREAM_HEADERS)) {
res.setHeader(header, value);
}
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const clientSessionId = requestContext.clientSessionId;
let streamClosed = false;
const abortController = new AbortController();
sessionBridge.registerAbortController(clientSessionId, abortController);
const initialMessages = createInitialStreamingMessages(
baseMessages,
promptText,
);
const activeRun: ActiveRun = {
clientSessionId,
controller: abortController,
messages: initialMessages,
pendingPermissions: new Map(),
pendingQuestions: new Map(),
status: "running",
subscribers: new Set(),
};
activeRuns.set(clientSessionId, activeRun);
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,
{
id: assistantUiMessageId,
role: "assistant",
parts: assistantParts.map((part) => ({ ...part })),
} as UIMessage,
];
};
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;
};
writeUiChunk({ type: "start", messageId: assistantUiMessageId });
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: latestUiMessages,
});
const queueSessionUiStatePersist = () => {
const snapshot = {
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: latestUiMessages,
};
persistQueue = persistQueue
.catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"failed to persist previous chat stream state",
);
})
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
return persistQueue;
};
const primarySubscriber: StreamSubscriber = {
write: (event, data) => {
if (streamClosed || res.writableEnded || res.destroyed) {
return;
}
if (event === "token") {
writeTextDelta(typeof data.content === "string" ? data.content : "");
return;
}
if (event === "done") {
finishUiMessage("stop");
return;
}
if (event === "error") {
writeUiChunk({
type: "error",
errorText:
typeof data.message === "string"
? data.message
: "Agent stream failed",
});
finishUiMessage("error");
return;
}
writeDataPart(event, data);
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
activeRun.subscribers.add(primarySubscriber);
const handleClientClose = () => {
streamClosed = true;
activeRun.subscribers.delete(primarySubscriber);
};
req.on("close", handleClientClose);
res.on("close", handleClientClose);
const publish = (event: string, data: Record<string, unknown>) => {
const subscriberData =
event === "token"
? {
...data,
message_id:
typeof data.message_id === "string"
? data.message_id
: assistantUiMessageId,
}
: data;
if (event === "token") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = "completed";
lastRunStatuses.set(clientSessionId, "completed");
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
permissions: [
...(Array.isArray(message.permissions) ? message.permissions : []),
toFrontendPermission(payload),
],
}));
} else if (event === "permission_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
const reply =
data.reply === "once" || data.reply === "always" || data.reply === "reject"
? data.reply
: undefined;
if (requestId && reply) {
activeRun.pendingPermissions.delete(requestId);
activeRun.messages = updateLastAssistantPermission(
activeRun.messages,
requestId,
(permission) => ({
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
}),
);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = updateLastAssistantQuestion(
activeRun.messages,
requestId,
(question) => ({
...question,
status: data.rejected === true ? "rejected" : "answered",
repliedAt: Date.now(),
answers: Array.isArray(data.answers) ? data.answers : question.answers,
error: undefined,
}),
);
}
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
} else if (event === "ui_envelope") {
const payload = data as UIEnvelopePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
}));
}
for (const subscriber of activeRun.subscribers) {
subscriber.write(event, subscriberData);
}
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
};
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
: undefined;
try {
const preparedMessage = await buildPromptWithLearningContext(
memoryStore,
requestContext.actorKey,
requestContext.projectKey,
{
recentTurns,
persistedMessages: baseMessages,
message: promptText,
restoreConversation: shouldRestoreConversation,
},
);
const streamResult = await streamPromptResponse({
runtime,
sessionId: binding.sessionId,
clientSessionId,
message: preparedMessage,
model: parsed.data.model,
approvalMode: parsed.data.approval_mode,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
signal: abortController.signal,
suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"),
write: (event, data) => {
publish(event, data);
},
});
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const latestSessionRecord =
(await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
activeSessionRecord.sessionId,
)) ?? activeSessionRecord;
const latestSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(latestSessionRecord),
);
const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle;
const shouldGenerateTitle = shouldGenerateSessionTitle({
recentTurnCount: recentTurns.length,
isTitleManuallyEdited:
latestSessionState?.isTitleManuallyEdited ?? false,
});
if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId,
latestAssistantMessage: assistantText,
latestUserMessage: promptText,
fallbackTitle: existingSessionTitle,
});
}
await sessionMetadataStore.touch(latestSessionRecord, {
...(sessionTitle && sessionTitle !== existingSessionTitle
? { title: sessionTitle }
: {}),
});
if (
shouldGenerateTitle &&
sessionTitle &&
sessionTitle !== existingSessionTitle
) {
publish("session_title", {
session_id: clientSessionId,
title: sessionTitle,
});
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
}
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext,
sessionId: clientSessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"stream-completed learning failed",
);
});
}
}
} finally {
unregisterFrontendActionSink?.();
if (abortController.signal.aborted) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
writeUiChunk({
type: "error",
errorText: "请求已中断",
});
finishUiMessage("error");
}
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
});
}
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
finishUiMessage(activeRun.status === "error" ? "error" : "stop");
}
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
sessionBridge.finalizeRequest(clientSessionId);
activeRun.status = abortController.signal.aborted
? activeRun.status === "aborted"
? "aborted"
: "aborted"
: activeRun.status === "running"
? "completed"
: activeRun.status;
lastRunStatuses.set(clientSessionId, activeRun.status);
for (const subscriber of activeRun.subscribers) {
subscriber.close();
}
activeRun.subscribers.clear();
if (
activeRun.pendingPermissions.size === 0 &&
activeRun.pendingQuestions.size === 0
) {
activeRuns.delete(clientSessionId);
}
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
}
if (!res.writableEnded && !res.destroyed) {
res.end();
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed");
if (res.headersSent) {
if (!res.writableEnded && !res.destroyed) {
pipeUiChunk(res, {
type: "error",
errorText: detail,
});
pipeUiChunk(res, {
type: "finish",
finishReason: "error",
});
res.end();
}
return;
}
res.status(500).json({
message: "chat stream failed",
detail,
});
}
});
};
+172
View File
@@ -0,0 +1,172 @@
import { smoothStream, type TextStreamPart } from "ai";
type TokenSmootherOptions = {
enabled: boolean;
delayMs: number;
locale: string;
sessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
};
type SmoothTextStreamPart = TextStreamPart<Record<string, never>>;
type SmoothTextDeltaPart = Extract<SmoothTextStreamPart, { type: "text-delta" }>;
type SmoothTextEndPart = Extract<SmoothTextStreamPart, { type: "text-end" }>;
const segmenters = new Map<string, Intl.Segmenter | null>();
const CJK_SCRIPT_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
const CJK_PUNCTUATION_PATTERN = /[!?,;:]/u;
const CJK_FALLBACK_TRIGGER_CHARS = 24;
const CJK_FALLBACK_MIN_CHARS = 12;
const CJK_FALLBACK_TARGET_CHARS = 18;
function getTokenSmoothingSegmenter(locale: string) {
if (segmenters.has(locale)) {
return segmenters.get(locale) ?? null;
}
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(locale, { granularity: "word" })
: null;
segmenters.set(locale, segmenter);
return segmenter;
}
function sliceCodePoints(value: string, count: number) {
return Array.from(value).slice(0, count).join("");
}
function codePointLength(value: string) {
return Array.from(value).length;
}
function isCjkSmoothingContent(content: string, locale: string) {
return (
CJK_SCRIPT_PATTERN.test(content) ||
(/^(zh|ja|ko)(?:-|$)/i.test(locale) && CJK_PUNCTUATION_PATTERN.test(content))
);
}
function detectCjkFallbackChunk(content: string, locale: string) {
if (codePointLength(content) < CJK_FALLBACK_TRIGGER_CHARS) {
return null;
}
const segmenter = getTokenSmoothingSegmenter(locale);
if (segmenter) {
let candidate: string | null = null;
for (const segment of segmenter.segment(content)) {
const nextCandidate = content.slice(0, segment.index + segment.segment.length);
const nextLength = codePointLength(nextCandidate);
if (nextLength > CJK_FALLBACK_TARGET_CHARS) {
break;
}
if (nextLength >= CJK_FALLBACK_MIN_CHARS) {
candidate = nextCandidate;
}
}
if (candidate) {
return candidate;
}
}
return sliceCodePoints(content, CJK_FALLBACK_TARGET_CHARS);
}
export function detectTokenSmoothingChunk(content: string, locale = "zh") {
if (!content) {
return null;
}
if (isCjkSmoothingContent(content, locale)) {
return detectCjkFallbackChunk(content, locale);
}
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk;
}
return sliceCodePoints(content, 1);
}
export function createTokenSmoother({
enabled,
delayMs,
locale,
sessionId,
write,
}: TokenSmootherOptions) {
const emitChunk = (content: string) => {
write("token", {
session_id: sessionId,
content,
});
};
if (!enabled) {
return {
writeToken(content: string) {
if (content) {
emitChunk(content);
}
},
async flush() {
return;
},
};
}
const textPartId = `smooth-text-${sessionId}`;
const stream = smoothStream<Record<string, never>>({
delayInMs: delayMs,
chunking: (buffer) => detectTokenSmoothingChunk(buffer, locale),
})({ tools: {} });
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
let closed = false;
let writeChain = Promise.resolve();
const readPromise = (async () => {
while (true) {
const result = await reader.read();
if (result.done) {
return;
}
if (result.value.type === "text-delta") {
emitChunk(result.value.text);
}
}
})();
return {
writeToken(content: string) {
if (!content) {
return;
}
if (closed) {
return;
}
writeChain = writeChain.then(() =>
writer.write({
type: "text-delta",
id: textPartId,
text: content,
} satisfies SmoothTextDeltaPart),
);
},
async flush() {
if (closed) {
await readPromise;
return;
}
closed = true;
await writeChain;
await writer.write({
type: "text-end",
id: textPartId,
} satisfies SmoothTextEndPart);
await writer.close();
await readPromise;
},
};
}
+1 -16
View File
@@ -207,17 +207,9 @@ export const updateLastAssistantQuestion = (
});
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "show_chart" || tool === "chart") return "chart";
if (
tool === "locate_features" ||
tool === "zoom_to_map" ||
tool === "render_junctions" ||
tool === "apply_layer_style" ||
tool.startsWith("locate_")
) {
if (tool === "render_scada_analysis" || tool === "clear_scada_analysis") {
return "map";
}
if (tool === "view_history" || tool === "view_scada") return "panel";
return "tool";
};
@@ -225,13 +217,6 @@ const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) =>
if (typeof params.title === "string" && params.title.trim()) {
return params.title.trim();
}
if (tool === "show_chart" || tool === "chart") return "生成图表";
if (tool === "zoom_to_map") return "缩放到地图坐标";
if (tool === "render_junctions") return "渲染节点分区";
if (tool === "view_history") return "打开计算结果曲线";
if (tool === "view_scada") return "打开 SCADA 数据面板";
if (tool === "apply_layer_style") return "应用图层样式";
if (tool === "locate_features" || tool.startsWith("locate_")) return "地图定位";
return tool || "工具调用";
};
-66
View File
@@ -1,66 +0,0 @@
import { createEnvelopeId } from "../uiEnvelope/ids.js";
import { toUiEnvelopeFromToolCall } from "../uiEnvelope/fromToolCall.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
export type PressureTrendDemoToolCall = {
tool: "show_chart";
params: {
reason: string;
title: string;
chart_type: "line";
x_axis_name: string;
y_axis_name: string;
x_data: string[];
series: Array<{
name: string;
type: "line";
data: number[];
}>;
};
reason: string;
};
export const isPressureTrendDemoPrompt = (message: string) =>
message.replace(/\s+/g, "").includes("展示最近压力趋势");
export const createPressureTrendDemoToolCall = (): PressureTrendDemoToolCall => ({
tool: "show_chart",
reason: "展示最近 6 小时压力趋势。",
params: {
reason: "展示最近 6 小时压力趋势。",
title: "最近压力趋势",
chart_type: "line",
x_axis_name: "时间",
y_axis_name: "压力 MPa",
x_data: ["10:00", "11:00", "12:00", "13:00", "14:00", "15:00"],
series: [
{
name: "东部主干压力",
type: "line",
data: [0.42, 0.41, 0.39, 0.36, 0.35, 0.37],
},
{
name: "正常基线",
type: "line",
data: [0.43, 0.43, 0.42, 0.42, 0.41, 0.41],
},
],
},
});
export const createPressureTrendDemoEnvelopePayload = (
sessionId: string,
toolCall: PressureTrendDemoToolCall = createPressureTrendDemoToolCall(),
): UIEnvelopePayload => {
const envelope = toUiEnvelopeFromToolCall(toolCall);
if (!envelope) {
throw new Error("pressure trend demo tool call did not produce a UIEnvelope");
}
return {
session_id: sessionId,
envelope_id: createEnvelopeId(toolCall.tool),
created_at: Date.now(),
envelope,
};
};
+1
View File
@@ -9,6 +9,7 @@ export type RuntimeSessionContext = {
projectKey: string;
sessionId: string;
traceId: string;
capabilities?: string[];
};
const contexts = new Map<string, RuntimeSessionContext>();
+43 -215
View File
@@ -11,16 +11,11 @@ import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js";
import { MemoryStore } from "./memory/store.js";
import { ResultReferenceResolver } from "./results/resolver.js";
import {
RESULT_REFERENCE_SOURCE,
ResultReferenceStore,
} from "./results/store.js";
import { ResultReferenceStore } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js";
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import {
getRuntimeSessionContext,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
const app = express();
@@ -38,13 +33,43 @@ const learningOrchestrator = new LearningOrchestrator(
const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
const frontendActionCoordinator = new FrontendActionCoordinator();
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.post("/internal/frontend-actions/request", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const runtimeSessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
if (!context) {
res.status(404).json({ message: "session context not found" });
return;
}
if (!context.capabilities?.includes("frontend-action@1")) {
res.status(409).json({ message: "frontend-action@1 browser capability is required" });
return;
}
try {
const result = await frontendActionCoordinator.request({
sessionId: context.clientSessionId,
toolCallId: typeof req.body?.call_id === "string" ? req.body.call_id : "",
name: typeof req.body?.name === "string" ? req.body.name : "",
params: req.body?.params,
fallbackText: typeof req.body?.fallback_text === "string" ? req.body.fallback_text : undefined,
});
res.json(result);
} catch (error) {
const status = error instanceof FrontendActionError ? error.status : 500;
res.status(status).json({ message: error instanceof Error ? error.message : String(error) });
}
});
app.get("/health", async (_req, res) => {
try {
const runtime = await opencodeRuntime.health();
@@ -64,56 +89,6 @@ app.get("/health", async (_req, res) => {
}
});
app.post("/internal/tools/store-render-ref", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
if (!filePath) {
res.status(400).json({ message: "file_path is required" });
return;
}
try {
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectId: context.projectId,
projectKey: context.projectKey,
sessionId: context.clientSessionId,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: context.traceId,
});
res.json({
ok: true,
render_ref: record.resultRef,
stored_at: record.createdAt,
preview: record.preview,
kind: record.kind,
schema_version: record.schemaVersion,
source: record.source,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(400).json({
message: "store render ref failed",
detail,
});
}
});
app.post("/internal/tools/session-search", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
@@ -149,166 +124,19 @@ app.post("/internal/tools/session-search", async (req, res) => {
});
});
const callBackendJson = async (
path: string,
_context: RuntimeSessionContext,
payload: unknown,
) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
const text = await response.text();
return {
ok: response.ok,
status: response.status,
text,
};
} finally {
clearTimeout(timer);
}
};
const parseStringArray = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: undefined;
const webSearchFreshnessMap: Record<string, string> = {
no_limit: "noLimit",
one_day: "oneDay",
one_week: "oneWeek",
one_month: "oneMonth",
one_year: "oneYear",
};
const normalizeWebSearchFreshness = (value: unknown) => {
if (typeof value !== "string") {
return undefined;
}
return webSearchFreshnessMap[value] ?? value;
};
app.post("/internal/tools/web-search", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
const query = typeof req.body?.query === "string" ? req.body.query.trim() : "";
if (!query) {
res.status(400).json({ message: "query is required" });
return;
}
const count =
typeof req.body?.count === "number" && Number.isFinite(req.body.count)
? Math.trunc(req.body.count)
: undefined;
const payload = {
query,
freshness: normalizeWebSearchFreshness(req.body?.freshness),
summary:
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
count,
include: parseStringArray(req.body?.include),
exclude: parseStringArray(req.body?.exclude),
};
try {
const response = await callBackendJson(
"/api/v1/web-search",
context,
payload,
);
res
.status(response.ok ? 200 : response.status)
.type("application/json")
.send(response.text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
message: "web search service unavailable",
detail,
});
}
});
app.post("/internal/tools/geocode", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
const keyword =
typeof req.body?.keyword === "string" ? req.body.keyword.trim() : "";
if (!keyword) {
res.status(400).json({ message: "keyword is required" });
return;
}
try {
const response = await callBackendJson(
"/api/v1/tianditu/geocode",
context,
{ keyword },
);
res
.status(response.ok ? 200 : response.status)
.type("application/json")
.send(response.text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
message: "geocoding service unavailable",
detail,
});
}
});
app.use(
"/api/v1/agent/chat",
buildChatRouter(
sessionBridge,
opencodeRuntime,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionTranscriptStore,
buildChatRouter({
frontendActionCoordinator,
learningOrchestrator,
memoryStore,
resultReferenceResolver,
),
runtime: opencodeRuntime,
sessionBridge,
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
}),
);
const bootstrap = async () => {
+1 -115
View File
@@ -1,5 +1,3 @@
import { RESULT_REF_PATTERN } from "../results/store.js";
import { chartGrammar } from "./registry.js";
import type { UIEnvelope } from "./types.js";
type ToolCallInput = {
@@ -8,117 +6,5 @@ type ToolCallInput = {
reason?: string;
};
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
const normalizeSeries = (value: unknown) =>
Array.isArray(value)
? value
.filter(
(item): item is { name: string; data: number[]; type?: "line" | "bar" } =>
typeof item === "object" &&
item !== null &&
"name" in item &&
typeof item.name === "string" &&
"data" in item &&
Array.isArray(item.data) &&
item.data.every((entry: unknown) => typeof entry === "number"),
)
.map((item) => ({
name: item.name,
data: item.data,
...(item.type === "line" || item.type === "bar" ? { type: item.type } : {}),
}))
: [];
export const toUiEnvelopeFromToolCall = ({
tool,
params,
reason,
}: ToolCallInput): UIEnvelope | null => {
if (tool === "show_chart" || tool === "chart") {
const xData = isStringArray(params.x_data) ? params.x_data : [];
const series = normalizeSeries(params.series);
if (xData.length === 0 || series.length === 0) {
return null;
}
const chartType =
params.chart_type === "bar" || params.chart_type === "pie"
? params.chart_type
: "line";
return {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: chartGrammar,
surface: "chat_inline",
spec: {
title: typeof params.title === "string" ? params.title : undefined,
chart_type: chartType,
x_axis_name:
typeof params.x_axis_name === "string" ? params.x_axis_name : undefined,
y_axis_name:
typeof params.y_axis_name === "string" ? params.y_axis_name : undefined,
},
data: {
x_data: xData,
series,
},
fallbackText: reason || "已生成图表。",
};
}
if (
tool === "locate_features" ||
tool === "zoom_to_map" ||
tool === "apply_layer_style"
) {
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: tool,
surface: "map_overlay",
params,
fallbackText: reason || "已生成地图操作。",
};
}
if (tool === "render_junctions") {
const renderRef =
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
if (!RESULT_REF_PATTERN.test(renderRef)) {
return null;
}
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: tool,
surface: "map_overlay",
params: { render_ref: renderRef },
fallbackText: reason || "已生成节点渲染操作。",
};
}
if (tool === "view_history") {
return {
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "HistoryPanel",
surface: "side_panel",
props: params,
fallbackText: reason || "已打开历史数据面板。",
};
}
if (tool === "view_scada") {
return {
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "ScadaPanel",
surface: "side_panel",
props: params,
fallbackText: reason || "已打开 SCADA 面板。",
};
}
return null;
};
}: ToolCallInput): UIEnvelope | null => null;
+3 -41
View File
@@ -2,51 +2,13 @@ import type { ActionManifest, ComponentManifest } from "./types.js";
export const chartGrammar = "echarts-safe-subset" as const;
export const componentRegistry: ComponentManifest[] = [
{
id: "HistoryPanel",
version: "1.0.0",
description: "Open a historical result panel for selected network features.",
supportedSurfaces: ["side_panel", "canvas"],
},
{
id: "ScadaPanel",
version: "1.0.0",
description: "Open a SCADA history panel for selected devices.",
supportedSurfaces: ["side_panel", "canvas"],
},
];
export const componentRegistry: ComponentManifest[] = [];
export const actionRegistry: ActionManifest[] = [
{
id: "locate_features",
version: "1.0.0",
description: "Locate and highlight network features on the map.",
supportedSurfaces: ["map_overlay"],
},
{
id: "zoom_to_map",
version: "1.0.0",
description: "Zoom the map to a coordinate.",
supportedSurfaces: ["map_overlay"],
},
{
id: "render_junctions",
version: "1.0.0",
description: "Render junction categories from an authorized render_ref.",
supportedSurfaces: ["map_overlay"],
},
{
id: "apply_layer_style",
version: "1.0.0",
description: "Apply or reset a map layer style.",
supportedSurfaces: ["map_overlay"],
},
];
export const actionRegistry: ActionManifest[] = [];
export const uiRegistryResponse = {
schema_version: "agent-ui-registry@1",
chart_grammars: [chartGrammar],
chart_grammars: [],
components: componentRegistry,
actions: actionRegistry,
};
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test";
import { frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js";
describe("frontend action call ID plugin", () => {
it("injects call IDs only for SCADA frontend actions", async () => {
const hooks = await frontendActionCallIdPlugin({} as never);
const before = hooks["tool.execute.before"];
if (!before) throw new Error("before hook missing");
const actionArgs: Record<string, unknown> = { items: [] };
const actionOutput: { args: Record<string, unknown> } = { args: actionArgs };
await before(
{ tool: "render_scada_analysis", callID: "call-1", sessionID: "s" },
actionOutput,
);
expect(actionOutput.args).toBe(actionArgs);
expect(actionArgs).toEqual({
items: [],
__frontend_action_call_id: "call-1",
});
const regularArgs = { query: "SCADA" };
const regularOutput = { args: regularArgs };
await before(
{ tool: "session_search", callID: "call-2", sessionID: "s" },
regularOutput,
);
expect(regularOutput.args).toBe(regularArgs);
expect(regularArgs).toEqual({ query: "SCADA" });
});
it("exports only plugin functions so OpenCode can load the module", async () => {
const pluginModule = await import("../../.opencode/plugins/frontend-action-call-id.js");
expect(Object.keys(pluginModule)).toEqual(["frontendActionCallIdPlugin"]);
expect(Object.values(pluginModule).every((value) => typeof value === "function")).toBeTrue();
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "bun:test";
import {
FrontendActionCoordinator,
FrontendActionError,
} from "../../src/frontendAction/coordinator.js";
import { frontendActionRegistry } from "../../src/frontendAction/registry.js";
import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
const items = [
{ sensor_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" },
] as const;
const start = (
coordinator: FrontendActionCoordinator,
name = "render_scada_analysis",
params: unknown = { items },
) => {
let emitted: FrontendActionRequest | undefined;
coordinator.registerSink("session-1", (request) => {
emitted = request;
});
const pending = coordinator.request({
sessionId: "session-1",
toolCallId: "call-1",
name,
params,
});
if (!emitted) throw new Error("request was not emitted");
return { emitted, pending };
};
describe("FrontendActionCoordinator", () => {
it("registers only the two view-risk SCADA actions with 15 second expiry", () => {
expect(frontendActionRegistry).toEqual([
expect.objectContaining({ id: "render_scada_analysis", risk: "view", timeoutMs: 15_000 }),
expect.objectContaining({ id: "clear_scada_analysis", risk: "view", timeoutMs: 15_000 }),
]);
});
it("emits once and resolves with validated browser output", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator);
expect(emitted).toMatchObject({
name: "render_scada_analysis",
risk: "view",
replayPolicy: "never",
params: { items },
});
expect(emitted.expiresAt - emitted.issuedAt).toBe(15_000);
const result = {
version: "frontend-action-result@1" as const,
actionId: emitted.actionId,
status: "succeeded" as const,
output: {
rendered_ids: ["MP01", "MP02"],
missing_ids: [],
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 },
fitted: true as const,
},
completedAt: Date.now(),
};
expect(coordinator.submitResult("session-1", result).duplicate).toBe(false);
expect((await pending).output).toEqual(result.output);
expect(coordinator.submitResult("session-1", result).duplicate).toBe(true);
});
it("does not replay never-policy actions when the browser sink reconnects", async () => {
const coordinator = new FrontendActionCoordinator();
const emitted: FrontendActionRequest[] = [];
const unregister = coordinator.registerSink("session-1", (request) => emitted.push(request));
const pending = coordinator.request({
sessionId: "session-1",
toolCallId: "call-no-replay",
name: "clear_scada_analysis",
params: {},
});
unregister();
coordinator.registerSink("session-1", (request) => emitted.push(request));
expect(emitted).toHaveLength(1);
coordinator.submitResult("session-1", {
version: "frontend-action-result@1",
actionId: emitted[0].actionId,
status: "succeeded",
output: { cleared: true },
completedAt: Date.now(),
});
await pending;
});
it("accepts clear output and rejects invalid successful output", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator, "clear_scada_analysis", {});
expect(() => coordinator.submitResult("session-1", {
version: "frontend-action-result@1",
actionId: emitted.actionId,
status: "succeeded",
output: { cleared: false },
completedAt: Date.now(),
})).toThrow("invalid frontend action output");
coordinator.submitResult("session-1", {
version: "frontend-action-result@1",
actionId: emitted.actionId,
status: "succeeded",
output: { cleared: true },
completedAt: Date.now(),
});
expect((await pending).output).toEqual({ cleared: true });
});
it("validates item count, unique IDs, ID length, and levels", () => {
const coordinator = new FrontendActionCoordinator();
coordinator.registerSink("s", () => undefined);
const request = (value: unknown) => coordinator.request({
sessionId: "s",
toolCallId: "call-validation",
name: "render_scada_analysis",
params: value,
});
expect(() => request({ items: [] })).toThrow(FrontendActionError);
expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ sensor_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ sensor_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ sensor_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params");
});
it("keeps terminal results idempotent and rejects conflicts", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator, "clear_scada_analysis", {});
const result = {
version: "frontend-action-result@1" as const,
actionId: emitted.actionId,
status: "succeeded" as const,
output: { cleared: true as const },
completedAt: Date.now(),
};
expect(() => coordinator.submitResult("other", result)).toThrow("session mismatch");
coordinator.submitResult("session-1", result);
await pending;
expect(() => coordinator.submitResult("session-1", { ...result, completedAt: result.completedAt + 1 })).toThrow("conflicts");
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test";
import { unwrapFrontendActionResult } from "../../.opencode/tools/frontend_action.js";
describe("frontend action tool bridge", () => {
it("returns only validated browser output for successful actions", () => {
expect(unwrapFrontendActionResult(JSON.stringify({
version: "frontend-action-result@1",
actionId: "action-1",
status: "succeeded",
output: { cleared: true },
completedAt: Date.now(),
}))).toBe('{"cleared":true}');
});
it("throws the browser error for failed and expired actions", () => {
expect(() => unwrapFrontendActionResult(JSON.stringify({
version: "frontend-action-result@1",
actionId: "action-1",
status: "failed",
error: { code: "WFS_UNAVAILABLE", message: "SCADA geometry query failed" },
completedAt: Date.now(),
}))).toThrow("WFS_UNAVAILABLE: SCADA geometry query failed");
expect(() => unwrapFrontendActionResult(JSON.stringify({
version: "frontend-action-result@1",
actionId: "action-2",
status: "expired",
error: { code: "ACTION_TIMEOUT", message: "browser action timed out" },
completedAt: Date.now(),
}))).toThrow("ACTION_TIMEOUT: browser action timed out");
});
});
+2 -2
View File
@@ -24,11 +24,11 @@ describe("MemoryStore", () => {
it("dedupes exact duplicate memories", async () => {
const first = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
content: "SCADA level anomaly checks should compare level and temperature metrics first.",
source: "tool",
});
const second = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
content: "SCADA level anomaly checks should compare level and temperature metrics first.",
source: "tool",
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import { existsSync } from "node:fs";
const disabledServerTools = [
"geocode",
"get_project_context",
"list_scada_assets",
"query_scada_readings",
"web_search",
];
describe("disabled opencode tools", () => {
it("exposes only the two SCADA frontend-action tools", () => {
for (const toolName of disabledServerTools) {
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
}
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeFalse();
expect(existsSync(".opencode/tools/frontend_action.ts")).toBeTrue();
expect(existsSync(".opencode/tools/render_scada_analysis.ts")).toBeTrue();
expect(existsSync(".opencode/tools/clear_scada_analysis.ts")).toBeTrue();
expect(existsSync(".opencode/plugins/frontend-action-call-id.ts")).toBeTrue();
});
});
+9 -9
View File
@@ -26,8 +26,8 @@ describe("skill_manager tool", () => {
const skillDocument = (body: string) =>
[
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"name: scada-level-review",
"description: SCADA level review workflow.",
"---",
"",
body,
@@ -64,32 +64,32 @@ describe("skill_manager tool", () => {
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Pressure Review"),
content: skillDocument("# SCADA Level Review"),
reason: "verified reusable workflow",
skill_path: "workflow/pressure-review",
skill_path: "workflow/scada-level-review",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
"# SCADA Level Review\n",
);
const updateResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Updated Pressure Review"),
content: skillDocument("# Updated SCADA Level Review"),
reason: "verified reusable workflow overwrite",
skill_path: "workflow/pressure-review",
skill_path: "workflow/scada-level-review",
},
toolContext,
) as string,
);
expect(updateResult.decision).toBe("accepted");
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
"# Updated SCADA Level Review\n",
);
const removeResult = JSON.parse(
@@ -97,7 +97,7 @@ describe("skill_manager tool", () => {
{
action: "remove_skill",
reason: "workflow is obsolete",
skill_path: "workflow/pressure-review",
skill_path: "workflow/scada-level-review",
},
toolContext,
) as string,
+16 -94
View File
@@ -26,28 +26,26 @@ describe("ResultReferenceResolver", () => {
await rm(tempDir, { force: true, recursive: true });
});
it("stores metadata for render refs and resolves them", async () => {
it("stores metadata for server API result refs and resolves them", async () => {
const record = await resolver.register({
actorKey: "actor-1",
clientSessionId: "client-1",
data: {
node_area_map: {
J1: "DMA-1",
J2: "DMA-2",
},
data: [{ asset_id: "asset-1", value: 1.23 }],
meta: { rows: 1 },
},
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
kind: RESULT_REFERENCE_KIND.serverApiPayload,
projectId: "project-1",
projectKey: "project-key-1",
schemaVersion: 1,
sessionId: "session-1",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
source: RESULT_REFERENCE_SOURCE.serverApi,
traceId: "trace-1",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(record.kind).toBe(RESULT_REFERENCE_KIND.serverApiPayload);
expect(record.schemaVersion).toBe(1);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.serverApi);
const result = await resolver.getFullAuthorized(
record.resultRef,
@@ -58,14 +56,12 @@ describe("ResultReferenceResolver", () => {
);
expect(result).not.toBeNull();
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.serverApiPayload);
expect(result?.schema_version).toBe(1);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.serverApi);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "DMA-2",
},
data: [{ asset_id: "asset-1", value: 1.23 }],
meta: { rows: 1 },
});
});
@@ -102,97 +98,23 @@ describe("ResultReferenceResolver", () => {
});
expect(malformed).toBeNull();
const renderRecord = await resolver.register({
const serverRecord = await resolver.register({
actorKey: "actor-2",
clientSessionId: "client-2",
data: {
node_area_map: {
J1: "DMA-1",
},
},
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
data: { data: [{ asset_id: "asset-2" }] },
kind: RESULT_REFERENCE_KIND.serverApiPayload,
projectId: "project-2",
projectKey: "project-key-2",
schemaVersion: 1,
sessionId: "session-2",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
source: RESULT_REFERENCE_SOURCE.serverApi,
traceId: "trace-2",
});
const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
const wrongActor = await resolver.getFullAuthorized(serverRecord.resultRef, {
actorKey: "actor-other",
projectId: "project-2",
});
expect(wrongActor).toBeNull();
});
it("registers render refs from local wrapper files and normalizes payloads", async () => {
const filePath = join(tempDir, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify(
{
metadata: {
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-3",
},
location: {
file_path: filePath,
},
data: {
node_area_map: {
J1: "DMA-1",
J2: 2,
},
area_ids: ["DMA-1", " DMA-2 "],
area_colors: {
"DMA-1": "#ff0000",
"DMA-2": "#00ff00",
},
},
createdAt: "2026-05-21T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
const record = await resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-3",
clientSessionId: "client-3",
projectId: "project-3",
projectKey: "project-key-3",
sessionId: "session-3",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-3",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
const result = await resolver.getFullAuthorized(
record.resultRef,
{
actorKey: "actor-3",
projectId: "project-3",
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "2",
},
area_ids: ["DMA-1", "DMA-2"],
area_colors: {
"DMA-1": "#ff0000",
"DMA-2": "#00ff00",
},
});
});
});
+30 -32
View File
@@ -2,12 +2,10 @@ import { describe, expect, it } from "bun:test";
import {
buildForkedSessionUiState,
resolveRequestedStreamSessionId,
} from "../../src/routes/chat.js";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle,
resolveRequestedStreamSessionId,
shouldRestoreConversationForRuntime,
shouldGenerateSessionTitle,
} from "../../src/routes/chatSession.js";
@@ -24,26 +22,26 @@ describe("resolveRequestedStreamSessionId", () => {
expect(
resolveRequestedStreamSessionId({
sessionId: " existing-session ",
promptText: "检查压力异常",
promptText: "检查 SCADA 液位异常",
createClientSessionId,
}),
).toBe("existing-session");
expect(
resolveRequestedStreamSessionId({
promptText: "检查压力异常",
promptText: "检查 SCADA 液位异常",
createClientSessionId,
}),
).toBeUndefined();
});
it("keeps the pressure trend demo on its generated client session id", () => {
it("does not special-case removed SCADA trend demo prompts", () => {
expect(
resolveRequestedStreamSessionId({
promptText: "展示最近压力趋势",
promptText: "展示最近液位趋势",
createClientSessionId: () => "demo-session",
}),
).toBe("demo-session");
).toBeUndefined();
});
});
@@ -94,7 +92,7 @@ describe("generateSessionTitle", () => {
messages: async () => [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
parts: [{ type: "text", text: "标题:SCADA 液位异常排查。" }],
},
],
abortSession: async () => undefined,
@@ -102,14 +100,14 @@ describe("generateSessionTitle", () => {
const title = await generateSessionTitle(runtime, {
sessionId: "chat-session",
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
latestUserMessage: "检查一下 82c7d04c 资产最近液位波动的原因",
latestAssistantMessage: "该 SCADA 资产液位波动主要与夜间水位快速变化时段重合。",
fallbackTitle: "新对话",
});
expect(title).toBe("泵站压力异常排查");
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
expect(title).toBe("SCADA 液位异常排查");
expect(titlePrompt).toContain("用户:检查一下 82c7d04c 资产最近液位波动的原因");
expect(titlePrompt).toContain("助手:该 SCADA 资产液位波动主要与夜间水位快速变化时段重合。");
});
});
@@ -126,32 +124,32 @@ describe("buildPromptWithLearningContext", () => {
{
recentTurns: [],
persistedMessages: [
{ role: "user", content: "先分析 3 号泵站夜间压力波动" },
{ role: "user", content: "先分析 82c7d04c 资产夜间液位波动" },
{
role: "assistant",
content: "已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。",
content: "已定位到夜间液位变化与读数异常时段重合,下一步准备对比温度和液位曲线。",
isError: true,
},
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
],
message: "继续刚才的分析,并补充相邻支路影响",
message: "继续刚才的分析,并补充温度指标对照",
},
);
expect(prompt).toContain("用户:先分析 3 号泵站夜间压力波动");
expect(prompt).toContain("用户:先分析 82c7d04c 资产夜间液位波动");
expect(prompt).toContain(
"助手:已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。",
"助手:已定位到夜间液位变化与读数异常时段重合,下一步准备对比温度和液位曲线。",
);
expect(prompt).not.toContain("⚠️ **请求已中断**");
expect(prompt).toContain("[Current user request]\n继续刚才的分析,并补充相邻支路影响");
expect(prompt).toContain("[Current user request]\n继续刚才的分析,并补充温度指标对照");
});
it("falls back to history turns when frontend state is unavailable", async () => {
const recentTurns: SessionTurnRecord[] = [
{
id: "turn-1",
userMessage: "检查 DMA-2 夜间漏损异常",
assistantMessage: "DMA-2 在 02:00-04:00 出现持续最小夜流抬升。",
userMessage: "检查 82c7d04c 资产夜间液位异常",
assistantMessage: "82c7d04c 在 02:00-04:00 出现持续液位抬升。",
timestamp: new Date().toISOString(),
toolCallCount: 1,
},
@@ -163,12 +161,12 @@ describe("buildPromptWithLearningContext", () => {
"project-1",
{
recentTurns,
message: "继续给出查建议",
message: "继续给出数据核查建议",
},
);
expect(prompt).toContain("用户:检查 DMA-2 夜间漏损异常");
expect(prompt).toContain("助手:DMA-2 在 02:00-04:00 出现持续最小夜流抬升。");
expect(prompt).toContain("用户:检查 82c7d04c 资产夜间液位异常");
expect(prompt).toContain("助手:82c7d04c 在 02:00-04:00 出现持续液位抬升。");
});
it("skips restored conversation injection when reusing an existing opencode session", async () => {
@@ -218,20 +216,20 @@ describe("buildPromptWithLearningContext", () => {
describe("extractLatestFrontendTurn", () => {
it("extracts the latest valid frontend user and assistant turn", () => {
const turn = extractLatestFrontendTurn([
{ role: "user", content: "检查 DMA-2 漏损" },
{ role: "user", content: "检查 82c7d04c 液位异常" },
{
role: "assistant",
content: "DMA-2 夜间最小流量持续抬升。",
content: "82c7d04c 夜间液位持续抬升。",
progress: [{ id: "tool-dma", phase: "tool" }],
},
{ role: "user", content: "继续分析相邻分区" },
{ role: "user", content: "继续分析同类资产" },
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
]);
expect(turn).toEqual({
assistantMessage: "DMA-2 夜间最小流量持续抬升。",
assistantMessage: "82c7d04c 夜间液位持续抬升。",
toolCallCount: 1,
userMessage: "检查 DMA-2 漏损",
userMessage: "检查 82c7d04c 液位异常",
});
});
});
@@ -241,7 +239,7 @@ describe("buildForkedSessionUiState", () => {
const forked = buildForkedSessionUiState(
{
messages: [
{ role: "user", content: "画压力曲线" },
{ role: "user", content: "画液位曲线" },
{
role: "assistant",
content: "已生成图表",
@@ -267,7 +265,7 @@ describe("buildForkedSessionUiState", () => {
sessionId: "forked-session",
isTitleManuallyEdited: false,
messages: [
{ role: "user", content: "画压力曲线" },
{ role: "user", content: "画液位曲线" },
{
role: "assistant",
content: "已生成图表",
+11 -74
View File
@@ -20,12 +20,12 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("streamPromptResponse", () => {
it("does not split short Chinese text only because it has punctuation", () => {
expect(detectTokenSmoothingChunk("管网压力异常,需要继续分析", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("管网压力异常需要继续分析。", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("SCADA 液位异常,需要继续分析", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("SCADA 液位异常需要继续分析。", "zh")).toBeNull();
});
it("does not immediately release a single Chinese character", () => {
expect(detectTokenSmoothingChunk("", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("", "zh")).toBeNull();
});
it("keeps non-Chinese text on the word path even with zh locale", () => {
@@ -33,7 +33,7 @@ describe("streamPromptResponse", () => {
});
it("falls back to bounded Chinese chunks when punctuation is absent", () => {
const content = "管网压力异常需要继续分析东部主干供水走廊和相关阀门状态变化";
const content = "SCADA 液位异常需要继续分析水质温度和液位读数变化";
const chunk = detectTokenSmoothingChunk(content, "zh");
expect(chunk).not.toBeNull();
@@ -72,11 +72,11 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
smoother.writeToken("");
smoother.writeToken("");
await sleep(8);
expect(events).toHaveLength(0);
const content = "网压力异常需要继续分析东部主干供水走廊和相关阀门状态";
const content = "异常需要继续分析水质温度和液位读数持续变化趋势";
for (const char of Array.from(content)) {
smoother.writeToken(char);
}
@@ -84,7 +84,7 @@ describe("streamPromptResponse", () => {
expect(events.length).toBeGreaterThan(0);
await smoother.flush();
expect(events.map((item) => item.data.content).join("")).toBe(`${content}`);
expect(events.map((item) => item.data.content).join("")).toBe(`${content}`);
});
it("smooths long Chinese text without waiting for paragraph-sized buffers", async () => {
@@ -97,7 +97,7 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
const content = "管网压力异常需要继续分析东部主干供水走廊和相关阀门状态变化";
const content = "SCADA 液位异常需要继续分析水质温度和液位读数变化";
for (const char of Array.from(content)) {
smoother.writeToken(char);
}
@@ -133,7 +133,7 @@ describe("streamPromptResponse", () => {
sessionID: "runtime-session-1",
partID: "text-part-1",
field: "text",
delta: "管网压力异常需要继续分析",
delta: "SCADA 液位异常需要继续分析",
},
},
{
@@ -142,7 +142,7 @@ describe("streamPromptResponse", () => {
sessionID: "runtime-session-1",
partID: "text-part-1",
field: "text",
delta: "东部主干供水走廊和相关阀门状态。",
delta: "水质温度和液位读数。",
},
},
{
@@ -172,7 +172,7 @@ describe("streamPromptResponse", () => {
expect(tokenEvents.length).toBeGreaterThan(0);
expect(tokenEvents.every((item) => events.indexOf(item) < doneIndex)).toBe(true);
expect(tokenEvents.map((item) => item.data.content).join("")).toBe(
"管网压力异常需要继续分析东部主干供水走廊和相关阀门状态。",
"SCADA 液位异常需要继续分析水质温度和液位读数。",
);
});
@@ -535,67 +535,4 @@ describe("streamPromptResponse", () => {
});
});
it("maps visual tool calls to UIEnvelope SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-chart",
sessionID: "runtime-session-1",
messageID: "message-1",
type: "tool",
callID: "call-chart",
tool: "show_chart",
state: {
status: "running",
input: {
reason: "展示压力趋势",
title: "压力趋势",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-1", data: [0.4, 0.42] }],
},
time: { start: Date.now() },
},
},
time: Date.now(),
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "chart",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "tool_call")?.data).toMatchObject({
session_id: "client-session-1",
tool: "show_chart",
});
expect(events.find((item) => item.event === "ui_envelope")?.data).toMatchObject({
session_id: "client-session-1",
envelope: {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
},
});
});
});
+22 -8
View File
@@ -8,13 +8,27 @@ import {
} from "../../src/routes/chatUiState.js";
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
it("marks SCADA frontend actions as map artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
tool: "render_scada_analysis",
reason: "显示已完成的分级结论",
params: { items: [{ sensor_id: "MP01", level: "high" }] },
}) as Array<Record<string, unknown>>;
expect(artifacts[0]).toMatchObject({
tool: "render_scada_analysis",
kind: "map",
description: "显示已完成的分级结论",
});
});
it("persists removed visual tool calls as generic tool artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
title: "压力曲线",
title: "液位曲线",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
@@ -24,8 +38,8 @@ describe("appendBackendToolArtifact", () => {
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
tool: "show_chart",
kind: "chart",
title: "压力曲线",
kind: "tool",
title: "液位曲线",
description: "测试折线图渲染",
params: {
chart_type: "line",
@@ -50,9 +64,9 @@ describe("appendBackendUiEnvelope", () => {
envelope: {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: "locate_features",
action: "zoom_to_map",
surface: "map_overlay",
params: { ids: ["J-1"], feature_type: "junction" },
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
},
});
@@ -63,9 +77,9 @@ describe("appendBackendUiEnvelope", () => {
envelope: {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: "locate_features",
action: "zoom_to_map",
surface: "map_overlay",
params: { ids: ["J-1"], feature_type: "junction" },
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
},
renderStatus: "pending",
},
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
createPressureTrendDemoEnvelopePayload,
createPressureTrendDemoToolCall,
isPressureTrendDemoPrompt,
} from "../../src/routes/pressureTrendDemo.js";
describe("pressure trend demo", () => {
it("matches the controlled demo prompt", () => {
expect(isPressureTrendDemoPrompt("展示最近压力趋势")).toBe(true);
expect(isPressureTrendDemoPrompt("请 展示 最近 压力 趋势")).toBe(true);
expect(isPressureTrendDemoPrompt("展示最近流量趋势")).toBe(false);
});
it("creates a show_chart tool call that maps to a chart UIEnvelope", () => {
const toolCall = createPressureTrendDemoToolCall();
const payload = createPressureTrendDemoEnvelopePayload("client-session-1", toolCall);
expect(toolCall.tool).toBe("show_chart");
expect(payload).toMatchObject({
session_id: "client-session-1",
envelope: {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: "chat_inline",
spec: {
title: "最近压力趋势",
chart_type: "line",
},
data: {
x_data: ["10:00", "11:00", "12:00", "13:00", "14:00", "15:00"],
},
},
});
});
});
+4 -4
View File
@@ -88,9 +88,9 @@ describe("SessionTranscriptStore", () => {
sessionId: "thread-3",
},
{
assistantMessage: "已完成压力波动分析。",
assistantMessage: "已完成液位波动分析。",
toolCallCount: 1,
userMessage: "分析压力波动。",
userMessage: "分析液位波动。",
},
);
@@ -102,9 +102,9 @@ describe("SessionTranscriptStore", () => {
sessionId: "thread-3",
},
{
assistantMessage: "已完成压力波动分析。",
assistantMessage: "已完成液位波动分析。",
toolCallCount: 2,
userMessage: "分析压力波动。",
userMessage: "分析液位波动。",
},
);
+8 -8
View File
@@ -42,7 +42,7 @@ describe("SkillStore", () => {
process.chdir(alternateCwd);
const result = await store.writeScript(
"workflow/hydraulic-bottleneck-analysis",
"workflow/scada-level-review",
"scripts/analyze.py",
"print('ok')\n",
);
@@ -53,7 +53,7 @@ describe("SkillStore", () => {
target: join(
skillsRoot,
"workflow",
"hydraulic-bottleneck-analysis",
"scada-level-review",
"scripts",
"analyze.py",
),
@@ -63,7 +63,7 @@ describe("SkillStore", () => {
it("rejects script paths outside scripts/*.py", async () => {
const result = await store.writeScript(
"workflow/hydraulic-bottleneck-analysis",
"workflow/scada-level-review",
"analyze.ts",
"console.log('ok')\n",
);
@@ -76,21 +76,21 @@ describe("SkillStore", () => {
});
it("writes and overwrites the main skill file", async () => {
const skillPath = "workflow/pressure-review";
const skillPath = "workflow/scada-level-review";
const writeResult = await store.writeSkill(
skillPath,
skillDocument("pressure-review", "# Pressure Review"),
skillDocument("scada-level-review", "# SCADA Level Review"),
);
expect(writeResult).toEqual({
changed: true,
detail: "skill written",
target: join(skillsRoot, "workflow", "pressure-review", "SKILL.md"),
target: join(skillsRoot, "workflow", "scada-level-review", "SKILL.md"),
});
const overwriteResult = await store.writeSkill(
skillPath,
skillDocument("pressure-review", "# Updated Pressure Review"),
skillDocument("scada-level-review", "# Updated SCADA Level Review"),
);
expect(overwriteResult).toEqual({
@@ -99,7 +99,7 @@ describe("SkillStore", () => {
target: writeResult.target,
});
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
"# Updated SCADA Level Review\n",
);
});
+13 -44
View File
@@ -3,51 +3,20 @@ import { describe, expect, it } from "bun:test";
import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js";
describe("toUiEnvelopeFromToolCall", () => {
it("maps show_chart calls to chart envelopes", () => {
const envelope = toUiEnvelopeFromToolCall({
tool: "show_chart",
reason: "展示压力趋势",
params: {
title: "压力趋势",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-1", data: [0.41, 0.43] }],
},
});
expect(envelope).toMatchObject({
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: "chat_inline",
fallbackText: "展示压力趋势",
data: {
x_data: ["00:00", "01:00"],
series: [{ name: "P-1", data: [0.41, 0.43] }],
},
});
it("does not map removed visual tools", () => {
expect(toUiEnvelopeFromToolCall({ tool: "zoom_to_map", params: {} })).toBeNull();
expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull();
expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull();
});
it("rejects render_junctions without a valid render_ref", () => {
expect(
toUiEnvelopeFromToolCall({
tool: "render_junctions",
params: { render_ref: "/tmp/payload.json" },
}),
).toBeNull();
});
it("maps view_scada calls to registered component envelopes", () => {
const envelope = toUiEnvelopeFromToolCall({
tool: "view_scada",
params: { device_id: "D-1" },
});
expect(envelope).toMatchObject({
kind: "registered_component",
component: "ScadaPanel",
surface: "side_panel",
props: { device_id: "D-1" },
});
it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => {
expect(toUiEnvelopeFromToolCall({
tool: "render_scada_analysis",
params: { items: [{ sensor_id: "MP01", level: "high" }] },
})).toBeNull();
expect(toUiEnvelopeFromToolCall({
tool: "clear_scada_analysis",
params: {},
})).toBeNull();
});
});
+1 -1
View File
@@ -13,5 +13,5 @@
"allowSyntheticDefaultImports": true,
"types": ["node", "bun-types"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "cli/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts"]
}