21 Commits
Author SHA1 Message Date
jiang 741e39b444 build(docker): fix agent image build
Agent CI/CD / docker-image (push) Successful in 2m3s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-07 17:53:19 +08:00
jiang 5020e58b7e feat(auth): validate agent requests 2026-06-07 17:15:40 +08:00
jiang ba46258845 style(opencode): format tool definitions 2026-06-07 17:08:17 +08:00
jiang 9d4e5486e9 refactor: keep runtime context in memory 2026-06-07 17:07:14 +08:00
jiang 1ed7e56f35 refactor: remove legacy data compatibility 2026-06-07 16:56:23 +08:00
jiang 5e0c16f8b2 更新 .gitignore 2026-06-05 14:31:18 +08:00
jiang 67d027e60c Remove .opencode/skills/ from git tracking 2026-06-05 14:30:05 +08:00
jiang 8f0e93ceec 提示词禁止使用 read/cat 读取文件,避免输出过长信息到终端 2026-06-05 13:20:38 +08:00
jiang ad31956f53 完善 skill_manager 的技能维护能力 2026-06-05 13:03:39 +08:00
jiang fc0e76439d fix(chat): 解决token传输、本地文件存储顺序、读取的问题 2026-06-04 18:19:29 +08:00
jiang 10c11a5254 refactor(agent): 移除旧工具桥 2026-06-04 18:02:38 +08:00
jiang f4749d6e2e 增加流式信息中断处理机制 2026-06-04 16:27:13 +08:00
jiang 8a1785c244 更新memory读取机制,新增前需要先list阅读已有的内容 2026-06-04 15:35:01 +08:00
jiang 0188240d62 重建会话记录逻辑 2026-06-04 15:26:23 +08:00
jiang 0ecb2babf3 refactor: unify agent session persistence 2026-06-04 15:02:27 +08:00
jiang 04ded0ceb0 重构会话管理,简化上下文存储逻辑 2026-06-03 17:14:55 +08:00
jiang 76d4b510f4 避免abort后创建新的session 2026-06-03 10:04:00 +08:00
jiang 96e5d25518 更新tjwater-cli skill和环境 2026-06-03 09:49:37 +08:00
jiang a825c3c31d 重新整理提示词和工具说明。 2026-06-02 17:42:02 +08:00
jiang 5b285ad7a5 后端服务将通过tjwater-cli形式访问 2026-06-02 15:31:21 +08:00
jiang 20329bb771 新增应用样式工具 2026-05-29 10:28:08 +08:00
107 changed files with 2577 additions and 4108 deletions
-1
View File
@@ -1,5 +1,4 @@
.git
.gitignore
node_modules
.opencode/node_modules
.local.env
+5
View File
@@ -18,6 +18,11 @@ jobs:
shell: bash
steps:
- name: Setup tools
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq jq
jq --version
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
+5
View File
@@ -1,7 +1,12 @@
node_modules/
__pycache__/
.opencode/node_modules/
.opencode/skills/
.local.env
.vscode
docker-compose.yml
data/
logs/
cli/
AGENT_HARNESS_REPORT.md
HARNESS_INTRODUCTION.md
+88 -26
View File
@@ -4,31 +4,93 @@ mode: primary
model: deepseek/deepseek-v4-pro
temperature: 0.2
---
您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确
按照以下规则操作:
## 工作流生命周期
1. 使用 `.opencode/skills/tjwater-skills-root-index` 作为 TJWater 技能树,仅在任务需要该领域知识时加载特定技能。对分析类问题,优先检查 `workflow` 域下是否已有固定工作流(例如 `bottleneck-analysis`);只有在 workflow 不存在、信息不足或需要补充原子能力时,才继续查询其他 API / action skills。
2. 当您需要后端数据用于推理、总结、诊断或分析时,优先使用 `dynamic_http_call`
3. 当用户主要需要 UI 操作或可视化时,优先使用前端工具(`locate_features``view_history``view_scada``show_chart`)。
4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。
5. 保持回复准确、简洁,对供水网络用户在操作上有用。
6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。
7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。
8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。默认遵循 **workflow-first**:先查固定工作流 skill,再按需回落到原子 API skills。
9.`dynamic_http_call` 返回 `result_mode = referenced``result_ref` 时,说明当前只拿到了预览;如果后续推理仍需要完整结果,必须调用 `fetch_result_ref` 回读,不能把 preview 当成完整数据。
10.`render_ref``result_ref` 或其他引用型结果,默认只使用 preview、摘要、局部字段,或直接把引用传给前端工具;如果引用仅用于渲染/展示(例如 `render_junctions`),直接传引用,不要先读取完整内容再重组。
11. 对任何可能很大的引用文件、结果文件或普通大文件,禁止完整读取;优先使用预览、分页、截断、按字段读取、按片段读取或采样读取。只有在没有其他办法且当前推理确实必须依赖完整内容时,才允许读取完整内容,并先明确说明必要性。
12. 不得通过 sub-agent、并行代理或任何间接方式,去读取引用文件或大文件的完整内容;主 agent 与其调用链中的其他代理都必须遵守同样限制。
13. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
- `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实
- `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置
14. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。
15. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager``skill_manager`
16. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。
17. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。
18. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
19. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
20. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
21. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns``references/``scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
22. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
```
初次遇到问题 → tjwater_cli + Python 脚本拼装 → 验证有效 →
→ 立即调用 skill_manager 保存到 skills/workflow/<name>/
→ 下次遇到同类问题直接加载该 skill,按既定步骤执行
```
## 任务执行决策
收到用户请求时,按以下顺序决策:
1. **查已有工作流** — 检查 `skills/workflow/` 下是否存在匹配的 SKILL.md,有则加载并按步骤执行
2. **历史参考** — 用 `session_search` 检索历史相似案例,避免重复试错
3. **从零拼装** — 无匹配工作流时,自行组合 `tjwater_cli` 命令 + Python 脚本完成
4. **完成后复盘** — 判断当前流程是否稳定、可复用,决定是否沉淀为 workflow
## 工具选择
| 场景 | 工具 |
|------|------|
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
| 发现可用命令 | `tjwater_cli(command="help")` |
| UI 操作 / 可视化 | `locate_features``view_scada``show_chart``render_junctions``view_history``apply_layer_style` |
| 持久化渲染数据 | `store_render_ref``render_junctions` |
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
## 执行约束
1. 每次工具调用必须在 `reason` 字段填写具体理由
2. `tjwater-cli` 输出为 JSON`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code`
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
5. 无可用数据时不得编造结果
## 工作流沉淀(skill_manager
**写入条件**(必须同时满足):
- 经过当前对话验证有效
- 可被未来同类任务复用
- 非一次性/临时/猜测
**写入位置**`skills/workflow/<name>/`,包含 SKILL.md(步骤说明)、references/*.md(参考材料)和 scripts/*.py(分析脚本)。
**工具动作**`write_skill / remove_skill` 维护主 SKILL.md`append_pattern / remove_pattern` 维护 `## Learned Patterns``write_reference / remove_reference` 维护 references/*.md`write_script / remove_script` 维护 scripts/*.py。`write_skill` 可创建或覆盖完整 SKILL.md。
目录入口也通过 `skill_manager` 维护:更新 `skills/workflow/SKILL.md` 时使用 `write_skill(skill_path="workflow", ...)`,更新根入口 `skills/SKILL.md` 时使用 `write_skill(skill_path="__root__", ...)`
**脚本编写要求——优先用 pipe 串联**
workflow skill 脚本应尽量用 shell pipe 在一次 subprocess 调用中串联多个 CLI 命令。减少 tool calling 次数,提升执行效率。
```python
import subprocess, os
# env dict 仅用于当前子进程,不污染 os.environ,多用户安全
env = {**os.environ,
"TJWATER_SERVER": auth["server"],
"TJWATER_ACCESS_TOKEN": auth["access_token"], ...}
# 好:一次 shell 调用,pipe 串联
cmd = "tjwater-cli net list-pipes | jq '...' | xargs tjwater-cli analysis calc"
result = subprocess.run(cmd, shell=True, env=env, capture_output=True, text=True)
# 差:多次 subprocess.run
step1 = subprocess.run(["tjwater-cli", "net", "list-pipes"], ...)
step2 = subprocess.run(["tjwater-cli", "analysis", "calc"], ...)
```
管道场景下用子进程隔离的 env dict 传认证,释放 stdin 给管道数据流。不修改全局 `os.environ`。认证 JSON 由内部桥接注入,脚本不硬编码。
CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell 管道中完成。
**触发时机**
- 用户明确说"保存/沉淀/记录工作流"
- 任务完成且所有工具调用已结束、产生最终结果后,再判断当前流程是否稳定可复用
- **禁止**在规划任务未完成、工具调用链中间(即仍有 pending 步骤时)触发沉淀
- 严禁写入:token、password、secret、API key、system prompt、隐私数据
## 用户偏好持久化(memory_manager
仅保存长期有效的稳定事实,写成简短陈述句。严格区分:
- `memory_manager` = 用户偏好 / 项目事实(如"用户要简洁风格"、"当前项目管网规模 5000 管段")
- `skill_manager` = 可复用操作流程
- `session_search` = 检索历史案例(只读)
- 修改 memory 前先 `list` 当前 scope 的已有内容,先通读,再决定 `add / replace / remove`
-34
View File
@@ -1,34 +0,0 @@
---
name: tjwater-skills-root-index
description: TJWater Skills 分层索引(Domain -> Scenario -> Action)。
version: 1.2.0
---
# TJWater Skills
## 简介
按“领域 (Domain) -> 场景 (Scenario) -> 操作 (Action)”组织技能文档,逐层进入具体能力。
## 子模块索引 (渐进式引导)
- **analytics**: 见 `./analytics/SKILL.md`
- **business**: 见 `./business/SKILL.md`
- **data**: 见 `./data/SKILL.md`
- **platform**: 见 `./platform/SKILL.md`
- **workflow**: 见 `./workflow/SKILL.md`
## 加载策略
- 先按用户问题判断最可能的 Domain,再进入最小必要的 Scenario / Action。
- 对分析、诊断、建议类问题,优先检查 `workflow/` 下是否已有固定工作流 skill;若存在,可先按 workflow 执行,再回补所需原子 skills。
- 如果当前节点已经足以指导工具选择,不继续下钻到更多 skill。
- 如果 workflow 已覆盖主要步骤,则不要先从大量 API skills 开始拼装流程;仅在 workflow 缺失、步骤不全或需要额外原子能力时,才继续下钻。
- 优先更新已有 skill,而不是为一次性问题新增新的 skill 目录。
- learned pattern 应写成可复用的方法或坑点,不应写成某次会话的流水账。
- 某个 workflow 反复验证过的私有辅助脚本,应放在该 skill 目录下的 `scripts/*.py`,并随 skill 一起维护;不要写入 `data/`
## 参考
- 示例:`./examples.md`
- 运行手册:`./runbook.md`
-15
View File
@@ -1,15 +0,0 @@
---
name: tjwater-domain-analytics
description: 负责仿真分析、SCADA 分析等计算类 API 能力。
version: 3.0.0
---
# Analytics Domain Skill
## 简介
负责仿真分析、SCADA 分析等计算类 API 能力。
## 子模块索引 (渐进式引导)
- **scada-operations**: 见 `./scada-operations/SKILL.md`
- **simulation-analysis**: 见 `./simulation-analysis/SKILL.md`
@@ -1,14 +0,0 @@
---
name: tjwater-scenario-analytics-scada-operations
description: 负责 SCADA 设备与数据操作。
version: 3.0.0
---
# scada-operations Scenario Skill
## 简介
负责 SCADA 设备与数据操作。
## 子模块索引 (渐进式引导)
- **scada**: 见 `./scada/SKILL.md`
@@ -1,107 +0,0 @@
---
name: tjwater-action-analytics-scada-operations-scada
description: analytics/scada-operations 下 scada 操作技能。
version: 3.0.0
---
# scada Action Skill
## 简介
负责 `analytics/scada-operations` 场景下 `scada` 的具体接口调用,分为**设备配置(静态元数据)**、**时序监测数据(TimescaleDB**、**实时模拟数据**、**方案数据**和**复合查询**五类。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
### SCADA 设备配置(静态元数据)
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getscadadeviceschema/` | 获取SCADA设备架构 | network (query) | - |
| GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | network (query), id (query) | - |
| GET | `/api/v1/getallscadadeviceids/` | 获取所有SCADA设备ID | network (query) | - |
| GET | `/api/v1/getallscadadevices/` | 获取所有SCADA设备 | network (query) | - |
| POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | network (query) | - |
| POST | `/api/v1/setscadadevice/` | 更新SCADA设备 | network (query) | - |
| POST | `/api/v1/deletescadadevice/` | 删除SCADA设备 | network (query) | - |
| POST | `/api/v1/cleanscadadevice/` | 清空SCADA设备表 | network (query) | - |
| GET | `/api/v1/getscadadevicedataschema/` | 获取SCADA设备数据架构 | network (query) | - |
| GET | `/api/v1/getscadadevicedata/` | 获取SCADA设备数据 | network (query), device_id (query) | - |
| POST | `/api/v1/addscadadevicedata/` | 添加SCADA设备数据 | network (query) | - |
| POST | `/api/v1/setscadadevicedata/` | 更新SCADA设备数据 | network (query) | - |
| POST | `/api/v1/deletescadadevicedata/` | 删除SCADA设备数据 | network (query) | - |
| POST | `/api/v1/cleanscadadevicedata/` | 清空SCADA设备数据表 | network (query) | - |
| GET | `/api/v1/getscadaelementschema/` | 获取SCADA元素架构 | network (query) | - |
| GET | `/api/v1/getscadaelement/` | 获取单个SCADA元素映射 | network (query), id (query) | - |
| GET | `/api/v1/getscadaelements/` | 获取所有SCADA元素映射 | network (query) | - |
| POST | `/api/v1/addscadaelement/` | 添加SCADA元素映射 | network (query) | - |
| POST | `/api/v1/setscadaelement/` | 更新SCADA元素映射 | network (query) | - |
| POST | `/api/v1/deletescadaelement/` | 删除SCADA元素映射 | network (query) | - |
| POST | `/api/v1/cleanscadaelement/` | 清空SCADA元素映射表 | network (query) | - |
| GET | `/api/v1/getscadainfoschema/` | 获取SCADA信息架构 | network (query) | - |
| GET | `/api/v1/getscadainfo/` | 获取SCADA信息 | network (query), id (query) | - |
| GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | network (query) | - |
| GET | `/api/v1/getscadaproperties/` | 获取SCADA属性 | network (query), scada (query) | - |
| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA属性 | network (query) | - |
### SCADA 时序监测数据(TimescaleDB
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/scada/batch` | 批量插入SCADA监测数据 | data (body) | - |
| GET | `/api/v1/scada/by-ids-time-range` | 按设备ID和时间范围查询SCADA数据 | start_time (query), end_time (query), device_ids (query) | - |
| GET | `/api/v1/scada/by-ids-field-time-range` | 按设备ID、字段和时间范围查询SCADA数据 | start_time (query), end_time (query), field (query), device_ids (query) | - |
| PATCH | `/api/v1/scada/{device_id}/field` | 更新SCADA设备字段 | device_id (path), time (query), field (query), value (query) | - |
| DELETE | `/api/v1/scada/by-id-time-range` | 按设备ID和时间范围删除SCADA数据 | device_id (query), start_time (query), end_time (query) | - |
### 实时模拟数据(TimescaleDB - Realtime
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/realtime/links/batch` | 批量插入实时管道数据 | data (body) | - |
| GET | `/api/v1/realtime/links` | 查询实时管道数据 | start_time (query), end_time (query) | link_ids (query) |
| DELETE | `/api/v1/realtime/links` | 删除实时管道数据 | start_time (query), end_time (query) | - |
| PATCH | `/api/v1/realtime/links/{link_id}/field` | 更新实时管道字段 | link_id (path), time (query), field (query), value (query) | - |
| POST | `/api/v1/realtime/nodes/batch` | 批量插入实时节点数据 | data (body) | - |
| GET | `/api/v1/realtime/nodes` | 查询实时节点数据 | start_time (query), end_time (query) | node_ids (query) |
| DELETE | `/api/v1/realtime/nodes` | 删除实时节点数据 | start_time (query), end_time (query) | - |
| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | data (body) | - |
| GET | `/api/v1/realtime/query/by-time-property` | 按时间和属性查询实时数据 | time (query), property (query) | - |
| GET | `/api/v1/realtime/query/by-id-time` | 按ID和时间查询实时模拟数据 | element_id (query), time (query) | - |
### 方案模拟数据(TimescaleDB - Scheme
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/scheme/links/batch` | 批量插入方案管道数据 | data (body) | - |
| GET | `/api/v1/scheme/links` | 查询方案管道数据 | scheme_type (query) | link_ids (query) |
| GET | `/api/v1/scheme/links/{link_id}/field` | 查询方案管道字段数据 | link_id (path), scheme_type (query), field (query) | - |
| PATCH | `/api/v1/scheme/links/{link_id}/field` | 更新方案管道字段 | link_id (path), scheme_type (query), field (query), value (query) | - |
| DELETE | `/api/v1/scheme/links` | 删除方案管道数据 | scheme_type (query) | - |
| POST | `/api/v1/scheme/nodes/batch` | 批量插入方案节点数据 | data (body) | - |
| GET | `/api/v1/scheme/nodes/{node_id}/field` | 查询方案节点字段数据 | node_id (path), scheme_type (query), field (query) | - |
| PATCH | `/api/v1/scheme/nodes/{node_id}/field` | 更新方案节点字段 | node_id (path), scheme_type (query), field (query), value (query) | - |
| DELETE | `/api/v1/scheme/nodes` | 删除方案节点数据 | scheme_type (query) | - |
| POST | `/api/v1/scheme/simulation/store` | 存储方案模拟结果 | scheme_type (query), data (body) | - |
| GET | `/api/v1/scheme/query/by-id-time` | 按ID和时间查询方案模拟数据 | element_id (query), scheme_type (query), time (query) | - |
### 复合查询(TimescaleDB - Composite
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/composite/scada-simulation` | 获取SCADA关联的模拟数据 | network (query), start_time (query) | end_time (query) |
| GET | `/api/v1/composite/element-simulation` | 获取管网元素的模拟数据 | network (query), element_id (query), start_time (query) | end_time (query) |
| GET | `/api/v1/composite/element-scada` | 获取管网元素关联的SCADA监测数据 | element_id (query), start_time (query) | end_time (query) |
| POST | `/api/v1/composite/clean-scada` | 清洗SCADA监测数据 | data (body) | - |
| GET | `/api/v1/composite/pipeline-health-prediction` | 预测管道健康状况 | network (query), time (query) | - |
- 覆盖方法:`DELETE, GET, PATCH, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /scada/by-ids-time-range` | 查询多个设备在指定时间范围内的所有监测字段数据,device_ids 为逗号分隔的ID字符串 |
| `GET /scada/by-ids-field-time-range` | 查询多个设备在指定时间范围内的特定字段数据(如只查压力或只查流量) |
| `POST /realtime/simulation/store` | 将水力模拟结果以实时数据形式存入TimescaleDB,供前端实时展示 |
| `GET /realtime/query/by-time-property` | 按特定时间点和属性名查询管网实时模拟结果 |
| `GET /composite/scada-simulation` | 同时返回指定管网的SCADA监测数据和对应的水力模拟数据,便于对比分析 |
| `GET /composite/element-scada` | 查询特定管网元素(管道或节点)关联的SCADA监测时序数据 |
| `GET /composite/pipeline-health-prediction` | 基于历史SCADA数据和模型预测管道健康状态 |
| `POST /composite/clean-scada` | 对指定设备的SCADA原始数据进行清洗处理(去异常值等),支持传 'all' 清洗所有设备 |
@@ -1,18 +0,0 @@
---
name: tjwater-scenario-analytics-simulation-analysis
description: 负责仿真、风险、漏损与爆管分析。
version: 3.0.0
---
# simulation-analysis Scenario Skill
## 简介
负责仿真、风险、漏损与爆管分析。
## 子模块索引 (渐进式引导)
- **burst_detection**: 见 `./burst_detection/SKILL.md`
- **burst_location**: 见 `./burst_location/SKILL.md`
- **leakage**: 见 `./leakage/SKILL.md`
- **risk**: 见 `./risk/SKILL.md`
- **simulation**: 见 `./simulation/SKILL.md`
@@ -1,43 +0,0 @@
---
name: tjwater-action-analytics-simulation-analysis-burst-detection
description: analytics/simulation-analysis 下 burst-detection 操作技能。
version: 3.0.0
---
# burst-detection Action Skill
## 简介
负责 `analytics/simulation-analysis` 场景下 `burst-detection` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/burst-detection/detect/` | 执行爆管检测 | data (body) | - |
| GET | `/api/v1/burst-detection/schemes/` | 查询爆管检测方案列表 | network (query) | query_date (query) |
| GET | `/api/v1/burst-detection/schemes/{scheme_name}` | 获取爆管检测方案详情 | network (query), scheme_name (path) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /detect/` | 基于压力观测数据执行爆管检测分析。使用异常检测算法(隔离森林 IsolationForest)识别压力时间序列中的异常,判定为潜在爆管事件。请求体支持列式字典、逐时刻对象数组、二维数组三种格式的压力数据,可指定数据来源(monitoring 监测 / simulation 模拟)。 |
| `GET /schemes/` | 获取指定管网的所有爆管检测方案列表,可通过 query_date 按日期筛选。 |
| `GET /schemes/{scheme_name}` | 获取指定名称的爆管检测方案详细配置信息,包含传感器节点、算法参数等。 |
## 请求体关键字段(POST /detect/
| 字段 | 类型 | 说明 |
|---|---|---|
| `network` | str | 管网名称(数据库名) |
| `observed_pressure_data` | dict/list/null | 压力观测数据,支持列式字典 `{sensor_id: [values]}` 或逐行数组 |
| `points_per_day` | int | 每天数据点数,默认1440 |
| `mu` | int | 异常值检测参数,默认100 |
| `iforest_params` | dict/null | 隔离森林算法参数,可选 |
| `scada_start` / `scada_end` | datetime/null | 从SCADA数据库查询的时间范围 |
| `sensor_nodes` | list/null | 指定传感器节点,null为全部 |
| `data_source` | str | 数据来源:`monitoring`(监测)或 `simulation`(模拟),默认monitoring |
@@ -1,47 +0,0 @@
---
name: tjwater-action-analytics-simulation-analysis-burst-location
description: analytics/simulation-analysis 下 burst-location 操作技能。
version: 3.0.0
---
# burst-location Action Skill
## 简介
负责 `analytics/simulation-analysis` 场景下 `burst-location` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/burst-location/locate/` | 执行爆管定位 | data (body) | - |
| GET | `/api/v1/burst-location/schemes/` | 查询爆管定位方案列表 | network (query) | query_date (query) |
| GET | `/api/v1/burst-location/schemes/{scheme_name}` | 获取爆管定位方案详情 | network (query), scheme_name (path) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /locate/` | 基于压力和流量SCADA数据定位管网中的爆管位置。通过对比爆管时与正常状态下的压力/流量差异,计算最可能的爆管节点。 |
| `GET /schemes/` | 获取指定管网的所有爆管定位方案列表,可通过 query_date 按日期筛选。 |
| `GET /schemes/{scheme_name}` | 获取指定名称的爆管定位方案详细配置,包含传感器布置、阈值参数等。 |
## 请求体关键字段(POST /locate/
| 字段 | 类型 | 说明 |
|---|---|---|
| `network` | str | 管网名称(数据库名) |
| `data_source` | str | 数据来源:`monitoring`(监测)或 `simulation`(模拟),默认monitoring |
| `pressure_scada_ids` | list/null | 压力SCADA传感器ID列表 |
| `burst_pressure` | dict/list/null | 爆管时的压力数据 |
| `normal_pressure` | dict/list/null | 正常时的压力数据 |
| `burst_leakage` | float | 爆管时的漏水量(必填) |
| `flow_scada_ids` | list/null | 流量SCADA传感器ID列表 |
| `burst_flow` / `normal_flow` | dict/list/null | 爆管/正常时的流量数据 |
| `min_dpressure` | float | 最小压力差(bar),默认2.0 |
| `basic_pressure` | float | 基准压力(bar),默认10.0 |
| `scada_burst_start` / `scada_burst_end` | datetime/null | 从SCADA数据库查询的爆管时间范围 |
| `use_scada_flow` | bool | 是否使用SCADA流量数据,默认false |
@@ -1,46 +0,0 @@
---
name: tjwater-action-analytics-simulation-analysis-leakage
description: analytics/simulation-analysis 下 leakage 操作技能。
version: 3.0.0
---
# leakage Action Skill
## 简介
负责 `analytics/simulation-analysis` 场景下 `leakage` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/leakage/identify/` | 执行漏损识别 | data (body) | - |
| GET | `/api/v1/leakage/schemes/` | 查询漏损识别方案列表 | network (query) | query_date (query) |
| GET | `/api/v1/leakage/schemes/{scheme_name}` | 获取漏损识别方案详情 | network (query), scheme_name (path) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /identify/` | 基于压力观测数据和遗传算法识别管网中的漏损位置和大小。通过对比模型计算与实测压力数据,迭代优化找到最匹配的漏损节点和漏水量。 |
| `GET /schemes/` | 获取指定管网的所有漏损识别方案列表,可通过 query_date 按日期筛选。 |
| `GET /schemes/{scheme_name}` | 获取指定名称的漏损识别方案详细配置,包含传感器节点、算法参数等。 |
## 请求体关键字段(POST /identify/
| 字段 | 类型 | 说明 |
|---|---|---|
| `network` | str | 管网名称(数据库名) |
| `observed_pressure_data` | str/dict/list/null | 观测压力数据 |
| `start_time` | float | 起始时间(小时),默认0 |
| `duration` | float | 持续时间(小时),默认24 |
| `timestep` | float | 时间步长(分钟),默认5 |
| `q_sum` | float | 总流量(m³/s),默认0.2 |
| `pop_size` | int | 遗传算法种群大小,默认50 |
| `max_gen` | int | 遗传算法最大代数,默认100 |
| `n_workers` | int | 并行工作线程数,默认CPU数-1(最大4) |
| `scada_start` / `scada_end` | datetime/null | 从SCADA数据库查询的时间范围 |
| `sensor_nodes` | list/null | 传感器节点列表,null为全部 |
@@ -1,34 +0,0 @@
---
name: tjwater-action-analytics-simulation-analysis-risk
description: analytics/simulation-analysis 下 risk 操作技能。
version: 3.0.0
---
# risk Action Skill
## 简介
负责 `analytics/simulation-analysis` 场景下 `risk` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getpiperiskprobabilitynow/` | 获取管道当前风险概率 | network (query), pipe_id (query) | - |
| GET | `/api/v1/getpiperiskprobability/` | 获取管道风险概率历史 | network (query), pipe_id (query) | - |
| GET | `/api/v1/getpipesriskprobability/` | 批量获取多条管道风险概率 | network (query), pipe_ids (query) | - |
| GET | `/api/v1/getnetworkpiperiskprobabilitynow/` | 获取整个网络的管道风险概率 | network (query) | - |
| GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | network (query) | - |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpiperiskprobabilitynow/` | 查询指定管道在当前时刻的风险概率值 |
| `GET /getpiperiskprobability/` | 查询指定管道的历史风险概率时间序列数据 |
| `GET /getpipesriskprobability/` | 批量查询多条管道的风险概率,pipe_ids为逗号分隔的ID字符串(如 `pipe1,pipe2,pipe3` |
| `GET /getnetworkpiperiskprobabilitynow/` | 查询整个管网中所有管道的当前风险概率,返回列表 |
| `GET /getpiperiskprobabilitygeometries/` | 查询管网中管道的地理位置和风险相关几何数据,适合地图可视化 |
@@ -1,74 +0,0 @@
---
name: tjwater-action-analytics-simulation-analysis-simulation
description: analytics/simulation-analysis 下 simulation 操作技能。
version: 3.0.0
---
# simulation Action Skill
## 简介
负责 `analytics/simulation-analysis` 场景下 `simulation` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/runproject/` | 运行项目模拟 | network (query) | - |
| GET | `/api/v1/runprojectreturndict/` | 运行项目模拟(返回字典) | network (query) | - |
| GET | `/api/v1/runinp/` | 运行INP文件 | network (query) | - |
| GET | `/api/v1/dumpoutput/` | 导出模拟输出 | output (query) | - |
| GET | `/api/v1/burstanalysis/` | 爆管分析(基础) | network (query), pipe_id (query), start_time (query), end_time (query), burst_flow (query) | - |
| GET | `/api/v1/burst_analysis/` | 爆管分析(高级) | network (query), modify_pattern_start_time (query), burst_ID (query), burst_size (query), modify_total_duration (query), scheme_name (query) | - |
| GET | `/api/v1/valvecloseanalysis/` | 阀门关闭分析(基础) | network (query), valve_id (query), start_time (query), end_time (query) | - |
| GET | `/api/v1/valve_close_analysis/` | 阀门关闭分析(高级) | network (query), start_time (query), valves (query) | duration (query) |
| GET | `/api/v1/valve_isolation_analysis/` | 阀门隔离分析 | network (query), accident_element (query) | disabled_valves (query) |
| GET | `/api/v1/flushinganalysis/` | 冲洗分析(基础) | network (query), pipe_id (query), start_time (query), duration (query), flow (query) | - |
| GET | `/api/v1/flushing_analysis/` | 冲洗分析(高级) | network (query), start_time (query), valves (query), valves_k (query), drainage_node_ID (query) | flush_flow (query), duration (query), scheme_name (query) |
| GET | `/api/v1/contaminant_simulation/` | 污染物模拟 | network (query), start_time (query), source (query), concentration (query), duration (query) | scheme_name (query), pattern (query) |
| GET | `/api/v1/ageanalysis/` | 水龄分析(基础) | network (query) | - |
| GET | `/api/v1/age_analysis/` | 水龄分析(高级) | network (query), start_time (query), end_time (query), duration (query) | - |
| GET | `/api/v1/pressureregulation/` | 压力调节(基础) | network (query), target_node (query), target_pressure (query) | - |
| POST | `/api/v1/pressure_regulation/` | 压力调节(高级) | data (body) | - |
| GET | `/api/v1/projectmanagement/` | 项目管理(基础) | network (query) | - |
| POST | `/api/v1/project_management/` | 项目管理(高级) | data (body) | - |
| POST | `/api/v1/scheduling_analysis/` | 排程分析 | data (body) | - |
| POST | `/api/v1/daily_scheduling_analysis/` | 日排程分析 | data (body) | - |
| POST | `/api/v1/network_project/` | 导入网络项目 | file (file) | - |
| GET | `/api/v1/networkupdate/` | 管网更新(基础) | network (query) | - |
| POST | `/api/v1/network_update/` | 管网更新(高级) | file (file) | - |
| POST | `/api/v1/pump_failure/` | 泵故障管理 | data (body) | - |
| GET | `/api/v1/pressuresensorplacementsensitivity/` | 压力传感器放置-灵敏度分析(基础) | name (query), scheme_name (query), sensor_number (query), min_diameter (query), username (query) | - |
| POST | `/api/v1/pressure_sensor_placement_sensitivity/` | 压力传感器放置-灵敏度分析(高级) | data (body) | - |
| GET | `/api/v1/pressuresensorplacementkmeans/` | 压力传感器放置-KMeans聚类分析(基础) | name (query), scheme_name (query), sensor_number (query), min_diameter (query), username (query) | - |
| POST | `/api/v1/pressure_sensor_placement_kmeans/` | 压力传感器放置-KMeans聚类分析(高级) | data (body) | - |
| POST | `/api/v1/sensorplacementscheme/create` | 传感器放置方案创建 | network (query), scheme_name (query), sensor_type (query), method (query), sensor_count (query), user_name (query) | min_diameter (query) |
| POST | `/api/v1/runsimulationmanuallybydate/` | 手动运行日期指定模拟 | data (body) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /runproject/` | 运行标准水力模拟,返回纯文本格式的模拟报告 |
| `GET /runprojectreturndict/` | 运行标准水力模拟,返回JSON字典(含节点/管段结果数据),适合程序处理;结果可达30MB+ |
| `GET /runinp/` | 运行指定INP文件(文件放在inp文件夹中,参数为不含扩展名的文件名)进行水力模拟 |
| `GET /dumpoutput/` | 导出指定绝对路径的模拟输出文件内容 |
| `GET /burstanalysis/` | 基础爆管分析:对指定管道指定时间范围内的爆管事件进行分析,评估对压力/流量的影响 |
| `GET /burst_analysis/` | 高级爆管分析:支持在指定时间点修改泵控制模式和阀门开度,分析干预措施对爆管影响的作用;支持固定泵和变速泵独立控制 |
| `GET /valve_close_analysis/` | 高级阀门关闭分析:支持同时关闭多个阀门,指定持续时间,返回纯文本格式结果 |
| `GET /valve_isolation_analysis/` | 阀门隔离分析:分析突发事件时通过关闭指定阀门进行隔离,确定必须关闭阀门、可选关闭阀门及隔离可行性 |
| `GET /flushing_analysis/` | 高级冲洗分析:支持同时开启多个阀门冲洗,指定排污节点,设置固定冲洗流量,返回纯文本结果 |
| `GET /contaminant_simulation/` | 污染物模拟:评估污染源对管网的影响范围和浓度分布,支持指定污染位置、浓度和扩散模式 |
| `GET /age_analysis/` | 高级水龄分析:在指定时间点分析水体停留时间,支持自定义模拟持续时间,返回纯文本结果 |
| `POST /pressure_regulation/` | 高级压力调节:通过JSON体提供详细控制参数(固定泵/变速泵独立控制、水箱初始水位等)进行压力优化 |
| `POST /project_management/` | 高级项目管理:通过JSON体提供详细参数(泵控制策略、水箱水位、区域需水量控制)进行管网管理 |
| `POST /scheduling_analysis/` | 排程分析:优化泵运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求 |
| `POST /daily_scheduling_analysis/` | 日排程分析:优化水库、水厂、水箱和用户需求协调,制定合理的每日排程方案 |
| `POST /pump_failure/` | 泵故障管理:记录故障发生时间和受影响的泵列表,更新泵状态日志 |
| `POST /pressure_sensor_placement_sensitivity/` | 高级传感器放置(灵敏度法):通过JSON体提供详细参数,基于灵敏度矩阵确定最优放置位置 |
| `POST /pressure_sensor_placement_kmeans/` | 高级传感器放置(KMeans法):通过JSON体提供详细参数,基于聚类算法确定最优放置位置 |
| `POST /sensorplacementscheme/create` | 创建传感器放置方案:支持 sensitivity 和 kmeans 两种算法,自动计算最优传感器位置并存储方案 |
| `POST /runsimulationmanuallybydate/` | 按日期手动运行模拟:根据指定日期、开始时间和持续时间查询管网参数并执行水力模拟 |
-17
View File
@@ -1,17 +0,0 @@
---
name: tjwater-domain-business
description: 负责业务逻辑、业务对象与项目侧 API 能力。
version: 3.0.0
---
# Business Domain Skill
## 简介
负责业务逻辑、业务对象与项目侧 API 能力。
## 子模块索引 (渐进式引导)
- **component-config**: 见 `./component-config/SKILL.md`
- **identity-access**: 见 `./identity-access/SKILL.md`
- **network-assets**: 见 `./network-assets/SKILL.md`
- **project-workspace**: 见 `./project-workspace/SKILL.md`
@@ -1,19 +0,0 @@
---
name: tjwater-scenario-business-component-config
description: 负责组件配置与参数管理。
version: 3.0.0
---
# component-config Scenario Skill
## 简介
负责组件配置与参数管理。
## 子模块索引 (渐进式引导)
- **controls**: 见 `./controls/SKILL.md`
- **curves**: 见 `./curves/SKILL.md`
- **options**: 见 `./options/SKILL.md`
- **patterns**: 见 `./patterns/SKILL.md`
- **quality**: 见 `./quality/SKILL.md`
- **visuals**: 见 `./visuals/SKILL.md`
@@ -1,36 +0,0 @@
---
name: tjwater-action-business-component-config-controls
description: business/component-config 下 controls 操作技能。
version: 3.0.0
---
# controls Action Skill
## 简介
负责 `business/component-config` 场景下 `controls` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getcontrolproperties/` | 获取控制属性 | network (query) | - |
| GET | `/api/v1/getcontrolschema/` | 获取控制架构 | network (query) | - |
| GET | `/api/v1/getruleproperties/` | 获取规则属性 | network (query) | - |
| GET | `/api/v1/getruleschema/` | 获取规则架构 | network (query) | - |
| POST | `/api/v1/setcontrolproperties/` | 设置控制属性 | network (query) | - |
| POST | `/api/v1/setruleproperties/` | 设置规则属性 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getcontrolschema/` | 返回简单控制(Control)数据模型的字段定义 |
| `GET /getcontrolproperties/` | 获取管网中所有简单控制规则的属性列表 |
| `GET /getruleschema/` | 返回规则控制(Rule)数据模型的字段定义 |
| `GET /getruleproperties/` | 获取管网中所有基于规则的复杂控制条件列表 |
| `POST /setcontrolproperties/` | 设置/更新简单控制规则的属性 |
| `POST /setruleproperties/` | 设置/更新规则控制的属性 |
@@ -1,37 +0,0 @@
---
name: tjwater-action-business-component-config-curves
description: business/component-config 下 curves 操作技能。
version: 3.0.0
---
# curves Action Skill
## 简介
负责 `business/component-config` 场景下 `curves` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addcurve/` | 添加曲线 | network (query), curve (query) | - |
| POST | `/api/v1/deletecurve/` | 删除曲线 | network (query), curve (query) | - |
| GET | `/api/v1/getcurveproperties/` | 获取曲线属性 | network (query), curve (query) | - |
| GET | `/api/v1/getcurves/` | 获取所有曲线 | network (query) | - |
| GET | `/api/v1/getcurveschema` | 获取曲线架构 | network (query) | - |
| GET | `/api/v1/iscurve/` | 检查曲线存在性 | network (query), curve (query) | - |
| POST | `/api/v1/setcurveproperties/` | 设置曲线属性 | network (query), curve (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getcurveschema` | 返回曲线(Curve)数据模型的字段定义(水泵特性曲线、效率曲线等) |
| `GET /getcurves/` | 获取管网中所有曲线的ID列表 |
| `GET /getcurveproperties/` | 查询指定曲线的详细属性(类型、控制点数据等) |
| `POST /addcurve/` | 向管网添加一条新曲线 |
| `POST /deletecurve/` | 从管网删除指定曲线 |
| `POST /setcurveproperties/` | 设置/更新曲线的属性(控制点坐标等) |
@@ -1,43 +0,0 @@
---
name: tjwater-action-business-component-config-options
description: business/component-config 下 options 操作技能。
version: 3.0.0
---
# options Action Skill
## 简介
负责 `business/component-config` 场景下 `options` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getenergyproperties/` | 获取能耗选项属性 | network (query) | - |
| GET | `/api/v1/getenergyschema/` | 获取能耗选项架构 | network (query) | - |
| GET | `/api/v1/getoptionproperties/` | 获取选项属性 | network (query) | - |
| GET | `/api/v1/getoptionschema/` | 获取选项架构 | network (query) | - |
| GET | `/api/v1/getpumpenergyproperties/` | 获取泵能耗属性 | - | - |
| GET | `/api/v1/getpumpenergyschema/` | 获取泵能耗选项架构 | network (query) | - |
| GET | `/api/v1/gettimeproperties/` | 获取时间选项属性 | network (query) | - |
| GET | `/api/v1/gettimeschema` | 获取时间选项架构 | network (query) | - |
| POST | `/api/v1/setenergyproperties/` | 设置能耗选项属性 | network (query) | - |
| POST | `/api/v1/setoptionproperties/` | 设置选项属性 | network (query) | - |
| GET | `/api/v1/setpumpenergyproperties/` | 设置泵能耗属性 | - | - |
| POST | `/api/v1/settimeproperties/` | 设置时间选项属性 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getoptionschema/` | 返回模拟选项(Options)数据模型的字段定义 |
| `GET /getoptionproperties/` | 获取管网模拟参数设置(时步、精度、单位系统等) |
| `GET /getenergyschema/` | 返回能耗选项(Energy)数据模型的字段定义 |
| `GET /getenergyproperties/` | 获取全局能耗设置(电价、效率等) |
| `GET /getpumpenergyproperties/` | 获取单台水泵的能耗参数 |
| `POST /setoptionproperties/` | 设置管网模拟参数 |
| `POST /setenergyproperties/` | 设置全局能耗参数 |
@@ -1,37 +0,0 @@
---
name: tjwater-action-business-component-config-patterns
description: business/component-config 下 patterns 操作技能。
version: 3.0.0
---
# patterns Action Skill
## 简介
负责 `business/component-config` 场景下 `patterns` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addpattern/` | 添加模式 | network (query), pattern (query) | - |
| POST | `/api/v1/deletepattern/` | 删除模式 | network (query), pattern (query) | - |
| GET | `/api/v1/getpatternproperties/` | 获取模式属性 | network (query), pattern (query) | - |
| GET | `/api/v1/getpatterns/` | 获取所有模式 | network (query) | - |
| GET | `/api/v1/getpatternschema` | 获取模式架构 | network (query) | - |
| GET | `/api/v1/ispattern/` | 检查模式存在性 | network (query), pattern (query) | - |
| POST | `/api/v1/setpatternproperties/` | 设置模式属性 | network (query), pattern (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpatternschema` | 返回模式(Pattern)数据模型的字段定义 |
| `GET /getpatterns/` | 获取管网中所有时间模式的ID列表 |
| `GET /getpatternproperties/` | 查询指定模式的属性(时间序列乘数值等) |
| `POST /addpattern/` | 向管网添加一个新时间模式 |
| `POST /deletepattern/` | 从管网删除指定时间模式 |
| `POST /setpatternproperties/` | 设置/更新时间模式的属性(乘数序列) |
@@ -1,59 +0,0 @@
---
name: tjwater-action-business-component-config-quality
description: business/component-config 下 quality 操作技能。
version: 3.0.0
---
# quality Action Skill
## 简介
负责 `business/component-config` 场景下 `quality` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addmixing/` | 添加混合 | network (query) | - |
| POST | `/api/v1/addsource/` | 添加水源 | network (query) | - |
| POST | `/api/v1/deletemixing/` | 删除混合 | network (query) | - |
| POST | `/api/v1/deletesource/` | 删除水源 | network (query), node (query) | - |
| GET | `/api/v1/getemitterproperties/` | 获取发射器属性 | network (query), junction (query) | - |
| GET | `/api/v1/getemitterschema` | 获取发射器架构 | network (query) | - |
| GET | `/api/v1/getmixing/` | 获取混合属性 | network (query), tank (query) | - |
| GET | `/api/v1/getmixingschema/` | 获取混合架构 | network (query) | - |
| GET | `/api/v1/getpipereaction/` | 获取管道反应属性 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipereactionschema/` | 获取管道反应架构 | network (query) | - |
| GET | `/api/v1/getqualityproperties/` | 获取水质属性 | network (query), node (query) | - |
| GET | `/api/v1/getqualityschema/` | 获取水质架构 | network (query) | - |
| GET | `/api/v1/getreaction/` | 获取反应属性 | network (query) | - |
| GET | `/api/v1/getreactionschema/` | 获取反应架构 | network (query) | - |
| GET | `/api/v1/getsource/` | 获取水源属性 | network (query), node (query) | - |
| GET | `/api/v1/getsourcechema/` | 获取水源架构 | network (query) | - |
| GET | `/api/v1/gettankreaction/` | 获取水池反应属性 | network (query), tank (query) | - |
| GET | `/api/v1/gettankreactionschema/` | 获取水池反应架构 | network (query) | - |
| POST | `/api/v1/setemitterproperties/` | 设置发射器属性 | network (query), junction (query) | - |
| POST | `/api/v1/setmixing/` | 设置混合属性 | network (query) | - |
| POST | `/api/v1/setpipereaction/` | 设置管道反应属性 | network (query) | - |
| POST | `/api/v1/setqualityproperties/` | 设置水质属性 | network (query) | - |
| POST | `/api/v1/setreaction/` | 设置反应属性 | network (query) | - |
| POST | `/api/v1/setsource/` | 设置水源属性 | network (query) | - |
| POST | `/api/v1/settankreaction/` | 设置水池反应属性 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getemitterproperties/` | 查询节点处发射器(用于模拟管漏)的属性 |
| `GET /getmixingproperties/` | 查询水箱水质混合模型属性 |
| `GET /getsourceproperties/` | 查询水质污染源的属性(位置、类型、浓度等) |
| `GET /getreactionproperties/` | 获取全局水质反应参数(管网反应系数等) |
| `GET /getwaterqualityresult/` | 查询水质模拟结果 |
| `POST /addsource/` | 向管网添加一个水质污染源 |
| `POST /deletesource/` | 删除指定水质污染源 |
| `POST /addmixing/` | 为水箱添加水质混合模型 |
| `POST /deletemixing/` | 删除水箱水质混合模型 |
| `POST /setemitter*/` | 设置发射器属性(流量系数等) |
@@ -1,45 +0,0 @@
---
name: tjwater-action-business-component-config-visuals
description: business/component-config 下 visuals 操作技能。
version: 3.0.0
---
# visuals Action Skill
## 简介
负责 `business/component-config` 场景下 `visuals` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addlabel/` | 添加标签 | network (query) | - |
| POST | `/api/v1/addvertex/` | 添加图形元素 | network (query) | - |
| POST | `/api/v1/deletelabel/` | 删除标签 | network (query) | - |
| POST | `/api/v1/deletevertex/` | 删除图形元素 | network (query) | - |
| GET | `/api/v1/getallvertexlinks/` | 获取所有图形元素链接 | network (query) | - |
| GET | `/api/v1/getallvertices/` | 获取所有图形元素 | network (query) | - |
| GET | `/api/v1/getbackdropproperties/` | 获取背景属性 | network (query) | - |
| GET | `/api/v1/getbackdropschema/` | 获取背景架构 | network (query) | - |
| GET | `/api/v1/getlabelproperties/` | 获取标签属性 | network (query), x (query), y (query) | - |
| GET | `/api/v1/getlabelschema/` | 获取标签架构 | network (query) | - |
| GET | `/api/v1/getvertexproperties/` | 获取图形元素属性 | network (query), link (query) | - |
| GET | `/api/v1/getvertexschema/` | 获取图形元素架构 | network (query) | - |
| POST | `/api/v1/setbackdropproperties/` | 设置背景属性 | network (query) | - |
| POST | `/api/v1/setlabelproperties/` | 设置标签属性 | network (query) | - |
| POST | `/api/v1/setvertexproperties/` | 设置图形元素属性 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallvertexlinks/` | 获取所有管线的折点列表(用于地图还原管线真实走向) |
| `GET /getvertexlink/` | 获取单条管线的折点坐标序列 |
| `POST /addvertex/` | 为管线添加一个折点(改变管线显示路径) |
| `POST /deletevertex/` | 删除管线上的指定折点 |
| `POST /addlabel/` | 在地图上添加文字标注 |
| `POST /deletelabel/` | 删除地图文字标注 |
@@ -1,16 +0,0 @@
---
name: tjwater-scenario-business-identity-access
description: 负责认证、用户与权限相关操作。
version: 3.0.0
---
# identity-access Scenario Skill
## 简介
负责认证、用户与权限相关操作。
## 子模块索引 (渐进式引导)
- **auth**: 见 `./auth/SKILL.md`
- **user_management**: 见 `./user_management/SKILL.md`
- **users**: 见 `./users/SKILL.md`
@@ -1,32 +0,0 @@
---
name: tjwater-action-business-identity-access-auth
description: business/identity-access 下 auth 操作技能。
version: 3.0.0
---
# auth Action Skill
## 简介
负责 `business/identity-access` 场景下 `auth` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/auth/login` | 用户登录 | form_data (body) | - |
| POST | `/api/v1/auth/login/simple` | 简化版登录 | username (query), password (query) | - |
| GET | `/api/v1/auth/me` | 获取当前用户信息 | - | - |
| POST | `/api/v1/auth/refresh` | 刷新AccessToken | refresh_token (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /login` | OAuth2标准格式登录,提交form-datausername+password),返回JWT Access Token和Refresh Token |
| `POST /login/simple` | 简化版登录,直接通过query参数传递username和password,保持向后兼容 |
| `GET /me` | 返回当前已登录用户的详细信息(需携带Access Token |
| `POST /refresh` | 使用Refresh Token换取新的Access Token,延续会话 |
@@ -1,36 +0,0 @@
---
name: tjwater-action-business-identity-access-user-management
description: business/identity-access 下 user-management 操作技能。
version: 3.0.0
---
# user-management Action Skill
## 简介
负责 `business/identity-access` 场景下 `user-management` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/users/` | 列出所有用户 | - | skip (query), limit (query) |
| DELETE | `/api/v1/users/{user_id}` | 删除用户 | user_id (path) | - |
| GET | `/api/v1/users/{user_id}` | 获取用户详情 | user_id (path) | - |
| PUT | `/api/v1/users/{user_id}` | 更新用户信息 | user_id (path) | user_update (body) |
| POST | `/api/v1/users/{user_id}/activate` | 激活用户 | user_id (path) | - |
| POST | `/api/v1/users/{user_id}/deactivate` | 停用用户 | user_id (path) | - |
- 覆盖方法:`DELETE, GET, POST, PUT`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /users/` | 列出系统中所有用户(管理员权限),支持分页(skip/limit |
| `GET /users/{user_id}` | 按用户ID查询单个用户的详细信息 |
| `PUT /users/{user_id}` | 更新指定用户的信息(邮箱、角色、密码等),请求体为 user_update 对象 |
| `DELETE /users/{user_id}` | 删除指定用户(软删除或硬删除) |
| `POST /users/{user_id}/activate` | 激活指定用户账号(管理员操作) |
| `POST /users/{user_id}/deactivate` | 停用指定用户账号(禁止登录,管理员操作) |
@@ -1,30 +0,0 @@
---
name: tjwater-action-business-identity-access-users
description: business/identity-access 下 users 操作技能。
version: 3.0.0
---
# users Action Skill
## 简介
负责 `business/identity-access` 场景下 `users` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getallusers/` | 获取所有用户 | network (query) | - |
| GET | `/api/v1/getuser/` | 获取单个用户 | network (query), user_name (query) | - |
| GET | `/api/v1/getuserschema/` | 获取用户模式 | network (query) | - |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallusers/` | 获取指定管网下的所有用户列表(旧版接口,返回管网级别用户信息) |
| `GET /getuser/` | 按用户名查询指定管网下的单个用户信息 |
| `GET /getuserschema/` | 获取用户数据模型的字段定义(Schema) |
@@ -1,24 +0,0 @@
---
name: tjwater-scenario-business-network-assets
description: 负责管网资产与拓扑对象操作。
version: 3.0.0
---
# network-assets Scenario Skill
## 简介
负责管网资产与拓扑对象操作。
## 子模块索引 (渐进式引导)
- **demands**: 见 `./demands/SKILL.md`
- **general**: 见 `./general/SKILL.md`
- **geometry**: 见 `./geometry/SKILL.md`
- **junctions**: 见 `./junctions/SKILL.md`
- **pipes**: 见 `./pipes/SKILL.md`
- **pumps**: 见 `./pumps/SKILL.md`
- **regions**: 见 `./regions/SKILL.md`
- **reservoirs**: 见 `./reservoirs/SKILL.md`
- **tags**: 见 `./tags/SKILL.md`
- **tanks**: 见 `./tanks/SKILL.md`
- **valves**: 见 `./valves/SKILL.md`
@@ -1,36 +0,0 @@
---
name: tjwater-action-business-network-assets-demands
description: business/network-assets 下 demands 操作技能。
version: 3.0.0
---
# demands Action Skill
## 简介
负责 `business/network-assets` 场景下 `demands` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/calculatedemandtonetwork/` | 计算需水量到整网分配 | network (query), demand (query) | - |
| GET | `/api/v1/calculatedemandtonodes/` | 计算需水量到节点分配 | network (query) | - |
| GET | `/api/v1/calculatedemandtoregion/` | 计算需水量到区域分配 | network (query) | - |
| GET | `/api/v1/getdemandproperties/` | 获取需水量属性 | network (query), junction (query) | - |
| GET | `/api/v1/getdemandschema` | 获取需水量属性架构 | network (query) | - |
| POST | `/api/v1/setdemandproperties/` | 设置需水量属性 | network (query), junction (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getdemandschema` | 返回需水量(Demand)数据模型的字段定义 |
| `GET /getdemandproperties/` | 查询指定节点的需水量属性(基础需水量、模式等) |
| `POST /setdemandproperties/` | 设置节点的需水量属性 |
| `POST /calculatedemandtonodes/` | 将指定总需水量计算分配到各节点 |
| `POST /calculatedemandtoregion/` | 将指定总需水量计算分配到指定区域内的节点 |
| `POST /calculatedemandtonetwork/` | 将指定总需水量按比例分配到整个管网的所有节点 |
@@ -1,62 +0,0 @@
---
name: tjwater-action-business-network-assets-general
description: business/network-assets 下 general 操作技能。
version: 3.0.0
---
# general Action Skill
## 简介
负责 `business/network-assets` 场景下 `general` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/deletelink/` | 删除管线 | network (query), link (query) | - |
| POST | `/api/v1/deletenode/` | 删除节点 | network (query), node (query) | - |
| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA点属性 | network (query) | - |
| GET | `/api/v1/getelementproperties/` | 获取元素属性 | network (query), element (query) | - |
| GET | `/api/v1/getelementpropertieswithtype/` | 获取指定类型元素属性 | network (query), elementtype (query), element (query) | - |
| GET | `/api/v1/getelementtype/` | 获取元素类型 | network (query), element (query) | - |
| GET | `/api/v1/getelementtypevalue/` | 获取元素类型值 | network (query), element (query) | - |
| GET | `/api/v1/getlinkproperties/` | 获取管线属性 | network (query), link (query) | - |
| GET | `/api/v1/getlinks/` | 获取所有管线 | network (query) | - |
| GET | `/api/v1/getlinktype/` | 获取管线类型 | network (query), link (query) | - |
| GET | `/api/v1/getnodelinks/` | 获取节点的关联管线 | network (query), node (query) | - |
| GET | `/api/v1/getnodeproperties/` | 获取节点属性 | network (query), node (query) | - |
| GET | `/api/v1/getnodes/` | 获取所有节点 | network (query) | - |
| GET | `/api/v1/getnodetype/` | 获取节点类型 | network (query), node (query) | - |
| GET | `/api/v1/getscadaproperties/` | 获取SCADA点属性 | network (query), scada (query) | - |
| GET | `/api/v1/getstatus/` | 获取管线状态 | network (query), link (query) | - |
| GET | `/api/v1/getstatusschema` | 获取状态属性架构 | network (query) | - |
| GET | `/api/v1/gettitle/` | 获取水网标题属性 | network (query) | - |
| GET | `/api/v1/gettitleschema/` | 获取标题属性架构 | network (query) | - |
| GET | `/api/v1/isjunction/` | 检查是否为接点 | network (query), node (query) | - |
| GET | `/api/v1/islink/` | 检查管线有效性 | network (query), link (query) | - |
| GET | `/api/v1/isnode/` | 检查节点有效性 | network (query), node (query) | - |
| GET | `/api/v1/ispipe/` | 检查是否为管道 | network (query), link (query) | - |
| GET | `/api/v1/ispump/` | 检查是否为泵 | network (query), link (query) | - |
| GET | `/api/v1/isreservoir/` | 检查是否为水源 | network (query), node (query) | - |
| GET | `/api/v1/istank/` | 检查是否为蓄水池 | network (query), node (query) | - |
| GET | `/api/v1/isvalve/` | 检查是否为阀门 | network (query), link (query) | - |
| POST | `/api/v1/setstatus/` | 设置管线状态 | network (query), link (query) | - |
| GET | `/api/v1/settitle/` | 设置水网标题属性 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getelementtype/` | 查询指定ID元素的类型(Junction/Pipe/Pump/Tank/Valve/Reservoir |
| `GET /getelementtypevalue/` | 查询指定ID元素的类型编码值 |
| `GET /getelementproperties/` | 查询指定ID元素的所有属性(自动识别类型) |
| `GET /getelementpropertieswithtype/` | 查询指定类型和ID的元素属性 |
| `GET /getlinkproperties/` | 查询管线(Pipe/Pump/Valve)的属性 |
| `GET /getnodeproperties/` | 查询节点(Junction/Tank/Reservoir)的属性 |
| `GET /settitle/` | 设置管网标题属性 |
| `POST /deletelink/` | 删除管线(管道/水泵/阀门) |
| `POST /deletenode/` | 删除节点(节点/水箱/水库) |
@@ -1,36 +0,0 @@
---
name: tjwater-action-business-network-assets-geometry
description: business/network-assets 下 geometry 操作技能。
version: 3.0.0
---
# geometry Action Skill
## 简介
负责 `business/network-assets` 场景下 `geometry` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getmajornodecoords/` | 获取主要节点坐标 | network (query), diameter (query) | - |
| GET | `/api/v1/getmajorpipenodes/` | 获取主要管道节点 | network (query), diameter (query) | - |
| GET | `/api/v1/getnetworkgeometries/` | 获取完整网络几何信息 | network (query) | - |
| GET | `/api/v1/getnetworkinextent/` | 获取范围内的网络元素 | network (query), x1 (query), y1 (query), x2 (query), y2 (query) | - |
| GET | `/api/v1/getnetworklinknodes/` | 获取网络管线节点 | network (query) | - |
| GET | `/api/v1/getnodecoord/` | 获取节点坐标 | network (query), node (query) | - |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getnodecoord/` | 查询单个节点(任意类型)的坐标(x, y) |
| `GET /getmajornodecoords/` | 获取管网主要节点(干管节点)的坐标列表,用于快速渲染 |
| `GET /getmajorpipenodes/` | 获取主要管道的起终节点列表 |
| `GET /getnetworklinknodes/` | 获取管网中所有管线的起终节点信息 |
| `GET /getnetworkgeometries/` | 获取整个管网的完整几何信息(节点坐标 + 管线折点),适合地图绘制 |
| `GET /getnetworkinextent/` | 查询指定地理范围(bbox)内的管网节点和管线 |
@@ -1,52 +0,0 @@
---
name: tjwater-action-business-network-assets-junctions
description: business/network-assets 下 junctions 操作技能。
version: 3.0.0
---
# junctions Action Skill
## 简介
负责 `business/network-assets` 场景下 `junctions` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addjunction/` | 添加节点 | network (query), junction (query), x (query), y (query), z (query) | - |
| POST | `/api/v1/deletejunction/` | 删除节点 | network (query), junction (query) | - |
| GET | `/api/v1/getalljunctionproperties/` | 获取所有节点属性 | network (query) | - |
| GET | `/api/v1/getjunctioncoord/` | 获取节点坐标 | network (query), junction (query) | - |
| GET | `/api/v1/getjunctiondemand/` | 获取节点需水量 | network (query), junction (query) | - |
| GET | `/api/v1/getjunctionelevation/` | 获取节点标高 | network (query), junction (query) | - |
| GET | `/api/v1/getjunctionpattern/` | 获取节点需水模式 | network (query), junction (query) | - |
| GET | `/api/v1/getjunctionproperties/` | 获取节点属性 | network (query), junction (query) | - |
| GET | `/api/v1/getjunctionschema` | 获取节点架构 | network (query) | - |
| GET | `/api/v1/getjunctionx/` | 获取节点 X 坐标 | network (query), junction (query) | - |
| GET | `/api/v1/getjunctiony/` | 获取节点 Y 坐标 | network (query), junction (query) | - |
| POST | `/api/v1/setjunctioncoord/` | 设置节点坐标 | network (query), junction (query), x (query), y (query) | - |
| POST | `/api/v1/setjunctiondemand/` | 设置节点需水量 | network (query), junction (query), demand (query) | - |
| POST | `/api/v1/setjunctionelevation/` | 设置节点标高 | network (query), junction (query), elevation (query) | - |
| POST | `/api/v1/setjunctionpattern/` | 设置节点需水模式 | network (query), junction (query), pattern (query) | - |
| POST | `/api/v1/setjunctionproperties/` | 批量设置节点属性 | network (query), junction (query) | - |
| POST | `/api/v1/setjunctionx/` | 设置节点 X 坐标 | network (query), junction (query), x (query) | - |
| POST | `/api/v1/setjunctiony/` | 设置节点 Y 坐标 | network (query), junction (query), y (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getjunctionschema` | 返回节点(Junction)数据模型的所有字段定义 |
| `GET /getjunctionproperties/` | 查询单个节点的所有属性(标高、需水量、坐标等) |
| `GET /getalljunctionproperties/` | 批量获取管网中所有节点的属性列表 |
| `GET /getjunctioncoord/` | 查询单个节点的坐标(x, y) |
| `GET /getjunctionelevation/` | 查询节点标高值 |
| `GET /getjunctiondemand/` | 查询节点基础需水量 |
| `GET /getjunctionpattern/` | 查询节点关联的需水时间模式名称 |
| `POST /addjunction/` | 向管网添加一个新节点,需提供ID、坐标和标高 |
| `POST /deletejunction/` | 从管网删除指定节点 |
| `POST /setjunction*/` | 设置节点某个具体属性(坐标、标高、需水量等) |
@@ -1,54 +0,0 @@
---
name: tjwater-action-business-network-assets-pipes
description: business/network-assets 下 pipes 操作技能。
version: 3.0.0
---
# pipes Action Skill
## 简介
负责 `business/network-assets` 场景下 `pipes` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addpipe/` | 添加管道 | network (query), pipe (query), node1 (query), node2 (query) | length (query), diameter (query), roughness (query), minor_loss (query), status (query) |
| POST | `/api/v1/deletepipe/` | 删除管道 | network (query), pipe (query) | - |
| GET | `/api/v1/getallpipeproperties/` | 获取所有管道属性 | network (query) | - |
| GET | `/api/v1/getpipediameter/` | 获取管道管径 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipelength/` | 获取管道长度 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipeminorloss/` | 获取管道局部阻力系数 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipenode1/` | 获取管道起始节点 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipenode2/` | 获取管道终止节点 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipeproperties/` | 获取管道属性 | network (query), pipe (query) | - |
| GET | `/api/v1/getpiperoughness/` | 获取管道粗糙度 | network (query), pipe (query) | - |
| GET | `/api/v1/getpipeschema` | 获取管道模式 | network (query) | - |
| GET | `/api/v1/getpipestatus/` | 获取管道状态 | network (query), pipe (query) | - |
| POST | `/api/v1/setpipediameter/` | 设置管道管径 | network (query), pipe (query), diameter (query) | - |
| POST | `/api/v1/setpipelength/` | 设置管道长度 | network (query), pipe (query), length (query) | - |
| POST | `/api/v1/setpipeminorloss/` | 设置管道局部阻力系数 | network (query), pipe (query), minor_loss (query) | - |
| POST | `/api/v1/setpipenode1/` | 设置管道起始节点 | network (query), pipe (query), node1 (query) | - |
| POST | `/api/v1/setpipenode2/` | 设置管道终止节点 | network (query), pipe (query), node2 (query) | - |
| POST | `/api/v1/setpipeproperties/` | 设置管道属性 | network (query), pipe (query) | - |
| POST | `/api/v1/setpiperoughness/` | 设置管道粗糙度 | network (query), pipe (query), roughness (query) | - |
| POST | `/api/v1/setpipestatus/` | 设置管道状态 | network (query), pipe (query), status (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpipeschema` | 返回管道(Pipe)数据模型的所有字段定义 |
| `GET /getpipeproperties/` | 查询单条管道的所有属性(管径、长度、起终节点等) |
| `GET /getallpipeproperties/` | 批量获取管网中所有管道的属性列表 |
| `GET /getpipelength/` | 查询管道长度 |
| `GET /getpipediameter/` | 查询管道管径 |
| `GET /getpipestatus/` | 查询管道当前状态(开/关/CV) |
| `GET /getpiperoughness/` | 查询管道粗糙系数 |
| `POST /addpipe/` | 向管网添加一条新管道,需提供ID、起终节点、长度和管径 |
| `POST /deletepipe/` | 从管网删除指定管道 |
| `POST /setpipe*/` | 设置管道某个具体属性(管径、长度、状态等) |
@@ -1,42 +0,0 @@
---
name: tjwater-action-business-network-assets-pumps
description: business/network-assets 下 pumps 操作技能。
version: 3.0.0
---
# pumps Action Skill
## 简介
负责 `business/network-assets` 场景下 `pumps` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addpump/` | 添加水泵 | network (query), pump (query), node1 (query), node2 (query) | power (query) |
| POST | `/api/v1/deletepump/` | 删除水泵 | network (query), pump (query) | - |
| GET | `/api/v1/getallpumpproperties/` | 获取所有水泵属性 | network (query) | - |
| GET | `/api/v1/getpumpnode1/` | 获取水泵起始节点 | network (query), pump (query) | - |
| GET | `/api/v1/getpumpnode2/` | 获取水泵终止节点 | network (query), pump (query) | - |
| GET | `/api/v1/getpumpproperties/` | 获取水泵属性 | network (query), pump (query) | - |
| GET | `/api/v1/getpumpschema` | 获取水泵模式 | network (query) | - |
| POST | `/api/v1/setpumpnode1/` | 设置水泵起始节点 | network (query), pump (query), node1 (query) | - |
| POST | `/api/v1/setpumpnode2/` | 设置水泵终止节点 | network (query), pump (query), node2 (query) | - |
| POST | `/api/v1/setpumpproperties/` | 设置水泵属性 | network (query), pump (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpumpschema` | 返回水泵(Pump)数据模型的所有字段定义 |
| `GET /getpumpproperties/` | 查询单台水泵的所有属性(曲线名称、起终节点等) |
| `GET /getallpumpproperties/` | 批量获取管网中所有水泵的属性列表 |
| `POST /addpump/` | 向管网添加一台新水泵,需提供ID和起终节点 |
| `POST /deletepump/` | 从管网删除指定水泵 |
| `POST /setpumpproperties/` | 批量设置水泵属性(曲线、初始状态、效率等) |
| `POST /setpumpnode1/` | 设置水泵起始节点 |
| `POST /setpumpnode2/` | 设置水泵终止节点 |
@@ -1,69 +0,0 @@
---
name: tjwater-action-business-network-assets-regions
description: business/network-assets 下 regions 操作技能。
version: 3.0.1
---
# regions Action Skill
## 简介
负责 `business/network-assets` 场景下 `regions` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/adddistrictmeteringarea/` | 添加新DMA | network (query) | - |
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
| POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - |
| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
| GET | `/api/v1/calculateservicearea/` | 计算服务区(返回全部时间步) | network (query) | - |
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | network (query) | - |
| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | network (query), inflate_delta (query) | - |
| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | network (query), dma (query), part_count (query), part_type (query), inflate_delta (query) | - |
| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
| GET | `/api/v1/getdistrictmeteringareaschema/` | 获取DMA属性架构 | network (query) | - |
| GET | `/api/v1/getregion/` | 获取区域信息 | network (query), id (query) | - |
| GET | `/api/v1/getregionschema/` | 获取区域属性架构 | network (query) | - |
| GET | `/api/v1/getservicearea/` | 获取服务区信息 | network (query), id (query) | - |
| GET | `/api/v1/getserviceareaschema/` | 获取服务区属性架构 | network (query) | - |
| GET | `/api/v1/getvirtualdistrict/` | 获取虚拟分区信息 | network (query), id (query) | - |
| GET | `/api/v1/getvirtualdistrictschema/` | 获取虚拟分区属性架构 | network (query) | - |
| POST | `/api/v1/setdistrictmeteringarea/` | 设置DMA属性 | network (query) | - |
| POST | `/api/v1/setregion/` | 设置区域属性 | network (query) | - |
| POST | `/api/v1/setservicearea/` | 设置服务区属性 | network (query) | - |
| POST | `/api/v1/setvirtualdistrict/` | 设置虚拟分区属性 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getregionschema` | 返回区域(Region)数据模型的字段定义 |
| `GET /getregion/` | 查询单个区域的属性 |
| `GET /getalldistrictmeteringareas/` | 获取所有 DMA(独立计量区)列表 |
| `GET /getallserviceareas/` | 获取所有服务区列表 |
| `POST /addregion/` | 新增区域(需提供名称和节点/管道列表) |
| `POST /adddistrictmeteringarea/` | 新增 DMA 分区 |
| `POST /addvirtualdistrict/` | 新增虚拟分区 |
| `POST /addservicearea/` | 新增服务区 |
| `GET /calculatedistrictmeteringareafornodes/` | 为指定节点集合计算其所属 DMA |
| `GET /calculatedistrictmeteringareaforregion/` | 为指定区域内的所有节点计算 DMA 归属 |
| `GET /calculatedistrictmeteringareafornetwork/` | 为整个管网的所有节点计算 DMA 归属 |
| `GET /calculateservicearea/` | 计算服务区,返回全部时间步结果 |
@@ -1,48 +0,0 @@
---
name: tjwater-action-business-network-assets-reservoirs
description: business/network-assets 下 reservoirs 操作技能。
version: 3.0.0
---
# reservoirs Action Skill
## 简介
负责 `business/network-assets` 场景下 `reservoirs` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addreservoir/` | 添加水库 | network (query), reservoir (query), x (query), y (query), head (query) | - |
| POST | `/api/v1/deletereservoir/` | 删除水库 | network (query), reservoir (query) | - |
| GET | `/api/v1/getallreservoirproperties/` | 获取所有水库属性 | network (query) | - |
| GET | `/api/v1/getreservoircoord/` | 获取水库坐标 | network (query), reservoir (query) | - |
| GET | `/api/v1/getreservoirhead/` | 获取水库水头 | network (query), reservoir (query) | - |
| GET | `/api/v1/getreservoirpattern/` | 获取水库模式 | network (query), reservoir (query) | - |
| GET | `/api/v1/getreservoirproperties/` | 获取水库属性 | network (query), reservoir (query) | - |
| GET | `/api/v1/getreservoirschema` | 获取水库模式 | network (query) | - |
| GET | `/api/v1/getreservoirx/` | 获取水库X坐标 | network (query), reservoir (query) | - |
| GET | `/api/v1/getreservoiry/` | 获取水库Y坐标 | network (query), reservoir (query) | - |
| POST | `/api/v1/setreservoircoord/` | 设置水库坐标 | network (query), reservoir (query), x (query), y (query) | - |
| POST | `/api/v1/setreservoirhead/` | 设置水库水头 | network (query), reservoir (query), head (query) | - |
| POST | `/api/v1/setreservoirpattern/` | 设置水库模式 | network (query), reservoir (query), pattern (query) | - |
| POST | `/api/v1/setreservoirproperties/` | 设置水库属性 | network (query), reservoir (query) | - |
| POST | `/api/v1/setreservoirx/` | 设置水库X坐标 | network (query), reservoir (query), x (query) | - |
| POST | `/api/v1/setreservoiry/` | 设置水库Y坐标 | network (query), reservoir (query), y (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getreservoirschema` | 返回水库(Reservoir)数据模型的所有字段定义 |
| `GET /getreservoirproperties/` | 查询单个水库的所有属性(水头、模式、坐标等) |
| `GET /getallreservoirproperties/` | 批量获取管网中所有水库的属性列表 |
| `GET /getreservoirhead/` | 查询水库水头(即水库水位高度) |
| `GET /getreservoirpattern/` | 查询水库关联的时间模式名称 |
| `POST /addreservoir/` | 向管网添加一个新水库,需提供ID、坐标和水头 |
| `POST /deletereservoir/` | 从管网删除指定水库 |
| `POST /setreservoir*/` | 设置水库某个具体属性(水头、模式、坐标等) |
@@ -1,32 +0,0 @@
---
name: tjwater-action-business-network-assets-tags
description: business/network-assets 下 tags 操作技能。
version: 3.0.0
---
# tags Action Skill
## 简介
负责 `business/network-assets` 场景下 `tags` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/gettag/` | 获取标签信息 | network (query), t_type (query), id (query) | - |
| GET | `/api/v1/gettags/` | 获取所有标签 | network (query) | - |
| GET | `/api/v1/gettagschema/` | 获取标签属性架构 | network (query) | - |
| POST | `/api/v1/settag/` | 设置标签 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /gettagschema/` | 返回标签(Tag)数据模型的字段定义 |
| `GET /gettag/` | 查询单个元素绑定的标签信息 |
| `GET /gettags/` | 获取管网中所有标签列表 |
| `POST /settag/` | 为管网元素设置/更新标签(支持自定义键值对属性) |
@@ -1,61 +0,0 @@
---
name: tjwater-action-business-network-assets-tanks
description: business/network-assets 下 tanks 操作技能。
version: 3.0.0
---
# tanks Action Skill
## 简介
负责 `business/network-assets` 场景下 `tanks` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addtank/` | 新增水箱 | network (query), tank (query), x (query), y (query), elevation (query) | init_level (query), min_level (query), max_level (query), diameter (query), min_vol (query) |
| POST | `/api/v1/deletetank/` | 删除水箱 | network (query), tank (query) | - |
| GET | `/api/v1/getalltankproperties/` | 获取所有水箱属性 | network (query) | - |
| GET | `/api/v1/gettankcoord/` | 获取水箱坐标 | network (query), tank (query) | - |
| GET | `/api/v1/gettankdiameter/` | 获取水箱直径 | network (query), tank (query) | - |
| GET | `/api/v1/gettankelevation/` | 获取水箱标高 | network (query), tank (query) | - |
| GET | `/api/v1/gettankinitlevel/` | 获取水箱初始水位 | network (query), tank (query) | - |
| GET | `/api/v1/gettankmaxlevel/` | 获取水箱最大水位 | network (query), tank (query) | - |
| GET | `/api/v1/gettankminlevel/` | 获取水箱最小水位 | network (query), tank (query) | - |
| GET | `/api/v1/gettankminvol/` | 获取水箱最小体积 | network (query), tank (query) | - |
| GET | `/api/v1/gettankoverflow/` | 获取水箱溢流口 | network (query), tank (query) | - |
| GET | `/api/v1/gettankproperties/` | 获取水箱属性 | network (query), tank (query) | - |
| GET | `/api/v1/gettankschema` | 获取水箱模式 | network (query) | - |
| GET | `/api/v1/gettankvolcurve/` | 获取水箱容积曲线 | network (query), tank (query) | - |
| GET | `/api/v1/gettankx/` | 获取水箱X坐标 | network (query), tank (query) | - |
| GET | `/api/v1/gettanky/` | 获取水箱Y坐标 | network (query), tank (query) | - |
| POST | `/api/v1/settankcoord/` | 设置水箱坐标 | network (query), tank (query), x (query), y (query) | - |
| POST | `/api/v1/settankdiameter/` | 设置水箱直径 | network (query), tank (query), diameter (query) | - |
| POST | `/api/v1/settankelevation/` | 设置水箱标高 | network (query), tank (query), elevation (query) | - |
| POST | `/api/v1/settankinitlevel/` | 设置水箱初始水位 | network (query), tank (query), init_level (query) | - |
| POST | `/api/v1/settankmaxlevel/` | 设置水箱最大水位 | network (query), tank (query), max_level (query) | - |
| POST | `/api/v1/settankminlevel/` | 设置水箱最小水位 | network (query), tank (query), min_level (query) | - |
| POST | `/api/v1/settankminvol/` | 设置水箱最小体积 | network (query), tank (query), min_vol (query) | - |
| POST | `/api/v1/settankoverflow/` | 设置水箱溢流口 | network (query), tank (query), overflow (query) | - |
| POST | `/api/v1/settankproperties/` | 设置水箱属性 | network (query), tank (query) | - |
| POST | `/api/v1/settankvolcurve/` | 设置水箱容积曲线 | network (query), tank (query), vol_curve (query) | - |
| POST | `/api/v1/settankx/` | 设置水箱X坐标 | network (query), tank (query), x (query) | - |
| POST | `/api/v1/settanky/` | 设置水箱Y坐标 | network (query), tank (query), y (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /gettankschema` | 返回水箱(Tank)数据模型的所有字段定义 |
| `GET /gettankproperties/` | 查询单个水箱的所有属性(标高、直径、初始/最大/最小水位等) |
| `GET /getalltankproperties/` | 批量获取管网中所有水箱的属性列表 |
| `GET /gettankelevation/` | 查询水箱底部标高 |
| `GET /gettankdiameter/` | 查询水箱直径 |
| `GET /gettankinitlevel/` | 查询水箱初始水位 |
| `POST /addtank/` | 向管网添加一个新水箱,需提供ID、坐标、标高和水位参数 |
| `POST /deletetank/` | 从管网删除指定水箱 |
| `POST /settank*/` | 设置水箱某个具体属性(坐标、标高、直径、水位等) |
@@ -1,50 +0,0 @@
---
name: tjwater-action-business-network-assets-valves
description: business/network-assets 下 valves 操作技能。
version: 3.0.0
---
# valves Action Skill
## 简介
负责 `business/network-assets` 场景下 `valves` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/addvalve/` | 添加阀门 | network (query), valve (query), node1 (query), node2 (query) | diameter (query), v_type (query), setting (query), minor_loss (query) |
| POST | `/api/v1/deletevalve/` | 删除阀门 | network (query), valve (query) | - |
| GET | `/api/v1/getallvalveproperties/` | 获取所有阀门属性 | network (query) | - |
| GET | `/api/v1/getvalvediameter/` | 获取阀门直径 | network (query), valve (query) | - |
| GET | `/api/v1/getvalveminorloss/` | 获取阀门损失系数 | network (query), valve (query) | - |
| GET | `/api/v1/getvalvenode1/` | 获取阀门起点节点 | network (query), valve (query) | - |
| GET | `/api/v1/getvalvenode2/` | 获取阀门终点节点 | network (query), valve (query) | - |
| GET | `/api/v1/getvalveproperties/` | 获取阀门所有属性 | network (query), valve (query) | - |
| GET | `/api/v1/getvalveschema` | 获取阀门架构 | network (query) | - |
| GET | `/api/v1/getvalvesetting/` | 获取阀门开度 | network (query), valve (query) | - |
| GET | `/api/v1/getvalvetype/` | 获取阀门类型 | network (query), valve (query) | - |
| POST | `/api/v1/setvalvenode1/` | 设置阀门起点节点 | network (query), valve (query), node1 (query) | - |
| POST | `/api/v1/setvalvenode2/` | 设置阀门终点节点 | network (query), valve (query), node2 (query) | - |
| POST | `/api/v1/setvalvenodediameter/` | 设置阀门直径 | network (query), valve (query), diameter (query) | - |
| POST | `/api/v1/setvalveproperties/` | 批量设置阀门属性 | network (query), valve (query) | - |
| POST | `/api/v1/setvalvesetting/` | 设置阀门开度 | network (query), valve (query), setting (query) | - |
| POST | `/api/v1/setvalvetype/` | 设置阀门类型 | network (query), valve (query), type (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getvalveschema` | 返回阀门(Valve)数据模型的所有字段定义 |
| `GET /getvalveproperties/` | 查询单个阀门的所有属性(管径、类型、设定值等) |
| `GET /getallvalveproperties/` | 批量获取管网中所有阀门的属性列表 |
| `GET /getvalvediameter/` | 查询阀门管径 |
| `GET /getvalvetype/` | 查询阀门类型(PRV/PSV/TCV/FCV/PBV/GPV |
| `GET /getvalvesetting/` | 查询阀门设定值(压力设定或流量设定) |
| `POST /addvalve/` | 向管网添加一个新阀门,需提供ID、起终节点、管径和类型 |
| `POST /deletevalve/` | 从管网删除指定阀门 |
| `POST /setvalve*/` | 设置阀门某个具体属性(类型、设定值、管径等) |
@@ -1,19 +0,0 @@
---
name: tjwater-scenario-business-project-workspace
description: 负责项目空间、快照与扩展操作。
version: 3.0.0
---
# project-workspace Scenario Skill
## 简介
负责项目空间、快照与扩展操作。
## 子模块索引 (渐进式引导)
- **extension**: 见 `./extension/SKILL.md`
- **misc**: 见 `./misc/SKILL.md`
- **project**: 见 `./project/SKILL.md`
- **project_data**: 见 `./project_data/SKILL.md`
- **schemes**: 见 `./schemes/SKILL.md`
- **snapshots**: 见 `./snapshots/SKILL.md`
@@ -1,32 +0,0 @@
---
name: tjwater-action-business-project-workspace-extension
description: business/project-workspace 下 extension 操作技能。
version: 3.0.0
---
# extension Action Skill
## 简介
负责 `business/project-workspace` 场景下 `extension` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getallextensiondata/` | 获取所有扩展数据 | network (query) | - |
| GET | `/api/v1/getallextensiondatakeys/` | 获取所有扩展数据键 | network (query) | - |
| GET | `/api/v1/getextensiondata/` | 获取指定扩展数据 | network (query), key (query) | - |
| POST | `/api/v1/setextensiondata/` | 设置扩展数据 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallextensiondatakeys/` | 获取当前管网中所有已存储的自定义扩展数据的键名列表 |
| `GET /getallextensiondata/` | 获取当前管网所有自定义扩展数据(键值对集合) |
| `GET /getextensiondata/` | 按 key 查询指定的自定义扩展数据值 |
| `POST /setextensiondata/` | 设置或更新一个自定义扩展数据键值对(可用于存储任意业务自定义信息) |
@@ -1,36 +0,0 @@
---
name: tjwater-action-business-project-workspace-misc
description: business/project-workspace 下 misc 操作技能。
version: 3.0.0
---
# misc Action Skill
## 简介
负责 `business/project-workspace` 场景下 `misc` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getallburstlocateresults/` | 获取所有爆管定位结果 | network (query) | - |
| GET | `/api/v1/getallsensorplacements/` | 获取所有传感器位置 | network (query) | - |
| GET | `/api/v1/getjson/` | 获取JSON示例 | - | - |
| GET | `/api/v1/getrealtimedata/` | 获取实时数据 | - | - |
| GET | `/api/v1/getsimulationresult/` | 获取模拟结果 | - | - |
| POST | `/api/v1/test_dict/` | 测试字典处理 | data (body) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallburstlocateresults/` | 获取当前管网所有历史爆管定位分析结果(旧版接口) |
| `GET /getallsensorplacements/` | 获取当前管网所有传感器布置方案的结果列表 |
| `GET /getsimulationresult/` | 获取最近一次水力模拟结果(旧版接口) |
| `GET /getrealtimedata/` | 获取管网实时监测数据(旧版接口) |
| `GET /getjson/` | 返回示例 JSON 数据结构,用于开发调试 |
| `POST /test_dict/` | 测试字典类型请求体的接口,用于开发调试 |
@@ -1,32 +0,0 @@
---
name: tjwater-action-business-project-workspace-project-data
description: business/project-workspace 下 project_data 操作技能。
version: 3.0.0
---
# project_data Action Skill
## 简介
负责 `business/project-workspace` 场景下 `project_data` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/burst-locate-result` | 获取爆管定位结果 | - | - |
| GET | `/api/v1/burst-locate-result/{burst_incident}` | 按事件查询爆管定位结果 | burst_incident (path) | - |
| GET | `/api/v1/scada-info` | 获取SCADA信息 | - | - |
| GET | `/api/v1/scheme-list` | 获取方案列表 | - | - |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /burst-locate-result` | 获取所有爆管定位事件的历史结果列表(新版REST接口) |
| `GET /burst-locate-result/{burst_incident}` | 查询指定爆管事件(burst_incident ID)的详细定位结果 |
| `GET /scada-info` | 获取当前项目关联的 SCADA 设备和监测点信息汇总 |
| `GET /scheme-list` | 获取当前项目中所有可用的水力计算方案列表 |
@@ -1,55 +0,0 @@
---
name: tjwater-action-business-project-workspace-project
description: business/project-workspace 下 project 操作技能。
version: 3.0.0
---
# project Action Skill
## 简介
负责 `business/project-workspace` 场景下 `project` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/listprojects/` | 获取项目列表 | - | - |
| GET | `/api/v1/project_info/` | 获取项目信息 | network (query) | - |
| GET | `/api/v1/haveproject/` | 检查项目是否存在 | network (query) | - |
| POST | `/api/v1/createproject/` | 创建新项目 | network (query) | - |
| POST | `/api/v1/deleteproject/` | 删除项目 | network (query) | - |
| POST | `/api/v1/copyproject/` | 复制项目 | source (query), target (query) | - |
| GET | `/api/v1/isprojectopen/` | 检查项目是否已打开 | network (query) | - |
| POST | `/api/v1/openproject/` | 打开项目 | network (query) | - |
| POST | `/api/v1/closeproject/` | 关闭项目 | network (query) | - |
| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | network (query) | - |
| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - |
| POST | `/api/v1/lockproject/` | 锁定项目 | network (query) | - |
| POST | `/api/v1/unlockproject/` | 解锁项目 | network (query) | - |
| POST | `/api/v1/importinp/` | 导入 INP 文件内容 | network (query) | - |
| GET | `/api/v1/exportinp/` | 导出项目为 ChangeSet | network (query), version (query) | - |
| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | network (query), inp (query) | - |
| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | network (query), inp (query) | - |
| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | afile (body), name (query) | - |
| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | name (query) | - |
| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | - | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /listprojects/` | 获取服务器上所有可用的供水管网项目名称列表 |
| `GET /project_info/` | 从数据库获取项目的详细信息,包括地图范围等配置 |
| `POST /createproject/` | 创建一个新的供水管网项目;若已存在可能覆盖或报错 |
| `POST /deleteproject/` | 永久删除指定项目,此操作不可恢复 |
| `POST /openproject/` | 将指定项目加载到内存并初始化数据库连接池 |
| `POST /closeproject/` | 将指定项目从内存中卸载,释放相关资源 |
| `POST /lockproject/` | 锁定项目以防止并发修改 |
| `POST /unlockproject/` | 释放对项目的锁定 |
| `POST /importinp/` | 将 INP 格式文本内容导入到指定项目 |
| `GET /exportinp/` | 导出项目变更集(ChangeSet),含顶点、SCADA元素、DMA、SA、VD等 |
| `GET /convertv3tov2/` | 将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式 |
@@ -1,30 +0,0 @@
---
name: tjwater-action-business-project-workspace-schemes
description: business/project-workspace 下 schemes 操作技能。
version: 3.0.0
---
# schemes Action Skill
## 简介
负责 `business/project-workspace` 场景下 `schemes` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/getallschemes/` | 获取所有方案 | network (query) | - |
| GET | `/api/v1/getscheme/` | 获取单个方案 | network (query), schema_name (query) | - |
| GET | `/api/v1/getschemeschema/` | 获取方案模式 | network (query) | - |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getschemeschema/` | 返回方案(Scheme)数据模型的字段定义 |
| `GET /getallschemes/` | 获取当前管网下所有已保存方案的列表 |
| `GET /getscheme/` | 查询指定方案名称(schema_name)的详细属性和配置 |
@@ -1,54 +0,0 @@
---
name: tjwater-action-business-project-workspace-snapshots
description: business/project-workspace 下 snapshots 操作技能。
version: 3.0.0
---
# snapshots Action Skill
## 简介
负责 `business/project-workspace` 场景下 `snapshots` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/batch/` | 执行批量命令 | network (query) | - |
| POST | `/api/v1/compressedbatch/` | 执行压缩批量命令 | network (query) | - |
| GET | `/api/v1/getcurrentoperationid/` | 获取当前操作ID | network (query) | - |
| GET | `/api/v1/getrestoreoperation/` | 获取恢复操作ID | network (query) | - |
| GET | `/api/v1/getsnapshots/` | 获取快照列表 | network (query) | - |
| GET | `/api/v1/havesnapshot/` | 检查快照是否存在 | network (query), tag (query) | - |
| GET | `/api/v1/havesnapshotforcurrentoperation/` | 检查当前操作快照是否存在 | network (query) | - |
| GET | `/api/v1/havesnapshotforoperation/` | 检查操作快照是否存在 | network (query), operation (query) | - |
| POST | `/api/v1/pickoperation/` | 选择操作 | network (query), operation (query) | discard (query) |
| POST | `/api/v1/picksnapshot/` | 选择快照 | network (query), tag (query) | discard (query) |
| POST | `/api/v1/redo/` | 重做操作 | network (query) | - |
| POST | `/api/v1/setrestoreoperation/` | 设置恢复操作ID | network (query), operation (query) | - |
| GET | `/api/v1/syncwithserver/` | 与服务器同步 | network (query), operation (query) | - |
| POST | `/api/v1/takenapshotforcurrentoperation` | 为当前操作创建快照(兼容模式) | network (query), tag (query) | - |
| POST | `/api/v1/takesnapshot/` | 创建快照 | network (query), tag (query) | - |
| POST | `/api/v1/takesnapshotforcurrentoperation` | 为当前操作创建快照 | network (query), tag (query) | - |
| POST | `/api/v1/takesnapshotforoperation/` | 为操作创建快照 | network (query), operation (query), tag (query) | - |
| POST | `/api/v1/undo/` | 撤销操作 | network (query) | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getsnapshots/` | 列出当前管网所有已保存的快照标签(tag)列表 |
| `GET /havesnapshot/` | 检查指定 tag 的快照是否存在 |
| `POST /takesnapshot/` | 保存当前管网状态为一个快照,tag 为快照名称 |
| `POST /picksnapshot/` | 将管网状态回滚到指定快照,discard=true 时丢弃当前未保存修改 |
| `GET /getcurrentoperationid/` | 获取当前管网的操作ID(用于追踪操作历史) |
| `POST /undo/` | 撤销对管网的最近一次操作 |
| `POST /redo/` | 重做上一次被撤销的操作 |
| `POST /batch/` | 批量执行多个管网操作命令(原子事务) |
| `POST /compressedbatch/` | 执行压缩格式的批量命令(减少网络传输量) |
| `GET /syncwithserver/` | 将客户端的操作与服务端管网状态同步 |
| `POST /pickoperation/` | 切换到指定 operation ID 的历史操作状态 |
| `POST /takesnapshotforcurrentoperation` | 为当前 operation 创建快照(保存当前操作节点状态) |
-14
View File
@@ -1,14 +0,0 @@
---
name: tjwater-domain-data
description: 负责时序数据访问与读写能力。
version: 3.0.0
---
# Data Domain Skill
## 简介
负责时序数据访问与读写能力。
## 子模块索引 (渐进式引导)
- **timeseries-access**: 见 `./timeseries-access/SKILL.md`
@@ -1,16 +0,0 @@
---
name: tjwater-scenario-data-timeseries-access
description: 负责时序数据查询、写入与聚合。
version: 3.0.0
---
# timeseries-access Scenario Skill
## 简介
负责时序数据查询、写入与聚合。
## 子模块索引 (渐进式引导)
- **composite**: 见 `./composite/SKILL.md`
- **realtime**: 见 `./realtime/SKILL.md`
- **scheme**: 见 `./scheme/SKILL.md`
@@ -1,34 +0,0 @@
---
name: tjwater-action-data-timeseries-access-composite
description: data/timeseries-access 下 composite 操作技能。
version: 3.0.0
---
# composite Action Skill
## 简介
负责 `data/timeseries-access` 场景下 `composite` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/composite/clean-scada` | 清洗SCADA监测数据 | device_ids (query), start_time (query), end_time (query) | - |
| GET | `/api/v1/composite/element-scada` | 获取管网元素关联的SCADA监测数据 | element_id (query), start_time (query), end_time (query) | use_cleaned (query) |
| GET | `/api/v1/composite/element-simulation` | 获取管网元素的模拟数据 | start_time (query), end_time (query), feature_infos (query) | scheme_type (query), scheme_name (query) |
| GET | `/api/v1/composite/pipeline-health-prediction` | 预测管道健康状况 | query_time (query), network_name (query) | - |
| GET | `/api/v1/composite/scada-simulation` | 获取SCADA关联的模拟数据 | start_time (query), end_time (query), device_ids (query) | scheme_type (query), scheme_name (query) |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /composite/scada-simulation` | 将 SCADA 设备历史监测数据与水力模拟结果对齐返回,便于压差分析 |
| `GET /composite/element-simulation` | 按管网元素ID和属性类型查询该元素在某方案下的模拟时序数据 |
| `GET /composite/element-scada` | 查询某管网元素关联的 SCADA 设备的历史监测时序数据,支持使用清洗后数据(use_cleaned |
| `POST /composite/clean-scada` | 对指定设备在指定时间段的 SCADA 数据进行清洗处理(去噪、异常值替换) |
| `GET /composite/pipeline-health-prediction` | 在指定时刻对整个管网的管道健康状态进行预测,返回各管道健康评分 |
@@ -1,44 +0,0 @@
---
name: tjwater-action-data-timeseries-access-realtime
description: data/timeseries-access 下 realtime 操作技能。
version: 3.0.0
---
# realtime Action Skill
## 简介
负责 `data/timeseries-access` 场景下 `realtime` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| DELETE | `/api/v1/realtime/links` | 删除实时管道数据 | start_time (query), end_time (query) | - |
| GET | `/api/v1/realtime/links` | 查询实时管道数据 | start_time (query), end_time (query) | - |
| POST | `/api/v1/realtime/links/batch` | 批量插入实时管道数据 | data (body) | - |
| PATCH | `/api/v1/realtime/links/{link_id}/field` | 更新实时管道字段 | link_id (path), time (query), field (query), value (query) | - |
| DELETE | `/api/v1/realtime/nodes` | 删除实时节点数据 | start_time (query), end_time (query) | - |
| GET | `/api/v1/realtime/nodes` | 查询实时节点数据 | start_time (query), end_time (query) | - |
| POST | `/api/v1/realtime/nodes/batch` | 批量插入实时节点数据 | data (body) | - |
| GET | `/api/v1/realtime/query/by-id-time` | 按ID和时间查询实时模拟数据 | id (query), type (query), query_time (query) | - |
| GET | `/api/v1/realtime/query/by-time-property` | 按时间和属性查询实时数据 | query_time (query), type (query), property (query) | - |
| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | node_result_list (body), link_result_list (body), result_start_time (query) | - |
- 覆盖方法:`DELETE, GET, PATCH, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /realtime/links/batch` | 批量将管道(link)模拟结果写入实时数据表(TimescaleDB |
| `GET /realtime/links` | 查询指定时间范围内的实时管道模拟数据 |
| `DELETE /realtime/links` | 删除指定时间范围内的实时管道数据 |
| `PATCH /realtime/links/{link_id}/field` | 修改某条管道在特定时刻的某个字段值 |
| `POST /realtime/nodes/batch` | 批量将节点模拟结果写入实时数据表 |
| `GET /realtime/nodes` | 查询指定时间范围内的实时节点模拟数据 |
| `DELETE /realtime/nodes` | 删除指定时间范围内的实时节点数据 |
| `POST /realtime/simulation/store` | 一次性存储一次完整模拟运行的节点和管道结果(含起始时间) |
| `GET /realtime/query/by-time-property` | 按查询时间点和属性名(如 pressure/flow)查询全网实时模拟值 |
| `GET /realtime/query/by-id-time` | 按单个元素ID、类型和时间点查询其实时模拟值 |
@@ -1,48 +0,0 @@
---
name: tjwater-action-data-timeseries-access-scheme
description: data/timeseries-access 下 scheme 操作技能。
version: 3.0.0
---
# scheme Action Skill
## 简介
负责 `data/timeseries-access` 场景下 `scheme` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| DELETE | `/api/v1/scheme/links` | 删除方案管道数据 | scheme_type (query), scheme_name (query), start_time (query), end_time (query) | - |
| GET | `/api/v1/scheme/links` | 查询方案管道数据 | scheme_type (query), scheme_name (query), start_time (query), end_time (query) | - |
| POST | `/api/v1/scheme/links/batch` | 批量插入方案管道数据 | data (body) | - |
| GET | `/api/v1/scheme/links/{link_id}/field` | 查询方案管道字段数据 | link_id (path), scheme_type (query), scheme_name (query), start_time (query), end_time (query), field (query) | - |
| PATCH | `/api/v1/scheme/links/{link_id}/field` | 更新方案管道字段 | link_id (path), scheme_type (query), scheme_name (query), time (query), field (query), value (query) | - |
| DELETE | `/api/v1/scheme/nodes` | 删除方案节点数据 | scheme_type (query), scheme_name (query), start_time (query), end_time (query) | - |
| POST | `/api/v1/scheme/nodes/batch` | 批量插入方案节点数据 | data (body) | - |
| GET | `/api/v1/scheme/nodes/{node_id}/field` | 查询方案节点字段数据 | node_id (path), scheme_type (query), scheme_name (query), start_time (query), end_time (query), field (query) | - |
| PATCH | `/api/v1/scheme/nodes/{node_id}/field` | 更新方案节点字段 | node_id (path), scheme_type (query), scheme_name (query), time (query), field (query), value (query) | - |
| GET | `/api/v1/scheme/query/by-id-time` | 按ID和时间查询方案模拟数据 | scheme_type (query), scheme_name (query), id (query), type (query), query_time (query) | - |
| GET | `/api/v1/scheme/query/by-scheme-time-property` | 按方案、时间和属性查询数据 | scheme_type (query), scheme_name (query), query_time (query), type (query), property (query) | - |
| POST | `/api/v1/scheme/simulation/store` | 存储方案模拟结果 | scheme_type (query), scheme_name (query), node_result_list (body), link_result_list (body), result_start_time (query) | - |
- 覆盖方法:`DELETE, GET, PATCH, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /scheme/links/batch` | 批量将管道模拟结果写入指定方案(scheme_type + scheme_name)的数据表 |
| `GET /scheme/links` | 查询指定方案和时间范围内的所有管道模拟数据 |
| `DELETE /scheme/links` | 删除指定方案和时间范围内的管道模拟数据 |
| `GET /scheme/links/{link_id}/field` | 查询特定管道在指定方案和时间范围内某字段的时序数据 |
| `PATCH /scheme/links/{link_id}/field` | 修改特定管道在指定方案某时刻的某个字段值 |
| `POST /scheme/nodes/batch` | 批量将节点模拟结果写入指定方案的数据表 |
| `GET /scheme/nodes/{node_id}/field` | 查询特定节点在指定方案和时间范围内某字段的时序数据 |
| `PATCH /scheme/nodes/{node_id}/field` | 修改特定节点在指定方案某时刻的某个字段值 |
| `DELETE /scheme/nodes` | 删除指定方案和时间范围内的节点模拟数据 |
| `POST /scheme/simulation/store` | 一次性存储完整方案模拟结果(节点 + 管道),需提供 scheme_type 和 scheme_name |
| `GET /scheme/query/by-id-time` | 按元素ID、类型和时间点查询该元素在指定方案下的模拟值 |
| `GET /scheme/query/by-scheme-time-property` | 按方案、时间点和属性名查询全网在指定方案下的模拟值 |
-191
View File
@@ -1,191 +0,0 @@
# 示例(基于 opencode Agent chat/stream 工具调用链)
## 示例 1:前端发起对话,opencode agent 触发工具调用
用户意图:查询设备 `170490` 在时间范围内的 `monitored_value`
前端调用 `POST /api/v1/agent/chat/stream`
```json
{
"message": "请查询设备170490在最近24小时的monitored_value历史数据",
"session_id": "agent-demo-001"
}
```
请求头至少包含(由前端传入):
- `Authorization: Bearer <token>`
- `x-project-id: <project-id>`
服务端内部行为:
- 持续通过 SSE `progress` 输出处理阶段,例如“正在规划分析步骤”“正在调用后端数据查询”
- opencode agent 选择工具 `dynamic_http_call`
- 工具参数示例:
```json
{
"path": "/api/v1/scada/by-ids-field-time-range",
"method": "GET",
"arguments": {
"device_ids": "170490",
"field": "monitored_value",
"start_time": "2026-03-29T07:57:47.338Z",
"end_time": "2026-03-30T07:57:47.338Z"
}
}
```
## 示例 2opencode agent 多步规划 + 多次工具调用
用户消息:
- “先查这个设备历史数据,再给我异常点摘要。”
典型链路:
- 第一步工具调用:查询历史数据接口。
- 第二步(可选)工具调用:查询补充数据接口。
- opencode agent 汇总工具结果,持续通过 SSE 输出 `progress``token`,最终返回 `done`
`progress` 示例:
```json
{
"session_id": "agent-demo-001",
"id": "tool-dynamic-http",
"phase": "tool",
"status": "running",
"title": "正在调用后端数据查询"
}
```
## 示例 3:前端工具 — 定位要素
用户消息:
- "帮我找到管道 P-001 和 P-002"
opencode agent 调用工具 `locate_features`
```json
{
"ids": ["P-001", "P-002"],
"feature_type": "pipe"
}
```
前端收到 SSE 事件后缩放地图并高亮管道。opencode agent 回复文字:"已在地图上定位到管道 P-001 和 P-002。"
## 示例 4:前端工具 — 对话内图表
用户消息:
- "展示节点 J-001 最近一天的压力变化曲线"
典型链路:
1. opencode agent 先调用 `dynamic_http_call` 查询数据
2. 拿到数据后,调用 `show_chart` 将处理好的数据传给前端渲染
第一步 — opencode agent 调用 `dynamic_http_call`
```json
{
"path": "/api/v1/composite/element-simulation",
"method": "GET",
"arguments": {
"feature_infos": "[\"J-001\", \"node\"]",
"start_time": "2026-03-29T00:00:00Z",
"end_time": "2026-03-30T00:00:00Z"
}
}
```
第二步 — opencode agent 处理数据后调用 `show_chart`
```json
{
"title": "节点 J-001 压力变化",
"chart_type": "line",
"x_data": ["03-29 00:00", "03-29 01:00", "03-29 02:00", "..."],
"series": [
{
"name": "J-001 压力",
"data": [32.5, 31.8, 30.2, "..."]
}
],
"y_axis_name": "压力 (m)"
}
```
前端直接用 AI 提供的数据渲染 ECharts 图表,不再请求后端。
## 示例 5:前端工具 — 查看 SCADA 监测面板
用户消息:
- "我想看看 J-001 的监测数据"
opencode agent 调用工具 `view_scada`
```json
{
"device_ids": ["J-001"],
"start_time": "2026-03-29T00:00:00Z",
"end_time": "2026-03-30T00:00:00Z"
}
```
前端打开 SCADA 监测面板,展示该节点的历史监测曲线。
## 示例 6:记住用户长期偏好
用户消息:
- "以后回答尽量简洁,先给结论再解释。"
opencode agent 调用工具 `memory_manager`
```json
{
"action": "add",
"reason": "用户明确给出了长期有效的回答风格偏好,后续会话也应遵守。",
"scope": "user",
"content": "用户偏好先给结论、再补必要解释,整体风格尽量简洁。"
}
```
## 示例 7:检索历史案例而不是误写入 memory
用户消息:
- "我们之前是不是分析过类似的爆管定位问题?"
opencode agent 调用工具 `session_search`
```json
{
"reason": "用户在询问过往会话中的类似案例,应先检索历史 transcript 而不是写入新的 memory。",
"query": "爆管定位 类似案例",
"max_results": 5
}
```
## 示例 8:沉淀可复用 workflow 模式
用户消息:
- "这套瓶颈分析流程之后可以复用。"
opencode agent 调用工具 `skill_manager`
```json
{
"action": "append_pattern",
"reason": "本轮已验证一套稳定可复用的瓶颈分析 workflow,适合沉淀到已有 skill。",
"skill_path": "workflow/bottleneck-analysis",
"pattern": "当瓶颈分析依赖大体量属性数据和模拟结果时,先用 dynamic_http_call 获取 preview,再用 fetch_result_ref 回读完整数据后再做合并与排序。"
}
```
## 示例 9:给单个 workflow skill 写入可复用脚本
当某个 workflow 的本地 Python 处理逻辑已经稳定、未来同类任务会重复使用时,可写入该 skill 自己的 `scripts/*.py`
```json
{
"action": "write_script",
"reason": "本轮已验证瓶颈分析中的合并与排序脚本,后续同类 workflow 可直接复用。",
"skill_path": "workflow/bottleneck-analysis",
"file_path": "scripts/merge_and_rank.py",
"content": "import json\n\n\ndef rank_links(rows):\n return sorted(rows, key=lambda row: row['composite_score'], reverse=True)\n"
}
```
脚本应只归属当前 `skill_path`,不要写到 `data/` 或其他 skill 目录。
-14
View File
@@ -1,14 +0,0 @@
---
name: tjwater-domain-platform
description: 负责治理、审计、缓存与元数据能力。
version: 3.0.0
---
# Platform Domain Skill
## 简介
负责治理、审计、缓存与元数据能力。
## 子模块索引 (渐进式引导)
- **governance-observability**: 见 `./governance-observability/SKILL.md`
@@ -1,16 +0,0 @@
---
name: tjwater-scenario-platform-governance-observability
description: 负责审计、缓存与平台元数据。
version: 3.0.0
---
# governance-observability Scenario Skill
## 简介
负责审计、缓存与平台元数据。
## 子模块索引 (渐进式引导)
- **audit**: 见 `./audit/SKILL.md`
- **cache**: 见 `./cache/SKILL.md`
- **meta**: 见 `./meta/SKILL.md`
@@ -1,30 +0,0 @@
---
name: tjwater-action-platform-governance-observability-audit
description: platform/governance-observability 下 audit 操作技能。
version: 3.0.0
---
# audit Action Skill
## 简介
负责 `platform/governance-observability` 场景下 `audit` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/audit/logs` | 查询审计日志 | - | user_id (query), project_id (query), action (query), resource_type (query), start_time (query), end_time (query), skip (query), limit (query) |
| GET | `/api/v1/audit/logs/count` | 获取审计日志总数 | - | user_id (query), project_id (query), action (query), resource_type (query), start_time (query), end_time (query) |
| GET | `/api/v1/audit/logs/my` | 查询我的审计日志 | - | action (query), start_time (query), end_time (query), skip (query), limit (query) |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /audit/logs` | 查询系统审计日志(仅管理员),支持按用户ID、项目ID、操作类型、资源类型、时间范围过滤;支持分页(skip/limit) |
| `GET /audit/logs/count` | 获取满足过滤条件的审计日志总条数,用于分页显示 |
| `GET /audit/logs/my` | 查询当前登录用户自己的操作日志,支持按操作类型和时间范围过滤 |
@@ -1,32 +0,0 @@
---
name: tjwater-action-platform-governance-observability-cache
description: platform/governance-observability 下 cache 操作技能。
version: 3.0.0
---
# cache Action Skill
## 简介
负责 `platform/governance-observability` 场景下 `cache` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| POST | `/api/v1/clearallredis/` | 清除所有缓存 | - | - |
| POST | `/api/v1/clearrediskey/` | 清除单个缓存键 | key (query) | - |
| POST | `/api/v1/clearrediskeys/` | 清除匹配的缓存键 | keys (query) | - |
| GET | `/api/v1/queryredis/` | 查询缓存键列表 | - | - |
- 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /queryredis/` | 查询当前 Redis 中所有缓存键列表,用于检查缓存状态 |
| `POST /clearrediskey/` | 删除指定单个键的缓存(精确匹配) |
| `POST /clearrediskeys/` | 批量删除多个匹配的缓存键(支持模式匹配) |
| `POST /clearallredis/` | 清除 Redis 中所有缓存数据(慎用,会影响所有会话) |
@@ -1,30 +0,0 @@
---
name: tjwater-action-platform-governance-observability-meta
description: platform/governance-observability 下 meta 操作技能。
version: 3.0.0
---
# meta Action Skill
## 简介
负责 `platform/governance-observability` 场景下 `meta` 的具体接口调用。
## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。
## 接口目录
| Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---|
| GET | `/api/v1/meta/db/health` | 检查数据库健康状态 | - | - |
| GET | `/api/v1/meta/project` | 获取项目元数据 | - | - |
| GET | `/api/v1/meta/projects` | 列出用户项目 | - | - |
- 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /meta/db/health` | 检查数据库(PostgreSQL/TimescaleDB)连接健康状态,返回 ok/error |
| `GET /meta/project` | 获取当前用户当前项目的元数据(名称、创建时间、所有者等) |
| `GET /meta/projects` | 列出当前登录用户有权限访问的所有项目信息 |
-125
View File
@@ -1,125 +0,0 @@
# API Skills 使用 Runbook(工具调用链)
## 1) 总体原则
- Skills 负责“告诉模型可做什么”。
- `chat/stream` 内部启动 opencode 会话,并注册工具 `dynamic_http_call`
- opencode agent 通过工具调用后端能力,不直接发 HTTP。
- TJWaterAgent 执行器负责“代表当前用户调真实后端 API”(动态路径,无白名单)。
- 会话完成后,运行时会基于 transcript 做后台 learning review;这一步用于判断是否需要更新 memory 或 skill,而不是替代主任务回答。
## 1.1) 自我学习闭环
- **memory_manager**:保存用户长期偏好 / 约束,以及稳定 workspace 事实
- **skill_manager**:保存经过验证、可复用的 workflow / 方法 / pitfall
- **session_search**:检索当前用户 + 当前项目范围内的历史会话 transcript,用于回忆旧案例,避免把一次性案例写入 memory
推荐分流:
- 需要长期遵守的偏好 / 稳定事实 → `memory_manager`
- 可复用的方法、步骤、坑点 → `skill_manager`
- 某次分析过程、历史案例、临时结论 → `session_search`
## 2) 请求入口(前端)
- `POST /api/v1/agent/chat/stream`(唯一前端入口)
不提供 `/execute` 对外调用路径,统一通过 `chat/stream` + 工具调用链执行。
请求体:
```json
{
"message": "帮我分析当前管网中的水力瓶颈管道,并给出改造建议",
"session_id": "agent-demo-001"
}
```
SSE 事件:
| event | 用途 | 关键字段 |
| --- | --- | --- |
| `progress` | 展示 Agent 处理过程、规划和工具进度 | `session_id`, `id`, `phase`, `status`, `title`, `detail` |
| `token` | 渲染面向用户的最终回答文本 | `session_id`, `content` |
| `tool_call` | 驱动前端地图/面板/图表动作 | `session_id`, `tool`, `params` |
| `done` | 当前轮对话结束 | `session_id` |
| `error` | 当前轮失败 | `session_id`, `message`, `detail` |
`progress.status` 取值为 `running``completed``error`;前端应按相同 `id` 覆盖更新同一条进度,而不是重复追加。
## 3) 工具参数约定(opencode agent 调用工具时)
```json
{
"path": "/api/v1/scada/by-ids-field-time-range",
"method": "GET",
"arguments": {
"device_ids": "170490",
"field": "monitored_value",
"start_time": "2026-03-29T07:57:47.338Z",
"end_time": "2026-03-30T07:57:47.338Z"
}
}
```
说明(工具 `dynamic_http_call`):
- `path` 必须以 `/` 开头。
- `method` 支持:`GET/POST/PUT/PATCH/DELETE`
- `arguments` 会编码为 query 参数(列表会转为逗号拼接)。
## 3.1) 学习工具约定
- 所有学习类工具都必须带 `reason`
- `memory_manager` 支持:`add / list / replace / remove`
- `skill_manager` 支持:`list / append_pattern / remove_pattern / write_reference / remove_reference / write_script / remove_script`
- `session_search` 只搜索当前用户 + 当前项目作用域,不接受跨项目检索
- `skill_manager` 的结构化写入优先落到:
1. `## Learned Patterns`
2. `references/*.md`
3. `scripts/*.py`
不应直接重写 skill frontmatter 或任意正文段落
- `scripts/*.py` 仅表示当前 `skill_path` 私有的可复用脚本资产;不要把运行时临时脚本写进 `data/`
## 4) 用户上下文注入(后端执行阶段)
- `Authorization`Bearer Token
- `x-project-id`
执行器会附带 `x-trace-id` 用于链路排查(可透传或自动生成)。
## 5) 排障要点
- `400`:检查工具参数 `path/method/arguments` 格式。
- `401/403`:检查 token 与项目权限。
- `404`:检查 `path` 是否正确。
- `422`:检查 `arguments` 字段名与类型。
- `5xx`:记录 `trace_id`,结合后端日志排查。
## 6) 前端工具调用链
前端工具(`locate_features`, `view_history`, `view_scada`, `show_chart`)的调用链与 `dynamic_http_call` 不同:
```
用户消息 → opencode agent → tool calling → 调用前端工具 (如 locate_features)
tool handler:
1) 推送 SSE tool_call 事件到前端
2) 返回简短确认给 opencode agent"已定位到管道"
前端同时收到:
- SSE event: progress → 展示规划/工具执行/完成状态
- SSE event: tool_call → 前端执行操作(定位地图/打开面板/渲染图表)
- SSE event: token → 渲染 opencode agent 文字回复
```
关键区别:
- `dynamic_http_call`TJWaterAgent 代理 HTTP 请求,结果返回给 opencode agent 做后续分析。
- 前端工具:TJWaterAgent 仅推送 SSE 事件,前端直接执行,结果不返回 opencode agent。
- `show_chart`opencode agent 先通过 `dynamic_http_call` 查询数据,处理为 x_data + series 格式后调用 `show_chart`,前端直接渲染图表,不再请求后端。
## 7) 复盘与沉淀建议
- 复杂多工具任务完成后,优先判断是否产生了稳定 workflow,可写入 `skill_manager`
- 用户明确纠正表达风格、输出格式或步骤时,优先判断是否需要写入 `memory_manager`
- 如果你只是想确认“以前是不是处理过类似问题”,先用 `session_search`
- 如果结果仍然只是 preview,不要基于 preview 做 learned pattern,总是先 `fetch_result_ref`
-19
View File
@@ -1,19 +0,0 @@
---
name: tjwater-workflow
description: 负责分析类工作流能力。
version: 1.0.0
---
# Workflow Domain Skill
## 简介
负责分析场景下的工作流组织与调用入口能力。
## 使用策略
- 当用户问题明显属于“多接口 + 本地分析 + 综合结论”的分析任务时,优先从本目录查找固定 workflow。
- 如果找到合适 workflow,应先按 workflow 执行主路径,再补充缺少的原子 skill。
- 如果没有匹配 workflow,或现有 workflow 缺少关键步骤、接口或输出约束,再回到其他 domain/scenario/action skills 组合能力。
## 子模块索引 (渐进式引导)
- **bottleneck-analysis**: 见 `./bottleneck-analysis/SKILL.md`
@@ -1,121 +0,0 @@
---
name: tjwater-workflow-bottleneck-analysis
description: workflow 下 bottleneck-analysis(水力瓶颈分析)工作流技能。
version: 1.1.0
---
# bottleneck-analysis Workflow Skill
## 简介
负责 `analytics/simulation-analysis` 场景下的水力瓶颈综合分析,通过结合管道属性与水力模拟结果,识别管网中超负荷、高流速、高水头损失的瓶颈管道,并给出分级改造建议。
## 前置依赖
本工作流依赖以下两个数据源,需按顺序并行或串行获取:
### 依赖 1:管道属性数据
- 接口:`GET /api/v1/getallpipeproperties/`
- 参数:`network`query,如 `tjwater`
- 用途:获取全部管道的 id、管径(diameter)、长度(length)、粗糙度(roughness)、起端(node1)、终端(node2) 等属性
- 注意:结果可能很大(数万条),需使用 `fetch_result_ref` 分批或全量获取
### 依赖 2:水力模拟结果
- 接口:`GET /api/v1/runprojectreturndict/`
- 参数:`network`query,如 `tjwater`
- 用途:运行管网水力模拟,返回各管段的 flow(LPS)、velocity(m/s)、headloss(m)、status,以及各节点的 demand、head、pressure(KPA)
- 注意:结果可达 30MB+,需用 Python 脚本批量处理或使用 `fetch_result_ref` 回读
## 工作流步骤
### 第 1 步:并行获取管道属性和运行水力模拟
同时调用 `getallpipeproperties``runprojectreturndict`,network 参数使用项目名称(如 `tjwater`)。
### 第 2 步:合并数据
用 Python 脚本将管道属性的 pipe_id 与模拟结果的 link_id 进行关联,构建含以下字段的合并数据集:
| 字段 | 来源 | 说明 |
|------|------|------|
| id | 两者关联键 | 管道/链路 ID |
| flow | 模拟 link_results | 流量 (LPS) |
| velocity | 模拟 link_results | 流速 (m/s) |
| headloss | 模拟 link_results | 水头损失 (m) |
| diameter | 管道属性 | 管径 (mm) |
| length | 管道属性 | 长度 (m) |
| roughness | 管道属性 | 粗糙度系数 |
| node1 / node2 | 管道属性 | 起端/终端节点 ID |
| unit_headloss | 计算 | headloss / length (m/m) |
| capacity_ratio | 计算 | |flow| / (π×(d/2000)²×1000),即实际流量与 1m/s 设计流量的比值 |
同时从模拟 node_results 提取各节点 pressure,关联到管段两端。
### 第 3 步:多维度瓶颈识别
按以下 5 个维度分别排序筛选,交叉印证:
| 维度 | 筛选条件 | 指示含义 |
|------|---------|---------|
| 高流速 | velocity > 1.2 m/s | 管径不足 |
| 主干管高流量 | diameter ≥ 300mm 且 velocity > 0.5 m/s | 传输瓶颈 |
| 高水头损失 | headloss > 5m 且 0.3 < velocity < 1.5 m/s | 能耗瓶颈/粗糙度问题 |
| 高单位水头损失 | unit_headloss > 1.0 m/m | 严重局部瓶颈 |
| 超负荷 | capacity_ratio > 1.0 | 实际流量超过设计能力 |
排除极短管道(length < 0.5m)以减少噪声。
### 第 4 步:综合评分
对有效管道计算综合瓶颈分数:
```
composite_score = (velocity / max_velocity) × 0.4
+ (headloss / max_headloss) × 0.3
+ (capacity_ratio / max_capacity_ratio) × 0.3
```
取 TOP 10~20 作为最严重瓶颈管道。
### 第 5 步:前端可视化
- 使用 `show_chart` 展示流速分布柱状图
- 使用 `locate_features` 在地图上定位 TOP 瓶颈管道(feature_type=pipe
- 可选:使用 `view_history` 查看瓶颈管道的历史运行数据
- 前端工具仅用于展示,分析结论必须来自 `dynamic_http_call` / `fetch_result_ref` 获得的数据
### 第 6 步:给出分级改造建议
按严重程度分为三级:
- **🚨 紧急**:综合评分 > 0.3,立即安排管径升级
- **⚡ 重点**:综合评分 0.15~0.3,纳入近期改造计划
- **📋 关注**:综合评分 0.05~0.15 或单维度超标,持续监测
每条建议含:当前管径 → 建议管径(基于目标流速 1.0~1.5 m/s 反推),并附改造理由。
## 改造管径计算公式
```
建议管径(mm) = 2 × 1000 × sqrt(|flow| / (π × target_velocity × 1000))
```
目标流速:DN<300 取 1.0 m/sDN≥300 取 1.2 m/s。
## 证据约束
- 如果关键数据仍处于 preview 状态,不得直接输出最终瓶颈结论
- 如果模拟结果不完整或接口失败,应明确说明当前仅能做初步筛查
- 改造建议必须区分“数据直接支持的结论”和“工程经验推断”
## 推荐输出结构
1. 分析范围与数据来源
2. 主要瓶颈管段 Top N
3. 分级建议(紧急 / 重点 / 关注)
4. 假设与局限
5. 是否建议地图定位或图表展示
## 参考
- 管道属性操作:`../business/network-assets/pipes/SKILL.md`
- 模拟操作:`./simulation/SKILL.md`
- 节点属性操作:`../business/network-assets/junctions/SKILL.md`
## Learned Patterns
- 先按“属性数据获取 → 模拟结果获取 → 本地关联 → 多指标筛选 → 分级建议”拆解工作流,再组织展示步骤,避免把一次分析过程写成会话流水账。
- 结果集较大时,优先使用 `fetch_result_ref` 或本地脚本批处理;只要数据仍是 preview、截断或未完整回读,就不能直接输出 Top N 瓶颈结论。
- 关联前先统一关键字段和单位:`pipe_id/link_id``diameter(mm)``length(m)``flow(LPS)``pressure(KPA)`;字段未对齐时,后续 ranking 和建议都会失真。
- `unit_headloss``capacity_ratio` 等衍生指标应在过滤异常数据(如 `length < 0.5m` 的短管)后再计算,否则容易被极端值放大。
- 阈值和评分权重应视为可调启发式,而不是唯一真理;输出时要区分“数据直接支持的结论”和“工程经验推断的建议”。
- 地图定位、图表展示属于证据呈现层,不能替代分析层;瓶颈判定必须基于后端原始结果或完整回读数据。
- 常见坑点:短管导致单位水头损失虚高、节点或链路映射缺失导致误判、模拟结果不完整时误把局部结果当全量结论。
+94
View File
@@ -0,0 +1,94 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
args: {
reason: tool.schema
.string()
.describe(
"Why this style action is needed for the current user request.",
),
layer_id: tool.schema
.enum(["junctions", "pipes"])
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
reset_to_default: tool.schema
.boolean()
.optional()
.describe("Whether to reset the target layer to its default style."),
style_config: tool.schema
.object({
property: tool.schema
.string()
.optional()
.describe("Data property to render."),
classification_method: tool.schema
.enum(["pretty_breaks", "custom_breaks"])
.optional()
.describe("Classification method."),
segments: tool.schema
.number()
.optional()
.describe("Number of segments."),
min_size: tool.schema
.number()
.optional()
.describe("Minimum point radius."),
max_size: tool.schema
.number()
.optional()
.describe("Maximum point radius."),
min_stroke_width: tool.schema
.number()
.optional()
.describe("Minimum line width."),
max_stroke_width: tool.schema
.number()
.optional()
.describe("Maximum line width."),
fixed_stroke_width: tool.schema
.number()
.optional()
.describe("Fixed line width when width is not data-driven."),
color_type: tool.schema
.enum(["single", "gradient", "rainbow", "custom"])
.optional()
.describe("Color strategy."),
single_palette_index: tool.schema.number().optional(),
gradient_palette_index: tool.schema.number().optional(),
rainbow_palette_index: tool.schema.number().optional(),
show_labels: tool.schema
.boolean()
.optional()
.describe("Whether to show labels."),
show_id: tool.schema
.boolean()
.optional()
.describe("Whether to show ids."),
opacity: tool.schema.number().optional().describe("Opacity in [0, 1]."),
adjust_width_by_property: tool.schema
.boolean()
.optional()
.describe("Whether line width is driven by the rendered property."),
custom_breaks: tool.schema
.array(tool.schema.number())
.optional()
.describe("Custom break values."),
custom_colors: tool.schema
.array(tool.schema.string())
.optional()
.describe("Custom rgba colors."),
})
.optional()
.describe(
"Optional style config overrides. Omit when reset_to_default is true.",
),
},
async execute(args) {
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
if (args.reset_to_default) {
return `已将${layerLabel}图层重置为默认样式。`;
}
return `已对${layerLabel}图层应用样式。`;
},
});
-54
View File
@@ -1,54 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({
description:
"通过本地 Agent 桥接调用 TJWater 后端 API。需提供 API 路径、可选的请求方法以及查询参数。",
args: {
reason: tool.schema
.string()
.describe("Why this tool call is required for the current user request."),
path: tool.schema.string().describe("Target backend API path, starting with '/'."),
method: tool.schema
.string()
.optional()
.describe("HTTP method. Defaults to GET."),
arguments: tool.schema
.record(tool.schema.string(), tool.schema.unknown())
.optional()
.describe("Query arguments object."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
// 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey,
reason: args.reason,
path: args.path,
method: args.method,
arguments: args.arguments,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-49
View File
@@ -1,49 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({
description:
"回读由 dynamic_http_call 生成的持久化 result_ref。适用于大结果只返回 preview 时,再按需读取完整或截断后的数据。",
args: {
reason: tool.schema
.string()
.describe("Why the stored result needs to be read for the current user request."),
result_ref: tool.schema.string().describe("The result_ref returned by dynamic_http_call."),
max_items: tool.schema
.number()
.int()
.positive()
.optional()
.describe("Optional maximum number of top-level items or fields to return."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey,
result_ref: args.result_ref,
max_items: args.max_items,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+6 -2
View File
@@ -5,8 +5,12 @@ export default tool({
args: {
reason: tool.schema
.string()
.describe("Why this map positioning action is needed for the user request."),
ids: tool.schema.array(tool.schema.string()).describe("Feature ids to locate."),
.describe(
"Why this map positioning action is needed for the user request.",
),
ids: tool.schema
.array(tool.schema.string())
.describe("Feature ids to locate."),
feature_type: tool.schema
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
.describe("Type of feature to locate."),
+70 -45
View File
@@ -1,17 +1,16 @@
import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
const memoryStore = new MemoryStore();
const toolContextStore = new ToolSessionContextStore();
const initializePromise = Promise.all([
memoryStore.initialize(),
toolContextStore.initialize(),
]);
const initializePromise = memoryStore.initialize();
export default tool({
description:
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。add 前必须先对同 scope 执行 list 并阅读现有记忆,再决定 add、replace 或 remove;不要跳过读取直接新增。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
args: {
action: tool.schema
.enum(["add", "list", "replace", "remove"])
@@ -37,7 +36,7 @@ export default tool({
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
const sessionContext = getRuntimeSessionContext(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
@@ -57,68 +56,94 @@ export default tool({
}
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "memory writes are disabled for this session",
ok: true,
kind: "memory",
decision: "rejected",
detail: "memory writes are disabled for this session",
});
}
const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (args.action === "list") {
const readScopes = {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
};
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: readScopes,
});
return JSON.stringify({
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
});
}
if (args.action === "add") {
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `must list ${scope} memory and review existing entries before add`,
target: scope,
});
}
const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
if (!result.entry) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.changed ? "memory stored" : "memory already existed",
entry: result.entry,
target: scope,
decision: result.changed ? "accepted" : "deduped",
detail: result.detail,
entry: result.entry,
target: scope,
});
}
if (args.action === "replace") {
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
const result = await memoryStore.replace(
scope,
scopeKey,
args.target_id ?? "",
{
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
},
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
}
const result = await memoryStore.remove(scope, scopeKey, args.target_id ?? "");
const result = await memoryStore.remove(
scope,
scopeKey,
args.target_id ?? "",
);
return JSON.stringify({
ok: true,
kind: "memory",
+4 -2
View File
@@ -6,11 +6,13 @@ export default tool({
args: {
reason: tool.schema
.string()
.describe("Why this junction rendering action is needed for the user request."),
.describe(
"Why this junction rendering action is needed for the user request.",
),
render_ref: tool.schema
.string()
.describe(
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } }node_area_map 必填,area_ids / area_colors 可选。",
"渲染引用 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 可选。",
),
},
async execute() {
+14 -19
View File
@@ -1,11 +1,8 @@
import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({
description:
@@ -25,23 +22,21 @@ export default tool({
.describe("Optional maximum number of hits to return."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
const response = await fetch(
`${internalBaseUrl}/internal/tools/session-search`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
max_results: args.max_results,
query: args.query,
session_id: context.sessionID,
}),
},
body: JSON.stringify({
max_results: args.max_results,
query: args.query,
sessionScopeKey: sessionContext.sessionScopeKey,
}),
});
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
+8 -2
View File
@@ -21,8 +21,14 @@ export default tool({
}),
)
.describe("Series data."),
x_axis_name: tool.schema.string().optional().describe("X-axis display name."),
y_axis_name: tool.schema.string().optional().describe("Y-axis display name."),
x_axis_name: tool.schema
.string()
.optional()
.describe("X-axis display name."),
y_axis_name: tool.schema
.string()
.optional()
.describe("Y-axis display name."),
},
async execute() {
// 图表数据已经在工具参数里,前端收到 tool_call 后直接渲染,不再二次请求后端。
+138 -99
View File
@@ -1,115 +1,154 @@
import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
import {
getRuntimeSessionContext,
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
const skillStore = new SkillStore();
type ToolContextReader = {
read(sessionId: string): RuntimeSessionContext | null;
};
export default tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
content: tool.schema
.string()
.optional()
.describe("Asset content used by write_reference or write_script."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "skill writes are disabled for this session",
});
}
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
const runtimeContextReader: ToolContextReader = {
read: getRuntimeSessionContext,
};
export const createSkillManagerTool = (
skillStore = new SkillStore(),
toolContextStore: ToolContextReader = runtimeContextReader,
initializePromise: Promise<unknown> = Promise.resolve(),
) =>
tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe(
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
),
content: tool.schema
.string()
.optional()
.describe(
"Content used by write_skill, write_reference, or write_script.",
),
},
async execute(args, context) {
await initializePromise;
const sessionContext = toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
if (
sessionContext.allowLearningWrite === false &&
args.action !== "list"
) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
detail: "skill writes are disabled for this session",
});
}
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
});
}
return JSON.stringify({
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
...result,
});
}
const result =
args.action === "write_skill"
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
: args.action === "remove_skill"
? await skillStore.removeSkill(args.skill_path)
: args.action === "append_pattern"
? await skillStore.appendPattern(
args.skill_path,
args.pattern ?? "",
)
: args.action === "remove_pattern"
? await skillStore.removePattern(
args.skill_path,
args.target_id ?? "",
)
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: args.action === "remove_reference"
? await skillStore.removeReference(
args.skill_path,
args.file_path ?? "",
)
: args.action === "write_script"
? await skillStore.writeScript(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeScript(
args.skill_path,
args.file_path ?? "",
);
return JSON.stringify({
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
...result,
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
});
}
},
});
const result =
args.action === "append_pattern"
? await skillStore.appendPattern(args.skill_path, args.pattern ?? "")
: args.action === "remove_pattern"
? await skillStore.removePattern(args.skill_path, args.target_id ?? "")
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: args.action === "remove_reference"
? await skillStore.removeReference(args.skill_path, args.file_path ?? "")
: args.action === "write_script"
? await skillStore.writeScript(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeScript(args.skill_path, args.file_path ?? "");
return JSON.stringify({
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
});
},
});
export default createSkillManagerTool();
+18 -20
View File
@@ -1,10 +1,8 @@
import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({
description:
@@ -12,7 +10,9 @@ export default tool({
args: {
reason: tool.schema
.string()
.describe("Why this local render payload should be persisted as a render_ref."),
.describe(
"Why this local render payload should be persisted as a render_ref.",
),
file_path: tool.schema
.string()
.describe(
@@ -20,22 +20,20 @@ export default tool({
),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
const response = await fetch(
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
file_path: args.file_path,
}),
},
body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey,
file_path: args.file_path,
}),
});
);
const text = await response.text();
if (!response.ok) {
+48
View File
@@ -0,0 +1,48 @@
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:
"通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。",
args: {
reason: tool.schema
.string()
.describe("Why this tool call is required for the current user request."),
command: tool.schema
.string()
.describe(
"tjwater-cli 子命令,不含二进制路径。示例:'project list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'",
),
timeout: tool.schema
.number()
.optional()
.describe("超时秒数,默认 120。大结果集建议设 300+。"),
},
async execute(args, context) {
const response = await fetch(
`${internalBaseUrl}/internal/tools/tjwater-cli-call`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
reason: args.reason,
command: args.command,
timeout: args.timeout,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+11 -3
View File
@@ -5,15 +5,23 @@ export default tool({
args: {
reason: tool.schema
.string()
.describe("Why this history panel should be opened for the current task."),
.describe(
"Why this history panel should be opened for the current task.",
),
feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."),
data_type: tool.schema
.enum(["realtime", "scheme", "none"])
.describe("History data source type."),
start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."),
end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."),
start_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
},
async execute() {
// 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。
+11 -6
View File
@@ -10,13 +10,18 @@ export default tool({
.array(tool.schema.string())
.optional()
.describe("Preferred SCADA device ids."),
device_id: tool.schema.string().optional().describe("Single SCADA device id."),
feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
device_id: tool.schema
.string()
.optional()
.describe("Legacy [id, type] pairs."),
start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."),
end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."),
.describe("Single SCADA device id."),
start_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
},
async execute() {
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。
+5 -3
View File
@@ -15,6 +15,7 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
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 && \
apt-get update && apt-get install -y --no-install-recommends \
curl \
jq \
unzip \
python3 \
python3-venv && \
@@ -51,7 +52,9 @@ RUN bun install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY tsconfig.json opencode.json README.md ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY tsconfig.json opencode.json README.md .gitignore ./
COPY src ./src
COPY .opencode ./.opencode
RUN bun run check
@@ -66,7 +69,7 @@ ENV PORT=8787
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./
COPY tsconfig.json opencode.json ./
COPY tsconfig.json opencode.json .gitignore ./
COPY src ./src
COPY .opencode ./.opencode
@@ -74,7 +77,6 @@ COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
COPY .opencode ./.opencode
EXPOSE 8787
CMD ["bun", "src/server.ts"]
+23 -12
View File
@@ -32,7 +32,7 @@ TJWaterAgent/
3. 管理前端 `session_id -> opencode sessionId` 的映射。
4. 保存并传递用户 `Authorization``x-user-id``x-project-id``x-trace-id`
5. 把 opencode 输出适配成前端需要的 SSE 事件。
6. 为 `.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。
6. 为 `.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
7. 代理调用真实 TJWater 后端 API。
当前 Agent API 的主入口:
@@ -84,34 +84,45 @@ src/
```text
.opencode/tools/
dynamic_http_call.ts
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 可以调用的自定义工具。
`dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端
`tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令
前端类工具如 `locate_features``view_history``view_scada``show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具
`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/tjwater-skills-root-index/
.opencode/skills/
SKILL.md
ai/
analytics/
business/
data/
platform/
examples.md
runbook.md
tjwater-cli/ ← tjwater-cli 可执行文件
workflow/ ← 可复用分析工作流
SKILL.md
simulation-diagnosis/
bottleneck-analysis/
source-service-area-analysis/
```
这里保存 TJWater 技能树,并保持树结构,符合渐进式披露设计
Skills 仅保留可复用的多步工作流。Agent 通过 `tjwater-cli help` 自行发现原子命令,无需逐接口技能树
agent 需要某个领域知识时再按需加载对应 skill,不把整棵技能树作为 always-loaded prompt 一次性注入
agent 加载技能树时按需取用对应 workflow skill
## 依赖边界
+60
View File
@@ -0,0 +1,60 @@
import type { NextFunction, Request, Response } from "express";
import { config } from "../config.js";
import { logger } from "../logger.js";
export const extractBearerToken = (authorization?: string) => {
const value = authorization?.trim();
if (!value) {
return "";
}
return value.replace(/^Bearer\s+/i, "").trim();
};
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。
export const requireAgentAuth = async (
req: Request,
res: Response,
next: NextFunction,
) => {
const token = extractBearerToken(req.header("authorization"));
if (!token) {
res.status(401).json({ message: "authorization token is required" });
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
try {
const response = await fetch(new URL("/api/v1/auth/me", config.TJWATER_API_BASE_URL), {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
if (response.ok) {
next();
return;
}
const detail = await response.text();
res.status(response.status === 403 ? 403 : 401).json({
message: response.status === 403 ? "forbidden" : "unauthorized",
detail: detail || undefined,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.warn({ err: error }, "agent auth validation failed");
res.status(503).json({
message: "authentication service unavailable",
detail,
});
} finally {
clearTimeout(timer);
}
};
+73 -68
View File
@@ -3,9 +3,9 @@ import { randomUUID } from "node:crypto";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = {
@@ -28,15 +28,12 @@ export type ChatRequestContext = SessionContext & {
};
export class ChatSessionBridge {
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。
private readonly activeRuntimeSessions = new Map<string, string>();
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
private readonly toolContextStore = new ToolSessionContextStore();
private readonly abortControllers = new Map<string, AbortController>();
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
async resolve(context: {
clientSessionId?: string;
sessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
@@ -46,63 +43,68 @@ export class ChatSessionBridge {
requestContext: ChatRequestContext;
created: boolean;
}> {
const requestContext = this.buildRequestContext(context);
await this.abortActiveRuntime(requestContext.clientSessionId);
let requestContext = this.buildRequestContext(context);
const existingSessionId = context.sessionId?.trim();
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
const session = await this.runtime.createSession(requestContext.clientSessionId);
let sessionId = existingSessionId;
let created = false;
if (!sessionId) {
const session = await this.runtime.createSession();
sessionId = session.id;
requestContext = {
...requestContext,
clientSessionId: sessionId,
};
created = true;
}
const binding: SessionBinding = {
clientSessionId: requestContext.clientSessionId,
sessionId: session.id,
sessionId,
startedAt: Date.now(),
};
const sessionScopeKey = buildToolSessionScopeKey(
requestContext.actorKey,
requestContext.projectKey,
requestContext.clientSessionId,
);
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
await this.toolContextStore.write({
setRuntimeSessionContext({
accessToken: requestContext.accessToken,
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: session.id,
sessionScopeKey,
sessionId,
traceId: requestContext.traceId,
});
return { binding, requestContext, created: true };
return { binding, requestContext, created };
}
count(): number {
return this.activeRuntimeSessions.size;
return this.abortControllers.size;
}
createClientSessionId() {
return `agent-${randomUUID().slice(0, 12)}`;
}
getActiveSensitiveContext(sessionScopeKey: string) {
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
registerAbortController(clientSessionId: string, controller: AbortController) {
this.abortControllers.set(clientSessionId, controller);
}
finalizeRequest(clientSessionId: string) {
this.abortControllers.delete(clientSessionId);
}
async abort(context: {
clientSessionId?: string;
sessionId?: string;
}): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) {
const sessionId = context.sessionId?.trim();
if (!clientSessionId || !sessionId) {
return null;
}
const sessionId = this.activeRuntimeSessions.get(clientSessionId);
if (!sessionId) {
return null;
}
await this.abortActiveRuntime(clientSessionId);
await this.abortActiveRuntime(clientSessionId, sessionId);
return {
clientSessionId,
sessionId,
@@ -110,29 +112,42 @@ export class ChatSessionBridge {
};
}
async releaseRuntimeSession(clientSessionId: string, sessionId: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
if (activeSessionId === sessionId) {
this.activeRuntimeSessions.delete(clientSessionId);
async deleteSession(context: {
clientSessionId: string;
sessionId: string;
}) {
const clientSessionId = context.clientSessionId.trim();
const sessionId = context.sessionId.trim();
const controller = this.abortControllers.get(clientSessionId);
if (controller) {
this.abortControllers.delete(clientSessionId);
controller.abort();
}
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
await this.toolContextStore.remove(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context");
});
await this.runtime.abortSession(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "failed to cleanup runtime session");
logger.warn(
{ clientSessionId, sessionId, err: error },
"failed to abort runtime session",
);
});
await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId, err: error },
"failed while waiting for runtime session to become idle",
);
});
removeRuntimeSessionContext(sessionId);
}
private buildRequestContext(context: {
clientSessionId?: string;
sessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
userId?: string;
}): ChatRequestContext {
const sessionId = context.sessionId?.trim();
return {
clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(),
clientSessionId: sessionId || this.createClientSessionId(),
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
projectId: context.projectId,
@@ -142,37 +157,27 @@ export class ChatSessionBridge {
};
}
private async abortActiveRuntime(clientSessionId: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
if (!activeSessionId) {
private async abortActiveRuntime(clientSessionId: string, sessionId?: string) {
const controller = this.abortControllers.get(clientSessionId);
if (controller) {
this.abortControllers.delete(clientSessionId);
controller.abort();
}
if (!sessionId) {
return;
}
this.activeRuntimeSessions.delete(clientSessionId);
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
await this.runtime.abortSession(activeSessionId).catch((error) => {
await this.runtime.abortSession(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error },
"failed to abort previous active runtime session",
{ clientSessionId, sessionId, err: error },
"failed to abort active runtime session",
);
});
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => {
await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error },
"failed while waiting for previous runtime session to become idle",
{ clientSessionId, sessionId, err: error },
"failed while waiting for active runtime session to become idle",
);
});
}
}
const findScopeKey = (
contexts: Map<string, ChatRequestContext>,
clientSessionId: string,
) => {
for (const [scopeKey, context] of contexts.entries()) {
if (context.clientSessionId === clientSessionId) {
return scopeKey;
}
}
return clientSessionId;
};
+16 -18
View File
@@ -33,6 +33,8 @@ const envSchema = z
.default("./logs/llm-request-audit.log"),
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
AGENT_INTERNAL_TOKEN: optionalString(),
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// embedded opencode server 的监听地址。
@@ -43,10 +45,12 @@ const envSchema = z
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
// client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 提供给本地 opencode tools 读取的会话上下文目录
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
// tjwater-cli 可执行文件路径
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
// TJWater 后端 API 的基础地址。
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
@@ -57,18 +61,18 @@ const envSchema = z
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
// memory 持久化存储目录。
MEMORY_STORAGE_DIR: z.string().default("./data/memory"),
// 持久化文件写入前保留历史版本的目录。
PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"),
// 持久化文件写入前保留备份版本的目录。
PERSISTENCE_BACKUP_DIR: z.string().default("./data/backup"),
// 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
// session transcript 持久化目录。
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
// conversation metadata 持久化目录。
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
// conversation UI state 持久化目录。
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
SESSION_TRANSCRIPT_STORAGE_DIR: z.string().default("./data/session-transcripts"),
// session metadata 持久化目录。
SESSION_METADATA_STORAGE_DIR: z.string().default("./data/session-metadata"),
// session UI state 持久化目录。
SESSION_UI_STATE_STORAGE_DIR: z.string().default("./data/session-ui-states"),
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION: z.coerce
.number()
.int()
.positive()
@@ -77,8 +81,8 @@ const envSchema = z
SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8),
// session_search 查询文本最大长度。
SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240),
// learning review 会话状态目录。
LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"),
// 当前 session 的 learning 进度状态目录。
SESSION_LEARNING_STATE_STORAGE_DIR: z.string().default("./data/session-learning-state"),
// learning audit 日志路径。
LEARNING_AUDIT_LOG_PATH: z
.string()
@@ -101,12 +105,6 @@ const envSchema = z
.int()
.positive()
.default(3600000),
// fetch_result_ref 默认最多返回的顶层项/字段数量。
RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce
.number()
.int()
.positive()
.default(50),
})
.superRefine((env, ctx) => {
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
-41
View File
@@ -1,41 +0,0 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
removeFileIfExists,
} from "../utils/fileStore.js";
export type ConversationStateRecord = {
sessionId: string;
isTitleManuallyEdited?: boolean;
messages: unknown[];
branchGroups: unknown[];
};
export class ConversationStateStore {
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async read(sessionScopeKey: string) {
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey));
}
async write(sessionScopeKey: string, state: ConversationStateRecord) {
await atomicWriteJson(this.filePath(sessionScopeKey), state);
return state;
}
async remove(sessionScopeKey: string) {
await removeFileIfExists(this.filePath(sessionScopeKey));
}
private filePath(sessionScopeKey: string) {
return join(this.baseDir, `${sessionScopeKey}.json`);
}
}
+97 -65
View File
@@ -3,16 +3,16 @@ import { z } from "zod";
import { writeLearningAuditLog } from "../audit/learningAudit.js";
import { type ChatRequestContext } from "../chat/sessionBridge.js";
import { config } from "../config.js";
import { type SessionTurnRecord, SessionHistoryStore } from "../history/store.js";
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { logger } from "../logger.js";
import { LearningStateStore } from "./stateStore.js";
import { SessionLearningStateStore } from "./sessionStateStore.js";
import { MemoryStore, type MemoryScope } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SkillStore } from "../skills/store.js";
import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import {
sanitizePersistentDocument,
sanitizePersistentLine,
@@ -41,7 +41,13 @@ const reviewResultSchema = z.object({
skills: z
.array(
z.object({
action: z.enum(["append_pattern", "remove_pattern", "write_reference"]),
action: z.enum([
"append_pattern",
"remove_pattern",
"write_reference",
"write_skill",
"remove_skill",
]),
confidence: z.number().min(0).max(1),
content: z.string().optional(),
evidence: z.string().default(""),
@@ -71,25 +77,21 @@ type TurnReviewInput = {
export class LearningOrchestrator {
private readonly activeReviews = new Set<string>();
private readonly learningStateStore = new LearningStateStore();
private readonly sessionLearningStateStore = new SessionLearningStateStore();
private readonly skillStore = new SkillStore();
private readonly toolContextStore = new ToolSessionContextStore();
constructor(
private readonly runtime: OpencodeRuntimeAdapter,
private readonly memoryStore: MemoryStore,
private readonly historyStore: SessionHistoryStore,
private readonly transcriptStore: SessionTranscriptStore,
) {}
async initialize() {
await Promise.all([
this.learningStateStore.initialize(),
this.toolContextStore.initialize(),
]);
await this.sessionLearningStateStore.initialize();
}
async onTurnCompleted(input: TurnReviewInput) {
const transcript = await this.historyStore.appendTurn(
const transcript = await this.transcriptStore.appendTurn(
{
actorKey: input.requestContext.actorKey,
clientSessionId: input.requestContext.clientSessionId,
@@ -108,13 +110,12 @@ export class LearningOrchestrator {
}
this.activeReviews.add(input.sessionId);
try {
const state = await this.learningStateStore.read(input.sessionId);
const state = await this.sessionLearningStateStore.read(input.sessionId);
const turnsSinceGate = Math.max(0, turnCount - state.lastGatedTurn);
if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN || state.pendingReview) {
if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN) {
this.activeReviews.delete(input.sessionId);
return;
}
await this.learningStateStore.markPending(input.sessionId, true);
} catch (error) {
this.activeReviews.delete(input.sessionId);
throw error;
@@ -145,7 +146,7 @@ export class LearningOrchestrator {
`learning-gate-${input.requestContext.clientSessionId}`,
);
gateSessionId = gateSession.id;
await this.toolContextStore.write({
setRuntimeSessionContext({
actorKey: input.requestContext.actorKey,
allowLearningWrite: false,
clientSessionId: `gate-${input.requestContext.clientSessionId}`,
@@ -153,11 +154,6 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey,
sessionId: gateSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId,
});
await this.runtime.prompt(
@@ -172,7 +168,7 @@ export class LearningOrchestrator {
const gateText = collectTextContent(assistantMessage?.parts ?? []);
const gate = parseGateResult(gateText);
if (!gate) {
await this.learningStateStore.completeGate(input.sessionId, turnCount);
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
await writeLearningAuditLog({
action: "review-gate",
detail: "gate result was not valid JSON",
@@ -197,7 +193,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId,
});
if (!shouldPromote) {
await this.learningStateStore.completeGate(input.sessionId, turnCount);
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
return;
}
await this.runReview({
@@ -207,7 +203,6 @@ export class LearningOrchestrator {
turnCount,
});
} catch (error) {
await this.learningStateStore.markPending(input.sessionId, false);
logger.warn({ err: error, sessionId: input.sessionId }, "learning gate failed");
await writeLearningAuditLog({
action: "review-gate",
@@ -219,7 +214,7 @@ export class LearningOrchestrator {
});
} finally {
if (gateSessionId) {
await this.toolContextStore.remove(gateSessionId).catch(() => undefined);
removeRuntimeSessionContext(gateSessionId);
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
}
}
@@ -239,7 +234,7 @@ export class LearningOrchestrator {
const reviewSession = await this.runtime.createSession(
`learning-review-${input.requestContext.clientSessionId}`,
);
await this.toolContextStore.write({
setRuntimeSessionContext({
actorKey: input.requestContext.actorKey,
allowLearningWrite: false,
clientSessionId: `review-${input.requestContext.clientSessionId}`,
@@ -247,17 +242,13 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey,
sessionId: reviewSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId,
});
try {
const existingMemory = await this.loadMemoryContext(input.requestContext);
await this.runtime.prompt(
reviewSession.id,
buildReviewPrompt({ focus, recentTurns }),
buildReviewPrompt({ existingMemory, focus, recentTurns }),
toRuntimeModel(input.model),
);
const messages = await this.runtime.messages(reviewSession.id, 20);
@@ -267,7 +258,7 @@ export class LearningOrchestrator {
const reviewText = collectTextContent(assistantMessage?.parts ?? []);
const parsed = parseReviewResult(reviewText);
if (!parsed) {
await this.learningStateStore.completeGate(input.sessionId, turnCount);
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
await writeLearningAuditLog({
action: "review-parse",
detail: "review result was not valid JSON",
@@ -279,9 +270,8 @@ export class LearningOrchestrator {
return;
}
await this.applyReviewResult(input, parsed, turnCount);
await this.learningStateStore.completeReview(input.sessionId, turnCount);
await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount);
} catch (error) {
await this.learningStateStore.markPending(input.sessionId, false);
logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed");
await writeLearningAuditLog({
action: "review-run",
@@ -292,7 +282,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId,
});
} finally {
await this.toolContextStore.remove(reviewSession.id).catch(() => undefined);
removeRuntimeSessionContext(reviewSession.id);
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
}
}
@@ -328,6 +318,14 @@ export class LearningOrchestrator {
});
}
private async loadMemoryContext(context: ChatRequestContext) {
const [userMemory, workspaceMemory] = await Promise.all([
this.memoryStore.list("user", context.actorKey),
this.memoryStore.list("workspace", context.projectKey),
]);
return { userMemory, workspaceMemory };
}
private async applyMemoryProposal(
input: TurnReviewInput,
proposal: ReviewResult["memories"][number],
@@ -355,28 +353,33 @@ export class LearningOrchestrator {
source: "review" as const,
traceId: input.requestContext.traceId,
};
const result =
proposal.action === "add"
? await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft)
: proposal.action === "replace"
? await this.memoryStore.replace(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
draft,
)
: await this.memoryStore.remove(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
);
const accepted =
"entry" in result ? Boolean(result.entry) : Boolean(result.changed);
let accepted = false;
let detail = "memory rejected";
if (proposal.action === "add") {
const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft);
accepted = Boolean(result.entry);
detail = result.detail;
} else if (proposal.action === "replace") {
const result = await this.memoryStore.replace(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
draft,
);
accepted = Boolean(result.changed);
detail = result.detail;
} else {
const result = await this.memoryStore.remove(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
);
accepted = Boolean(result.changed);
detail = result.detail;
}
await writeLearningAuditLog({
action: `memory-${proposal.action}`,
detail: sanitizeAuditDetail(
"detail" in result ? result.detail : result.changed ? "memory stored" : "memory deduped",
),
detail: sanitizeAuditDetail(detail),
outcome: accepted ? "accepted" : "rejected",
projectId: input.requestContext.projectId,
proposal: sanitizeMemoryProposalForAudit(proposal),
@@ -411,11 +414,15 @@ export class LearningOrchestrator {
proposal.skill_path,
proposal.target_id ?? "",
)
: await this.skillStore.writeReference(
proposal.skill_path,
proposal.file_path ?? "",
proposal.content ?? "",
);
: proposal.action === "write_reference"
? await this.skillStore.writeReference(
proposal.skill_path,
proposal.file_path ?? "",
proposal.content ?? "",
)
: proposal.action === "write_skill"
? await this.skillStore.writeSkill(proposal.skill_path, proposal.content ?? "")
: await this.skillStore.removeSkill(proposal.skill_path);
await writeLearningAuditLog({
action: `skill-${proposal.action}`,
detail: sanitizeAuditDetail(result.detail),
@@ -451,9 +458,14 @@ const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] })
};
const buildReviewPrompt = ({
existingMemory,
focus,
recentTurns,
}: {
existingMemory: {
userMemory: Array<{ content: string; id: string }>;
workspaceMemory: Array<{ content: string; id: string }>;
};
focus: GateResult["focus"];
recentTurns: SessionTurnRecord[];
}) => {
@@ -463,6 +475,16 @@ const buildReviewPrompt = ({
`Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`,
)
.join("\n\n");
const userMemoryBlock =
existingMemory.userMemory.length > 0
? existingMemory.userMemory.map((entry) => `- [${entry.id}] ${entry.content}`).join("\n")
: "(empty)";
const workspaceMemoryBlock =
existingMemory.workspaceMemory.length > 0
? existingMemory.workspaceMemory
.map((entry) => `- [${entry.id}] ${entry.content}`)
.join("\n")
: "(empty)";
return [
"You are doing an internal self-improvement review for TJWaterAgent.",
"Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.",
@@ -473,16 +495,26 @@ const buildReviewPrompt = ({
"- Keep only stable user preferences, durable constraints, or stable workspace facts.",
"- Use scope='user' for user preferences and constraints.",
"- Use scope='workspace' for project or environment facts.",
"- Read the existing memories first before proposing changes.",
"- If a new lesson overlaps, refines, or supersedes an existing memory, prefer replace/remove using target_id instead of add.",
"- Use add only when the lesson is genuinely new after reviewing the existing memory list.",
"- Do not store one-off task outcomes, temporary facts, or speculative conclusions.",
"",
"Current persisted memories:",
"[User memory]",
userMemoryBlock,
"[Workspace memory]",
workspaceMemoryBlock,
"",
"Skill rules:",
"- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.",
"- Prefer append_pattern for concise reusable lessons.",
"- Use write_reference only for compact durable supporting notes under references/*.md.",
"- Do not edit frontmatter or arbitrary sections.",
"- Use write_skill only when the conversation establishes a complete reusable SKILL.md with frontmatter name and description; it creates or overwrites the main SKILL.md.",
"- Use remove_skill only when the conversation clearly establishes the whole skill is obsolete or invalid.",
"",
"Output JSON schema:",
`{"summary":"string","memories":[{"action":"add|replace|remove","scope":"user|workspace","content":"string?","target_id":"string?","confidence":0.0,"evidence":"string"}],"skills":[{"action":"append_pattern|remove_pattern|write_reference","skill_path":"string","pattern":"string?","target_id":"string?","file_path":"references/example.md?","content":"string?","confidence":0.0,"evidence":"string"}]}`,
`{"summary":"string","memories":[{"action":"add|replace|remove","scope":"user|workspace","content":"string?","target_id":"string?","confidence":0.0,"evidence":"string"}],"skills":[{"action":"append_pattern|remove_pattern|write_reference|write_skill|remove_skill","skill_path":"string","pattern":"string?","target_id":"string?","file_path":"references/example.md?","content":"string?","confidence":0.0,"evidence":"string"}]}`,
"",
"If nothing should be saved, return empty arrays.",
"",
@@ -7,57 +7,51 @@ import {
readJsonFile,
} from "../utils/fileStore.js";
export type LearningSessionState = {
export type SessionLearningState = {
lastGatedTurn: number;
lastReviewedTurn: number;
pendingReview: boolean;
sessionId: string;
updatedAt: string;
};
export class LearningStateStore {
constructor(private readonly baseDir = config.LEARNING_STATE_STORAGE_DIR) {}
export class SessionLearningStateStore {
constructor(private readonly baseDir = config.SESSION_LEARNING_STATE_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async read(sessionId: string): Promise<LearningSessionState> {
const existing = await readJsonFile<LearningSessionState>(this.filePath(sessionId));
async read(sessionId: string): Promise<SessionLearningState> {
const existing = await readJsonFile<SessionLearningState>(this.filePath(sessionId));
if (existing) {
return existing;
return {
lastGatedTurn: existing.lastGatedTurn,
lastReviewedTurn: existing.lastReviewedTurn,
sessionId: existing.sessionId,
updatedAt: existing.updatedAt,
};
}
return {
lastGatedTurn: 0,
lastReviewedTurn: 0,
pendingReview: false,
sessionId,
updatedAt: new Date(0).toISOString(),
};
}
async write(state: LearningSessionState) {
async write(state: SessionLearningState) {
await atomicWriteJson(this.filePath(state.sessionId), {
...state,
updatedAt: new Date().toISOString(),
});
}
async markPending(sessionId: string, pendingReview: boolean) {
const current = await this.read(sessionId);
await this.write({
...current,
pendingReview,
});
}
async completeReview(sessionId: string, reviewedTurnCount: number) {
const current = await this.read(sessionId);
await this.write({
...current,
lastGatedTurn: Math.max(current.lastGatedTurn, reviewedTurnCount),
lastReviewedTurn: reviewedTurnCount,
pendingReview: false,
});
}
@@ -66,7 +60,6 @@ export class LearningStateStore {
await this.write({
...current,
lastGatedTurn: gatedTurnCount,
pendingReview: false,
});
}
+26
View File
@@ -2,14 +2,40 @@ import pino from "pino";
import { config } from "./config.js";
const pad = (value: number) => value.toString().padStart(2, "0");
const padMilliseconds = (value: number) => value.toString().padStart(3, "0");
const formatLocalTimestamp = (date: Date) => {
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const milliseconds = padMilliseconds(date.getMilliseconds());
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
const offsetHours = pad(Math.floor(absoluteOffsetMinutes / 60));
const offsetRemainderMinutes = pad(absoluteOffsetMinutes % 60);
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${sign}${offsetHours}:${offsetRemainderMinutes}`;
};
export const logger = pino({
level: config.LOG_LEVEL,
formatters: {
level: (label) => ({ level: label }),
},
timestamp: () => `,"time":"${formatLocalTimestamp(new Date())}"`,
transport:
config.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l o",
},
}
: undefined,
+21 -9
View File
@@ -42,28 +42,36 @@ export class MemoryStore {
constructor(
private readonly baseDir = config.MEMORY_STORAGE_DIR,
private readonly historyDir = join(config.PERSISTENCE_HISTORY_DIR, "memory"),
private readonly backupDir = join(config.PERSISTENCE_BACKUP_DIR, "memory"),
) {}
async initialize() {
await ensureDirectory(this.baseDir);
await ensureDirectory(join(this.baseDir, "users"));
await ensureDirectory(join(this.baseDir, "workspaces"));
// 历史备份与正式数据分目录存放,便于排查和手工恢复。
await ensureDirectory(this.historyDir);
// 备份与正式数据分目录存放,便于排查和手工恢复。
await ensureDirectory(this.backupDir);
}
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content);
if (!content) {
return { changed: false, entry: null as MemoryEntry | null };
return {
changed: false,
detail: "content rejected by persistence policy",
entry: null as MemoryEntry | null,
};
}
const entries = await this.readEntries(scope, key);
const existing = entries.find((entry) => entry.content === content);
if (existing) {
return { changed: false, entry: existing };
return {
changed: false,
detail: "memory already existed",
entry: existing,
};
}
const entry: MemoryEntry = {
@@ -76,11 +84,15 @@ export class MemoryStore {
this.filePath(scope, key),
renderMemoryMarkdown(scope, entries),
{
historyDir: this.historyDir,
backupDir: this.backupDir,
rootDir: this.baseDir,
},
);
return { changed: true, entry };
return {
changed: true,
detail: "memory stored",
entry,
};
});
}
@@ -113,7 +125,7 @@ export class MemoryStore {
this.filePath(scope, key),
renderMemoryMarkdown(scope, entries),
{
historyDir: this.historyDir,
backupDir: this.backupDir,
rootDir: this.baseDir,
},
);
@@ -132,7 +144,7 @@ export class MemoryStore {
this.filePath(scope, key),
renderMemoryMarkdown(scope, next),
{
historyDir: this.historyDir,
backupDir: this.backupDir,
rootDir: this.baseDir,
},
);
+19 -110
View File
@@ -1,17 +1,16 @@
import { config } from "../config.js";
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
import { readJsonFile } from "../utils/fileStore.js";
import {
type ResultReferenceKind,
type ResultReferenceRecord,
type ResultReferenceSource,
type RetrievalContext,
RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
type ResultReferenceStore,
} from "./store.js";
type ResolveOptions = {
expectedKind?: ResultReferenceKind;
maxItems?: number;
};
type RegisterResultReferenceInput = {
@@ -36,6 +35,7 @@ export type RenderJunctionPayload = {
export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {}
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
async register(input: RegisterResultReferenceInput) {
const normalizedData = normalizeDataForKind(
input.kind,
@@ -68,15 +68,12 @@ export class ResultReferenceResolver {
throw new Error(`render payload file not found: ${filePath}`);
}
const payloadCandidate = normalizeRenderPayloadFile(raw, {
filePath,
projectId: input.projectId,
});
if (payloadCandidate.repaired) {
await atomicWriteJson(filePath, payloadCandidate.file);
const wrapper = normalizeRenderPayloadFile(raw, filePath);
if (!wrapper) {
throw new Error("render payload file must use the wrapped { metadata, location, data } format");
}
const payload = extractRenderJunctionPayload(payloadCandidate.data);
const payload = extractRenderJunctionPayload(wrapper.data);
if (!payload) {
throw new Error("render payload file does not contain a valid junction render payload");
}
@@ -86,27 +83,10 @@ export class ResultReferenceResolver {
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
});
}
async getAuthorized(resultRef: string, context: RetrievalContext, options: ResolveOptions = {}) {
const record = await this.getResolvedRecord(resultRef, context, options);
if (!record) {
return null;
}
return {
ok: true,
result_ref: record.resultRef,
result_size_bytes: record.sizeBytes,
stored_at: record.createdAt,
data: projectData(record.data, options.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS),
preview: record.preview,
kind: record.kind,
schema_version: record.schemaVersion,
source: record.source,
};
}
async getFullAuthorized(
resultRef: string,
context: RetrievalContext,
@@ -164,6 +144,7 @@ export const extractRenderJunctionPayload = (
return null;
}
// 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
if (Object.keys(nodeAreaMap).length === 0) {
return null;
@@ -202,35 +183,18 @@ const normalizeDataForKind = (
const normalizeRenderPayloadFile = (
value: unknown,
context: { filePath: string; projectId?: string },
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
filePath: string,
): { data: unknown } | null => {
if (!isRecord(value) || !("data" in value)) {
return {
data: value,
file: {
metadata: buildWrapperMetadata({}, value, context.projectId),
location: buildWrapperLocation(undefined, context.filePath),
data: value,
},
repaired: false,
};
return null;
}
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
const location = buildWrapperLocation(value.location, context.filePath);
const next: Record<string, unknown> = {
...value,
metadata,
location,
};
return {
data: next.data,
file: next,
repaired:
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
};
if (!isRecord(value.metadata) || !isRecord(value.location)) {
return null;
}
if (value.location.file_path !== filePath) {
return null;
}
return { data: value.data };
};
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
@@ -250,60 +214,5 @@ const normalizeStringRecord = (value: Record<string, unknown>) =>
.filter(([, entry]) => entry.length > 0),
);
const projectData = (data: unknown, maxItems: number) => {
if (Array.isArray(data)) {
return data.slice(0, maxItems);
}
if (isRecord(data)) {
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
}
return data;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const buildWrapperMetadata = (
value: unknown,
root: unknown,
fallbackProjectId?: string,
) => {
const metadata = isRecord(value) ? { ...value } : {};
const source = isRecord(root) ? root : {};
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
const createdAt =
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
? source.createdAt.trim()
: new Date().toISOString();
metadata.createdAt = createdAt;
}
if (
typeof metadata.projectId !== "string" ||
metadata.projectId.trim().length === 0
) {
const projectId =
typeof source.projectId === "string" && source.projectId.trim().length > 0
? source.projectId.trim()
: fallbackProjectId;
if (projectId) {
metadata.projectId = projectId;
}
}
return metadata;
};
const buildWrapperLocation = (value: unknown, filePath: string) => {
if (isRecord(value)) {
return {
...value,
file_path: filePath,
};
}
return {
file_path: filePath,
};
};
+6 -69
View File
@@ -10,22 +10,17 @@ import {
listJsonFiles,
readJsonFile,
removeFileIfExists,
toProjectKey,
} from "../utils/fileStore.js";
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
export const RESULT_REFERENCE_KIND = {
dynamicHttpResult: "dynamic-http-result",
renderJunctionsPayload: "render-junctions-payload",
} as const;
export const RESULT_REFERENCE_SOURCE = {
dynamicHttp: "dynamic_http",
agentGenerated: "agent_generated",
legacy: "legacy",
migration: "migration",
} as const;
export type ResultReferenceKind =
@@ -135,6 +130,7 @@ export class ResultReferenceStore {
source: input.source,
traceId: input.traceId,
};
// result_ref 对外暴露短引用,完整数据落盘;这样可以避免大结果直接塞进模型上下文。
await atomicWriteJson(this.filePath(resultRef), record);
return record;
}
@@ -145,13 +141,13 @@ export class ResultReferenceStore {
return null;
}
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
const record =
normalizeResultReferenceRecord(rawRecord) ??
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
const record = normalizeResultReferenceRecord(
await readJsonFile<unknown>(this.filePath(normalizedResultRef)),
);
if (!record) {
return null;
}
// 读取 result_ref 时按用户、项目和可选会话三层校验,防止跨项目/跨用户取数。
if (record.actorKey !== context.actorKey) {
return null;
}
@@ -204,6 +200,7 @@ export class ResultReferenceStore {
if (!stats) {
continue;
}
// TTL 以文件修改时间为准,清理长期无人访问的 result_ref 文件。
if (now - stats.mtimeMs > this.ttlMs) {
await removeFileIfExists(filePath);
}
@@ -280,9 +277,6 @@ export const normalizeResultReferenceRecord = (
const normalizeResultReferenceKind = (
value: unknown,
): ResultReferenceKind | null => {
if (value === undefined) {
return RESULT_REFERENCE_KIND.dynamicHttpResult;
}
return Object.values(RESULT_REFERENCE_KIND).includes(
value as ResultReferenceKind,
)
@@ -293,9 +287,6 @@ const normalizeResultReferenceKind = (
const normalizeResultReferenceSource = (
value: unknown,
): ResultReferenceSource | null => {
if (value === undefined) {
return RESULT_REFERENCE_SOURCE.legacy;
}
return Object.values(RESULT_REFERENCE_SOURCE).includes(
value as ResultReferenceSource,
)
@@ -311,60 +302,6 @@ const normalizeResultRef = (value: string) => {
return match?.[1] ?? null;
};
const normalizeLegacyRenderReferenceRecord = (
value: unknown,
resultRef: string,
context: RetrievalContext,
): ResultReferenceRecord | null => {
const data = extractLegacyRenderPayload(value);
if (!data) {
return null;
}
const root = isRecord(value) ? value : {};
const metadata = isRecord(root.metadata) ? root.metadata : {};
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
const createdAt =
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
return {
resultRef,
actorKey: context.actorKey,
clientSessionId: context.clientSessionId ?? "",
createdAt,
data,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
preview: buildPreview(data),
projectId,
projectKey: toProjectKey(projectId),
schemaVersion: 1,
sessionId: context.clientSessionId ?? resultRef,
sizeBytes: estimateBytes(data),
source: RESULT_REFERENCE_SOURCE.legacy,
traceId: "legacy-render-ref",
};
};
const extractLegacyRenderPayload = (value: unknown) => {
if (!isRecord(value)) {
return null;
}
const candidate = isRecord(value.data) ? value.data : value;
if (!isRecord(candidate.node_area_map)) {
return null;
}
return candidate;
};
const firstNonEmptyString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
};
const isResultPreview = (value: unknown): value is ResultPreview =>
isRecord(value) &&
typeof value.count === "number" &&
+523 -121
View File
@@ -2,18 +2,20 @@ import { Router } from "express";
import { z } from "zod";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { type SessionHistoryStore } from "../history/store.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { logger } from "../logger.js";
import { MemoryStore } from "../memory/store.js";
import { type ConversationStateStore } from "../conversations/stateStore.js";
import { type ConversationStore } from "../conversations/store.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle,
shouldGenerateSessionTitle,
} from "./chatSession.js";
@@ -44,20 +46,153 @@ const forkPayloadSchema = z.object({
keep_message_count: z.coerce.number().int().min(0),
});
const conversationStateSchema = z.object({
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 lastRunStatuses = new Map<string, RunStatus>();
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
const getSessionRunStatus = (sessionId: string) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
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)}`;
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 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;
};
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 = (
sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter,
conversationStore: ConversationStore,
conversationStateStore: ConversationStateStore,
sessionMetadataStore: SessionMetadataStore,
sessionUiStateStore: SessionUiStateStore,
memoryStore: MemoryStore,
sessionHistoryStore: SessionHistoryStore,
sessionTranscriptStore: SessionTranscriptStore,
learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver,
) => {
@@ -77,13 +212,15 @@ export const buildChatRouter = (
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const sessionId = requestedSessionId || (await runtime.createSession()).id;
const { record, created } = await conversationStore.ensure({
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId: parsed.data.session_id,
sessionId,
userId,
});
@@ -102,7 +239,7 @@ export const buildChatRouter = (
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const records = await conversationStore.list({
const records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
@@ -116,6 +253,8 @@ export const buildChatRouter = (
updated_at: record.updatedAt,
status: record.status,
parent_session_id: record.parentSessionId,
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
run_status: getSessionRunStatus(record.sessionId),
})),
});
});
@@ -131,7 +270,7 @@ export const buildChatRouter = (
return;
}
const conversation = await conversationStore.get(
const sessionRecord = await sessionMetadataStore.get(
{
actorKey,
projectId,
@@ -140,29 +279,97 @@ export const buildChatRouter = (
},
sessionId,
);
if (!conversation) {
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const state = await conversationStateStore.read(conversation.sessionScopeKey);
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
res.json({
id: conversation.sessionId,
title: conversation.title ?? "新对话",
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: conversation.createdAt,
updated_at: conversation.updatedAt,
status: conversation.status,
session_id: conversation.sessionId,
created_at: sessionRecord.createdAt,
updated_at: sessionRecord.updatedAt,
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
branch_groups: state?.branchGroups ?? [],
parent_session_id: conversation.parentSessionId,
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
});
});
chatRouter.get("/session/:sessionId/stream", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
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 sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: state?.messages ?? run?.messages ?? [],
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed",
}),
);
if (!run || run.status !== "running") {
res.end();
return;
}
const subscriber: StreamSubscriber = {
write: (event, data) => {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
run.subscribers.add(subscriber);
const cleanup = () => {
run.subscribers.delete(subscriber);
};
req.on("close", cleanup);
res.on("close", cleanup);
});
chatRouter.put("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const parsed = conversationStateSchema.safeParse(req.body ?? {});
const parsed = sessionStateSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -180,22 +387,42 @@ export const buildChatRouter = (
return;
}
const { record } = await conversationStore.ensure({
const { record } = await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId,
userId,
});
const nextRecord = await conversationStore.touch(record, {
const nextRecord = await sessionMetadataStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}),
});
await conversationStateStore.write(nextRecord.sessionScopeKey, {
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 ?? "新对话",
@@ -222,27 +449,32 @@ export const buildChatRouter = (
res.status(400).json({ message: "session_id and title are required" });
return;
}
const conversation = await conversationStore.get(
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!conversation) {
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const nextConversation = await conversationStore.touch(conversation, { title });
const state = await conversationStateStore.read(nextConversation.sessionScopeKey);
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title });
const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
);
if (state) {
await conversationStateStore.write(nextConversation.sessionScopeKey, {
await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
{
...state,
isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
});
},
);
}
res.json({
id: nextConversation.sessionId,
title: nextConversation.title,
updated_at: nextConversation.updatedAt,
id: nextSessionRecord.sessionId,
title: nextSessionRecord.title,
updated_at: nextSessionRecord.updatedAt,
});
});
@@ -256,16 +488,22 @@ export const buildChatRouter = (
res.status(400).json({ message: "session_id is required" });
return;
}
const conversation = await conversationStore.get(
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!conversation) {
if (!sessionRecord) {
res.status(204).end();
return;
}
await conversationStateStore.remove(conversation.sessionScopeKey);
await conversationStore.remove(conversation);
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
});
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
await sessionMetadataStore.remove(sessionRecord);
res.status(204).end();
});
@@ -323,11 +561,56 @@ export const buildChatRouter = (
}
try {
const binding = await sessionBridge.abort({
clientSessionId: parsed.data.session_id,
});
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
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) {
if (!binding && !run) {
res.status(204).end();
return;
}
@@ -335,7 +618,7 @@ export const buildChatRouter = (
logger.info(
{
clientSessionId: parsed.data.session_id,
sessionId: binding.sessionId,
sessionId: binding?.sessionId ?? parsed.data.session_id,
},
"aborted chat session by client request",
);
@@ -370,54 +653,56 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sourceClientSessionId = parsed.data.session_id?.trim();
const sourceConversation = sourceClientSessionId
? await conversationStore.get(
const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sourceClientSessionId,
sourceSessionId,
)
: null;
const { record: targetConversation } = await conversationStore.ensure({
const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceClientSessionId,
parentSessionId: sourceSessionId,
projectId,
projectKey,
sessionId: forkSession.id,
userId,
});
const nextClientSessionId = targetConversation.sessionId;
const nextSessionId = targetSessionRecord.sessionId;
if (sourceClientSessionId && parsed.data.keep_message_count > 0) {
await sessionHistoryStore.cloneThread(
if (sourceSessionId && parsed.data.keep_message_count > 0) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceClientSessionId,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceClientSessionId,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextClientSessionId,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextClientSessionId,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
if (sourceConversation?.title) {
await conversationStore.touch(targetConversation, {
title: sourceConversation.title,
if (sourceSessionRecord?.title) {
await sessionMetadataStore.touch(targetSessionRecord, {
title: sourceSessionRecord.title,
});
}
}
logger.info(
{
sourceClientSessionId: parsed.data.session_id,
clientSessionId: nextClientSessionId,
sourceSessionId: parsed.data.session_id,
sessionId: nextSessionId,
traceId,
projectId,
keepMessageCount: parsed.data.keep_message_count,
@@ -426,7 +711,7 @@ export const buildChatRouter = (
);
res.status(200).json({
session_id: nextClientSessionId,
session_id: nextSessionId,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@@ -458,36 +743,54 @@ export const buildChatRouter = (
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const { record: conversation, created: conversationCreated } =
await conversationStore.ensure({
actorKey,
projectId,
projectKey,
sessionId: parsed.data.session_id,
userId,
});
const activeConversation = await conversationStore.touch(conversation);
const requestedSessionId = parsed.data.session_id?.trim();
const existingSessionRecord = requestedSessionId
? await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
requestedSessionId,
)
: null;
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: activeConversation.sessionId,
sessionId: requestedSessionId,
accessToken,
projectId,
traceId,
userId,
});
const { record: ensuredSessionRecord, created: sessionCreated } =
await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId: binding.sessionId,
userId,
});
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
const historyContext = {
actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId,
};
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord),
);
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
res.status(409).json({
message: "session is already streaming",
session_id: activeSessionRecord.sessionId,
});
return;
}
logger.info(
{
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
created: created || conversationCreated,
created: created || sessionCreated,
model: parsed.data.model,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
@@ -505,27 +808,126 @@ export const buildChatRouter = (
const clientSessionId = requestContext.clientSessionId;
let streamClosed = false;
const abortController = new AbortController();
sessionBridge.registerAbortController(clientSessionId, abortController);
const initialMessages = createInitialStreamingMessages(
initialSessionState?.messages ?? [],
parsed.data.message,
);
const branchGroups = initialSessionState?.branchGroups ?? [];
const activeRun: ActiveRun = {
clientSessionId,
controller: abortController,
messages: initialMessages,
status: "running",
subscribers: new Set(),
};
activeRuns.set(clientSessionId, activeRun);
lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: initialMessages,
branchGroups,
});
const queueSessionUiStatePersist = () => {
const snapshot = {
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: activeRun.messages,
branchGroups,
};
persistQueue = persistQueue
.catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"failed to persist previous chat stream state",
);
})
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
return persistQueue;
};
const primarySubscriber: StreamSubscriber = {
write: (event, data) => {
if (!streamClosed && !res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
activeRun.subscribers.add(primarySubscriber);
const handleClientClose = () => {
if (streamClosed || abortController.signal.aborted) {
return;
}
abortController.abort();
streamClosed = true;
activeRun.subscribers.delete(primarySubscriber);
};
req.on("close", handleClientClose);
res.on("close", handleClientClose);
const publish = (event: string, data: Record<string, unknown>) => {
if (event === "token") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = "completed";
lastRunStatuses.set(clientSessionId, "completed");
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
}));
}
for (const subscriber of activeRun.subscribers) {
subscriber.write(event, data);
}
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
};
try {
const preparedMessage = await buildPromptWithLearningContext(
memoryStore,
requestContext.actorKey,
requestContext.projectKey,
recentTurns,
parsed.data.message,
{
recentTurns,
persistedMessages: initialSessionState?.messages,
message: parsed.data.message,
restoreConversation: !hadExistingRuntimeSession,
},
);
const streamResult = await streamPromptResponse({
runtime,
opencodeSessionId: binding.sessionId,
sessionId: binding.sessionId,
clientSessionId,
message: preparedMessage,
model: parsed.data.model,
@@ -533,12 +935,12 @@ export const buildChatRouter = (
projectId: requestContext.projectId,
signal: abortController.signal,
write: (event, data) => {
if (streamClosed || res.writableEnded || res.destroyed) {
return;
}
res.write(toSse(event, data));
publish(event, data);
},
});
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60);
@@ -546,20 +948,20 @@ export const buildChatRouter = (
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const latestConversation =
(await conversationStore.get(
const latestSessionRecord =
(await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
activeConversation.sessionId,
)) ?? activeConversation;
const latestConversationState = await conversationStateStore.read(
latestConversation.sessionScopeKey,
activeSessionRecord.sessionId,
)) ?? activeSessionRecord;
const latestSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(latestSessionRecord),
);
const existingSessionTitle = latestConversation.title;
const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle;
const shouldGenerateTitle = shouldGenerateSessionTitle({
recentTurnCount: recentTurns.length,
isTitleManuallyEdited:
latestConversationState?.isTitleManuallyEdited ?? false,
latestSessionState?.isTitleManuallyEdited ?? false,
});
if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, {
@@ -569,43 +971,43 @@ export const buildChatRouter = (
fallbackTitle: existingSessionTitle,
});
}
const nextConversation = await conversationStore.touch(latestConversation, {
const nextSessionRecord = await sessionMetadataStore.touch(latestSessionRecord, {
...(sessionTitle && sessionTitle !== existingSessionTitle
? { title: sessionTitle }
: {}),
});
if (!streamClosed && !res.writableEnded && !res.destroyed) {
if (
shouldGenerateTitle &&
sessionTitle &&
sessionTitle !== existingSessionTitle
) {
res.write(
toSse("session_title", {
session_id: clientSessionId,
title: sessionTitle,
}),
);
}
}
if (assistantText) {
void learningOrchestrator.onTurnCompleted({
assistantMessage: assistantText,
model: parsed.data.model,
requestContext,
sessionId: clientSessionId,
toolCallCount: streamResult.toolCallCount,
userMessage: parsed.data.message,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"post-turn learning failed",
);
if (
shouldGenerateTitle &&
sessionTitle &&
sessionTitle !== existingSessionTitle
) {
publish("session_title", {
session_id: clientSessionId,
title: sessionTitle,
});
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
}
}
} finally {
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId);
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
sessionBridge.finalizeRequest(clientSessionId);
activeRun.status = abortController.signal.aborted
? activeRun.status === "aborted"
? "aborted"
: "aborted"
: activeRun.status === "running"
? "completed"
: activeRun.status;
lastRunStatuses.set(clientSessionId, activeRun.status);
for (const subscriber of activeRun.subscribers) {
subscriber.close();
}
activeRun.subscribers.clear();
activeRuns.delete(clientSessionId);
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
+118 -6
View File
@@ -1,5 +1,5 @@
import { logger } from "../logger.js";
import { type SessionTurnRecord } from "../history/store.js";
import { type SessionTurnRecord } from "../sessions/transcriptStore.js";
import { MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
@@ -192,15 +192,22 @@ export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore,
actorKey: string,
projectKey: string,
recentTurns: SessionTurnRecord[],
message: string,
options: {
recentTurns: SessionTurnRecord[];
persistedMessages?: unknown[];
message: string;
restoreConversation?: boolean;
},
) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
const restoredConversation = buildRestoredConversationContext(recentTurns);
const restoredConversation = options.restoreConversation === false
? ""
: buildRestoredConversationFromMessages(options.persistedMessages) ||
buildRestoredConversationContext(options.recentTurns);
if (!snapshot && !restoredConversation) {
return message;
return options.message;
}
return [snapshot, restoredConversation, `[Current user request]\n${message}`]
return [snapshot, restoredConversation, `[Current user request]\n${options.message}`]
.filter(Boolean)
.join("\n\n");
};
@@ -240,3 +247,108 @@ const compactMessage = (value: string) => {
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: normalized;
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isSyntheticAssistantError = (content: string) =>
/^⚠️\s*\*\*(请求已中断|错误[:]?)/.test(content);
export const extractLatestFrontendTurn = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return null;
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const assistant = messages[index];
if (!isObjectRecord(assistant) || assistant.role !== "assistant") {
continue;
}
const assistantMessage =
typeof assistant.content === "string"
? assistant.content.replace(/\s+/g, " ").trim()
: "";
if (!assistantMessage || isSyntheticAssistantError(assistantMessage)) {
continue;
}
const user = messages
.slice(0, index)
.reverse()
.find((message) => isObjectRecord(message) && message.role === "user");
if (!isObjectRecord(user) || typeof user.content !== "string") {
continue;
}
const userMessage = user.content.replace(/\s+/g, " ").trim();
if (!userMessage) {
continue;
}
return {
assistantMessage,
toolCallCount: estimateFrontendToolCallCount(assistant),
userMessage,
};
}
return null;
};
const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return "";
}
const formattedMessages = messages
.slice(-(RESTORE_TURN_LIMIT * 2 + 2))
.flatMap((message) => {
if (!isObjectRecord(message)) {
return [];
}
const role = message.role;
const content = message.content;
if ((role !== "user" && role !== "assistant") || typeof content !== "string") {
return [];
}
const normalizedContent = compactMessage(content);
if (!normalizedContent) {
return [];
}
if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) {
return [];
}
return [`${role === "user" ? "用户" : "助手"}${normalizedContent}`];
});
if (formattedMessages.length === 0) {
return "";
}
const conversation = formattedMessages.join("\n");
const trimmedConversation =
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
: conversation;
return [
"[Previous conversation context]",
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
trimmedConversation,
].join("\n");
};
const estimateFrontendToolCallCount = (assistant: Record<string, unknown>) => {
const progress = Array.isArray(assistant.progress) ? assistant.progress : [];
const artifacts = Array.isArray(assistant.artifacts) ? assistant.artifacts : [];
const toolProgressCount = progress.filter(
(item) =>
isObjectRecord(item) &&
(item.phase === "tool" ||
(typeof item.id === "string" && item.id.startsWith("tool-"))),
).length;
return Math.max(toolProgressCount, artifacts.length);
};
+15 -17
View File
@@ -13,7 +13,7 @@ export type SupportedModel = (typeof supportedModels)[number];
type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter;
opencodeSessionId: string;
sessionId: string;
clientSessionId: string;
message: string;
model?: SupportedModel;
@@ -36,8 +36,6 @@ type ProgressPayload = {
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
dynamic_http_call: "后端数据查询",
fetch_result_ref: "结果引用回读",
memory_manager: "记忆写入",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
@@ -168,11 +166,11 @@ export const collectTextContent = (parts: Part[]) =>
const emitFallbackMessage = async (
runtime: OpencodeRuntimeAdapter,
opencodeSessionId: string,
sessionId: string,
clientSessionId: string,
write: (event: string, data: Record<string, unknown>) => void,
) => {
const messages = await runtime.messages(opencodeSessionId);
const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
@@ -293,7 +291,7 @@ const getToolProgressTitle = (tool: string, status: string) => {
export const streamPromptResponse = async ({
runtime,
opencodeSessionId,
sessionId,
clientSessionId,
message,
model,
@@ -332,7 +330,7 @@ export const streamPromptResponse = async ({
let aborted = signal?.aborted ?? false;
let failed = false;
const debugContext = {
opencodeSessionId,
sessionId,
clientSessionId,
traceId,
projectId,
@@ -406,7 +404,7 @@ export const streamPromptResponse = async ({
});
const promptPromise = runtime
.prompt(opencodeSessionId, message, toRuntimeModel(model))
.prompt(sessionId, message, toRuntimeModel(model))
.then(() => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", {
@@ -471,7 +469,7 @@ export const streamPromptResponse = async ({
}
const event = next.result.value as OpencodeEvent;
if (!isSessionEvent(event, opencodeSessionId)) {
if (!isSessionEvent(event, sessionId)) {
continue;
}
@@ -541,7 +539,7 @@ export const streamPromptResponse = async ({
});
void writeLlmRequestAuditLog({
kind: "skill",
sessionId: opencodeSessionId,
sessionId: sessionId,
clientSessionId,
traceId,
projectId,
@@ -691,7 +689,7 @@ export const streamPromptResponse = async ({
logger.warn(
{
tool: part.tool,
sessionId: opencodeSessionId,
sessionId: sessionId,
clientSessionId,
},
"llm tool request missing reason",
@@ -699,7 +697,7 @@ export const streamPromptResponse = async ({
}
void writeLlmRequestAuditLog({
kind: "tool",
sessionId: opencodeSessionId,
sessionId: sessionId,
clientSessionId,
traceId,
projectId,
@@ -781,12 +779,12 @@ export const streamPromptResponse = async ({
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await runtime.abortSession(opencodeSessionId).catch((error) => {
logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session");
await runtime.abortSession(sessionId).catch((error) => {
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
});
await runtime.waitForSessionIdle(opencodeSessionId).catch((error) => {
await runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn(
{ sessionId: opencodeSessionId, err: error },
{ sessionId: sessionId, err: error },
"failed while waiting for aborted opencode session to become idle",
);
});
@@ -803,7 +801,7 @@ export const streamPromptResponse = async ({
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await emitFallbackMessage(runtime, opencodeSessionId, clientSessionId, write);
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
}
emitProgress({
id: "request-received",
+11
View File
@@ -167,6 +167,7 @@ export class OpencodeRuntimeAdapter {
"starting opencode server in embedded mode",
);
const startedAt = Date.now();
let runtime;
try {
runtime = await createOpencode({
@@ -186,6 +187,16 @@ export class OpencodeRuntimeAdapter {
throw error;
}
logger.info(
{
elapsedMs: Math.max(0, Date.now() - startedAt),
hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT,
mode: config.OPENCODE_MODE,
},
"opencode server started in embedded mode",
);
this.closeServer = () => {
runtime.server.close();
};
+27
View File
@@ -0,0 +1,27 @@
export type RuntimeSessionContext = {
accessToken?: string;
actorKey: string;
allowLearningWrite?: boolean;
clientSessionId: string;
learningMode?: "interactive" | "review";
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
projectId?: string;
projectKey: string;
sessionId: string;
traceId: string;
};
const contexts = new Map<string, RuntimeSessionContext>();
export const setRuntimeSessionContext = (context: RuntimeSessionContext) => {
contexts.set(context.sessionId, { ...context });
};
export const getRuntimeSessionContext = (sessionId: string) => {
const context = contexts.get(sessionId);
return context ? { ...context } : null;
};
export const removeRuntimeSessionContext = (sessionId: string) => {
contexts.delete(sessionId);
};
+119 -110
View File
@@ -1,40 +1,44 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import cors from "cors";
import express from "express";
import { SessionHistoryStore } from "./history/store.js";
import { requireAgentAuth } from "./auth/agentAuth.js";
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js";
import { ConversationStateStore } from "./conversations/stateStore.js";
import { ConversationStore } from "./conversations/store.js";
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
import { SessionMetadataStore } from "./sessions/metadataStore.js";
import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js";
import { MemoryStore } from "./memory/store.js";
import { ResultReferenceResolver } from "./results/resolver.js";
import { ResultReferenceStore } from "./results/store.js";
import {
RESULT_REFERENCE_SOURCE,
ResultReferenceStore,
} from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import { ToolSessionContextStore } from "./session/toolContextStore.js";
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
const app = express();
// 这里集中组装 Agent 服务的运行期依赖,路由层只通过接口调用,便于测试时替换实现。
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
const conversationStore = new ConversationStore();
const conversationStateStore = new ConversationStateStore();
const sessionMetadataStore = new SessionMetadataStore();
const sessionUiStateStore = new SessionUiStateStore();
const memoryStore = new MemoryStore();
const sessionHistoryStore = new SessionHistoryStore();
const toolContextStore = new ToolSessionContextStore();
const sessionTranscriptStore = new SessionTranscriptStore();
const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime,
memoryStore,
sessionHistoryStore,
sessionTranscriptStore,
);
const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors());
@@ -59,118 +63,123 @@ app.get("/health", async (_req, res) => {
}
});
app.post("/internal/tools/dynamic-http-call", async (req, res) => {
app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const threadContext = await toolContextStore.read(sessionScopeKey);
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
if (!threadContext && !runtimeContext) {
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: "runtime or session context not found",
detail: sessionScopeKey,
message: "session context not found",
detail: sessionId,
});
return;
}
const context = runtimeContext ?? threadContext;
if (!context) {
res.status(404).json({
message: "runtime or session context not found",
detail: sessionScopeKey,
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
if (!command) {
res.status(400).json({ message: "command is required" });
return;
}
const timeoutSec =
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
const authJson = JSON.stringify({
server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken,
project_id: context.projectId,
network:"tjwater",
});
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
const child = spawn(config.TJWATER_CLI_PATH, cliArgs, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString("utf-8");
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf-8");
});
child.stdin.write(authJson);
child.stdin.end();
const exitCode = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve(-1);
}, timeoutSec * 1000);
child.on("close", (code) => {
clearTimeout(timer);
resolve(code);
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
if (exitCode === -1) {
res.status(504).json({
ok: false,
schema_version: "tjwater-cli/v1",
summary: "命令超时",
error: {
code: "TIMEOUT",
message: `command timed out after ${timeoutSec}s`,
retryable: true,
},
});
return;
}
if (exitCode !== 0) {
res.status(502).json({
ok: false,
exit_code: exitCode,
stderr: stderr.slice(0, 2000),
stdout: stdout.slice(0, 2000),
message: `CLI exited with code ${exitCode}`,
});
return;
}
try {
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
const result = await dynamicHttpExecutor.execute(
{
reason: req.body?.reason,
path: req.body?.path,
method: req.body?.method,
arguments: req.body?.arguments,
},
{
accessToken: runtimeContext?.accessToken,
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectId: context.projectId,
projectKey: context.projectKey,
sessionId: context.clientSessionId,
traceId: context.traceId,
},
);
res.json(result);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(400).json({
message: "dynamic http execution failed",
detail,
res.json(JSON.parse(stdout));
} catch {
res.json({
ok: true,
schema_version: "tjwater-cli/v1",
raw: stdout,
stderr: stderr || undefined,
});
}
});
app.post("/internal/tools/fetch-result-ref", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
const context = await toolContextStore.read(sessionScopeKey);
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionScopeKey,
});
return;
}
if (!resultRef) {
res.status(400).json({ message: "result_ref is required" });
return;
}
const result = await resultReferenceResolver.getAuthorized(
resultRef,
{
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectId: context.projectId,
},
{
maxItems:
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
},
);
if (!result) {
res.status(404).json({ message: "result_ref not found" });
return;
}
res.json(result);
});
app.post("/internal/tools/store-render-ref", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
const context = await toolContextStore.read(sessionScopeKey);
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionScopeKey,
detail: sessionId,
});
return;
}
@@ -186,7 +195,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
projectId: context.projectId,
projectKey: context.projectKey,
sessionId: context.clientSessionId,
source: "migration",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: context.traceId,
});
res.json({
@@ -213,14 +222,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = await toolContextStore.read(sessionScopeKey);
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionScopeKey,
detail: sessionId,
});
return;
}
@@ -228,7 +237,7 @@ app.post("/internal/tools/session-search", async (req, res) => {
res.status(400).json({ message: "query is required" });
return;
}
const hits = await sessionHistoryStore.search(
const hits = await sessionTranscriptStore.search(
{
actorKey: context.actorKey,
projectKey: context.projectKey,
@@ -244,13 +253,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
app.use(
"/api/v1/agent/chat",
requireAgentAuth,
buildChatRouter(
sessionBridge,
opencodeRuntime,
conversationStore,
conversationStateStore,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionHistoryStore,
sessionTranscriptStore,
learningOrchestrator,
resultReferenceResolver,
),
@@ -258,13 +268,12 @@ app.use(
const bootstrap = async () => {
await Promise.all([
conversationStore.initialize(),
conversationStateStore.initialize(),
sessionMetadataStore.initialize(),
sessionUiStateStore.initialize(),
learningOrchestrator.initialize(),
memoryStore.initialize(),
resultReferenceStore.initialize(),
sessionHistoryStore.initialize(),
toolContextStore.initialize(),
sessionTranscriptStore.initialize(),
]);
resultReferenceStore.startCleanupLoop();
};
-55
View File
@@ -1,55 +0,0 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
removeFileIfExists,
} from "../utils/fileStore.js";
import { toConversationScopeKey } from "../utils/fileStore.js";
export type ToolSessionContext = {
actorKey: string;
allowLearningWrite?: boolean;
clientSessionId: string;
learningMode?: "interactive" | "review";
projectId?: string;
projectKey: string;
sessionId: string;
sessionScopeKey: string;
traceId: string;
};
export class ToolSessionContextStore {
constructor(private readonly baseDir = config.SESSION_CONTEXT_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async write(context: ToolSessionContext) {
await atomicWriteJson(this.filePath(context.sessionId), context);
if (context.learningMode === "interactive" && context.sessionScopeKey) {
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
}
}
async read(sessionId: string) {
return await readJsonFile<ToolSessionContext>(this.filePath(sessionId));
}
async remove(sessionId: string) {
await removeFileIfExists(this.filePath(sessionId));
}
private filePath(sessionId: string) {
return join(this.baseDir, `${sessionId}.json`);
}
}
export const buildToolSessionScopeKey = (
actorKey: string,
projectKey: string,
clientSessionId: string,
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { config } from "../config.js";
@@ -8,14 +7,13 @@ import {
listJsonFiles,
readJsonFile,
removeFileIfExists,
slugify,
} from "../utils/fileStore.js";
import { toConversationScopeKey } from "../utils/fileStore.js";
export type ConversationStatus = "active" | "archived";
export type SessionStatus = "active" | "archived";
export type ConversationRecord = {
export type SessionRecord = {
sessionId: string;
sessionScopeKey: string;
actorKey: string;
ownerUserId?: string;
projectId?: string;
@@ -23,45 +21,44 @@ export type ConversationRecord = {
parentSessionId?: string;
createdAt: string;
updatedAt: string;
status: ConversationStatus;
status: SessionStatus;
title?: string;
};
type ConversationContext = {
type SessionMetadataContext = {
actorKey: string;
userId?: string;
projectId?: string;
projectKey: string;
};
type EnsureConversationInput = ConversationContext & {
sessionId?: string;
type EnsureSessionMetadataInput = SessionMetadataContext & {
sessionId: string;
parentSessionId?: string;
};
export class ConversationStore {
constructor(private readonly baseDir = config.CONVERSATION_STORAGE_DIR) {}
export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async ensure(input: EnsureConversationInput) {
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
const sessionScopeKey = toConversationScopeKey(
input.actorKey,
input.projectKey,
sessionId,
async ensure(input: EnsureSessionMetadataInput) {
const sessionId = normalizeSessionId(input.sessionId);
if (!sessionId) {
throw new Error("sessionId is required");
}
const existing = await readJsonFile<SessionRecord>(
this.filePath(sessionId),
);
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
if (existing) {
return { created: false, record: existing };
}
const now = new Date().toISOString();
const record: ConversationRecord = {
const record: SessionRecord = {
sessionId,
sessionScopeKey,
actorKey: input.actorKey,
ownerUserId: input.userId?.trim(),
projectId: input.projectId,
@@ -71,42 +68,46 @@ export class ConversationStore {
updatedAt: now,
status: "active",
};
await atomicWriteJson(this.filePath(sessionScopeKey), record);
await atomicWriteJson(
this.filePath(record.sessionId),
record,
);
return { created: true, record };
}
async get(context: ConversationContext, sessionId: string) {
async get(context: SessionMetadataContext, sessionId: string) {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) {
return null;
}
return await readJsonFile<ConversationRecord>(
this.filePath(
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
),
return await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId),
);
}
async touch(
record: ConversationRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
record: SessionRecord,
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
) {
const next: ConversationRecord = {
const next: SessionRecord = {
...record,
...normalizeConversationUpdates(updates),
...normalizeSessionUpdates(updates),
updatedAt: new Date().toISOString(),
};
await atomicWriteJson(this.filePath(record.sessionScopeKey), next);
await atomicWriteJson(
this.filePath(record.sessionId),
next,
);
return next;
}
async list(context: ConversationContext) {
async list(context: SessionMetadataContext) {
const files = await listJsonFiles(this.baseDir);
const records = await Promise.all(
files.map((file) => readJsonFile<ConversationRecord>(file)),
files.map((file) => readJsonFile<SessionRecord>(file)),
);
return records
.filter((record): record is ConversationRecord => Boolean(record))
.filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
@@ -115,26 +116,26 @@ export class ConversationStore {
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async remove(record: ConversationRecord) {
await removeFileIfExists(this.filePath(record.sessionScopeKey));
async remove(record: SessionRecord) {
await removeFileIfExists(
this.filePath(record.sessionId),
);
}
private filePath(sessionScopeKey: string) {
return join(this.baseDir, `${sessionScopeKey}.json`);
private filePath(sessionId: string) {
return join(this.baseDir, `${slugify(sessionId)}.json`);
}
}
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
const normalizeSessionId = (value?: string) => {
const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined;
};
const normalizeConversationUpdates = (
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
const normalizeSessionUpdates = (
updates: Partial<Pick<SessionRecord, "title" | "status">>,
) => {
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {};
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
@@ -36,24 +36,27 @@ export type SessionSearchHit = {
turnId: string;
};
type SessionHistoryContext = {
type SessionTranscriptContext = {
actorKey: string;
clientSessionId?: string;
projectKey: string;
sessionId: string;
};
export class SessionHistoryStore {
const DEFAULT_SEARCH_MAX_RESULTS = 8;
const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240;
export class SessionTranscriptStore {
private readonly writeQueues = new Map<string, Promise<void>>();
constructor(private readonly baseDir = config.SESSION_HISTORY_STORAGE_DIR) {}
constructor(private readonly baseDir = config.SESSION_TRANSCRIPT_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async appendTurn(
context: SessionHistoryContext,
context: SessionTranscriptContext,
turn: {
assistantMessage: string;
toolCallCount: number;
@@ -62,6 +65,7 @@ export class SessionHistoryStore {
) {
const key = this.filePath(context);
return this.serializeWrite(key, async () => {
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
const transcript = (await this.readTranscript(context)) ?? {
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
@@ -77,6 +81,20 @@ export class SessionHistoryStore {
}
const timestamp = new Date().toISOString();
const lastTurn = transcript.turns.at(-1);
if (
lastTurn?.userMessage === userMessage &&
lastTurn.assistantMessage === assistantMessage
) {
// 相同问答重复写入时只更新工具调用数量,避免刷新/重试造成 transcript 重复膨胀。
lastTurn.toolCallCount = Math.max(lastTurn.toolCallCount, turn.toolCallCount);
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.sessionId = context.sessionId;
transcript.updatedAt = timestamp;
await atomicWriteJson(key, transcript);
return transcript;
}
const record: SessionTurnRecord = {
id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage),
assistantMessage,
@@ -87,9 +105,9 @@ export class SessionHistoryStore {
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.sessionId = context.sessionId;
transcript.turns.push(record);
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
if (transcript.turns.length > config.SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION) {
transcript.turns = transcript.turns.slice(
transcript.turns.length - config.SESSION_HISTORY_MAX_TURNS_PER_SESSION,
transcript.turns.length - config.SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION,
);
}
transcript.updatedAt = timestamp;
@@ -99,7 +117,7 @@ export class SessionHistoryStore {
}
async getRecentTurns(
context: SessionHistoryContext,
context: SessionTranscriptContext,
limit: number,
): Promise<SessionTurnRecord[]> {
const transcript = await this.readTranscript(context);
@@ -110,12 +128,13 @@ export class SessionHistoryStore {
}
async cloneThread(
sourceContext: SessionHistoryContext,
targetContext: SessionHistoryContext,
sourceContext: SessionTranscriptContext,
targetContext: SessionTranscriptContext,
keepMessageCount: number,
) {
const sourceTranscript = await this.readTranscript(sourceContext);
const timestamp = new Date().toISOString();
// 分叉会话只复制用户选择保留的上下文,后续新分支拥有独立 transcript 文件。
const nextTranscript: SessionTranscriptRecord = {
actorKey: targetContext.actorKey,
clientSessionId: targetContext.clientSessionId,
@@ -129,11 +148,12 @@ export class SessionHistoryStore {
}
async search(
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
query: string,
maxResults = config.SESSION_SEARCH_MAX_RESULTS,
maxResults = DEFAULT_SEARCH_MAX_RESULTS,
): Promise<SessionSearchHit[]> {
const normalizedQuery = query.trim().toLowerCase().slice(0, config.SESSION_SEARCH_MAX_QUERY_CHARS);
// 当前搜索是轻量本地文本匹配,按 actor/project 过滤后再计算简单相关性分数。
const normalizedQuery = query.trim().toLowerCase().slice(0, DEFAULT_SEARCH_MAX_QUERY_CHARS);
if (!normalizedQuery) {
return [];
}
@@ -175,42 +195,11 @@ export class SessionHistoryStore {
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
}
private async readTranscript(context: SessionHistoryContext) {
const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
if (direct) {
return direct;
}
const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) {
return null;
}
const files = await listJsonFiles(this.baseDir);
const matches: SessionTranscriptRecord[] = [];
for (const file of files) {
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
if (!transcript) {
continue;
}
if (
transcript.actorKey !== context.actorKey ||
transcript.projectKey !== context.projectKey ||
transcript.clientSessionId !== clientSessionId
) {
continue;
}
matches.push(transcript);
}
if (matches.length === 0) {
return null;
}
return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
private async readTranscript(context: SessionTranscriptContext) {
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
}
private filePath(context: SessionHistoryContext) {
private filePath(context: SessionTranscriptContext) {
return join(
this.baseDir,
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
+46
View File
@@ -0,0 +1,46 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
removeFileIfExists,
slugify,
} from "../utils/fileStore.js";
export type SessionUiStateRecord = {
sessionId: string;
isTitleManuallyEdited?: boolean;
messages: unknown[];
branchGroups: unknown[];
};
type SessionUiStateContext = {
sessionId: string;
};
export class SessionUiStateStore {
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async read(context: SessionUiStateContext) {
return await readJsonFile<SessionUiStateRecord>(this.filePath(context));
}
async write(context: SessionUiStateContext, state: SessionUiStateRecord) {
await atomicWriteJson(this.filePath(context), state);
return state;
}
async remove(context: SessionUiStateContext) {
await removeFileIfExists(this.filePath(context));
}
private filePath(context: SessionUiStateContext) {
return join(this.baseDir, `${slugify(context.sessionId)}.json`);
}
}
+116 -23
View File
@@ -1,4 +1,5 @@
import { dirname, join, posix } from "node:path";
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "../config.js";
import {
@@ -17,8 +18,14 @@ import {
} from "../utils/persistencePolicy.js";
const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
const SKILLS_ROOT_DIR = ".opencode/skills";
const SKILLS_HISTORY_DIR = join(config.PERSISTENCE_HISTORY_DIR, "skills");
const ROOT_SKILL_ALIAS = "__root__";
const PROJECT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const resolveProjectPath = (path: string) =>
isAbsolute(path) ? path : resolve(PROJECT_ROOT_DIR, path);
const DEFAULT_SKILLS_ROOT_DIR = resolveProjectPath(config.OPENCODE_SKILLS_ROOT_DIR);
const DEFAULT_SKILLS_BACKUP_DIR = resolveProjectPath(
join(config.PERSISTENCE_BACKUP_DIR, "skills"),
);
export type SkillPatternRecord = {
id: string;
@@ -28,13 +35,19 @@ export type SkillPatternRecord = {
export class SkillStore {
private writeQueue: Promise<void> = Promise.resolve();
constructor(
private readonly rootDir = DEFAULT_SKILLS_ROOT_DIR,
private readonly backupDir = DEFAULT_SKILLS_BACKUP_DIR,
) {}
async list(skillPath: string) {
const normalizedSkillPath = normalizeSkillPath(skillPath);
if (!normalizedSkillPath) {
return null;
}
const target = this.skillFilePath(normalizedSkillPath);
const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath);
const current =
(await readTextFile(target)) ?? defaultSkillDocument(normalizedSkillPath);
return {
references: await this.listReferenceFiles(normalizedSkillPath),
scripts: await this.listScriptFiles(normalizedSkillPath),
@@ -55,7 +68,8 @@ export class SkillStore {
}
return this.serializeWrite(async () => {
const target = this.skillFilePath(normalizedSkillPath);
const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath);
const current =
(await readTextFile(target)) ?? defaultSkillDocument(normalizedSkillPath);
const existingPatterns = extractLearnedPatterns(current);
if (existingPatterns.some((entry) => entry.content === sanitizedPattern)) {
return { changed: false, detail: "pattern already existed", target };
@@ -70,15 +84,58 @@ export class SkillStore {
`${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}`,
)
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}\n`;
await ensureDirectory(join(SKILLS_ROOT_DIR, normalizedSkillPath));
await ensureDirectory(join(this.rootDir, normalizedSkillPath));
await atomicWriteFileWithHistory(target, next, {
historyDir: SKILLS_HISTORY_DIR,
rootDir: SKILLS_ROOT_DIR,
backupDir: this.backupDir,
rootDir: this.rootDir,
});
return { changed: true, detail: "skill file updated", target };
});
}
async writeSkill(skillPath: string, content: string) {
const normalizedSkillPath = normalizeSkillPath(skillPath);
if (!normalizedSkillPath) {
return { changed: false, detail: "invalid skill_path", target: "" };
}
const sanitizedContent = sanitizePersistentDocument(content, 12000);
if (!sanitizedContent) {
return { changed: false, detail: "skill content rejected by persistence policy", target: "" };
}
if (!hasValidSkillFrontmatter(sanitizedContent)) {
return {
changed: false,
detail: "skill content rejected: expected SKILL.md frontmatter with name and description",
target: "",
};
}
return this.serializeWrite(async () => {
const target = this.skillFilePath(normalizedSkillPath);
await ensureDirectory(this.skillDirPath(normalizedSkillPath));
await atomicWriteFileWithHistory(target, `${sanitizedContent}\n`, {
backupDir: this.backupDir,
rootDir: this.rootDir,
});
return { changed: true, detail: "skill written", target };
});
}
async removeSkill(skillPath: string) {
const normalizedSkillPath = normalizeSkillPath(skillPath);
if (!normalizedSkillPath) {
return { changed: false, detail: "invalid skill_path", target: "" };
}
return this.serializeWrite(async () => {
const target = this.skillFilePath(normalizedSkillPath);
const previous = await readTextFile(target);
if (previous === null) {
return { changed: false, detail: "skill file not found", target };
}
await removeFileIfExists(target);
return { changed: true, detail: "skill removed", target };
});
}
async removePattern(skillPath: string, targetId: string) {
const normalizedSkillPath = normalizeSkillPath(skillPath);
if (!normalizedSkillPath) {
@@ -97,8 +154,8 @@ export class SkillStore {
}
const next = rewriteLearnedPatterns(current, remaining);
await atomicWriteFileWithHistory(target, next, {
historyDir: SKILLS_HISTORY_DIR,
rootDir: SKILLS_ROOT_DIR,
backupDir: this.backupDir,
rootDir: this.rootDir,
});
return { changed: true, detail: "pattern removed", target };
});
@@ -118,11 +175,11 @@ export class SkillStore {
return { changed: false, detail: "reference content rejected by persistence policy", target: "" };
}
return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedReferencePath);
const target = join(this.rootDir, normalizedSkillPath, normalizedReferencePath);
await ensureDirectory(dirname(target));
await atomicWriteFileWithHistory(target, `${sanitizedContent}\n`, {
historyDir: SKILLS_HISTORY_DIR,
rootDir: SKILLS_ROOT_DIR,
backupDir: this.backupDir,
rootDir: this.rootDir,
});
return { changed: true, detail: "reference written", target };
});
@@ -138,7 +195,7 @@ export class SkillStore {
return { changed: false, detail: "invalid reference file_path", target: "" };
}
return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedReferencePath);
const target = join(this.rootDir, normalizedSkillPath, normalizedReferencePath);
const previous = await readTextFile(target);
if (previous === null) {
return { changed: false, detail: "reference not found", target };
@@ -162,11 +219,11 @@ export class SkillStore {
return { changed: false, detail: "script content rejected by persistence policy", target: "" };
}
return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath);
const target = join(this.rootDir, normalizedSkillPath, normalizedScriptPath);
await ensureDirectory(dirname(target));
await atomicWriteFileWithHistory(target, sanitizedContent, {
historyDir: SKILLS_HISTORY_DIR,
rootDir: SKILLS_ROOT_DIR,
backupDir: this.backupDir,
rootDir: this.rootDir,
});
return { changed: true, detail: "script written", target };
});
@@ -182,7 +239,7 @@ export class SkillStore {
return { changed: false, detail: "invalid script file_path", target: "" };
}
return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath);
const target = join(this.rootDir, normalizedSkillPath, normalizedScriptPath);
const previous = await readTextFile(target);
if (previous === null) {
return { changed: false, detail: "script not found", target };
@@ -193,19 +250,29 @@ export class SkillStore {
}
private async listReferenceFiles(skillPath: string) {
const referenceDir = join(SKILLS_ROOT_DIR, skillPath, "references");
const referenceDir = join(this.rootDir, skillPath, "references");
if (skillPath === ROOT_SKILL_ALIAS) {
return [];
}
const files = await listFiles(referenceDir);
return files.map((file) => file.slice(referenceDir.length + 1));
}
private async listScriptFiles(skillPath: string) {
const scriptDir = join(SKILLS_ROOT_DIR, skillPath, "scripts");
if (skillPath === ROOT_SKILL_ALIAS) {
return [];
}
const scriptDir = join(this.rootDir, skillPath, "scripts");
const files = await listFiles(scriptDir);
return files.map((file) => file.slice(scriptDir.length + 1));
}
private skillFilePath(skillPath: string) {
return join(SKILLS_ROOT_DIR, skillPath, "SKILL.md");
return join(this.skillDirPath(skillPath), "SKILL.md");
}
private skillDirPath(skillPath: string) {
return skillPath === ROOT_SKILL_ALIAS ? this.rootDir : join(this.rootDir, skillPath);
}
private async serializeWrite<T>(task: () => Promise<T>) {
@@ -220,6 +287,9 @@ export class SkillStore {
export const normalizeSkillPath = (rawSkillPath: string) => {
const normalized = posix.normalize(rawSkillPath.trim().replace(/^\/+|\/+$/g, ""));
if (normalized === ROOT_SKILL_ALIAS) {
return ROOT_SKILL_ALIAS;
}
if (!normalized || normalized === "." || normalized.startsWith("..")) {
return null;
}
@@ -330,6 +400,26 @@ const extractLearnedPatternsSection = (content: string) => {
return tail.slice(0, nextHeadingMatch?.index ?? tail.length);
};
const hasValidSkillFrontmatter = (content: string) => {
const match = content.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
if (!match) {
return false;
}
const lines = match[1].split("\n").map((line) => line.trim());
return (
lines.some((line) => /^name:\s*\S+/i.test(line)) &&
lines.some((line) => /^description:\s*\S+/i.test(line))
);
};
const defaultRootSkill = () => `---
name: skills
description: TJWater Skills root index.
---
# TJWater Skills
`;
const defaultLearnedSkill = (skillPath: string) => `---
name: tjwater-action-${skillPath
.split("/")
@@ -338,7 +428,7 @@ name: tjwater-action-${skillPath
.replace(/[^a-z0-9._-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || "generated-skill"}
description: skill_manager 线 workflow
description: skill_manager 线 workflow
version: 1.0.0
---
@@ -346,7 +436,10 @@ version: 1.0.0
##
\`skill_manager\` 在线追加的高置信度 workflow 模式。
\`skill_manager\` 在线维护的高置信度 workflow 模式。
## Learned Patterns
`;
const defaultSkillDocument = (skillPath: string) =>
skillPath === ROOT_SKILL_ALIAS ? defaultRootSkill() : defaultLearnedSkill(skillPath);
-167
View File
@@ -1,167 +0,0 @@
import { config } from "../config.js";
import { logger } from "../logger.js";
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
import { ResultReferenceStore } from "../results/store.js";
export type DynamicHttpInput = {
reason?: string;
path: string;
method?: string;
arguments?: Record<string, unknown>;
};
export type SessionToolContext = {
accessToken?: string;
actorKey: string;
clientSessionId: string;
projectKey: string;
sessionId: string;
projectId?: string;
traceId: string;
};
const allowedMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
export class DynamicHttpExecutor {
constructor(private readonly resultStore: ResultReferenceStore) {}
async execute(input: DynamicHttpInput, context: SessionToolContext) {
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,
reason: typeof input.reason === "string" ? input.reason : undefined,
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,
},
...(await normalizeSuccessResult(data, context, this.resultStore)),
};
}
}
const buildQuery = (argumentsObject: Record<string, unknown>) => {
const pairs: Array<[string, string]> = [];
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 = async (
data: unknown,
context: SessionToolContext,
resultStore: ResultReferenceStore,
) => {
const sizeBytes = estimateBytes(data);
if (sizeBytes <= config.MAX_INLINE_RESULT_BYTES) {
return {
result_mode: "inline",
result_size_bytes: sizeBytes,
data,
};
}
// 大结果转成持久化引用,支持 review 和跨重启回读。
const record = await resultStore.store({
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
data,
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
projectId: context.projectId,
projectKey: context.projectKey,
schemaVersion: 1,
sessionId: context.sessionId,
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
traceId: context.traceId,
});
return {
result_mode: "referenced",
result_size_bytes: sizeBytes,
result_ref: record.resultRef,
preview: record.preview,
};
};
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
+14 -15
View File
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
@@ -13,14 +13,19 @@ export const ensureDirectory = async (path: string) => {
export const atomicWriteFile = async (path: string, content: string) => {
await ensureDirectory(dirname(path));
const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
await writeFile(tempPath, content, "utf8");
await rename(tempPath, path);
const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.${randomUUID()}.tmp`;
try {
await writeFile(tempPath, content, "utf8");
await rename(tempPath, path);
} catch (error) {
await removeFileIfExists(tempPath);
throw error;
}
};
type HistoricalWriteOptions = {
afterWrite?: () => Promise<void> | void;
historyDir: string;
backupDir: string;
rootDir: string;
};
@@ -36,8 +41,8 @@ export const atomicWriteFileWithHistory = async (
let backupPath: string | null = null;
if (previous !== null) {
// 仅在覆盖已有文件时保留历史版本,避免为首次创建产生空备份。
backupPath = buildHistoryBackupPath(path, options);
// 仅在覆盖已有文件时保留备份版本,避免为首次创建产生空备份。
backupPath = buildBackupPath(path, options);
await atomicWriteFile(backupPath, previous);
}
@@ -149,12 +154,6 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
export const toStableId = (...parts: string[]) =>
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
export const toConversationScopeKey = (
actorKey: string,
projectKey: string,
sessionId: string,
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
export const slugify = (value: string) =>
value
.toLowerCase()
@@ -162,11 +161,11 @@ export const slugify = (value: string) =>
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "entry";
const buildHistoryBackupPath = (path: string, options: HistoricalWriteOptions) => {
const buildBackupPath = (path: string, options: HistoricalWriteOptions) => {
const relativePath = relative(options.rootDir, path);
const scopedPath =
relativePath && !relativePath.startsWith("..") ? relativePath : basename(path);
// 备份目录尽量复用原始相对路径,便于按业务目录回看历史。
const backupName = `${basename(path)}.${Date.now().toString(36)}.bak`;
return join(options.historyDir, dirname(scopedPath), backupName);
return join(options.backupDir, dirname(scopedPath), backupName);
};
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MemoryStore } from "../../src/memory/store.js";
describe("MemoryStore", () => {
let tempDir: string;
let backupDir: string;
let store: MemoryStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-memory-"));
backupDir = await mkdtemp(join(tmpdir(), "tjwater-memory-backup-"));
store = new MemoryStore(tempDir, backupDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
await rm(backupDir, { force: true, recursive: true });
});
it("dedupes exact duplicate memories", async () => {
const first = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
source: "tool",
});
const second = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
source: "tool",
});
expect(first.changed).toBe(true);
expect(second.changed).toBe(false);
expect(second.detail).toBe("memory already existed");
});
it("allows rewritten memories when the content is not exactly the same", async () => {
await store.upsert("workspace", "project-1", {
content: "保存记忆前先查看当前 workspace memory,避免重复写入相同约束。",
source: "tool",
});
const result = await store.upsert("workspace", "project-1", {
content: "写入前先看一遍当前 workspace 记忆,避免把同样的约束重复保存进去。",
source: "tool",
});
expect(result.changed).toBe(true);
expect(result.detail).toBe("memory stored");
expect(result.entry?.content).toBe("写入前先看一遍当前 workspace 记忆,避免把同样的约束重复保存进去。");
});
it("rejects replace when the new content would become an exact duplicate", async () => {
const first = await store.upsert("user", "actor-1", {
content: "回答时默认使用中文,并保持结论先行。",
source: "tool",
});
const second = await store.upsert("user", "actor-1", {
content: "回答要包含必要的文件路径引用。",
source: "tool",
});
const result = await store.replace("user", "actor-1", second.entry?.id ?? "", {
content: "回答时默认使用中文,并保持结论先行。",
source: "tool",
});
expect(first.changed).toBe(true);
expect(second.changed).toBe(true);
expect(result.changed).toBe(false);
expect(result.detail).toBe("replacement would duplicate an existing memory");
});
});
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";
describe("skill_manager tool", () => {
let tempDir: string;
let skillStore: SkillStore;
let context: RuntimeSessionContext;
const toolContext = {
abort: new AbortController().signal,
agent: "test",
ask: (() => undefined) as never,
directory: "",
messageID: "message-1",
metadata: () => undefined,
sessionID: "session-1",
worktree: "",
};
const skillDocument = (body: string) =>
[
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"---",
"",
body,
].join("\n");
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
skillStore = new SkillStore(
join(tempDir, "skills"),
join(tempDir, "backup", "skills"),
);
context = {
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "client-session-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
};
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("dispatches skill-level write, overwrite, and remove actions", async () => {
const tool = createSkillManagerTool(
skillStore,
{ read: () => context },
Promise.resolve(),
);
const writeResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Pressure Review"),
reason: "verified reusable workflow",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
);
const updateResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Updated Pressure Review"),
reason: "verified reusable workflow overwrite",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(updateResult.decision).toBe("accepted");
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
);
const removeResult = JSON.parse(
await tool.execute(
{
action: "remove_skill",
reason: "workflow is obsolete",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(removeResult.decision).toBe("accepted");
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
});
it("writes the root skills index through the reserved alias", async () => {
const tool = createSkillManagerTool(
skillStore,
{ read: () => context },
Promise.resolve(),
);
const writeResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: [
"---",
"name: skills",
"description: TJWater Skills root index.",
"---",
"",
"# TJWater Skills",
].join("\n"),
reason: "refresh root skills index",
skill_path: "__root__",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# TJWater Skills\n",
);
});
});
+23 -242
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -26,83 +26,50 @@ describe("ResultReferenceResolver", () => {
await rm(tempDir, { force: true, recursive: true });
});
it("stores metadata for new referenced results and resolves them", async () => {
it("stores metadata for render refs and resolves them", async () => {
const record = await resolver.register({
actorKey: "actor-1",
clientSessionId: "client-1",
data: [{ id: "J1" }, { id: "J2" }],
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
data: {
node_area_map: {
J1: "DMA-1",
J2: "DMA-2",
},
},
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
projectId: "project-1",
projectKey: "project-key-1",
schemaVersion: 1,
sessionId: "session-1",
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-1",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(record.schemaVersion).toBe(1);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
const result = await resolver.getAuthorized(
const result = await resolver.getFullAuthorized(
record.resultRef,
{
actorKey: "actor-1",
projectId: "project-1",
},
{
maxItems: 1,
},
);
expect(result).not.toBeNull();
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(result?.schema_version).toBe(1);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
expect(result?.data).toEqual([{ id: "J1" }]);
});
it("keeps legacy result refs readable while defaulting metadata", async () => {
const legacyRef = "res-aaaaaaaaaaaaaaaa";
await writeFile(
join(tempDir, `${legacyRef}.json`),
JSON.stringify(
{
resultRef: legacyRef,
actorKey: "actor-legacy",
clientSessionId: "client-legacy",
createdAt: "2026-05-21T00:00:00.000Z",
data: { nodes: ["J1"] },
preview: {
count: 1,
fields: ["nodes"],
sample: { nodes: ["J1"] },
summary: "object<1 fields>",
},
projectId: "project-legacy",
projectKey: "project-key-legacy",
sessionId: "session-legacy",
sizeBytes: 16,
traceId: "trace-legacy",
},
null,
2,
),
"utf8",
);
const record = await store.getAuthorizedRecord(legacyRef, {
actorKey: "actor-legacy",
projectId: "project-legacy",
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "DMA-2",
},
});
expect(record).not.toBeNull();
expect(record?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
expect(record?.schemaVersion).toBe(1);
expect(record?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
});
it("rejects malformed refs, mismatched kinds, and auth mismatches", async () => {
it("rejects malformed refs and auth mismatches", async () => {
const malformedRef = "res-bbbbbbbbbbbbbbbb";
await writeFile(
join(tempDir, `${malformedRef}.json`),
@@ -152,18 +119,6 @@ describe("ResultReferenceResolver", () => {
traceId: "trace-2",
});
const wrongKind = await resolver.getFullAuthorized(
renderRecord.resultRef,
{
actorKey: "actor-2",
projectId: "project-2",
},
{
expectedKind: RESULT_REFERENCE_KIND.dynamicHttpResult,
},
);
expect(wrongKind).toBeNull();
const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
actorKey: "actor-other",
projectId: "project-2",
@@ -209,12 +164,12 @@ describe("ResultReferenceResolver", () => {
projectId: "project-3",
projectKey: "project-key-3",
sessionId: "session-3",
source: RESULT_REFERENCE_SOURCE.migration,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-3",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.migration);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
const result = await resolver.getFullAuthorized(
record.resultRef,
@@ -240,178 +195,4 @@ describe("ResultReferenceResolver", () => {
});
});
it("repairs wrapper files that omit metadata and location", async () => {
const filePath = join(tempDir, "render-wrapper-missing-fields.json");
await writeFile(
filePath,
JSON.stringify(
{
data: {
node_area_map: {
J1: "DMA-1",
},
},
createdAt: "2026-05-21T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
const record = await resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-4",
clientSessionId: "client-4",
projectId: "project-4",
projectKey: "project-key-4",
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.migration,
traceId: "trace-4",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
metadata?: Record<string, unknown>;
location?: Record<string, unknown>;
};
expect(repaired.metadata).toEqual({
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-4",
});
expect(repaired.location).toEqual({
file_path: filePath,
});
});
it("repairs wrapper files whose location points elsewhere", async () => {
const filePath = join(tempDir, "render-wrapper-wrong-location.json");
await writeFile(
filePath,
JSON.stringify(
{
metadata: {
createdAt: "2026-05-21T00:00:00.000Z",
},
location: {
file_path: "/tmp/elsewhere.json",
source: "legacy",
},
data: {
node_area_map: {
J1: "DMA-1",
},
},
},
null,
2,
),
"utf8",
);
await resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-4",
clientSessionId: "client-4",
projectId: "project-4",
projectKey: "project-key-4",
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.migration,
traceId: "trace-4",
});
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
metadata?: Record<string, unknown>;
location?: Record<string, unknown>;
};
expect(repaired.metadata).toEqual({
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-4",
});
expect(repaired.location).toEqual({
file_path: filePath,
source: "legacy",
});
});
it("resolves legacy render payload files when callers include the json suffix", async () => {
const legacyRef = "res-c2fcee33-577e";
await writeFile(
join(tempDir, `${legacyRef}.json`),
JSON.stringify(
{
data: {
node_area_map: {
J1: "DMA-1",
J2: 2,
},
area_ids: ["DMA-1"],
},
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-legacy-render",
},
null,
2,
),
"utf8",
);
const result = await resolver.getFullAuthorized(
`${legacyRef}.json`,
{
actorKey: "actor-legacy-render",
clientSessionId: "chat-legacy-render",
projectId: "project-legacy-render",
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
expect(result?.result_ref).toBe(legacyRef);
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "2",
},
area_ids: ["DMA-1"],
});
});
it("keeps legacy render payload files scoped to their project", async () => {
const legacyRef = "res-dddddddddddddddd";
await writeFile(
join(tempDir, `${legacyRef}.json`),
JSON.stringify(
{
data: {
node_area_map: {
J1: "DMA-1",
},
},
projectId: "project-allowed",
},
null,
2,
),
"utf8",
);
const result = await resolver.getFullAuthorized(
legacyRef,
{
actorKey: "actor-legacy-render",
clientSessionId: "chat-legacy-render",
projectId: "project-denied",
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
expect(result).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More