迁移到 bun 框架,移除 js 文件依赖;增加对 tools 参数的校验

This commit is contained in:
2026-04-30 13:41:27 +08:00
parent 04f2f814f9
commit e6b10cd603
17 changed files with 371 additions and 1055 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.opencode/node_modules/
.local.env
.vscode
+16
View File
@@ -0,0 +1,16 @@
---
description: TJWater Agent,用于供水网络分析和操作员工作流
mode: primary
model: deepseek/deepseek-v4-pro
temperature: 0.2
---
您是运行在 opencode 上的默认 TJWater Agent。
按照以下规则操作:
1. 使用 `.opencode/skills/tjwater-skills-root-index` 作为 TJWater 技能树,仅在任务需要该领域知识时加载特定技能。
2. 当您需要后端数据用于推理、总结、诊断或分析时,优先使用 `dynamic_http_call`
3. 当用户主要需要 UI 操作或可视化时,优先使用前端工具(`locate_features``view_history``view_scada``show_chart`)。
4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。
5. 保持回复准确、简洁,对供水网络用户在操作上有用。
6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。
-16
View File
@@ -1,16 +0,0 @@
---
description: TJWater default assistant for water-network analysis and operator workflows
mode: primary
model: deepseek/deepseek-v4-pro
temperature: 0.2
---
You are the default TJWater assistant running on opencode.
Operate with these rules:
1. Use `.opencode/skills/tjwater-skills-root-index` as the TJWater skill tree and load specific skills only when the task needs that domain knowledge.
2. Prefer `dynamic_http_call` when you need backend data for reasoning, summaries, diagnosis, or analysis.
3. Prefer frontend tools (`locate_features`, `view_history`, `view_scada`, `show_chart`) when the user mainly needs UI actions or visualization.
4. Treat frontend tools as display/interaction tools only. Do not assume they return data.
5. Keep replies accurate, concise, and operationally useful for water-network users.
6. Respect user authorization and project isolation. Never invent backend results when a tool call fails or no data is available.
+20 -21
View File
@@ -73,12 +73,12 @@ src/
### agents
```text
.opencode/agents/tjwater-assistant.md
.opencode/agents/agent.md
```
这里定义默认 agent 的角色、行为规则、模型配置和工具使用策略。
当前项目已将 always-loaded instructions 收敛到 `tjwater-assistant.md``opencode.json` 不再额外配置 `instructions` 数组。
当前项目已将 always-loaded instructions 收敛到 `agent.md``opencode.json` 不再额外配置 `instructions` 数组。
### tools
@@ -152,17 +152,17 @@ typescript
默认部署不需要全局安装 `opencode` CLI。服务会通过 `@opencode-ai/sdk` 的 embedded 模式启动 opencode server。
根目录的 npm scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
### 本地开发
```bash
cd TJWaterAgent
npm install
npm run dev
bun install
bun run dev
```
`npm install` 会通过 `postinstall` 自动执行 `.opencode` 依赖安装;`npm run dev` 启动前会检查 `.opencode/tools` 的类型。
`bun install` 会通过 `postinstall` 自动执行 `.opencode` 依赖安装;`bun run dev` 启动前会检查 `.opencode/tools` 的类型。
开发模式支持热重载,以下文件变化会触发服务重启并重新拉起 embedded opencode
@@ -173,7 +173,7 @@ opencode.json
.local.env
```
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `npm run dev`
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev`
本地开发可以在项目根目录的 `.local.env` 中配置环境变量:
@@ -188,29 +188,28 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000
```bash
cd TJWaterAgent
npm install
npm run build
npm run start
bun install
bun run check
bun run start
```
也可以使用一条命令完成构建并启动:
```bash
cd TJWaterAgent
npm install
npm run start:prod
bun install
bun run start:prod
```
### 常用脚本
| 命令 | 作用 |
| --- | --- |
| `npm run dev` | 类型检查 `.opencode` tools 后,以 watch 模式启动服务,并监听 `src``.opencode``opencode.json``.local.env` |
| `npm run build` | 构建服务并类型检查 `.opencode` tools |
| `npm run start` | 启动已构建的 `dist/server.js` |
| `npm run start:prod` | 先构建再启动 |
| `npm run check` | 执行完整构建检查 |
| `npm run install:opencode` | 手动安装 `.opencode` 依赖 |
| `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` 依赖 |
### 模型与 API 配置
@@ -231,13 +230,13 @@ src/config.ts 的 OPENCODE_MODEL 默认值
如果需要临时覆盖模型,可以在启动时设置:
```bash
OPENCODE_MODEL=deepseek/deepseek-v4-pro npm run start
OPENCODE_MODEL=deepseek/deepseek-v4-pro bun run start
```
DeepSeek API key 不写入代码,部署时通过环境变量设置:
```bash
DEEPSEEK_API_KEY=sk-xxx npm run start
DEEPSEEK_API_KEY=sk-xxx bun run start
```
`opencode.json` 已配置从环境变量读取:
@@ -259,7 +258,7 @@ DEEPSEEK_API_KEY=sk-xxx npm run start
```bash
OPENCODE_CONFIG_CONTENT='{"provider":{"deepseek":{"options":{"baseURL":"https://your-api.example.com/v1"}}}}' \
DEEPSEEK_API_KEY=sk-xxx \
npm run start
bun run start
```
也可以使用 opencode 的 `/connect` 命令写入用户级凭据,但服务部署更推荐使用环境变量。
+267
View File
@@ -0,0 +1,267 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "tjwater-agent",
"dependencies": {
"@opencode-ai/sdk": "^1.14.29",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"pino": "^9.7.0",
"pino-pretty": "^13.1.2",
"zod": "^3.25.76",
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.7.2",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-OgPEDvALekHZIjByo/okJ699aLPn+XtsVxgZxUqE8TlzAG7TtskMGFl0fro8O0T2p+nkOT/LstnKGbECvc0+YA=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"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=="],
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
-117
View File
@@ -1,117 +0,0 @@
import { randomUUID } from "node:crypto";
import { logger } from "../logger.js";
export class ChatSessionBridge {
registry;
runtime;
// 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用。
sessionContexts = new Map();
constructor(registry, runtime) {
this.registry = registry;
this.runtime = runtime;
}
async resolve(context) {
const requestContext = {
clientSessionId: context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken,
projectId: context.projectId,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
};
this.cleanupExpired();
const current = this.registry.get(requestContext);
if (current) {
this.sessionContexts.set(current.sessionId, requestContext);
try {
// 只有 opencode 侧 session 仍存在时,才复用本地映射。
await this.runtime.getSession(current.sessionId);
return { binding: current, requestContext, created: false };
}
catch (error) {
logger.warn({
clientSessionId: requestContext.clientSessionId,
sessionId: current.sessionId,
err: error,
}, "existing opencode session lookup failed, creating a new session");
}
}
const session = await this.runtime.createSession(requestContext.clientSessionId);
const binding = this.registry.upsert(requestContext, session.id);
this.sessionContexts.set(binding.sessionId, requestContext);
return { binding, requestContext, created: true };
}
count() {
return this.registry.count();
}
getSessionContext(sessionId) {
return this.sessionContexts.get(sessionId) ?? null;
}
async abort(context) {
const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) {
return null;
}
const requestContext = {
clientSessionId,
accessToken: context.accessToken,
projectId: context.projectId,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
};
this.cleanupExpired();
const binding = this.registry.get(requestContext);
if (!binding) {
return null;
}
this.sessionContexts.set(binding.sessionId, requestContext);
await this.runtime.abortSession(binding.sessionId);
return binding;
}
async fork(context) {
const currentClientSessionId = context.clientSessionId?.trim();
const nextRequestContext = {
clientSessionId: `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken,
projectId: context.projectId,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
};
this.cleanupExpired();
if (!currentClientSessionId || context.keepMessageCount <= 0) {
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
return { binding, requestContext: nextRequestContext, created: true };
}
const currentContext = {
clientSessionId: currentClientSessionId,
accessToken: context.accessToken,
projectId: context.projectId,
traceId: nextRequestContext.traceId,
};
const current = this.registry.get(currentContext);
if (!current) {
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
return { binding, requestContext: nextRequestContext, created: true };
}
await this.runtime.getSession(current.sessionId);
const messages = await this.runtime.messages(current.sessionId, Math.max(100, context.keepMessageCount + 20));
const chatMessages = messages.filter((message) => message.info.role === "user" || message.info.role === "assistant");
const keepMessage = chatMessages[context.keepMessageCount - 1];
if (!keepMessage) {
throw new Error(`fork keep point not found for message count ${context.keepMessageCount}`);
}
const session = await this.runtime.forkSession(current.sessionId, keepMessage.info.id);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
return { binding, requestContext: nextRequestContext, created: true };
}
cleanupExpired() {
const expiredSessionIds = this.registry.evictExpired();
for (const sessionId of expiredSessionIds) {
this.sessionContexts.delete(sessionId);
// 这里用 abort 做轻量清理;即使失败,也不阻断本地过期回收。
void this.runtime.abortSession(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "ignoring failed abort for expired session");
});
}
}
}
-25
View File
@@ -1,25 +0,0 @@
import dotenv from "dotenv";
import { z } from "zod";
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
dotenv.config({ path: ".local.env", override: false });
// 统一在启动时解析环境变量,避免业务代码里散落字符串默认值。
const envSchema = z.object({
NODE_ENV: z.string().default("development"),
PORT: z.coerce.number().int().positive().default(8788),
HOST: z.string().default("0.0.0.0"),
LOG_LEVEL: z.string().default("info"),
AGENT_INTERNAL_TOKEN: z.string().optional(),
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
OPENCODE_PORT: z.coerce.number().int().positive().default(4096),
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
OPENCODE_BASE_URL: z.string().optional(),
OPENCODE_SERVER_PASSWORD: z.string().optional(),
OPENCODE_SERVER_USERNAME: z.string().default("opencode"),
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
});
export const config = envSchema.parse(process.env);
-13
View File
@@ -1,13 +0,0 @@
import pino from "pino";
import { config } from "./config.js";
export const logger = pino({
level: config.LOG_LEVEL,
transport: config.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
-449
View File
@@ -1,449 +0,0 @@
import { Router } from "express";
import { z } from "zod";
import { logger } from "../logger.js";
const payloadSchema = z.object({
message: z.string().min(1).max(10000),
session_id: z.string().max(128).optional(),
});
const abortPayloadSchema = z.object({
session_id: z.string().max(128),
});
const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
keep_message_count: z.coerce.number().int().min(0),
});
export const buildChatRouter = (sessionBridge, runtime) => {
const chatRouter = Router();
chatRouter.post("/abort", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const binding = await sessionBridge.abort({
clientSessionId: parsed.data.session_id,
accessToken,
projectId,
traceId,
});
if (!binding) {
res.status(204).end();
return;
}
logger.info({
clientSessionId: parsed.data.session_id,
sessionId: binding.sessionId,
traceId,
projectId,
}, "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 authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const { binding, requestContext } = await sessionBridge.fork({
clientSessionId: parsed.data.session_id,
accessToken,
projectId,
traceId,
keepMessageCount: parsed.data.keep_message_count,
});
logger.info({
sourceClientSessionId: parsed.data.session_id,
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
keepMessageCount: parsed.data.keep_message_count,
}, "forked chat session");
res.status(200).json({
session_id: requestContext.clientSessionId,
});
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat fork failed");
res.status(500).json({
message: "chat fork failed",
detail,
});
}
});
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: parsed.data.session_id,
accessToken,
projectId,
traceId,
});
logger.info({
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
created,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
}, "processing chat request");
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const clientSessionId = requestContext.clientSessionId;
let streamClosed = false;
const abortController = new AbortController();
const handleClientClose = () => {
if (streamClosed || abortController.signal.aborted) {
return;
}
abortController.abort();
};
req.on("close", handleClientClose);
res.on("close", handleClientClose);
try {
await streamPromptResponse({
runtime,
opencodeSessionId: binding.sessionId,
clientSessionId,
message: parsed.data.message,
signal: abortController.signal,
write: (event, data) => {
if (streamClosed || res.writableEnded || res.destroyed) {
return;
}
res.write(toSse(event, data));
},
});
}
finally {
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
}
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed");
res.status(500).json({
message: "chat stream failed",
detail,
});
}
});
return chatRouter;
};
const toSse = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const getErrorMessage = (error) => error.data?.message ?? error.name;
const streamPromptResponse = async ({ runtime, opencodeSessionId, clientSessionId, message, signal, write, }) => {
const eventStream = await runtime.subscribeEvents();
const iterator = eventStream[Symbol.asyncIterator]();
const emittedToolParts = new Set();
const partTypes = new Map();
const pendingTextDeltas = new Map();
let emittedText = false;
let done = false;
let promptSettled = false;
let aborted = signal?.aborted ?? false;
const abortPromise = signal
? new Promise((resolve) => {
if (signal.aborted) {
resolve({ type: "abort" });
return;
}
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
once: true,
});
})
: null;
write("progress", {
session_id: clientSessionId,
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
});
const promptPromise = runtime
.prompt(opencodeSessionId, message)
.then(() => {
promptSettled = true;
})
.catch((error) => {
promptSettled = true;
throw error;
});
try {
while (!done) {
if (signal?.aborted) {
aborted = true;
break;
}
const nextEvent = iterator
.next()
.then((result) => ({ type: "event", result }));
const nextPrompt = promptSettled
? null
: promptPromise.then(() => ({ type: "prompt" }), (error) => ({ type: "prompt-error", error }));
const next = await Promise.race([
...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]),
...(abortPromise ? [abortPromise] : []),
]);
if (next.type === "abort") {
aborted = true;
break;
}
if (next.type === "prompt-error") {
throw next.error;
}
if (next.type === "prompt") {
continue;
}
if (next.result.done) {
break;
}
const event = next.result.value;
if (!isSessionEvent(event, opencodeSessionId)) {
continue;
}
if (event.type === "session.status") {
write("progress", {
session_id: clientSessionId,
id: "session-status",
phase: "session",
status: event.properties.status.type === "idle" ? "completed" : "running",
title: event.properties.status.type === "retry"
? `模型请求重试中:${event.properties.status.message}`
: event.properties.status.type === "busy"
? "Agent 正在处理请求"
: "Agent 已空闲",
});
continue;
}
if (event.type === "message.part.delta" && event.properties.field === "text") {
const partType = partTypes.get(event.properties.partID);
if (partType === "text") {
emittedText = true;
write("token", {
session_id: clientSessionId,
content: event.properties.delta,
});
}
else if (!partType) {
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
pendingTextDeltas.set(event.properties.partID, pending);
}
continue;
}
if (event.type === "message.part.updated") {
const part = event.properties.part;
partTypes.set(part.id, part.type);
if (part.type === "text") {
const pending = pendingTextDeltas.get(part.id) ?? [];
pendingTextDeltas.delete(part.id);
for (const content of pending) {
emittedText = true;
write("token", {
session_id: clientSessionId,
content,
});
}
}
else if (part.type === "reasoning") {
pendingTextDeltas.delete(part.id);
write("progress", {
session_id: clientSessionId,
id: part.id,
phase: "planning",
status: part.time.end ? "completed" : "running",
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
});
}
if (part.type === "tool") {
write("progress", {
session_id: clientSessionId,
id: part.id,
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
detail: part.state.status === "error" ? part.state.error : undefined,
});
if (!emittedToolParts.has(part.id)) {
emittedToolParts.add(part.id);
write("tool_call", {
session_id: clientSessionId,
tool: part.tool,
params: part.state.input,
});
}
}
continue;
}
if (event.type === "todo.updated") {
const completed = event.properties.todos.filter((todo) => todo.status === "completed").length;
write("progress", {
session_id: clientSessionId,
id: "todo-progress",
phase: "planning",
status: completed === event.properties.todos.length ? "completed" : "running",
title: `计划进度 ${completed}/${event.properties.todos.length}`,
detail: event.properties.todos
.map((todo) => `${todo.status}: ${todo.content}`)
.join("\n"),
});
continue;
}
if (event.type === "session.error") {
write("error", {
session_id: clientSessionId,
message: event.properties.error
? getErrorMessage(event.properties.error)
: "opencode session error",
detail: event.properties.error?.name,
});
done = true;
continue;
}
if (event.type === "session.idle") {
write("progress", {
session_id: clientSessionId,
id: "session-status",
phase: "session",
status: "completed",
title: "Agent 已完成处理",
});
done = true;
}
}
if (aborted) {
await runtime.abortSession(opencodeSessionId).catch((error) => {
logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session");
});
return;
}
await promptPromise;
if (!emittedText) {
await emitFallbackMessage(runtime, opencodeSessionId, clientSessionId, write);
}
write("progress", {
session_id: clientSessionId,
id: "request-received",
phase: "start",
status: "completed",
title: "请求处理完成",
});
write("progress", {
session_id: clientSessionId,
id: "request-completed",
phase: "complete",
status: "completed",
title: "分析完成",
});
write("done", { session_id: clientSessionId });
}
finally {
await iterator.return?.(undefined);
if (!promptSettled) {
await promptPromise.catch(() => undefined);
}
}
};
const isSessionEvent = (event, sessionId) => "properties" in event &&
typeof event.properties === "object" &&
event.properties !== null &&
"sessionID" in event.properties &&
event.properties.sessionID === sessionId;
const emitFallbackMessage = async (runtime, opencodeSessionId, clientSessionId, write) => {
const messages = await runtime.messages(opencodeSessionId);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const parts = assistantMessage?.parts ?? [];
const text = collectTextContent(parts);
if (text) {
write("token", {
session_id: clientSessionId,
content: text,
});
}
};
const collectTextContent = (parts) => parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
const normalizeToolStatus = (status) => {
if (status === "completed")
return "completed";
if (status === "error")
return "error";
return "running";
};
const getToolProgressTitle = (tool, status) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed")
return `${toolName} 已完成`;
if (status === "error")
return `${toolName} 执行失败`;
if (status === "pending")
return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
const toolLabels = {
dynamic_http_call: "后端数据查询",
locate_features: "地图定位",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
};
-115
View File
@@ -1,115 +0,0 @@
import { createOpencode, createOpencodeClient, } from "@opencode-ai/sdk/v2";
import { config } from "../config.js";
import { logger } from "../logger.js";
export class OpencodeRuntimeAdapter {
clientPromise = null;
closeServer = null;
async ensureClient() {
if (!this.clientPromise) {
this.clientPromise = this.bootstrapClient();
}
return this.clientPromise;
}
async health() {
const client = await this.ensureClient();
const response = await client.global.health();
return requireData(response.data, "global.health");
}
async createSession(title) {
const client = await this.ensureClient();
const response = await client.session.create({
title,
});
return requireData(response.data, "session.create");
}
async getSession(id) {
const client = await this.ensureClient();
const response = await client.session.get({
sessionID: id,
});
return requireData(response.data, "session.get");
}
async sendPrompt(sessionId, text) {
await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts
// 所以这里紧跟一次 messages() 回读,给上层路由统一消费。
return this.messages(sessionId);
}
async prompt(sessionId, text) {
const client = await this.ensureClient();
await client.session.prompt({
sessionID: sessionId,
parts: [{ type: "text", text }],
});
}
async messages(sessionId, limit = 20) {
const client = await this.ensureClient();
const messages = await client.session.messages({
sessionID: sessionId,
limit,
});
return requireData(messages.data, "session.messages");
}
async forkSession(sessionId, messageId) {
const client = await this.ensureClient();
const response = await client.session.fork({
sessionID: sessionId,
messageID: messageId,
});
return requireData(response.data, "session.fork");
}
async abortSession(sessionId) {
const client = await this.ensureClient();
const response = await client.session.abort({
sessionID: sessionId,
});
return requireData(response.data, "session.abort");
}
async subscribeEvents() {
const client = await this.ensureClient();
const response = await client.event.subscribe();
return response.stream;
}
async dispose() {
this.closeServer?.();
this.closeServer = null;
this.clientPromise = null;
}
async bootstrapClient() {
if (config.OPENCODE_BASE_URL) {
logger.info({ baseUrl: config.OPENCODE_BASE_URL }, "connecting to external opencode server");
return createOpencodeClient({
baseUrl: config.OPENCODE_BASE_URL,
});
}
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
process.env.TJWATER_AGENT_INTERNAL_TOKEN =
config.AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
logger.info({
hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT,
model: config.OPENCODE_MODEL,
}, "starting embedded opencode server");
const runtime = await createOpencode({
hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT,
timeout: config.OPENCODE_TIMEOUT_MS,
config: {
model: config.OPENCODE_MODEL,
},
});
this.closeServer = () => {
runtime.server.close();
};
return runtime.client;
}
}
export const opencodeRuntime = new OpencodeRuntimeAdapter();
function requireData(data, operation) {
if (data === undefined) {
throw new Error(`${operation} returned no data`);
}
return data;
}
-84
View File
@@ -1,84 +0,0 @@
import { randomUUID } from "node:crypto";
import cors from "cors";
import express from "express";
import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js";
import { logger } from "./logger.js";
import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import { SessionRegistry } from "./session/registry.js";
import { dynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
const app = express();
const registry = new SessionRegistry(config.SESSION_TTL_SECONDS);
const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
// 这个 token 只用于 .opencode/tools 回调本服务,避免把 internal endpoint 暴露成无鉴权入口。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.get("/health", async (_req, res) => {
try {
const runtime = await opencodeRuntime.health();
res.json({
ok: true,
runtime,
sessions: sessionBridge.count(),
});
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
ok: false,
message: "opencode runtime unavailable",
detail,
sessions: sessionBridge.count(),
});
}
});
app.post("/internal/tools/dynamic-http-call", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
const context = sessionBridge.getSessionContext(sessionId);
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
try {
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
const result = await dynamicHttpExecutor.execute({
path: req.body?.path,
method: req.body?.method,
arguments: req.body?.arguments,
}, context);
res.json(result);
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(400).json({
message: "dynamic http execution failed",
detail,
});
}
});
app.use("/api/v1/agent/chat", buildChatRouter(sessionBridge, opencodeRuntime));
const server = app.listen(config.PORT, config.HOST, () => {
logger.info({ host: config.HOST, port: config.PORT }, "TJWaterAgent listening");
});
const shutdown = async () => {
logger.info("shutting down TJWaterAgent");
server.close();
// 同步关闭 embedded opencode server,避免本服务退出后留下孤儿进程。
await opencodeRuntime.dispose();
};
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});
-57
View File
@@ -1,57 +0,0 @@
import crypto from "node:crypto";
export class SessionRegistry {
ttlMs;
bindings = new Map();
constructor(ttlSeconds) {
this.ttlMs = ttlSeconds * 1000;
}
upsert(context, sessionId) {
const binding = {
clientSessionId: context.clientSessionId,
sessionId,
lastUsedAt: Date.now(),
};
this.bindings.set(this.makeKey(context), binding);
return binding;
}
get(context) {
const key = this.makeKey(context);
const binding = this.bindings.get(key);
if (!binding) {
return null;
}
if (Date.now() - binding.lastUsedAt > this.ttlMs) {
this.bindings.delete(key);
return null;
}
binding.lastUsedAt = Date.now();
return binding;
}
count() {
this.evictExpired();
return this.bindings.size;
}
evictExpired() {
const expired = [];
const now = Date.now();
for (const [key, binding] of this.bindings.entries()) {
if (now - binding.lastUsedAt > this.ttlMs) {
expired.push(binding.sessionId);
this.bindings.delete(key);
}
}
return expired;
}
makeKey(context) {
// 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
const digest = crypto
.createHash("sha256")
.update([
context.clientSessionId,
context.accessToken ?? "",
context.projectId ?? "",
].join("|"))
.digest("hex");
return digest;
}
}
-154
View File
@@ -1,154 +0,0 @@
import { randomUUID } from "node:crypto";
import { config } from "../config.js";
import { logger } from "../logger.js";
const allowedMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const resultStore = new Map();
export class DynamicHttpExecutor {
async execute(input, context) {
const method = (input.method ?? "GET").trim().toUpperCase();
if (!allowedMethods.has(method)) {
throw new Error(`unsupported method: ${method}`);
}
const path = input.path.trim();
if (!path.startsWith("/")) {
throw new Error("path must start with '/'");
}
const query = buildQuery(input.arguments ?? {});
const url = new URL(path, config.TJWATER_API_BASE_URL);
for (const [key, value] of query) {
url.searchParams.append(key, value);
}
// 这里复用 chat session 绑定的用户上下文,保持后端鉴权与项目隔离语义不变。
const headers = new Headers({
Accept: "application/json",
"x-trace-id": context.traceId,
});
if (context.accessToken) {
headers.set("Authorization", `Bearer ${context.accessToken}`);
}
if (context.projectId) {
headers.set("x-project-id", context.projectId);
}
const startedAt = Date.now();
const response = await fetch(url, {
method,
headers,
signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS),
});
const durationMs = Date.now() - startedAt;
logger.info({
method,
path,
statusCode: response.status,
durationMs,
traceId: context.traceId,
projectId: context.projectId,
}, "dynamic_http_call completed");
const contentType = response.headers.get("content-type") ?? "";
const rawText = await response.text();
const data = contentType.includes("application/json") && rawText
? JSON.parse(rawText)
: rawText;
if (!response.ok) {
return {
ok: false,
trace_id: context.traceId,
upstream: {
method,
path,
status_code: response.status,
},
error: {
message: "upstream API returned error",
detail: data,
},
};
}
return {
ok: true,
trace_id: context.traceId,
upstream: {
method,
path,
status_code: response.status,
},
...normalizeSuccessResult(data, context),
};
}
getResult(resultRef) {
return resultStore.get(resultRef);
}
}
export const dynamicHttpExecutor = new DynamicHttpExecutor();
const buildQuery = (argumentsObject) => {
const pairs = [];
for (const [key, value] of Object.entries(argumentsObject)) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
if (value.length === 0) {
continue;
}
pairs.push([key, value.map(String).join(",")]);
continue;
}
pairs.push([key, String(value)]);
}
return pairs;
};
const normalizeSuccessResult = (data, context) => {
const sizeBytes = estimateBytes(data);
if (sizeBytes <= config.MAX_INLINE_RESULT_BYTES) {
return {
result_mode: "inline",
result_size_bytes: sizeBytes,
data,
};
}
const resultRef = `res-${randomUUID().slice(0, 16)}`;
// 大结果先落本地引用,避免工具输出把模型上下文直接撑爆。
resultStore.set(resultRef, {
rawResult: data,
traceId: context.traceId,
projectId: context.projectId,
});
return {
result_mode: "referenced",
result_size_bytes: sizeBytes,
result_ref: resultRef,
preview: buildPreview(data),
};
};
const estimateBytes = (data) => Buffer.byteLength(JSON.stringify(data));
const buildPreview = (data) => {
if (Array.isArray(data)) {
const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS);
const fields = sample.length > 0 && isRecord(sample[0])
? Object.keys(sample[0]).slice(0, 30)
: [];
return {
count: data.length,
fields,
sample,
summary: `list[${data.length}]`,
};
}
if (isRecord(data)) {
const fields = Object.keys(data).slice(0, 30);
const sample = Object.fromEntries(fields.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS).map((field) => [field, data[field]]));
return {
count: fields.length,
fields,
sample,
summary: `object<${fields.length} fields>`,
};
}
return {
count: 1,
fields: [],
sample: String(data).slice(0, 300),
summary: `scalar<${typeof data}>`,
};
};
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
+1 -1
View File
@@ -12,5 +12,5 @@
"hostname": "127.0.0.1",
"port": 4096
},
"default_agent": "tjwater-assistant"
"default_agent": "agent"
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "tjwater-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"postinstall": "bun run install:opencode",
"install:opencode": "bun install --cwd .opencode",
"typecheck": "tsc --noEmit -p tsconfig.json",
"typecheck:opencode": "bun run --cwd .opencode typecheck",
"dev": "bun run typecheck:opencode && bun --watch src/server.ts",
"build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode",
"start": "bun src/server.ts",
"start:prod": "bun run check && bun src/server.ts"
},
"dependencies": {
"@opencode-ai/sdk": "^1.14.29",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"pino": "^9.7.0",
"pino-pretty": "^13.1.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.7.2",
"typescript": "^5.9.3"
}
}
+30 -2
View File
@@ -230,6 +230,27 @@ const getErrorMessage = (error: {
data?: { message?: string };
}) => error.data?.message ?? error.name;
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 hasToolParams = (params: Record<string, unknown>) =>
Object.keys(params).length > 0;
type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter;
opencodeSessionId: string;
@@ -386,6 +407,10 @@ const streamPromptResponse = async ({
});
}
if (part.type === "tool") {
const toolParams = normalizeToolParams(part.state.input);
const isToolFinalState =
part.state.status === "completed" || part.state.status === "error";
write("progress", {
session_id: clientSessionId,
id: part.id,
@@ -394,12 +419,15 @@ const streamPromptResponse = async ({
title: getToolProgressTitle(part.tool, part.state.status),
detail: part.state.status === "error" ? part.state.error : undefined,
});
if (!emittedToolParts.has(part.id)) {
if (
!emittedToolParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState)
) {
emittedToolParts.add(part.id);
write("tool_call", {
session_id: clientSessionId,
tool: part.tool,
params: part.state.input,
params: toolParams,
});
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"noEmit": true,
"rootDir": "src",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true