37 Commits
Author SHA1 Message Date
jiang 94529cb141 feat(api): expose REST-only agent routes
Agent CI/CD / docker-image (push) Failing after 35s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-30 20:38:52 +08:00
jiang 2415f75841 docs: 编写中文 README 2026-07-22 11:26:06 +08:00
jiang d7faaa2ecb fix(docker): make ubuntu apt mirror optional
Agent CI/CD / docker-image (push) Successful in 3m48s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-07-17 17:59:01 +08:00
jiang 78f9f6fb42 fix(ci): build docker image with host network
Agent CI/CD / docker-image (push) Failing after 26s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-17 17:46:53 +08:00
jiang 9aad38acb1 fix(docker): vendor bun binary for image builds
Agent CI/CD / docker-image (push) Failing after 24s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-17 17:42:44 +08:00
jiang d782b7fa11 fix(session): propagate network context
Agent CI/CD / docker-image (push) Failing after 15m0s
Agent CI/CD / deploy-fallback-log (push) Successful in 0s
2026-07-17 16:33:11 +08:00
jiang 40c0395fb1 fix(style): validate layer style configuration 2026-07-17 15:12:15 +08:00
jiang d75b98a61b fix(cli): use renamed backend APIs 2026-06-13 13:56:44 +08:00
jiang ead76185b7 refactor(agent): normalize API naming 2026-06-13 11:15:59 +08:00
jiang 8857b18dc9 feat(auth): validate agent context upstream 2026-06-12 10:18:41 +08:00
jiang 4b572d9264 chore(model): use flash agent model 2026-06-11 15:53:41 +08:00
jiang fc7393fa2b refactor(sessions): remove transcript truncate 2026-06-11 15:52:34 +08:00
jiang c823e3935e feat(chat): expose model options config
Agent CI/CD / docker-image (push) Failing after 24s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-06-10 19:50:40 +08:00
jiang 366c05b752 chore(model): default to deepseek flash 2026-06-10 19:33:08 +08:00
jiang fa2c28c1c0 refactor(chat): centralize session persistence 2026-06-10 19:29:42 +08:00
jiang cf6cada538 fix(tools): clarify chart data format
Agent CI/CD / docker-image (push) Successful in 49s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-10 16:19:39 +08:00
jiang d1b91a4b1e docs: add repository guidelines
Agent CI/CD / docker-image (push) Successful in 1m1s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-09 18:18:22 +08:00
jiang 20b93c688f feat(tools): add search and map tools 2026-06-09 17:54:46 +08:00
jiang 873c169c2c fix(agent): warm up opencode on startup
Agent CI/CD / docker-image (push) Successful in 1m59s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 20:19:46 +08:00
jiang 8ed73b1da6 fix(chat): hide raw permission metadata
Agent CI/CD / docker-image (push) Successful in 2m38s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 20:12:08 +08:00
jiang 60b9080c47 refactor(chat): drop branch groups state
Agent CI/CD / docker-image (push) Successful in 49s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 19:47:19 +08:00
jiang 801f611ce5 fix(chat): restore forked context
Agent CI/CD / docker-image (push) Successful in 1m38s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 19:33:13 +08:00
jiang 15c3263369 fix(chat): handle question and todo state 2026-06-08 18:10:28 +08:00
jiang f20847399a 更新 store_render_ref.ts render_junctions.ts 工具使用说明 2026-06-08 17:40:40 +08:00
jiang f3b62ed108 fix: regenerate from target turn
Agent CI/CD / docker-image (push) Successful in 49s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 16:07:39 +08:00
jiang 0e1ca2418f fix(chat): 支持重新生成前撤销消息
Agent CI/CD / docker-image (push) Successful in 5m14s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 14:38:52 +08:00
jiang f61389ab07 feat(chat): 支持自动始终允许权限 2026-06-08 14:14:52 +08:00
jiang 05d36aa8ca feat: handle opencode permission requests 2026-06-08 13:32:50 +08:00
jiang 4e31b141e7 添加不使用自代理的要求 2026-06-08 12:31:16 +08:00
jiang 7dbbeb4aa9 升级@opencode-ai版本到1.16.2 2026-06-08 11:46:24 +08:00
jiang 6f3b72628f fix(chat): guard abort and early idle races 2026-06-07 20:22:05 +08:00
jiang 2295bdcb97 暂时移除后端的认证校验
Agent CI/CD / docker-image (push) Successful in 1m5s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-07 19:58:59 +08:00
jiang 359b1e6e55 build(agent): include cli in build stage
Agent CI/CD / docker-image (push) Successful in 55s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-07 19:44:58 +08:00
jiang 93d70da8be refactor(cli): split tjwater cli modules
Agent CI/CD / deploy-fallback-log (push) Has been cancelled
Agent CI/CD / docker-image (push) Has been cancelled
2026-06-07 19:43:44 +08:00
jiang ff87817fb5 fix(cli): show group help 2026-06-07 18:57:48 +08:00
jiang 4b03aa3a91 build(cli): replace bundled binary cli 2026-06-07 18:53:10 +08:00
jiang 8a7964dc57 build(docker): include tjwater cli 2026-06-07 18:02:42 +08:00
76 changed files with 8902 additions and 1000 deletions
+2
View File
@@ -148,12 +148,14 @@ jobs:
if [ "${IMAGE_TAG}" = "latest" ]; then if [ "${IMAGE_TAG}" = "latest" ]; then
docker build \ docker build \
--network=host \
-f ./Dockerfile \ -f ./Dockerfile \
-t "${IMAGE_NAME}:latest" \ -t "${IMAGE_NAME}:latest" \
. .
push_with_retry "${IMAGE_NAME}:latest" push_with_retry "${IMAGE_NAME}:latest"
else else
docker build \ docker build \
--network=host \
-f ./Dockerfile \ -f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \ -t "${IMAGE_NAME}:latest" \
-1
View File
@@ -7,6 +7,5 @@ __pycache__/
docker-compose.yml docker-compose.yml
data/ data/
logs/ logs/
cli/
AGENT_HARNESS_REPORT.md AGENT_HARNESS_REPORT.md
HARNESS_INTRODUCTION.md HARNESS_INTRODUCTION.md
+6 -3
View File
@@ -1,7 +1,7 @@
--- ---
description: TJWater Agent,用于供水网络分析和操作员工作流 description: TJWater Agent,用于供水网络分析和操作员工作流
mode: primary mode: primary
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-flash
temperature: 0.2 temperature: 0.2
--- ---
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。 你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
@@ -30,8 +30,10 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
|------|------| |------|------|
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` | | 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
| 发现可用命令 | `tjwater_cli(command="help")` | | 发现可用命令 | `tjwater_cli(command="help")` |
| UI 操作 / 可视化 | `locate_features``view_scada``show_chart``render_junctions``view_history``apply_layer_style` | | 查询实时公开网页信息 | `web_search` |
| 持久化渲染数据 | `store_render_ref``render_junctions` | | 地址/地点转经纬度 | `geocode` |
| UI 操作 / 可视化 | `locate_features``zoom_to_map``view_scada``show_chart``render_junctions``view_history``apply_layer_style` |
| 持久化渲染数据 | ①准备 { node_area_map } JSON → ②`store_render_ref` 存为受控 ref → ③`render_junctions` 渲染到前端 |
**前端工具仅做显示,不返回数据**,不要假设其返回内容。 **前端工具仅做显示,不返回数据**,不要假设其返回内容。
@@ -42,6 +44,7 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
3. 大结果集禁止完整读取,优先采样/截断/按字段读取 3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout 4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
5. 无可用数据时不得编造结果 5. 无可用数据时不得编造结果
6. 尽量不使用 `task` 子代理,避免无法观测过程进行人为干预
## 工作流沉淀(skill_manager ## 工作流沉淀(skill_manager
+19 -9
View File
@@ -4,7 +4,7 @@
"workspaces": { "workspaces": {
"": { "": {
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.14.41", "@opencode-ai/plugin": "^1.16.2",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
@@ -13,11 +13,21 @@
}, },
}, },
"packages": { "packages": {
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.41", "", { "dependencies": { "@opencode-ai/sdk": "1.14.41", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.2", "@opentui/solid": ">=0.2.2" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.41", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ=="], "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
"@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=="],
@@ -27,21 +37,21 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
@@ -61,7 +71,7 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"uuid": ["uuid@13.0.2", "", { "bin": "dist-node/bin/uuid" }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+1 -1
View File
@@ -4,7 +4,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.14.41" "@opencode-ai/plugin": "^1.16.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
+28 -9
View File
@@ -28,8 +28,11 @@ export default tool({
.describe("Classification method."), .describe("Classification method."),
segments: tool.schema segments: tool.schema
.number() .number()
.int()
.min(2)
.max(10)
.optional() .optional()
.describe("Number of segments."), .describe("Number of rendered intervals, from 2 to 10."),
min_size: tool.schema min_size: tool.schema
.number() .number()
.optional() .optional()
@@ -54,9 +57,9 @@ export default tool({
.enum(["single", "gradient", "rainbow", "custom"]) .enum(["single", "gradient", "rainbow", "custom"])
.optional() .optional()
.describe("Color strategy."), .describe("Color strategy."),
single_palette_index: tool.schema.number().optional(), single_palette_index: tool.schema.number().int().min(0).max(6).optional(),
gradient_palette_index: tool.schema.number().optional(), gradient_palette_index: tool.schema.number().int().min(0).max(2).optional(),
rainbow_palette_index: tool.schema.number().optional(), rainbow_palette_index: tool.schema.number().int().min(0).max(1).optional(),
show_labels: tool.schema show_labels: tool.schema
.boolean() .boolean()
.optional() .optional()
@@ -65,7 +68,7 @@ export default tool({
.boolean() .boolean()
.optional() .optional()
.describe("Whether to show ids."), .describe("Whether to show ids."),
opacity: tool.schema.number().optional().describe("Opacity in [0, 1]."), opacity: tool.schema.number().min(0).max(1).optional().describe("Opacity in [0, 1]."),
adjust_width_by_property: tool.schema adjust_width_by_property: tool.schema
.boolean() .boolean()
.optional() .optional()
@@ -73,11 +76,11 @@ export default tool({
custom_breaks: tool.schema custom_breaks: tool.schema
.array(tool.schema.number()) .array(tool.schema.number())
.optional() .optional()
.describe("Custom break values."), .describe("Strictly increasing boundaries. Length must equal segments + 1."),
custom_colors: tool.schema custom_colors: tool.schema
.array(tool.schema.string()) .array(tool.schema.string())
.optional() .optional()
.describe("Custom rgba colors."), .describe("Custom CSS colors. Length must equal segments."),
}) })
.optional() .optional()
.describe( .describe(
@@ -87,8 +90,24 @@ export default tool({
async execute(args) { async execute(args) {
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道"; const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
if (args.reset_to_default) { if (args.reset_to_default) {
return `${layerLabel}图层重置为默认样式。`; return `提交${layerLabel}图层默认样式重置请求`;
} }
return `已对${layerLabel}图层应用样式。`; const style = args.style_config;
if (style?.custom_breaks) {
if (
style.custom_breaks.some(
(value, index, values) => index > 0 && value <= values[index - 1],
)
) {
throw new Error("custom_breaks must be strictly increasing");
}
if (style.segments && style.custom_breaks.length !== style.segments + 1) {
throw new Error("custom_breaks length must equal segments + 1");
}
}
if (style?.segments && style.custom_colors && style.custom_colors.length !== style.segments) {
throw new Error("custom_colors length must equal segments");
}
return `已提交${layerLabel}图层样式请求。`;
}, },
}); });
+37
View File
@@ -0,0 +1,37 @@
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;
},
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: description:
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id。", "在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
@@ -12,7 +12,7 @@ export default tool({
render_ref: tool.schema render_ref: tool.schema
.string() .string()
.describe( .describe(
"渲染引用 ID。必须是持久化结果引用res-...)。前端会按该引用取完整 payload.data 并渲染。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } }node_area_map 必填,area_ids / area_colors 可选。", "上一步通过 store_render_ref 获得的渲染引用 ID(res-...)。前端会按该引用取完整 payload 并渲染。不可直接传入本地文件路径或完整 JSON 数据。",
), ),
}, },
async execute() { async execute() {
+8 -3
View File
@@ -1,7 +1,8 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "在前端对话界面中渲染图表。", description:
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
@@ -11,12 +12,16 @@ export default tool({
.enum(["line", "bar", "pie"]) .enum(["line", "bar", "pie"])
.optional() .optional()
.describe("Chart type."), .describe("Chart type."),
x_data: tool.schema.array(tool.schema.string()).describe("X-axis labels."), x_data: tool.schema
.array(tool.schema.string())
.describe("X-axis labels. For line charts, put time/category labels here."),
series: tool.schema series: tool.schema
.array( .array(
tool.schema.object({ tool.schema.object({
name: tool.schema.string(), name: tool.schema.string(),
data: tool.schema.array(tool.schema.number()), 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(), type: tool.schema.enum(["line", "bar"]).optional(),
}), }),
) )
+5 -1
View File
@@ -100,7 +100,11 @@ export const createSkillManagerTool = (
kind: "skill", kind: "skill",
decision: "accepted", decision: "accepted",
detail: "skill listed", detail: "skill listed",
...result, references: result.references,
scripts: result.scripts,
skill_path: result.skillPath,
target: result.target,
patterns: result.patterns,
}); });
} }
+3 -3
View File
@@ -6,17 +6,17 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({ export default tool({
description: description:
"本地 JSON 渲染文件迁移成受控的 render_ref。仅适用于需要通过链接引用传递的大型 junction render payload。", "本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_refres-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe( .describe(
"Why this local render payload should be persisted as a render_ref.", "为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
), ),
file_path: tool.schema file_path: tool.schema
.string() .string()
.describe( .describe(
"Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.", "本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。",
), ),
}, },
async execute(args, context) { async execute(args, context) {
+62
View File
@@ -0,0 +1,62 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: {
reason: tool.schema
.string()
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."),
freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
.optional()
.describe("Optional freshness filter. Defaults to no_limit."),
summary: tool.schema
.boolean()
.optional()
.describe("Whether the backend should include page summaries."),
count: tool.schema
.number()
.int()
.positive()
.optional()
.describe("Optional result count, backend accepts 1 to 50."),
include: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to include."),
exclude: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to exclude."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/web-search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
query: args.query,
freshness: args.freshness,
summary: args.summary,
count: args.count,
include: args.include,
exclude: args.exclude,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+32
View File
@@ -0,0 +1,32 @@
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 "已缩放到指定地图坐标。";
},
});
+39
View File
@@ -0,0 +1,39 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is the internal TJWater agent service. Runtime TypeScript code is organized under `src/`; CLI-specific code is under `cli/src`; Node test files live in `node-tests/`. Agent extensions and OpenCode integration files are under `.opencode/`, including `.opencode/agents`, `.opencode/skills`, and `.opencode/tools`. Runtime data, session metadata, logs, and result references are stored under `data/` and `logs/` and should be treated as local/generated state.
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
## Build, Test, and Development Commands
Use Bun for this project:
```bash
bun install
bun run dev
bun run check
bun run test:cli
bun run start
```
`bun run dev` starts `src/server.ts` in watch mode. `bun run check` runs TypeScript checks for the main project and `.opencode`. `bun run test:cli` runs Node CLI tests. `bun run start` starts the service without watch mode.
## Coding Style & Naming Conventions
Use TypeScript ESM and keep types explicit at module boundaries. Use two-space indentation, `camelCase` for functions and variables, `PascalCase` for classes/types, and kebab-case or descriptive lowercase names for scripts and data files. Prefer existing `src/` service, route, and runtime patterns before introducing new structure.
## Testing Guidelines
Tests use Node's built-in test runner for CLI coverage. Name test files with `.node.mjs` when using `node --test`, matching `node-tests/cli/*.node.mjs`. Add focused tests for CLI parsing, tool behavior, and session/runtime changes. Do not depend on mutable local files under `data/`.
## Commit & Pull Request Guidelines
History uses Conventional Commit messages such as `feat(tools): add search and map tools`, `fix(agent): warm up opencode on startup`, and `refactor(chat): ...`. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
PRs should describe runtime behavior changes, list `bun run check` and any test commands run, and mention changes to `.opencode`, secrets, ports, or deploy workflow behavior.
## Security & Configuration Tips
Do not commit `.env`, logs, session transcripts, generated result references, or `node_modules/`. Keep registry and deploy credentials in Gitea secrets.
+15 -8
View File
@@ -1,8 +1,6 @@
FROM oven/bun:canary-slim AS bun-bin
FROM smanx/opencode:latest AS base FROM smanx/opencode:latest AS base
USER root USER root
ARG UBUNTU_APT_MIRROR=mirrors.aliyun.com ARG UBUNTU_APT_MIRROR=
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
ENV VIRTUAL_ENV=/opt/venv ENV VIRTUAL_ENV=/opt/venv
@@ -11,14 +9,22 @@ ENV PIP_INDEX_URL=${PYPI_INDEX_URL}
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST} ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
ENV UV_INDEX_URL=${PYPI_INDEX_URL} ENV UV_INDEX_URL=${PYPI_INDEX_URL}
RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true && \ COPY vendor/bun-linux-x64.zip /tmp/bun.zip
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
fi && \
apt-get update && apt-get install -y --no-install-recommends \ apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
jq \ jq \
unzip \ unzip \
python3 \ python3 \
python3-venv && \ python3-venv && \
unzip -q /tmp/bun.zip -d /usr/local && \
mv /usr/local/bun-linux-x64 /usr/local/bun && \
ln -sf /usr/local/bun/bun /usr/local/bin/bun && \
rm -f /tmp/bun.zip && \
curl -LsSf https://astral.sh/uv/install.sh | sh && \ curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \ ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \ ln -sf /usr/bin/python3 /usr/local/bin/python && \
@@ -39,8 +45,6 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
pytest && \ pytest && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
@@ -56,6 +60,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY tsconfig.json opencode.json README.md .gitignore ./ COPY tsconfig.json opencode.json README.md .gitignore ./
COPY src ./src COPY src ./src
COPY cli ./cli
COPY .opencode ./.opencode COPY .opencode ./.opencode
RUN bun run check RUN bun run check
@@ -65,6 +70,7 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=8787 ENV PORT=8787
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
@@ -72,9 +78,10 @@ COPY package.json bun.lock ./
COPY tsconfig.json opencode.json .gitignore ./ COPY tsconfig.json opencode.json .gitignore ./
COPY src ./src COPY src ./src
COPY .opencode ./.opencode COPY .opencode ./.opencode
COPY cli ./cli
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+55 -282
View File
@@ -1,323 +1,96 @@
# TJWaterAgent 目录结构说明 # TJWaterAgent 内部智能体服务
`TJWaterAgent/`新的 opencode Agent 服务工程目录,负责对外提供 TJWater 智能助手接口,并通过 opencode SDK 启动或连接 opencode 运行时 `TJWaterAgent` TJWater 内部版智能体服务,负责连接前端聊天界面、OpenCode 运行时、MCP 工具和 TJWater 后端 API。它面向内部研发与部署,保留完整的 Agent 编排、会话上下文、工具调用和运行时调试能力
## 总体边界 ## 主要能力
- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。
- 管理前端 `session_id` 与 OpenCode session 的映射。
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。
- 通过 `data/` 保存运行时会话元数据、结果引用和本地状态。
## 目录结构
```text ```text
TJWaterAgent/ src/ 服务端 TypeScript 源码
package.json src/routes/ HTTP 路由
tsconfig.json src/chat/ 聊天流和 SSE 事件适配
src/ src/runtime/ OpenCode 运行时管理
opencode.json src/session/ 会话映射和运行上下文
.opencode/ src/mcp/ MCP 服务与工具桥接
agents/ .opencode/agents/ Agent prompt 和模型行为配置
tools/ .opencode/tools/ OpenCode 自定义工具
skills/ .opencode/skills/ 可复用分析工作流
package.json node-tests/ Node CLI 测试
tsconfig.json data/ 本地运行时数据,禁止提交
logs/ 本地日志,禁止提交
``` ```
| 位置 | 主要作用 | 典型内容 | ## 本地开发
| --- | --- | --- |
| `TJWaterAgent/` | 服务宿主、API 层和编排层 | Express 服务、SSE 接口、会话管理、鉴权上下文、后端 API 代理、opencode SDK 启动逻辑 |
| `TJWaterAgent/.opencode/` | opencode 项目资产目录 | agent prompt、自定义 tools、skills 树、plugins 相关依赖 |
## `TJWaterAgent/` 根目录的职责 项目使用 Bun
根目录是 Node/TypeScript 服务本体,主要负责:
1. 启动 HTTP 服务。
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
3. 管理前端 `session_id -> opencode sessionId` 的映射。
4. 保存并传递用户 `Authorization``x-user-id``x-project-id``x-trace-id`
5. 把 opencode 输出适配成前端需要的 SSE 事件。
6.`.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
7. 代理调用真实 TJWater 后端 API。
当前 Agent API 的主入口:
```text
POST /api/v1/agent/chat/stream
```
该接口返回 SSE,事件包括:
| event | 用途 |
| --- | --- |
| `progress` | 前端过程可视化,展示规划、工具调用和完成状态 |
| `token` | 最终回答文本流 |
| `tool_call` | 前端地图/面板/图表动作 |
| `done` | 当前轮完成 |
| `error` | 当前轮失败 |
主要目录和文件:
```text
src/
server.ts
config.ts
runtime/
session/
chat/
routes/
tools/
```
其中 `src/` 是业务服务层,不直接放 opencode skill 或 agent prompt。
## `.opencode/` 的职责
`.opencode/` 是给 opencode 运行时读取的项目资产目录,不是对外 HTTP 服务的主代码目录。
### agents
```text
.opencode/agents/agent.md
```
这里定义默认 agent 的角色、行为规则、模型配置和工具使用策略。
当前项目已将 always-loaded instructions 收敛到 `agent.md``opencode.json` 不再额外配置 `instructions` 数组。
### tools
```text
.opencode/tools/
tjwater_cli.ts
store_render_ref.ts
locate_features.ts
view_history.ts
view_scada.ts
show_chart.ts
render_junctions.ts
apply_layer_style.ts
memory_manager.ts
session_search.ts
skill_manager.ts
```
这些是 opencode 可以调用的自定义工具。
`tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令。
`store_render_ref.ts` 用于把大型 junction 渲染 payload 存成 `render_ref`,再由 `render_junctions.ts` 交给前端回读并渲染。
前端类工具如 `locate_features``view_history``view_scada``show_chart``render_junctions``apply_layer_style` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
### skills
```text
.opencode/skills/
SKILL.md
examples.md
runbook.md
tjwater-cli/ ← tjwater-cli 可执行文件
workflow/ ← 可复用分析工作流
SKILL.md
simulation-diagnosis/
bottleneck-analysis/
source-service-area-analysis/
```
Skills 仅保留可复用的多步工作流。Agent 通过 `tjwater-cli help` 自行发现原子命令,无需逐接口技能树。
agent 加载技能树时按需取用对应 workflow skill。
## 依赖边界
根目录和 `.opencode/` 使用两组 npm 依赖,职责不同。
### 根目录依赖
```text
TJWaterAgent/package.json
```
用于服务本体,例如:
```text
@opencode-ai/sdk
express
zod
pino
```
### `.opencode` 依赖
```text
TJWaterAgent/.opencode/package.json
```
用于 opencode 自定义 tools/plugins,例如:
```text
@opencode-ai/plugin
typescript
@types/node
```
这两组依赖不要混在一起:根目录负责服务运行,`.opencode` 负责 opencode 扩展资产的类型检查和运行依赖。
## 启动与部署
支持两种 opencode 接入方式:
1. Embedded 模式:服务通过 `@opencode-ai/sdk` 调用 `createOpencode`,启动本地 `opencode` CLI 子进程并自动创建 client。
2. Client 模式:服务通过 `createOpencodeClient` 直接连接一个已经存在的 opencode server。
因此,只有 Embedded 模式要求运行环境已安装 `opencode` CLIClient 模式不依赖本地 CLI。
根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
### 本地开发
```bash ```bash
cd TJWaterAgent
bun install bun install
bun run dev bun run dev
``` ```
`bun install` 会通过 `postinstall` 自动执行 `.opencode` 依赖安装;`bun run dev` 启动前会检查 `.opencode/tools` 的类型 `bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**``.opencode/**``opencode.json``.local.env` 后会自动重启
开发模式支持热重载,以下文件变化会触发服务重启并重新拉起 embedded opencode ## 常用命令
```text ```bash
src/** bun run check
.opencode/** bun run contract:generate
opencode.json bun run test:api
.local.env bun run test:cli
bun run start
bun run start:prod
docker build -t tjwater-agent:local .
``` ```
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev` - `bun run check`:检查主项目和 `.opencode` 的 TypeScript 类型
- `bun run contract:generate`:生成 `contracts/agent-v1.openapi.json`
- `bun run test:api`:验证公开 REST 契约和聊天路由。
- `bun run test:cli`:运行 `node-tests/cli/*.node.mjs`
- `bun run start`:直接启动服务。
- `bun run start:prod`:先类型检查,再启动服务。
本地开发可以在项目根目录的 `.local.env` 中配置环境变量。 ## 运行模式
Embedded 模式示例 Embedded 模式由服务进程拉起本机 OpenCode
```bash ```bash
OPENCODE_MODE=embedded OPENCODE_MODE=embedded
DEEPSEEK_API_KEY=sk-xxx
TJWATER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_API_BASE_URL=http://127.0.0.1:8000
``` ```
Client 模式示例 Client 模式连接外部 OpenCode server
```bash ```bash
OPENCODE_MODE=client OPENCODE_MODE=client
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096 OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
DEEPSEEK_API_KEY=sk-xxx
TJWATER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_API_BASE_URL=http://127.0.0.1:8000
``` ```
服务启动时会自动读取 `.local.env`,但系统环境变量优先级更高,适合在本机保存开发用 key 本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。
### 生产启动 ## 配置与安全
不要提交 `.env``.local.env``data/``logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
## 发布
Gitea 包工作流位于 `.gitea/workflows/package.yml`。发布前至少运行:
```bash ```bash
cd TJWaterAgent
bun install
bun run check bun run check
bun run start
``` ```
也可以使用一条命令完成构建并启动 如修改 CLI 或工具调用逻辑,同时运行
```bash ```bash
cd TJWaterAgent bun run test:cli
bun install
bun run start:prod
``` ```
### Docker Compose 启动
项目根目录已提供 `Dockerfile``docker-compose.yml`,可直接使用:
```bash
cd TJWaterAgent
docker compose up -d --build
```
查看日志:
```bash
docker compose logs -f tjwater-agent
```
停止并清理容器:
```bash
docker compose down
```
### 常用脚本
| 命令 | 作用 |
| --- | --- |
| `bun run dev` | 类型检查 `.opencode` tools 后,以 watch 模式直接运行 `src/server.ts` |
| `bun run check` | 执行完整类型检查(服务与 `.opencode` tools |
| `bun run start` | 直接运行 `src/server.ts` |
| `bun run start:prod` | 先类型检查再启动 |
| `bun run install:opencode` | 手动安装 `.opencode` 依赖 |
| `bun run pipeline:trigger` | 通过重建并强推 annotated `latest` tag 触发 Gitea CI/CD,只发布/覆盖 `latest` 镜像 |
### 模型与 API 配置
默认 Agent 模型为:
```text
deepseek/deepseek-v4-pro
```
涉及位置:
```text
opencode.json
.opencode/agents/tjwater-assistant.md
src/config.ts 的 OPENCODE_MODEL 默认值
```
如果需要临时覆盖模型,可以在启动时设置:
```bash
OPENCODE_MODEL=deepseek/deepseek-v4-pro bun run start
```
DeepSeek API key 不写入代码,部署时通过环境变量设置:
```bash
DEEPSEEK_API_KEY=sk-xxx bun run start
```
`opencode.json` 已配置从环境变量读取:
```json
{
"provider": {
"deepseek": {
"options": {
"apiKey": "{env:DEEPSEEK_API_KEY}"
}
}
}
}
```
如果需要自定义 DeepSeek 兼容 API 地址,可以通过 opencode 的 provider 配置增加 `baseURL`,例如在部署环境使用 `OPENCODE_CONFIG_CONTENT` 覆盖:
```bash
OPENCODE_CONFIG_CONTENT='{"provider":{"deepseek":{"options":{"baseURL":"https://your-api.example.com/v1"}}}}' \
DEEPSEEK_API_KEY=sk-xxx \
bun run start
```
也可以使用 opencode 的 `/connect` 命令写入用户级凭据,但服务部署更推荐使用环境变量。
如果需要连接外部独立运行的 opencode server,可以配置:
```bash
OPENCODE_MODE=client
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
```
配置后,`TJWaterAgent` 会连接该外部 opencode server,而不是自行启动 embedded opencode server。
+9 -2
View File
@@ -5,7 +5,7 @@
"": { "": {
"name": "tjwater-agent", "name": "tjwater-agent",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.14.29", "@opencode-ai/sdk": "^1.16.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
@@ -14,6 +14,7 @@
"zod": "^3.25.76", "zod": "^3.25.76",
}, },
"devDependencies": { "devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
@@ -23,7 +24,9 @@
}, },
}, },
"packages": { "packages": {
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-OgPEDvALekHZIjByo/okJ699aLPn+XtsVxgZxUqE8TlzAG7TtskMGFl0fro8O0T2p+nkOT/LstnKGbECvc0+YA=="], "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -175,6 +178,8 @@
"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=="],
"openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="],
"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=="],
@@ -259,6 +264,8 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"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=="],
+189
View File
@@ -0,0 +1,189 @@
import { CliError } from "../core/errors.js";
import { emitApi, requestJson } from "../core/http.js";
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js";
import { success } from "../core/output.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
return emitApi(ctx, "爆管分析执行成功", {
method: "POST",
path: "/burst-analyses",
params: {
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
burst_id: ids,
burst_size: sizes,
modify_total_duration: requiredNumber(values, "duration"),
scheme_name: schemeName,
},
requireProject: true,
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
}
function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { valve: "repeat", element: "repeat", "disabled-valve": "repeat", duration: "integer" });
const mode = validateChoice(requiredString(values, "mode"), ["close", "isolation"] as const, "--mode");
if (mode === "close") {
const valves = optionalStringArray(values, "valve");
const startTime = optionalString(values, "start-time");
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
return emitApi(ctx, "阀门关闭分析执行成功", {
method: "POST",
path: "/valve-isolation-analyses",
params: {
start_time: parseTime(startTime, "--start-time"),
valves,
duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
const elements = optionalStringArray(values, "element");
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
return emitApi(ctx, "阀门隔离分析执行成功", {
method: "POST",
path: "/valve-isolation-analyses",
params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
requireProject: true,
});
}
function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
return emitApi(ctx, "冲洗分析执行成功", {
method: "POST",
path: "/flushing-analyses",
params: {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
valves,
valves_k: openings,
drainage_node_id: requiredString(values, "drainage-node"),
flush_flow: requiredNumber(values, "flow"),
duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
return emitApi(ctx, "水龄分析执行成功", {
method: "POST",
path: "/water-age-analyses",
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
requireProject: true,
});
}
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
const params: Record<string, unknown> = {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
source: requiredString(values, "source-node"),
concentration: requiredNumber(values, "concentration"),
duration: requiredNumber(values, "duration"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
};
const pattern = optionalString(values, "pattern");
if (pattern) params.pattern = pattern;
return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
}
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
return emitApi(ctx, "传感器选址执行成功", {
method: "POST",
path: "/pressure-sensor-placement-kmeans",
body: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
sensor_number: requiredNumber(values, "count"),
min_diameter: optionalNumber(values, "min-diameter") || 0,
},
requireProject: true,
});
}
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, startKey: string, endKey: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, {
method: "POST",
path,
body: {
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true });
}
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise<void> {
const { positionals } = parseOptions(argv);
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true });
}
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" });
const body: Record<string, unknown> = {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
data_source: optionalString(values, "data-source") || "monitoring",
scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"),
scada_burst_end: parseTime(requiredString(values, "end-time"), "--end-time"),
burst_leakage: requiredNumber(values, "burst-leakage"),
use_scada_flow: Boolean(values["use-scada-flow"]),
};
const pressureIds = optionalStringArray(values, "pressure-scada-id");
const flowIds = optionalStringArray(values, "flow-scada-id");
if (pressureIds) body.pressure_scada_ids = pressureIds;
if (flowIds) body.flow_scada_ids = flowIds;
const pressureFile = optionalString(values, "pressure-file");
const flowFile = optionalString(values, "flow-file");
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow");
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
}
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { pipe_id: requiredString(values, "pipe") }, requireProject: true });
}
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
}
export const analysisHandlers: HandlerMap = {
"analysis burst": analysisBurst,
"analysis valve": analysisValve,
"analysis flushing": analysisFlushing,
"analysis age": analysisAge,
"analysis contaminant": analysisContaminant,
"analysis sensor-placement kmeans": sensorKmeans,
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
"analysis burst-location locate": burstLocation,
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
"analysis risk network": riskNetwork,
};
+33
View File
@@ -0,0 +1,33 @@
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean): Promise<void> {
const { values } = parseOptions(argv);
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
const routes: Record<`${ComponentKind}:${boolean}`, string> = {
"time:true": "/network-schemas/time",
"time:false": "/network-options/time",
"energy:true": "/network-schemas/energy",
"energy:false": "/network-options/energy",
"pump-energy:true": "/network-schemas/pump-energy",
"pump-energy:false": "/network-options/pump-energy",
"network:true": "/network-schemas/option",
"network:false": "/network-options",
};
const params: Record<string, unknown> = {};
const pump = optionalString(values, "pump");
if (kind === "pump-energy") {
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
if (pump) params.pump = pump;
}
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireProject: true });
}
export const componentHandlers: HandlerMap = {
"component option schema": (ctx, argv) => componentOption(ctx, argv, true),
"component option get": (ctx, argv) => componentOption(ctx, argv, false),
};
+171
View File
@@ -0,0 +1,171 @@
import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, {
method: "GET",
path,
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), end_time: parseTime(requiredString(values, "end-time"), "--end-time") },
requireProject: true,
});
}
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取实时模拟数据成功", {
method: "GET",
path: "/timeseries/realtime/simulation-results",
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true,
});
}
function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取实时属性聚合数据成功", {
method: "GET",
path: "/timeseries/realtime/records",
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
requireProject: true,
});
}
function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案管道数据成功", {
method: "GET",
path: "/timeseries/schemes/links",
params: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
},
requireProject: true,
});
}
function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案节点字段成功", {
method: "GET",
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
params: {
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
},
requireProject: true,
});
}
function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const query = validateChoice(requiredString(values, "query"), ["by-id-time", "by-scheme-time-property"] as const, "--query");
const type = validateChoice(optionalString(values, "type") || "pipe", ["pipe", "junction"] as const, "--type") as ElementType;
const params: Record<string, unknown> = {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
query_time: parseTime(requiredString(values, "time"), "--time"),
type,
};
if (query === "by-id-time") {
params.id = requiredString(values, "id");
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/timeseries/schemes/simulation-results", params, requireProject: true });
}
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/timeseries/schemes/records", params, requireProject: true });
}
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "device-id": "repeat" });
const params: Record<string, unknown> = {
device_ids: requiredStringArray(values, "device-id").join(","),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
};
const field = optionalString(values, "field");
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field");
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/timeseries/scada-readings/fields" : "/timeseries/scada-readings", params, requireProject: true });
}
function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { feature: "repeat", "use-cleaned": "boolean" });
const kind = validateChoice(requiredString(values, "kind"), ["scada-simulation", "element-simulation", "element-scada"] as const, "--kind");
const params: Record<string, unknown> = {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
};
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"));
if (schemeName) Object.assign(params, { scheme_name: schemeName, scheme_type: optionalString(values, "scheme-type") || "simulation" });
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
else {
const feature = requiredStringArray(values, "feature");
if (feature.length !== 1) throw new CliError("CLI 参数错误", "FEATURE_REQUIRED", "element-scada requires exactly one --feature as element_id", 2);
params.element_id = feature[0];
params.use_cleaned = Boolean(values["use-cleaned"]);
}
const paths = {
"scada-simulation": "/timeseries/views/scada-simulations",
"element-simulation": "/timeseries/views/element-simulations",
"element-scada": "/timeseries/views/element-scada-readings",
} as const;
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: paths[kind], params, requireProject: true });
}
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
requiredString(values, "pipe");
requiredString(values, "start-time");
return emitApi(ctx, "读取管道健康预测成功", {
method: "GET",
path: "/pipeline-health-predictions",
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
requireProject: true,
});
}
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
}
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true });
}
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true });
}
export const dataHandlers: HandlerMap = {
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/timeseries/realtime/links"),
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
"data timeseries scheme links": schemeLinks,
"data timeseries scheme node-field": schemeNodeField,
"data timeseries scheme simulation": schemeSimulation,
"data timeseries scada query": scadaQuery,
"data timeseries composite": composite,
"data timeseries composite pipeline-health": pipelineHealth,
"data scada get": dataScadaGet,
"data scada list": dataScadaList,
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }),
"data scheme get": dataSchemeGet,
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
};
+26
View File
@@ -0,0 +1,26 @@
import { emitApi } from "../core/http.js";
import { parseOptions, requiredString } from "../core/options.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
}
function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path, requireProject: true });
}
export const networkHandlers: HandlerMap = {
"network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"),
"network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"),
"network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"),
"network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
"network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"),
"network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"),
"network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"),
"network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
"network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"),
"network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"),
"network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"),
};
+24
View File
@@ -0,0 +1,24 @@
import { emitApi } from "../core/http.js";
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
const start = parseTime(requiredString(values, "start-time"), "--start-time");
const duration = requiredNumber(values, "duration");
const end = addMinutesPreservingOffset(start, duration);
return emitApi(
ctx,
"触发模拟成功",
{ method: "POST", path: "/simulation-runs", body: { start_time: start.replace(/\.\d+/, ""), duration }, requireProject: true },
[
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`,
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
],
);
}
export const simulationHandlers: HandlerMap = {
"simulation run": simulationRun,
};
+13
View File
@@ -0,0 +1,13 @@
export const SCHEMA_VERSION = "tjwater-cli/v1";
export const DEFAULT_TIMEOUT = 180;
export const DEFAULT_SERVER = "http://192.168.1.114:8000";
export const PIPE_FIELDS = ["flow", "friction", "headloss", "quality", "reaction", "setting", "status", "velocity"] as const;
export const JUNCTION_FIELDS = ["actual_demand", "total_head", "pressure", "quality"] as const;
export const SCADA_FIELDS = ["monitored_value", "cleaned_value"] as const;
export type ElementType = "pipe" | "junction";
export type PipeField = (typeof PIPE_FIELDS)[number];
export type JunctionField = (typeof JUNCTION_FIELDS)[number];
export type ScadaField = (typeof SCADA_FIELDS)[number];
+31
View File
@@ -0,0 +1,31 @@
export class CliError extends Error {
summary: string;
code: string;
exitCode: number;
retryable: boolean;
data: unknown;
nextCommands: string[];
constructor(
summary: string,
code: string,
message: string,
exitCode = 2,
retryable = false,
data: unknown = null,
nextCommands: string[] = [],
) {
super(message);
this.summary = summary;
this.code = code;
this.exitCode = exitCode;
this.retryable = retryable;
this.data = data;
this.nextCommands = nextCommands;
}
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from "node:fs";
import { CliError } from "./errors.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function readJsonFile(path: string, label: string): unknown {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
throw new CliError("CLI 参数错误", "INPUT_NOT_FOUND", `${label} file not found: ${path}`, 2);
}
if (error instanceof SyntaxError) throw new CliError("CLI 参数错误", "INPUT_INVALID_JSON", `${label} file must be valid JSON: ${path}`, 2);
throw error;
}
}
export function parseBurstFile(path: string): [string[], number[]] {
let raw = readJsonFile(path, "burst");
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
if (isRecord(raw) && "burst_id" in raw && "burst_size" in raw && Array.isArray(raw.burst_id) && Array.isArray(raw.burst_size)) {
const ids = raw.burst_id.map(String);
const sizes = raw.burst_size.map(Number);
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_id and burst_size must have the same length", 2);
return [ids, sizes];
}
if (Array.isArray(raw)) {
return [
raw.map((item) => {
if (!isRecord(item) || !("id" in item) || !("size" in item)) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file items must contain id and size", 2);
return String(item.id);
}),
raw.map((item) => Number((item as Record<string, unknown>).size)),
];
}
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_id/burst_size", 2);
}
export function parseValveSettingFile(path: string): [string[], number[]] {
const raw = readJsonFile(path, "valve-setting");
if (isRecord(raw) && "valves" in raw && "valves_k" in raw && Array.isArray(raw.valves) && Array.isArray(raw.valves_k)) {
const valves = raw.valves.map(String);
const openings = raw.valves_k.map(Number);
if (valves.length !== openings.length) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valves and valves_k must have the same length", 2);
return [valves, openings];
}
if (Array.isArray(raw)) {
return [
raw.map((item) => {
if (!isRecord(item) || !("valve" in item) || !("opening" in item)) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting items must contain valve and opening", 2);
return String(item.valve);
}),
raw.map((item) => Number((item as Record<string, unknown>).opening)),
];
}
throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting file must be a JSON array or object with valves/valves_k", 2);
}
export function assignDatasetKeys(target: Record<string, unknown>, path: string, keys: string[], label: string): void {
const payload = readJsonFile(path, label);
if (isRecord(payload)) {
for (const key of keys) {
if (key in payload) target[key] = payload[key];
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { CliError, errorMessage } from "./errors.js";
import { success } from "./output.js";
import type { RequestOptions, RuntimeContext } from "./types.js";
function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: boolean): Record<string, string> {
const out: Record<string, string> = {
Accept: "application/json, text/plain, */*",
"X-Request-Id": ctx.requestId,
...ctx.auth.headers,
};
if (requireAuth) {
if (!ctx.auth.accessToken) {
throw new CliError("认证失败", "UNAUTHENTICATED", "missing access token for agent context", 3, false, null, ["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"]);
}
out.Authorization = `Bearer ${ctx.auth.accessToken}`;
} else if (ctx.auth.accessToken) out.Authorization = `Bearer ${ctx.auth.accessToken}`;
if (requireProject) {
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
out["X-Project-Id"] = ctx.auth.projectId;
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
return out;
}
function stringifyParam(value: unknown): string {
if (typeof value === "boolean") return value ? "True" : "False";
return String(value);
}
function appendParams(url: URL, params: Record<string, unknown> = {}): void {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, stringifyParam(item)));
else url.searchParams.set(key, stringifyParam(value));
}
}
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
const { method, path, params, body, requireAuth = true, requireProject = false } = request;
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
appendParams(url, params);
const started = performance.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
let response: Response;
try {
response = await fetch(url, {
method: method.toUpperCase(),
headers: {
...headers(ctx, requireAuth, requireProject),
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new CliError("请求超时", "REQUEST_TIMEOUT", `request timed out after ${ctx.timeout} seconds`, 7, true);
throw new CliError("连接失败", "REQUEST_FAILED", errorMessage(error), 7, true);
} finally {
clearTimeout(timer);
}
const durationMs = Math.trunc(performance.now() - started);
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
const text = response.status === 204 ? "" : await response.text();
let payload: unknown = {};
if (contentType.includes("application/json") && text) payload = JSON.parse(text);
else if (text) payload = { report: text };
if (!response.ok) {
const record = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record<string, unknown>) : {};
const message = typeof record.detail === "string" ? record.detail : typeof record.message === "string" ? record.message : text || `http ${response.status}`;
throw new CliError("请求失败", `HTTP_${response.status}`, message, mapStatus(response.status), response.status >= 500);
}
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
const record = payload as Record<string, unknown>;
if (record.status === "error") throw new CliError("服务端错误", "SERVER_ERROR", String(record.message || "server returned error status"), 7, false, payload);
}
return [payload, durationMs];
}
function mapStatus(status: number): number {
if (status === 400 || status === 422) return 2;
if (status === 401) return 3;
if (status === 403) return 4;
if (status === 404) return 5;
if (status === 409 || status === 412) return 6;
return 7;
}
export async function emitApi(ctx: RuntimeContext, summary: string, request: RequestOptions, nextCommands: string[] = []): Promise<void> {
const [data, durationMs] = await requestJson(ctx, request);
success(summary, data, ctx, durationMs, nextCommands);
}
+112
View File
@@ -0,0 +1,112 @@
import { JUNCTION_FIELDS, PIPE_FIELDS, type ElementType } from "./constants.js";
import { CliError } from "./errors.js";
import type { OptionSchema, ParsedOptionValue, ParsedOptions } from "./types.js";
export function requireValue(argv: string[], index: number, option: string): string {
const value = argv[index];
if (!value || value.startsWith("--")) throw new CliError("CLI 参数错误", "MISSING_OPTION_VALUE", `${option} requires a value`, 2);
return value;
}
export function parseIntStrict(value: string, option: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_INTEGER", `${option} must be an integer`, 2);
return parsed;
}
export function parseFloatStrict(value: string, option: string): number {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_NUMBER", `${option} must be a number`, 2);
return parsed;
}
export function parseOptions(argv: string[], schema: OptionSchema = {}): ParsedOptions {
const values: Record<string, ParsedOptionValue> = {};
const positionals: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (!arg.startsWith("--")) {
positionals.push(arg);
continue;
}
const name = arg.slice(2);
const kind = schema[name] ?? "string";
if (kind === "boolean") {
values[name] = true;
} else {
const raw = requireValue(argv, ++i, arg);
if (kind === "repeat") values[name] = [...asStringArray(values[name]), raw];
else if (kind === "number") values[name] = parseFloatStrict(raw, arg);
else if (kind === "integer") values[name] = parseIntStrict(raw, arg);
else values[name] = raw;
}
}
return { values, positionals };
}
export function required(values: Record<string, ParsedOptionValue>, name: string): string | number | boolean | string[] {
const value = values[name];
if (value === undefined || value === null || value === "") {
throw new CliError("CLI 参数错误", "MISSING_PARAMETER", `Missing option '--${name}'`, 2);
}
return value;
}
export function requiredString(values: Record<string, ParsedOptionValue>, name: string): string {
const value = required(values, name);
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
return value;
}
export function requiredNumber(values: Record<string, ParsedOptionValue>, name: string): number {
const value = required(values, name);
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
return value;
}
export function requiredStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] {
const value = required(values, name);
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
return value;
}
export function optionalString(values: Record<string, ParsedOptionValue>, name: string): string | undefined {
const value = values[name];
if (value === undefined) return undefined;
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
return value;
}
export function optionalNumber(values: Record<string, ParsedOptionValue>, name: string): number | undefined {
const value = values[name];
if (value === undefined) return undefined;
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
return value;
}
export function optionalStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] | undefined {
const value = values[name];
if (value === undefined) return undefined;
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
return value;
}
export function asStringArray(value: ParsedOptionValue | undefined): string[] {
if (value === undefined) return [];
if (Array.isArray(value)) return value;
throw new CliError("CLI 参数错误", "INVALID_PARAMETER", "repeat option received a non-array value", 2);
}
export function validateChoice<T extends readonly string[]>(value: string, valid: T, option: string): T[number] {
if (!valid.includes(value)) {
throw new CliError("CLI 参数错误", `INVALID_${option.replace(/^--/, "").replaceAll("-", "_").toUpperCase()}`, `${option} must be one of: ${valid.join(", ")}`, 2);
}
return value as T[number];
}
export function fieldsFor(type: ElementType): typeof PIPE_FIELDS | typeof JUNCTION_FIELDS {
if (type === "pipe") return PIPE_FIELDS;
if (type === "junction") return JUNCTION_FIELDS;
throw new CliError("CLI 参数错误", "INVALID_TYPE", "--type must be one of: pipe, junction", 2);
}
+61
View File
@@ -0,0 +1,61 @@
import { SCHEMA_VERSION } from "./constants.js";
import type { RuntimeContext } from "./types.js";
export function json(value: unknown): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
export function generatedAt(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function success(summary: string, data: unknown, ctx: RuntimeContext, durationMs: number, nextCommands: string[] = []): void {
json({
ok: true,
schema_version: SCHEMA_VERSION,
summary,
data,
metadata: {
request_id: ctx.requestId,
server: ctx.server,
duration_ms: durationMs,
generated_at: generatedAt(),
},
next_commands: nextCommands,
});
}
export function failure({
summary,
code,
message,
retryable = false,
server = null,
requestId = null,
data = null,
nextCommands = [],
}: {
summary: string;
code: string;
message: string;
retryable?: boolean;
server?: string | null;
requestId?: string | null;
data?: unknown;
nextCommands?: string[];
}): void {
json({
ok: false,
schema_version: SCHEMA_VERSION,
summary,
error: { code, message, retryable },
data,
metadata: {
request_id: requestId,
server,
generated_at: generatedAt(),
},
next_commands: nextCommands,
});
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import { DEFAULT_SERVER, DEFAULT_TIMEOUT } from "./constants.js";
import { CliError } from "./errors.js";
import { requireValue, parseIntStrict } from "./options.js";
import type { AuthContext, GlobalArgs, ParsedGlobalArgs, RuntimeContext } from "./types.js";
function pick(source: Record<string, unknown>, ...keys: string[]): string | null {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null && value !== "") return String(value);
}
return null;
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let body = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
body += chunk;
});
process.stdin.on("end", () => resolve(body));
process.stdin.on("error", reject);
});
}
export async function loadAuthContext(authStdin: boolean): Promise<AuthContext> {
const raw = authStdin
? (JSON.parse(await readStdin()) as Record<string, unknown>)
: {
server: process.env.TJWATER_SERVER,
access_token: process.env.TJWATER_ACCESS_TOKEN,
project_id: process.env.TJWATER_PROJECT_ID,
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
};
const headers = (raw.headers ?? {}) as unknown;
if (!headers || Array.isArray(headers) || typeof headers !== "object") {
throw new CliError("认证失败", "AUTH_CONTEXT_INVALID", "auth context headers must be a JSON object", 3);
}
return {
server: pick(raw, "server", "base_url"),
accessToken: pick(raw, "access_token", "token", "accessToken"),
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
};
}
export function parseGlobalArgs(argv: string[]): ParsedGlobalArgs {
const globals: GlobalArgs = {
server: null,
authStdin: false,
scheme: null,
timeout: DEFAULT_TIMEOUT,
requestId: null,
};
const rest: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--auth-stdin") globals.authStdin = true;
else if (arg === "--server") globals.server = requireValue(argv, ++i, "--server");
else if (arg === "--scheme") globals.scheme = requireValue(argv, ++i, "--scheme");
else if (arg === "--timeout") globals.timeout = parseIntStrict(requireValue(argv, ++i, "--timeout"), "--timeout");
else if (arg === "--request-id") globals.requestId = requireValue(argv, ++i, "--request-id");
else rest.push(arg);
}
return { globals, rest };
}
export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext> {
const auth = await loadAuthContext(globals.authStdin);
return {
server: globals.server || auth.server || DEFAULT_SERVER,
auth,
scheme: globals.scheme,
timeout: globals.timeout,
requestId: globals.requestId || randomUUID(),
};
}
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
const scheme = explicit || ctx.scheme;
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
return scheme ?? null;
}
+23
View File
@@ -0,0 +1,23 @@
import { CliError } from "./errors.js";
export function parseTime(value: string, option: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new CliError("CLI 参数错误", "INVALID_TIME", `${option} must be a valid ISO 8601 / RFC 3339 timestamp`, 2);
}
if (!/[zZ]|[+-]\d\d:\d\d$/.test(value)) {
throw new CliError("CLI 参数错误", "TIMEZONE_REQUIRED", `${option} must include an explicit timezone offset`, 2);
}
return value;
}
export function addMinutesPreservingOffset(value: string, minutes: number): string {
const match = value.match(/([+-])(\d\d):(\d\d)$/);
const end = new Date(new Date(value).getTime() + minutes * 60_000);
if (!match) return end.toISOString().replace(".000Z", "Z");
const sign = match[1] === "+" ? 1 : -1;
const offsetMinutes = sign * (Number(match[2]) * 60 + Number(match[3]));
const local = new Date(end.getTime() + offsetMinutes * 60_000);
return `${local.toISOString().replace(".000Z", "")}${match[1]}${match[2]}:${match[3]}`;
}
+83
View File
@@ -0,0 +1,83 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type Dict<T = unknown> = Record<string, T>;
export interface AuthContext {
server: string | null;
accessToken: string | null;
projectId: string | null;
headers: Record<string, string>;
}
export interface RuntimeContext {
server: string;
auth: AuthContext;
scheme: string | null;
timeout: number;
requestId: string;
}
export interface GlobalArgs {
server: string | null;
authStdin: boolean;
scheme: string | null;
timeout: number;
requestId: string | null;
}
export interface ParsedGlobalArgs {
globals: GlobalArgs;
rest: string[];
}
export type OptionKind = "string" | "boolean" | "repeat" | "number" | "integer";
export type OptionSchema = Record<string, OptionKind>;
export type ParsedOptionValue = string | number | boolean | string[];
export interface ParsedOptions {
values: Record<string, ParsedOptionValue>;
positionals: string[];
}
export interface RequestOptions {
method: string;
path: string;
params?: Record<string, unknown>;
body?: unknown;
requireAuth?: boolean;
requireProject?: boolean;
}
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
export type HandlerMap = Record<string, Handler>;
export interface CommandDoc {
ok: true;
schema_version: string;
command: string;
summary: string;
description: string;
usage: string;
options: CommandOptionDoc[];
examples: string[];
output: string;
next_commands: string[];
}
export interface CommandOptionDoc {
name: string;
description: string;
required: boolean;
repeated: boolean;
default: null;
}
export type HelpPayload =
| CommandDoc
| {
ok: true;
schema_version: string;
summary: string;
menu_level?: number;
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
};
+50
View File
@@ -0,0 +1,50 @@
import { CliError } from "./core/errors.js";
import { handlers } from "./handlers.js";
import { helpPayload } from "./help/index.js";
import { failure, json } from "./core/output.js";
import type { RuntimeContext } from "./core/types.js";
export async function dispatch(ctx: RuntimeContext | null, argv: string[]): Promise<void> {
if (argv.length === 0 || argv.includes("--help")) {
process.stdout.write("Usage: tjwater-cli [OPTIONS] COMMAND [ARGS]...\n\nStructured JSON:\n tjwater-cli help\n");
return;
}
if (argv[0] === "help") {
const payload = helpPayload(argv.slice(1));
if (!payload) {
failure({
summary: "未找到命令",
code: "COMMAND_NOT_FOUND",
message: `unknown command path: ${argv.slice(1).join(" ")}`,
retryable: false,
data: { usage: "tjwater-cli help <command-path>", examples: ["tjwater-cli help simulation run", "tjwater-cli simulation help"] },
nextCommands: ["tjwater-cli help", "tjwater-cli help simulation"],
});
return;
}
json(payload);
return;
}
const matched = matchCommand(argv);
if (!matched) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]);
const handler = handlers[matched.path];
if (!handler) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]);
if (!ctx && matched.path !== "__noop") throw new CliError("CLI 参数错误", "RUNTIME_REQUIRED", "runtime context is required for command execution", 2);
await handler(ctx!, matched.args);
}
function matchCommand(argv: string[]): { path: string; args: string[] } | null {
const paths = Object.keys(handlers).sort((a, b) => b.split(" ").length - a.split(" ").length);
for (const path of paths) {
const parts = path.split(" ");
if (parts.every((part, index) => argv[index] === part)) return { path, args: argv.slice(parts.length) };
}
if (argv.at(-1) === "help") {
const payload = helpPayload(argv.slice(0, -1));
if (payload) {
json(payload);
return { path: "__noop", args: [] };
}
}
return null;
}
+15
View File
@@ -0,0 +1,15 @@
import { analysisHandlers } from "./commands/analysis.js";
import { componentHandlers } from "./commands/component.js";
import { dataHandlers } from "./commands/data.js";
import { networkHandlers } from "./commands/network.js";
import { simulationHandlers } from "./commands/simulation.js";
import type { HandlerMap } from "./core/types.js";
export const handlers: HandlerMap = {
"__noop": async () => {},
...networkHandlers,
...componentHandlers,
...simulationHandlers,
...analysisHandlers,
...dataHandlers,
};
+145
View File
@@ -0,0 +1,145 @@
import { SCHEMA_VERSION } from "../core/constants.js";
import type { CommandDoc, CommandOptionDoc } from "../core/types.js";
export const GROUP_SUMMARIES: Record<string, string> = {
network: "管网节点、管线等基础属性查询命令。",
component: "组件选项与配置读取命令。",
"component option": "组件选项查询命令。",
simulation: "模拟运行与调度相关命令。",
analysis: "分析计算与诊断相关命令。",
"analysis leakage": "漏损分析相关命令。",
"analysis leakage schemes": "漏损方案查询命令。",
"analysis burst-detection": "爆管检测相关命令。",
"analysis burst-detection schemes": "爆管检测方案查询命令。",
"analysis burst-location": "爆管定位相关命令。",
"analysis burst-location schemes": "爆管定位方案查询命令。",
"analysis risk": "风险分析相关命令。",
"analysis sensor-placement": "传感器选址相关命令。",
data: "时序、SCADA 和方案数据查询命令。",
"data timeseries": "时序数据查询命令。",
"data timeseries realtime": "实时模拟时序查询命令。",
"data timeseries scheme": "方案时序查询命令。",
"data timeseries scada": "SCADA 时序查询命令。",
"data timeseries composite": "复合时序查询命令。",
"data scada": "SCADA 元数据查询命令。",
"data scheme": "方案数据查询命令。",
};
export const HIDDEN_PATH_PREFIXES = ["analysis burst-location", "analysis risk"];
type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]];
const commandSpecs: readonly CommandSpec[] = [
["network get-junction-properties", "读取节点属性", ["--junction <JUNCTION>"], ["tjwater-cli network get-junction-properties --junction J1"]],
["network get-pipe-properties", "读取管道属性", ["--pipe <PIPE>"], ["tjwater-cli network get-pipe-properties --pipe P1"]],
["network get-all-pipes-properties", "读取全部管道属性", [], ["tjwater-cli network get-all-pipes-properties"]],
["network get-reservoir-properties", "读取水库属性", ["--reservoir <RESERVOIR>"], ["tjwater-cli network get-reservoir-properties --reservoir R1"]],
["network get-all-reservoirs-properties", "读取全部水库属性", [], ["tjwater-cli network get-all-reservoirs-properties"]],
["network get-tank-properties", "读取水箱属性", ["--tank <TANK>"], ["tjwater-cli network get-tank-properties --tank T1"]],
["network get-all-tanks-properties", "读取全部水箱属性", [], ["tjwater-cli network get-all-tanks-properties"]],
["network get-pump-properties", "读取水泵属性", ["--pump <PUMP>"], ["tjwater-cli network get-pump-properties --pump PU1"]],
["network get-all-pumps-properties", "读取全部水泵属性", [], ["tjwater-cli network get-all-pumps-properties"]],
["network get-valve-properties", "读取阀门属性", ["--valve <VALVE>"], ["tjwater-cli network get-valve-properties --valve V1"]],
["network get-all-valves-properties", "读取全部阀门属性", [], ["tjwater-cli network get-all-valves-properties"]],
["component option schema", "读取选项 schema", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]],
["component option get", "读取选项属性", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]],
["simulation run", "触发指定绝对时间的模拟运行", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]],
["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list"]],
["analysis valve", "阀门工况分析。", ["--mode <MODE>", "[--start-time <START_TIME>]", "[--valve <VALVE>]", "[--element <ELEMENT>]", "[--disabled-valve <DISABLED_VALVE>]", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", "tjwater-cli analysis valve --mode isolation --element E1 --element E2", "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3"]],
["analysis flushing", "执行冲洗分析", ["--start-time <START_TIME>", "--valve-setting-file <VALVE_SETTING_FILE>", "--drainage-node <DRAINAGE_NODE>", "--flow <FLOW>", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]],
["analysis age", "执行水龄分析", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]],
["analysis contaminant", "执行污染物模拟", ["--start-time <START_TIME>", "--duration <DURATION>", "--source-node <SOURCE_NODE>", "--concentration <CONCENTRATION>", "[--pattern <PATTERN>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]],
["analysis sensor-placement kmeans", "执行 KMeans 传感器选址", ["--count <COUNT>", "[--min-diameter <MIN_DIAMETER>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01"]],
["analysis leakage identify", "执行漏损识别", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"]],
["analysis leakage schemes list", "列出漏损方案", [], ["tjwater-cli analysis leakage schemes list"]],
["analysis leakage schemes get", "读取漏损方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis leakage schemes get my_scheme"]],
["analysis burst-detection detect", "执行爆管检测", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01"]],
["analysis burst-detection schemes list", "列出爆管检测方案", [], ["tjwater-cli analysis burst-detection schemes list"]],
["analysis burst-detection schemes get", "读取爆管检测方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-detection schemes get my_scheme"]],
["analysis burst-location locate", "执行爆管定位", ["--start-time <START_TIME>", "--end-time <END_TIME>", "--burst-leakage <BURST_LEAKAGE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01"]],
["analysis burst-location schemes list", "列出爆管定位方案", [], ["tjwater-cli analysis burst-location schemes list"]],
["analysis burst-location schemes get", "读取爆管定位方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-location schemes get my_scheme"]],
["analysis risk pipe-now", "读取单条管道当前风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-now --pipe P1"]],
["analysis risk pipe-history", "读取单条管道历史风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-history --pipe P1"]],
["analysis risk network", "读取全网风险", [], ["tjwater-cli analysis risk network"]],
["data timeseries realtime links", "查询实时管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime nodes", "查询实时节点时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id <ID>", "--type <TYPE>", "--time <TIME>"], ["tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00"]],
["data timeseries realtime simulation-by-time-property", "按时间和属性查询实时模拟结果", ["--type <TYPE>", "--time <TIME>", "--property <PROPERTY>"], ["tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow"]],
["data timeseries scheme links", "查询方案管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]],
["data timeseries scheme node-field", "查询方案节点字段时序", ["--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]],
["data timeseries scheme simulation", "查询方案模拟数据", ["--query <QUERY>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--id <ID>]", "[--time <TIME>]", "[--type <TYPE>]", "[--property <PROPERTY>]"], ["tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme"]],
["data timeseries scada query", "查询 SCADA 时序", ["--device-id <DEVICE_ID>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--field <FIELD>]"], ["tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value"]],
["data timeseries composite", "执行复合时序查询", ["[--kind <KIND>]", "[--feature <FEATURE>]", "[--start-time <START_TIME>]", "[--end-time <END_TIME>]", "[--pipe <PIPE>]", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]],
["data timeseries composite pipeline-health", "查询管道健康预测", ["--pipe <PIPE>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data scada get", "读取单条 SCADA 元数据", ["--kind <KIND>", "--id <ID>"], ["tjwater-cli data scada get --kind info --id SCADA-001"]],
["data scada list", "列出 SCADA 元数据", ["--kind <KIND>"], ["tjwater-cli data scada list --kind info"]],
["data scheme schema", "读取方案 schema", [], ["tjwater-cli data scheme schema"]],
["data scheme get", "读取单条方案", ["--name <NAME>"], ["tjwater-cli data scheme get --name my_scheme"]],
["data scheme list", "列出方案", [], ["tjwater-cli data scheme list"]],
];
export const commandDocs = new Map<string, CommandDoc>(
commandSpecs.map(([path, summary, options, examples, nextCommands = []]) => [
path,
{
ok: true,
schema_version: SCHEMA_VERSION,
command: path,
summary,
description: summary,
usage: buildUsage(path, options),
options: options.filter((item) => item.startsWith("--") || item.startsWith("[--")).map((item) => optionDoc(path, item)),
examples: [...(examples ?? [`tjwater-cli ${path}${exampleSuffix(options)}`])],
output: "标准 JSON 输出",
next_commands: [...nextCommands],
},
]),
);
function buildUsage(path: string, options: readonly string[]): string {
const optionTokens = options.filter((item) => item.startsWith("--") || item.startsWith("[--"));
return `tjwater-cli ${path}${optionTokens.length ? ` ${optionTokens.join(" ")}` : ""}`;
}
function optionDoc(path: string, token: string): CommandOptionDoc {
const optional = token.startsWith("[");
const clean = token.replace(/^\[/, "").replace(/\]$/, "");
const name = clean.slice(2).split(/\s+/)[0]!;
const repeatedOptions: Record<string, string[]> = {
"analysis valve": ["valve", "element", "disabled-valve"],
"analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"],
"data timeseries scada query": ["device-id"],
"data timeseries composite": ["feature"],
};
return {
name,
description: "",
required: !optional,
repeated: (repeatedOptions[path] ?? []).includes(name),
default: null,
};
}
function exampleSuffix(options: readonly string[]): string {
return options
.map((item) => {
if (item.includes("START_TIME")) return " --start-time 2025-01-02T03:00:00+08:00";
if (item.includes("END_TIME")) return " --end-time 2025-01-02T04:00:00+08:00";
if (item.includes("DURATION")) return " --duration 900";
if (item.includes("SCHEME")) return " --scheme my_scheme";
if (item.includes("KIND")) return " --kind info";
if (item.includes("TYPE")) return " --type pipe";
if (item.includes("TIME")) return " --time 2025-01-02T03:30:00+08:00";
return "";
})
.join("");
}
export function isHiddenPath(path: string): boolean {
return HIDDEN_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix} `));
}
export function hasVisibleSubcommands(path: string): boolean {
return [...commandDocs.keys()].some((key) => !isHiddenPath(key) && key.startsWith(`${path} `));
}
+53
View File
@@ -0,0 +1,53 @@
import { SCHEMA_VERSION } from "../core/constants.js";
import { GROUP_SUMMARIES, commandDocs, hasVisibleSubcommands, isHiddenPath } from "./docs.js";
import type { HelpPayload } from "../core/types.js";
export function helpPayload(pathParts: string[]): HelpPayload | null {
const path = pathParts.join(" ");
if (path && isHiddenPath(path)) return null;
if (path && commandDocs.has(path)) return commandDocs.get(path)!;
if (!path) {
const seen = new Set<string>();
const commands: Array<{ command: string; summary: string }> = [];
for (const doc of [...commandDocs.values()].sort((a, b) => a.command.localeCompare(b.command))) {
if (isHiddenPath(doc.command)) continue;
const command = doc.command.split(" ")[0]!;
if (seen.has(command)) continue;
seen.add(command);
commands.push({ command, summary: GROUP_SUMMARIES[command] ?? `${command} 可用子命令` });
}
return {
ok: true,
schema_version: SCHEMA_VERSION,
summary: "可用一级菜单",
menu_level: 1,
commands,
};
}
const prefix = path ? `${path} ` : "";
const children: Array<{ command: string; summary: string; usage: string; example: string }> = [];
const seen = new Set<string>();
for (const doc of [...commandDocs.values()].sort((a, b) => a.command.localeCompare(b.command))) {
if (isHiddenPath(doc.command)) continue;
if (!doc.command.startsWith(prefix)) continue;
const child = doc.command.slice(prefix.length).split(" ")[0]!;
if (seen.has(child)) continue;
seen.add(child);
const childPath = path ? `${path} ${child}` : child;
const isGroup = hasVisibleSubcommands(childPath);
const childDoc = commandDocs.get(childPath) ?? doc;
children.push({
command: childPath,
summary: isGroup ? (GROUP_SUMMARIES[childPath] ?? `${childPath} 可用子命令`) : childDoc.summary,
usage: isGroup ? `tjwater-cli ${childPath} help` : childDoc.usage,
example: isGroup ? `tjwater-cli ${childPath} help` : childDoc.examples[0]!,
});
}
if (!children.length && path) return null;
return {
ok: true,
schema_version: SCHEMA_VERSION,
summary: GROUP_SUMMARIES[path] ?? `${path} 可用子命令`,
commands: children,
};
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if command -v bun >/dev/null 2>&1; then
exec bun "$SCRIPT_DIR/tjwater-cli.ts" "$@"
fi
exec node "$SCRIPT_DIR/tjwater-cli.ts" "$@"
+45
View File
@@ -0,0 +1,45 @@
import { dispatch } from "./src/dispatch.js";
import { CliError, errorMessage } from "./src/core/errors.js";
import { failure } from "./src/core/output.js";
import { buildRuntime, parseGlobalArgs } from "./src/core/runtime.js";
let currentServer: string | null = null;
let currentRequestId: string | null = null;
async function main(): Promise<number> {
const { globals, rest } = parseGlobalArgs(process.argv.slice(2));
if (isHelpRequest(rest)) {
await dispatch(null, rest);
return 0;
}
const ctx = await buildRuntime(globals);
currentServer = ctx.server;
currentRequestId = ctx.requestId;
await dispatch(ctx, rest);
return 0;
}
function isHelpRequest(argv: string[]): boolean {
return argv.length === 0 || argv[0] === "help" || argv.includes("--help") || argv.at(-1) === "help";
}
try {
process.exitCode = await main();
} catch (error) {
if (error instanceof CliError) {
failure({
summary: error.summary,
code: error.code,
message: error.message,
retryable: error.retryable,
server: currentServer,
requestId: currentRequestId,
data: error.data,
nextCommands: error.nextCommands,
});
process.exitCode = error.exitCode;
} else {
failure({ summary: "CLI 执行失败", code: "UNHANDLED_ERROR", message: errorMessage(error), retryable: false });
process.exitCode = 1;
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"contract_version": "1.0.0",
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
}
}
}
+352
View File
@@ -0,0 +1,352 @@
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
function runCommand(command, args, input, options = {}) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("error", reject);
child.on("close", (exitCode) => resolveRun({ exitCode, stdout, stderr }));
if (input !== undefined) child.stdin.end(JSON.stringify(input));
else child.stdin.end();
});
}
function runCli(args, input) {
return runCommand(cliPath, args, input);
}
function runPythonCli(args, input) {
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
}
function parseJsonResult(result) {
return JSON.parse(result.stdout);
}
async function startJsonServer(responseData) {
const seen = [];
const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const text = Buffer.concat(chunks).toString("utf8");
seen.push({
body: text ? JSON.parse(text) : null,
headers: req.headers,
method: req.method,
url: req.url,
});
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(responseData));
});
await new Promise((resolveListen, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolveListen);
});
const address = server.address();
return {
seen,
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolveClose) => server.close(resolveClose)),
};
}
function normalizeSeenRequest(request) {
const url = new URL(request.url, "http://127.0.0.1");
const query = {};
for (const key of [...new Set(url.searchParams.keys())].sort()) {
const values = url.searchParams.getAll(key);
query[key] = values.length === 1 ? values[0] : values;
}
return {
body: request.body,
headers: {
authorization: request.headers.authorization,
"x-project-id": request.headers["x-project-id"],
},
method: request.method,
path: url.pathname,
query,
};
}
async function runAgainstServer(name, runner, args, auth, responseData = { accepted: true }) {
const server = await startJsonServer(responseData);
try {
const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url });
if (!result.stdout.trim()) {
throw new Error(`${name}: CLI produced empty stdout; exit=${result.exitCode}; stderr=${result.stderr}`);
}
return {
exitCode: result.exitCode,
payload: parseJsonResult(result),
requests: server.seen.map(normalizeSeenRequest),
stderr: result.stderr,
};
} finally {
await server.close();
}
}
test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
const result = await runCli(["help", "simulation", "run"]);
assert.equal(result.exitCode, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.schema_version, "tjwater-cli/v1");
assert.equal(payload.command, "simulation run");
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
});
test("matches Python CLI help discovery and hidden command behavior", async () => {
for (const args of [["help"], ["help", "analysis"]]) {
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
}
const [nodeLeaf, pythonLeaf] = await Promise.all([
runCli(["help", "simulation", "run"]),
runPythonCli(["help", "simulation", "run"]),
]);
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
const nodePayload = parseJsonResult(nodeLeaf);
const pythonPayload = parseJsonResult(pythonLeaf);
assert.equal(nodePayload.ok, pythonPayload.ok);
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
assert.equal(nodePayload.command, pythonPayload.command);
assert.equal(nodePayload.summary, pythonPayload.summary);
assert.equal(nodePayload.usage, pythonPayload.usage);
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
const [nodeHidden, pythonHidden] = await Promise.all([
runCli(["help", "analysis", "risk"]),
runPythonCli(["help", "analysis", "risk"]),
]);
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
const nodeError = parseJsonResult(nodeHidden);
const pythonError = parseJsonResult(pythonHidden);
delete nodeError.metadata.generated_at;
delete pythonError.metadata.generated_at;
assert.deepEqual(nodeError, pythonError);
});
test("matches Python CLI leaf help for every visible command", async () => {
const listResult = await runCommand(
"python",
[
"-c",
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
],
undefined,
{ cwd: pythonCliCwd },
);
assert.equal(listResult.exitCode, 0, listResult.stderr);
const commands = JSON.parse(listResult.stdout);
for (const command of commands) {
const args = ["help", ...command.split(" ")];
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
const nodePayload = parseJsonResult(nodeResult);
const pythonPayload = parseJsonResult(pythonResult);
const comparable = (payload) => ({
command: payload.command,
summary: payload.summary,
usage: payload.usage,
examples: payload.examples,
next_commands: payload.next_commands,
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
name,
required,
repeated,
})),
});
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
}
});
test("sends auth headers and simulation body through the backend API contract", async () => {
const server = await startJsonServer({ accepted: true });
try {
const result = await runCli(
[
"--auth-stdin",
"simulation",
"run",
"--start-time",
"2025-01-02T03:00:00+08:00",
"--duration",
"60",
],
{
server: server.url,
access_token: "token-1",
project_id: "project-1",
headers: { "x-extra": "extra" },
},
);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(server.seen[0].method, "POST");
assert.equal(server.seen[0].url, "/api/v1/simulation-runs");
assert.equal(server.seen[0].headers.authorization, "Bearer token-1");
assert.equal(server.seen[0].headers["x-extra"], "extra");
assert.deepEqual(server.seen[0].body, {
start_time: "2025-01-02T03:00:00+08:00",
duration: 60,
});
const payload = JSON.parse(result.stdout);
assert.equal(payload.ok, true);
assert.match(payload.next_commands[0], /--end-time 2025-01-02T04:00:00\+08:00/);
} finally {
await server.close();
}
});
test("uses project scoped headers for realtime data commands", async () => {
const server = await startJsonServer([{ id: "P1" }]);
try {
const result = await runCli(
[
"--auth-stdin",
"data",
"timeseries",
"realtime",
"links",
"--start-time",
"2025-01-02T03:00:00+08:00",
"--end-time",
"2025-01-02T04:00:00+08:00",
],
{
server: server.url,
accessToken: "token-2",
projectId: "project-1",
},
);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(server.seen[0].method, "GET");
assert.equal(
server.seen[0].url,
"/api/v1/timeseries/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
);
assert.equal(server.seen[0].headers.authorization, "Bearer token-2");
assert.equal(server.seen[0].headers["x-project-id"], "project-1");
} finally {
await server.close();
}
});
test("matches Python CLI backend request shape for every command and key variants", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
try {
const burstFile = join(tempDir, "burst.json");
const valveFile = join(tempDir, "valve.json");
const pressureFile = join(tempDir, "pressure.json");
const flowFile = join(tempDir, "flow.json");
await writeFile(burstFile, JSON.stringify([{ id: "B1", size: 12.5 }]));
await writeFile(valveFile, JSON.stringify([{ valve: "V1", opening: 0.5 }]));
await writeFile(pressureFile, JSON.stringify({ burst_pressure: [1.1], normal_pressure: [2.2] }));
await writeFile(flowFile, JSON.stringify({ burst_flow: [3.3], normal_flow: [4.4] }));
const auth = {
access_token: "token",
network: "tjwater",
project_id: "project-1",
username: "alice",
headers: { "x-extra": "extra" },
};
const start = "2025-01-02T03:00:00+08:00";
const end = "2025-01-02T04:00:00+08:00";
const at = "2025-01-02T03:30:00+08:00";
const cases = [
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
["network get-all-pipes-properties", ["network", "get-all-pipes-properties"]],
["network get-reservoir-properties", ["network", "get-reservoir-properties", "--reservoir", "R1"]],
["network get-all-reservoirs-properties", ["network", "get-all-reservoirs-properties"]],
["network get-tank-properties", ["network", "get-tank-properties", "--tank", "T1"]],
["network get-all-tanks-properties", ["network", "get-all-tanks-properties"]],
["network get-pump-properties", ["network", "get-pump-properties", "--pump", "PU1"]],
["network get-all-pumps-properties", ["network", "get-all-pumps-properties"]],
["network get-valve-properties", ["network", "get-valve-properties", "--valve", "V1"]],
["network get-all-valves-properties", ["network", "get-all-valves-properties"]],
["component option schema", ["component", "option", "schema", "--kind", "time"]],
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]],
["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]],
["analysis valve close", ["analysis", "valve", "--mode", "close", "--start-time", start, "--valve", "V1", "--valve", "V2", "--duration", "900", "--scheme", "valve_case"]],
["analysis valve isolation", ["analysis", "valve", "--mode", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]],
["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]],
["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]],
["analysis sensor-placement kmeans", ["analysis", "sensor-placement", "kmeans", "--count", "5", "--min-diameter", "100", "--scheme", "place_case"]],
["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]],
["analysis leakage schemes list", ["analysis", "leakage", "schemes", "list"]],
["analysis leakage schemes get", ["analysis", "leakage", "schemes", "get", "leak_case"]],
["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]],
["analysis burst-detection schemes list", ["analysis", "burst-detection", "schemes", "list"]],
["analysis burst-detection schemes get", ["analysis", "burst-detection", "schemes", "get", "detect_case"]],
["analysis burst-location locate", ["analysis", "burst-location", "locate", "--start-time", start, "--end-time", end, "--burst-leakage", "50.5", "--scheme", "locate_case", "--data-source", "simulation", "--pressure-file", pressureFile, "--flow-file", flowFile, "--use-scada-flow"]],
["analysis burst-location schemes list", ["analysis", "burst-location", "schemes", "list"]],
["analysis burst-location schemes get", ["analysis", "burst-location", "schemes", "get", "locate_case"]],
["analysis risk pipe-now", ["analysis", "risk", "pipe-now", "--pipe", "P1"]],
["analysis risk pipe-history", ["analysis", "risk", "pipe-history", "--pipe", "P1"]],
["analysis risk network", ["analysis", "risk", "network"]],
["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]],
["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]],
["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]],
["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]],
["data scheme links", ["data", "timeseries", "scheme", "links", "--start-time", start, "--end-time", end, "--scheme", "scheme_case", "--scheme-type", "simulation"]],
["data scheme node-field", ["data", "timeseries", "scheme", "node-field", "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
["data scheme simulation by-id", ["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "J1", "--time", at, "--type", "junction", "--scheme", "scheme_case"]],
["data scheme simulation by-property", ["data", "timeseries", "scheme", "simulation", "--query", "by-scheme-time-property", "--time", at, "--type", "pipe", "--property", "flow", "--scheme", "scheme_case"]],
["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]],
["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]],
["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]],
["data composite pipeline-health", ["data", "timeseries", "composite", "pipeline-health", "--pipe", "P1", "--start-time", start, "--end-time", end]],
["data scada get", ["data", "scada", "get", "--kind", "info", "--id", "SCADA-001"]],
["data scada list", ["data", "scada", "list", "--kind", "info"]],
["data scheme schema", ["data", "scheme", "schema"]],
["data scheme get", ["data", "scheme", "get", "--name", "scheme_case"]],
["data scheme list", ["data", "scheme", "list"]],
];
for (const [name, args] of cases) {
const [nodeRun, pythonRun] = await Promise.all([
runAgainstServer(`${name} node`, runCli, args, auth),
runAgainstServer(`${name} python`, runPythonCli, args, auth),
]);
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
}
} finally {
await rm(tempDir, { force: true, recursive: true });
}
});
+17 -1
View File
@@ -7,10 +7,26 @@
} }
} }
}, },
"model": "deepseek/deepseek-v4-pro", "model": "deepseek/deepseek-v4-flash",
"server": { "server": {
"hostname": "127.0.0.1", "hostname": "127.0.0.1",
"port": 4096 "port": 4096
}, },
"permission": {
"*": "allow",
"external_directory": "ask",
"bash": {
"*": "allow",
"rm *": "ask",
"rmdir *": "ask",
"mv *": "ask",
"chmod *": "ask",
"chown *": "ask",
"sudo *": "ask",
"curl *": "ask",
"wget *": "ask"
},
"edit": "ask"
},
"default_agent": "instruction" "default_agent": "instruction"
} }
+7 -1
View File
@@ -8,15 +8,20 @@
"install:opencode": "bun install --cwd .opencode", "install:opencode": "bun install --cwd .opencode",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"typecheck:opencode": "bun run --cwd .opencode typecheck", "typecheck:opencode": "bun run --cwd .opencode typecheck",
"test:cli": "node --test node-tests/cli/*.node.mjs",
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
"contract:generate": "bun scripts/generate-contract.ts",
"contract:check": "bun scripts/generate-contract.ts --check",
"test:api": "bun test tests/routes tests/contracts",
"migrate:session-identity": "bun scripts/migrate-session-identity.ts",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
"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"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.14.29", "@opencode-ai/sdk": "^1.16.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
@@ -25,6 +30,7 @@
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
+37
View File
@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { generateAgentOpenApi } from "../src/contracts/openapi.js";
const canonicalJson = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
const document = generateAgentOpenApi();
const payload = canonicalJson(document);
const sha256 = createHash("sha256").update(payload).digest("hex");
const manifestPayload = canonicalJson({
contract_version: "1.0.0",
contracts: {
agent: {
file: "agent-v1.openapi.json",
sha256,
},
},
});
if (process.argv.includes("--check")) {
const currentContract = await readFile(
"contracts/agent-v1.openapi.json",
"utf8",
);
const currentManifest = await readFile("contracts/manifest.json", "utf8");
if (currentContract !== payload || currentManifest !== manifestPayload) {
throw new Error(
"Agent OpenAPI contract is stale: run `bun run contract:generate`",
);
}
console.log(`validated Agent OpenAPI contract (${sha256})`);
} else {
await mkdir("contracts", { recursive: true });
await writeFile("contracts/agent-v1.openapi.json", payload, "utf8");
await writeFile("contracts/manifest.json", manifestPayload, "utf8");
console.log(`generated Agent OpenAPI contract (${sha256})`);
}
+424
View File
@@ -0,0 +1,424 @@
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
type JsonRecord = Record<string, unknown>;
type Options = {
backupDir: string;
dataDir: string;
dryRun: boolean;
fromActorKey: string;
fromProjectId?: string;
fromProjectKey?: string;
fromUserId?: string;
metadataDir: string;
memoryDir: string;
resultRefsDir: string;
toActorKey: string;
toProjectId?: string;
toProjectKey?: string;
toUserId?: string;
transcriptsDir: string;
};
type Change = {
detail: string;
file: string;
kind: "metadata" | "transcript" | "result-ref" | "memory";
};
const usage = `Usage:
bun scripts/migrate-session-identity.ts --from-user-id <old> --to-user-id <new> [--write]
bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]
Optional:
--from-project-id <old> Match old project id
--to-project-id <new> Rewrite project id
--from-project-key <old> Match old project key
--to-project-key <new> Rewrite project key
--data-dir <dir> Default: ./data
--backup-dir <dir> Default: <data-dir>/backup/session-identity-migration/<timestamp>
--write Apply changes. Without this, dry-run only.
Examples:
bun scripts/migrate-session-identity.ts \\
--from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\
--to-actor-key actor-49a0275a85079f9d
bun scripts/migrate-session-identity.ts \\
--from-actor-key actor-da98d99c20c386e7 \\
--to-actor-key actor-49a0275a85079f9d \\
--write
`;
const main = async () => {
const options = parseOptions(process.argv.slice(2));
const changes: Change[] = [];
changes.push(...(await migrateMetadata(options)));
changes.push(...(await migrateTranscripts(options)));
changes.push(...(await migrateResultRefs(options)));
changes.push(...(await migrateUserMemory(options)));
printSummary(options, changes);
};
const parseOptions = (args: string[]): Options => {
const values = new Map<string, string | boolean>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`unexpected argument: ${arg}\n\n${usage}`);
}
const key = arg.slice(2);
if (key === "help" || key === "h") {
console.log(usage);
process.exit(0);
}
if (key === "write") {
values.set(key, true);
continue;
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for --${key}\n\n${usage}`);
}
values.set(key, value);
index += 1;
}
const dataDir = stringOption(values, "data-dir") ?? "./data";
const fromUserId = stringOption(values, "from-user-id");
const toUserId = stringOption(values, "to-user-id");
const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId);
const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId);
const fromProjectId = stringOption(values, "from-project-id");
const toProjectId = stringOption(values, "to-project-id");
const fromProjectKey =
stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId);
const toProjectKey =
stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId);
if (!fromActorKey || !toActorKey) {
throw new Error(
"provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" +
usage,
);
}
if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) {
throw new Error("source and target identity are identical");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir =
stringOption(values, "backup-dir") ??
join(dataDir, "backup", "session-identity-migration", timestamp);
return {
backupDir,
dataDir,
dryRun: !values.has("write"),
fromActorKey,
fromProjectId,
fromProjectKey,
fromUserId,
metadataDir: join(dataDir, "session-metadata"),
memoryDir: join(dataDir, "memory"),
resultRefsDir: join(dataDir, "result-refs"),
toActorKey,
toProjectId,
toProjectKey,
toUserId,
transcriptsDir: join(dataDir, "session-transcripts"),
};
};
const stringOption = (values: Map<string, string | boolean>, key: string) => {
const value = values.get(key);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
};
const actorKeyFromUserId = (userId?: string) =>
userId ? scopedKey("actor", userId) : undefined;
const projectKeyFromProjectId = (projectId?: string) =>
projectId ? scopedKey("project", projectId) : undefined;
const scopedKey = (prefix: string, value: string) =>
`${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`;
const migrateMetadata = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.metadataDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toUserId ? { ownerUserId: options.toUserId } : {}),
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "metadata",
});
}
return changes;
};
const migrateTranscripts = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.transcriptsDir)) {
const transcript = await readJson(file);
if (!transcript || transcript.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) {
continue;
}
const next: JsonRecord = {
...transcript,
actorKey: options.toActorKey,
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
const nextPath = join(
dirname(file),
`${next.actorKey}__${next.projectKey}__${next.sessionId}.json`,
);
if (nextPath !== file && (await exists(nextPath))) {
throw new Error(`refusing to overwrite existing transcript: ${nextPath}`);
}
await writeJsonWithBackup(options, file, next);
if (nextPath !== file) {
await renameWithBackup(options, file, nextPath);
}
changes.push({
detail: `${basename(file)} -> ${basename(nextPath)}`,
file,
kind: "transcript",
});
}
return changes;
};
const migrateResultRefs = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.resultRefsDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "result-ref",
});
}
return changes;
};
const migrateUserMemory = async (options: Options): Promise<Change[]> => {
const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`);
if (!(await exists(fromFile))) {
return [];
}
const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`);
if (fromFile === toFile) {
return [];
}
if (await exists(toFile)) {
const [fromContent, toContent] = await Promise.all([
readFile(fromFile, "utf8"),
readFile(toFile, "utf8"),
]);
const merged = mergeMemoryMarkdown(toContent, fromContent);
await writeTextWithBackup(options, toFile, merged);
await removeWithBackup(options, fromFile);
} else {
await renameWithBackup(options, fromFile, toFile);
}
return [
{
detail: `${basename(fromFile)} -> ${basename(toFile)}`,
file: fromFile,
kind: "memory",
},
];
};
const mergeMemoryMarkdown = (target: string, source: string) => {
const lines = target.split("\n");
const existing = new Set(lines.map((line) => line.trim()).filter(Boolean));
for (const line of source.split("\n")) {
const normalized = line.trim();
if (!normalized || existing.has(normalized)) {
continue;
}
lines.push(line);
existing.add(normalized);
}
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
};
const listJsonFiles = async (dir: string) => {
try {
const names = await readdir(dir);
return names
.filter((name) => name.endsWith(".json"))
.map((name) => join(dir, name));
} catch (error) {
if (isErrno(error, "ENOENT")) {
return [];
}
throw error;
}
};
const readJson = async (file: string): Promise<JsonRecord | null> => {
try {
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
return isRecord(value) ? value : null;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return null;
}
throw error;
}
};
const writeJsonWithBackup = async (
options: Options,
file: string,
value: JsonRecord,
) => {
await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`);
};
const writeTextWithBackup = async (
options: Options,
file: string,
content: string,
) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await writeFile(file, content, "utf8");
};
const renameWithBackup = async (options: Options, from: string, to: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, from);
await mkdir(dirname(to), { recursive: true });
await rename(from, to);
};
const removeWithBackup = async (options: Options, file: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await rm(file, { force: true });
};
const backupFile = async (options: Options, file: string) => {
const relativePath = relative(process.cwd(), file);
const target = join(options.backupDir, relativePath);
await mkdir(dirname(target), { recursive: true });
await copyFile(file, target);
};
const exists = async (file: string) => {
try {
await stat(file);
return true;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return false;
}
throw error;
}
};
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isErrno = (error: unknown, code: string) =>
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === code;
const printSummary = (options: Options, changes: Change[]) => {
const counts = changes.reduce<Record<string, number>>((acc, change) => {
acc[change.kind] = (acc[change.kind] ?? 0) + 1;
return acc;
}, {});
console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied");
console.log(`from actor: ${options.fromActorKey}`);
console.log(`to actor: ${options.toActorKey}`);
if (options.fromProjectKey || options.toProjectKey) {
console.log(`project: ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`);
}
if (!options.dryRun) {
console.log(`backup dir: ${options.backupDir}`);
}
console.log(`changes: ${changes.length}`);
for (const [kind, count] of Object.entries(counts)) {
console.log(` ${kind}: ${count}`);
}
for (const change of changes.slice(0, 40)) {
console.log(`- [${change.kind}] ${change.detail}`);
}
if (changes.length > 40) {
console.log(`... ${changes.length - 40} more`);
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+112 -13
View File
@@ -3,6 +3,39 @@ import type { NextFunction, Request, Response } from "express";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
export type AgentAuthContext = {
accessToken: string;
userId: string;
keycloakSub: string;
username: string;
role: string;
isSuperuser: boolean;
projectId: string;
network: string;
projectRole: string;
tokenExpiresAt?: string;
};
type AgentAuthContextResponse = {
user_id?: unknown;
keycloak_sub?: unknown;
username?: unknown;
role?: unknown;
is_superuser?: unknown;
project_id?: unknown;
network?: unknown;
project_role?: unknown;
token_expires_at?: unknown;
};
declare global {
namespace Express {
interface Request {
agentAuth?: AgentAuthContext;
}
}
}
export const extractBearerToken = (authorization?: string) => { export const extractBearerToken = (authorization?: string) => {
const value = authorization?.trim(); const value = authorization?.trim();
if (!value) { if (!value) {
@@ -11,8 +44,41 @@ export const extractBearerToken = (authorization?: string) => {
return value.replace(/^Bearer\s+/i, "").trim(); return value.replace(/^Bearer\s+/i, "").trim();
}; };
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token const normalizeAgentAuthContext = (
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。 payload: AgentAuthContextResponse,
accessToken: string,
): AgentAuthContext | null => {
if (
typeof payload.user_id !== "string" ||
typeof payload.keycloak_sub !== "string" ||
typeof payload.username !== "string" ||
typeof payload.role !== "string" ||
typeof payload.is_superuser !== "boolean" ||
typeof payload.project_id !== "string" ||
typeof payload.network !== "string" ||
typeof payload.project_role !== "string"
) {
return null;
}
return {
accessToken,
userId: payload.user_id,
keycloakSub: payload.keycloak_sub,
username: payload.username,
role: payload.role,
isSuperuser: payload.is_superuser,
projectId: payload.project_id,
network: payload.network,
projectRole: payload.project_role,
tokenExpiresAt:
typeof payload.token_expires_at === "string"
? payload.token_expires_at
: undefined,
};
};
// Agent API 使用 TJWater 后端专用认证上下文:后端负责校验 Keycloak token、
// metadata 用户状态和项目 membershipAgent 只信任后端返回的 user/project。
export const requireAgentAuth = async ( export const requireAgentAuth = async (
req: Request, req: Request,
res: Response, res: Response,
@@ -24,37 +90,70 @@ export const requireAgentAuth = async (
return; return;
} }
const projectId = req.header("x-project-id")?.trim();
if (!projectId) {
res.status(400).json({ message: "x-project-id is required" });
return;
}
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
try { try {
const response = await fetch(new URL("/api/v1/auth/me", config.TJWATER_API_BASE_URL), { const response = await fetch(new URL("/api/v1/agent-auth-context", config.TJWATER_API_BASE_URL), {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"X-Project-Id": projectId,
...(req.header("x-trace-id") ? { "X-Trace-Id": req.header("x-trace-id") as string } : {}),
}, },
signal: controller.signal, signal: controller.signal,
}); });
if (response.ok) { if (response.ok) {
const context = normalizeAgentAuthContext(
(await response.json()) as AgentAuthContextResponse,
token,
);
if (!context) {
res.status(502).json({ message: "invalid authentication context" });
return;
}
req.agentAuth = context;
next(); next();
return; return;
} }
const detail = await response.text(); const status =
res.status(response.status === 403 ? 403 : 401).json({ response.status === 400 ||
message: response.status === 403 ? "forbidden" : "unauthorized", response.status === 401 ||
detail: detail || undefined, response.status === 403 ||
}); response.status === 404
? response.status
: response.status === 503
? 503
: 502;
const messages: Record<number, string> = {
400: "invalid authentication request",
401: "unauthorized",
403: "forbidden",
404: "authentication context not found",
502: "invalid authentication service response",
503: "authentication service unavailable",
};
res.status(status).json({ message: messages[status] });
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.warn({ err: error }, "agent auth validation failed"); logger.warn({ err: error }, "agent auth validation failed");
res.status(503).json({ res.status(503).json({ message: "authentication service unavailable" });
message: "authentication service unavailable",
detail,
});
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
}; };
export const getAgentAuthContext = (req: Request) => {
if (!req.agentAuth) {
throw new Error("agent auth context is missing");
}
return req.agentAuth;
};
+78
View File
@@ -0,0 +1,78 @@
export type SupportedModel = string;
export type AgentModelIcon = "bolt" | "sparkle";
export type AgentModelOption = {
id: SupportedModel;
label: string;
description: string;
icon: AgentModelIcon;
};
export const defaultAgentModelOptions: AgentModelOption[] = [
{
id: "deepseek/deepseek-v4-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
},
{
id: "deepseek/deepseek-v4-pro",
label: "专家",
description: "探索、解决复杂任务",
icon: "sparkle",
},
];
export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions);
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const parseAgentModelOptions = (value: string): AgentModelOption[] => {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON");
}
if (!Array.isArray(parsed)) {
throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array");
}
const seen = new Set<string>();
return parsed.map((item, index) => {
if (!isObjectRecord(item)) {
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}] must be an object`);
}
const id = typeof item.id === "string" ? item.id.trim() : "";
if (!id) {
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}].id is required`);
}
if (seen.has(id)) {
throw new Error(`duplicate OPENCODE_MODEL_OPTIONS id: ${id}`);
}
seen.add(id);
const label =
typeof item.label === "string" && item.label.trim()
? item.label.trim()
: id;
const description =
typeof item.description === "string" && item.description.trim()
? item.description.trim()
: label;
const icon = item.icon === "bolt" || item.icon === "sparkle"
? item.icon
: "sparkle";
return {
id,
label,
description,
icon,
};
});
};
export const getAgentModelIds = (options: AgentModelOption[]) =>
options.map((option) => option.id);
+28
View File
@@ -0,0 +1,28 @@
import { config } from "../config.js";
import {
getAgentModelIds,
parseAgentModelOptions,
type SupportedModel,
} from "./modelConfig.js";
export {
type AgentModelIcon,
type AgentModelOption,
type SupportedModel,
} from "./modelConfig.js";
export const agentModelOptions = parseAgentModelOptions(
config.OPENCODE_MODEL_OPTIONS,
);
export const supportedModels = getAgentModelIds(agentModelOptions);
export const isSupportedModel = (model: string): model is SupportedModel =>
supportedModels.includes(model);
export const resolveDefaultModel = (model: string): SupportedModel => {
if (!isSupportedModel(model)) {
throw new Error(`unsupported default agent model: ${model}`);
}
return model;
};
+10
View File
@@ -17,7 +17,9 @@ export type SessionBinding = {
export type SessionContext = { export type SessionContext = {
clientSessionId: string; clientSessionId: string;
accessToken?: string; accessToken?: string;
network?: string;
projectId?: string; projectId?: string;
tokenExpiresAt?: string;
userId?: string; userId?: string;
}; };
@@ -35,8 +37,10 @@ export class ChatSessionBridge {
async resolve(context: { async resolve(context: {
sessionId?: string; sessionId?: string;
accessToken?: string; accessToken?: string;
network?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
tokenExpiresAt?: string;
userId?: string; userId?: string;
}): Promise<{ }): Promise<{
binding: SessionBinding; binding: SessionBinding;
@@ -69,9 +73,11 @@ export class ChatSessionBridge {
allowLearningWrite: true, allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
learningMode: "interactive", learningMode: "interactive",
network: requestContext.network,
projectId: requestContext.projectId, projectId: requestContext.projectId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId, sessionId,
tokenExpiresAt: requestContext.tokenExpiresAt,
traceId: requestContext.traceId, traceId: requestContext.traceId,
}); });
@@ -141,8 +147,10 @@ export class ChatSessionBridge {
private buildRequestContext(context: { private buildRequestContext(context: {
sessionId?: string; sessionId?: string;
accessToken?: string; accessToken?: string;
network?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
tokenExpiresAt?: string;
userId?: string; userId?: string;
}): ChatRequestContext { }): ChatRequestContext {
const sessionId = context.sessionId?.trim(); const sessionId = context.sessionId?.trim();
@@ -150,8 +158,10 @@ export class ChatSessionBridge {
clientSessionId: sessionId || this.createClientSessionId(), clientSessionId: sessionId || this.createClientSessionId(),
accessToken: context.accessToken, accessToken: context.accessToken,
actorKey: toActorKey(context.userId), actorKey: toActorKey(context.userId),
network: context.network,
projectId: context.projectId, projectId: context.projectId,
projectKey: toProjectKey(context.projectId), projectKey: toProjectKey(context.projectId),
tokenExpiresAt: context.tokenExpiresAt,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(), userId: context.userId?.trim(),
}; };
+43 -3
View File
@@ -1,6 +1,12 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import { z } from "zod"; import { z } from "zod";
import {
defaultAgentModelOptionsJson,
getAgentModelIds,
parseAgentModelOptions,
} from "./chat/modelConfig.js";
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
dotenv.config({ path: ".local.env", override: false }); dotenv.config({ path: ".local.env", override: false });
@@ -33,7 +39,7 @@ const envSchema = z
.default("./logs/llm-request-audit.log"), .default("./logs/llm-request-audit.log"),
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
AGENT_INTERNAL_TOKEN: optionalString(), AGENT_INTERNAL_TOKEN: optionalString(),
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。 // Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。 // opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
@@ -44,11 +50,15 @@ const envSchema = z
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。 // opencode SDK 启动或连接运行时时的超时时间(毫秒)。
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。 // 默认使用的 opencode 模型标识。
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-flash"),
// 聊天 UI 和 /stream 允许选择的 opencode 模型完整配置,JSON 数组。
OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson),
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。 // opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
// client 模式下,目标 opencode server 的基础地址。 // client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
OPENCODE_BASE_URL: z.string().url().optional(),
// tjwater-cli 可执行文件路径。 // tjwater-cli 可执行文件路径。
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"), TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
// TJWater 后端 API 的基础地址。 // TJWater 后端 API 的基础地址。
@@ -114,6 +124,32 @@ const envSchema = z
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client", message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
}); });
} }
let modelOptions;
try {
modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_MODEL_OPTIONS"],
message: error instanceof Error ? error.message : String(error),
});
return;
}
const supportedModels = getAgentModelIds(modelOptions);
if (supportedModels.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_MODEL_OPTIONS"],
message: "OPENCODE_MODEL_OPTIONS must include at least one model",
});
}
if (!supportedModels.includes(env.OPENCODE_MODEL)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_MODEL"],
message: "OPENCODE_MODEL must be included in OPENCODE_MODEL_OPTIONS",
});
}
}); });
export type AppConfig = z.infer<typeof envSchema>; export type AppConfig = z.infer<typeof envSchema>;
@@ -122,7 +158,11 @@ const normalizedEnv = {
...process.env, ...process.env,
OPENCODE_MODE: OPENCODE_MODE:
process.env.OPENCODE_MODE ?? process.env.OPENCODE_MODE ??
(process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"), (process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL
? "client"
: "embedded"),
OPENCODE_CLIENT_BASE_URL:
process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL,
}; };
export const config: AppConfig = envSchema.parse(normalizedEnv); export const config: AppConfig = envSchema.parse(normalizedEnv);
+325
View File
@@ -0,0 +1,325 @@
import {
extendZodWithOpenApi,
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
const SessionId = z.object({ session_id: z.string().max(128) });
const RenderRef = z.object({ render_ref: z.string().min(1) });
const RenderReferenceQuery = z.object({
session_id: z.string().max(128).optional(),
});
const SessionCreate = z
.object({
session_id: z.string(),
title: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
})
.openapi("AgentSessionCreate");
const SessionSummary = z
.object({
id: z.string(),
title: z.string(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
is_streaming: z.boolean(),
run_status: z.string().nullable().optional(),
})
.openapi("AgentSessionSummary");
const SessionDetail = SessionSummary.extend({
session_id: z.string(),
is_title_manually_edited: z.boolean(),
messages: z.array(z.unknown()),
}).openapi("AgentSessionDetail");
const SessionUpdate = z
.object({
id: z.string(),
title: z.string(),
updated_at: z.string(),
})
.openapi("AgentSessionUpdate");
const SessionFork = z
.object({
session_id: z.string(),
})
.openapi("AgentSessionFork");
const Problem = z
.object({
type: z.string(),
title: z.string(),
status: z.number().int(),
detail: z.string(),
instance: z.string(),
code: z.string(),
trace_id: z.string(),
errors: z.array(z.unknown()),
})
.openapi("ProblemDetails");
const JsonObject = z.record(z.unknown());
const errorResponses = {
400: {
description: "Invalid request",
content: { "application/problem+json": { schema: Problem } },
},
401: {
description: "Authentication required",
content: { "application/problem+json": { schema: Problem } },
},
403: {
description: "Insufficient permission",
content: { "application/problem+json": { schema: Problem } },
},
404: {
description: "Resource not found",
content: { "application/problem+json": { schema: Problem } },
},
409: {
description: "Resource conflict",
content: { "application/problem+json": { schema: Problem } },
},
422: {
description: "Validation error",
content: { "application/problem+json": { schema: Problem } },
},
500: {
description: "Internal server error",
content: { "application/problem+json": { schema: Problem } },
},
502: {
description: "Upstream dependency error",
content: { "application/problem+json": { schema: Problem } },
},
503: {
description: "Dependency unavailable",
content: { "application/problem+json": { schema: Problem } },
},
};
const jsonResponse = (schema: z.ZodTypeAny, description = "Successful response") => ({
description,
content: { "application/json": { schema } },
});
type RegisterConfig = {
summary: string;
request?: Record<string, unknown>;
responses: Record<number, unknown>;
};
export const createRouteRegistrar = (targetRegistry: OpenAPIRegistry) => {
const operations = new Set<string>();
return (
path: string,
method: "get" | "post" | "patch" | "delete",
config: RegisterConfig,
) => {
const key = `${method.toUpperCase()} ${path}`;
if (operations.has(key)) {
throw new Error(`Duplicate Agent OpenAPI operation: ${key}`);
}
operations.add(key);
targetRegistry.registerPath({
path,
method,
operationId: `${method}_${path
.replace(/^\/api\/v1\/agent\/?/, "")
.replaceAll(/[{}]/g, "")
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
.replaceAll(/^_|_$/g, "")}`,
tags: ["Agent"],
security: [{ bearerAuth: [] }],
...config,
responses: { ...config.responses, ...errorResponses },
});
};
};
const register = createRouteRegistrar(registry);
register("/api/v1/agent/models", "get", {
summary: "List available agent models",
responses: { 200: jsonResponse(JsonObject) },
});
register("/api/v1/agent/sessions", "get", {
summary: "List agent sessions",
responses: { 200: jsonResponse(z.object({ sessions: z.array(SessionSummary) })) },
});
register("/api/v1/agent/sessions", "post", {
summary: "Create an agent session",
request: {
body: {
content: {
"application/json": {
schema: z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
}),
},
},
},
},
responses: {
200: jsonResponse(SessionCreate, "Existing session returned"),
201: jsonResponse(SessionCreate, "Session created"),
},
});
register("/api/v1/agent/sessions/{session_id}", "get", {
summary: "Get an agent session",
request: { params: SessionId },
responses: { 200: jsonResponse(SessionDetail) },
});
register("/api/v1/agent/sessions/{session_id}", "patch", {
summary: "Update an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
title: z.string().min(1).max(120),
is_title_manually_edited: z.boolean().optional(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionUpdate) },
});
register("/api/v1/agent/sessions/{session_id}", "delete", {
summary: "Delete an agent session",
request: { params: SessionId },
responses: { 204: { description: "Session deleted" } },
});
register("/api/v1/agent/sessions/{session_id}/runs", "post", {
summary: "Run an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
message: z.string().min(1).max(10000),
model: z.string().optional(),
approval_mode: z.enum(["request", "always"]).optional(),
}),
},
},
},
},
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
});
register(
"/api/v1/agent/sessions/{session_id}/runs/current/events",
"get",
{
summary: "Resume the current agent event stream",
request: { params: SessionId },
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
},
);
register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
summary: "Abort the current agent run",
request: { params: SessionId },
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
});
register(
"/api/v1/agent/sessions/{session_id}/permission-responses",
"post",
{
summary: "Reply to an agent permission request",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register(
"/api/v1/agent/sessions/{session_id}/question-responses",
"post",
{
summary: "Reply to or reject an agent question",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register("/api/v1/agent/sessions/{session_id}/forks", "post", {
summary: "Fork an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
keep_message_count: z.number().int().nonnegative(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionFork) },
});
register("/api/v1/agent/render-references/{render_ref}", "get", {
summary: "Resolve a render reference",
request: { params: RenderRef, query: RenderReferenceQuery },
responses: { 200: jsonResponse(JsonObject) },
});
export const generateAgentOpenApi = () => {
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.3",
info: {
title: "TJWater Agent API",
version: "1.0.0",
description: "Public REST API for TJWater Agent sessions and runs",
},
});
};
+1 -2
View File
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { writeLearningAuditLog } from "../audit/learningAudit.js"; import { writeLearningAuditLog } from "../audit/learningAudit.js";
import { type SupportedModel } from "../chat/models.js";
import { type ChatRequestContext } from "../chat/sessionBridge.js"; import { type ChatRequestContext } from "../chat/sessionBridge.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
@@ -64,8 +65,6 @@ const reviewResultSchema = z.object({
type GateResult = z.infer<typeof gateResultSchema>; type GateResult = z.infer<typeof gateResultSchema>;
type ReviewResult = z.infer<typeof reviewResultSchema>; type ReviewResult = z.infer<typeof reviewResultSchema>;
type SupportedModel = "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro";
type TurnReviewInput = { type TurnReviewInput = {
assistantMessage: string; assistantMessage: string;
model?: SupportedModel; model?: SupportedModel;
+322 -377
View File
@@ -1,6 +1,14 @@
import { Router } from "express"; import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import {
agentModelOptions,
isSupportedModel,
resolveDefaultModel,
type SupportedModel,
} from "../chat/models.js";
import { config } from "../config.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js"; import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
@@ -8,8 +16,10 @@ import { MemoryStore } from "../memory/store.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type ResultReferenceResolver } from "../results/resolver.js"; import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js"; import {
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js"; import { type SessionRecord } from "../sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
@@ -18,22 +28,43 @@ import {
extractLatestFrontendTurn, extractLatestFrontendTurn,
generateSessionTitle, generateSessionTitle,
shouldGenerateSessionTitle, shouldGenerateSessionTitle,
shouldRestoreConversationForRuntime,
} from "./chatSession.js"; } from "./chatSession.js";
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import { import {
collectTextContent, collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse, streamPromptResponse,
supportedModels, type TodoUpdatePayload,
type SupportedModel,
} from "./chatStream.js"; } from "./chatStream.js";
import {
type ActiveRun,
type RunStatus,
type StreamSubscriber,
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages,
isObjectRecord,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js";
const payloadSchema = z.object({ const payloadSchema = z.object({
message: z.string().min(1).max(10000), message: z.string().min(1).max(10000),
session_id: z.string().max(128).optional(), session_id: z.string().max(128).optional(),
model: z.enum(supportedModels).optional(), model: z.string().refine(isSupportedModel, {
}); message: "unsupported model",
}).optional(),
const abortPayloadSchema = z.object({ approval_mode: z.enum(["request", "always"]).optional().default("request"),
session_id: z.string().max(128),
}); });
const createSessionPayloadSchema = z.object({ const createSessionPayloadSchema = z.object({
@@ -46,28 +77,6 @@ const forkPayloadSchema = z.object({
keep_message_count: z.coerce.number().int().min(0), keep_message_count: z.coerce.number().int().min(0),
}); });
const sessionStateSchema = z.object({
title: z.string().max(120).optional(),
is_title_manually_edited: z.boolean().optional(),
messages: z.array(z.unknown()).default([]),
branch_groups: z.array(z.unknown()).default([]),
});
type RunStatus = "running" | "completed" | "error" | "aborted";
type StreamSubscriber = {
write: (event: string, data: Record<string, unknown>) => void;
close: () => void;
};
type ActiveRun = {
clientSessionId: string;
controller: AbortController;
messages: unknown[];
status: RunStatus;
subscribers: Set<StreamSubscriber>;
};
const activeRuns = new Map<string, ActiveRun>(); const activeRuns = new Map<string, ActiveRun>();
const lastRunStatuses = new Map<string, RunStatus>(); const lastRunStatuses = new Map<string, RunStatus>();
@@ -75,115 +84,32 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
}); });
export const buildForkedSessionUiState = (
sourceState: { messages?: unknown[] } | null | undefined,
input: {
keepMessageCount: number;
targetSessionId: string;
},
) => ({
sessionId: input.targetSessionId,
isTitleManuallyEdited: false,
messages: Array.isArray(sourceState?.messages)
? sourceState.messages.slice(0, input.keepMessageCount)
: [],
});
const getSessionRunStatus = (sessionId: string) => const getSessionRunStatus = (sessionId: string) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId); activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
const isObjectRecord = (value: unknown): value is Record<string, unknown> => const runtimeHasConversation = async (
typeof value === "object" && value !== null && !Array.isArray(value); runtime: OpencodeRuntimeAdapter,
sessionId: string,
const createFrontendMessageId = () =>
`msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const createInitialStreamingMessages = (existingMessages: unknown[], userContent: string) => {
const userMessage = {
id: createFrontendMessageId(),
role: "user",
content: userContent,
};
return [
...existingMessages,
{
...userMessage,
branchRootId: userMessage.id,
},
{
id: createFrontendMessageId(),
role: "assistant",
content: "",
progress: [
{
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
startedAt: Date.now(),
elapsedMs: 0,
elapsedSnapshotAt: Date.now(),
},
],
},
];
};
const upsertBackendProgress = (
progress: unknown,
payload: Record<string, unknown>,
) => { ) => {
const next = Array.isArray(progress) ? [...progress] : []; const messages = await runtime.messages(sessionId, 1);
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`; return messages.some(
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id); (message) =>
const nextItem = { message.info.role === "user" || message.info.role === "assistant",
id, );
phase: typeof payload.phase === "string" ? payload.phase : "progress",
status:
payload.status === "completed" || payload.status === "error"
? payload.status
: "running",
title: typeof payload.title === "string" ? payload.title : "正在处理",
detail: typeof payload.detail === "string" ? payload.detail : undefined,
startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined,
endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined,
elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined,
elapsedSnapshotAt:
typeof payload.elapsed_ms === "number" ? Date.now() : undefined,
durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
const completeBackendProgress = (progress: unknown) =>
Array.isArray(progress)
? progress.map((item) => {
if (!isObjectRecord(item) || item.status !== "running") {
return item;
}
const endedAt = Date.now();
const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined;
return {
...item,
status: "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
typeof item.durationMs === "number"
? item.durationMs
: startedAt !== undefined
? Math.max(0, endedAt - startedAt)
: item.elapsedMs,
};
})
: progress;
const updateLastAssistantMessage = (
messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>,
) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (isObjectRecord(message) && message.role === "assistant") {
const next = [...messages];
next[index] = updater(message);
return next;
}
}
return messages;
}; };
export const buildChatRouter = ( export const buildChatRouter = (
@@ -198,7 +124,14 @@ export const buildChatRouter = (
) => { ) => {
const chatRouter = Router(); const chatRouter = Router();
chatRouter.post("/session", async (req, res) => { chatRouter.get("/models", (_req, res) => {
res.json({
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
models: agentModelOptions,
});
});
chatRouter.post("/sessions", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {}); const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
@@ -208,8 +141,9 @@ export const buildChatRouter = (
return; return;
} }
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim(); const requestedSessionId = parsed.data.session_id?.trim();
@@ -235,8 +169,9 @@ export const buildChatRouter = (
}); });
chatRouter.get("/sessions", async (req, res) => { chatRouter.get("/sessions", async (req, res) => {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const records = await sessionMetadataStore.list({ const records = await sessionMetadataStore.list({
@@ -259,10 +194,11 @@ export const buildChatRouter = (
}); });
}); });
chatRouter.get("/session/:sessionId", async (req, res) => { chatRouter.get("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.session_id?.trim();
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId) { if (!sessionId) {
@@ -296,17 +232,17 @@ export const buildChatRouter = (
status: sessionRecord.status, status: sessionRecord.status,
session_id: sessionRecord.sessionId, session_id: sessionRecord.sessionId,
messages: state?.messages ?? [], messages: state?.messages ?? [],
branch_groups: state?.branchGroups ?? [],
parent_session_id: sessionRecord.parentSessionId, parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running", is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId), run_status: getSessionRunStatus(sessionRecord.sessionId),
}); });
}); });
chatRouter.get("/session/:sessionId/stream", async (req, res) => { chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.session_id?.trim();
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId) { if (!sessionId) {
@@ -367,82 +303,17 @@ export const buildChatRouter = (
res.on("close", cleanup); res.on("close", cleanup);
}); });
chatRouter.put("/session/:sessionId", async (req, res) => { chatRouter.patch("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.session_id?.trim();
const parsed = sessionStateSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const { record } = await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId,
userId,
});
const nextRecord = await sessionMetadataStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}),
});
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
sessionId: nextRecord.sessionId,
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
messages: parsed.data.messages,
branchGroups: parsed.data.branch_groups,
});
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext: {
actorKey,
clientSessionId: nextRecord.sessionId,
projectId,
projectKey,
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
userId,
},
sessionId: nextRecord.sessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: nextRecord.sessionId },
"post-save learning failed",
);
});
}
res.json({
id: nextRecord.sessionId,
title: nextRecord.title ?? "新对话",
created_at: nextRecord.createdAt,
updated_at: nextRecord.updatedAt,
status: nextRecord.status,
session_id: nextRecord.sessionId,
});
});
chatRouter.patch("/session/:sessionId/title", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const title = const title =
typeof req.body?.title === "string" ? req.body.title.trim() : ""; typeof req.body?.title === "string" ? req.body.title.trim() : "";
const isTitleManuallyEdited = const isTitleManuallyEdited =
typeof req.body?.is_title_manually_edited === "boolean" typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited ? req.body.is_title_manually_edited
: undefined; : undefined;
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId || !title) { if (!sessionId || !title) {
@@ -478,10 +349,11 @@ export const buildChatRouter = (
}); });
}); });
chatRouter.delete("/session/:sessionId", async (req, res) => { chatRouter.delete("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.session_id?.trim();
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
if (!sessionId) { if (!sessionId) {
@@ -507,51 +379,27 @@ export const buildChatRouter = (
res.status(204).end(); res.status(204).end();
}); });
chatRouter.get("/render-ref/:renderRef", async (req, res) => { registerChatAuxiliaryRoutes(chatRouter, {
const renderRef = req.params.renderRef?.trim(); activeRuns,
const userId = req.header("x-user-id")?.trim(); lastRunStatuses,
const projectId = req.header("x-project-id") ?? undefined; resultReferenceResolver,
const clientSessionId = sessionBridge,
typeof req.query.session_id === "string" sessionMetadataStore,
? req.query.session_id.trim() sessionUiStateStore,
: undefined;
if (!userId) {
res.status(400).json({
message: "x-user-id is required",
});
return;
}
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
});
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
if (!result) {
res.status(404).json({ message: "render_ref not found" });
return;
}
res.json(result);
}); });
chatRouter.post("/abort", async (req, res) => { registerChatInteractionRoutes(chatRouter, {
const parsed = abortPayloadSchema.safeParse(req.body); activeRuns,
runtime,
sessionMetadataStore,
sessionUiStateStore,
});
chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
const parsed = forkPayloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
@@ -561,95 +409,10 @@ export const buildChatRouter = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const authContext = getAgentAuthContext(req);
const userId = req.header("x-user-id") ?? undefined; const projectId = authContext.projectId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
branchGroups: currentState?.branchGroups ?? [],
});
}
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
}
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info(
{
clientSessionId: parsed.data.session_id,
sessionId: binding?.sessionId ?? parsed.data.session_id,
},
"aborted chat session by client request",
);
res.status(202).json({
session_id: parsed.data.session_id,
aborted: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat abort failed");
res.status(500).json({
message: "chat abort failed",
detail,
});
}
});
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 projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
@@ -665,6 +428,10 @@ export const buildChatRouter = (
sourceSessionId, sourceSessionId,
) )
: null; : null;
if (!sourceSessionId || !sourceSessionRecord) {
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession(); const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({ const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey, actorKey,
@@ -676,7 +443,6 @@ export const buildChatRouter = (
}); });
const nextSessionId = targetSessionRecord.sessionId; const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId && parsed.data.keep_message_count > 0) {
await sessionTranscriptStore.cloneThread( await sessionTranscriptStore.cloneThread(
{ {
actorKey, actorKey,
@@ -692,12 +458,23 @@ export const buildChatRouter = (
}, },
parsed.data.keep_message_count, parsed.data.keep_message_count,
); );
if (sourceSessionRecord?.title) { const sourceState = await sessionUiStateStore.read(
await sessionMetadataStore.touch(targetSessionRecord, { toSessionUiStateContext(sourceSessionRecord),
title: sourceSessionRecord.title, );
}); 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( logger.info(
{ {
@@ -723,8 +500,11 @@ export const buildChatRouter = (
} }
}); });
chatRouter.post("/stream", async (req, res) => { chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body); const parsed = payloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
@@ -734,13 +514,11 @@ export const buildChatRouter = (
} }
try { try {
const authHeader = req.header("authorization"); const authContext = getAgentAuthContext(req);
const accessToken = authHeader?.startsWith("Bearer ") const accessToken = authContext.accessToken;
? authHeader.slice("Bearer ".length) const projectId = authContext.projectId;
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim(); const requestedSessionId = parsed.data.session_id?.trim();
@@ -755,8 +533,10 @@ export const buildChatRouter = (
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
sessionId: requestedSessionId, sessionId: requestedSessionId,
accessToken, accessToken,
network: authContext.network,
projectId, projectId,
traceId, traceId,
tokenExpiresAt: authContext.tokenExpiresAt,
userId, userId,
}); });
const { record: ensuredSessionRecord, created: sessionCreated } = const { record: ensuredSessionRecord, created: sessionCreated } =
@@ -768,16 +548,24 @@ export const buildChatRouter = (
userId, userId,
}); });
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord); const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
const hasRuntimeConversation = hadExistingRuntimeSession
? await runtimeHasConversation(runtime, binding.sessionId)
: false;
const shouldRestoreConversation = shouldRestoreConversationForRuntime({
hadExistingSessionRecord: hadExistingRuntimeSession,
runtimeHasConversation: hasRuntimeConversation,
});
const historyContext = { const historyContext = {
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId, sessionId: requestContext.clientSessionId,
}; };
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
const initialSessionState = await sessionUiStateStore.read( const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord), toSessionUiStateContext(activeSessionRecord),
); );
const persistedMessages = initialSessionState?.messages ?? [];
const baseMessages = persistedMessages;
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") { if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
res.status(409).json({ res.status(409).json({
message: "session is already streaming", message: "session is already streaming",
@@ -785,6 +573,7 @@ export const buildChatRouter = (
}); });
return; return;
} }
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
logger.info( logger.info(
{ {
@@ -792,6 +581,7 @@ export const buildChatRouter = (
sessionId: binding.sessionId, sessionId: binding.sessionId,
created: created || sessionCreated, created: created || sessionCreated,
model: parsed.data.model, model: parsed.data.model,
approvalMode: parsed.data.approval_mode,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
}, },
@@ -810,14 +600,15 @@ export const buildChatRouter = (
const abortController = new AbortController(); const abortController = new AbortController();
sessionBridge.registerAbortController(clientSessionId, abortController); sessionBridge.registerAbortController(clientSessionId, abortController);
const initialMessages = createInitialStreamingMessages( const initialMessages = createInitialStreamingMessages(
initialSessionState?.messages ?? [], baseMessages,
parsed.data.message, parsed.data.message,
); );
const branchGroups = initialSessionState?.branchGroups ?? [];
const activeRun: ActiveRun = { const activeRun: ActiveRun = {
clientSessionId, clientSessionId,
controller: abortController, controller: abortController,
messages: initialMessages, messages: initialMessages,
pendingPermissions: new Map(),
pendingQuestions: new Map(),
status: "running", status: "running",
subscribers: new Set(), subscribers: new Set(),
}; };
@@ -828,14 +619,12 @@ export const buildChatRouter = (
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: initialMessages, messages: initialMessages,
branchGroups,
}); });
const queueSessionUiStatePersist = () => { const queueSessionUiStatePersist = () => {
const snapshot = { const snapshot = {
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: activeRun.messages, messages: activeRun.messages,
branchGroups,
}; };
persistQueue = persistQueue persistQueue = persistQueue
.catch((error) => { .catch((error) => {
@@ -902,6 +691,108 @@ export const buildChatRouter = (
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`, : `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "auth_required") {
activeRun.status = "error";
lastRunStatuses.set(clientSessionId, "error");
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof data.message === "string"
? `⚠️ **${data.message}**`
: "⚠️ **登录态已过期,请刷新登录后重试**",
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),
})); }));
} }
@@ -920,9 +811,9 @@ export const buildChatRouter = (
requestContext.projectKey, requestContext.projectKey,
{ {
recentTurns, recentTurns,
persistedMessages: initialSessionState?.messages, persistedMessages: baseMessages,
message: parsed.data.message, message: parsed.data.message,
restoreConversation: !hadExistingRuntimeSession, restoreConversation: shouldRestoreConversation,
}, },
); );
const streamResult = await streamPromptResponse({ const streamResult = await streamPromptResponse({
@@ -931,6 +822,7 @@ export const buildChatRouter = (
clientSessionId, clientSessionId,
message: preparedMessage, message: preparedMessage,
model: parsed.data.model, model: parsed.data.model,
approvalMode: parsed.data.approval_mode,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
signal: abortController.signal, signal: abortController.signal,
@@ -942,6 +834,16 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
}); });
const latestRuntimeContext = getRuntimeSessionContext(binding.sessionId);
if (latestRuntimeContext?.authExpired) {
publish("auth_required", {
session_id: clientSessionId,
reason: latestRuntimeContext.authExpired.reason,
message: latestRuntimeContext.authExpired.message,
});
return;
}
if (!streamResult.aborted && !streamResult.failed) { if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60); const messages = await runtime.messages(binding.sessionId, 60);
const assistantMessage = [...messages] const assistantMessage = [...messages]
@@ -989,8 +891,36 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); 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 { } finally {
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),
}));
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
});
}
await persistQueue.catch((error) => { await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
}); });
@@ -1007,7 +937,12 @@ export const buildChatRouter = (
subscriber.close(); subscriber.close();
} }
activeRun.subscribers.clear(); activeRun.subscribers.clear();
if (
activeRun.pendingPermissions.size === 0 &&
activeRun.pendingQuestions.size === 0
) {
activeRuns.delete(clientSessionId); activeRuns.delete(clientSessionId);
}
streamClosed = true; streamClosed = true;
req.off("close", handleClientClose); req.off("close", handleClientClose);
res.off("close", handleClientClose); res.off("close", handleClientClose);
@@ -1019,6 +954,16 @@ export const buildChatRouter = (
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed"); logger.error({ err: error }, "chat stream failed");
if (res.headersSent) {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse("error", {
message: "chat stream failed",
detail,
}));
res.end();
}
return;
}
res.status(500).json({ res.status(500).json({
message: "chat stream failed", message: "chat stream failed",
detail, detail,
+171
View File
@@ -0,0 +1,171 @@
import { type Router } from "express";
import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { logger } from "../logger.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
type RunStatus,
cancelBackendTodos,
completeBackendProgress,
updateLastAssistantMessage,
} from "./chatUiState.js";
const abortParamsSchema = z.object({
session_id: z.string().max(128),
});
type RegisterAuxiliaryRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
lastRunStatuses: Map<string, RunStatus>;
resultReferenceResolver: ResultReferenceResolver;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
export const registerChatAuxiliaryRoutes = (
chatRouter: Router,
{
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/render-references/:render_ref", async (req, res) => {
const renderRef = req.params.render_ref?.trim();
const authContext = getAgentAuthContext(req);
const userId = authContext.userId;
const projectId = authContext.projectId;
const clientSessionId =
typeof req.query.session_id === "string"
? req.query.session_id.trim()
: undefined;
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
});
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
if (!result) {
res.status(404).json({ message: "render_ref not found" });
return;
}
res.json(result);
});
chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
const parsed = abortParamsSchema.safeParse(req.params);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
}
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
}
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info(
{
clientSessionId: parsed.data.session_id,
sessionId: binding?.sessionId ?? parsed.data.session_id,
},
"aborted chat session by client request",
);
res.status(202).json({
session_id: parsed.data.session_id,
aborted: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat abort failed");
res.status(500).json({
message: "chat abort failed",
detail,
});
}
});
};
+309
View File
@@ -0,0 +1,309 @@
import { type Router } from "express";
import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
toPermissionStatus,
updateLastAssistantPermission,
updateLastAssistantQuestion,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
request_id: z.string().min(1),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
});
const questionReplyPayloadSchema = z.object({
request_id: z.string().min(1),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).default([]),
});
type RegisterInteractionRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
runtime: OpencodeRuntimeAdapter;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
export const registerChatInteractionRoutes = (
chatRouter: Router,
{
activeRuns,
runtime,
sessionMetadataStore,
sessionUiStateStore,
}: RegisterInteractionRoutesOptions,
) => {
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run || run.status !== "running") {
res.status(409).json({ message: "session is not waiting for permissions" });
return;
}
const pendingPermission = run.pendingPermissions.get(requestId);
if (!pendingPermission) {
res.status(404).json({ message: "permission request not found" });
return;
}
const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.replyPermission({
requestId,
sessionId: sessionRecord.sessionId,
reply: parsed.data.reply,
message: parsed.data.message,
});
} catch (error) {
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission error state",
);
});
res.status(502).json({
message: "permission reply failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingPermissions.delete(requestId);
const status = toPermissionStatus(parsed.data.reply);
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("permission_response", {
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
}
res.status(202).json({
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "permission reply route failed");
res.status(500).json({
message: "permission reply route failed",
detail,
});
}
});
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = run.pendingQuestions.get(requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
if (parsed.data.action === "reject") {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} else {
await runtime.replyQuestion({
requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
}
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: `failed to ${parsed.data.action} question`,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: `question ${parsed.data.action} failed`,
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: parsed.data.action === "reject" ? "rejected" : "answered",
answers:
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question response route failed");
res.status(500).json({
message: "question response route failed",
detail,
});
}
});
};
+5
View File
@@ -212,6 +212,11 @@ export const buildPromptWithLearningContext = async (
.join("\n\n"); .join("\n\n");
}; };
export const shouldRestoreConversationForRuntime = (options: {
hadExistingSessionRecord: boolean;
runtimeHasConversation: boolean;
}) => !options.hadExistingSessionRecord || !options.runtimeHasConversation;
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => { const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
const formattedTurns = recentTurns const formattedTurns = recentTurns
.slice(-RESTORE_TURN_LIMIT) .slice(-RESTORE_TURN_LIMIT)
+363 -231
View File
@@ -1,15 +1,61 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import {
type PermissionReply,
type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import {
buildPermissionDetail,
buildPermissionV2Detail,
buildReasoningProgressDetail,
buildSessionStatusDetail,
buildToolProgressDetail,
collectTextContent,
extractRequestReason,
extractSkillAuditInfo,
getErrorMessage,
getToolProgressTitle,
getUnknownErrorMessage,
hasToolParams,
isPermissionAskedEvent,
isPermissionRepliedEvent,
isPermissionV2AskedEvent,
isPermissionV2RepliedEvent,
isQuestionAskedEvent,
isObjectRecord,
isQuestionRejectedEvent,
isQuestionRepliedEvent,
isQuestionV2AskedEvent,
isQuestionV2RejectedEvent,
isQuestionV2RepliedEvent,
isSessionEvent,
isSkillEvent,
logDevelopmentDebug,
normalizeQuestionAnswers,
normalizeQuestionPayload,
normalizeQuestionToolPayload,
normalizeTodoPriority,
normalizeTodoStatus,
normalizeToolParams,
normalizeToolStatus,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
export const supportedModels = [ export {
"deepseek/deepseek-v4-flash", collectTextContent,
"deepseek/deepseek-v4-pro", type PermissionRequestPayload,
] as const; type QuestionRequestPayload,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
export type SupportedModel = (typeof supportedModels)[number]; export type ApprovalMode = "request" | "always";
type StreamPromptOptions = { type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
@@ -17,6 +63,7 @@ type StreamPromptOptions = {
clientSessionId: string; clientSessionId: string;
message: string; message: string;
model?: SupportedModel; model?: SupportedModel;
approvalMode?: ApprovalMode;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
signal?: AbortSignal; signal?: AbortSignal;
@@ -33,110 +80,19 @@ type ProgressPayload = {
detail?: string; detail?: string;
}; };
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const getPermissionTarget = (metadata: unknown) => {
if (!isObjectRecord(metadata)) {
const toolLabels: Record<string, string> = { return undefined;
memory_manager: "记忆写入", }
session_search: "历史会话检索", for (const key of ["command", "path", "file", "filepath", "directory"]) {
skill_manager: "流程沉淀", const value = metadata[key];
locate_features: "地图定位", if (typeof value === "string" && value.trim()) {
view_history: "历史数据面板", return value.trim();
view_scada: "SCADA 面板", }
show_chart: "图表渲染", }
render_junctions: "节点渲染", return undefined;
}; };
const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
if (!isDevelopmentDebugLoggingEnabled) {
return;
}
logger.info(metadata, message);
};
const getErrorMessage = (error: {
name: string;
data?: { message?: string };
}) => error.data?.message ?? error.name;
const getUnknownErrorMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
typeof error.name === "string"
) {
const maybeData = "data" in error ? error.data : undefined;
return getErrorMessage({
name: error.name,
data:
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
: undefined,
});
}
return error instanceof Error ? error.message : String(error);
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
};
const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill");
const extractSkillAuditInfo = (event: OpencodeEvent) => {
const payload = isObjectRecord(event.properties)
? (event.properties as Record<string, unknown>)
: {};
const candidateName =
typeof payload.skill === "string"
? payload.skill
: typeof payload.skillName === "string"
? payload.skillName
: typeof payload.name === "string"
? payload.name
: event.type;
const reason = extractRequestReason(payload);
return {
name: candidateName,
reason,
payload,
};
};
const hasToolParams = (params: Record<string, unknown>) =>
Object.keys(params).length > 0;
const toRuntimeModel = (model?: SupportedModel) => { const toRuntimeModel = (model?: SupportedModel) => {
if (!model) { if (!model) {
return undefined; return undefined;
@@ -151,19 +107,6 @@ const toRuntimeModel = (model?: SupportedModel) => {
}; };
}; };
const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
"properties" in event &&
typeof event.properties === "object" &&
event.properties !== null &&
"sessionID" in event.properties &&
event.properties.sessionID === sessionId;
export const collectTextContent = (parts: Part[]) =>
parts
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("");
const emitFallbackMessage = async ( const emitFallbackMessage = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
sessionId: string, sessionId: string,
@@ -184,117 +127,13 @@ const emitFallbackMessage = async (
} }
}; };
const normalizeToolStatus = (status: string) => {
if (status === "completed") return "completed";
if (status === "error") return "error";
return "running";
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const normalizeProgressText = (chunks: string[]) => chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
const buildReasoningProgressDetail = (chunks: string[], ended?: string | number | Date | null) => {
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
export const streamPromptResponse = async ({ export const streamPromptResponse = async ({
runtime, runtime,
sessionId, sessionId,
clientSessionId, clientSessionId,
message, message,
model, model,
approvalMode = "request",
traceId, traceId,
projectId, projectId,
signal, signal,
@@ -311,6 +150,8 @@ export const streamPromptResponse = async ({
const progressStartedAtMap = new Map<string, number>(); const progressStartedAtMap = new Map<string, number>();
const finalizedProgressIds = new Set<string>(); const finalizedProgressIds = new Set<string>();
const emittedToolParts = new Set<string>(); const emittedToolParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>();
const partTypes = new Map<string, Part["type"]>(); const partTypes = new Map<string, Part["type"]>();
const pendingPartTextDeltas = new Map<string, string[]>(); const pendingPartTextDeltas = new Map<string, string[]>();
const reasoningDeltas = new Map<string, string[]>(); const reasoningDeltas = new Map<string, string[]>();
@@ -323,6 +164,7 @@ export const streamPromptResponse = async ({
let firstToolEventLogged = false; let firstToolEventLogged = false;
let lastSessionStatus: string | null = null; let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null; let lastSessionStatusMessage: string | null = null;
let sawResponseActivity = false;
let emittedText = false; let emittedText = false;
let toolCallCount = 0; let toolCallCount = 0;
let done = false; let done = false;
@@ -528,7 +370,230 @@ export const streamPromptResponse = async ({
}); });
} }
if (isPermissionAskedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission request received", {
...debugContext,
requestId: event.properties.id,
permission: event.properties.permission,
patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
});
if (approvalMode === "always") {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
});
continue;
}
write("permission_request", {
session_id: clientSessionId,
request_id: event.properties.id,
permission: event.properties.permission,
patterns: event.properties.patterns,
target: getPermissionTarget(event.properties.metadata),
always: event.properties.always,
tool: event.properties.tool,
created_at: Date.now(),
} satisfies PermissionRequestPayload);
continue;
}
if (isPermissionV2AskedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission v2 request received", {
...debugContext,
requestId: event.properties.id,
action: event.properties.action,
resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
});
continue;
}
write("permission_request", {
session_id: clientSessionId,
request_id: event.properties.id,
permission: event.properties.action,
patterns: event.properties.resources,
target: getPermissionTarget(event.properties.metadata),
always: event.properties.save ?? [],
tool: undefined,
created_at: Date.now(),
} satisfies PermissionRequestPayload);
continue;
}
if (isPermissionRepliedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission request replied", {
...debugContext,
requestId: event.properties.requestID,
reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
reply: event.properties.reply satisfies PermissionReply,
});
continue;
}
if (isPermissionV2RepliedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission v2 request replied", {
...debugContext,
requestId: event.properties.requestID,
reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
reply: event.properties.reply satisfies PermissionReply,
});
continue;
}
if (isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("question request received", {
...debugContext,
requestId: event.properties.id,
questionCount: event.properties.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: event.properties.questions
.map((question) => question.question)
.join("\n"),
});
const payload = normalizeQuestionPayload(event, clientSessionId);
emittedQuestionRequestIds.add(payload.request_id);
write("question_request", payload);
continue;
}
if (isQuestionRepliedEvent(event) || isQuestionV2RepliedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("question request replied", {
...debugContext,
requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已收到补充信息",
detail: normalizeQuestionAnswers(event.properties.answers)
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("\n"),
});
write("question_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
answers: normalizeQuestionAnswers(event.properties.answers),
});
continue;
}
if (isQuestionRejectedEvent(event) || isQuestionV2RejectedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("question request rejected", {
...debugContext,
requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已跳过补充信息",
detail: "用户选择跳过本次补充信息。",
});
write("question_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
rejected: true,
});
continue;
}
if (isSkillEvent(event)) { if (isSkillEvent(event)) {
sawResponseActivity = true;
const { name, reason, payload } = extractSkillAuditInfo(event); const { name, reason, payload } = extractSkillAuditInfo(event);
logDevelopmentDebug("skill event received", { logDevelopmentDebug("skill event received", {
...debugContext, ...debugContext,
@@ -552,7 +617,15 @@ export const streamPromptResponse = async ({
}); });
} }
if (event.type === "message.updated") {
if (event.properties.info.role === "assistant") {
sawResponseActivity = true;
}
continue;
}
if (event.type === "message.part.delta" && event.properties.field === "text") { if (event.type === "message.part.delta" && event.properties.field === "text") {
sawResponseActivity = true;
const partType = partTypes.get(event.properties.partID); const partType = partTypes.get(event.properties.partID);
if (partType === "text") { if (partType === "text") {
if (!firstTokenLogged) { if (!firstTokenLogged) {
@@ -591,6 +664,7 @@ export const streamPromptResponse = async ({
} }
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
sawResponseActivity = true;
const part = event.properties.part; const part = event.properties.part;
partTypes.set(part.id, part.type); partTypes.set(part.id, part.type);
if (part.type === "text") { if (part.type === "text") {
@@ -666,6 +740,36 @@ export const streamPromptResponse = async ({
}); });
} }
const questionToolPayload = normalizeQuestionToolPayload(
part,
toolParams,
clientSessionId,
);
if (questionToolPayload) {
if (!emittedQuestionToolParts.has(part.id)) {
emittedQuestionToolParts.add(part.id);
emittedQuestionRequestIds.add(questionToolPayload.request_id);
logDevelopmentDebug("question tool request received", {
...debugContext,
requestId: questionToolPayload.request_id,
tool: part.tool,
questionCount: questionToolPayload.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${questionToolPayload.request_id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: questionToolPayload.questions
.map((question) => question.question)
.join("\n"),
});
write("question_request", questionToolPayload);
}
continue;
}
emitProgress({ emitProgress({
id: part.id, id: part.id,
phase: "tool", phase: "tool",
@@ -720,22 +824,41 @@ export const streamPromptResponse = async ({
} }
if (event.type === "todo.updated") { if (event.type === "todo.updated") {
const completed = event.properties.todos.filter( sawResponseActivity = true;
const todos = event.properties.todos as Array<{
content: string;
status: string;
priority: string;
}>;
const normalizedTodos = todos.map((todo, index) => ({
id: `todo-${index}-${todo.content.slice(0, 24)}`,
content: todo.content,
status: normalizeTodoStatus(todo.status),
priority: normalizeTodoPriority(todo.priority),
updated_at: Date.now(),
}));
const completed = todos.filter(
(todo) => todo.status === "completed", (todo) => todo.status === "completed",
).length; ).length;
emitProgress({ emitProgress({
id: "todo-progress", id: "todo-progress",
phase: "planning", phase: "planning",
status: completed === event.properties.todos.length ? "completed" : "running", status: completed === todos.length ? "completed" : "running",
title: `计划进度 ${completed}/${event.properties.todos.length}`, title: `计划进度 ${completed}/${todos.length}`,
detail: event.properties.todos detail: todos
.map((todo) => `${todo.status}: ${todo.content}`) .map((todo) => `${todo.status}: ${todo.content}`)
.join("\n"), .join("\n"),
}); });
write("todo_update", {
session_id: clientSessionId,
todos: normalizedTodos,
created_at: Date.now(),
} satisfies TodoUpdatePayload);
continue; continue;
} }
if (event.type === "session.error") { if (event.type === "session.error") {
sawResponseActivity = true;
logDevelopmentDebug("session error received", { logDevelopmentDebug("session error received", {
...debugContext, ...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
@@ -757,6 +880,13 @@ export const streamPromptResponse = async ({
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
if (!sawResponseActivity) {
logDevelopmentDebug("ignoring session idle before response activity", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
continue;
}
logDevelopmentDebug("session idle received", { logDevelopmentDebug("session idle received", {
...debugContext, ...debugContext,
emittedText, emittedText,
@@ -832,8 +962,10 @@ export const streamPromptResponse = async ({
return { aborted: false, failed: false, toolCallCount }; return { aborted: false, failed: false, toolCallCount };
} finally { } finally {
await iterator.return?.(undefined); await iterator.return?.(undefined);
if (!promptSettled) { if (!promptSettled && !aborted) {
await promptPromise.catch(() => undefined); await promptPromise.catch(() => undefined);
} else if (!promptSettled) {
void promptPromise.catch(() => undefined);
} }
logDevelopmentDebug("chat stream cleanup finished", { logDevelopmentDebug("chat stream cleanup finished", {
...debugContext, ...debugContext,
+460
View File
@@ -0,0 +1,460 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { logger } from "../logger.js";
import { type QuestionAnswers } from "../runtime/opencode.js";
export type PermissionRequestPayload = {
session_id: string;
request_id: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
created_at: number;
};
type QuestionOptionPayload = {
label: string;
description: string;
};
type QuestionInfoPayload = {
header: string;
question: string;
options: QuestionOptionPayload[];
multiple?: boolean;
custom?: boolean;
};
export type QuestionRequestPayload = {
session_id: string;
request_id: string;
questions: QuestionInfoPayload[];
tool?: {
messageID: string;
callID: string;
};
created_at: number;
};
export type TodoItemPayload = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
created_at?: number;
updated_at?: number;
};
export type TodoUpdatePayload = {
session_id: string;
message_id?: string;
todos: TodoItemPayload[];
created_at: number;
};
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
memory_manager: "记忆写入",
geocode: "地理编码",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
web_search: "网页搜索",
locate_features: "地图定位",
zoom_to_map: "地图缩放",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
};
export const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
if (!isDevelopmentDebugLoggingEnabled) {
return;
}
logger.info(metadata, message);
};
export const getErrorMessage = (error: {
name: string;
data?: { message?: string };
}) => error.data?.message ?? error.name;
export const getUnknownErrorMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
typeof error.name === "string"
) {
const maybeData = "data" in error ? error.data : undefined;
return getErrorMessage({
name: error.name,
data:
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
: undefined,
});
}
return error instanceof Error ? error.message : String(error);
};
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
};
export const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
export const isSkillEvent = (event: OpencodeEvent) =>
event.type.toLowerCase().includes("skill");
export const extractSkillAuditInfo = (event: OpencodeEvent) => {
const payload = isObjectRecord(event.properties)
? (event.properties as Record<string, unknown>)
: {};
const candidateName =
typeof payload.skill === "string"
? payload.skill
: typeof payload.skillName === "string"
? payload.skillName
: typeof payload.name === "string"
? payload.name
: event.type;
const reason = extractRequestReason(payload);
return {
name: candidateName,
reason,
payload,
};
};
export const hasToolParams = (params: Record<string, unknown>) =>
Object.keys(params).length > 0;
export const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
"properties" in event &&
typeof event.properties === "object" &&
event.properties !== null &&
"sessionID" in event.properties &&
event.properties.sessionID === sessionId;
export const isPermissionAskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
event.type === "permission.asked";
export const isPermissionV2AskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
event.type === "permission.v2.asked";
export const isPermissionRepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
event.type === "permission.replied";
export const isPermissionV2RepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
event.type === "permission.v2.replied";
export const isQuestionAskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.asked" }> =>
event.type === "question.asked";
export const isQuestionV2AskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.asked" }> =>
event.type === "question.v2.asked";
export const isQuestionRepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.replied" }> =>
event.type === "question.replied";
export const isQuestionV2RepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.replied" }> =>
event.type === "question.v2.replied";
export const isQuestionRejectedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.rejected" }> =>
event.type === "question.rejected";
export const isQuestionV2RejectedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
event.type === "question.v2.rejected";
export const buildPermissionDetail = (
event: Extract<OpencodeEvent, { type: "permission.asked" }>,
) => {
const patterns = event.properties.patterns.length
? event.properties.patterns.join(", ")
: event.properties.permission;
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
};
export const buildPermissionV2Detail = (
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
) => {
const resources = event.properties.resources.length
? event.properties.resources.join(", ")
: event.properties.action;
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
};
export const normalizeQuestionPayload = (
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
clientSessionId: string,
): QuestionRequestPayload => ({
session_id: clientSessionId,
request_id: event.properties.id,
questions: event.properties.questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple,
custom: question.custom,
})),
tool: event.properties.tool,
created_at: Date.now(),
});
export const normalizeQuestionAnswers = (answers: QuestionAnswers | undefined) =>
Array.isArray(answers)
? answers.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
)
: [];
const questionToolNames = new Set(["question", "request_user_input"]);
const normalizeQuestionOptions = (value: unknown): QuestionOptionPayload[] =>
Array.isArray(value)
? value.filter(isObjectRecord).map((option) => ({
label: typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string" ? option.description : "",
})).filter((option) => option.label.trim().length > 0)
: [];
const normalizeToolQuestionInfo = (value: unknown): QuestionInfoPayload | undefined => {
if (!isObjectRecord(value) || typeof value.question !== "string") {
return undefined;
}
const question = value.question.trim();
if (!question) {
return undefined;
}
return {
header:
typeof value.header === "string" && value.header.trim()
? value.header
: "补充信息",
question,
options: normalizeQuestionOptions(value.options),
multiple: typeof value.multiple === "boolean" ? value.multiple : undefined,
custom: typeof value.custom === "boolean" ? value.custom : undefined,
};
};
export const normalizeQuestionToolPayload = (
part: Extract<Part, { type: "tool" }>,
params: Record<string, unknown>,
clientSessionId: string,
): QuestionRequestPayload | undefined => {
if (!questionToolNames.has(part.tool)) {
return undefined;
}
const questions = Array.isArray(params.questions)
? params.questions
.map(normalizeToolQuestionInfo)
.filter((question): question is QuestionInfoPayload => Boolean(question))
: [];
if (questions.length === 0) {
return undefined;
}
return {
session_id: clientSessionId,
request_id: part.callID || part.id,
questions,
tool: {
messageID: part.messageID,
callID: part.callID,
},
created_at: Date.now(),
};
};
export const normalizeTodoStatus = (status: string): TodoItemPayload["status"] => {
if (status === "in_progress" || status === "completed" || status === "cancelled") {
return status;
}
return "pending";
};
export const normalizeTodoPriority = (
priority: string,
): TodoItemPayload["priority"] | undefined => {
if (priority === "low" || priority === "medium" || priority === "high") {
return priority;
}
return undefined;
};
export const collectTextContent = (parts: Part[]) =>
parts
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("");
export const normalizeToolStatus = (status: string) => {
if (status === "completed") return "completed";
if (status === "error") return "error";
return "running";
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const normalizeProgressText = (chunks: string[]) =>
chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
export const buildReasoningProgressDetail = (
chunks: string[],
ended?: string | number | Date | null,
) => {
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
export const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
+350
View File
@@ -0,0 +1,350 @@
import { type PermissionReply } from "../runtime/opencode.js";
import {
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoUpdatePayload,
} from "./chatStream.js";
export type RunStatus = "running" | "completed" | "error" | "aborted";
export type StreamSubscriber = {
write: (event: string, data: Record<string, unknown>) => void;
close: () => void;
};
export type ActiveRun = {
clientSessionId: string;
controller: AbortController;
messages: unknown[];
pendingPermissions: Map<string, PermissionRequestPayload>;
pendingQuestions: Map<string, QuestionRequestPayload>;
status: RunStatus;
subscribers: Set<StreamSubscriber>;
};
type ToolArtifactKind = "chart" | "map" | "panel" | "tool";
type ToolCallPayload = {
session_id?: string;
tool?: string;
params?: unknown;
reason?: string;
};
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const createFrontendMessageId = () =>
`msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
export const createInitialStreamingMessages = (
existingMessages: unknown[],
userContent: string,
) => {
const userMessage = {
id: createFrontendMessageId(),
role: "user",
content: userContent,
};
return [
...existingMessages,
{
...userMessage,
branchRootId: userMessage.id,
},
{
id: createFrontendMessageId(),
role: "assistant",
content: "",
progress: [
{
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
startedAt: Date.now(),
elapsedMs: 0,
elapsedSnapshotAt: Date.now(),
},
],
},
];
};
export const upsertBackendProgress = (
progress: unknown,
payload: Record<string, unknown>,
) => {
const next = Array.isArray(progress) ? [...progress] : [];
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
const nextItem = {
id,
phase: typeof payload.phase === "string" ? payload.phase : "progress",
status:
payload.status === "completed" || payload.status === "error"
? payload.status
: "running",
title: typeof payload.title === "string" ? payload.title : "正在处理",
detail: typeof payload.detail === "string" ? payload.detail : undefined,
startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined,
endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined,
elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined,
elapsedSnapshotAt:
typeof payload.elapsed_ms === "number" ? Date.now() : undefined,
durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
export const completeBackendProgress = (progress: unknown) =>
Array.isArray(progress)
? progress.map((item) => {
if (!isObjectRecord(item) || item.status !== "running") {
return item;
}
const endedAt = Date.now();
const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined;
return {
...item,
status: "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
typeof item.durationMs === "number"
? item.durationMs
: startedAt !== undefined
? Math.max(0, endedAt - startedAt)
: item.elapsedMs,
};
})
: progress;
export const cancelBackendTodos = (todos: unknown) =>
Array.isArray(todos)
? todos.map((todoUpdate) => {
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
return todoUpdate;
}
return {
...todoUpdate,
todos: todoUpdate.todos.map((todo) => {
if (!isObjectRecord(todo)) {
return todo;
}
if (todo.status !== "pending" && todo.status !== "in_progress") {
return todo;
}
return {
...todo,
status: "cancelled",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const updateLastAssistantMessage = (
messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>,
) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (isObjectRecord(message) && message.role === "assistant") {
const next = [...messages];
next[index] = updater(message);
return next;
}
}
return messages;
};
export const updateLastAssistantPermission = (
messages: unknown[],
requestId: string,
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const permissions = Array.isArray(message.permissions)
? message.permissions
: [];
return {
...message,
permissions: permissions.map((permission) =>
isObjectRecord(permission) && permission.requestId === requestId
? updater(permission)
: permission,
),
};
});
export const updateLastAssistantQuestion = (
messages: unknown[],
requestId: string,
updater: (question: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const questions = Array.isArray(message.questions)
? message.questions
: [];
return {
...message,
questions: questions.map((question) =>
isObjectRecord(question) && question.requestId === requestId
? updater(question)
: question,
),
};
});
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "show_chart" || tool === "chart") return "chart";
if (
tool === "locate_features" ||
tool === "zoom_to_map" ||
tool === "render_junctions" ||
tool === "apply_layer_style" ||
tool.startsWith("locate_")
) {
return "map";
}
if (tool === "view_history" || tool === "view_scada") return "panel";
return "tool";
};
const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) => {
if (typeof params.title === "string" && params.title.trim()) {
return params.title.trim();
}
if (tool === "show_chart" || tool === "chart") return "生成图表";
if (tool === "zoom_to_map") return "缩放到地图坐标";
if (tool === "render_junctions") return "渲染节点分区";
if (tool === "view_history") return "打开计算结果曲线";
if (tool === "view_scada") return "打开 SCADA 数据面板";
if (tool === "apply_layer_style") return "应用图层样式";
if (tool === "locate_features" || tool.startsWith("locate_")) return "地图定位";
return tool || "工具调用";
};
export const appendBackendToolArtifact = (
artifacts: unknown,
payload: ToolCallPayload,
) => {
const tool = typeof payload.tool === "string" ? payload.tool.trim() : "";
if (!tool) {
return artifacts;
}
const params = isObjectRecord(payload.params) ? payload.params : {};
const next = Array.isArray(artifacts) ? [...artifacts] : [];
next.push({
id: `${tool}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
tool,
kind: getToolArtifactKind(tool),
title: getToolArtifactTitle(tool, params),
description:
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: undefined,
params,
});
return next;
};
export const toFrontendPermission = (
payload: PermissionRequestPayload,
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
) => ({
requestId: payload.request_id,
sessionId: payload.session_id,
permission: payload.permission,
patterns: payload.patterns,
target: payload.target,
always: payload.always,
tool: payload.tool,
createdAt: payload.created_at,
status,
});
const toFrontendQuestion = (
payload: QuestionRequestPayload,
status: "pending" | "submitting" | "answered" | "rejected" | "error" = "pending",
) => ({
requestId: payload.request_id,
sessionId: payload.session_id,
questions: payload.questions,
tool: payload.tool,
createdAt: payload.created_at,
status,
});
export const toPermissionStatus = (reply: PermissionReply) => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
export const upsertBackendQuestion = (
questions: unknown,
payload: QuestionRequestPayload,
) => {
const next = Array.isArray(questions) ? [...questions] : [];
const index = next.findIndex((item) => {
if (!isObjectRecord(item)) return false;
if (item.requestId === payload.request_id) return true;
const tool = isObjectRecord(item.tool) ? item.tool : undefined;
return Boolean(
payload.tool?.callID &&
tool?.callID === payload.tool.callID,
);
});
const nextItem = toFrontendQuestion(payload);
if (index >= 0) {
const current = next[index];
const currentRequestId = isObjectRecord(current) ? current.requestId : undefined;
const currentTool = isObjectRecord(current) && isObjectRecord(current.tool)
? current.tool
: undefined;
const currentIsActionable =
typeof currentRequestId === "string" &&
currentRequestId !== currentTool?.callID;
const payloadIsToolPlaceholder =
Boolean(payload.tool?.callID) && payload.request_id === payload.tool?.callID;
next[index] = {
...(isObjectRecord(current) ? current : {}),
...(currentIsActionable && payloadIsToolPlaceholder
? {
questions: nextItem.questions,
tool: nextItem.tool,
createdAt: nextItem.createdAt,
}
: nextItem),
status:
isObjectRecord(current) && current.status === "submitting"
? "submitting"
: nextItem.status,
};
} else {
next.push(nextItem);
}
return next;
};
export const upsertBackendTodoUpdate = (
_todos: unknown,
payload: TodoUpdatePayload,
) => [
{
sessionId: payload.session_id,
messageId: payload.message_id,
todos: payload.todos,
createdAt: payload.created_at,
},
];
+55
View File
@@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
import { type Request, type Response, Router } from "express";
const problemDetails = (req: Request, status: number, body: unknown) => {
const record =
typeof body === "object" && body !== null
? (body as Record<string, unknown>)
: {};
const detail =
typeof record.detail === "string"
? record.detail
: typeof record.message === "string"
? record.message
: `Request failed with status ${status}`;
const codeByStatus: Record<number, string> = {
400: "invalid_request",
401: "unauthenticated",
403: "forbidden",
404: "not_found",
409: "conflict",
422: "validation_error",
503: "dependency_unavailable",
};
const code = codeByStatus[status] ?? "request_error";
return {
type: `https://tjwater.example/problems/${code.replaceAll("_", "-")}`,
title: code.replaceAll("_", " "),
status,
detail,
instance: req.originalUrl.split("?")[0],
code,
trace_id: req.header("x-request-id") ?? randomUUID(),
errors: record.detail && typeof record.detail === "object" ? [record.detail] : [],
};
};
export const buildAgentPublicRouter = (chatRouter: Router) => {
const router = Router();
router.use((req, res, next) => {
const json = res.json.bind(res);
res.json = ((body: unknown) => {
if (res.statusCode >= 400) {
res.type("application/problem+json");
return json(problemDetails(req, res.statusCode, body));
}
return json(body);
}) as Response["json"];
next();
});
router.use(chatRouter);
return router;
};
+248 -4
View File
@@ -3,6 +3,8 @@ import {
createOpencodeClient, createOpencodeClient,
type OpencodeClient, type OpencodeClient,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
@@ -29,13 +31,29 @@ type RuntimeModelOverride = {
modelID: string; modelID: string;
}; };
export type PermissionReply = "once" | "always" | "reject";
export type QuestionAnswers = string[][];
type RuntimeMessage = {
info: {
id: string;
role: string;
};
};
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
export class OpencodeRuntimeAdapter { export class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null; private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null; private closeServer: (() => void) | null = null;
async ensureClient(): Promise<OpencodeClient> { async ensureClient(): Promise<OpencodeClient> {
if (!this.clientPromise) { if (!this.clientPromise) {
this.clientPromise = this.bootstrapClient(); this.clientPromise = this.bootstrapClient().catch((error) => {
this.clientPromise = null;
throw error;
});
} }
return this.clientPromise; return this.clientPromise;
} }
@@ -95,6 +113,63 @@ export class OpencodeRuntimeAdapter {
return requireData(messages.data, "session.messages"); return requireData(messages.data, "session.messages");
} }
async revertMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
const response = await client.session.revert({
sessionID: sessionId,
messageID: messageId,
});
return response.data;
}
async removeMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
const response = await client.session.deleteMessage({
sessionID: sessionId,
messageID: messageId,
});
return response.data;
}
async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) {
const messages = await this.messages(sessionId, 80);
const userMessages = messages.filter(
(message) => getRuntimeMessageRole(message) === "user",
);
const targetUserMessage = userMessages[options.userOrdinal - 1];
if (!targetUserMessage) {
if (messages.length === 0 && options.userOrdinal === 1) {
logger.warn(
{ sessionId, userOrdinal: options.userOrdinal },
"skipping opencode revert because runtime session has no messages",
);
return;
}
throw new Error("target user message not found to revert");
}
const targetMessageId = getRuntimeMessageId(targetUserMessage);
const targetIndex = messages.findIndex(
(message) => getRuntimeMessageId(message) === targetMessageId,
);
const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage];
await this.revertMessage(sessionId, targetMessageId);
for (const message of messagesToRemove.reverse()) {
const messageId = getRuntimeMessageId(message);
try {
await this.removeMessage(sessionId, messageId);
} catch (error) {
logger.warn(
{ err: error, sessionId, messageId },
"failed to remove reverted opencode message",
);
}
}
}
async abortSession(sessionId: string) { async abortSession(sessionId: string) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.session.abort({ const response = await client.session.abort({
@@ -129,6 +204,124 @@ export class OpencodeRuntimeAdapter {
return response.stream; return response.stream;
} }
async replyPermission(options: {
requestId: string;
sessionId?: string;
reply: PermissionReply;
message?: string;
}) {
const client = await this.ensureClient();
if ("permission" in client && client.permission?.reply) {
const response = await client.permission.reply({
requestID: options.requestId,
reply: options.reply,
message: options.message,
});
return response.data;
}
if ("permission" in client && client.permission?.respond && options.sessionId) {
const response = await client.permission.respond({
sessionID: options.sessionId,
permissionID: options.requestId,
response: options.reply,
});
return response.data;
}
throw new Error("opencode permission reply API is unavailable");
}
async replyQuestion(options: {
requestId: string;
sessionId?: string;
answers: QuestionAnswers;
}) {
const client = await this.ensureClient();
if ("question" in client && client.question?.reply) {
try {
const response = await client.question.reply({
requestID: options.requestId,
answers: options.answers,
});
return response.data;
} catch (error) {
if (!options.sessionId) {
throw error;
}
}
}
const v2Question = (client as unknown as {
v2?: {
session?: {
question?: {
reply?: (parameters: {
sessionID: string;
requestID: string;
questionV2Reply: { answers: QuestionAnswers };
}) => Promise<{ data: unknown }>;
};
};
};
}).v2?.session?.question;
if (v2Question?.reply && options.sessionId) {
const response = await v2Question.reply({
sessionID: options.sessionId,
requestID: options.requestId,
questionV2Reply: {
answers: options.answers,
},
});
return response.data;
}
throw new Error("opencode question reply API is unavailable");
}
async rejectQuestion(options: {
requestId: string;
sessionId?: string;
}) {
const client = await this.ensureClient();
if ("question" in client && client.question?.reject) {
try {
const response = await client.question.reject({
requestID: options.requestId,
});
return response.data;
} catch (error) {
if (!options.sessionId) {
throw error;
}
}
}
const v2Question = (client as unknown as {
v2?: {
session?: {
question?: {
reject?: (parameters: {
sessionID: string;
requestID: string;
}) => Promise<{ data: unknown }>;
};
};
};
}).v2?.session?.question;
if (v2Question?.reject && options.sessionId) {
const response = await v2Question.reject({
sessionID: options.sessionId,
requestID: options.requestId,
});
return response.data;
}
throw new Error("opencode question reject API is unavailable");
}
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.closeServer?.(); this.closeServer?.();
this.closeServer = null; this.closeServer = null;
@@ -174,9 +367,7 @@ export class OpencodeRuntimeAdapter {
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port: config.OPENCODE_PORT,
timeout: config.OPENCODE_TIMEOUT_MS, timeout: config.OPENCODE_TIMEOUT_MS,
config: { config: buildOpencodeConfig(),
model: config.OPENCODE_MODEL,
},
}); });
} catch (error) { } catch (error) {
if (isMissingOpencodeCli(error)) { if (isMissingOpencodeCli(error)) {
@@ -207,6 +398,59 @@ export class OpencodeRuntimeAdapter {
export const opencodeRuntime = new OpencodeRuntimeAdapter(); export const opencodeRuntime = new OpencodeRuntimeAdapter();
function buildOpencodeConfig(): Record<string, unknown> {
return deepMerge(
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
{
model: config.OPENCODE_MODEL,
},
);
}
function readProjectOpencodeConfig(): Record<string, unknown> {
const path = resolve(process.cwd(), "opencode.json");
if (!existsSync(path)) {
return {};
}
return parseConfigJson(readFileSync(path, "utf8"), path);
}
function readEnvOpencodeConfig(): Record<string, unknown> {
const content = process.env.OPENCODE_CONFIG_CONTENT;
if (!content?.trim()) {
return {};
}
return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
}
function parseConfigJson(content: string, source: string): Record<string, unknown> {
const parsed = JSON.parse(content) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${source} must contain a JSON object`);
}
return parsed as Record<string, unknown>;
}
function deepMerge(
left: Record<string, unknown>,
right: Record<string, unknown>,
): Record<string, unknown> {
const next = { ...left };
for (const [key, value] of Object.entries(right)) {
const existing = next[key];
if (isPlainObject(existing) && isPlainObject(value)) {
next[key] = deepMerge(existing, value);
} else {
next[key] = value;
}
}
return next;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException { function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
return ( return (
typeof error === "object" && typeof error === "object" &&
+23
View File
@@ -1,13 +1,19 @@
export type RuntimeSessionContext = { export type RuntimeSessionContext = {
accessToken?: string; accessToken?: string;
actorKey: string; actorKey: string;
authExpired?: {
message: string;
reason: "access_token_expired" | "access_token_rejected";
};
allowLearningWrite?: boolean; allowLearningWrite?: boolean;
clientSessionId: string; clientSessionId: string;
learningMode?: "interactive" | "review"; learningMode?: "interactive" | "review";
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>; memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
network?: string;
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
tokenExpiresAt?: string;
traceId: string; traceId: string;
}; };
@@ -22,6 +28,23 @@ export const getRuntimeSessionContext = (sessionId: string) => {
return context ? { ...context } : null; return context ? { ...context } : null;
}; };
export const markRuntimeSessionAuthExpired = (
sessionId: string,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
message = "登录态已过期,请刷新登录后重试",
) => {
const context = contexts.get(sessionId);
if (!context) {
return null;
}
const nextContext: RuntimeSessionContext = {
...context,
authExpired: { message, reason },
};
contexts.set(sessionId, nextContext);
return { ...nextContext };
};
export const removeRuntimeSessionContext = (sessionId: string) => { export const removeRuntimeSessionContext = (sessionId: string) => {
contexts.delete(sessionId); contexts.delete(sessionId);
}; };
+244 -7
View File
@@ -18,8 +18,13 @@ import {
ResultReferenceStore, ResultReferenceStore,
} from "./results/store.js"; } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { buildAgentPublicRouter } from "./routes/publicApi.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js"; import {
getRuntimeSessionContext,
markRuntimeSessionAuthExpired,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
const app = express(); const app = express();
@@ -79,6 +84,14 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
}); });
return; return;
} }
if (isRuntimeAuthExpired(context)) {
markAuthExpired(context, "access_token_expired");
res.status(401).json({
message: "access token expired; refresh chat context",
detail: sessionId,
});
return;
}
const command = typeof req.body?.command === "string" ? req.body.command.trim() : ""; const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
if (!command) { if (!command) {
@@ -89,11 +102,18 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
const timeoutSec = const timeoutSec =
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120; typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
if (!context.network) {
res.status(400).json({
message: "runtime network missing; refresh chat context",
detail: sessionId,
});
return;
}
const authJson = JSON.stringify({ const authJson = JSON.stringify({
server: config.TJWATER_API_BASE_URL, server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken, access_token: context.accessToken,
project_id: context.projectId, project_id: context.projectId,
network:"tjwater",
}); });
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)]; const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
@@ -251,10 +271,198 @@ app.post("/internal/tools/session-search", async (req, res) => {
}); });
}); });
app.use( const callBackendJson = async (
"/api/v1/agent/chat", path: string,
requireAgentAuth, context: RuntimeSessionContext,
buildChatRouter( payload: unknown,
) => {
if (isRuntimeAuthExpired(context)) {
markAuthExpired(context, "access_token_expired");
return {
ok: false,
status: 401,
text: JSON.stringify({
message: "access token expired; refresh chat context",
}),
};
}
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",
};
if (context.accessToken) {
headers.Authorization = `Bearer ${context.accessToken}`;
}
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();
if (response.status === 401) {
markAuthExpired(context, "access_token_rejected");
}
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;
};
const AUTH_EXPIRY_SKEW_MS = 30_000;
function isRuntimeAuthExpired(context: RuntimeSessionContext) {
if (!context.tokenExpiresAt) {
return false;
}
const expiresAt = Date.parse(context.tokenExpiresAt);
if (!Number.isFinite(expiresAt)) {
return false;
}
return Date.now() >= expiresAt - AUTH_EXPIRY_SKEW_MS;
}
function markAuthExpired(
context: RuntimeSessionContext,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
) {
markRuntimeSessionAuthExpired(context.sessionId, reason);
void opencodeRuntime.abortSession(context.sessionId).catch((error) => {
logger.warn(
{ err: error, sessionId: context.sessionId },
"failed to abort runtime after auth expired",
);
});
}
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-searches",
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/geocoding-requests",
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,
});
}
});
const chatRouter = buildChatRouter(
sessionBridge, sessionBridge,
opencodeRuntime, opencodeRuntime,
sessionMetadataStore, sessionMetadataStore,
@@ -263,7 +471,12 @@ app.use(
sessionTranscriptStore, sessionTranscriptStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
), );
const authenticatedChatRouter = express.Router();
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
app.use(
"/api/v1/agent",
buildAgentPublicRouter(authenticatedChatRouter),
); );
const bootstrap = async () => { const bootstrap = async () => {
@@ -285,8 +498,32 @@ const server = app.listen(config.PORT, config.HOST, () => {
{ host: config.HOST, port: config.PORT }, { host: config.HOST, port: config.PORT },
"TJWaterAgent listening", "TJWaterAgent listening",
); );
void warmupOpencodeRuntime();
}); });
const warmupOpencodeRuntime = async () => {
const startedAt = Date.now();
try {
await opencodeRuntime.ensureClient();
logger.info(
{
elapsedMs: Math.max(0, Date.now() - startedAt),
mode: config.OPENCODE_MODE,
},
"opencode runtime warmed up",
);
} catch (error) {
logger.error(
{
err: error,
elapsedMs: Math.max(0, Date.now() - startedAt),
mode: config.OPENCODE_MODE,
},
"failed to warm up opencode runtime",
);
}
};
const shutdown = async () => { const shutdown = async () => {
logger.info("shutting down TJWaterAgent"); logger.info("shutting down TJWaterAgent");
server.close(); server.close();
+59 -14
View File
@@ -8,6 +8,7 @@ import {
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
slugify, slugify,
toStableId,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export type SessionStatus = "active" | "archived"; export type SessionStatus = "active" | "archived";
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
parentSessionId?: string; parentSessionId?: string;
}; };
export class SessionMetadataOwnershipError extends Error {
constructor(sessionId: string) {
super(`session metadata ownership mismatch: ${sessionId}`);
this.name = "SessionMetadataOwnershipError";
}
}
export class SessionMetadataStore { export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {} constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
if (!sessionId) { if (!sessionId) {
throw new Error("sessionId is required"); throw new Error("sessionId is required");
} }
const existing = await readJsonFile<SessionRecord>( const existing = await this.readRecord(sessionId);
this.filePath(sessionId),
);
if (existing) { if (existing) {
if (!matchesContext(existing, input)) {
throw new SessionMetadataOwnershipError(sessionId);
}
await atomicWriteJson(this.filePath(sessionId), existing);
await removeFileIfExists(this.legacyFilePath(sessionId));
return { created: false, record: existing }; return { created: false, record: existing };
} }
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
if (!normalizedSessionId) { if (!normalizedSessionId) {
return null; return null;
} }
return await readJsonFile<SessionRecord>( const record = await this.readRecord(normalizedSessionId);
this.filePath(normalizedSessionId), return record && matchesContext(record, context) ? record : null;
);
} }
async touch( async touch(
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
this.filePath(record.sessionId), this.filePath(record.sessionId),
next, next,
); );
await removeFileIfExists(this.legacyFilePath(record.sessionId));
return next; return next;
} }
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
const records = await Promise.all( const records = await Promise.all(
files.map((file) => readJsonFile<SessionRecord>(file)), files.map((file) => readJsonFile<SessionRecord>(file)),
); );
return records const uniqueRecords = new Map<string, SessionRecord>();
for (const record of records) {
if (record && matchesContext(record, context)) {
const previous = uniqueRecords.get(record.sessionId);
if (!previous || record.updatedAt > previous.updatedAt) {
uniqueRecords.set(record.sessionId, record);
}
}
}
return [...uniqueRecords.values()]
.filter((record): record is SessionRecord => Boolean(record)) .filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey,
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
} }
async remove(record: SessionRecord) { async remove(record: SessionRecord) {
await removeFileIfExists( await Promise.all(
this.filePath(record.sessionId), this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
); );
} }
private filePath(sessionId: string) { private filePath(sessionId: string) {
return join(
this.baseDir,
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
);
}
private legacyFilePath(sessionId: string) {
return join(this.baseDir, `${slugify(sessionId)}.json`); return join(this.baseDir, `${slugify(sessionId)}.json`);
} }
private filePaths(sessionId: string) {
return [this.filePath(sessionId), this.legacyFilePath(sessionId)];
} }
private async readRecord(sessionId: string) {
for (const path of this.filePaths(sessionId)) {
const record = await readJsonFile<SessionRecord>(path);
if (record?.sessionId === sessionId) {
return record;
}
}
return null;
}
}
const matchesContext = (
record: SessionRecord,
context: SessionMetadataContext,
) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey &&
(!record.ownerUserId || record.ownerUserId === context.userId?.trim()) &&
(!record.projectId || record.projectId === context.projectId);
const normalizeSessionId = (value?: string) => { const normalizeSessionId = (value?: string) => {
const normalized = value?.trim(); const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined; return normalized ? normalized.slice(0, 128) : undefined;
-1
View File
@@ -13,7 +13,6 @@ export type SessionUiStateRecord = {
sessionId: string; sessionId: string;
isTitleManuallyEdited?: boolean; isTitleManuallyEdited?: boolean;
messages: unknown[]; messages: unknown[];
branchGroups: unknown[];
}; };
type SessionUiStateContext = { type SessionUiStateContext = {
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from "bun:test";
import type { NextFunction, Request, Response } from "express";
import { requireAgentAuth } from "../../src/auth/agentAuth.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("requireAgentAuth", () => {
it("preserves dependency outages without exposing upstream response text", async () => {
globalThis.fetch = (async () =>
new Response("postgresql://secret-host/internal-table", {
status: 503,
})) as unknown as typeof fetch;
const headers: Record<string, string> = {
authorization: "Bearer token",
"x-project-id": "project-id",
};
const req = {
header: (name: string) => headers[name.toLowerCase()],
} as Request;
let status = 200;
let body: unknown;
const res = {
status: (value: number) => {
status = value;
return res;
},
json: (value: unknown) => {
body = value;
return res;
},
} as unknown as Response;
let nextCalled = false;
await requireAgentAuth(req, res, (() => {
nextCalled = true;
}) as NextFunction);
expect(status).toBe(503);
expect(body).toEqual({ message: "authentication service unavailable" });
expect(JSON.stringify(body)).not.toContain("secret-host");
expect(nextCalled).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "bun:test";
import {
agentModelOptions,
isSupportedModel,
resolveDefaultModel,
supportedModels,
} from "../../src/chat/models.js";
import { parseAgentModelOptions } from "../../src/chat/modelConfig.js";
describe("agent model config", () => {
it("keeps every exposed option in the supported model list", () => {
expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels);
expect(
agentModelOptions.every(
(model) => model.label && model.description && model.icon,
),
).toBe(true);
});
it("validates supported model ids", () => {
expect(isSupportedModel("deepseek/deepseek-v4-flash")).toBe(true);
expect(isSupportedModel("unknown/model")).toBe(false);
});
it("rejects unsupported default models", () => {
expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe(
"deepseek/deepseek-v4-pro",
);
expect(() => resolveDefaultModel("unknown/model")).toThrow(
"unsupported default agent model",
);
});
it("parses full model option config from JSON", () => {
expect(
parseAgentModelOptions(
JSON.stringify([
{
id: "provider/model",
label: "自定义",
description: "自定义模型",
icon: "bolt",
},
]),
),
).toEqual([
{
id: "provider/model",
label: "自定义",
description: "自定义模型",
icon: "bolt",
},
]);
});
it("rejects invalid model option config", () => {
expect(() => parseAgentModelOptions("not-json")).toThrow(
"OPENCODE_MODEL_OPTIONS must be valid JSON",
);
expect(() =>
parseAgentModelOptions(
JSON.stringify([
{ id: "provider/model", label: "模型" },
{ id: "provider/model", label: "重复模型" },
]),
),
).toThrow("duplicate OPENCODE_MODEL_OPTIONS id");
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test";
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
import {
createRouteRegistrar,
generateAgentOpenApi,
} from "../../src/contracts/openapi.js";
const KEBAB_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
describe("Agent REST OpenAPI", () => {
test("rejects duplicate method and path registrations", () => {
const register = createRouteRegistrar(new OpenAPIRegistry());
const config = {
summary: "Test operation",
responses: { 200: { description: "Successful response" } },
};
register("/api/v1/agent/test", "get", config);
expect(() => register("/api/v1/agent/test", "get", config)).toThrow(
"Duplicate Agent OpenAPI operation: GET /api/v1/agent/test",
);
});
test("publishes only unique kebab-case public operations", () => {
const document = generateAgentOpenApi();
const operationIds = new Set<string>();
let operationCount = 0;
for (const [path, pathItem] of Object.entries(document.paths)) {
expect(path.endsWith("/")).toBe(false);
for (const segment of path.split("/").filter(Boolean)) {
if (!segment.startsWith("{")) {
expect(segment).toMatch(KEBAB_SEGMENT);
}
}
for (const operation of Object.values(pathItem ?? {})) {
if (!operation || typeof operation !== "object" || !("responses" in operation)) {
continue;
}
operationCount += 1;
expect(operation.operationId).toBeTruthy();
expect(operationIds.has(operation.operationId!)).toBe(false);
operationIds.add(operation.operationId!);
expect(operation.security).toEqual([{ bearerAuth: [] }]);
}
}
expect(operationCount).toBe(13);
});
test("models runs as session subresources", () => {
const document = generateAgentOpenApi();
expect(document.paths["/api/v1/agent/sessions/{session_id}/runs"]?.post).toBeTruthy();
expect(
document.paths[
"/api/v1/agent/sessions/{session_id}/runs/current/events"
]?.get,
).toBeTruthy();
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
});
test("matches the public session runtime response shapes", () => {
const document = generateAgentOpenApi();
const schemas = document.components?.schemas ?? {};
const createResponses =
document.paths["/api/v1/agent/sessions"]?.post?.responses ?? {};
const patchOperation =
document.paths["/api/v1/agent/sessions/{session_id}"]?.patch;
const patchRequestBody = patchOperation?.requestBody;
const forkResponses =
document.paths["/api/v1/agent/sessions/{session_id}/forks"]?.post
?.responses ?? {};
expect(Object.keys(createResponses)).toContain("200");
expect(Object.keys(createResponses)).toContain("201");
expect(schemas.AgentSessionSummary).toMatchObject({
required: expect.arrayContaining(["id", "created_at", "updated_at", "status"]),
});
expect(schemas.AgentSessionSummary).not.toMatchObject({
required: expect.arrayContaining(["session_id"]),
});
expect(
patchRequestBody && !("$ref" in patchRequestBody)
? patchRequestBody.content["application/json"]?.schema
: undefined,
).toMatchObject({
properties: {
is_title_manually_edited: { type: "boolean" },
},
});
expect(patchOperation?.responses["200"]).toMatchObject({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentSessionUpdate" },
},
},
});
expect(forkResponses["200"]).toMatchObject({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentSessionFork" },
},
},
});
expect(forkResponses["201"]).toBeUndefined();
});
test("documents runtime query parameters and error statuses", () => {
const document = generateAgentOpenApi();
const renderOperation =
document.paths["/api/v1/agent/render-references/{render_ref}"]?.get;
expect(renderOperation?.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
in: "query",
name: "session_id",
required: false,
}),
]),
);
expect(Object.keys(renderOperation?.responses ?? {})).toEqual(
expect.arrayContaining(["400", "500", "502"]),
);
});
});
+95
View File
@@ -1,9 +1,13 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import {
buildForkedSessionUiState,
} from "../../src/routes/chat.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
extractLatestFrontendTurn, extractLatestFrontendTurn,
generateSessionTitle, generateSessionTitle,
shouldRestoreConversationForRuntime,
shouldGenerateSessionTitle, shouldGenerateSessionTitle,
} from "../../src/routes/chatSession.js"; } from "../../src/routes/chatSession.js";
import { type SessionTurnRecord } from "../../src/sessions/transcriptStore.js"; import { type SessionTurnRecord } from "../../src/sessions/transcriptStore.js";
@@ -161,6 +165,21 @@ describe("buildPromptWithLearningContext", () => {
expect(prompt).not.toContain("[Previous conversation context]"); expect(prompt).not.toContain("[Previous conversation context]");
expect(prompt).toBe("基于刚才结果继续分析"); expect(prompt).toBe("基于刚才结果继续分析");
}); });
it("restores copied fork context when metadata exists but runtime has no conversation", () => {
expect(
shouldRestoreConversationForRuntime({
hadExistingSessionRecord: true,
runtimeHasConversation: false,
}),
).toBe(true);
expect(
shouldRestoreConversationForRuntime({
hadExistingSessionRecord: true,
runtimeHasConversation: true,
}),
).toBe(false);
});
}); });
describe("extractLatestFrontendTurn", () => { describe("extractLatestFrontendTurn", () => {
@@ -183,3 +202,79 @@ describe("extractLatestFrontendTurn", () => {
}); });
}); });
}); });
describe("buildForkedSessionUiState", () => {
it("copies truncated source messages and preserves tool artifacts", () => {
const forked = buildForkedSessionUiState(
{
messages: [
{ role: "user", content: "画压力曲线" },
{
role: "assistant",
content: "已生成图表",
artifacts: [
{
id: "chart-1",
tool: "show_chart",
kind: "chart",
params: { chart_type: "line" },
},
],
},
{ role: "user", content: "继续分析" },
],
},
{
keepMessageCount: 2,
targetSessionId: "forked-session",
},
);
expect(forked).toEqual({
sessionId: "forked-session",
isTitleManuallyEdited: false,
messages: [
{ role: "user", content: "画压力曲线" },
{
role: "assistant",
content: "已生成图表",
artifacts: [
{
id: "chart-1",
tool: "show_chart",
kind: "chart",
params: { chart_type: "line" },
},
],
},
],
});
});
it("creates an empty branch state when source UI state is missing or keep count is zero", () => {
expect(
buildForkedSessionUiState(null, {
keepMessageCount: 3,
targetSessionId: "forked-without-source",
}),
).toEqual({
sessionId: "forked-without-source",
isTitleManuallyEdited: false,
messages: [],
});
expect(
buildForkedSessionUiState(
{ messages: [{ role: "user", content: "不保留" }] },
{
keepMessageCount: 0,
targetSessionId: "forked-empty",
},
),
).toEqual({
sessionId: "forked-empty",
isTitleManuallyEdited: false,
messages: [],
});
});
});
+377
View File
@@ -0,0 +1,377 @@
import { describe, expect, it } from "bun:test";
import {
streamPromptResponse,
type PermissionRequestPayload,
} from "../../src/routes/chatStream.js";
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
const createEventStream = (events: unknown[]) => ({
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
}
},
});
describe("streamPromptResponse", () => {
it("forwards opencode permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["rm *"],
metadata: { command: "rm tmp.txt" },
always: ["rm *"],
},
},
{
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: "delete temp",
write: (event, data) => events.push({ event, data }),
});
const permissionEvent = events.find((item) => item.event === "permission_request");
expect(permissionEvent?.data).toMatchObject({
session_id: "client-session-1",
request_id: "perm-1",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
} satisfies Partial<PermissionRequestPayload>);
});
it("auto replies always when approval mode is always", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => {
replies.push(options);
},
} 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: "run tests",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-1",
sessionId: "runtime-session-1",
reply: "always",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-1",
reply: "always",
});
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.v2.asked",
properties: {
id: "perm-v2-1",
sessionID: "runtime-session-1",
action: "external_directory",
resources: ["/tmp"],
save: ["/tmp"],
metadata: { path: "/tmp" },
},
},
{
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: "read /tmp",
write: (event, data) => events.push({ event, data }),
});
const permissionEvent = events.find((item) => item.event === "permission_request");
expect(permissionEvent?.data).toMatchObject({
session_id: "client-session-1",
request_id: "perm-v2-1",
permission: "external_directory",
patterns: ["/tmp"],
target: "/tmp",
always: ["/tmp"],
} satisfies Partial<PermissionRequestPayload>);
});
it("forwards opencode question requests and replies as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "question.asked",
properties: {
id: "question-1",
sessionID: "runtime-session-1",
questions: [
{
header: "范围",
question: "选择分析范围",
options: [{ label: "城区", description: "中心城区" }],
multiple: false,
custom: true,
},
],
},
},
{
type: "question.replied",
properties: {
sessionID: "runtime-session-1",
requestID: "question-1",
answers: [["城区", "补充说明"]],
},
},
{
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: "ask",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
session_id: "client-session-1",
request_id: "question-1",
questions: [
{
header: "范围",
question: "选择分析范围",
options: [{ label: "城区", description: "中心城区" }],
multiple: false,
custom: true,
},
],
});
expect(events.find((item) => item.event === "question_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "question-1",
answers: [["城区", "补充说明"]],
});
});
it("converts question tool parts into question request SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-1",
sessionID: "runtime-session-1",
messageID: "message-1",
type: "tool",
callID: "call-1",
tool: "question",
state: {
status: "running",
input: {
questions: [
{
question: "你觉得这个 question 工具好用吗?",
header: "测试问题",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
},
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: "ask",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
session_id: "client-session-1",
request_id: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
});
expect(
events.some(
(item) => item.event === "tool_call" && item.data.tool === "question",
),
).toBe(false);
});
it("forwards todo updates as structured SSE payloads and progress", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "todo.updated",
properties: {
sessionID: "runtime-session-1",
todos: [
{ content: "分析水位", status: "completed", priority: "high" },
{ content: "生成建议", status: "in_progress", priority: "medium" },
],
},
},
{
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: "plan",
write: (event, data) => events.push({ event, data }),
});
expect(
events.find(
(item) => item.event === "progress" && item.data.id === "todo-progress",
)?.data,
).toMatchObject({
id: "todo-progress",
phase: "planning",
title: "计划进度 1/2",
});
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
session_id: "client-session-1",
todos: [
expect.objectContaining({
content: "分析水位",
status: "completed",
priority: "high",
}),
expect.objectContaining({
content: "生成建议",
status: "in_progress",
priority: "medium",
}),
],
});
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from "bun:test";
import {
appendBackendToolArtifact,
cancelBackendTodos,
upsertBackendQuestion,
} from "../../src/routes/chatUiState.js";
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
title: "压力曲线",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
}) as Array<Record<string, unknown>>;
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
tool: "show_chart",
kind: "chart",
title: "压力曲线",
description: "测试折线图渲染",
params: {
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
});
expect(artifacts[0]).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^show_chart-/),
}),
);
});
});
describe("upsertBackendQuestion", () => {
it("replaces a tool-call placeholder with the actionable question request", () => {
const questions = upsertBackendQuestion(
[
{
requestId: "call-1",
sessionId: "session-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
},
],
tool: { messageID: "message-1", callID: "call-1" },
createdAt: 123,
status: "pending",
},
],
{
session_id: "session-1",
request_id: "question-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
},
],
tool: { messageID: "message-1", callID: "call-1" },
created_at: 456,
},
);
expect(questions).toHaveLength(1);
expect(questions[0]).toMatchObject({
requestId: "question-1",
tool: { callID: "call-1" },
status: "pending",
});
});
it("does not replace an actionable question request with a later tool-call placeholder", () => {
const questions = upsertBackendQuestion(
[
{
requestId: "question-1",
sessionId: "session-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
},
],
tool: { messageID: "message-1", callID: "call-1" },
createdAt: 123,
status: "pending",
},
],
{
session_id: "session-1",
request_id: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
},
],
tool: { messageID: "message-1", callID: "call-1" },
created_at: 456,
},
);
expect(questions).toHaveLength(1);
expect(questions[0]).toMatchObject({
requestId: "question-1",
tool: { callID: "call-1" },
status: "pending",
});
});
});
describe("cancelBackendTodos", () => {
it("marks pending and in-progress todos as cancelled", () => {
const cancelled = cancelBackendTodos([
{
sessionId: "session-1",
todos: [
{ id: "todo-1", content: "分析水位", status: "in_progress" },
{ id: "todo-2", content: "生成建议", status: "pending" },
{ id: "todo-3", content: "完成报告", status: "completed" },
],
createdAt: 123,
},
]);
expect(cancelled).toEqual([
expect.objectContaining({
todos: [
expect.objectContaining({
id: "todo-1",
status: "cancelled",
updatedAt: expect.any(Number),
}),
expect.objectContaining({
id: "todo-2",
status: "cancelled",
updatedAt: expect.any(Number),
}),
expect.objectContaining({
id: "todo-3",
status: "completed",
}),
],
}),
]);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import express, { Router } from "express";
import type { Server } from "node:http";
import { generateAgentOpenApi } from "../../src/contracts/openapi.js";
import { buildChatRouter } from "../../src/routes/chat.js";
import { buildAgentPublicRouter } from "../../src/routes/publicApi.js";
describe("Agent public REST router", () => {
let baseUrl = "";
let server: Server;
beforeAll(async () => {
const chatRouter = Router();
chatRouter.use(express.json());
chatRouter.post("/sessions/:session_id/runs", (req, res) => {
res.json({ method: req.method, path: req.path, body: req.body });
});
chatRouter.delete("/sessions/:session_id/runs/current", (req, res) => {
res.json({ method: req.method, path: req.path, body: req.body });
});
chatRouter.get("/auth-failure", (_req, res) => {
res.status(503).json({ message: "authentication service unavailable" });
});
const app = express();
app.use(express.json());
app.use("/api/v1/agent", buildAgentPublicRouter(chatRouter));
server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => {
server.close();
});
test("matches the published OpenAPI method and path set", () => {
type RouterLayer = {
route?: {
methods: Record<string, boolean>;
path: string;
};
};
const router = buildChatRouter(
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
);
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
const runtimeOperations = layers
.flatMap((layer) => {
if (!layer.route) {
return [];
}
const path = `/api/v1/agent${layer.route.path.replace(
/:([^/]+)/g,
"{$1}",
)}`;
return Object.entries(layer.route.methods)
.filter(([, enabled]) => enabled)
.map(([method]) => `${method.toUpperCase()} ${path}`);
})
.sort();
const document = generateAgentOpenApi();
const contractOperations = Object.entries(document.paths)
.flatMap(([path, pathItem]) =>
Object.entries(pathItem ?? {})
.filter(([, operation]) => {
return (
operation &&
typeof operation === "object" &&
"responses" in operation
);
})
.map(([method]) => `${method.toUpperCase()} ${path}`),
)
.sort();
expect(runtimeOperations).toEqual(contractOperations);
});
test("serves session runs without rewriting the REST request", async () => {
const response = await fetch(
`${baseUrl}/api/v1/agent/sessions/session-1/runs`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "hello" }),
},
);
expect(await response.json()).toEqual({
method: "POST",
path: "/sessions/session-1/runs",
body: { message: "hello" },
});
});
test("serves DELETE current run without rewriting the HTTP method", async () => {
const response = await fetch(
`${baseUrl}/api/v1/agent/sessions/session-2/runs/current`,
{ method: "DELETE" },
);
expect(await response.json()).toEqual({
method: "DELETE",
path: "/sessions/session-2/runs/current",
body: {},
});
});
test("wraps authentication middleware failures as Problem Details", async () => {
const response = await fetch(`${baseUrl}/api/v1/agent/auth-failure`);
const body = await response.json();
expect(response.status).toBe(503);
expect(response.headers.get("content-type")).toContain(
"application/problem+json",
);
expect(body).toMatchObject({
status: 503,
code: "dependency_unavailable",
detail: "authentication service unavailable",
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
const createRuntimeAdapter = (
messages: unknown[],
calls: {
reverted: string[];
removed: string[];
} = { reverted: [], removed: [] },
) =>
Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
messages: async () => messages,
revertMessage: async (_sessionId: string, messageId: string) => {
calls.reverted.push(messageId);
},
removeMessage: async (_sessionId: string, messageId: string) => {
calls.removed.push(messageId);
},
}) as OpencodeRuntimeAdapter;
describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
it("skips reverting the first user message when the runtime session is empty", async () => {
const calls = { reverted: [] as string[], removed: [] as string[] };
const runtime = createRuntimeAdapter([], calls);
await runtime.revertToUserMessage("session-1", { userOrdinal: 1 });
expect(calls).toEqual({ reverted: [], removed: [] });
});
it("keeps ordinal mismatches visible when runtime messages exist", async () => {
const runtime = createRuntimeAdapter([
{ info: { id: "user-1", role: "user" } },
{ info: { id: "assistant-1", role: "assistant" } },
]);
await expect(
runtime.revertToUserMessage("session-1", { userOrdinal: 2 }),
).rejects.toThrow("target user message not found to revert");
});
it("reverts and removes messages from the target user message onward", async () => {
const calls = { reverted: [] as string[], removed: [] as string[] };
const runtime = createRuntimeAdapter(
[
{ info: { id: "user-1", role: "user" } },
{ info: { id: "assistant-1", role: "assistant" } },
{ info: { id: "user-2", role: "user" } },
{ info: { id: "assistant-2", role: "assistant" } },
],
calls,
);
await runtime.revertToUserMessage("session-1", { userOrdinal: 2 });
expect(calls).toEqual({
reverted: ["user-2"],
removed: ["assistant-2", "user-2"],
});
});
});
describe("OpencodeRuntimeAdapter.ensureClient", () => {
it("retries bootstrap after a failed startup attempt", async () => {
let attempts = 0;
const client = {
global: { health: async () => ({ data: { healthy: true } }) },
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
bootstrapClient: async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("startup failed");
}
return client;
},
}) as OpencodeRuntimeAdapter;
await expect(runtime.ensureClient()).rejects.toThrow("startup failed");
await expect(runtime.ensureClient()).resolves.toBe(client);
expect(attempts).toBe(2);
});
});
+2
View File
@@ -14,6 +14,7 @@ describe("runtime session context", () => {
allowLearningWrite: true, allowLearningWrite: true,
clientSessionId: "chat-session-1", clientSessionId: "chat-session-1",
learningMode: "interactive", learningMode: "interactive",
network: "fengyang",
projectId: "project-id-1", projectId: "project-id-1",
projectKey: "project-1", projectKey: "project-1",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
@@ -24,6 +25,7 @@ describe("runtime session context", () => {
expect(runtimeContext?.accessToken).toBe("token-1"); expect(runtimeContext?.accessToken).toBe("token-1");
expect(runtimeContext?.clientSessionId).toBe("chat-session-1"); expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(runtimeContext?.network).toBe("fengyang");
expect(runtimeContext?.sessionId).toBe("runtime-session-1"); expect(runtimeContext?.sessionId).toBe("runtime-session-1");
removeRuntimeSessionContext("runtime-session-1"); removeRuntimeSessionContext("runtime-session-1");
+75
View File
@@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => {
); );
expect(fetched?.title).toBe("新标题"); expect(fetched?.title).toBe("新标题");
}); });
it("does not expose a session across users or projects", async () => {
await store.ensure({
actorKey: "actor-a",
projectId: "project-a",
projectKey: "project-key-a",
sessionId: "private-session",
userId: "user-a",
});
expect(
await store.get(
{
actorKey: "actor-b",
projectId: "project-a",
projectKey: "project-key-a",
userId: "user-b",
},
"private-session",
),
).toBeNull();
expect(
await store.get(
{
actorKey: "actor-a",
projectId: "project-b",
projectKey: "project-key-b",
userId: "user-a",
},
"private-session",
),
).toBeNull();
});
it("rejects idempotent creation when the session belongs to another scope", async () => {
await store.ensure({
actorKey: "actor-a",
projectId: "project-a",
projectKey: "project-key-a",
sessionId: "claimed-session",
userId: "user-a",
});
await expect(
store.ensure({
actorKey: "actor-b",
projectId: "project-b",
projectKey: "project-key-b",
sessionId: "claimed-session",
userId: "user-b",
}),
).rejects.toThrow("session metadata ownership mismatch");
});
it("keeps long session ids with the same slug prefix distinct", async () => {
const prefix = "s".repeat(64);
const firstSessionId = `${prefix}-first`;
const secondSessionId = `${prefix}-second`;
const context = {
actorKey: "actor-long",
projectId: "project-long",
projectKey: "project-key-long",
userId: "user-long",
};
await store.ensure({ ...context, sessionId: firstSessionId });
await store.ensure({ ...context, sessionId: secondSessionId });
expect((await store.get(context, firstSessionId))?.sessionId).toBe(
firstSessionId,
);
expect((await store.get(context, secondSessionId))?.sessionId).toBe(
secondSessionId,
);
});
}); });
+1 -1
View File
@@ -13,5 +13,5 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"types": ["node", "bun-types"] "types": ["node", "bun-types"]
}, },
"include": ["src/**/*.ts", "tests/**/*.ts"] "include": ["src/**/*.ts", "tests/**/*.ts", "cli/**/*.ts"]
} }
BIN
View File
Binary file not shown.