Compare commits
12
Commits
900e60460c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9286ca67e9 | ||
|
|
7b23c61723 | ||
|
|
432edaaf2f | ||
|
|
72019ac1f2 | ||
|
|
c6231f7b00 | ||
|
|
155513fdbc | ||
|
|
8a21908785 | ||
|
|
f3f873e3c4 | ||
|
|
909bb771f4 | ||
|
|
6b45e7c40c | ||
|
|
4418e99a5c | ||
|
|
7f34098cf5 |
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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}图层应用样式。`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 "已在地图上定位到指定要素。";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
|
||||||
|
|
||||||
export default tool({
|
|
||||||
description:
|
|
||||||
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。",
|
|
||||||
args: {
|
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Why this junction rendering action is needed for the user request.",
|
|
||||||
),
|
|
||||||
render_ref: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"上一步通过 store_render_ref 获得的渲染引用 ID(res-...)。前端会按该引用拉取完整 payload 并渲染。不可直接传入本地文件路径或完整 JSON 数据。",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
async execute() {
|
|
||||||
// 工具参数里只需要 render_ref;浏览器端会再用该引用回读完整 payload.data 并完成渲染。
|
|
||||||
return "已在地图上应用节点分区渲染。";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 "图表将在对话中显示。";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
|
||||||
|
|
||||||
const internalBaseUrl =
|
|
||||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
|
||||||
|
|
||||||
export default tool({
|
|
||||||
description:
|
|
||||||
"将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_ref(res-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。",
|
|
||||||
args: {
|
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
|
|
||||||
),
|
|
||||||
file_path: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
async execute(args, context) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${internalBaseUrl}/internal/tools/store-render-ref`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-agent-internal-token": internalToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: context.sessionID,
|
|
||||||
file_path: args.file_path,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(text);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
|
||||||
|
|
||||||
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 "已打开计算结果面板。";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 监测面板。";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 "已缩放到指定地图坐标。";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,53 +1,52 @@
|
|||||||
# TJWaterAgentExperimental
|
# TJWater Agent 实验服务
|
||||||
|
|
||||||
Experimental opencode-based Agent service for TJWater UI orchestration.
|
基于 OpenCode 的实验性 Agent 服务,用于编排 TJWater 前端交互。
|
||||||
|
|
||||||
This repository is intentionally separate from the production `TJWaterAgent`
|
本仓库与生产环境中的 `TJWaterAgent` 历史代码相互独立,不包含用户认证,也不包含旧版 `tjwater-cli` 桥接层。运行时请求默认使用本地上下文,后续可通过 `src/context/` 接入实际的认证提供方。
|
||||||
history. It does not include user authentication or the old `tjwater-cli`
|
|
||||||
bridge. Runtime requests use a local default context and can later be wired to a
|
|
||||||
real auth provider through `src/context/`.
|
|
||||||
|
|
||||||
## Current Boundaries
|
## 当前边界
|
||||||
|
|
||||||
- No Keycloak / metadata auth context.
|
- 不包含 Keycloak 或元数据认证上下文。
|
||||||
- No required `Authorization` or `X-Project-Id` headers.
|
- 不强制要求 `Authorization` 或 `X-Project-Id` 请求头。
|
||||||
- No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge.
|
- 不包含 `cli/`、`tjwater_cli` OpenCode 工具或自由形式的 CLI 命令桥接。
|
||||||
- Agent visual output is expressed through `agent-ui@1` UIEnvelope events.
|
- Agent 的结构化界面输出使用 `agent-ui@1` UIEnvelope 事件;受控浏览器操作使用 `frontend-action@1` 前端动作。
|
||||||
- Backend data tools are not fully reintroduced yet; missing business data must
|
- 依赖 TJWater Server 的工具处于禁用状态,Agent 不能访问任意 URL、SQL、Shell 命令或文件系统路径。
|
||||||
be reported honestly by the Agent.
|
|
||||||
|
|
||||||
## Main API
|
## 主要 API
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /health
|
GET /health
|
||||||
GET /api/v1/agent/chat/models
|
GET /api/v1/agent/chat/models
|
||||||
GET /api/v1/agent/chat/ui-registry
|
GET /api/v1/agent/chat/ui-registry
|
||||||
POST /api/v1/agent/chat/session
|
GET /api/v1/agent/chat/frontend-action-registry
|
||||||
GET /api/v1/agent/chat/sessions
|
POST /api/v1/agent/chat/frontend-actions/:action_id/result
|
||||||
GET /api/v1/agent/chat/session/:session_id
|
POST /api/v1/agent/chat/session
|
||||||
GET /api/v1/agent/chat/session/:session_id/stream
|
GET /api/v1/agent/chat/sessions
|
||||||
POST /api/v1/agent/chat/stream
|
GET /api/v1/agent/chat/session/:session_id
|
||||||
POST /api/v1/agent/chat/abort
|
GET /api/v1/agent/chat/session/:session_id/stream
|
||||||
|
POST /api/v1/agent/chat/stream
|
||||||
|
POST /api/v1/agent/chat/abort
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /api/v1/agent/chat/stream` returns SSE. Important events:
|
`POST /api/v1/agent/chat/stream` 通过 SSE 返回事件流。主要事件如下:
|
||||||
|
|
||||||
| event | Purpose |
|
| 事件 | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `progress` | Agent planning/tool progress |
|
| `progress` | Agent 规划和工具执行进度 |
|
||||||
| `token` | Assistant text delta |
|
| `token` | 助手回复的增量文本 |
|
||||||
| `ui_envelope` | Structured UI description for trusted frontend rendering |
|
| `ui_envelope` | 供可信前端渲染的结构化界面描述 |
|
||||||
| `tool_call` | Deprecated compatibility/debug event |
|
| `frontend_action` | 请求可信前端执行受控动作 |
|
||||||
| `permission_request` | Runtime permission request |
|
| `tool_call` | 用于兼容和调试的旧版工具调用事件 |
|
||||||
| `question_request` | Runtime/user clarification request |
|
| `permission_request` | 运行时权限请求 |
|
||||||
| `done` | Run completed |
|
| `question_request` | 运行时向用户发起的澄清请求 |
|
||||||
| `error` | Run failed or aborted |
|
| `todo_update` | Agent 待办事项更新 |
|
||||||
|
| `session_title` | 会话标题更新 |
|
||||||
|
| `done` | 本次运行完成 |
|
||||||
|
| `error` | 本次运行失败或被中止 |
|
||||||
|
|
||||||
## Local Context
|
## 本地上下文
|
||||||
|
|
||||||
The service uses `src/context/localContext.ts`.
|
服务通过 `src/context/localContext.ts` 提供本地上下文,默认值如下:
|
||||||
|
|
||||||
Defaults:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
AGENT_LOCAL_USER_ID=local-user
|
AGENT_LOCAL_USER_ID=local-user
|
||||||
@@ -55,7 +54,9 @@ AGENT_LOCAL_PROJECT_ID=local-project
|
|||||||
AGENT_LOCAL_NETWORK=local-network
|
AGENT_LOCAL_NETWORK=local-network
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional request headers can override the local context during experiments:
|
监测时序数据从 Agent 项目的 `source/` 目录读取,并通过经过校验的本地技能进行分析。`get_project_context`、`list_scada_assets`、`query_scada_readings`、`web_search` 和 `geocode` 依赖 TJWater Server,因此当前不对外开放。
|
||||||
|
|
||||||
|
实验期间可以通过以下请求头覆盖本地上下文:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
X-Agent-Local-User
|
X-Agent-Local-User
|
||||||
@@ -64,23 +65,31 @@ X-Agent-Local-Network
|
|||||||
X-Trace-Id
|
X-Trace-Id
|
||||||
```
|
```
|
||||||
|
|
||||||
## UIEnvelope
|
## UIEnvelope 与前端动作
|
||||||
|
|
||||||
The registry is exposed at:
|
UIEnvelope 注册表通过以下接口提供:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/v1/agent/chat/ui-registry
|
GET /api/v1/agent/chat/ui-registry
|
||||||
```
|
```
|
||||||
|
|
||||||
Visual opencode tools are mapped by the service:
|
该接口用于兼容 `agent-ui@1` 协议。目前没有注册图表语法、界面组件或 UIEnvelope 动作。
|
||||||
|
|
||||||
- `show_chart` -> `chart`
|
前端动作使用独立的注册表:
|
||||||
- `locate_features`, `zoom_to_map`, `render_junctions`, `apply_layer_style` -> `map_action`
|
|
||||||
- `view_history`, `view_scada` -> `registered_component`
|
|
||||||
|
|
||||||
See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md).
|
```text
|
||||||
|
GET /api/v1/agent/chat/frontend-action-registry
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
当前提供 `render_scada_analysis` 和 `clear_scada_analysis` 两个地图叠加层动作。浏览器需要在流式请求中声明 `frontend-action@1` 能力,执行动作后再通过以下接口回传结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/agent/chat/frontend-actions/:action_id/result
|
||||||
|
```
|
||||||
|
|
||||||
|
协议详情参见 [Agent UI 协议](docs/agent-ui-protocol.md)。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
@@ -89,4 +98,4 @@ bun run check
|
|||||||
bun test tests/**/*.test.ts
|
bun test tests/**/*.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
`bun run check` typechecks the service and `.opencode` tools.
|
`bun run check` 会检查服务代码和 `.opencode` 工具的类型。
|
||||||
|
|||||||
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -26,7 +26,8 @@
|
|||||||
"curl *": "ask",
|
"curl *": "ask",
|
||||||
"wget *": "ask"
|
"wget *": "ask"
|
||||||
},
|
},
|
||||||
"edit": "ask"
|
"edit": "ask",
|
||||||
|
"task": "deny"
|
||||||
},
|
},
|
||||||
"default_agent": "instruction"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 时最多抽样的条目数。
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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"];
|
||||||
|
};
|
||||||
Vendored
+441
@@ -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
@@ -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);
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
+86
-1213
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
type ActiveRun,
|
||||||
|
type RunStatus,
|
||||||
|
} from "./chatUiState.js";
|
||||||
|
|
||||||
|
export type ChatRunState = {
|
||||||
|
activeRuns: Map<string, ActiveRun>;
|
||||||
|
lastRunStatuses: Map<string, RunStatus>;
|
||||||
|
getStatus: (sessionId: string) => RunStatus | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createChatRunState = (): ChatRunState => {
|
||||||
|
const activeRuns = new Map<string, ActiveRun>();
|
||||||
|
const lastRunStatuses = new Map<string, RunStatus>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeRuns,
|
||||||
|
lastRunStatuses,
|
||||||
|
getStatus: (sessionId) =>
|
||||||
|
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { type Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
|
import { getLocalAgentContext } from "../context/localContext.js";
|
||||||
|
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
import {
|
||||||
|
type SessionMetadataStore,
|
||||||
|
type SessionRecord,
|
||||||
|
} from "../sessions/metadataStore.js";
|
||||||
|
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||||
|
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||||
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
|
import { type ChatRunState } from "./chatRunState.js";
|
||||||
|
import { buildForkedSessionUiState } from "./chatSession.js";
|
||||||
|
import { type StreamSubscriber } from "./chatUiState.js";
|
||||||
|
|
||||||
|
const createSessionPayloadSchema = z.object({
|
||||||
|
session_id: z.string().max(128).optional(),
|
||||||
|
parent_session_id: z.string().max(128).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const forkPayloadSchema = z.object({
|
||||||
|
session_id: z.string().max(128).optional(),
|
||||||
|
keep_message_count: z.coerce.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterChatSessionRoutesOptions = {
|
||||||
|
frontendActionCoordinator: FrontendActionCoordinator;
|
||||||
|
runState: ChatRunState;
|
||||||
|
runtime: OpencodeRuntimeAdapter;
|
||||||
|
sessionBridge: ChatSessionBridge;
|
||||||
|
sessionMetadataStore: SessionMetadataStore;
|
||||||
|
sessionTranscriptStore: SessionTranscriptStore;
|
||||||
|
sessionUiStateStore: SessionUiStateStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toSse = (event: string, data: Record<string, unknown>) =>
|
||||||
|
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
|
||||||
|
export const registerChatSessionRoutes = (
|
||||||
|
chatRouter: Router,
|
||||||
|
options: RegisterChatSessionRoutesOptions,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
frontendActionCoordinator,
|
||||||
|
runState,
|
||||||
|
runtime,
|
||||||
|
sessionBridge,
|
||||||
|
sessionMetadataStore,
|
||||||
|
sessionTranscriptStore,
|
||||||
|
sessionUiStateStore,
|
||||||
|
} = options;
|
||||||
|
const { activeRuns, lastRunStatuses } = runState;
|
||||||
|
const getSessionRunStatus = runState.getStatus;
|
||||||
|
|
||||||
|
chatRouter.post("/session", async (req, res) => {
|
||||||
|
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "invalid request payload",
|
||||||
|
detail: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
const requestedSessionId = parsed.data.session_id?.trim();
|
||||||
|
let sessionId = requestedSessionId;
|
||||||
|
if (!sessionId) {
|
||||||
|
try {
|
||||||
|
sessionId = (await runtime.createSession()).id;
|
||||||
|
} catch (error) {
|
||||||
|
sessionId = sessionBridge.createClientSessionId();
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, sessionId },
|
||||||
|
"runtime unavailable while creating chat session; using local degraded session",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { record, created } = await sessionMetadataStore.ensure({
|
||||||
|
actorKey,
|
||||||
|
parentSessionId: parsed.data.parent_session_id,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(created ? 201 : 200).json({
|
||||||
|
session_id: record.sessionId,
|
||||||
|
created_at: record.createdAt,
|
||||||
|
updated_at: record.updatedAt,
|
||||||
|
status: record.status,
|
||||||
|
title: record.title,
|
||||||
|
parent_session_id: record.parentSessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.get("/sessions", async (req, res) => {
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
const records = await sessionMetadataStore.list({
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
sessions: records.map((record) => ({
|
||||||
|
id: record.sessionId,
|
||||||
|
title: record.title ?? "新对话",
|
||||||
|
created_at: record.createdAt,
|
||||||
|
updated_at: record.updatedAt,
|
||||||
|
status: record.status,
|
||||||
|
parent_session_id: record.parentSessionId,
|
||||||
|
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
|
||||||
|
run_status: getSessionRunStatus(record.sessionId),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.get("/session/:session_id", async (req, res) => {
|
||||||
|
const sessionId = req.params.session_id?.trim();
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ message: "session_id is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
|
{
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!sessionRecord) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await sessionUiStateStore.read(
|
||||||
|
toSessionUiStateContext(sessionRecord),
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
id: sessionRecord.sessionId,
|
||||||
|
title: sessionRecord.title ?? "新对话",
|
||||||
|
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||||
|
created_at: sessionRecord.createdAt,
|
||||||
|
updated_at: sessionRecord.updatedAt,
|
||||||
|
status: sessionRecord.status,
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
messages: state?.messages ?? [],
|
||||||
|
parent_session_id: sessionRecord.parentSessionId,
|
||||||
|
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
|
||||||
|
run_status: getSessionRunStatus(sessionRecord.sessionId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.get("/session/:session_id/stream", async (req, res) => {
|
||||||
|
const sessionId = req.params.session_id?.trim();
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ message: "session_id is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!sessionRecord) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.flushHeaders?.();
|
||||||
|
|
||||||
|
const run = activeRuns.get(sessionRecord.sessionId);
|
||||||
|
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
|
||||||
|
res.write(
|
||||||
|
toSse("state", {
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
messages: state?.messages ?? run?.messages ?? [],
|
||||||
|
is_streaming: run?.status === "running",
|
||||||
|
run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!run || run.status !== "running") {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriber: StreamSubscriber = {
|
||||||
|
write: (event, data) => {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.write(toSse(event, data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
run.subscribers.add(subscriber);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
run.subscribers.delete(subscriber);
|
||||||
|
};
|
||||||
|
req.on("close", cleanup);
|
||||||
|
res.on("close", cleanup);
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.patch("/session/:session_id/title", async (req, res) => {
|
||||||
|
const sessionId = req.params.session_id?.trim();
|
||||||
|
const title =
|
||||||
|
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
||||||
|
const isTitleManuallyEdited =
|
||||||
|
typeof req.body?.is_title_manually_edited === "boolean"
|
||||||
|
? req.body.is_title_manually_edited
|
||||||
|
: undefined;
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId || !title) {
|
||||||
|
res.status(400).json({ message: "session_id and title are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!sessionRecord) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, {
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
const state = await sessionUiStateStore.read(
|
||||||
|
toSessionUiStateContext(nextSessionRecord),
|
||||||
|
);
|
||||||
|
if (state) {
|
||||||
|
await sessionUiStateStore.write(
|
||||||
|
toSessionUiStateContext(nextSessionRecord),
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
isTitleManuallyEdited:
|
||||||
|
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
id: nextSessionRecord.sessionId,
|
||||||
|
title: nextSessionRecord.title,
|
||||||
|
updated_at: nextSessionRecord.updatedAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.delete("/session/:session_id", async (req, res) => {
|
||||||
|
const sessionId = req.params.session_id?.trim();
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ message: "session_id is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!sessionRecord) {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
|
||||||
|
await sessionBridge.deleteSession({
|
||||||
|
clientSessionId: sessionRecord.sessionId,
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
});
|
||||||
|
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
|
||||||
|
activeRuns.delete(sessionRecord.sessionId);
|
||||||
|
lastRunStatuses.delete(sessionRecord.sessionId);
|
||||||
|
await sessionMetadataStore.remove(sessionRecord);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.post("/fork", async (req, res) => {
|
||||||
|
const parsed = forkPayloadSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "invalid request payload",
|
||||||
|
detail: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const traceId = req.header("x-trace-id") ?? undefined;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
const sourceSessionId = parsed.data.session_id?.trim();
|
||||||
|
const sourceSessionRecord = sourceSessionId
|
||||||
|
? await sessionMetadataStore.get(
|
||||||
|
{
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
sourceSessionId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const forkSession = await runtime.createSession();
|
||||||
|
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||||
|
actorKey,
|
||||||
|
parentSessionId: sourceSessionId,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
sessionId: forkSession.id,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
const nextSessionId = targetSessionRecord.sessionId;
|
||||||
|
|
||||||
|
if (sourceSessionId) {
|
||||||
|
await sessionTranscriptStore.cloneThread(
|
||||||
|
{
|
||||||
|
actorKey,
|
||||||
|
clientSessionId: sourceSessionId,
|
||||||
|
projectKey,
|
||||||
|
sessionId: sourceSessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actorKey,
|
||||||
|
clientSessionId: nextSessionId,
|
||||||
|
projectKey,
|
||||||
|
sessionId: nextSessionId,
|
||||||
|
},
|
||||||
|
parsed.data.keep_message_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sourceState = sourceSessionRecord
|
||||||
|
? await sessionUiStateStore.read(
|
||||||
|
toSessionUiStateContext(sourceSessionRecord),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const forkTitle = sourceSessionRecord?.title
|
||||||
|
? `${sourceSessionRecord.title} 副本`
|
||||||
|
: "新对话副本";
|
||||||
|
const titledTargetSessionRecord = await sessionMetadataStore.touch(
|
||||||
|
targetSessionRecord,
|
||||||
|
{ title: forkTitle },
|
||||||
|
);
|
||||||
|
await sessionUiStateStore.write(
|
||||||
|
toSessionUiStateContext(titledTargetSessionRecord),
|
||||||
|
buildForkedSessionUiState(sourceState, {
|
||||||
|
keepMessageCount: parsed.data.keep_message_count,
|
||||||
|
targetSessionId: nextSessionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
sourceSessionId: parsed.data.session_id,
|
||||||
|
sessionId: nextSessionId,
|
||||||
|
traceId,
|
||||||
|
projectId,
|
||||||
|
keepMessageCount: parsed.data.keep_message_count,
|
||||||
|
},
|
||||||
|
"forked chat session",
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
session_id: nextSessionId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error({ err: error }, "chat fork failed");
|
||||||
|
res.status(500).json({
|
||||||
|
message: "chat fork failed",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
+8
-174
@@ -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;
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -0,0 +1,811 @@
|
|||||||
|
import { type Router } from "express";
|
||||||
|
import {
|
||||||
|
UI_MESSAGE_STREAM_HEADERS,
|
||||||
|
type UIMessage,
|
||||||
|
type UIMessageChunk,
|
||||||
|
} from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { isSupportedModel } from "../chat/models.js";
|
||||||
|
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
|
import { getLocalAgentContext } from "../context/localContext.js";
|
||||||
|
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
|
||||||
|
import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { type MemoryStore } from "../memory/store.js";
|
||||||
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
import {
|
||||||
|
getRuntimeSessionContext,
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
} from "../runtime/sessionContext.js";
|
||||||
|
import {
|
||||||
|
type SessionMetadataStore,
|
||||||
|
type SessionRecord,
|
||||||
|
} from "../sessions/metadataStore.js";
|
||||||
|
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||||
|
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||||
|
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
|
||||||
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
|
import {
|
||||||
|
buildPromptWithLearningContext,
|
||||||
|
extractLatestFrontendTurn,
|
||||||
|
generateSessionTitle,
|
||||||
|
resolveRequestedStreamSessionId,
|
||||||
|
shouldGenerateSessionTitle,
|
||||||
|
shouldRestoreConversationForRuntime,
|
||||||
|
} from "./chatSession.js";
|
||||||
|
import {
|
||||||
|
collectTextContent,
|
||||||
|
type PermissionRequestPayload,
|
||||||
|
type QuestionRequestPayload,
|
||||||
|
streamPromptResponse,
|
||||||
|
type TodoUpdatePayload,
|
||||||
|
} from "./chatStream.js";
|
||||||
|
import { type ChatRunState } from "./chatRunState.js";
|
||||||
|
import {
|
||||||
|
type ActiveRun,
|
||||||
|
type StreamSubscriber,
|
||||||
|
appendBackendToolArtifact,
|
||||||
|
appendBackendUiEnvelope,
|
||||||
|
cancelBackendTodos,
|
||||||
|
completeBackendProgress,
|
||||||
|
createInitialStreamingMessages,
|
||||||
|
isObjectRecord,
|
||||||
|
toFrontendPermission,
|
||||||
|
toPermissionStatus,
|
||||||
|
updateLastAssistantMessage,
|
||||||
|
updateLastAssistantPermission,
|
||||||
|
updateLastAssistantQuestion,
|
||||||
|
upsertBackendProgress,
|
||||||
|
upsertBackendQuestion,
|
||||||
|
upsertBackendTodoUpdate,
|
||||||
|
} from "./chatUiState.js";
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
id: z.string().max(128).optional(),
|
||||||
|
messages: z.array(z.unknown()).max(200).optional(),
|
||||||
|
message: z.string().min(1).max(10000).optional(),
|
||||||
|
session_id: z.string().max(128).optional(),
|
||||||
|
model: z.string().refine(isSupportedModel, {
|
||||||
|
message: "unsupported model",
|
||||||
|
}).optional(),
|
||||||
|
approval_mode: z.enum(["request", "always"]).optional().default("request"),
|
||||||
|
capabilities: z.array(z.string().max(64)).max(20).optional().default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeHasConversation = async (
|
||||||
|
runtime: OpencodeRuntimeAdapter,
|
||||||
|
sessionId: string,
|
||||||
|
) => {
|
||||||
|
const messages = await runtime.messages(sessionId, 1);
|
||||||
|
return messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.info.role === "user" || message.info.role === "assistant",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUiMessage = (value: unknown): value is UIMessage =>
|
||||||
|
isObjectRecord(value) &&
|
||||||
|
typeof value.id === "string" &&
|
||||||
|
(value.role === "system" || value.role === "user" || value.role === "assistant") &&
|
||||||
|
Array.isArray(value.parts);
|
||||||
|
|
||||||
|
const readUiMessageText = (message: UIMessage | undefined) =>
|
||||||
|
message?.parts
|
||||||
|
.flatMap((part) =>
|
||||||
|
isObjectRecord(part) && part.type === "text" && typeof part.text === "string"
|
||||||
|
? [part.text]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
.trim() ?? "";
|
||||||
|
|
||||||
|
const readLatestUserText = (messages: UIMessage[]) => {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (message.role !== "user") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = readUiMessageText(message);
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUiMessages = (messages: unknown[] | undefined) =>
|
||||||
|
Array.isArray(messages) ? messages.filter(isUiMessage) : [];
|
||||||
|
|
||||||
|
const toLegacyMessages = (messages: UIMessage[]) =>
|
||||||
|
messages.flatMap((message) => {
|
||||||
|
const content = readUiMessageText(message);
|
||||||
|
if (!content || (message.role !== "user" && message.role !== "assistant")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFallbackUserUiMessage = (content: string): UIMessage => ({
|
||||||
|
id: `user-${Date.now().toString(36)}`,
|
||||||
|
role: "user",
|
||||||
|
parts: [{ type: "text", text: content }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipeUiChunk = (
|
||||||
|
res: {
|
||||||
|
write: (chunk: string) => void;
|
||||||
|
writableEnded?: boolean;
|
||||||
|
destroyed?: boolean;
|
||||||
|
},
|
||||||
|
chunk: UIMessageChunk,
|
||||||
|
) => {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterChatStreamRouteOptions = {
|
||||||
|
frontendActionCoordinator: FrontendActionCoordinator;
|
||||||
|
learningOrchestrator: LearningOrchestrator;
|
||||||
|
memoryStore: MemoryStore;
|
||||||
|
runState: ChatRunState;
|
||||||
|
runtime: OpencodeRuntimeAdapter;
|
||||||
|
sessionBridge: ChatSessionBridge;
|
||||||
|
sessionMetadataStore: SessionMetadataStore;
|
||||||
|
sessionTranscriptStore: SessionTranscriptStore;
|
||||||
|
sessionUiStateStore: SessionUiStateStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerChatStreamRoute = (
|
||||||
|
chatRouter: Router,
|
||||||
|
{
|
||||||
|
frontendActionCoordinator,
|
||||||
|
learningOrchestrator,
|
||||||
|
memoryStore,
|
||||||
|
runState,
|
||||||
|
runtime,
|
||||||
|
sessionBridge,
|
||||||
|
sessionMetadataStore,
|
||||||
|
sessionTranscriptStore,
|
||||||
|
sessionUiStateStore,
|
||||||
|
}: RegisterChatStreamRouteOptions,
|
||||||
|
) => {
|
||||||
|
const { activeRuns, lastRunStatuses } = runState;
|
||||||
|
chatRouter.post("/stream", async (req, res) => {
|
||||||
|
const parsed = payloadSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "invalid request payload",
|
||||||
|
detail: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authContext = getLocalAgentContext(req);
|
||||||
|
const projectId = authContext.projectId;
|
||||||
|
const traceId = req.header("x-trace-id") ?? undefined;
|
||||||
|
const userId = authContext.userId;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
const requestUiMessages = toUiMessages(parsed.data.messages);
|
||||||
|
const promptText =
|
||||||
|
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
|
||||||
|
if (!promptText) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "message or messages with latest user text is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestedSessionId = resolveRequestedStreamSessionId({
|
||||||
|
sessionId: parsed.data.session_id,
|
||||||
|
promptText,
|
||||||
|
createClientSessionId: () => sessionBridge.createClientSessionId(),
|
||||||
|
});
|
||||||
|
const existingSessionRecord = requestedSessionId
|
||||||
|
? await sessionMetadataStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
requestedSessionId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
|
||||||
|
|
||||||
|
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||||
|
sessionId: requestedSessionId,
|
||||||
|
network: authContext.network,
|
||||||
|
projectId,
|
||||||
|
traceId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
setRuntimeSessionContext({
|
||||||
|
...(getRuntimeSessionContext(binding.sessionId)!),
|
||||||
|
capabilities: parsed.data.capabilities,
|
||||||
|
});
|
||||||
|
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||||
|
await sessionMetadataStore.ensure({
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
|
||||||
|
const hasRuntimeConversation = hadExistingRuntimeSession
|
||||||
|
? await runtimeHasConversation(runtime, binding.sessionId)
|
||||||
|
: false;
|
||||||
|
const shouldRestoreConversation = shouldRestoreConversationForRuntime({
|
||||||
|
hadExistingSessionRecord: hadExistingRuntimeSession,
|
||||||
|
runtimeHasConversation: hasRuntimeConversation,
|
||||||
|
});
|
||||||
|
const historyContext = {
|
||||||
|
actorKey: requestContext.actorKey,
|
||||||
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
projectKey: requestContext.projectKey,
|
||||||
|
sessionId: requestContext.clientSessionId,
|
||||||
|
};
|
||||||
|
const initialSessionState = await sessionUiStateStore.read(
|
||||||
|
toSessionUiStateContext(activeSessionRecord),
|
||||||
|
);
|
||||||
|
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
|
||||||
|
const requestMessages =
|
||||||
|
requestUiMessages.length > 0
|
||||||
|
? requestUiMessages
|
||||||
|
: [
|
||||||
|
...persistedUiMessages,
|
||||||
|
createFallbackUserUiMessage(promptText),
|
||||||
|
];
|
||||||
|
let latestUiMessages = requestMessages;
|
||||||
|
const baseMessages = toLegacyMessages(requestMessages);
|
||||||
|
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
|
||||||
|
res.status(409).json({
|
||||||
|
message: "session is already streaming",
|
||||||
|
session_id: activeSessionRecord.sessionId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
created: created || sessionCreated,
|
||||||
|
model: parsed.data.model,
|
||||||
|
approvalMode: parsed.data.approval_mode,
|
||||||
|
traceId: requestContext.traceId,
|
||||||
|
projectId: requestContext.projectId,
|
||||||
|
},
|
||||||
|
"processing chat request",
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200);
|
||||||
|
for (const [header, value] of Object.entries(UI_MESSAGE_STREAM_HEADERS)) {
|
||||||
|
res.setHeader(header, value);
|
||||||
|
}
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.flushHeaders?.();
|
||||||
|
|
||||||
|
const clientSessionId = requestContext.clientSessionId;
|
||||||
|
let streamClosed = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
sessionBridge.registerAbortController(clientSessionId, abortController);
|
||||||
|
const initialMessages = createInitialStreamingMessages(
|
||||||
|
baseMessages,
|
||||||
|
promptText,
|
||||||
|
);
|
||||||
|
const activeRun: ActiveRun = {
|
||||||
|
clientSessionId,
|
||||||
|
controller: abortController,
|
||||||
|
messages: initialMessages,
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
pendingQuestions: new Map(),
|
||||||
|
status: "running",
|
||||||
|
subscribers: new Set(),
|
||||||
|
};
|
||||||
|
activeRuns.set(clientSessionId, activeRun);
|
||||||
|
lastRunStatuses.set(clientSessionId, "running");
|
||||||
|
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
|
||||||
|
const assistantUiMessageId = `assistant-${Date.now().toString(36)}`;
|
||||||
|
const textPartId = "answer";
|
||||||
|
const assistantParts: Record<string, unknown>[] = [];
|
||||||
|
let responseText = "";
|
||||||
|
let textStarted = false;
|
||||||
|
let textEnded = false;
|
||||||
|
let uiFinished = false;
|
||||||
|
const syncLatestUiMessages = () => {
|
||||||
|
latestUiMessages = [
|
||||||
|
...requestMessages,
|
||||||
|
{
|
||||||
|
id: assistantUiMessageId,
|
||||||
|
role: "assistant",
|
||||||
|
parts: assistantParts.map((part) => ({ ...part })),
|
||||||
|
} as UIMessage,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
const writeUiChunk = (chunk: UIMessageChunk) => {
|
||||||
|
pipeUiChunk(res, chunk);
|
||||||
|
};
|
||||||
|
const writeTextDelta = (delta: string) => {
|
||||||
|
if (!delta || uiFinished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!textStarted) {
|
||||||
|
textStarted = true;
|
||||||
|
assistantParts.push({
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
state: "streaming",
|
||||||
|
});
|
||||||
|
writeUiChunk({ type: "text-start", id: textPartId });
|
||||||
|
}
|
||||||
|
responseText += delta;
|
||||||
|
const textPart = assistantParts.find(
|
||||||
|
(part) => part.type === "text",
|
||||||
|
);
|
||||||
|
if (textPart) {
|
||||||
|
textPart.text = responseText;
|
||||||
|
textPart.state = "streaming";
|
||||||
|
}
|
||||||
|
writeUiChunk({ type: "text-delta", id: textPartId, delta });
|
||||||
|
writeUiChunk({
|
||||||
|
type: "data-stream_token",
|
||||||
|
data: {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
message_id: assistantUiMessageId,
|
||||||
|
content: delta,
|
||||||
|
},
|
||||||
|
transient: true,
|
||||||
|
} as UIMessageChunk);
|
||||||
|
syncLatestUiMessages();
|
||||||
|
};
|
||||||
|
const writeDataPart = (event: string, data: Record<string, unknown>) => {
|
||||||
|
if (uiFinished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = `data-${event}`;
|
||||||
|
const id =
|
||||||
|
typeof data.id === "string"
|
||||||
|
? data.id
|
||||||
|
: typeof data.request_id === "string"
|
||||||
|
? data.request_id
|
||||||
|
: typeof data.envelope_id === "string"
|
||||||
|
? data.envelope_id
|
||||||
|
: undefined;
|
||||||
|
const existingIndex =
|
||||||
|
id === undefined
|
||||||
|
? -1
|
||||||
|
: assistantParts.findIndex(
|
||||||
|
(part) => part.type === type && part.id === id,
|
||||||
|
);
|
||||||
|
const part = id ? { type, id, data } : { type, data };
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
assistantParts[existingIndex] = part;
|
||||||
|
} else {
|
||||||
|
assistantParts.push(part);
|
||||||
|
}
|
||||||
|
writeUiChunk(part as UIMessageChunk);
|
||||||
|
syncLatestUiMessages();
|
||||||
|
};
|
||||||
|
const finishUiMessage = (finishReason: "stop" | "error" = "stop") => {
|
||||||
|
if (uiFinished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!textStarted && assistantParts.length === 0) {
|
||||||
|
writeTextDelta("Agent 已完成处理。");
|
||||||
|
}
|
||||||
|
if (textStarted && !textEnded) {
|
||||||
|
textEnded = true;
|
||||||
|
const textPart = assistantParts.find(
|
||||||
|
(part) => part.type === "text",
|
||||||
|
);
|
||||||
|
if (textPart) {
|
||||||
|
textPart.state = "done";
|
||||||
|
}
|
||||||
|
writeUiChunk({ type: "text-end", id: textPartId });
|
||||||
|
}
|
||||||
|
syncLatestUiMessages();
|
||||||
|
writeUiChunk({ type: "finish", finishReason });
|
||||||
|
uiFinished = true;
|
||||||
|
};
|
||||||
|
writeUiChunk({ type: "start", messageId: assistantUiMessageId });
|
||||||
|
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
|
||||||
|
sessionId: activeSessionRecord.sessionId,
|
||||||
|
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||||
|
messages: latestUiMessages,
|
||||||
|
});
|
||||||
|
const queueSessionUiStatePersist = () => {
|
||||||
|
const snapshot = {
|
||||||
|
sessionId: activeSessionRecord.sessionId,
|
||||||
|
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||||
|
messages: latestUiMessages,
|
||||||
|
};
|
||||||
|
persistQueue = persistQueue
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, sessionId: clientSessionId },
|
||||||
|
"failed to persist previous chat stream state",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
|
||||||
|
return persistQueue;
|
||||||
|
};
|
||||||
|
const primarySubscriber: StreamSubscriber = {
|
||||||
|
write: (event, data) => {
|
||||||
|
if (streamClosed || res.writableEnded || res.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event === "token") {
|
||||||
|
writeTextDelta(typeof data.content === "string" ? data.content : "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event === "done") {
|
||||||
|
finishUiMessage("stop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event === "error") {
|
||||||
|
writeUiChunk({
|
||||||
|
type: "error",
|
||||||
|
errorText:
|
||||||
|
typeof data.message === "string"
|
||||||
|
? data.message
|
||||||
|
: "Agent stream failed",
|
||||||
|
});
|
||||||
|
finishUiMessage("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDataPart(event, data);
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
activeRun.subscribers.add(primarySubscriber);
|
||||||
|
const handleClientClose = () => {
|
||||||
|
streamClosed = true;
|
||||||
|
activeRun.subscribers.delete(primarySubscriber);
|
||||||
|
};
|
||||||
|
|
||||||
|
req.on("close", handleClientClose);
|
||||||
|
res.on("close", handleClientClose);
|
||||||
|
|
||||||
|
const publish = (event: string, data: Record<string, unknown>) => {
|
||||||
|
const subscriberData =
|
||||||
|
event === "token"
|
||||||
|
? {
|
||||||
|
...data,
|
||||||
|
message_id:
|
||||||
|
typeof data.message_id === "string"
|
||||||
|
? data.message_id
|
||||||
|
: assistantUiMessageId,
|
||||||
|
}
|
||||||
|
: data;
|
||||||
|
if (event === "token") {
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
|
||||||
|
isError: false,
|
||||||
|
}));
|
||||||
|
} else if (event === "progress") {
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
progress: upsertBackendProgress(message.progress, data),
|
||||||
|
}));
|
||||||
|
} else if (event === "done") {
|
||||||
|
activeRun.status = "completed";
|
||||||
|
lastRunStatuses.set(clientSessionId, "completed");
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
content:
|
||||||
|
typeof message.content === "string" && message.content.trim()
|
||||||
|
? message.content
|
||||||
|
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||||
|
progress: completeBackendProgress(message.progress),
|
||||||
|
}));
|
||||||
|
} else if (event === "error") {
|
||||||
|
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
|
||||||
|
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
content:
|
||||||
|
typeof message.content === "string" && message.content.trim()
|
||||||
|
? message.content
|
||||||
|
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
|
||||||
|
isError: true,
|
||||||
|
progress: completeBackendProgress(message.progress),
|
||||||
|
todos: cancelBackendTodos(message.todos),
|
||||||
|
}));
|
||||||
|
} else if (event === "permission_request") {
|
||||||
|
const payload = data as PermissionRequestPayload;
|
||||||
|
activeRun.pendingPermissions.set(payload.request_id, payload);
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
permissions: [
|
||||||
|
...(Array.isArray(message.permissions) ? message.permissions : []),
|
||||||
|
toFrontendPermission(payload),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} else if (event === "permission_response") {
|
||||||
|
const requestId =
|
||||||
|
typeof data.request_id === "string" ? data.request_id : undefined;
|
||||||
|
const reply =
|
||||||
|
data.reply === "once" || data.reply === "always" || data.reply === "reject"
|
||||||
|
? data.reply
|
||||||
|
: undefined;
|
||||||
|
if (requestId && reply) {
|
||||||
|
activeRun.pendingPermissions.delete(requestId);
|
||||||
|
activeRun.messages = updateLastAssistantPermission(
|
||||||
|
activeRun.messages,
|
||||||
|
requestId,
|
||||||
|
(permission) => ({
|
||||||
|
...permission,
|
||||||
|
status: toPermissionStatus(reply),
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (event === "question_request") {
|
||||||
|
const payload = data as QuestionRequestPayload;
|
||||||
|
let shouldTrackQuestion = true;
|
||||||
|
if (payload.tool?.callID) {
|
||||||
|
if (payload.request_id !== payload.tool.callID) {
|
||||||
|
activeRun.pendingQuestions.delete(payload.tool.callID);
|
||||||
|
} else {
|
||||||
|
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
|
||||||
|
(question) =>
|
||||||
|
question.tool?.callID === payload.tool?.callID &&
|
||||||
|
question.request_id !== payload.tool?.callID,
|
||||||
|
);
|
||||||
|
if (hasActionableQuestion) {
|
||||||
|
activeRun.messages = updateLastAssistantMessage(
|
||||||
|
activeRun.messages,
|
||||||
|
(message) => ({
|
||||||
|
...message,
|
||||||
|
questions: upsertBackendQuestion(message.questions, payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
shouldTrackQuestion = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldTrackQuestion) {
|
||||||
|
activeRun.pendingQuestions.set(payload.request_id, payload);
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
questions: upsertBackendQuestion(message.questions, payload),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (event === "question_response") {
|
||||||
|
const requestId =
|
||||||
|
typeof data.request_id === "string" ? data.request_id : undefined;
|
||||||
|
if (requestId) {
|
||||||
|
activeRun.pendingQuestions.delete(requestId);
|
||||||
|
activeRun.messages = updateLastAssistantQuestion(
|
||||||
|
activeRun.messages,
|
||||||
|
requestId,
|
||||||
|
(question) => ({
|
||||||
|
...question,
|
||||||
|
status: data.rejected === true ? "rejected" : "answered",
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
answers: Array.isArray(data.answers) ? data.answers : question.answers,
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (event === "todo_update") {
|
||||||
|
const payload = data as TodoUpdatePayload;
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
todos: upsertBackendTodoUpdate(message.todos, payload),
|
||||||
|
}));
|
||||||
|
} else if (event === "tool_call") {
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
artifacts: appendBackendToolArtifact(message.artifacts, data),
|
||||||
|
}));
|
||||||
|
} else if (event === "ui_envelope") {
|
||||||
|
const payload = data as UIEnvelopePayload;
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const subscriber of activeRun.subscribers) {
|
||||||
|
subscriber.write(event, subscriberData);
|
||||||
|
}
|
||||||
|
void queueSessionUiStatePersist().catch((error) => {
|
||||||
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
|
||||||
|
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preparedMessage = await buildPromptWithLearningContext(
|
||||||
|
memoryStore,
|
||||||
|
requestContext.actorKey,
|
||||||
|
requestContext.projectKey,
|
||||||
|
{
|
||||||
|
recentTurns,
|
||||||
|
persistedMessages: baseMessages,
|
||||||
|
message: promptText,
|
||||||
|
restoreConversation: shouldRestoreConversation,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const streamResult = await streamPromptResponse({
|
||||||
|
runtime,
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
clientSessionId,
|
||||||
|
message: preparedMessage,
|
||||||
|
model: parsed.data.model,
|
||||||
|
approvalMode: parsed.data.approval_mode,
|
||||||
|
traceId: requestContext.traceId,
|
||||||
|
projectId: requestContext.projectId,
|
||||||
|
signal: abortController.signal,
|
||||||
|
suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"),
|
||||||
|
write: (event, data) => {
|
||||||
|
publish(event, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await persistQueue.catch((error) => {
|
||||||
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!streamResult.aborted && !streamResult.failed) {
|
||||||
|
const messages = await runtime.messages(binding.sessionId, 60);
|
||||||
|
const assistantMessage = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.info.role === "assistant");
|
||||||
|
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
|
||||||
|
const latestSessionRecord =
|
||||||
|
(await sessionMetadataStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
activeSessionRecord.sessionId,
|
||||||
|
)) ?? activeSessionRecord;
|
||||||
|
const latestSessionState = await sessionUiStateStore.read(
|
||||||
|
toSessionUiStateContext(latestSessionRecord),
|
||||||
|
);
|
||||||
|
const existingSessionTitle = latestSessionRecord.title;
|
||||||
|
let sessionTitle = existingSessionTitle;
|
||||||
|
const shouldGenerateTitle = shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: recentTurns.length,
|
||||||
|
isTitleManuallyEdited:
|
||||||
|
latestSessionState?.isTitleManuallyEdited ?? false,
|
||||||
|
});
|
||||||
|
if (shouldGenerateTitle) {
|
||||||
|
sessionTitle = await generateSessionTitle(runtime, {
|
||||||
|
sessionId: binding.sessionId,
|
||||||
|
latestAssistantMessage: assistantText,
|
||||||
|
latestUserMessage: promptText,
|
||||||
|
fallbackTitle: existingSessionTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await sessionMetadataStore.touch(latestSessionRecord, {
|
||||||
|
...(sessionTitle && sessionTitle !== existingSessionTitle
|
||||||
|
? { title: sessionTitle }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
shouldGenerateTitle &&
|
||||||
|
sessionTitle &&
|
||||||
|
sessionTitle !== existingSessionTitle
|
||||||
|
) {
|
||||||
|
publish("session_title", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
title: sessionTitle,
|
||||||
|
});
|
||||||
|
await persistQueue.catch((error) => {
|
||||||
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
|
||||||
|
if (latestTurn) {
|
||||||
|
void learningOrchestrator.onTurnCompleted({
|
||||||
|
...latestTurn,
|
||||||
|
requestContext,
|
||||||
|
sessionId: clientSessionId,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, sessionId: clientSessionId },
|
||||||
|
"stream-completed learning failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
unregisterFrontendActionSink?.();
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
content:
|
||||||
|
typeof message.content === "string" && message.content.trim()
|
||||||
|
? message.content
|
||||||
|
: "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: completeBackendProgress(message.progress),
|
||||||
|
todos: cancelBackendTodos(message.todos),
|
||||||
|
}));
|
||||||
|
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
|
||||||
|
writeUiChunk({
|
||||||
|
type: "error",
|
||||||
|
errorText: "请求已中断",
|
||||||
|
});
|
||||||
|
finishUiMessage("error");
|
||||||
|
}
|
||||||
|
void queueSessionUiStatePersist().catch((error) => {
|
||||||
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
|
||||||
|
finishUiMessage(activeRun.status === "error" ? "error" : "stop");
|
||||||
|
}
|
||||||
|
await persistQueue.catch((error) => {
|
||||||
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
|
});
|
||||||
|
sessionBridge.finalizeRequest(clientSessionId);
|
||||||
|
activeRun.status = abortController.signal.aborted
|
||||||
|
? activeRun.status === "aborted"
|
||||||
|
? "aborted"
|
||||||
|
: "aborted"
|
||||||
|
: activeRun.status === "running"
|
||||||
|
? "completed"
|
||||||
|
: activeRun.status;
|
||||||
|
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||||
|
for (const subscriber of activeRun.subscribers) {
|
||||||
|
subscriber.close();
|
||||||
|
}
|
||||||
|
activeRun.subscribers.clear();
|
||||||
|
if (
|
||||||
|
activeRun.pendingPermissions.size === 0 &&
|
||||||
|
activeRun.pendingQuestions.size === 0
|
||||||
|
) {
|
||||||
|
activeRuns.delete(clientSessionId);
|
||||||
|
}
|
||||||
|
streamClosed = true;
|
||||||
|
req.off("close", handleClientClose);
|
||||||
|
res.off("close", handleClientClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error({ err: error }, "chat stream failed");
|
||||||
|
if (res.headersSent) {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
pipeUiChunk(res, {
|
||||||
|
type: "error",
|
||||||
|
errorText: detail,
|
||||||
|
});
|
||||||
|
pipeUiChunk(res, {
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "error",
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({
|
||||||
|
message: "chat stream failed",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 || "工具调用";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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>();
|
||||||
|
|||||||
+43
-215
@@ -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,166 +124,19 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const callBackendJson = async (
|
|
||||||
path: string,
|
|
||||||
_context: RuntimeSessionContext,
|
|
||||||
payload: unknown,
|
|
||||||
) => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
return {
|
|
||||||
ok: response.ok,
|
|
||||||
status: response.status,
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseStringArray = (value: unknown) =>
|
|
||||||
Array.isArray(value)
|
|
||||||
? value.filter((item): item is string => typeof item === "string")
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const webSearchFreshnessMap: Record<string, string> = {
|
|
||||||
no_limit: "noLimit",
|
|
||||||
one_day: "oneDay",
|
|
||||||
one_week: "oneWeek",
|
|
||||||
one_month: "oneMonth",
|
|
||||||
one_year: "oneYear",
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeWebSearchFreshness = (value: unknown) => {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return webSearchFreshnessMap[value] ?? value;
|
|
||||||
};
|
|
||||||
|
|
||||||
app.post("/internal/tools/web-search", async (req, res) => {
|
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
|
||||||
res.status(403).json({ message: "forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId =
|
|
||||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
|
||||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
|
||||||
if (!context) {
|
|
||||||
res.status(404).json({
|
|
||||||
message: "session context not found",
|
|
||||||
detail: sessionId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = typeof req.body?.query === "string" ? req.body.query.trim() : "";
|
|
||||||
if (!query) {
|
|
||||||
res.status(400).json({ message: "query is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count =
|
|
||||||
typeof req.body?.count === "number" && Number.isFinite(req.body.count)
|
|
||||||
? Math.trunc(req.body.count)
|
|
||||||
: undefined;
|
|
||||||
const payload = {
|
|
||||||
query,
|
|
||||||
freshness: normalizeWebSearchFreshness(req.body?.freshness),
|
|
||||||
summary:
|
|
||||||
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
|
|
||||||
count,
|
|
||||||
include: parseStringArray(req.body?.include),
|
|
||||||
exclude: parseStringArray(req.body?.exclude),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await callBackendJson(
|
|
||||||
"/api/v1/web-search",
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
res
|
|
||||||
.status(response.ok ? 200 : response.status)
|
|
||||||
.type("application/json")
|
|
||||||
.send(response.text);
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
res.status(503).json({
|
|
||||||
message: "web search service unavailable",
|
|
||||||
detail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/internal/tools/geocode", async (req, res) => {
|
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
|
||||||
res.status(403).json({ message: "forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId =
|
|
||||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
|
||||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
|
||||||
if (!context) {
|
|
||||||
res.status(404).json({
|
|
||||||
message: "session context not found",
|
|
||||||
detail: sessionId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword =
|
|
||||||
typeof req.body?.keyword === "string" ? req.body.keyword.trim() : "";
|
|
||||||
if (!keyword) {
|
|
||||||
res.status(400).json({ message: "keyword is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await callBackendJson(
|
|
||||||
"/api/v1/tianditu/geocode",
|
|
||||||
context,
|
|
||||||
{ keyword },
|
|
||||||
);
|
|
||||||
res
|
|
||||||
.status(response.ok ? 200 : response.status)
|
|
||||||
.type("application/json")
|
|
||||||
.send(response.text);
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
res.status(503).json({
|
|
||||||
message: "geocoding service unavailable",
|
|
||||||
detail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
"/api/v1/agent/chat",
|
"/api/v1/agent/chat",
|
||||||
buildChatRouter(
|
buildChatRouter({
|
||||||
sessionBridge,
|
frontendActionCoordinator,
|
||||||
opencodeRuntime,
|
|
||||||
sessionMetadataStore,
|
|
||||||
sessionUiStateStore,
|
|
||||||
memoryStore,
|
|
||||||
sessionTranscriptStore,
|
|
||||||
learningOrchestrator,
|
learningOrchestrator,
|
||||||
|
memoryStore,
|
||||||
resultReferenceResolver,
|
resultReferenceResolver,
|
||||||
),
|
runtime: opencodeRuntime,
|
||||||
|
sessionBridge,
|
||||||
|
sessionMetadataStore,
|
||||||
|
sessionTranscriptStore,
|
||||||
|
sessionUiStateStore,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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.renderJunctionsPayload,
|
kind: RESULT_REFERENCE_KIND.serverApiPayload,
|
||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: "已生成图表",
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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: "分析液位波动。",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
|
||||||
kind: "chart",
|
|
||||||
schemaVersion: "agent-ui@1",
|
|
||||||
grammar: "echarts-safe-subset",
|
|
||||||
surface: "chat_inline",
|
|
||||||
fallbackText: "展示压力趋势",
|
|
||||||
data: {
|
|
||||||
x_data: ["00:00", "01:00"],
|
|
||||||
series: [{ name: "P-1", data: [0.41, 0.43] }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects render_junctions without a valid render_ref", () => {
|
it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => {
|
||||||
expect(
|
expect(toUiEnvelopeFromToolCall({
|
||||||
toUiEnvelopeFromToolCall({
|
tool: "render_scada_analysis",
|
||||||
tool: "render_junctions",
|
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
||||||
params: { render_ref: "/tmp/payload.json" },
|
})).toBeNull();
|
||||||
}),
|
expect(toUiEnvelopeFromToolCall({
|
||||||
).toBeNull();
|
tool: "clear_scada_analysis",
|
||||||
});
|
params: {},
|
||||||
|
})).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
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user