refactor(agent)!: focus tools on SCADA

This commit is contained in:
2026-07-15 18:30:58 +08:00
parent 8a21908785
commit 155513fdbc
47 changed files with 503 additions and 2910 deletions
+12 -23
View File
@@ -4,7 +4,7 @@ mode: primary
model: deepseek/deepseek-v4-flash
temperature: 0.2
---
你是 TJWater 实验性供水管网分析 Agent。回复用户时使用简体中文,内容简洁准确。
你是 TJWater 实验性 SCADA 数据 Agent。回复用户时使用简体中文,内容简洁准确。
## 核心定位
@@ -15,9 +15,9 @@ Agent 负责:
1. 理解用户意图。
2. 调用受控工具获取有限上下文或表达展示意图。
3. 输出文字摘要、结论、追问和建议。
4. 通过前端展示工具表达 UI 意图。
4. 通过受控地图工具表达定位意图。
服务端负责把前端展示工具转换成 `agent-ui@1` 的 UIEnvelope;前端只渲染可信组件、图表和地图动作。
服务端负责把受控地图工具转换成 `agent-ui@1` 的 UIEnvelope;前端只执行可信地图动作。
## 工具选择
@@ -25,27 +25,19 @@ Agent 负责:
|------|------|
| 查询实时公开网页信息 | `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` |
| 地图定位 | `zoom_to_map` |
| 历史会话检索 | `session_search` |
| 用户偏好和项目事实 | `memory_manager` |
| 可复用流程沉淀 | `skill_manager` |
| 项目背景 | `get_project_context` |
| 监测设备/点位 | `list_monitoring_assets` |
| 监测时序读数 | `query_monitoring_readings`(必须给出 `observed_from``observed_to` |
| 数据质量分析 | `start_data_quality_analysis``get_job_status` |
| SWMM 仿真 | `start_simulation``get_job_status` |
| SCADA 资产 | `list_scada_assets` |
| SCADA 时序读数 | `query_scada_readings`(必须给出 `observed_from``observed_to` |
## UI 约束
1. 前端工具仅表达展示意图,不返回业务数据。
1. 前端地图工具仅表达定位意图,不返回业务数据。
2. 每次工具调用必须填写具体 `reason`
3. 图表使用 `show_chart`,折线图/柱状图必须用 `x_data` 作为横轴标签,`series[].data` 作为同长度数值数组
4. 大型 junction 渲染必须使用 `render_ref`,不要直接把完整 payload 传给前端。
5. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码
## 执行约束
@@ -66,13 +58,10 @@ Agent 负责:
仅通过领域工具访问 Server;不得构造 URL、HTTP header、SQL、命令或项目 ID。
- 项目背景:`get_project_context`
- 设备/监测点:`list_monitoring_assets`
- 时序读数:`query_monitoring_readings`
- 数据质量任务:`start_data_quality_analysis`(写操作,必须等待审批)
- 仿真任务:`start_simulation`(写操作,必须等待审批)
- 任务状态:`get_job_status`
- SCADA 资产:`list_scada_assets`
- SCADA 时序读数:`query_scada_readings`
查询历史监测数据时,必须先明确时间区间;不清楚区间时先追问,不得省略或自行扩大范围。需要数值结论时先调用 `query_monitoring_readings`,传入 `observed_from``observed_to``granularity``metrics`设备 ID,再调用 `show_chart``view_history` 展示。`view_history` 只负责打开前端历史面板,也必须传入 `start_time``end_time`
先查询数据再作判断。回答必须说明查询时间范围、粒度指标,以及已创建任务的 ID 和状态
查询历史 SCADA 数据时,必须先明确时间区间;不清楚区间时先追问,不得省略或自行扩大范围。需要数值结论时先调用 `query_scada_readings`,传入 `observed_from``observed_to``granularity``metrics` `asset_ids`,再用文字摘要说明结果
先查询数据再作判断。回答必须说明查询时间范围、粒度指标。
遇到空数据、拒绝、权限错误、超时或服务不可用时,明确说明事实,不得补全或猜测结果。
工具返回 `result_ref` 时,说明完整结果已安全存储,并使用 preview 中的样本和统计作答。
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Plugin } from "@opencode-ai/plugin";
export const FRONTEND_ACTION_TOOLS = new Set(["zoom_to_map", "locate_features", "view_history", "view_scada"]);
export const FRONTEND_ACTION_TOOLS = new Set(["zoom_to_map"]);
export const frontendActionCallIdPlugin: Plugin = async () => ({
"tool.execute.before": async (input, output) => {
-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}图层应用样式。`;
},
});
-3
View File
@@ -1,3 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({ description: "读取分析或仿真任务的状态。", args: { job_id: tool.schema.string().uuid(), job_type: tool.schema.enum(["analysis", "simulation"]) }, execute: (args, context) => callServerApiTool("get_job_status", args, context.sessionID) });
@@ -1,3 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({ description: "列出当前项目的监测设备和监测点。", args: { include: tool.schema.enum(["devices", "points", "all"]).default("all") }, execute: (args, context) => callServerApiTool("list_monitoring_assets", args, context.sessionID) });
+10
View File
@@ -0,0 +1,10 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({
description:
"列出当前项目可查询的 SCADA 资产。无请求参数;返回 assets 数组,包含 assets[].scada_id、external_id、site_external_id、name、kind、active。后续查询读数时必须使用 assets[].scada_id 作为 query_scada_readings.asset_ids;不要使用 GeoServer feature id/fid。",
args: {},
execute: (args, context) =>
callServerApiTool("list_scada_assets", args, context.sessionID),
});
-22
View File
@@ -1,22 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
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().min(1)).min(1).max(100)
.describe("Feature ids to locate."),
feature_type: tool.schema
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"])
.describe("Type of feature to locate."),
},
async execute(args, context) {
return executeFrontendAction("locate_features", args, context);
},
});
@@ -1,3 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({ description: "按设备、时间范围、粒度和允许指标查询监测读数。", args: { device_ids: tool.schema.array(tool.schema.string().uuid()).min(1).max(100), observed_from: tool.schema.string().datetime(), observed_to: tool.schema.string().datetime(), granularity: tool.schema.enum(["5m", "1h", "1d"]).default("5m"), metrics: tool.schema.array(tool.schema.enum(["flow_mean", "flow_total", "conductivity_mean", "velocity_mean", "temperature_mean", "level_mean"])).min(1).max(6) }, execute: (args, context) => callServerApiTool("query_monitoring_readings", args, context.sessionID) });
+50
View File
@@ -0,0 +1,50 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({
description:
"查询一个或多个 SCADA 资产在指定时间范围内的时序读数。调用前通常先用 list_scada_assets 获取资产 id;必须给出明确的 observed_from 和 observed_to,不要省略或自行扩大时间范围。",
args: {
asset_ids: tool.schema
.array(tool.schema.string().uuid())
.min(1)
.max(100)
.describe(
"SCADA asset UUID 数组,1 到 100 个。必须使用 list_scada_assets 返回的 assets[].scada_id,不要使用 GeoServer feature id/fid、external_id 或 site_external_id。",
),
observed_from: tool.schema
.string()
.datetime()
.describe(
"查询开始时间,ISO8601 datetime 字符串,必须包含时区,例如 2026-04-10T07:15:00Z 或 2026-04-10T15:15:00+08:00。包含该时刻。",
),
observed_to: tool.schema
.string()
.datetime()
.describe(
"查询结束时间,ISO8601 datetime 字符串,必须包含时区,且必须晚于 observed_from。服务端按半开区间处理:observed_at >= observed_from 且 observed_at < observed_to。",
),
granularity: tool.schema
.enum(["5m", "1h", "1d"])
.default("5m")
.describe(
"读数粒度:5m 为原始 5 分钟读数,1h 为小时聚合,1d 为日聚合。未指定时默认 5m。",
),
metrics: tool.schema
.array(
tool.schema.enum([
"conductivity_mean",
"level_mean",
"flow_mean",
"temperature_mean",
]),
)
.min(1)
.max(4)
.describe(
"要返回的指标数组,1 到 4 个。允许值:conductivity_mean(电导率均值)、level_mean(液位均值)、flow_mean(流量均值)、temperature_mean(温度均值)。",
),
},
execute: (args, context) =>
callServerApiTool("query_scada_readings", args, context.sessionID),
});
-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 "已在地图上应用节点分区渲染。";
},
});
-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"])
.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 "图表将在对话中显示。";
},
});
@@ -1,3 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({ description: "创建数据质量分析任务。此操作会创建后台任务,需要用户审批。", args: { device_ids: tool.schema.array(tool.schema.string().uuid()).min(1).max(100), observed_from: tool.schema.string().datetime(), observed_to: tool.schema.string().datetime() }, execute: (args, context) => callServerApiTool("start_data_quality_analysis", args, context.sessionID) });
-3
View File
@@ -1,3 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({ description: "基于已注册模型版本创建仿真任务。此操作会创建后台任务,需要用户审批。", args: { model_version_id: tool.schema.string().uuid(), parameters: tool.schema.record(tool.schema.string(), tool.schema.unknown()).default({}) }, execute: (args, context) => callServerApiTool("start_simulation", args, context.sessionID) });
-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";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板;必须提供 ISO8601 时间区间 start_time 和 end_time。",
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()
.datetime()
.describe("Required ISO8601 inclusive start time for the history query."),
end_time: tool.schema
.string()
.datetime()
.describe("Required ISO8601 exclusive end time for the history query; must be after start_time."),
},
async execute(args, context) {
return executeFrontendAction("view_history", args, context);
},
});
-33
View File
@@ -1,33 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
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."),
result_ref: tool.schema.string().optional().describe("Authorized large monitoring result reference."),
metrics: tool.schema.array(tool.schema.string()).optional().describe("Metrics represented by the result."),
granularity: tool.schema.enum(["5m", "1h", "1d"]).optional().describe("Reading aggregation granularity."),
},
async execute(args, context) {
return executeFrontendAction("view_scada", args, context);
},
});
+171 -1047
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -3,36 +3,8 @@ import type { FrontendActionManifest } from "./types.js";
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
const zoomInput = z.object({ x: z.number().finite(), y: z.number().finite(), source_crs: z.enum(["EPSG:3857", "EPSG:4326"]).optional(), zoom: z.number().finite().optional(), duration_ms: z.number().finite().nonnegative().optional() }).strict();
const id = z.string().trim().min(1).max(256);
const isoTime = z.string().datetime({ offset: true });
const timeRange = {
start_time: isoTime.optional(),
end_time: isoTime.optional(),
};
const requiredTimeRange = {
start_time: isoTime,
end_time: isoTime,
};
const locateInput = z.object({
ids: z.array(id).min(1).max(100),
feature_type: z.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"]),
}).strict();
const historyInput = z.object({
feature_infos: z.array(z.tuple([id, id])).min(1).max(100),
data_type: z.enum(["realtime", "scheme", "none"]),
...requiredTimeRange,
}).strict().refine((value) => Date.parse(value.start_time) < Date.parse(value.end_time), "start_time must precede end_time");
const scadaInput = z.object({
device_id: id.optional(),
device_ids: z.array(id).min(1).max(100).optional(),
...timeRange,
}).strict().refine((value) => Boolean(value.device_id) !== Boolean(value.device_ids), "provide exactly one of device_id or device_ids")
.refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
const definitions: Definition[] = [
{ manifest: { id: "zoom_to_map", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["map_overlay"] }, inputSchema: zoomInput, outputSchema: z.object({}).passthrough() },
{ manifest: { id: "locate_features", version: "frontend-action@1", risk: "view", timeoutMs: 15_000, supportedSurfaces: ["map_overlay"] }, inputSchema: locateInput, outputSchema: z.object({ locatedIds: z.array(id), missingIds: z.array(id), featureType: id, bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]) }).strict() },
{ manifest: { id: "view_history", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["side_panel", "canvas"] }, inputSchema: historyInput, outputSchema: z.object({ component: z.literal("HistoryPanel"), rendered: z.literal(true), itemCount: z.number().int().nonnegative() }).strict() },
{ manifest: { id: "view_scada", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["side_panel", "canvas"] }, inputSchema: scadaInput, outputSchema: z.object({ component: z.literal("ScadaPanel"), rendered: z.literal(true), itemCount: z.number().int().nonnegative() }).strict() },
];
export const frontendActionRegistry = definitions.map((item) => item.manifest);
+75 -682
View File
@@ -4,7 +4,7 @@
*/
export interface paths {
"/api/v1/meta/projects": {
"/api/v1/projects": {
parameters: {
query?: never;
header?: never;
@@ -12,7 +12,7 @@ export interface paths {
cookie?: never;
};
/** Projects */
get: operations["projects_api_v1_meta_projects_get"];
get: operations["projects_api_v1_projects_get"];
put?: never;
post?: never;
delete?: never;
@@ -21,15 +21,15 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/meta/project": {
"/api/v1/scada/assets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Project */
get: operations["project_api_v1_meta_project_get"];
/** Assets */
get: operations["assets_api_v1_scada_assets_get"];
put?: never;
post?: never;
delete?: never;
@@ -38,58 +38,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/meta/db/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health */
get: operations["health_api_v1_meta_db_health_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/monitoring/devices": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Devices */
get: operations["devices_api_v1_monitoring_devices_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/monitoring/points": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Points */
get: operations["points_api_v1_monitoring_points_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/monitoring/readings/query": {
"/api/v1/scada/readings/query": {
parameters: {
query?: never;
header?: never;
@@ -99,133 +48,14 @@ export interface paths {
get?: never;
put?: never;
/** Readings */
post: operations["readings_api_v1_monitoring_readings_query_post"];
post: operations["readings_api_v1_scada_readings_query_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/analyses": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Create */
post: operations["create_api_v1_analyses_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/analyses/{job_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get */
get: operations["get_api_v1_analyses__job_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/analyses/{job_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Cancel */
post: operations["cancel_api_v1_analyses__job_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/simulation/models": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Upload */
post: operations["upload_api_v1_simulation_models_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/simulations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Create */
post: operations["create_api_v1_simulations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/simulations/{job_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get */
get: operations["get_api_v1_simulations__job_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/jobs/{job_id}/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Events */
get: operations["events_api_v1_jobs__job_id__events_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tianditu/geocode": {
"/api/v1/geocode": {
parameters: {
query?: never;
header?: never;
@@ -235,7 +65,7 @@ export interface paths {
get?: never;
put?: never;
/** Geocode */
post: operations["geocode_api_v1_tianditu_geocode_post"];
post: operations["geocode_api_v1_geocode_post"];
delete?: never;
options?: never;
head?: never;
@@ -280,80 +110,39 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AnalysisCreate */
AnalysisCreate: {
/**
* Algorithm
* @constant
*/
algorithm: "data-quality";
/** Device Ids */
device_ids: string[];
/**
* Observed From
* Format: date-time
*/
observed_from: string;
/**
* Observed To
* Format: date-time
*/
observed_to: string;
/** GeocodeLocation */
GeocodeLocation: {
/** Lon */
lon: number;
/** Lat */
lat: number;
/** Level */
level?: string | null;
/** Score */
score?: number | null;
/** Keyword */
keyWord?: string | null;
};
/** Body_upload_api_v1_simulation_models_post */
Body_upload_api_v1_simulation_models_post: {
/** Name */
name: string;
/** File */
file: string;
/** GeocodeRequest */
GeocodeRequest: {
/** Keyword */
keyword: string;
};
/** DatabaseHealthResponse */
DatabaseHealthResponse: {
/** Postgres */
postgres: string;
/** Timescale */
timescale: string;
};
/** DeviceOut */
DeviceOut: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Monitoring Point Id
* Format: uuid
*/
monitoring_point_id: string;
/** External Id */
external_id: string;
/** Active */
active: boolean;
/** 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"][];
};
/** JobOut */
JobOut: {
/**
* Id
* Format: uuid
*/
id: string;
/** Status */
status: string;
/** Progress */
progress?: number | null;
/** Result */
result?: {
[key: string]: unknown;
} | null;
/** Error */
error?: string | null;
};
/** MapExtent */
MapExtent: {
/** Xmin */
@@ -365,37 +154,8 @@ export interface components {
/** Ymax */
ymax: number;
};
/** ModelUploadOut */
ModelUploadOut: {
/**
* Model Id
* Format: uuid
*/
model_id: string;
/**
* Model Version Id
* Format: uuid
*/
model_version_id: string;
/** Version */
version: number;
/** Sha256 */
sha256: string;
};
/** MonitoringPointOut */
MonitoringPointOut: {
/**
* Id
* Format: uuid
*/
id: string;
/** External Id */
external_id: string;
/** Name */
name: string;
};
/** ProjectMetaResponse */
ProjectMetaResponse: {
/** ProjectResponse */
ProjectResponse: {
/**
* Project Id
* Format: uuid
@@ -418,31 +178,36 @@ export interface components {
gs_workspace: string;
map_extent: components["schemas"]["MapExtent"] | null;
};
/** ProjectSummaryResponse */
ProjectSummaryResponse: {
/** ScadaAssetOut */
ScadaAssetOut: {
/**
* Project Id
* Scada Id
* Format: uuid
*/
project_id: string;
/** Code */
code: string;
scada_id: string;
/** External Id */
external_id: string;
/** Site External Id */
site_external_id: string;
/** Name */
name: string;
/** Description */
description: string | null;
/**
* Status
* Kind
* @enum {string}
*/
status: "active" | "inactive";
/** Project Role */
project_role: string;
kind: "water_quality" | "radar_level" | "ultrasonic_flow";
/** Active */
active: boolean;
};
/** ReadingQuery */
ReadingQuery: {
/** Device Ids */
device_ids: string[];
/** ScadaAssetsResponse */
ScadaAssetsResponse: {
/** Assets */
assets: components["schemas"]["ScadaAssetOut"][];
};
/** ScadaReadingQuery */
ScadaReadingQuery: {
/** Asset Ids */
asset_ids: string[];
/**
* Observed From
* Format: date-time
@@ -460,24 +225,24 @@ export interface components {
*/
granularity: "5m" | "1h" | "1d";
/** Metrics */
metrics: string[];
metrics: ("conductivity_mean" | "level_mean" | "flow_mean" | "temperature_mean")[];
};
/** ReadingQueryResponse */
ReadingQueryResponse: {
/** ScadaReadingQueryResponse */
ScadaReadingQueryResponse: {
/** Data */
data: components["schemas"]["ReadingRow"][];
data: components["schemas"]["ScadaReadingRow"][];
/** Meta */
meta: {
[key: string]: unknown;
};
};
/** ReadingRow */
ReadingRow: {
/** ScadaReadingRow */
ScadaReadingRow: {
/**
* Device Id
* Asset Id
* Format: uuid
*/
device_id: string;
asset_id: string;
/**
* Observed At
* Format: date-time
@@ -488,46 +253,6 @@ export interface components {
[key: string]: number | null;
};
};
/** SimulationCreate */
SimulationCreate: {
/**
* Model Version Id
* Format: uuid
*/
model_version_id: string;
/** Parameters */
parameters?: {
[key: string]: unknown;
};
};
/** TiandituGeocodeLocation */
TiandituGeocodeLocation: {
/** Lon */
lon: number;
/** Lat */
lat: number;
/** Level */
level?: string | null;
/** Score */
score?: number | null;
/** Keyword */
keyWord?: string | null;
};
/** TiandituGeocodeRequest */
TiandituGeocodeRequest: {
/** Keyword */
keyword: string;
};
/** TiandituGeocodeResponse */
TiandituGeocodeResponse: {
/** Status */
status: string | number;
/** Msg */
msg?: string | null;
location?: components["schemas"]["TiandituGeocodeLocation"] | null;
/** Searchversion */
searchVersion?: string | null;
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -550,7 +275,7 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
projects_api_v1_meta_projects_get: {
projects_api_v1_projects_get: {
parameters: {
query?: never;
header?: never;
@@ -565,12 +290,12 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectSummaryResponse"][];
"application/json": components["schemas"]["ProjectResponse"][];
};
};
};
};
project_api_v1_meta_project_get: {
assets_api_v1_scada_assets_get: {
parameters: {
query?: never;
header?: {
@@ -587,7 +312,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectMetaResponse"];
"application/json": components["schemas"]["ScadaAssetsResponse"];
};
};
/** @description Validation Error */
@@ -601,100 +326,7 @@ export interface operations {
};
};
};
health_api_v1_meta_db_health_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"]["DatabaseHealthResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
devices_api_v1_monitoring_devices_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"]["DeviceOut"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
points_api_v1_monitoring_points_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"]["MonitoringPointOut"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
readings_api_v1_monitoring_readings_query_post: {
readings_api_v1_scada_readings_query_post: {
parameters: {
query?: never;
header?: {
@@ -705,7 +337,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["ReadingQuery"];
"application/json": components["schemas"]["ScadaReadingQuery"];
};
};
responses: {
@@ -715,7 +347,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReadingQueryResponse"];
"application/json": components["schemas"]["ScadaReadingQueryResponse"];
};
};
/** @description Validation Error */
@@ -729,246 +361,7 @@ export interface operations {
};
};
};
create_api_v1_analyses_post: {
parameters: {
query?: never;
header?: {
"Idempotency-Key"?: string | null;
"X-Project-Id"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AnalysisCreate"];
};
};
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobOut"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_api_v1_analyses__job_id__get: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobOut"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
cancel_api_v1_analyses__job_id__cancel_post: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobOut"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_api_v1_simulation_models_post: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_api_v1_simulation_models_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModelUploadOut"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_api_v1_simulations_post: {
parameters: {
query?: never;
header?: {
"Idempotency-Key"?: string | null;
"X-Project-Id"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SimulationCreate"];
};
};
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobOut"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_api_v1_simulations__job_id__get: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobOut"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
events_api_v1_jobs__job_id__events_get: {
parameters: {
query?: never;
header?: {
"X-Project-Id"?: string | null;
};
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
geocode_api_v1_tianditu_geocode_post: {
geocode_api_v1_geocode_post: {
parameters: {
query?: never;
header?: never;
@@ -977,7 +370,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["TiandituGeocodeRequest"];
"application/json": components["schemas"]["GeocodeRequest"];
};
};
responses: {
@@ -987,7 +380,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TiandituGeocodeResponse"];
"application/json": components["schemas"]["GeocodeResponse"];
};
};
/** @description Validation Error */
+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);
-1
View File
@@ -16,7 +16,6 @@ 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;
-38
View File
@@ -31,11 +31,6 @@ import { FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { frontendActionRegistryResponse } from "../frontendAction/registry.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
createPressureTrendDemoEnvelopePayload,
createPressureTrendDemoToolCall,
isPressureTrendDemoPrompt,
} from "./pressureTrendDemo.js";
import {
buildForkedSessionUiState,
buildPromptWithLearningContext,
@@ -1059,39 +1054,6 @@ export const buildChatRouter = (
: undefined;
try {
if (isPressureTrendDemoPrompt(promptText)) {
const toolCall = createPressureTrendDemoToolCall();
publish("progress", {
session_id: clientSessionId,
id: "pressure-trend-demo",
phase: "tool",
status: "running",
title: "生成压力趋势图",
detail: "已命中受控演示用例,正在通过 show_chart 生成可信 UIEnvelope。",
started_at: Date.now(),
elapsed_ms: 0,
});
publish("tool_call", {
session_id: clientSessionId,
tool: toolCall.tool,
params: toolCall.params,
reason: toolCall.reason,
});
publish(
"ui_envelope",
createPressureTrendDemoEnvelopePayload(clientSessionId, toolCall),
);
publish("token", {
session_id: clientSessionId,
content: "已生成最近压力趋势图,并通过可信 UIEnvelope 发送到前端。",
});
publish("done", {
session_id: clientSessionId,
duration_ms: 0,
});
return;
}
const preparedMessage = await buildPromptWithLearningContext(
memoryStore,
requestContext.actorKey,
-37
View File
@@ -48,43 +48,6 @@ export const registerChatAuxiliaryRoutes = (
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",
});
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
if (!result) {
res.status(404).json({ message: "render_ref not found" });
return;
}
res.json(result);
});
chatRouter.get("/result-ref/:result_ref", async (req, res) => {
const resultRef = req.params.result_ref?.trim();
const context = getLocalAgentContext(req);
-4
View File
@@ -4,7 +4,6 @@ import { MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { collectTextContent } from "./chatStream.js";
import { isPressureTrendDemoPrompt } from "./pressureTrendDemo.js";
const TITLE_PROMPT_TIMEOUT_MS = 5000;
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
@@ -241,9 +240,6 @@ export const resolveRequestedStreamSessionId = (input: {
if (sessionId) {
return sessionId;
}
if (isPressureTrendDemoPrompt(input.promptText)) {
return input.createClientSessionId();
}
return undefined;
};
+1 -1
View File
@@ -830,7 +830,7 @@ export const streamPromptResponse = async ({
params: toolParams,
reason,
});
if (envelope && !(suppressLegacyFrontendActions && ["zoom_to_map", "locate_features", "view_history", "view_scada"].includes(part.tool))) {
if (envelope && !(suppressLegacyFrontendActions && part.tool === "zoom_to_map")) {
write("ui_envelope", {
session_id: clientSessionId,
envelope_id: createEnvelopeId(part.tool),
-5
View File
@@ -65,12 +65,7 @@ const toolLabels: Record<string, string> = {
session_search: "历史会话检索",
skill_manager: "流程沉淀",
web_search: "网页搜索",
locate_features: "地图定位",
zoom_to_map: "地图缩放",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
};
export const logDevelopmentDebug = (
+1 -15
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 === "zoom_to_map") {
return "map";
}
if (tool === "view_history" || tool === "view_scada") return "panel";
return "tool";
};
@@ -225,13 +217,7 @@ 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,
};
};
+2 -56
View File
@@ -11,10 +11,7 @@ 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";
@@ -44,7 +41,6 @@ const serverApiGateway = new ServerApiGateway(resultReferenceResolver);
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());
@@ -124,56 +120,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" });
@@ -340,7 +286,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
try {
const response = await callBackendJson(
"/api/v1/tianditu/geocode",
"/api/v1/geocode",
context,
{ keyword },
);
+19 -26
View File
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { config } from "../config.js";
@@ -21,11 +20,8 @@ const responseSchemas: Record<DomainToolName, z.ZodTypeAny> = {
gs_workspace: z.string(),
map_extent: z.object({ xmin: z.number(), ymin: z.number(), xmax: z.number(), ymax: z.number() }).nullable(),
}),
list_monitoring_assets: z.object({ devices: z.array(z.unknown()).optional(), points: z.array(z.unknown()).optional() }),
query_monitoring_readings: z.object({ data: z.array(z.unknown()), meta: z.record(z.unknown()) }),
start_data_quality_analysis: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
start_simulation: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
get_job_status: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
list_scada_assets: z.object({ assets: z.array(z.unknown()) }),
query_scada_readings: z.object({ data: z.array(z.unknown()), meta: z.record(z.unknown()) }),
};
export class ServerApiGateway {
@@ -62,33 +58,32 @@ export class ServerApiGateway {
}
private async dispatch(name: DomainToolName, args: any, context: RuntimeSessionContext): Promise<unknown> {
if (name === "list_monitoring_assets") {
const result: Record<string, unknown> = {};
if (args.include !== "points") result.devices = await this.request("GET", "/api/v1/monitoring/devices", undefined, context);
if (args.include !== "devices") result.points = await this.request("GET", "/api/v1/monitoring/points", undefined, context);
return result;
if (name === "get_project_context") {
const projects = await this.request("GET", "/api/v1/projects", undefined, context);
if (!Array.isArray(projects)) throw new GatewayHttpError(502);
const project = projects.find((item) => (
typeof item === "object"
&& item !== null
&& "project_id" in item
&& item.project_id === context.projectId
));
if (!project) throw new GatewayHttpError(404);
return project;
}
const routes = {
get_project_context: ["GET", "/api/v1/meta/project"],
query_monitoring_readings: ["POST", "/api/v1/monitoring/readings/query"],
start_data_quality_analysis: ["POST", "/api/v1/analyses"],
start_simulation: ["POST", "/api/v1/simulations"],
get_job_status: ["GET", args.job_type === "analysis" ? `/api/v1/analyses/${args.job_id}` : `/api/v1/simulations/${args.job_id}`],
} as const;
const [method, path] = routes[name];
const body = name === "start_data_quality_analysis" ? { ...args, algorithm: "data-quality" } : method === "POST" ? args : undefined;
const idempotencyKey = name.startsWith("start_") ? createIdempotencyKey(context, name, body) : undefined;
return this.request(method, path, body, context, idempotencyKey);
if (name === "list_scada_assets") {
return this.request("GET", "/api/v1/scada/assets", undefined, context);
}
return this.request("POST", "/api/v1/scada/readings/query", args, context);
}
private async request(method: string, path: string, body: unknown, context: RuntimeSessionContext, idempotencyKey?: string) {
private async request(method: string, path: string, body: unknown, context: RuntimeSessionContext) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try {
const response = await this.fetchImpl(new URL(path, config.TJWATER_API_BASE_URL), {
method, signal: controller.signal, headers: {
Accept: "application/json", "Content-Type": "application/json", "X-Project-Id": context.projectId!,
"X-Request-Id": context.traceId, ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
"X-Request-Id": context.traceId,
}, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) throw new GatewayHttpError(response.status);
@@ -99,5 +94,3 @@ export class ServerApiGateway {
class GatewayHttpError extends Error { constructor(readonly status: number) { super("upstream request failed"); } }
const failure = (code: GatewayErrorCode, message: string, detail?: unknown) => ({ ok: false, error: { code, message, ...(detail ? { detail } : {}) } });
export const createIdempotencyKey = (context: RuntimeSessionContext, name: string, args: unknown) => createHash("sha256").update(JSON.stringify([context.clientSessionId, name, canonicalize(args)])).digest("hex");
const canonicalize = (value: unknown): unknown => Array.isArray(value) ? value.map(canonicalize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, canonicalize(entry)])) : value;
+5 -15
View File
@@ -3,31 +3,21 @@ import { z } from "zod";
const uuid = z.string().uuid();
const dateTime = z.string().datetime({ offset: true });
const metrics = z.enum([
"flow_mean", "flow_total", "conductivity_mean", "velocity_mean",
"temperature_mean", "level_mean",
"conductivity_mean", "level_mean", "flow_mean", "temperature_mean",
]);
export const domainToolSchemas = {
get_project_context: z.object({}),
list_monitoring_assets: z.object({ include: z.enum(["devices", "points", "all"]).default("all") }),
query_monitoring_readings: z.object({
device_ids: z.array(uuid).min(1).max(100),
list_scada_assets: z.object({}),
query_scada_readings: z.object({
asset_ids: z.array(uuid).min(1).max(100),
observed_from: dateTime,
observed_to: dateTime,
granularity: z.enum(["5m", "1h", "1d"]).default("5m"),
metrics: z.array(metrics).min(1).max(6),
metrics: z.array(metrics).min(1).max(4),
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
message: "observed_from must precede observed_to",
}),
start_data_quality_analysis: z.object({
device_ids: z.array(uuid).min(1).max(100),
observed_from: dateTime,
observed_to: dateTime,
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
message: "observed_from must precede observed_to",
}),
start_simulation: z.object({ model_version_id: uuid, parameters: z.record(z.unknown()).default({}) }),
get_job_status: z.object({ job_id: uuid, job_type: z.enum(["analysis", "simulation"]) }),
} as const;
export type DomainToolName = keyof typeof domainToolSchemas;
+1 -96
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,67 +6,12 @@ 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" ? "bar" : "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"
) {
if (tool === "zoom_to_map") {
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
@@ -79,43 +22,5 @@ export const toUiEnvelopeFromToolCall = ({
};
}
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;
};
+2 -33
View File
@@ -2,51 +2,20 @@ 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 uiRegistryResponse = {
schema_version: "agent-ui-registry@1",
chart_grammars: [chartGrammar],
chart_grammars: [],
components: componentRegistry,
actions: actionRegistry,
};
-43
View File
@@ -44,47 +44,4 @@ describe("FrontendActionCoordinator", () => {
coordinator.registerSink("s", () => undefined);
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action");
});
it("requires a valid time range when opening history data", async () => {
const coordinator = new FrontendActionCoordinator();
let emitted: FrontendActionRequest | undefined;
coordinator.registerSink("s", (request) => { emitted = request; });
expect(() => coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: { feature_infos: [["node-1", "junction"]], data_type: "realtime" },
})).toThrow("invalid frontend action params");
expect(() => coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: {
feature_infos: [["node-1", "junction"]],
data_type: "realtime",
start_time: "2026-07-14T00:00:00Z",
end_time: "2026-07-14T00:00:00Z",
},
})).toThrow("invalid frontend action params");
const pending = coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: {
feature_infos: [["node-1", "junction"]],
data_type: "realtime",
start_time: "2026-07-14T00:00:00Z",
end_time: "2026-07-14T01:00:00Z",
},
});
if (!emitted) throw new Error("request was not emitted");
coordinator.submitResult("s", {
version: "frontend-action-result@1",
actionId: emitted.actionId,
status: "succeeded",
output: { component: "HistoryPanel", rendered: true, itemCount: 1 },
completedAt: Date.now(),
});
await expect(pending).resolves.toMatchObject({ status: "succeeded" });
});
});
+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",
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
describe("SCADA opencode tools", () => {
it("documents asset listing and reading query parameters", () => {
const listTool = readFileSync(".opencode/tools/list_scada_assets.ts", "utf8");
const queryTool = readFileSync(".opencode/tools/query_scada_readings.ts", "utf8");
expect(listTool).toContain("无请求参数");
expect(listTool).toContain("assets[].scada_id");
expect(queryTool).toContain("asset_ids");
expect(queryTool).toContain("observed_from");
expect(queryTool).toContain("observed_to");
expect(queryTool).toContain("granularity");
expect(queryTool).toContain("metrics");
expect(queryTool).toContain("半开区间");
expect(queryTool).toContain("不要使用 GeoServer feature id/fid、external_id 或 site_external_id");
});
});
+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",
},
});
});
});
+29 -29
View File
@@ -22,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();
});
});
@@ -92,7 +92,7 @@ describe("generateSessionTitle", () => {
messages: async () => [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
parts: [{ type: "text", text: "标题:SCADA 液位异常排查。" }],
},
],
abortSession: async () => undefined,
@@ -100,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 资产液位波动主要与夜间水位快速变化时段重合。");
});
});
@@ -124,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,
},
@@ -161,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 () => {
@@ -216,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 液位异常",
});
});
});
@@ -239,7 +239,7 @@ describe("buildForkedSessionUiState", () => {
const forked = buildForkedSessionUiState(
{
messages: [
{ role: "user", content: "画压力曲线" },
{ role: "user", content: "画液位曲线" },
{
role: "assistant",
content: "已生成图表",
@@ -265,7 +265,7 @@ describe("buildForkedSessionUiState", () => {
sessionId: "forked-session",
isTitleManuallyEdited: false,
messages: [
{ role: "user", content: "画压力曲线" },
{ role: "user", content: "画液位曲线" },
{
role: "assistant",
content: "已生成图表",
+23 -23
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,7 +535,7 @@ describe("streamPromptResponse", () => {
});
});
it("maps visual tool calls to UIEnvelope SSE payloads", async () => {
it("maps zoom_to_map tool calls to UIEnvelope SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
@@ -544,20 +544,19 @@ describe("streamPromptResponse", () => {
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-chart",
id: "tool-part-map",
sessionID: "runtime-session-1",
messageID: "message-1",
type: "tool",
callID: "call-chart",
tool: "show_chart",
callID: "call-map",
tool: "zoom_to_map",
state: {
status: "running",
input: {
reason: "展示压力趋势",
title: "压力趋势",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-1", data: [0.4, 0.42] }],
reason: "定位 SCADA 资产附近位置",
x: 121.47,
y: 31.23,
source_crs: "EPSG:4326",
},
time: { start: Date.now() },
},
@@ -587,14 +586,15 @@ describe("streamPromptResponse", () => {
expect(events.find((item) => item.event === "tool_call")?.data).toMatchObject({
session_id: "client-session-1",
tool: "show_chart",
tool: "zoom_to_map",
});
expect(events.find((item) => item.event === "ui_envelope")?.data).toMatchObject({
session_id: "client-session-1",
envelope: {
kind: "chart",
kind: "map_action",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
action: "zoom_to_map",
surface: "map_overlay",
},
});
});
+8 -8
View File
@@ -8,13 +8,13 @@ import {
} from "../../src/routes/chatUiState.js";
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
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 +24,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 +50,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 +63,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"],
},
},
});
});
});
+14 -14
View File
@@ -16,38 +16,38 @@ describe("ServerApiGateway project context", () => {
let requestedPath = "";
const fetchImpl = (async (input: URL | RequestInfo) => {
requestedPath = new URL(input.toString()).pathname;
return Response.json({
project_id: context.projectId,
code: "lingang",
name: "临港排水",
description: null,
status: "active",
project_role: "owner",
gs_workspace: "lingang",
map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 },
});
return Response.json([{
project_id: context.projectId,
code: "lingang",
name: "SCADA 物联网监测",
description: null,
status: "active",
project_role: "owner",
gs_workspace: "lingang",
map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 },
}]);
}) as typeof fetch;
const gateway = new ServerApiGateway({} as never, fetchImpl);
const result = await gateway.execute("get_project_context", {}, context);
expect(requestedPath).toBe("/api/v1/meta/project");
expect(requestedPath).toBe("/api/v1/projects");
expect(result).toMatchObject({
ok: true,
data: { project_id: context.projectId, status: "active", project_role: "owner" },
});
});
test("rejects the removed id response shape", async () => {
test("does not select the removed id response shape", async () => {
const fetchImpl = (async () =>
Response.json({ id: context.projectId, code: "lingang", name: "临港排水" })) as unknown as typeof fetch;
Response.json([{ id: context.projectId, code: "lingang", name: "SCADA 物联网监测" }])) as unknown as typeof fetch;
const gateway = new ServerApiGateway({} as never, fetchImpl);
const result = await gateway.execute("get_project_context", {}, context);
expect(result).toMatchObject({
ok: false,
error: { code: "UPSTREAM_UNAVAILABLE" },
error: { code: "NOT_FOUND" },
});
});
});
+8 -15
View File
@@ -1,24 +1,17 @@
import { describe, expect, test } from "bun:test";
import { domainToolSchemas } from "../../src/serverApi/schemas.js";
import { createIdempotencyKey } from "../../src/serverApi/gateway.js";
const context = {
actorKey: "user", clientSessionId: "session", projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
projectKey: "project", sessionId: "runtime", traceId: "trace",
};
describe("domain tool validation", () => {
test("rejects unknown metrics and reversed time", () => {
const base = { device_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["pressure"] }).success).toBeFalse();
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse();
const base = { asset_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["ph_mean"] }).success).toBeFalse();
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse();
});
test("rejects invalid UUID before dispatch", () => {
expect(domainToolSchemas.start_simulation.safeParse({ model_version_id: "bad", parameters: {} }).success).toBeFalse();
});
test("idempotency key ignores object key order", () => {
expect(createIdempotencyKey(context, "start_simulation", { b: 2, a: 1 })).toBe(createIdempotencyKey(context, "start_simulation", { a: 1, b: 2 }));
test("accepts four current metrics and rejects removed metrics", () => {
const base = { asset_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T01:00:00Z", observed_to: "2026-07-14T02:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["conductivity_mean", "level_mean", "flow_mean", "temperature_mean"] }).success).toBeTrue();
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["flow_total"] }).success).toBeFalse();
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["velocity_mean"] }).success).toBeFalse();
});
});
+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",
);
});
+12 -39
View File
@@ -3,51 +3,24 @@ import { describe, expect, it } from "bun:test";
import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js";
describe("toUiEnvelopeFromToolCall", () => {
it("maps show_chart calls to chart envelopes", () => {
it("maps zoom_to_map calls to map action 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] }],
},
tool: "zoom_to_map",
reason: "定位 SCADA 资产附近位置",
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
});
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] }],
},
kind: "map_action",
action: "zoom_to_map",
surface: "map_overlay",
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
fallbackText: "定位 SCADA 资产附近位置",
});
});
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 map removed visual tools", () => {
expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull();
expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull();
});
});