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 .git
.gitignore
node_modules node_modules
.opencode/node_modules .opencode/node_modules
.local.env .local.env
+5
View File
@@ -18,6 +18,11 @@ jobs:
shell: bash shell: bash
steps: steps:
- name: Setup tools
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq jq
jq --version
- name: Checkout code - name: Checkout code
env: env:
SERVER_URL: ${{ github.server_url }} SERVER_URL: ${{ github.server_url }}
+5
View File
@@ -1,7 +1,12 @@
node_modules/ node_modules/
__pycache__/
.opencode/node_modules/ .opencode/node_modules/
.opencode/skills/
.local.env .local.env
.vscode .vscode
docker-compose.yml docker-compose.yml
data/ data/
logs/ logs/
cli/
AGENT_HARNESS_REPORT.md
HARNESS_INTRODUCTION.md
+88 -26
View File
@@ -4,31 +4,93 @@ mode: primary
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-pro
temperature: 0.2 temperature: 0.2
--- ---
您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题 你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确
按照以下规则操作: ## 工作流生命周期
1. 使用 `.opencode/skills/tjwater-skills-root-index` 作为 TJWater 技能树,仅在任务需要该领域知识时加载特定技能。对分析类问题,优先检查 `workflow` 域下是否已有固定工作流(例如 `bottleneck-analysis`);只有在 workflow 不存在、信息不足或需要补充原子能力时,才继续查询其他 API / action skills。 Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
2. 当您需要后端数据用于推理、总结、诊断或分析时,优先使用 `dynamic_http_call` ```
3. 当用户主要需要 UI 操作或可视化时,优先使用前端工具(`locate_features``view_history``view_scada``show_chart`)。 初次遇到问题 → tjwater_cli + Python 脚本拼装 → 验证有效 →
4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。 → 立即调用 skill_manager 保存到 skills/workflow/<name>/
5. 保持回复准确、简洁,对供水网络用户在操作上有用。 → 下次遇到同类问题直接加载该 skill,按既定步骤执行
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 与其调用链中的其他代理都必须遵守同样限制。 1. **查已有工作流** — 检查 `skills/workflow/` 下是否存在匹配的 SKILL.md,有则加载并按步骤执行
13. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具: 2. **历史参考** — 用 `session_search` 检索历史相似案例,避免重复试错
- `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实 3. **从零拼装** — 无匹配工作流时,自行组合 `tjwater_cli` 命令 + Python 脚本完成
- `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置 4. **完成后复盘** — 判断当前流程是否稳定、可复用,决定是否沉淀为 workflow
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。 | 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
20. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。 | 发现可用命令 | `tjwater_cli(command="help")` |
21. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns``references/``scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。 | UI 操作 / 可视化 | `locate_features``view_scada``show_chart``render_junctions``view_history``apply_layer_style` |
22. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。 | 持久化渲染数据 | `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: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe("Why this map positioning action is needed for the user request."), .describe(
ids: tool.schema.array(tool.schema.string()).describe("Feature ids to locate."), "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 feature_type: tool.schema
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"]) .enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
.describe("Type of feature to locate."), .describe("Type of feature to locate."),
+70 -45
View File
@@ -1,17 +1,16 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js"; 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 memoryStore = new MemoryStore();
const toolContextStore = new ToolSessionContextStore(); const initializePromise = memoryStore.initialize();
const initializePromise = Promise.all([
memoryStore.initialize(),
toolContextStore.initialize(),
]);
export default tool({ export default tool({
description: 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: { args: {
action: tool.schema action: tool.schema
.enum(["add", "list", "replace", "remove"]) .enum(["add", "list", "replace", "remove"])
@@ -37,7 +36,7 @@ export default tool({
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID); const sessionContext = getRuntimeSessionContext(context.sessionID);
if (!sessionContext) { if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`); throw new Error(`session context not found for ${context.sessionID}`);
} }
@@ -57,68 +56,94 @@ export default tool({
} }
if (sessionContext.allowLearningWrite === false && args.action !== "list") { if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
decision: "rejected", decision: "rejected",
detail: "memory writes are disabled for this session", detail: "memory writes are disabled for this session",
}); });
} }
const scopeKey = const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey; scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (args.action === "list") { if (args.action === "list") {
const readScopes = {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
};
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: readScopes,
});
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
decision: "accepted", decision: "accepted",
detail: "memory listed", detail: "memory listed",
items: await memoryStore.list(scope, scopeKey), items: await memoryStore.list(scope, scopeKey),
target: scope, target: scope,
}); });
} }
if (args.action === "add") { 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, { const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "", content: args.content ?? "",
sessionId: sessionContext.clientSessionId, sessionId: sessionContext.clientSessionId,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}); });
if (!result.entry) { if (!result.entry) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
decision: "rejected", decision: result.changed ? "accepted" : "deduped",
detail: "content rejected by persistence policy", detail: result.detail,
}); entry: result.entry,
} target: scope,
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,
}); });
} }
if (args.action === "replace") { if (args.action === "replace") {
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", { const result = await memoryStore.replace(
content: args.content ?? "", scope,
sessionId: sessionContext.clientSessionId, scopeKey,
source: "tool", args.target_id ?? "",
traceId: sessionContext.traceId, {
}); content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
},
);
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
decision: result.changed ? "accepted" : "rejected", decision: result.changed ? "accepted" : "rejected",
detail: result.detail, detail: result.detail,
target: scope, 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({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
+4 -2
View File
@@ -6,11 +6,13 @@ export default tool({
args: { args: {
reason: tool.schema reason: tool.schema
.string() .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 render_ref: tool.schema
.string() .string()
.describe( .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() { async execute() {
+14 -19
View File
@@ -1,11 +1,8 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -25,23 +22,21 @@ export default tool({
.describe("Optional maximum number of hits to return."), .describe("Optional maximum number of hits to return."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; const response = await fetch(
const sessionContext = await toolContextStore.read(context.sessionID); `${internalBaseUrl}/internal/tools/session-search`,
if (!sessionContext) { {
throw new Error(`session context not found for ${context.sessionID}`); method: "POST",
} headers: {
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, { "Content-Type": "application/json",
method: "POST", "x-agent-internal-token": internalToken,
headers: { },
"Content-Type": "application/json", body: JSON.stringify({
"x-agent-internal-token": internalToken, 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(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
throw new Error(text); throw new Error(text);
+8 -2
View File
@@ -21,8 +21,14 @@ export default tool({
}), }),
) )
.describe("Series data."), .describe("Series data."),
x_axis_name: tool.schema.string().optional().describe("X-axis display name."), x_axis_name: tool.schema
y_axis_name: tool.schema.string().optional().describe("Y-axis display name."), .string()
.optional()
.describe("X-axis display name."),
y_axis_name: tool.schema
.string()
.optional()
.describe("Y-axis display name."),
}, },
async execute() { async execute() {
// 图表数据已经在工具参数里,前端收到 tool_call 后直接渲染,不再二次请求后端。 // 图表数据已经在工具参数里,前端收到 tool_call 后直接渲染,不再二次请求后端。
+138 -99
View File
@@ -1,115 +1,154 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js"; 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(); type ToolContextReader = {
const initializePromise = toolContextStore.initialize(); read(sessionId: string): RuntimeSessionContext | null;
const skillStore = new SkillStore(); };
export default tool({ const runtimeContextReader: ToolContextReader = {
description: read: getRuntimeSessionContext,
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。", };
args: {
action: tool.schema export const createSkillManagerTool = (
.enum([ skillStore = new SkillStore(),
"list", toolContextStore: ToolContextReader = runtimeContextReader,
"append_pattern", initializePromise: Promise<unknown> = Promise.resolve(),
"remove_pattern", ) =>
"write_reference", tool({
"remove_reference", description:
"write_script", "维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
"remove_script", args: {
]) action: tool.schema
.describe("Skill maintenance operation."), .enum([
reason: tool.schema "list",
.string() "write_skill",
.describe( "remove_skill",
"Why this skill maintenance action is justified for future reuse.", "append_pattern",
), "remove_pattern",
skill_path: tool.schema "write_reference",
.string() "remove_reference",
.describe( "write_script",
"Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta.", "remove_script",
), ])
pattern: tool.schema .describe("Skill maintenance operation."),
.string() reason: tool.schema
.optional() .string()
.describe("Pattern text used by append_pattern."), .describe(
target_id: tool.schema "Why this skill maintenance action is justified for future reuse.",
.string() ),
.optional() skill_path: tool.schema
.describe("Stable learned pattern id used by remove_pattern."), .string()
file_path: tool.schema .describe(
.string() "Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
.optional() ),
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."), pattern: tool.schema
content: tool.schema .string()
.string() .optional()
.optional() .describe("Pattern text used by append_pattern."),
.describe("Asset content used by write_reference or write_script."), target_id: tool.schema
}, .string()
async execute(args, context) { .optional()
await initializePromise; .describe("Stable learned pattern id used by remove_pattern."),
const sessionContext = await toolContextStore.read(context.sessionID); file_path: tool.schema
if (!sessionContext) { .string()
throw new Error(`session context not found for ${context.sessionID}`); .optional()
} .describe(
if (sessionContext.allowLearningWrite === false && args.action !== "list") { "Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
return JSON.stringify({ ),
ok: true, content: tool.schema
kind: "skill", .string()
decision: "rejected", .optional()
detail: "skill writes are disabled for this session", .describe(
}); "Content used by write_skill, write_reference, or write_script.",
} ),
if (args.action === "list") { },
const result = await skillStore.list(args.skill_path); async execute(args, context) {
if (!result) { 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({ return JSON.stringify({
ok: true, ok: true,
kind: "skill", kind: "skill",
decision: "rejected", decision: "rejected",
detail: detail: "skill writes are disabled for this session",
"invalid skill_path; expected a relative path under .opencode/skills",
}); });
} }
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({ return JSON.stringify({
ok: true, ok: true,
kind: "skill", kind: "skill",
decision: "accepted", decision: result.changed ? "accepted" : "rejected",
detail: "skill listed", detail: result.detail,
...result, target: result.target,
}); });
} },
});
const result = export default createSkillManagerTool();
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,
});
},
});
+18 -20
View File
@@ -1,10 +1,8 @@
import { tool } from "@opencode-ai/plugin"; 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 internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -12,7 +10,9 @@ export default tool({
args: { args: {
reason: tool.schema reason: tool.schema
.string() .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 file_path: tool.schema
.string() .string()
.describe( .describe(
@@ -20,22 +20,20 @@ export default tool({
), ),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; const response = await fetch(
const sessionContext = await toolContextStore.read(context.sessionID); `${internalBaseUrl}/internal/tools/store-render-ref`,
if (!sessionContext) { {
throw new Error(`session context not found for ${context.sessionID}`); method: "POST",
} headers: {
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, { "Content-Type": "application/json",
method: "POST", "x-agent-internal-token": internalToken,
headers: { },
"Content-Type": "application/json", body: JSON.stringify({
"x-agent-internal-token": internalToken, 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(); const text = await response.text();
if (!response.ok) { 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: { args: {
reason: tool.schema reason: tool.schema
.string() .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 feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()])) .array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."), .describe("List of [id, type] pairs."),
data_type: tool.schema data_type: tool.schema
.enum(["realtime", "scheme", "none"]) .enum(["realtime", "scheme", "none"])
.describe("History data source type."), .describe("History data source type."),
start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."), start_time: tool.schema
end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."), .string()
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
}, },
async execute() { async execute() {
// 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。 // 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。
+11 -6
View File
@@ -10,13 +10,18 @@ export default tool({
.array(tool.schema.string()) .array(tool.schema.string())
.optional() .optional()
.describe("Preferred SCADA device ids."), .describe("Preferred SCADA device ids."),
device_id: tool.schema.string().optional().describe("Single SCADA device id."), device_id: tool.schema
feature_infos: tool.schema .string()
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.optional() .optional()
.describe("Legacy [id, type] pairs."), .describe("Single SCADA device id."),
start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."), start_time: tool.schema
end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."), .string()
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
}, },
async execute() { async execute() {
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。 // 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 && \ 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 \ apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
jq \
unzip \ unzip \
python3 \ python3 \
python3-venv && \ python3-venv && \
@@ -51,7 +52,9 @@ RUN bun install --frozen-lockfile
FROM base AS build FROM base AS build
WORKDIR /app 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 src ./src
COPY .opencode ./.opencode COPY .opencode ./.opencode
RUN bun run check RUN bun run check
@@ -66,7 +69,7 @@ ENV PORT=8787
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./ COPY package.json bun.lock ./
COPY tsconfig.json opencode.json ./ COPY tsconfig.json opencode.json .gitignore ./
COPY src ./src COPY src ./src
COPY .opencode ./.opencode COPY .opencode ./.opencode
@@ -74,7 +77,6 @@ COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
COPY .opencode ./.opencode
EXPOSE 8787 EXPOSE 8787
CMD ["bun", "src/server.ts"] CMD ["bun", "src/server.ts"]
+23 -12
View File
@@ -32,7 +32,7 @@ TJWaterAgent/
3. 管理前端 `session_id -> opencode sessionId` 的映射。 3. 管理前端 `session_id -> opencode sessionId` 的映射。
4. 保存并传递用户 `Authorization``x-user-id``x-project-id``x-trace-id` 4. 保存并传递用户 `Authorization``x-user-id``x-project-id``x-trace-id`
5. 把 opencode 输出适配成前端需要的 SSE 事件。 5. 把 opencode 输出适配成前端需要的 SSE 事件。
6.`.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。 6.`.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
7. 代理调用真实 TJWater 后端 API。 7. 代理调用真实 TJWater 后端 API。
当前 Agent API 的主入口: 当前 Agent API 的主入口:
@@ -84,34 +84,45 @@ src/
```text ```text
.opencode/tools/ .opencode/tools/
dynamic_http_call.ts tjwater_cli.ts
store_render_ref.ts
locate_features.ts locate_features.ts
view_history.ts view_history.ts
view_scada.ts view_scada.ts
show_chart.ts show_chart.ts
render_junctions.ts
apply_layer_style.ts
memory_manager.ts
session_search.ts
skill_manager.ts
``` ```
这些是 opencode 可以调用的自定义工具。 这些是 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 ### skills
```text ```text
.opencode/skills/tjwater-skills-root-index/ .opencode/skills/
SKILL.md SKILL.md
ai/ examples.md
analytics/ runbook.md
business/ tjwater-cli/ ← tjwater-cli 可执行文件
data/ workflow/ ← 可复用分析工作流
platform/ 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 { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { import {
buildToolSessionScopeKey, removeRuntimeSessionContext,
ToolSessionContextStore, setRuntimeSessionContext,
} from "../session/toolContextStore.js"; } from "../runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = { export type SessionBinding = {
@@ -28,15 +28,12 @@ export type ChatRequestContext = SessionContext & {
}; };
export class ChatSessionBridge { export class ChatSessionBridge {
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。 private readonly abortControllers = new Map<string, AbortController>();
private readonly activeRuntimeSessions = new Map<string, string>();
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
private readonly toolContextStore = new ToolSessionContextStore();
constructor(private readonly runtime: OpencodeRuntimeAdapter) {} constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
async resolve(context: { async resolve(context: {
clientSessionId?: string; sessionId?: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
@@ -46,63 +43,68 @@ export class ChatSessionBridge {
requestContext: ChatRequestContext; requestContext: ChatRequestContext;
created: boolean; created: boolean;
}> { }> {
const requestContext = this.buildRequestContext(context); let requestContext = this.buildRequestContext(context);
await this.abortActiveRuntime(requestContext.clientSessionId); 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 = { const binding: SessionBinding = {
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
sessionId: session.id, sessionId,
startedAt: Date.now(), startedAt: Date.now(),
}; };
const sessionScopeKey = buildToolSessionScopeKey( setRuntimeSessionContext({
requestContext.actorKey, accessToken: requestContext.accessToken,
requestContext.projectKey,
requestContext.clientSessionId,
);
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
await this.toolContextStore.write({
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
allowLearningWrite: true, allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
learningMode: "interactive", learningMode: "interactive",
projectId: requestContext.projectId, projectId: requestContext.projectId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId: session.id, sessionId,
sessionScopeKey,
traceId: requestContext.traceId, traceId: requestContext.traceId,
}); });
return { binding, requestContext, created: true }; return { binding, requestContext, created };
} }
count(): number { count(): number {
return this.activeRuntimeSessions.size; return this.abortControllers.size;
} }
createClientSessionId() { createClientSessionId() {
return `agent-${randomUUID().slice(0, 12)}`; return `agent-${randomUUID().slice(0, 12)}`;
} }
getActiveSensitiveContext(sessionScopeKey: string) { registerAbortController(clientSessionId: string, controller: AbortController) {
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null; this.abortControllers.set(clientSessionId, controller);
}
finalizeRequest(clientSessionId: string) {
this.abortControllers.delete(clientSessionId);
} }
async abort(context: { async abort(context: {
clientSessionId?: string; clientSessionId?: string;
sessionId?: string;
}): Promise<SessionBinding | null> { }): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim(); const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) { const sessionId = context.sessionId?.trim();
if (!clientSessionId || !sessionId) {
return null; return null;
} }
const sessionId = this.activeRuntimeSessions.get(clientSessionId); await this.abortActiveRuntime(clientSessionId, sessionId);
if (!sessionId) {
return null;
}
await this.abortActiveRuntime(clientSessionId);
return { return {
clientSessionId, clientSessionId,
sessionId, sessionId,
@@ -110,29 +112,42 @@ export class ChatSessionBridge {
}; };
} }
async releaseRuntimeSession(clientSessionId: string, sessionId: string) { async deleteSession(context: {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId); clientSessionId: string;
if (activeSessionId === sessionId) { sessionId: string;
this.activeRuntimeSessions.delete(clientSessionId); }) {
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) => { 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: { private buildRequestContext(context: {
clientSessionId?: string; sessionId?: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
userId?: string; userId?: string;
}): ChatRequestContext { }): ChatRequestContext {
const sessionId = context.sessionId?.trim();
return { return {
clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(), clientSessionId: sessionId || this.createClientSessionId(),
accessToken: context.accessToken, accessToken: context.accessToken,
actorKey: toActorKey(context.userId), actorKey: toActorKey(context.userId),
projectId: context.projectId, projectId: context.projectId,
@@ -142,37 +157,27 @@ export class ChatSessionBridge {
}; };
} }
private async abortActiveRuntime(clientSessionId: string) { private async abortActiveRuntime(clientSessionId: string, sessionId?: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId); const controller = this.abortControllers.get(clientSessionId);
if (!activeSessionId) { if (controller) {
this.abortControllers.delete(clientSessionId);
controller.abort();
}
if (!sessionId) {
return; return;
} }
this.activeRuntimeSessions.delete(clientSessionId); await this.runtime.abortSession(sessionId).catch((error) => {
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
await this.runtime.abortSession(activeSessionId).catch((error) => {
logger.warn( logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error }, { clientSessionId, sessionId, err: error },
"failed to abort previous active runtime session", "failed to abort active runtime session",
); );
}); });
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => { await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn( logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error }, { clientSessionId, sessionId, err: error },
"failed while waiting for previous runtime session to become idle", "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"), .default("./logs/llm-request-audit.log"),
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
AGENT_INTERNAL_TOKEN: optionalString(), 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 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// embedded opencode server 的监听地址。 // embedded opencode server 的监听地址。
@@ -43,10 +45,12 @@ const envSchema = z
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。 // 默认使用的 opencode 模型标识。
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
// client 模式下,目标 opencode server 的基础地址。 // client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 提供给本地 opencode tools 读取的会话上下文目录 // tjwater-cli 可执行文件路径
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"), TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
// TJWater 后端 API 的基础地址。 // TJWater 后端 API 的基础地址。
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"), TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。 // 代理调用 TJWater 后端 API 的超时时间(毫秒)。
@@ -57,18 +61,18 @@ const envSchema = z
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3), MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
// memory 持久化存储目录。 // memory 持久化存储目录。
MEMORY_STORAGE_DIR: z.string().default("./data/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 快照最大字符数,避免上下文过大。 // 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
// session transcript 持久化目录。 // session transcript 持久化目录。
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"), SESSION_TRANSCRIPT_STORAGE_DIR: z.string().default("./data/session-transcripts"),
// conversation metadata 持久化目录。 // session metadata 持久化目录。
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"), SESSION_METADATA_STORAGE_DIR: z.string().default("./data/session-metadata"),
// conversation UI state 持久化目录。 // session UI state 持久化目录。
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"), SESSION_UI_STATE_STORAGE_DIR: z.string().default("./data/session-ui-states"),
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。 // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION: z.coerce
.number() .number()
.int() .int()
.positive() .positive()
@@ -77,8 +81,8 @@ const envSchema = z
SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8), SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8),
// session_search 查询文本最大长度。 // session_search 查询文本最大长度。
SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240), SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240),
// learning review 会话状态目录。 // 当前 session 的 learning 进度状态目录。
LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"), SESSION_LEARNING_STATE_STORAGE_DIR: z.string().default("./data/session-learning-state"),
// learning audit 日志路径。 // learning audit 日志路径。
LEARNING_AUDIT_LOG_PATH: z LEARNING_AUDIT_LOG_PATH: z
.string() .string()
@@ -101,12 +105,6 @@ const envSchema = z
.int() .int()
.positive() .positive()
.default(3600000), .default(3600000),
// fetch_result_ref 默认最多返回的顶层项/字段数量。
RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce
.number()
.int()
.positive()
.default(50),
}) })
.superRefine((env, ctx) => { .superRefine((env, ctx) => {
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) { 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 { writeLearningAuditLog } from "../audit/learningAudit.js";
import { type ChatRequestContext } from "../chat/sessionBridge.js"; import { type ChatRequestContext } from "../chat/sessionBridge.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { type SessionTurnRecord, SessionHistoryStore } from "../history/store.js"; import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { logger } from "../logger.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 { MemoryStore, type MemoryScope } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SkillStore } from "../skills/store.js"; import { SkillStore } from "../skills/store.js";
import { import {
buildToolSessionScopeKey, removeRuntimeSessionContext,
ToolSessionContextStore, setRuntimeSessionContext,
} from "../session/toolContextStore.js"; } from "../runtime/sessionContext.js";
import { import {
sanitizePersistentDocument, sanitizePersistentDocument,
sanitizePersistentLine, sanitizePersistentLine,
@@ -41,7 +41,13 @@ const reviewResultSchema = z.object({
skills: z skills: z
.array( .array(
z.object({ 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), confidence: z.number().min(0).max(1),
content: z.string().optional(), content: z.string().optional(),
evidence: z.string().default(""), evidence: z.string().default(""),
@@ -71,25 +77,21 @@ type TurnReviewInput = {
export class LearningOrchestrator { export class LearningOrchestrator {
private readonly activeReviews = new Set<string>(); private readonly activeReviews = new Set<string>();
private readonly learningStateStore = new LearningStateStore(); private readonly sessionLearningStateStore = new SessionLearningStateStore();
private readonly skillStore = new SkillStore(); private readonly skillStore = new SkillStore();
private readonly toolContextStore = new ToolSessionContextStore();
constructor( constructor(
private readonly runtime: OpencodeRuntimeAdapter, private readonly runtime: OpencodeRuntimeAdapter,
private readonly memoryStore: MemoryStore, private readonly memoryStore: MemoryStore,
private readonly historyStore: SessionHistoryStore, private readonly transcriptStore: SessionTranscriptStore,
) {} ) {}
async initialize() { async initialize() {
await Promise.all([ await this.sessionLearningStateStore.initialize();
this.learningStateStore.initialize(),
this.toolContextStore.initialize(),
]);
} }
async onTurnCompleted(input: TurnReviewInput) { async onTurnCompleted(input: TurnReviewInput) {
const transcript = await this.historyStore.appendTurn( const transcript = await this.transcriptStore.appendTurn(
{ {
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
clientSessionId: input.requestContext.clientSessionId, clientSessionId: input.requestContext.clientSessionId,
@@ -108,13 +110,12 @@ export class LearningOrchestrator {
} }
this.activeReviews.add(input.sessionId); this.activeReviews.add(input.sessionId);
try { 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); 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); this.activeReviews.delete(input.sessionId);
return; return;
} }
await this.learningStateStore.markPending(input.sessionId, true);
} catch (error) { } catch (error) {
this.activeReviews.delete(input.sessionId); this.activeReviews.delete(input.sessionId);
throw error; throw error;
@@ -145,7 +146,7 @@ export class LearningOrchestrator {
`learning-gate-${input.requestContext.clientSessionId}`, `learning-gate-${input.requestContext.clientSessionId}`,
); );
gateSessionId = gateSession.id; gateSessionId = gateSession.id;
await this.toolContextStore.write({ setRuntimeSessionContext({
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
allowLearningWrite: false, allowLearningWrite: false,
clientSessionId: `gate-${input.requestContext.clientSessionId}`, clientSessionId: `gate-${input.requestContext.clientSessionId}`,
@@ -153,11 +154,6 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: gateSession.id, sessionId: gateSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
await this.runtime.prompt( await this.runtime.prompt(
@@ -172,7 +168,7 @@ export class LearningOrchestrator {
const gateText = collectTextContent(assistantMessage?.parts ?? []); const gateText = collectTextContent(assistantMessage?.parts ?? []);
const gate = parseGateResult(gateText); const gate = parseGateResult(gateText);
if (!gate) { if (!gate) {
await this.learningStateStore.completeGate(input.sessionId, turnCount); await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
await writeLearningAuditLog({ await writeLearningAuditLog({
action: "review-gate", action: "review-gate",
detail: "gate result was not valid JSON", detail: "gate result was not valid JSON",
@@ -197,7 +193,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
if (!shouldPromote) { if (!shouldPromote) {
await this.learningStateStore.completeGate(input.sessionId, turnCount); await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
return; return;
} }
await this.runReview({ await this.runReview({
@@ -207,7 +203,6 @@ export class LearningOrchestrator {
turnCount, turnCount,
}); });
} catch (error) { } catch (error) {
await this.learningStateStore.markPending(input.sessionId, false);
logger.warn({ err: error, sessionId: input.sessionId }, "learning gate failed"); logger.warn({ err: error, sessionId: input.sessionId }, "learning gate failed");
await writeLearningAuditLog({ await writeLearningAuditLog({
action: "review-gate", action: "review-gate",
@@ -219,7 +214,7 @@ export class LearningOrchestrator {
}); });
} finally { } finally {
if (gateSessionId) { if (gateSessionId) {
await this.toolContextStore.remove(gateSessionId).catch(() => undefined); removeRuntimeSessionContext(gateSessionId);
await this.runtime.abortSession(gateSessionId).catch(() => undefined); await this.runtime.abortSession(gateSessionId).catch(() => undefined);
} }
} }
@@ -239,7 +234,7 @@ export class LearningOrchestrator {
const reviewSession = await this.runtime.createSession( const reviewSession = await this.runtime.createSession(
`learning-review-${input.requestContext.clientSessionId}`, `learning-review-${input.requestContext.clientSessionId}`,
); );
await this.toolContextStore.write({ setRuntimeSessionContext({
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
allowLearningWrite: false, allowLearningWrite: false,
clientSessionId: `review-${input.requestContext.clientSessionId}`, clientSessionId: `review-${input.requestContext.clientSessionId}`,
@@ -247,17 +242,13 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: reviewSession.id, sessionId: reviewSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
try { try {
const existingMemory = await this.loadMemoryContext(input.requestContext);
await this.runtime.prompt( await this.runtime.prompt(
reviewSession.id, reviewSession.id,
buildReviewPrompt({ focus, recentTurns }), buildReviewPrompt({ existingMemory, focus, recentTurns }),
toRuntimeModel(input.model), toRuntimeModel(input.model),
); );
const messages = await this.runtime.messages(reviewSession.id, 20); const messages = await this.runtime.messages(reviewSession.id, 20);
@@ -267,7 +258,7 @@ export class LearningOrchestrator {
const reviewText = collectTextContent(assistantMessage?.parts ?? []); const reviewText = collectTextContent(assistantMessage?.parts ?? []);
const parsed = parseReviewResult(reviewText); const parsed = parseReviewResult(reviewText);
if (!parsed) { if (!parsed) {
await this.learningStateStore.completeGate(input.sessionId, turnCount); await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
await writeLearningAuditLog({ await writeLearningAuditLog({
action: "review-parse", action: "review-parse",
detail: "review result was not valid JSON", detail: "review result was not valid JSON",
@@ -279,9 +270,8 @@ export class LearningOrchestrator {
return; return;
} }
await this.applyReviewResult(input, parsed, turnCount); await this.applyReviewResult(input, parsed, turnCount);
await this.learningStateStore.completeReview(input.sessionId, turnCount); await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount);
} catch (error) { } catch (error) {
await this.learningStateStore.markPending(input.sessionId, false);
logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed"); logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed");
await writeLearningAuditLog({ await writeLearningAuditLog({
action: "review-run", action: "review-run",
@@ -292,7 +282,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
} finally { } finally {
await this.toolContextStore.remove(reviewSession.id).catch(() => undefined); removeRuntimeSessionContext(reviewSession.id);
await this.runtime.abortSession(reviewSession.id).catch(() => undefined); 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( private async applyMemoryProposal(
input: TurnReviewInput, input: TurnReviewInput,
proposal: ReviewResult["memories"][number], proposal: ReviewResult["memories"][number],
@@ -355,28 +353,33 @@ export class LearningOrchestrator {
source: "review" as const, source: "review" as const,
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}; };
const result = let accepted = false;
proposal.action === "add" let detail = "memory rejected";
? await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft) if (proposal.action === "add") {
: proposal.action === "replace" const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft);
? await this.memoryStore.replace( accepted = Boolean(result.entry);
proposal.scope as MemoryScope, detail = result.detail;
scopeKey, } else if (proposal.action === "replace") {
proposal.target_id ?? "", const result = await this.memoryStore.replace(
draft, proposal.scope as MemoryScope,
) scopeKey,
: await this.memoryStore.remove( proposal.target_id ?? "",
proposal.scope as MemoryScope, draft,
scopeKey, );
proposal.target_id ?? "", accepted = Boolean(result.changed);
); detail = result.detail;
const accepted = } else {
"entry" in result ? Boolean(result.entry) : Boolean(result.changed); const result = await this.memoryStore.remove(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
);
accepted = Boolean(result.changed);
detail = result.detail;
}
await writeLearningAuditLog({ await writeLearningAuditLog({
action: `memory-${proposal.action}`, action: `memory-${proposal.action}`,
detail: sanitizeAuditDetail( detail: sanitizeAuditDetail(detail),
"detail" in result ? result.detail : result.changed ? "memory stored" : "memory deduped",
),
outcome: accepted ? "accepted" : "rejected", outcome: accepted ? "accepted" : "rejected",
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
proposal: sanitizeMemoryProposalForAudit(proposal), proposal: sanitizeMemoryProposalForAudit(proposal),
@@ -411,11 +414,15 @@ export class LearningOrchestrator {
proposal.skill_path, proposal.skill_path,
proposal.target_id ?? "", proposal.target_id ?? "",
) )
: await this.skillStore.writeReference( : proposal.action === "write_reference"
proposal.skill_path, ? await this.skillStore.writeReference(
proposal.file_path ?? "", proposal.skill_path,
proposal.content ?? "", 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({ await writeLearningAuditLog({
action: `skill-${proposal.action}`, action: `skill-${proposal.action}`,
detail: sanitizeAuditDetail(result.detail), detail: sanitizeAuditDetail(result.detail),
@@ -451,9 +458,14 @@ const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] })
}; };
const buildReviewPrompt = ({ const buildReviewPrompt = ({
existingMemory,
focus, focus,
recentTurns, recentTurns,
}: { }: {
existingMemory: {
userMemory: Array<{ content: string; id: string }>;
workspaceMemory: Array<{ content: string; id: string }>;
};
focus: GateResult["focus"]; focus: GateResult["focus"];
recentTurns: SessionTurnRecord[]; recentTurns: SessionTurnRecord[];
}) => { }) => {
@@ -463,6 +475,16 @@ const buildReviewPrompt = ({
`Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`, `Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`,
) )
.join("\n\n"); .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 [ return [
"You are doing an internal self-improvement review for TJWaterAgent.", "You are doing an internal self-improvement review for TJWaterAgent.",
"Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.", "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.", "- Keep only stable user preferences, durable constraints, or stable workspace facts.",
"- Use scope='user' for user preferences and constraints.", "- Use scope='user' for user preferences and constraints.",
"- Use scope='workspace' for project or environment facts.", "- 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.", "- Do not store one-off task outcomes, temporary facts, or speculative conclusions.",
"", "",
"Current persisted memories:",
"[User memory]",
userMemoryBlock,
"[Workspace memory]",
workspaceMemoryBlock,
"",
"Skill rules:", "Skill rules:",
"- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.", "- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.",
"- Prefer append_pattern for concise reusable lessons.", "- Prefer append_pattern for concise reusable lessons.",
"- Use write_reference only for compact durable supporting notes under references/*.md.", "- 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:", "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.", "If nothing should be saved, return empty arrays.",
"", "",
@@ -7,57 +7,51 @@ import {
readJsonFile, readJsonFile,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export type LearningSessionState = { export type SessionLearningState = {
lastGatedTurn: number; lastGatedTurn: number;
lastReviewedTurn: number; lastReviewedTurn: number;
pendingReview: boolean;
sessionId: string; sessionId: string;
updatedAt: string; updatedAt: string;
}; };
export class LearningStateStore { export class SessionLearningStateStore {
constructor(private readonly baseDir = config.LEARNING_STATE_STORAGE_DIR) {} constructor(private readonly baseDir = config.SESSION_LEARNING_STATE_STORAGE_DIR) {}
async initialize() { async initialize() {
await ensureDirectory(this.baseDir); await ensureDirectory(this.baseDir);
} }
async read(sessionId: string): Promise<LearningSessionState> { async read(sessionId: string): Promise<SessionLearningState> {
const existing = await readJsonFile<LearningSessionState>(this.filePath(sessionId)); const existing = await readJsonFile<SessionLearningState>(this.filePath(sessionId));
if (existing) { if (existing) {
return existing; return {
lastGatedTurn: existing.lastGatedTurn,
lastReviewedTurn: existing.lastReviewedTurn,
sessionId: existing.sessionId,
updatedAt: existing.updatedAt,
};
} }
return { return {
lastGatedTurn: 0, lastGatedTurn: 0,
lastReviewedTurn: 0, lastReviewedTurn: 0,
pendingReview: false,
sessionId, sessionId,
updatedAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(),
}; };
} }
async write(state: LearningSessionState) { async write(state: SessionLearningState) {
await atomicWriteJson(this.filePath(state.sessionId), { await atomicWriteJson(this.filePath(state.sessionId), {
...state, ...state,
updatedAt: new Date().toISOString(), 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) { async completeReview(sessionId: string, reviewedTurnCount: number) {
const current = await this.read(sessionId); const current = await this.read(sessionId);
await this.write({ await this.write({
...current, ...current,
lastGatedTurn: Math.max(current.lastGatedTurn, reviewedTurnCount), lastGatedTurn: Math.max(current.lastGatedTurn, reviewedTurnCount),
lastReviewedTurn: reviewedTurnCount, lastReviewedTurn: reviewedTurnCount,
pendingReview: false,
}); });
} }
@@ -66,7 +60,6 @@ export class LearningStateStore {
await this.write({ await this.write({
...current, ...current,
lastGatedTurn: gatedTurnCount, lastGatedTurn: gatedTurnCount,
pendingReview: false,
}); });
} }
+26
View File
@@ -2,14 +2,40 @@ import pino from "pino";
import { config } from "./config.js"; 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({ export const logger = pino({
level: config.LOG_LEVEL, level: config.LOG_LEVEL,
formatters: {
level: (label) => ({ level: label }),
},
timestamp: () => `,"time":"${formatLocalTimestamp(new Date())}"`,
transport: transport:
config.NODE_ENV === "development" config.NODE_ENV === "development"
? { ? {
target: "pino-pretty", target: "pino-pretty",
options: { options: {
colorize: true, colorize: true,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l o",
}, },
} }
: undefined, : undefined,
+21 -9
View File
@@ -42,28 +42,36 @@ export class MemoryStore {
constructor( constructor(
private readonly baseDir = config.MEMORY_STORAGE_DIR, 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() { async initialize() {
await ensureDirectory(this.baseDir); await ensureDirectory(this.baseDir);
await ensureDirectory(join(this.baseDir, "users")); await ensureDirectory(join(this.baseDir, "users"));
await ensureDirectory(join(this.baseDir, "workspaces")); await ensureDirectory(join(this.baseDir, "workspaces"));
// 历史备份与正式数据分目录存放,便于排查和手工恢复。 // 备份与正式数据分目录存放,便于排查和手工恢复。
await ensureDirectory(this.historyDir); await ensureDirectory(this.backupDir);
} }
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) { async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
return this.serializeWrite(async () => { return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content); const content = normalizeMemoryContent(draft.content);
if (!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 entries = await this.readEntries(scope, key);
const existing = entries.find((entry) => entry.content === content); const existing = entries.find((entry) => entry.content === content);
if (existing) { if (existing) {
return { changed: false, entry: existing }; return {
changed: false,
detail: "memory already existed",
entry: existing,
};
} }
const entry: MemoryEntry = { const entry: MemoryEntry = {
@@ -76,11 +84,15 @@ export class MemoryStore {
this.filePath(scope, key), this.filePath(scope, key),
renderMemoryMarkdown(scope, entries), renderMemoryMarkdown(scope, entries),
{ {
historyDir: this.historyDir, backupDir: this.backupDir,
rootDir: this.baseDir, 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), this.filePath(scope, key),
renderMemoryMarkdown(scope, entries), renderMemoryMarkdown(scope, entries),
{ {
historyDir: this.historyDir, backupDir: this.backupDir,
rootDir: this.baseDir, rootDir: this.baseDir,
}, },
); );
@@ -132,7 +144,7 @@ export class MemoryStore {
this.filePath(scope, key), this.filePath(scope, key),
renderMemoryMarkdown(scope, next), renderMemoryMarkdown(scope, next),
{ {
historyDir: this.historyDir, backupDir: this.backupDir,
rootDir: this.baseDir, rootDir: this.baseDir,
}, },
); );
+19 -110
View File
@@ -1,17 +1,16 @@
import { config } from "../config.js"; import { readJsonFile } from "../utils/fileStore.js";
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
import { import {
type ResultReferenceKind, type ResultReferenceKind,
type ResultReferenceRecord, type ResultReferenceRecord,
type ResultReferenceSource, type ResultReferenceSource,
type RetrievalContext, type RetrievalContext,
RESULT_REFERENCE_KIND, RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
type ResultReferenceStore, type ResultReferenceStore,
} from "./store.js"; } from "./store.js";
type ResolveOptions = { type ResolveOptions = {
expectedKind?: ResultReferenceKind; expectedKind?: ResultReferenceKind;
maxItems?: number;
}; };
type RegisterResultReferenceInput = { type RegisterResultReferenceInput = {
@@ -36,6 +35,7 @@ export type RenderJunctionPayload = {
export class ResultReferenceResolver { export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {} constructor(private readonly store: ResultReferenceStore) {}
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
async register(input: RegisterResultReferenceInput) { async register(input: RegisterResultReferenceInput) {
const normalizedData = normalizeDataForKind( const normalizedData = normalizeDataForKind(
input.kind, input.kind,
@@ -68,15 +68,12 @@ export class ResultReferenceResolver {
throw new Error(`render payload file not found: ${filePath}`); throw new Error(`render payload file not found: ${filePath}`);
} }
const payloadCandidate = normalizeRenderPayloadFile(raw, { const wrapper = normalizeRenderPayloadFile(raw, filePath);
filePath, if (!wrapper) {
projectId: input.projectId, throw new Error("render payload file must use the wrapped { metadata, location, data } format");
});
if (payloadCandidate.repaired) {
await atomicWriteJson(filePath, payloadCandidate.file);
} }
const payload = extractRenderJunctionPayload(payloadCandidate.data); const payload = extractRenderJunctionPayload(wrapper.data);
if (!payload) { if (!payload) {
throw new Error("render payload file does not contain a valid junction render payload"); throw new Error("render payload file does not contain a valid junction render payload");
} }
@@ -86,27 +83,10 @@ export class ResultReferenceResolver {
data: payload, data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1, 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( async getFullAuthorized(
resultRef: string, resultRef: string,
context: RetrievalContext, context: RetrievalContext,
@@ -164,6 +144,7 @@ export const extractRenderJunctionPayload = (
return null; return null;
} }
// 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map); const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
if (Object.keys(nodeAreaMap).length === 0) { if (Object.keys(nodeAreaMap).length === 0) {
return null; return null;
@@ -202,35 +183,18 @@ const normalizeDataForKind = (
const normalizeRenderPayloadFile = ( const normalizeRenderPayloadFile = (
value: unknown, value: unknown,
context: { filePath: string; projectId?: string }, filePath: string,
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => { ): { data: unknown } | null => {
if (!isRecord(value) || !("data" in value)) { if (!isRecord(value) || !("data" in value)) {
return { return null;
data: value,
file: {
metadata: buildWrapperMetadata({}, value, context.projectId),
location: buildWrapperLocation(undefined, context.filePath),
data: value,
},
repaired: false,
};
} }
if (!isRecord(value.metadata) || !isRecord(value.location)) {
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId); return null;
const location = buildWrapperLocation(value.location, context.filePath); }
const next: Record<string, unknown> = { if (value.location.file_path !== filePath) {
...value, return null;
metadata, }
location, return { data: value.data };
};
return {
data: next.data,
file: next,
repaired:
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
};
}; };
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => { const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
@@ -250,60 +214,5 @@ const normalizeStringRecord = (value: Record<string, unknown>) =>
.filter(([, entry]) => entry.length > 0), .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> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value); 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, listJsonFiles,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
toProjectKey,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/; export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/; const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
export const RESULT_REFERENCE_KIND = { export const RESULT_REFERENCE_KIND = {
dynamicHttpResult: "dynamic-http-result",
renderJunctionsPayload: "render-junctions-payload", renderJunctionsPayload: "render-junctions-payload",
} as const; } as const;
export const RESULT_REFERENCE_SOURCE = { export const RESULT_REFERENCE_SOURCE = {
dynamicHttp: "dynamic_http",
agentGenerated: "agent_generated", agentGenerated: "agent_generated",
legacy: "legacy",
migration: "migration",
} as const; } as const;
export type ResultReferenceKind = export type ResultReferenceKind =
@@ -135,6 +130,7 @@ export class ResultReferenceStore {
source: input.source, source: input.source,
traceId: input.traceId, traceId: input.traceId,
}; };
// result_ref 对外暴露短引用,完整数据落盘;这样可以避免大结果直接塞进模型上下文。
await atomicWriteJson(this.filePath(resultRef), record); await atomicWriteJson(this.filePath(resultRef), record);
return record; return record;
} }
@@ -145,13 +141,13 @@ export class ResultReferenceStore {
return null; return null;
} }
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef)); const record = normalizeResultReferenceRecord(
const record = await readJsonFile<unknown>(this.filePath(normalizedResultRef)),
normalizeResultReferenceRecord(rawRecord) ?? );
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
if (!record) { if (!record) {
return null; return null;
} }
// 读取 result_ref 时按用户、项目和可选会话三层校验,防止跨项目/跨用户取数。
if (record.actorKey !== context.actorKey) { if (record.actorKey !== context.actorKey) {
return null; return null;
} }
@@ -204,6 +200,7 @@ export class ResultReferenceStore {
if (!stats) { if (!stats) {
continue; continue;
} }
// TTL 以文件修改时间为准,清理长期无人访问的 result_ref 文件。
if (now - stats.mtimeMs > this.ttlMs) { if (now - stats.mtimeMs > this.ttlMs) {
await removeFileIfExists(filePath); await removeFileIfExists(filePath);
} }
@@ -280,9 +277,6 @@ export const normalizeResultReferenceRecord = (
const normalizeResultReferenceKind = ( const normalizeResultReferenceKind = (
value: unknown, value: unknown,
): ResultReferenceKind | null => { ): ResultReferenceKind | null => {
if (value === undefined) {
return RESULT_REFERENCE_KIND.dynamicHttpResult;
}
return Object.values(RESULT_REFERENCE_KIND).includes( return Object.values(RESULT_REFERENCE_KIND).includes(
value as ResultReferenceKind, value as ResultReferenceKind,
) )
@@ -293,9 +287,6 @@ const normalizeResultReferenceKind = (
const normalizeResultReferenceSource = ( const normalizeResultReferenceSource = (
value: unknown, value: unknown,
): ResultReferenceSource | null => { ): ResultReferenceSource | null => {
if (value === undefined) {
return RESULT_REFERENCE_SOURCE.legacy;
}
return Object.values(RESULT_REFERENCE_SOURCE).includes( return Object.values(RESULT_REFERENCE_SOURCE).includes(
value as ResultReferenceSource, value as ResultReferenceSource,
) )
@@ -311,60 +302,6 @@ const normalizeResultRef = (value: string) => {
return match?.[1] ?? null; 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 => const isResultPreview = (value: unknown): value is ResultPreview =>
isRecord(value) && isRecord(value) &&
typeof value.count === "number" && typeof value.count === "number" &&
+523 -121
View File
@@ -2,18 +2,20 @@ import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { type LearningOrchestrator } from "../learning/orchestrator.js"; 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 { logger } from "../logger.js";
import { MemoryStore } from "../memory/store.js"; import { MemoryStore } from "../memory/store.js";
import { type ConversationStateStore } from "../conversations/stateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type ConversationStore } from "../conversations/store.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type ResultReferenceResolver } from "../results/resolver.js"; import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle, generateSessionTitle,
shouldGenerateSessionTitle, shouldGenerateSessionTitle,
} from "./chatSession.js"; } from "./chatSession.js";
@@ -44,20 +46,153 @@ const forkPayloadSchema = z.object({
keep_message_count: z.coerce.number().int().min(0), keep_message_count: z.coerce.number().int().min(0),
}); });
const conversationStateSchema = z.object({ const sessionStateSchema = z.object({
title: z.string().max(120).optional(), title: z.string().max(120).optional(),
is_title_manually_edited: z.boolean().optional(), is_title_manually_edited: z.boolean().optional(),
messages: z.array(z.unknown()).default([]), messages: z.array(z.unknown()).default([]),
branch_groups: 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 = ( export const buildChatRouter = (
sessionBridge: ChatSessionBridge, sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
conversationStore: ConversationStore, sessionMetadataStore: SessionMetadataStore,
conversationStateStore: ConversationStateStore, sessionUiStateStore: SessionUiStateStore,
memoryStore: MemoryStore, memoryStore: MemoryStore,
sessionHistoryStore: SessionHistoryStore, sessionTranscriptStore: SessionTranscriptStore,
learningOrchestrator: LearningOrchestrator, learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver, resultReferenceResolver: ResultReferenceResolver,
) => { ) => {
@@ -77,13 +212,15 @@ export const buildChatRouter = (
const userId = req.header("x-user-id") ?? undefined; const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); 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, actorKey,
parentSessionId: parsed.data.parent_session_id, parentSessionId: parsed.data.parent_session_id,
projectId, projectId,
projectKey, projectKey,
sessionId: parsed.data.session_id, sessionId,
userId, userId,
}); });
@@ -102,7 +239,7 @@ export const buildChatRouter = (
const userId = req.header("x-user-id") ?? undefined; const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const records = await conversationStore.list({ const records = await sessionMetadataStore.list({
actorKey, actorKey,
projectId, projectId,
projectKey, projectKey,
@@ -116,6 +253,8 @@ export const buildChatRouter = (
updated_at: record.updatedAt, updated_at: record.updatedAt,
status: record.status, status: record.status,
parent_session_id: record.parentSessionId, 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; return;
} }
const conversation = await conversationStore.get( const sessionRecord = await sessionMetadataStore.get(
{ {
actorKey, actorKey,
projectId, projectId,
@@ -140,29 +279,97 @@ export const buildChatRouter = (
}, },
sessionId, sessionId,
); );
if (!conversation) { if (!sessionRecord) {
res.status(404).json({ message: "session not found" }); res.status(404).json({ message: "session not found" });
return; return;
} }
const state = await conversationStateStore.read(conversation.sessionScopeKey); const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
res.json({ res.json({
id: conversation.sessionId, id: sessionRecord.sessionId,
title: conversation.title ?? "新对话", title: sessionRecord.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false, is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: conversation.createdAt, created_at: sessionRecord.createdAt,
updated_at: conversation.updatedAt, updated_at: sessionRecord.updatedAt,
status: conversation.status, status: sessionRecord.status,
session_id: conversation.sessionId, session_id: sessionRecord.sessionId,
messages: state?.messages ?? [], messages: state?.messages ?? [],
branch_groups: state?.branchGroups ?? [], 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) => { chatRouter.put("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim(); const sessionId = req.params.sessionId?.trim();
const parsed = conversationStateSchema.safeParse(req.body ?? {}); const parsed = sessionStateSchema.safeParse(req.body ?? {});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
@@ -180,22 +387,42 @@ export const buildChatRouter = (
return; return;
} }
const { record } = await conversationStore.ensure({ const { record } = await sessionMetadataStore.ensure({
actorKey, actorKey,
projectId, projectId,
projectKey, projectKey,
sessionId, sessionId,
userId, userId,
}); });
const nextRecord = await conversationStore.touch(record, { const nextRecord = await sessionMetadataStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}), ...(parsed.data.title ? { title: parsed.data.title } : {}),
}); });
await conversationStateStore.write(nextRecord.sessionScopeKey, { await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
sessionId: nextRecord.sessionId, sessionId: nextRecord.sessionId,
isTitleManuallyEdited: parsed.data.is_title_manually_edited, isTitleManuallyEdited: parsed.data.is_title_manually_edited,
messages: parsed.data.messages, messages: parsed.data.messages,
branchGroups: parsed.data.branch_groups, 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({ res.json({
id: nextRecord.sessionId, id: nextRecord.sessionId,
title: nextRecord.title ?? "新对话", title: nextRecord.title ?? "新对话",
@@ -222,27 +449,32 @@ export const buildChatRouter = (
res.status(400).json({ message: "session_id and title are required" }); res.status(400).json({ message: "session_id and title are required" });
return; return;
} }
const conversation = await conversationStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
sessionId, sessionId,
); );
if (!conversation) { if (!sessionRecord) {
res.status(404).json({ message: "session not found" }); res.status(404).json({ message: "session not found" });
return; return;
} }
const nextConversation = await conversationStore.touch(conversation, { title }); const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title });
const state = await conversationStateStore.read(nextConversation.sessionScopeKey); const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
);
if (state) { if (state) {
await conversationStateStore.write(nextConversation.sessionScopeKey, { await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
{
...state, ...state,
isTitleManuallyEdited: isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited, isTitleManuallyEdited ?? state.isTitleManuallyEdited,
}); },
);
} }
res.json({ res.json({
id: nextConversation.sessionId, id: nextSessionRecord.sessionId,
title: nextConversation.title, title: nextSessionRecord.title,
updated_at: nextConversation.updatedAt, updated_at: nextSessionRecord.updatedAt,
}); });
}); });
@@ -256,16 +488,22 @@ export const buildChatRouter = (
res.status(400).json({ message: "session_id is required" }); res.status(400).json({ message: "session_id is required" });
return; return;
} }
const conversation = await conversationStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
sessionId, sessionId,
); );
if (!conversation) { if (!sessionRecord) {
res.status(204).end(); res.status(204).end();
return; return;
} }
await conversationStateStore.remove(conversation.sessionScopeKey); await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
await conversationStore.remove(conversation); 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(); res.status(204).end();
}); });
@@ -323,11 +561,56 @@ export const buildChatRouter = (
} }
try { try {
const binding = await sessionBridge.abort({ const projectId = req.header("x-project-id") ?? undefined;
clientSessionId: parsed.data.session_id, 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(); res.status(204).end();
return; return;
} }
@@ -335,7 +618,7 @@ export const buildChatRouter = (
logger.info( logger.info(
{ {
clientSessionId: parsed.data.session_id, clientSessionId: parsed.data.session_id,
sessionId: binding.sessionId, sessionId: binding?.sessionId ?? parsed.data.session_id,
}, },
"aborted chat session by client request", "aborted chat session by client request",
); );
@@ -370,54 +653,56 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const sourceClientSessionId = parsed.data.session_id?.trim(); const sourceSessionId = parsed.data.session_id?.trim();
const sourceConversation = sourceClientSessionId const sourceSessionRecord = sourceSessionId
? await conversationStore.get( ? await sessionMetadataStore.get(
{ {
actorKey, actorKey,
projectId, projectId,
projectKey, projectKey,
userId, userId,
}, },
sourceClientSessionId, sourceSessionId,
) )
: null; : null;
const { record: targetConversation } = await conversationStore.ensure({ const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey, actorKey,
parentSessionId: sourceClientSessionId, parentSessionId: sourceSessionId,
projectId, projectId,
projectKey, projectKey,
sessionId: forkSession.id,
userId, userId,
}); });
const nextClientSessionId = targetConversation.sessionId; const nextSessionId = targetSessionRecord.sessionId;
if (sourceClientSessionId && parsed.data.keep_message_count > 0) { if (sourceSessionId && parsed.data.keep_message_count > 0) {
await sessionHistoryStore.cloneThread( await sessionTranscriptStore.cloneThread(
{ {
actorKey, actorKey,
clientSessionId: sourceClientSessionId, clientSessionId: sourceSessionId,
projectKey, projectKey,
sessionId: sourceClientSessionId, sessionId: sourceSessionId,
}, },
{ {
actorKey, actorKey,
clientSessionId: nextClientSessionId, clientSessionId: nextSessionId,
projectKey, projectKey,
sessionId: nextClientSessionId, sessionId: nextSessionId,
}, },
parsed.data.keep_message_count, parsed.data.keep_message_count,
); );
if (sourceConversation?.title) { if (sourceSessionRecord?.title) {
await conversationStore.touch(targetConversation, { await sessionMetadataStore.touch(targetSessionRecord, {
title: sourceConversation.title, title: sourceSessionRecord.title,
}); });
} }
} }
logger.info( logger.info(
{ {
sourceClientSessionId: parsed.data.session_id, sourceSessionId: parsed.data.session_id,
clientSessionId: nextClientSessionId, sessionId: nextSessionId,
traceId, traceId,
projectId, projectId,
keepMessageCount: parsed.data.keep_message_count, keepMessageCount: parsed.data.keep_message_count,
@@ -426,7 +711,7 @@ export const buildChatRouter = (
); );
res.status(200).json({ res.status(200).json({
session_id: nextClientSessionId, session_id: nextSessionId,
}); });
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(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 userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const { record: conversation, created: conversationCreated } = const requestedSessionId = parsed.data.session_id?.trim();
await conversationStore.ensure({ const existingSessionRecord = requestedSessionId
actorKey, ? await sessionMetadataStore.get(
projectId, { actorKey, projectId, projectKey, userId },
projectKey, requestedSessionId,
sessionId: parsed.data.session_id, )
userId, : null;
}); const hadExistingRuntimeSession = Boolean(existingSessionRecord);
const activeConversation = await conversationStore.touch(conversation);
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: activeConversation.sessionId, sessionId: requestedSessionId,
accessToken, accessToken,
projectId, projectId,
traceId, traceId,
userId, userId,
}); });
const { record: ensuredSessionRecord, created: sessionCreated } =
await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId: binding.sessionId,
userId,
});
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
const historyContext = { const historyContext = {
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId, sessionId: requestContext.clientSessionId,
}; };
const recentTurns = await 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( logger.info(
{ {
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId, sessionId: binding.sessionId,
created: created || conversationCreated, created: created || sessionCreated,
model: parsed.data.model, model: parsed.data.model,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
@@ -505,27 +808,126 @@ export const buildChatRouter = (
const clientSessionId = requestContext.clientSessionId; const clientSessionId = requestContext.clientSessionId;
let streamClosed = false; let streamClosed = false;
const abortController = new AbortController(); 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 = () => { const handleClientClose = () => {
if (streamClosed || abortController.signal.aborted) { streamClosed = true;
return; activeRun.subscribers.delete(primarySubscriber);
}
abortController.abort();
}; };
req.on("close", handleClientClose); req.on("close", handleClientClose);
res.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 { try {
const preparedMessage = await buildPromptWithLearningContext( const preparedMessage = await buildPromptWithLearningContext(
memoryStore, memoryStore,
requestContext.actorKey, requestContext.actorKey,
requestContext.projectKey, requestContext.projectKey,
recentTurns, {
parsed.data.message, recentTurns,
persistedMessages: initialSessionState?.messages,
message: parsed.data.message,
restoreConversation: !hadExistingRuntimeSession,
},
); );
const streamResult = await streamPromptResponse({ const streamResult = await streamPromptResponse({
runtime, runtime,
opencodeSessionId: binding.sessionId, sessionId: binding.sessionId,
clientSessionId, clientSessionId,
message: preparedMessage, message: preparedMessage,
model: parsed.data.model, model: parsed.data.model,
@@ -533,12 +935,12 @@ export const buildChatRouter = (
projectId: requestContext.projectId, projectId: requestContext.projectId,
signal: abortController.signal, signal: abortController.signal,
write: (event, data) => { write: (event, data) => {
if (streamClosed || res.writableEnded || res.destroyed) { publish(event, data);
return;
}
res.write(toSse(event, data));
}, },
}); });
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
if (!streamResult.aborted && !streamResult.failed) { if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60); const messages = await runtime.messages(binding.sessionId, 60);
@@ -546,20 +948,20 @@ export const buildChatRouter = (
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []); const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const latestConversation = const latestSessionRecord =
(await conversationStore.get( (await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
activeConversation.sessionId, activeSessionRecord.sessionId,
)) ?? activeConversation; )) ?? activeSessionRecord;
const latestConversationState = await conversationStateStore.read( const latestSessionState = await sessionUiStateStore.read(
latestConversation.sessionScopeKey, toSessionUiStateContext(latestSessionRecord),
); );
const existingSessionTitle = latestConversation.title; const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle; let sessionTitle = existingSessionTitle;
const shouldGenerateTitle = shouldGenerateSessionTitle({ const shouldGenerateTitle = shouldGenerateSessionTitle({
recentTurnCount: recentTurns.length, recentTurnCount: recentTurns.length,
isTitleManuallyEdited: isTitleManuallyEdited:
latestConversationState?.isTitleManuallyEdited ?? false, latestSessionState?.isTitleManuallyEdited ?? false,
}); });
if (shouldGenerateTitle) { if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, { sessionTitle = await generateSessionTitle(runtime, {
@@ -569,43 +971,43 @@ export const buildChatRouter = (
fallbackTitle: existingSessionTitle, fallbackTitle: existingSessionTitle,
}); });
} }
const nextConversation = await conversationStore.touch(latestConversation, { const nextSessionRecord = await sessionMetadataStore.touch(latestSessionRecord, {
...(sessionTitle && sessionTitle !== existingSessionTitle ...(sessionTitle && sessionTitle !== existingSessionTitle
? { title: sessionTitle } ? { title: sessionTitle }
: {}), : {}),
}); });
if (!streamClosed && !res.writableEnded && !res.destroyed) { if (
if ( shouldGenerateTitle &&
shouldGenerateTitle && sessionTitle &&
sessionTitle && sessionTitle !== existingSessionTitle
sessionTitle !== existingSessionTitle ) {
) { publish("session_title", {
res.write( session_id: clientSessionId,
toSse("session_title", { title: sessionTitle,
session_id: clientSessionId, });
title: sessionTitle, await persistQueue.catch((error) => {
}), logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
);
}
}
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",
);
}); });
} }
} }
} finally { } 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; streamClosed = true;
req.off("close", handleClientClose); req.off("close", handleClientClose);
res.off("close", handleClientClose); res.off("close", handleClientClose);
+118 -6
View File
@@ -1,5 +1,5 @@
import { logger } from "../logger.js"; 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 { MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
@@ -192,15 +192,22 @@ export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore, memoryStore: MemoryStore,
actorKey: string, actorKey: string,
projectKey: string, projectKey: string,
recentTurns: SessionTurnRecord[], options: {
message: string, recentTurns: SessionTurnRecord[];
persistedMessages?: unknown[];
message: string;
restoreConversation?: boolean;
},
) => { ) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey }); 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) { 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) .filter(Boolean)
.join("\n\n"); .join("\n\n");
}; };
@@ -240,3 +247,108 @@ const compactMessage = (value: string) => {
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...` ? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: normalized; : 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 = { type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
opencodeSessionId: string; sessionId: string;
clientSessionId: string; clientSessionId: string;
message: string; message: string;
model?: SupportedModel; model?: SupportedModel;
@@ -36,8 +36,6 @@ type ProgressPayload = {
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = { const toolLabels: Record<string, string> = {
dynamic_http_call: "后端数据查询",
fetch_result_ref: "结果引用回读",
memory_manager: "记忆写入", memory_manager: "记忆写入",
session_search: "历史会话检索", session_search: "历史会话检索",
skill_manager: "流程沉淀", skill_manager: "流程沉淀",
@@ -168,11 +166,11 @@ export const collectTextContent = (parts: Part[]) =>
const emitFallbackMessage = async ( const emitFallbackMessage = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
opencodeSessionId: string, sessionId: string,
clientSessionId: string, clientSessionId: string,
write: (event: string, data: Record<string, unknown>) => void, write: (event: string, data: Record<string, unknown>) => void,
) => { ) => {
const messages = await runtime.messages(opencodeSessionId); const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages] const assistantMessage = [...messages]
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .find((message) => message.info.role === "assistant");
@@ -293,7 +291,7 @@ const getToolProgressTitle = (tool: string, status: string) => {
export const streamPromptResponse = async ({ export const streamPromptResponse = async ({
runtime, runtime,
opencodeSessionId, sessionId,
clientSessionId, clientSessionId,
message, message,
model, model,
@@ -332,7 +330,7 @@ export const streamPromptResponse = async ({
let aborted = signal?.aborted ?? false; let aborted = signal?.aborted ?? false;
let failed = false; let failed = false;
const debugContext = { const debugContext = {
opencodeSessionId, sessionId,
clientSessionId, clientSessionId,
traceId, traceId,
projectId, projectId,
@@ -406,7 +404,7 @@ export const streamPromptResponse = async ({
}); });
const promptPromise = runtime const promptPromise = runtime
.prompt(opencodeSessionId, message, toRuntimeModel(model)) .prompt(sessionId, message, toRuntimeModel(model))
.then(() => { .then(() => {
promptSettled = true; promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", { logDevelopmentDebug("runtime.prompt resolved", {
@@ -471,7 +469,7 @@ export const streamPromptResponse = async ({
} }
const event = next.result.value as OpencodeEvent; const event = next.result.value as OpencodeEvent;
if (!isSessionEvent(event, opencodeSessionId)) { if (!isSessionEvent(event, sessionId)) {
continue; continue;
} }
@@ -541,7 +539,7 @@ export const streamPromptResponse = async ({
}); });
void writeLlmRequestAuditLog({ void writeLlmRequestAuditLog({
kind: "skill", kind: "skill",
sessionId: opencodeSessionId, sessionId: sessionId,
clientSessionId, clientSessionId,
traceId, traceId,
projectId, projectId,
@@ -691,7 +689,7 @@ export const streamPromptResponse = async ({
logger.warn( logger.warn(
{ {
tool: part.tool, tool: part.tool,
sessionId: opencodeSessionId, sessionId: sessionId,
clientSessionId, clientSessionId,
}, },
"llm tool request missing reason", "llm tool request missing reason",
@@ -699,7 +697,7 @@ export const streamPromptResponse = async ({
} }
void writeLlmRequestAuditLog({ void writeLlmRequestAuditLog({
kind: "tool", kind: "tool",
sessionId: opencodeSessionId, sessionId: sessionId,
clientSessionId, clientSessionId,
traceId, traceId,
projectId, projectId,
@@ -781,12 +779,12 @@ export const streamPromptResponse = async ({
...debugContext, ...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
await runtime.abortSession(opencodeSessionId).catch((error) => { await runtime.abortSession(sessionId).catch((error) => {
logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session"); 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( logger.warn(
{ sessionId: opencodeSessionId, err: error }, { sessionId: sessionId, err: error },
"failed while waiting for aborted opencode session to become idle", "failed while waiting for aborted opencode session to become idle",
); );
}); });
@@ -803,7 +801,7 @@ export const streamPromptResponse = async ({
...debugContext, ...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
await emitFallbackMessage(runtime, opencodeSessionId, clientSessionId, write); await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
} }
emitProgress({ emitProgress({
id: "request-received", id: "request-received",
+11
View File
@@ -167,6 +167,7 @@ export class OpencodeRuntimeAdapter {
"starting opencode server in embedded mode", "starting opencode server in embedded mode",
); );
const startedAt = Date.now();
let runtime; let runtime;
try { try {
runtime = await createOpencode({ runtime = await createOpencode({
@@ -186,6 +187,16 @@ export class OpencodeRuntimeAdapter {
throw error; 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 = () => { this.closeServer = () => {
runtime.server.close(); 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 { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import cors from "cors"; import cors from "cors";
import express from "express"; 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 { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { ConversationStateStore } from "./conversations/stateStore.js"; import { SessionUiStateStore } from "./sessions/uiStateStore.js";
import { ConversationStore } from "./conversations/store.js"; import { SessionMetadataStore } from "./sessions/metadataStore.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js"; import { LearningOrchestrator } from "./learning/orchestrator.js";
import { MemoryStore } from "./memory/store.js"; import { MemoryStore } from "./memory/store.js";
import { ResultReferenceResolver } from "./results/resolver.js"; import { ResultReferenceResolver } from "./results/resolver.js";
import { ResultReferenceStore } from "./results/store.js"; import {
RESULT_REFERENCE_SOURCE,
ResultReferenceStore,
} from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { ToolSessionContextStore } from "./session/toolContextStore.js"; import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
const app = express(); const app = express();
// 这里集中组装 Agent 服务的运行期依赖,路由层只通过接口调用,便于测试时替换实现。
const sessionBridge = new ChatSessionBridge(opencodeRuntime); const sessionBridge = new ChatSessionBridge(opencodeRuntime);
const conversationStore = new ConversationStore(); const sessionMetadataStore = new SessionMetadataStore();
const conversationStateStore = new ConversationStateStore(); const sessionUiStateStore = new SessionUiStateStore();
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const sessionHistoryStore = new SessionHistoryStore(); const sessionTranscriptStore = new SessionTranscriptStore();
const toolContextStore = new ToolSessionContextStore();
const learningOrchestrator = new LearningOrchestrator( const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime, opencodeRuntime,
memoryStore, memoryStore,
sessionHistoryStore, sessionTranscriptStore,
); );
const resultReferenceStore = new ResultReferenceStore(); const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); 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; process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors()); 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) { if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const threadContext = await toolContextStore.read(sessionScopeKey); const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey); if (!context) {
if (!threadContext && !runtimeContext) {
res.status(404).json({ res.status(404).json({
message: "runtime or session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
const context = runtimeContext ?? threadContext;
if (!context) { const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
res.status(404).json({ if (!command) {
message: "runtime or session context not found", res.status(400).json({ message: "command is required" });
detail: sessionScopeKey, 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; return;
} }
try { try {
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。 res.json(JSON.parse(stdout));
const result = await dynamicHttpExecutor.execute( } catch {
{ res.json({
reason: req.body?.reason, ok: true,
path: req.body?.path, schema_version: "tjwater-cli/v1",
method: req.body?.method, raw: stdout,
arguments: req.body?.arguments, stderr: stderr || undefined,
},
{
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,
}); });
} }
}); });
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) => { app.post("/internal/tools/store-render-ref", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) { if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; 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 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) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
@@ -186,7 +195,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
projectId: context.projectId, projectId: context.projectId,
projectKey: context.projectKey, projectKey: context.projectKey,
sessionId: context.clientSessionId, sessionId: context.clientSessionId,
source: "migration", source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: context.traceId, traceId: context.traceId,
}); });
res.json({ res.json({
@@ -213,14 +222,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const query = typeof req.body?.query === "string" ? req.body.query : ""; const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = await toolContextStore.read(sessionScopeKey); const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
@@ -228,7 +237,7 @@ app.post("/internal/tools/session-search", async (req, res) => {
res.status(400).json({ message: "query is required" }); res.status(400).json({ message: "query is required" });
return; return;
} }
const hits = await sessionHistoryStore.search( const hits = await sessionTranscriptStore.search(
{ {
actorKey: context.actorKey, actorKey: context.actorKey,
projectKey: context.projectKey, projectKey: context.projectKey,
@@ -244,13 +253,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
app.use( app.use(
"/api/v1/agent/chat", "/api/v1/agent/chat",
requireAgentAuth,
buildChatRouter( buildChatRouter(
sessionBridge, sessionBridge,
opencodeRuntime, opencodeRuntime,
conversationStore, sessionMetadataStore,
conversationStateStore, sessionUiStateStore,
memoryStore, memoryStore,
sessionHistoryStore, sessionTranscriptStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
), ),
@@ -258,13 +268,12 @@ app.use(
const bootstrap = async () => { const bootstrap = async () => {
await Promise.all([ await Promise.all([
conversationStore.initialize(), sessionMetadataStore.initialize(),
conversationStateStore.initialize(), sessionUiStateStore.initialize(),
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
sessionHistoryStore.initialize(), sessionTranscriptStore.initialize(),
toolContextStore.initialize(),
]); ]);
resultReferenceStore.startCleanupLoop(); 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 { join } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
@@ -8,14 +7,13 @@ import {
listJsonFiles, listJsonFiles,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
slugify,
} from "../utils/fileStore.js"; } 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; sessionId: string;
sessionScopeKey: string;
actorKey: string; actorKey: string;
ownerUserId?: string; ownerUserId?: string;
projectId?: string; projectId?: string;
@@ -23,45 +21,44 @@ export type ConversationRecord = {
parentSessionId?: string; parentSessionId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
status: ConversationStatus; status: SessionStatus;
title?: string; title?: string;
}; };
type ConversationContext = { type SessionMetadataContext = {
actorKey: string; actorKey: string;
userId?: string; userId?: string;
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
}; };
type EnsureConversationInput = ConversationContext & { type EnsureSessionMetadataInput = SessionMetadataContext & {
sessionId?: string; sessionId: string;
parentSessionId?: string; parentSessionId?: string;
}; };
export class ConversationStore { export class SessionMetadataStore {
constructor(private readonly baseDir = config.CONVERSATION_STORAGE_DIR) {} constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
async initialize() { async initialize() {
await ensureDirectory(this.baseDir); await ensureDirectory(this.baseDir);
} }
async ensure(input: EnsureConversationInput) { async ensure(input: EnsureSessionMetadataInput) {
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId(); const sessionId = normalizeSessionId(input.sessionId);
const sessionScopeKey = toConversationScopeKey( if (!sessionId) {
input.actorKey, throw new Error("sessionId is required");
input.projectKey, }
sessionId, const existing = await readJsonFile<SessionRecord>(
this.filePath(sessionId),
); );
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
if (existing) { if (existing) {
return { created: false, record: existing }; return { created: false, record: existing };
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
const record: ConversationRecord = { const record: SessionRecord = {
sessionId, sessionId,
sessionScopeKey,
actorKey: input.actorKey, actorKey: input.actorKey,
ownerUserId: input.userId?.trim(), ownerUserId: input.userId?.trim(),
projectId: input.projectId, projectId: input.projectId,
@@ -71,42 +68,46 @@ export class ConversationStore {
updatedAt: now, updatedAt: now,
status: "active", status: "active",
}; };
await atomicWriteJson(this.filePath(sessionScopeKey), record); await atomicWriteJson(
this.filePath(record.sessionId),
record,
);
return { created: true, record }; return { created: true, record };
} }
async get(context: ConversationContext, sessionId: string) { async get(context: SessionMetadataContext, sessionId: string) {
const normalizedSessionId = normalizeSessionId(sessionId); const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) { if (!normalizedSessionId) {
return null; return null;
} }
return await readJsonFile<ConversationRecord>( return await readJsonFile<SessionRecord>(
this.filePath( this.filePath(normalizedSessionId),
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
),
); );
} }
async touch( async touch(
record: ConversationRecord, record: SessionRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {}, updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
) { ) {
const next: ConversationRecord = { const next: SessionRecord = {
...record, ...record,
...normalizeConversationUpdates(updates), ...normalizeSessionUpdates(updates),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await atomicWriteJson(this.filePath(record.sessionScopeKey), next); await atomicWriteJson(
this.filePath(record.sessionId),
next,
);
return next; return next;
} }
async list(context: ConversationContext) { async list(context: SessionMetadataContext) {
const files = await listJsonFiles(this.baseDir); const files = await listJsonFiles(this.baseDir);
const records = await Promise.all( const records = await Promise.all(
files.map((file) => readJsonFile<ConversationRecord>(file)), files.map((file) => readJsonFile<SessionRecord>(file)),
); );
return records return records
.filter((record): record is ConversationRecord => Boolean(record)) .filter((record): record is SessionRecord => Boolean(record))
.filter( .filter(
(record) => (record) =>
record.actorKey === context.actorKey && record.actorKey === context.actorKey &&
@@ -115,26 +116,26 @@ export class ConversationStore {
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
} }
async remove(record: ConversationRecord) { async remove(record: SessionRecord) {
await removeFileIfExists(this.filePath(record.sessionScopeKey)); await removeFileIfExists(
this.filePath(record.sessionId),
);
} }
private filePath(sessionScopeKey: string) { private filePath(sessionId: string) {
return join(this.baseDir, `${sessionScopeKey}.json`); return join(this.baseDir, `${slugify(sessionId)}.json`);
} }
} }
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
const normalizeSessionId = (value?: string) => { const normalizeSessionId = (value?: string) => {
const normalized = value?.trim(); const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined; return normalized ? normalized.slice(0, 128) : undefined;
}; };
const normalizeConversationUpdates = ( const normalizeSessionUpdates = (
updates: Partial<Pick<ConversationRecord, "title" | "status">>, 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") { if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status; normalized.status = updates.status;
} }
@@ -36,24 +36,27 @@ export type SessionSearchHit = {
turnId: string; turnId: string;
}; };
type SessionHistoryContext = { type SessionTranscriptContext = {
actorKey: string; actorKey: string;
clientSessionId?: string; clientSessionId?: string;
projectKey: string; projectKey: string;
sessionId: 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>>(); 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() { async initialize() {
await ensureDirectory(this.baseDir); await ensureDirectory(this.baseDir);
} }
async appendTurn( async appendTurn(
context: SessionHistoryContext, context: SessionTranscriptContext,
turn: { turn: {
assistantMessage: string; assistantMessage: string;
toolCallCount: number; toolCallCount: number;
@@ -62,6 +65,7 @@ export class SessionHistoryStore {
) { ) {
const key = this.filePath(context); const key = this.filePath(context);
return this.serializeWrite(key, async () => { return this.serializeWrite(key, async () => {
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
const transcript = (await this.readTranscript(context)) ?? { const transcript = (await this.readTranscript(context)) ?? {
actorKey: context.actorKey, actorKey: context.actorKey,
clientSessionId: context.clientSessionId, clientSessionId: context.clientSessionId,
@@ -77,6 +81,20 @@ export class SessionHistoryStore {
} }
const timestamp = new Date().toISOString(); 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 = { const record: SessionTurnRecord = {
id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage), id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage),
assistantMessage, assistantMessage,
@@ -87,9 +105,9 @@ export class SessionHistoryStore {
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId; transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.sessionId = context.sessionId; transcript.sessionId = context.sessionId;
transcript.turns.push(record); 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 = 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; transcript.updatedAt = timestamp;
@@ -99,7 +117,7 @@ export class SessionHistoryStore {
} }
async getRecentTurns( async getRecentTurns(
context: SessionHistoryContext, context: SessionTranscriptContext,
limit: number, limit: number,
): Promise<SessionTurnRecord[]> { ): Promise<SessionTurnRecord[]> {
const transcript = await this.readTranscript(context); const transcript = await this.readTranscript(context);
@@ -110,12 +128,13 @@ export class SessionHistoryStore {
} }
async cloneThread( async cloneThread(
sourceContext: SessionHistoryContext, sourceContext: SessionTranscriptContext,
targetContext: SessionHistoryContext, targetContext: SessionTranscriptContext,
keepMessageCount: number, keepMessageCount: number,
) { ) {
const sourceTranscript = await this.readTranscript(sourceContext); const sourceTranscript = await this.readTranscript(sourceContext);
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
// 分叉会话只复制用户选择保留的上下文,后续新分支拥有独立 transcript 文件。
const nextTranscript: SessionTranscriptRecord = { const nextTranscript: SessionTranscriptRecord = {
actorKey: targetContext.actorKey, actorKey: targetContext.actorKey,
clientSessionId: targetContext.clientSessionId, clientSessionId: targetContext.clientSessionId,
@@ -129,11 +148,12 @@ export class SessionHistoryStore {
} }
async search( async search(
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">, context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
query: string, query: string,
maxResults = config.SESSION_SEARCH_MAX_RESULTS, maxResults = DEFAULT_SEARCH_MAX_RESULTS,
): Promise<SessionSearchHit[]> { ): 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) { if (!normalizedQuery) {
return []; return [];
} }
@@ -175,42 +195,11 @@ export class SessionHistoryStore {
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults)); return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
} }
private async readTranscript(context: SessionHistoryContext) { private async readTranscript(context: SessionTranscriptContext) {
const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context)); return 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 filePath(context: SessionHistoryContext) { private filePath(context: SessionTranscriptContext) {
return join( return join(
this.baseDir, this.baseDir,
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`, `${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 { config } from "../config.js";
import { import {
@@ -17,8 +18,14 @@ import {
} from "../utils/persistencePolicy.js"; } from "../utils/persistencePolicy.js";
const LEARNED_PATTERNS_MARKER = "## Learned Patterns"; const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
const SKILLS_ROOT_DIR = ".opencode/skills"; const ROOT_SKILL_ALIAS = "__root__";
const SKILLS_HISTORY_DIR = join(config.PERSISTENCE_HISTORY_DIR, "skills"); 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 = { export type SkillPatternRecord = {
id: string; id: string;
@@ -28,13 +35,19 @@ export type SkillPatternRecord = {
export class SkillStore { export class SkillStore {
private writeQueue: Promise<void> = Promise.resolve(); 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) { async list(skillPath: string) {
const normalizedSkillPath = normalizeSkillPath(skillPath); const normalizedSkillPath = normalizeSkillPath(skillPath);
if (!normalizedSkillPath) { if (!normalizedSkillPath) {
return null; return null;
} }
const target = this.skillFilePath(normalizedSkillPath); const target = this.skillFilePath(normalizedSkillPath);
const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath); const current =
(await readTextFile(target)) ?? defaultSkillDocument(normalizedSkillPath);
return { return {
references: await this.listReferenceFiles(normalizedSkillPath), references: await this.listReferenceFiles(normalizedSkillPath),
scripts: await this.listScriptFiles(normalizedSkillPath), scripts: await this.listScriptFiles(normalizedSkillPath),
@@ -55,7 +68,8 @@ export class SkillStore {
} }
return this.serializeWrite(async () => { return this.serializeWrite(async () => {
const target = this.skillFilePath(normalizedSkillPath); const target = this.skillFilePath(normalizedSkillPath);
const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath); const current =
(await readTextFile(target)) ?? defaultSkillDocument(normalizedSkillPath);
const existingPatterns = extractLearnedPatterns(current); const existingPatterns = extractLearnedPatterns(current);
if (existingPatterns.some((entry) => entry.content === sanitizedPattern)) { if (existingPatterns.some((entry) => entry.content === sanitizedPattern)) {
return { changed: false, detail: "pattern already existed", target }; return { changed: false, detail: "pattern already existed", target };
@@ -70,15 +84,58 @@ export class SkillStore {
`${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}`, `${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}`,
) )
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}\n`; : `${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, { await atomicWriteFileWithHistory(target, next, {
historyDir: SKILLS_HISTORY_DIR, backupDir: this.backupDir,
rootDir: SKILLS_ROOT_DIR, rootDir: this.rootDir,
}); });
return { changed: true, detail: "skill file updated", target }; 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) { async removePattern(skillPath: string, targetId: string) {
const normalizedSkillPath = normalizeSkillPath(skillPath); const normalizedSkillPath = normalizeSkillPath(skillPath);
if (!normalizedSkillPath) { if (!normalizedSkillPath) {
@@ -97,8 +154,8 @@ export class SkillStore {
} }
const next = rewriteLearnedPatterns(current, remaining); const next = rewriteLearnedPatterns(current, remaining);
await atomicWriteFileWithHistory(target, next, { await atomicWriteFileWithHistory(target, next, {
historyDir: SKILLS_HISTORY_DIR, backupDir: this.backupDir,
rootDir: SKILLS_ROOT_DIR, rootDir: this.rootDir,
}); });
return { changed: true, detail: "pattern removed", target }; 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 { changed: false, detail: "reference content rejected by persistence policy", target: "" };
} }
return this.serializeWrite(async () => { return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedReferencePath); const target = join(this.rootDir, normalizedSkillPath, normalizedReferencePath);
await ensureDirectory(dirname(target)); await ensureDirectory(dirname(target));
await atomicWriteFileWithHistory(target, `${sanitizedContent}\n`, { await atomicWriteFileWithHistory(target, `${sanitizedContent}\n`, {
historyDir: SKILLS_HISTORY_DIR, backupDir: this.backupDir,
rootDir: SKILLS_ROOT_DIR, rootDir: this.rootDir,
}); });
return { changed: true, detail: "reference written", target }; return { changed: true, detail: "reference written", target };
}); });
@@ -138,7 +195,7 @@ export class SkillStore {
return { changed: false, detail: "invalid reference file_path", target: "" }; return { changed: false, detail: "invalid reference file_path", target: "" };
} }
return this.serializeWrite(async () => { return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedReferencePath); const target = join(this.rootDir, normalizedSkillPath, normalizedReferencePath);
const previous = await readTextFile(target); const previous = await readTextFile(target);
if (previous === null) { if (previous === null) {
return { changed: false, detail: "reference not found", target }; 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 { changed: false, detail: "script content rejected by persistence policy", target: "" };
} }
return this.serializeWrite(async () => { return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath); const target = join(this.rootDir, normalizedSkillPath, normalizedScriptPath);
await ensureDirectory(dirname(target)); await ensureDirectory(dirname(target));
await atomicWriteFileWithHistory(target, sanitizedContent, { await atomicWriteFileWithHistory(target, sanitizedContent, {
historyDir: SKILLS_HISTORY_DIR, backupDir: this.backupDir,
rootDir: SKILLS_ROOT_DIR, rootDir: this.rootDir,
}); });
return { changed: true, detail: "script written", target }; return { changed: true, detail: "script written", target };
}); });
@@ -182,7 +239,7 @@ export class SkillStore {
return { changed: false, detail: "invalid script file_path", target: "" }; return { changed: false, detail: "invalid script file_path", target: "" };
} }
return this.serializeWrite(async () => { return this.serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath); const target = join(this.rootDir, normalizedSkillPath, normalizedScriptPath);
const previous = await readTextFile(target); const previous = await readTextFile(target);
if (previous === null) { if (previous === null) {
return { changed: false, detail: "script not found", target }; return { changed: false, detail: "script not found", target };
@@ -193,19 +250,29 @@ export class SkillStore {
} }
private async listReferenceFiles(skillPath: string) { 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); const files = await listFiles(referenceDir);
return files.map((file) => file.slice(referenceDir.length + 1)); return files.map((file) => file.slice(referenceDir.length + 1));
} }
private async listScriptFiles(skillPath: string) { 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); const files = await listFiles(scriptDir);
return files.map((file) => file.slice(scriptDir.length + 1)); return files.map((file) => file.slice(scriptDir.length + 1));
} }
private skillFilePath(skillPath: string) { 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>) { private async serializeWrite<T>(task: () => Promise<T>) {
@@ -220,6 +287,9 @@ export class SkillStore {
export const normalizeSkillPath = (rawSkillPath: string) => { export const normalizeSkillPath = (rawSkillPath: string) => {
const normalized = posix.normalize(rawSkillPath.trim().replace(/^\/+|\/+$/g, "")); const normalized = posix.normalize(rawSkillPath.trim().replace(/^\/+|\/+$/g, ""));
if (normalized === ROOT_SKILL_ALIAS) {
return ROOT_SKILL_ALIAS;
}
if (!normalized || normalized === "." || normalized.startsWith("..")) { if (!normalized || normalized === "." || normalized.startsWith("..")) {
return null; return null;
} }
@@ -330,6 +400,26 @@ const extractLearnedPatternsSection = (content: string) => {
return tail.slice(0, nextHeadingMatch?.index ?? tail.length); 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) => `--- const defaultLearnedSkill = (skillPath: string) => `---
name: tjwater-action-${skillPath name: tjwater-action-${skillPath
.split("/") .split("/")
@@ -338,7 +428,7 @@ name: tjwater-action-${skillPath
.replace(/[^a-z0-9._-]+/gi, "-") .replace(/[^a-z0-9._-]+/gi, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.slice(0, 120) || "generated-skill"} .slice(0, 120) || "generated-skill"}
description: 由 skill_manager 在线追加的高置信度可复用 workflow。 description: 由 skill_manager 在线维护的高置信度可复用 workflow。
version: 1.0.0 version: 1.0.0
--- ---
@@ -346,7 +436,10 @@ version: 1.0.0
## 简介 ## 简介
记录由 \`skill_manager\` 在线追加的高置信度 workflow 模式。 记录由 \`skill_manager\` 在线维护的高置信度 workflow 模式。
## Learned Patterns ## 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 { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path"; 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) => { export const atomicWriteFile = async (path: string, content: string) => {
await ensureDirectory(dirname(path)); await ensureDirectory(dirname(path));
const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`; const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.${randomUUID()}.tmp`;
await writeFile(tempPath, content, "utf8"); try {
await rename(tempPath, path); await writeFile(tempPath, content, "utf8");
await rename(tempPath, path);
} catch (error) {
await removeFileIfExists(tempPath);
throw error;
}
}; };
type HistoricalWriteOptions = { type HistoricalWriteOptions = {
afterWrite?: () => Promise<void> | void; afterWrite?: () => Promise<void> | void;
historyDir: string; backupDir: string;
rootDir: string; rootDir: string;
}; };
@@ -36,8 +41,8 @@ export const atomicWriteFileWithHistory = async (
let backupPath: string | null = null; let backupPath: string | null = null;
if (previous !== null) { if (previous !== null) {
// 仅在覆盖已有文件时保留历史版本,避免为首次创建产生空备份。 // 仅在覆盖已有文件时保留备份版本,避免为首次创建产生空备份。
backupPath = buildHistoryBackupPath(path, options); backupPath = buildBackupPath(path, options);
await atomicWriteFile(backupPath, previous); await atomicWriteFile(backupPath, previous);
} }
@@ -149,12 +154,6 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
export const toStableId = (...parts: string[]) => export const toStableId = (...parts: string[]) =>
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24); 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) => export const slugify = (value: string) =>
value value
.toLowerCase() .toLowerCase()
@@ -162,11 +161,11 @@ export const slugify = (value: string) =>
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.slice(0, 64) || "entry"; .slice(0, 64) || "entry";
const buildHistoryBackupPath = (path: string, options: HistoricalWriteOptions) => { const buildBackupPath = (path: string, options: HistoricalWriteOptions) => {
const relativePath = relative(options.rootDir, path); const relativePath = relative(options.rootDir, path);
const scopedPath = const scopedPath =
relativePath && !relativePath.startsWith("..") ? relativePath : basename(path); relativePath && !relativePath.startsWith("..") ? relativePath : basename(path);
// 备份目录尽量复用原始相对路径,便于按业务目录回看历史。 // 备份目录尽量复用原始相对路径,便于按业务目录回看历史。
const backupName = `${basename(path)}.${Date.now().toString(36)}.bak`; 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 { 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -26,83 +26,50 @@ describe("ResultReferenceResolver", () => {
await rm(tempDir, { force: true, recursive: true }); 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({ const record = await resolver.register({
actorKey: "actor-1", actorKey: "actor-1",
clientSessionId: "client-1", clientSessionId: "client-1",
data: [{ id: "J1" }, { id: "J2" }], data: {
kind: RESULT_REFERENCE_KIND.dynamicHttpResult, node_area_map: {
J1: "DMA-1",
J2: "DMA-2",
},
},
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
projectId: "project-1", projectId: "project-1",
projectKey: "project-key-1", projectKey: "project-key-1",
schemaVersion: 1, schemaVersion: 1,
sessionId: "session-1", sessionId: "session-1",
source: RESULT_REFERENCE_SOURCE.dynamicHttp, source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-1", 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.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, record.resultRef,
{ {
actorKey: "actor-1", actorKey: "actor-1",
projectId: "project-1", projectId: "project-1",
}, },
{
maxItems: 1,
},
); );
expect(result).not.toBeNull(); 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?.schema_version).toBe(1);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp); expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
expect(result?.data).toEqual([{ id: "J1" }]); expect(result?.data).toEqual({
}); node_area_map: {
J1: "DMA-1",
it("keeps legacy result refs readable while defaulting metadata", async () => { J2: "DMA-2",
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(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"; const malformedRef = "res-bbbbbbbbbbbbbbbb";
await writeFile( await writeFile(
join(tempDir, `${malformedRef}.json`), join(tempDir, `${malformedRef}.json`),
@@ -152,18 +119,6 @@ describe("ResultReferenceResolver", () => {
traceId: "trace-2", 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, { const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
actorKey: "actor-other", actorKey: "actor-other",
projectId: "project-2", projectId: "project-2",
@@ -209,12 +164,12 @@ describe("ResultReferenceResolver", () => {
projectId: "project-3", projectId: "project-3",
projectKey: "project-key-3", projectKey: "project-key-3",
sessionId: "session-3", sessionId: "session-3",
source: RESULT_REFERENCE_SOURCE.migration, source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-3", traceId: "trace-3",
}); });
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); 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( const result = await resolver.getFullAuthorized(
record.resultRef, 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