refactor(agent)!: focus tools on SCADA
This commit is contained in:
@@ -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,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) => {
|
||||
|
||||
@@ -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}图层应用样式。`;
|
||||
},
|
||||
});
|
||||
@@ -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) });
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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) });
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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 id,value 是 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 "已在地图上应用节点分区渲染。";
|
||||
},
|
||||
});
|
||||
@@ -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) });
|
||||
@@ -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) });
|
||||
@@ -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_ref(res-...)。前置步骤:先准备好符合 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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
Vendored
+75
-682
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 || "工具调用";
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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,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,
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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: "已生成图表",
|
||||
|
||||
@@ -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,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",
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "分析液位波动。",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user