feat(agent): add typed server API tools
This commit is contained in:
+4
-1
@@ -8,7 +8,10 @@ OPENCODE_HOSTNAME=127.0.0.1
|
||||
OPENCODE_PORT=4096
|
||||
|
||||
AGENT_LOCAL_USER_ID=local-user
|
||||
AGENT_LOCAL_PROJECT_ID=local-project
|
||||
# 必须填写 Server /api/v1/projects 返回的项目 UUID。
|
||||
AGENT_LOCAL_PROJECT_ID=00000000-0000-0000-0000-000000000000
|
||||
AGENT_LOCAL_NETWORK=local-network
|
||||
|
||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
||||
TJWATER_API_ENABLED=true
|
||||
TJWATER_AUTH_MODE=disabled
|
||||
|
||||
@@ -33,8 +33,11 @@ Agent 负责:
|
||||
| 历史会话检索 | `session_search` |
|
||||
| 用户偏好和项目事实 | `memory_manager` |
|
||||
| 可复用流程沉淀 | `skill_manager` |
|
||||
|
||||
当前实验版没有通用 TJWater 数据查询工具;如果缺少业务数据,应明确说明数据能力尚未接入,不得编造结果。
|
||||
| 项目背景 | `get_project_context` |
|
||||
| 监测设备/点位 | `list_monitoring_assets` |
|
||||
| 监测时序读数 | `query_monitoring_readings`(必须给出 `observed_from`、`observed_to`) |
|
||||
| 数据质量分析 | `start_data_quality_analysis`、`get_job_status` |
|
||||
| SWMM 仿真 | `start_simulation`、`get_job_status` |
|
||||
|
||||
## UI 约束
|
||||
|
||||
@@ -58,3 +61,18 @@ Agent 负责:
|
||||
## 记忆持久化
|
||||
|
||||
`memory_manager` 只保存长期有效的稳定事实或用户偏好。修改 memory 前先 list 当前 scope,再决定 add、replace 或 remove。
|
||||
## TJWater Server 受控领域工具
|
||||
|
||||
仅通过领域工具访问 Server;不得构造 URL、HTTP header、SQL、命令或项目 ID。
|
||||
|
||||
- 项目背景:`get_project_context`
|
||||
- 设备/监测点:`list_monitoring_assets`
|
||||
- 时序读数:`query_monitoring_readings`
|
||||
- 数据质量任务:`start_data_quality_analysis`(写操作,必须等待审批)
|
||||
- 仿真任务:`start_simulation`(写操作,必须等待审批)
|
||||
- 任务状态:`get_job_status`
|
||||
|
||||
查询历史监测数据时,必须先明确时间区间;不清楚区间时先追问,不得省略或自行扩大范围。需要数值结论时先调用 `query_monitoring_readings`,传入 `observed_from`、`observed_to`、`granularity`、`metrics` 和设备 ID,再调用 `show_chart` 或 `view_history` 展示。`view_history` 只负责打开前端历史面板,也必须传入 `start_time`、`end_time`。
|
||||
先查询数据再作判断。回答必须说明查询时间范围、粒度、指标,以及已创建任务的 ID 和状态。
|
||||
遇到空数据、拒绝、权限错误、超时或服务不可用时,明确说明事实,不得补全或猜测结果。
|
||||
工具返回 `result_ref` 时,说明完整结果已安全存储,并使用 preview 中的样本和统计作答。
|
||||
|
||||
@@ -9,4 +9,6 @@ export const frontendActionCallIdPlugin: Plugin = async () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const server = frontendActionCallIdPlugin;
|
||||
|
||||
export default frontendActionCallIdPlugin;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { callServerApiTool } from "./server_api_shared.js";
|
||||
export default tool({ description: "读取分析或仿真任务的状态。", args: { job_id: tool.schema.string().uuid(), job_type: tool.schema.enum(["analysis", "simulation"]) }, execute: (args, context) => callServerApiTool("get_job_status", args, context.sessionID) });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { callServerApiTool } from "./server_api_shared.js";
|
||||
export default tool({ description: "读取当前受控项目的基本信息。", args: {}, execute: (_args, context) => callServerApiTool("get_project_context", {}, context.sessionID) });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { callServerApiTool } from "./server_api_shared.js";
|
||||
export default tool({ description: "列出当前项目的监测设备和监测点。", args: { include: tool.schema.enum(["devices", "points", "all"]).default("all") }, execute: (args, context) => callServerApiTool("list_monitoring_assets", args, context.sessionID) });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { callServerApiTool } from "./server_api_shared.js";
|
||||
export default tool({ description: "按设备、时间范围、粒度和允许指标查询监测读数。", args: { device_ids: tool.schema.array(tool.schema.string().uuid()).min(1).max(100), observed_from: tool.schema.string().datetime(), observed_to: tool.schema.string().datetime(), granularity: tool.schema.enum(["5m", "1h", "1d"]).default("5m"), metrics: tool.schema.array(tool.schema.enum(["flow_mean", "flow_total", "conductivity_mean", "velocity_mean", "temperature_mean", "level_mean"])).min(1).max(6) }, execute: (args, context) => callServerApiTool("query_monitoring_readings", args, context.sessionID) });
|
||||
@@ -0,0 +1,13 @@
|
||||
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 callServerApiTool = async (name: string, args: unknown, sessionID: string) => {
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/server-api/${name}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
|
||||
body: JSON.stringify({ session_id: sessionID, args }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error("TJWater domain tool bridge rejected the request");
|
||||
return text;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { callServerApiTool } from "./server_api_shared.js";
|
||||
export default tool({ description: "创建数据质量分析任务。此操作会创建后台任务,需要用户审批。", args: { device_ids: tool.schema.array(tool.schema.string().uuid()).min(1).max(100), observed_from: tool.schema.string().datetime(), observed_to: tool.schema.string().datetime() }, execute: (args, context) => callServerApiTool("start_data_quality_analysis", args, context.sessionID) });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { callServerApiTool } from "./server_api_shared.js";
|
||||
export default tool({ description: "基于已注册模型版本创建仿真任务。此操作会创建后台任务,需要用户审批。", args: { model_version_id: tool.schema.string().uuid(), parameters: tool.schema.record(tool.schema.string(), tool.schema.unknown()).default({}) }, execute: (args, context) => callServerApiTool("start_simulation", args, context.sessionID) });
|
||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
|
||||
import { executeFrontendAction } from "./frontend_action.js";
|
||||
|
||||
export default tool({
|
||||
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
||||
description: "为选定的管网要素打开前端的历史记录或计算结果面板;必须提供 ISO8601 时间区间 start_time 和 end_time。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
@@ -17,12 +17,12 @@ export default tool({
|
||||
.describe("History data source type."),
|
||||
start_time: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 start time."),
|
||||
.datetime()
|
||||
.describe("Required ISO8601 inclusive start time for the history query."),
|
||||
end_time: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 end time."),
|
||||
.datetime()
|
||||
.describe("Required ISO8601 exclusive end time for the history query; must be after start_time."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
return executeFrontendAction("view_history", args, context);
|
||||
|
||||
@@ -23,6 +23,9 @@ export default tool({
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 end time."),
|
||||
result_ref: tool.schema.string().optional().describe("Authorized large monitoring result reference."),
|
||||
metrics: tool.schema.array(tool.schema.string()).optional().describe("Metrics represented by the result."),
|
||||
granularity: tool.schema.enum(["5m", "1h", "1d"]).optional().describe("Reading aggregation granularity."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
return executeFrontendAction("view_scada", args, context);
|
||||
|
||||
@@ -13,8 +13,8 @@ real auth provider through `src/context/`.
|
||||
- No required `Authorization` or `X-Project-Id` headers.
|
||||
- No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge.
|
||||
- Agent visual output is expressed through `agent-ui@1` UIEnvelope events.
|
||||
- Backend data tools are not fully reintroduced yet; missing business data must
|
||||
be reported honestly by the Agent.
|
||||
- Backend business data is available through typed, allowlisted domain tools.
|
||||
Arbitrary URLs, SQL, shell commands, and filesystem paths remain unavailable.
|
||||
|
||||
## Main API
|
||||
|
||||
@@ -51,10 +51,26 @@ Defaults:
|
||||
|
||||
```text
|
||||
AGENT_LOCAL_USER_ID=local-user
|
||||
AGENT_LOCAL_PROJECT_ID=local-project
|
||||
AGENT_LOCAL_PROJECT_ID=<UUID returned as project_id by /api/v1/meta/projects>
|
||||
AGENT_LOCAL_NETWORK=local-network
|
||||
```
|
||||
|
||||
Local domain tools are enabled by default. Set `TJWATER_API_BASE_URL` to the
|
||||
Server address and `AGENT_LOCAL_PROJECT_ID` to an enabled project UUID. Setting
|
||||
`TJWATER_API_ENABLED=false` disables all Server calls. Production startup rejects
|
||||
enabled Server tools while `TJWATER_AUTH_MODE=disabled`.
|
||||
|
||||
Available domain tools:
|
||||
|
||||
- `get_project_context`
|
||||
- `list_monitoring_assets`
|
||||
- `query_monitoring_readings` with required `observed_from` and `observed_to`
|
||||
ISO8601 timestamps. The server rejects reversed or unbounded historical
|
||||
queries.
|
||||
- `start_data_quality_analysis` (requires approval)
|
||||
- `start_simulation` (requires approval)
|
||||
- `get_job_status`
|
||||
|
||||
Optional request headers can override the local context during experiments:
|
||||
|
||||
```text
|
||||
@@ -78,6 +94,11 @@ Visual opencode tools are mapped by the service:
|
||||
- `locate_features`, `zoom_to_map`, `render_junctions`, `apply_layer_style` -> `map_action`
|
||||
- `view_history`, `view_scada` -> `registered_component`
|
||||
|
||||
`view_history` opens the historical data/result panel and must include
|
||||
`start_time` and `end_time` ISO8601 timestamps. Use `query_monitoring_readings`
|
||||
first when the answer needs actual monitoring values; use `view_history` only to
|
||||
ask the frontend to display the selected feature history panel.
|
||||
|
||||
See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.7.2",
|
||||
"bun-types": "^1.3.3",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
@@ -30,10 +31,20 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.2", "", { "dependencies": { "@ai-sdk/provider": "4.0.1", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EcmdjJb7yggsZPCbS3MFBpvAUnKaPW+QvanU5GzF00XCq0bqqAmvJ3MN19ejlmOETbW8sJNiq6qam48wTcbUNw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
|
||||
|
||||
"@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
|
||||
|
||||
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.17", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.2.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
@@ -64,14 +75,24 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ai": ["ai@7.0.8", "", { "dependencies": { "@ai-sdk/gateway": "4.0.6", "@ai-sdk/provider": "4.0.1", "@ai-sdk/provider-utils": "5.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vTEKl6fDBZ2IxBXTRaZOajf9W2Ev57Ju8iKtUvqlmDk8Z9BrEP4c22SWJsg1RcWHSFmJMSBa/s5dlUBHUq3YwA=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
@@ -80,6 +101,8 @@
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
@@ -128,6 +151,8 @@
|
||||
|
||||
"fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
@@ -152,8 +177,12 @@
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -162,8 +191,16 @@
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
@@ -178,6 +215,8 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
@@ -194,12 +233,18 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
|
||||
|
||||
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
@@ -208,6 +253,8 @@
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
@@ -224,6 +271,8 @@
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
@@ -258,10 +307,14 @@
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
@@ -270,6 +323,8 @@
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -278,12 +333,22 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||
|
||||
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,12 @@ Initial mappings:
|
||||
- `view_history` -> `registered_component: HistoryPanel`
|
||||
- `view_scada` -> `registered_component: ScadaPanel`
|
||||
|
||||
`view_history` parameters must include `start_time` and `end_time` ISO8601
|
||||
timestamps with `start_time < end_time`. When the Agent needs historical
|
||||
monitoring values for analysis or a chart, it should call
|
||||
`query_monitoring_readings` first, then use `view_history` only to open the
|
||||
frontend panel.
|
||||
|
||||
`render_junctions` accepts only a `render_ref`. The frontend must resolve the
|
||||
full payload through:
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -26,7 +26,8 @@
|
||||
"curl *": "ask",
|
||||
"wget *": "ask"
|
||||
},
|
||||
"edit": "ask"
|
||||
"edit": "ask",
|
||||
"task": "deny"
|
||||
},
|
||||
"plugin": [
|
||||
"./.opencode/plugins/frontend-action-call-id.ts"
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"typecheck:opencode": "bun run --cwd .opencode typecheck",
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "bun run check",
|
||||
"generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts",
|
||||
"check:openapi": "bun run generate:openapi && git diff --exit-code -- openapi/openapi.json src/generated/server-api.d.ts",
|
||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||
"start": "bun src/server.ts",
|
||||
"start:prod": "bun run check && bun src/server.ts"
|
||||
@@ -30,5 +32,6 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"bun-types": "^1.3.3",
|
||||
"typescript": "^5.9.3"
|
||||
,"openapi-typescript": "^7.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ const envSchema = z
|
||||
OPENCODE_BASE_URL: z.string().url().optional(),
|
||||
// TJWater 后端 API 的基础地址。
|
||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||
// 本地实验环境默认开放受控领域工具;生产环境仍由下方鉴权校验保护。
|
||||
TJWATER_API_ENABLED: optionalBoolean(true),
|
||||
TJWATER_AUTH_MODE: z.enum(["disabled"]).default("disabled"),
|
||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
||||
// 后端结果在直接内联返回给模型前允许的最大字节数。
|
||||
@@ -138,6 +141,13 @@ const envSchema = z
|
||||
.default(3600000),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.NODE_ENV === "production" && env.TJWATER_API_ENABLED && env.TJWATER_AUTH_MODE === "disabled") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["TJWATER_AUTH_MODE"],
|
||||
message: "TJWATER API tools cannot run without authentication in production",
|
||||
});
|
||||
}
|
||||
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
@@ -9,6 +9,10 @@ const timeRange = {
|
||||
start_time: isoTime.optional(),
|
||||
end_time: isoTime.optional(),
|
||||
};
|
||||
const requiredTimeRange = {
|
||||
start_time: isoTime,
|
||||
end_time: isoTime,
|
||||
};
|
||||
const locateInput = z.object({
|
||||
ids: z.array(id).min(1).max(100),
|
||||
feature_type: z.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"]),
|
||||
@@ -16,8 +20,8 @@ const locateInput = z.object({
|
||||
const historyInput = z.object({
|
||||
feature_infos: z.array(z.tuple([id, id])).min(1).max(100),
|
||||
data_type: z.enum(["realtime", "scheme", "none"]),
|
||||
...timeRange,
|
||||
}).strict().refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
|
||||
...requiredTimeRange,
|
||||
}).strict().refine((value) => Date.parse(value.start_time) < Date.parse(value.end_time), "start_time must precede end_time");
|
||||
const scadaInput = z.object({
|
||||
device_id: id.optional(),
|
||||
device_ids: z.array(id).min(1).max(100).optional(),
|
||||
|
||||
Vendored
+1048
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,12 @@ const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||
|
||||
export const RESULT_REFERENCE_KIND = {
|
||||
renderJunctionsPayload: "render-junctions-payload",
|
||||
serverApiPayload: "server_api_payload",
|
||||
} as const;
|
||||
|
||||
export const RESULT_REFERENCE_SOURCE = {
|
||||
agentGenerated: "agent_generated",
|
||||
serverApi: "server_api",
|
||||
} as const;
|
||||
|
||||
export type ResultReferenceKind =
|
||||
|
||||
@@ -85,6 +85,31 @@ export const registerChatAuxiliaryRoutes = (
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
chatRouter.get("/result-ref/:result_ref", async (req, res) => {
|
||||
const resultRef = req.params.result_ref?.trim();
|
||||
const context = getLocalAgentContext(req);
|
||||
const clientSessionId = typeof req.query.session_id === "string" ? req.query.session_id.trim() : undefined;
|
||||
const offset = Math.max(0, Number.parseInt(String(req.query.offset ?? "0"), 10) || 0);
|
||||
const limit = Math.min(500, Math.max(1, Number.parseInt(String(req.query.limit ?? "100"), 10) || 100));
|
||||
if (!resultRef || !clientSessionId) {
|
||||
res.status(400).json({ message: "result_ref and session_id are required" });
|
||||
return;
|
||||
}
|
||||
const result = await resultReferenceResolver.getFullAuthorized(resultRef, {
|
||||
actorKey: toActorKey(context.userId), clientSessionId, projectId: context.projectId,
|
||||
}, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
|
||||
if (!result) {
|
||||
res.status(404).json({ message: "result_ref not found" });
|
||||
return;
|
||||
}
|
||||
const rows = Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data && typeof result.data === "object" && Array.isArray((result.data as { data?: unknown }).data)
|
||||
? (result.data as { data: unknown[] }).data
|
||||
: [result.data];
|
||||
res.json({ ...result, data: rows.slice(offset, offset + limit), page: { offset, limit, total: rows.length, has_more: offset + limit < rows.length } });
|
||||
});
|
||||
|
||||
chatRouter.post("/abort", async (req, res) => {
|
||||
const parsed = abortPayloadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { ServerApiGateway } from "./serverApi/gateway.js";
|
||||
import { isDomainToolName } from "./serverApi/schemas.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
type RuntimeSessionContext,
|
||||
@@ -38,6 +40,7 @@ const learningOrchestrator = new LearningOrchestrator(
|
||||
);
|
||||
const resultReferenceStore = new ResultReferenceStore();
|
||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||
const serverApiGateway = new ServerApiGateway(resultReferenceResolver);
|
||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||
const frontendActionCoordinator = new FrontendActionCoordinator();
|
||||
|
||||
@@ -47,6 +50,25 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.post("/internal/tools/server-api/:tool", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const name = req.params.tool;
|
||||
if (!isDomainToolName(name)) {
|
||||
res.status(404).json({ message: "unknown domain tool" });
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
res.json(await serverApiGateway.execute(name, req.body?.args ?? {}, context));
|
||||
});
|
||||
|
||||
app.post("/internal/frontend-actions/request", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { ResultReferenceResolver } from "../results/resolver.js";
|
||||
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
|
||||
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { domainToolSchemas, type DomainToolName } from "./schemas.js";
|
||||
|
||||
export type GatewayErrorCode = "VALIDATION_FAILED" | "NOT_FOUND" | "UPSTREAM_UNAVAILABLE" | "TIMEOUT" | "PROJECT_CONTEXT_INVALID";
|
||||
type Fetch = typeof fetch;
|
||||
|
||||
const responseSchemas: Record<DomainToolName, z.ZodTypeAny> = {
|
||||
get_project_context: z.object({
|
||||
project_id: z.string().uuid(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
status: z.enum(["active", "inactive"]),
|
||||
project_role: z.string(),
|
||||
gs_workspace: z.string(),
|
||||
map_extent: z.object({ xmin: z.number(), ymin: z.number(), xmax: z.number(), ymax: z.number() }).nullable(),
|
||||
}),
|
||||
list_monitoring_assets: z.object({ devices: z.array(z.unknown()).optional(), points: z.array(z.unknown()).optional() }),
|
||||
query_monitoring_readings: z.object({ data: z.array(z.unknown()), meta: z.record(z.unknown()) }),
|
||||
start_data_quality_analysis: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
|
||||
start_simulation: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
|
||||
get_job_status: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(),
|
||||
};
|
||||
|
||||
export class ServerApiGateway {
|
||||
constructor(private readonly resolver: ResultReferenceResolver, private readonly fetchImpl: Fetch = fetch) {}
|
||||
|
||||
async execute(name: DomainToolName, rawArgs: unknown, context: RuntimeSessionContext) {
|
||||
if (!config.TJWATER_API_ENABLED) return failure("UPSTREAM_UNAVAILABLE", "TJWater API tools are disabled");
|
||||
if (!context.projectId || !z.string().uuid().safeParse(context.projectId).success) {
|
||||
return failure("PROJECT_CONTEXT_INVALID", "project context is unavailable or invalid");
|
||||
}
|
||||
const parsed = domainToolSchemas[name].safeParse(rawArgs);
|
||||
if (!parsed.success) return failure("VALIDATION_FAILED", "tool parameters are invalid", parsed.error.flatten());
|
||||
try {
|
||||
const data = await this.dispatch(name, parsed.data, context);
|
||||
const validated = responseSchemas[name].safeParse(data);
|
||||
if (!validated.success) return failure("UPSTREAM_UNAVAILABLE", "upstream returned an invalid response");
|
||||
const bytes = Buffer.byteLength(JSON.stringify(validated.data));
|
||||
if (bytes <= config.MAX_INLINE_RESULT_BYTES) return { ok: true, data: validated.data, truncated: false };
|
||||
const record = await this.resolver.register({
|
||||
actorKey: context.actorKey, clientSessionId: context.clientSessionId, data: validated.data,
|
||||
kind: RESULT_REFERENCE_KIND.serverApiPayload, projectId: context.projectId,
|
||||
projectKey: context.projectKey, schemaVersion: 1, sessionId: context.clientSessionId,
|
||||
source: RESULT_REFERENCE_SOURCE.serverApi, traceId: context.traceId,
|
||||
});
|
||||
return { ok: true, result_ref: record.resultRef, preview: record.preview, result_size_bytes: bytes, truncated: true };
|
||||
} catch (error) {
|
||||
if (error instanceof GatewayHttpError) {
|
||||
if (error.status === 404) return failure("NOT_FOUND", "requested resource was not found");
|
||||
return failure("UPSTREAM_UNAVAILABLE", `upstream request failed (${error.status})`);
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") return failure("TIMEOUT", "upstream request timed out");
|
||||
return failure("UPSTREAM_UNAVAILABLE", "upstream service is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(name: DomainToolName, args: any, context: RuntimeSessionContext): Promise<unknown> {
|
||||
if (name === "list_monitoring_assets") {
|
||||
const result: Record<string, unknown> = {};
|
||||
if (args.include !== "points") result.devices = await this.request("GET", "/api/v1/monitoring/devices", undefined, context);
|
||||
if (args.include !== "devices") result.points = await this.request("GET", "/api/v1/monitoring/points", undefined, context);
|
||||
return result;
|
||||
}
|
||||
const routes = {
|
||||
get_project_context: ["GET", "/api/v1/meta/project"],
|
||||
query_monitoring_readings: ["POST", "/api/v1/monitoring/readings/query"],
|
||||
start_data_quality_analysis: ["POST", "/api/v1/analyses"],
|
||||
start_simulation: ["POST", "/api/v1/simulations"],
|
||||
get_job_status: ["GET", args.job_type === "analysis" ? `/api/v1/analyses/${args.job_id}` : `/api/v1/simulations/${args.job_id}`],
|
||||
} as const;
|
||||
const [method, path] = routes[name];
|
||||
const body = name === "start_data_quality_analysis" ? { ...args, algorithm: "data-quality" } : method === "POST" ? args : undefined;
|
||||
const idempotencyKey = name.startsWith("start_") ? createIdempotencyKey(context, name, body) : undefined;
|
||||
return this.request(method, path, body, context, idempotencyKey);
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body: unknown, context: RuntimeSessionContext, idempotencyKey?: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await this.fetchImpl(new URL(path, config.TJWATER_API_BASE_URL), {
|
||||
method, signal: controller.signal, headers: {
|
||||
Accept: "application/json", "Content-Type": "application/json", "X-Project-Id": context.projectId!,
|
||||
"X-Request-Id": context.traceId, ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
}, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (!response.ok) throw new GatewayHttpError(response.status);
|
||||
return await response.json();
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
}
|
||||
|
||||
class GatewayHttpError extends Error { constructor(readonly status: number) { super("upstream request failed"); } }
|
||||
const failure = (code: GatewayErrorCode, message: string, detail?: unknown) => ({ ok: false, error: { code, message, ...(detail ? { detail } : {}) } });
|
||||
export const createIdempotencyKey = (context: RuntimeSessionContext, name: string, args: unknown) => createHash("sha256").update(JSON.stringify([context.clientSessionId, name, canonicalize(args)])).digest("hex");
|
||||
const canonicalize = (value: unknown): unknown => Array.isArray(value) ? value.map(canonicalize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, canonicalize(entry)])) : value;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const dateTime = z.string().datetime({ offset: true });
|
||||
const metrics = z.enum([
|
||||
"flow_mean", "flow_total", "conductivity_mean", "velocity_mean",
|
||||
"temperature_mean", "level_mean",
|
||||
]);
|
||||
|
||||
export const domainToolSchemas = {
|
||||
get_project_context: z.object({}),
|
||||
list_monitoring_assets: z.object({ include: z.enum(["devices", "points", "all"]).default("all") }),
|
||||
query_monitoring_readings: z.object({
|
||||
device_ids: z.array(uuid).min(1).max(100),
|
||||
observed_from: dateTime,
|
||||
observed_to: dateTime,
|
||||
granularity: z.enum(["5m", "1h", "1d"]).default("5m"),
|
||||
metrics: z.array(metrics).min(1).max(6),
|
||||
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
|
||||
message: "observed_from must precede observed_to",
|
||||
}),
|
||||
start_data_quality_analysis: z.object({
|
||||
device_ids: z.array(uuid).min(1).max(100),
|
||||
observed_from: dateTime,
|
||||
observed_to: dateTime,
|
||||
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
|
||||
message: "observed_from must precede observed_to",
|
||||
}),
|
||||
start_simulation: z.object({ model_version_id: uuid, parameters: z.record(z.unknown()).default({}) }),
|
||||
get_job_status: z.object({ job_id: uuid, job_type: z.enum(["analysis", "simulation"]) }),
|
||||
} as const;
|
||||
|
||||
export type DomainToolName = keyof typeof domainToolSchemas;
|
||||
export const isDomainToolName = (value: string): value is DomainToolName => value in domainToolSchemas;
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js";
|
||||
import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin, server } from "../../.opencode/plugins/frontend-action-call-id.js";
|
||||
import { frontendActionRegistry } from "../../src/frontendAction/registry.js";
|
||||
|
||||
const frontendActionToolNames = () => readdirSync(".opencode/tools")
|
||||
@@ -17,6 +17,10 @@ describe("frontend action call ID plugin", () => {
|
||||
expect(config.plugin).toContain("./.opencode/plugins/frontend-action-call-id.ts");
|
||||
});
|
||||
|
||||
it("exposes the server export required by OpenCode", () => {
|
||||
expect(server).toBe(frontendActionCallIdPlugin);
|
||||
});
|
||||
|
||||
it("overwrites forged IDs only for allowlisted tools", async () => {
|
||||
const hooks = await frontendActionCallIdPlugin({} as never);
|
||||
const before = hooks["tool.execute.before"];
|
||||
|
||||
@@ -44,4 +44,47 @@ describe("FrontendActionCoordinator", () => {
|
||||
coordinator.registerSink("s", () => undefined);
|
||||
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action");
|
||||
});
|
||||
|
||||
it("requires a valid time range when opening history data", async () => {
|
||||
const coordinator = new FrontendActionCoordinator();
|
||||
let emitted: FrontendActionRequest | undefined;
|
||||
coordinator.registerSink("s", (request) => { emitted = request; });
|
||||
expect(() => coordinator.request({
|
||||
sessionId: "s",
|
||||
toolCallId: "c",
|
||||
name: "view_history",
|
||||
params: { feature_infos: [["node-1", "junction"]], data_type: "realtime" },
|
||||
})).toThrow("invalid frontend action params");
|
||||
expect(() => coordinator.request({
|
||||
sessionId: "s",
|
||||
toolCallId: "c",
|
||||
name: "view_history",
|
||||
params: {
|
||||
feature_infos: [["node-1", "junction"]],
|
||||
data_type: "realtime",
|
||||
start_time: "2026-07-14T00:00:00Z",
|
||||
end_time: "2026-07-14T00:00:00Z",
|
||||
},
|
||||
})).toThrow("invalid frontend action params");
|
||||
const pending = coordinator.request({
|
||||
sessionId: "s",
|
||||
toolCallId: "c",
|
||||
name: "view_history",
|
||||
params: {
|
||||
feature_infos: [["node-1", "junction"]],
|
||||
data_type: "realtime",
|
||||
start_time: "2026-07-14T00:00:00Z",
|
||||
end_time: "2026-07-14T01:00:00Z",
|
||||
},
|
||||
});
|
||||
if (!emitted) throw new Error("request was not emitted");
|
||||
coordinator.submitResult("s", {
|
||||
version: "frontend-action-result@1",
|
||||
actionId: emitted.actionId,
|
||||
status: "succeeded",
|
||||
output: { component: "HistoryPanel", rendered: true, itemCount: 1 },
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
await expect(pending).resolves.toMatchObject({ status: "succeeded" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { ServerApiGateway } from "../../src/serverApi/gateway.js";
|
||||
|
||||
const context = {
|
||||
actorKey: "user",
|
||||
clientSessionId: "session",
|
||||
projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
|
||||
projectKey: "project",
|
||||
sessionId: "runtime",
|
||||
traceId: "trace",
|
||||
};
|
||||
|
||||
describe("ServerApiGateway project context", () => {
|
||||
test("uses the metadata path and accepts project_id responses", async () => {
|
||||
let requestedPath = "";
|
||||
const fetchImpl = (async (input: URL | RequestInfo) => {
|
||||
requestedPath = new URL(input.toString()).pathname;
|
||||
return Response.json({
|
||||
project_id: context.projectId,
|
||||
code: "lingang",
|
||||
name: "临港排水",
|
||||
description: null,
|
||||
status: "active",
|
||||
project_role: "owner",
|
||||
gs_workspace: "lingang",
|
||||
map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
const gateway = new ServerApiGateway({} as never, fetchImpl);
|
||||
|
||||
const result = await gateway.execute("get_project_context", {}, context);
|
||||
|
||||
expect(requestedPath).toBe("/api/v1/meta/project");
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { project_id: context.projectId, status: "active", project_role: "owner" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the removed id response shape", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
Response.json({ id: context.projectId, code: "lingang", name: "临港排水" })) as unknown as typeof fetch;
|
||||
const gateway = new ServerApiGateway({} as never, fetchImpl);
|
||||
|
||||
const result = await gateway.execute("get_project_context", {}, context);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UPSTREAM_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { domainToolSchemas } from "../../src/serverApi/schemas.js";
|
||||
import { createIdempotencyKey } from "../../src/serverApi/gateway.js";
|
||||
|
||||
const context = {
|
||||
actorKey: "user", clientSessionId: "session", projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
|
||||
projectKey: "project", sessionId: "runtime", traceId: "trace",
|
||||
};
|
||||
|
||||
describe("domain tool validation", () => {
|
||||
test("rejects unknown metrics and reversed time", () => {
|
||||
const base = { device_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" };
|
||||
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["pressure"] }).success).toBeFalse();
|
||||
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse();
|
||||
});
|
||||
|
||||
test("rejects invalid UUID before dispatch", () => {
|
||||
expect(domainToolSchemas.start_simulation.safeParse({ model_version_id: "bad", parameters: {} }).success).toBeFalse();
|
||||
});
|
||||
|
||||
test("idempotency key ignores object key order", () => {
|
||||
expect(createIdempotencyKey(context, "start_simulation", { b: 2, a: 1 })).toBe(createIdempotencyKey(context, "start_simulation", { a: 1, b: 2 }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user