Compare commits

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