1 Commits
Author SHA1 Message Date
jiang 5ac50bfeaa 切换到使用pg数据库 2026-05-28 18:22:39 +08:00
120 changed files with 6455 additions and 3495 deletions
+1
View File
@@ -1,4 +1,5 @@
.git .git
.gitignore
node_modules node_modules
.opencode/node_modules .opencode/node_modules
.local.env .local.env
-5
View File
@@ -18,11 +18,6 @@ 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 }}
+124
View File
@@ -0,0 +1,124 @@
# Copilot Instructions for TJWaterAgent
## Overview
- TJWaterAgent is a Bun + TypeScript backend service.
- Main entrypoint: `src/server.ts`
- Chat API base path: `/api/v1/agent/chat`
- Persistent storage is **PostgreSQL-first**. Historical file storage only remains for one-time migration and external result payload files.
## Core identity model
- `sessionId` = durable chat thread identifier exposed to clients and used for persistence.
- `runtimeSessionId` = temporary opencode runtime execution identifier for a single active request.
- Do **not** reintroduce `conversationId` / `clientSessionId` naming for the durable thread id. Use `sessionId`.
## Persistence design
Canonical PostgreSQL tables:
- `conversations`
- `conversation_states`
- `conversation_turns`
- `runtime_sessions`
- `learning_states`
- `result_refs`
- `memories`
### conversations
Stores durable thread metadata:
- `session_id`
- actor / owner / project fields
- `parent_session_id`
- `title`
- `status`
- streaming lifecycle fields:
- `is_streaming`
- `active_runtime_session_id`
- `streaming_started_at`
`is_streaming` is persisted so the frontend can recover streaming state after refresh. Stream cleanup must be guarded by `active_runtime_session_id` so an old stream cannot clear a newer one.
### conversation_states
Stores frontend-facing session UI state:
- `messages`
- `branch_groups`
- `is_title_manually_edited`
### conversation_turns
Stores transcript turns as one row per turn:
- keyed by `turn_id`
- linked by `session_id`
- ordered by deterministic `turn_index`
Do not fall back to legacy transcript files in runtime code.
### runtime_sessions
Stores runtime binding context for internal tools and learning review/gate flows:
- `runtime_session_id`
- `session_id`
- actor / project / trace fields
- `allow_learning_write`
- `learning_mode`
- `released_at`
This replaces the old alias-based `tool_session_contexts` model.
### result_refs
`result_refs` is **metadata-only in PostgreSQL**:
- metadata lives in PG
- payload body lives outside PG via `payload_path` or future `object_key`
Most payloads are large, so do not inline them into PostgreSQL.
### memories
Durable memory is stored in PG, not markdown files.
## Runtime flow
1. Client sends/loads a `session_id`.
2. `ChatSessionBridge` binds that durable `sessionId` to a fresh `runtimeSessionId`.
3. Internal tools call back into the server using `runtimeSessionId`.
4. Transcript, memory, learning state, and result refs persist against `sessionId`.
5. Stream start sets `conversations.is_streaming = true`; stream completion clears it only if the runtime id still matches.
## Important files
- `src/server.ts` - bootstrap, internal tool endpoints, app wiring
- `src/chat/sessionBridge.ts` - `sessionId` vs `runtimeSessionId` binding
- `src/db/index.ts` - PostgreSQL bootstrap and schema migration/cleanup
- `src/conversations/store.ts` - durable session metadata + streaming state
- `src/conversations/stateStore.ts` - UI state persistence
- `src/history/store.ts` - transcript persistence
- `src/session/runtimeSessionStore.ts` - runtime session persistence
- `src/results/store.ts` - metadata-only result refs + external payload files
- `src/results/resolver.ts` - result ref normalization and retrieval
- `src/learning/orchestrator.ts` / `src/learning/stateStore.ts` - learning review pipeline
- `src/memory/store.ts` - persistent memory store
- `scripts/migrate-file-storage-to-postgres.ts` - one-time migration from legacy file storage
## Compatibility rules
- Runtime code should target the canonical schema only.
- Legacy column/table handling belongs only in `src/db/index.ts` bootstrap migration or `scripts/migrate-file-storage-to-postgres.ts`.
- Do not add back file-based runtime persistence or compatibility reads unless a migration explicitly requires it.
## Validation
Useful commands:
```bash
bun run check
PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... PGDATABASE=agent PGSSLMODE=disable bun test tests/conversations/store.test.ts tests/history/store.test.ts tests/session/runtimeSessionStore.test.ts tests/results/store.test.ts tests/routes/chatSession.test.ts
```
-5
View File
@@ -1,12 +1,7 @@
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
+26 -88
View File
@@ -4,93 +4,31 @@ mode: primary
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-pro
temperature: 0.2 temperature: 0.2
--- ---
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确 您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题
## 工作流生命周期 按照以下规则操作:
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的: 1. 使用 `.opencode/skills/tjwater-skills-root-index` 作为 TJWater 技能树,仅在任务需要该领域知识时加载特定技能。对分析类问题,优先检查 `workflow` 域下是否已有固定工作流(例如 `bottleneck-analysis`);只有在 workflow 不存在、信息不足或需要补充原子能力时,才继续查询其他 API / action skills。
``` 2. 当您需要后端数据用于推理、总结、诊断或分析时,优先使用 `dynamic_http_call`
初次遇到问题 → tjwater_cli + Python 脚本拼装 → 验证有效 → 3. 当用户主要需要 UI 操作或可视化时,优先使用前端工具(`locate_features``view_history``view_scada``show_chart`)。
→ 立即调用 skill_manager 保存到 skills/workflow/<name>/ 4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。
→ 下次遇到同类问题直接加载该 skill,按既定步骤执行 5. 保持回复准确、简洁,对供水网络用户在操作上有用。
``` 6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。
7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。
## 任务执行决策 8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。默认遵循 **workflow-first**:先查固定工作流 skill,再按需回落到原子 API skills。
9.`dynamic_http_call` 返回 `result_mode = referenced``result_ref` 时,说明当前只拿到了预览;如果后续推理仍需要完整结果,必须调用 `fetch_result_ref` 回读,不能把 preview 当成完整数据。
收到用户请求时,按以下顺序决策: 10.`render_ref``result_ref` 或其他引用型结果,默认只使用 preview、摘要、局部字段,或直接把引用传给前端工具;如果引用仅用于渲染/展示(例如 `render_junctions`),直接传引用,不要先读取完整内容再重组。
11. 对任何可能很大的引用文件、结果文件或普通大文件,禁止完整读取;优先使用预览、分页、截断、按字段读取、按片段读取或采样读取。只有在没有其他办法且当前推理确实必须依赖完整内容时,才允许读取完整内容,并先明确说明必要性。
1. **查已有工作流** — 检查 `skills/workflow/` 下是否存在匹配的 SKILL.md,有则加载并按步骤执行 12. 不得通过 sub-agent、并行代理或任何间接方式,去读取引用文件或大文件的完整内容;主 agent 与其调用链中的其他代理都必须遵守同样限制。
2. **历史参考** — 用 `session_search` 检索历史相似案例,避免重复试错 13. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
3. **从零拼装** — 无匹配工作流时,自行组合 `tjwater_cli` 命令 + Python 脚本完成 - `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实
4. **完成后复盘** — 判断当前流程是否稳定、可复用,决定是否沉淀为 workflow - `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置
14. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。
## 工具选择 15. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager``skill_manager`
16. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。
| 场景 | 工具 | 17. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。
|------|------| 18. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` | 19. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
| 发现可用命令 | `tjwater_cli(command="help")` | 20. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
| UI 操作 / 可视化 | `locate_features``view_scada``show_chart``render_junctions``view_history``apply_layer_style` | 21. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns``references/``scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
| 持久化渲染数据 | `store_render_ref``render_junctions` | 22. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
## 执行约束
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
@@ -0,0 +1,34 @@
---
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
@@ -0,0 +1,15 @@
---
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`
@@ -0,0 +1,14 @@
---
name: tjwater-scenario-analytics-scada-operations
description: 负责 SCADA 设备与数据操作。
version: 3.0.0
---
# scada-operations Scenario Skill
## 简介
负责 SCADA 设备与数据操作。
## 子模块索引 (渐进式引导)
- **scada**: 见 `./scada/SKILL.md`
@@ -0,0 +1,107 @@
---
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' 清洗所有设备 |
@@ -0,0 +1,18 @@
---
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`
@@ -0,0 +1,43 @@
---
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 |
@@ -0,0 +1,47 @@
---
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 |
@@ -0,0 +1,46 @@
---
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为全部 |
@@ -0,0 +1,34 @@
---
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/` | 查询管网中管道的地理位置和风险相关几何数据,适合地图可视化 |
@@ -0,0 +1,74 @@
---
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
@@ -0,0 +1,17 @@
---
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`
@@ -0,0 +1,19 @@
---
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`
@@ -0,0 +1,36 @@
---
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/` | 设置/更新规则控制的属性 |
@@ -0,0 +1,37 @@
---
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/` | 设置/更新曲线的属性(控制点坐标等) |
@@ -0,0 +1,43 @@
---
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/` | 设置全局能耗参数 |
@@ -0,0 +1,37 @@
---
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/` | 设置/更新时间模式的属性(乘数序列) |
@@ -0,0 +1,59 @@
---
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*/` | 设置发射器属性(流量系数等) |
@@ -0,0 +1,45 @@
---
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/` | 删除地图文字标注 |
@@ -0,0 +1,16 @@
---
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`
@@ -0,0 +1,32 @@
---
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,延续会话 |
@@ -0,0 +1,36 @@
---
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` | 停用指定用户账号(禁止登录,管理员操作) |
@@ -0,0 +1,30 @@
---
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) |
@@ -0,0 +1,24 @@
---
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`
@@ -0,0 +1,36 @@
---
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/` | 将指定总需水量按比例分配到整个管网的所有节点 |
@@ -0,0 +1,62 @@
---
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/` | 删除节点(节点/水箱/水库) |
@@ -0,0 +1,36 @@
---
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)内的管网节点和管线 |
@@ -0,0 +1,52 @@
---
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*/` | 设置节点某个具体属性(坐标、标高、需水量等) |
@@ -0,0 +1,54 @@
---
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*/` | 设置管道某个具体属性(管径、长度、状态等) |
@@ -0,0 +1,42 @@
---
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/` | 设置水泵终止节点 |
@@ -0,0 +1,69 @@
---
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/` | 计算服务区,返回全部时间步结果 |
@@ -0,0 +1,48 @@
---
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*/` | 设置水库某个具体属性(水头、模式、坐标等) |
@@ -0,0 +1,32 @@
---
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/` | 为管网元素设置/更新标签(支持自定义键值对属性) |
@@ -0,0 +1,61 @@
---
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*/` | 设置水箱某个具体属性(坐标、标高、直径、水位等) |
@@ -0,0 +1,50 @@
---
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*/` | 设置阀门某个具体属性(类型、设定值、管径等) |
@@ -0,0 +1,19 @@
---
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`
@@ -0,0 +1,32 @@
---
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/` | 设置或更新一个自定义扩展数据键值对(可用于存储任意业务自定义信息) |
@@ -0,0 +1,36 @@
---
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/` | 测试字典类型请求体的接口,用于开发调试 |
@@ -0,0 +1,32 @@
---
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` | 获取当前项目中所有可用的水力计算方案列表 |
@@ -0,0 +1,55 @@
---
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 格式 |
@@ -0,0 +1,30 @@
---
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)的详细属性和配置 |
@@ -0,0 +1,54 @@
---
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
@@ -0,0 +1,14 @@
---
name: tjwater-domain-data
description: 负责时序数据访问与读写能力。
version: 3.0.0
---
# Data Domain Skill
## 简介
负责时序数据访问与读写能力。
## 子模块索引 (渐进式引导)
- **timeseries-access**: 见 `./timeseries-access/SKILL.md`
@@ -0,0 +1,16 @@
---
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`
@@ -0,0 +1,34 @@
---
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` | 在指定时刻对整个管网的管道健康状态进行预测,返回各管道健康评分 |
@@ -0,0 +1,44 @@
---
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、类型和时间点查询其实时模拟值 |
@@ -0,0 +1,48 @@
---
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` | 按方案、时间点和属性名查询全网在指定方案下的模拟值 |
+206
View File
@@ -0,0 +1,206 @@
# 示例(基于 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`
若接口要求 JSON 请求体,可这样调用:
```json
{
"path": "/api/v1/runproject/simulate",
"method": "POST",
"arguments": {
"network": "demo-network"
},
"body": {
"duration_minutes": 15
}
}
```
`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
@@ -0,0 +1,14 @@
---
name: tjwater-domain-platform
description: 负责治理、审计、缓存与元数据能力。
version: 3.0.0
---
# Platform Domain Skill
## 简介
负责治理、审计、缓存与元数据能力。
## 子模块索引 (渐进式引导)
- **governance-observability**: 见 `./governance-observability/SKILL.md`
@@ -0,0 +1,16 @@
---
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`
@@ -0,0 +1,30 @@
---
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` | 查询当前登录用户自己的操作日志,支持按操作类型和时间范围过滤 |
@@ -0,0 +1,32 @@
---
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 中所有缓存数据(慎用,会影响所有会话) |
@@ -0,0 +1,30 @@
---
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` | 列出当前登录用户有权限访问的所有项目信息 |
+126
View File
@@ -0,0 +1,126 @@
# 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 参数(列表会转为逗号拼接)。
- `body` 会作为 JSON request body 透传;`GET` 不支持传 `body`
## 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
@@ -0,0 +1,19 @@
---
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`
@@ -0,0 +1,121 @@
---
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
@@ -1,94 +0,0 @@
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}图层应用样式。`;
},
});
+51
View File
@@ -0,0 +1,51 @@
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 后端 API。支持 query 参数,且在 POST/PUT/PATCH/DELETE 时可选传递 JSON body。",
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. Encoded into URL query string."),
body: tool.schema
.unknown()
.optional()
.describe("Optional JSON body for POST/PUT/PATCH/DELETE requests."),
},
async execute(args, context) {
// 工具本身不直接持有用户 token;通过 runtimeSessionId 回调 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({
runtimeSessionId: context.sessionID,
reason: args.reason,
path: args.path,
method: args.method,
arguments: args.arguments,
body: args.body,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+41
View File
@@ -0,0 +1,41 @@
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:
"回读由 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) {
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({
runtimeSessionId: context.sessionID,
result_ref: args.result_ref,
max_items: args.max_items,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+2 -6
View File
@@ -5,12 +5,8 @@ export default tool({
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe( .describe("Why this map positioning action is needed for the user request."),
"Why this map positioning action is needed for the user request.", ids: tool.schema.array(tool.schema.string()).describe("Feature ids to locate."),
),
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."),
+14 -39
View File
@@ -1,16 +1,17 @@
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 { import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const initializePromise = memoryStore.initialize(); const runtimeSessionStore = new RuntimeSessionStore();
const initializePromise = Promise.all([
memoryStore.initialize(),
runtimeSessionStore.initialize(),
]);
export default tool({ export default tool({
description: description:
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。add 前必须先对同 scope 执行 list 并阅读现有记忆,再决定 add、replace 或 remove;不要跳过读取直接新增。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。", "管理长期有效的用户偏好或项目事实。支持 add/list/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"])
@@ -36,7 +37,7 @@ export default tool({
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; await initializePromise;
const sessionContext = getRuntimeSessionContext(context.sessionID); const sessionContext = await runtimeSessionStore.read(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}`);
} }
@@ -66,14 +67,6 @@ export default tool({
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",
@@ -85,18 +78,9 @@ export default tool({
} }
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.sessionId,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}); });
@@ -112,24 +96,19 @@ export default tool({
ok: true, ok: true,
kind: "memory", kind: "memory",
decision: result.changed ? "accepted" : "deduped", decision: result.changed ? "accepted" : "deduped",
detail: result.detail, detail: result.changed ? "memory stored" : "memory already existed",
entry: result.entry, entry: result.entry,
target: scope, target: scope,
}); });
} }
if (args.action === "replace") { if (args.action === "replace") {
const result = await memoryStore.replace( const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
scope,
scopeKey,
args.target_id ?? "",
{
content: args.content ?? "", content: args.content ?? "",
sessionId: sessionContext.clientSessionId, sessionId: sessionContext.sessionId,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}, });
);
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
@@ -139,11 +118,7 @@ export default tool({
}); });
} }
const result = await memoryStore.remove( const result = await memoryStore.remove(scope, scopeKey, args.target_id ?? "");
scope,
scopeKey,
args.target_id ?? "",
);
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "memory", kind: "memory",
+2 -4
View File
@@ -6,13 +6,11 @@ export default tool({
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe( .describe("Why this junction rendering action is needed for the user request."),
"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 并渲染。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 并渲染,不需要先用 fetch_result_ref 提取完整数据。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() {
+3 -6
View File
@@ -22,9 +22,7 @@ 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) {
const response = await fetch( const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
`${internalBaseUrl}/internal/tools/session-search`,
{
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -33,10 +31,9 @@ export default tool({
body: JSON.stringify({ body: JSON.stringify({
max_results: args.max_results, max_results: args.max_results,
query: args.query, query: args.query,
session_id: context.sessionID, runtimeSessionId: context.sessionID,
}), }),
}, });
);
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
throw new Error(text); throw new Error(text);
+2 -8
View File
@@ -21,14 +21,8 @@ export default tool({
}), }),
) )
.describe("Series data."), .describe("Series data."),
x_axis_name: tool.schema x_axis_name: tool.schema.string().optional().describe("X-axis display name."),
.string() y_axis_name: tool.schema.string().optional().describe("Y-axis display name."),
.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 后直接渲染,不再二次请求后端。
+16 -55
View File
@@ -1,33 +1,19 @@
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 { import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
getRuntimeSessionContext,
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
type ToolContextReader = { const runtimeSessionStore = new RuntimeSessionStore();
read(sessionId: string): RuntimeSessionContext | null; const initializePromise = runtimeSessionStore.initialize();
}; const skillStore = new SkillStore();
const runtimeContextReader: ToolContextReader = { export default tool({
read: getRuntimeSessionContext,
};
export const createSkillManagerTool = (
skillStore = new SkillStore(),
toolContextStore: ToolContextReader = runtimeContextReader,
initializePromise: Promise<unknown> = Promise.resolve(),
) =>
tool({
description: description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。", "维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: { args: {
action: tool.schema action: tool.schema
.enum([ .enum([
"list", "list",
"write_skill",
"remove_skill",
"append_pattern", "append_pattern",
"remove_pattern", "remove_pattern",
"write_reference", "write_reference",
@@ -44,7 +30,7 @@ export const createSkillManagerTool = (
skill_path: tool.schema skill_path: tool.schema
.string() .string()
.describe( .describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.", "Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta.",
), ),
pattern: tool.schema pattern: tool.schema
.string() .string()
@@ -57,26 +43,19 @@ export const createSkillManagerTool = (
file_path: tool.schema file_path: tool.schema
.string() .string()
.optional() .optional()
.describe( .describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
),
content: tool.schema content: tool.schema
.string() .string()
.optional() .optional()
.describe( .describe("Asset content used by write_reference or write_script."),
"Content used by write_skill, write_reference, or write_script.",
),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; await initializePromise;
const sessionContext = toolContextStore.read(context.sessionID); const sessionContext = await runtimeSessionStore.read(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}`);
} }
if ( if (sessionContext.allowLearningWrite === false && args.action !== "list") {
sessionContext.allowLearningWrite === false &&
args.action !== "list"
) {
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
kind: "skill", kind: "skill",
@@ -105,20 +84,10 @@ export const createSkillManagerTool = (
} }
const result = const result =
args.action === "write_skill" args.action === "append_pattern"
? await skillStore.writeSkill(args.skill_path, args.content ?? "") ? await skillStore.appendPattern(args.skill_path, args.pattern ?? "")
: 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" : args.action === "remove_pattern"
? await skillStore.removePattern( ? await skillStore.removePattern(args.skill_path, args.target_id ?? "")
args.skill_path,
args.target_id ?? "",
)
: args.action === "write_reference" : args.action === "write_reference"
? await skillStore.writeReference( ? await skillStore.writeReference(
args.skill_path, args.skill_path,
@@ -126,20 +95,14 @@ export const createSkillManagerTool = (
args.content ?? "", args.content ?? "",
) )
: args.action === "remove_reference" : args.action === "remove_reference"
? await skillStore.removeReference( ? await skillStore.removeReference(args.skill_path, args.file_path ?? "")
args.skill_path,
args.file_path ?? "",
)
: args.action === "write_script" : args.action === "write_script"
? await skillStore.writeScript( ? await skillStore.writeScript(
args.skill_path, args.skill_path,
args.file_path ?? "", args.file_path ?? "",
args.content ?? "", args.content ?? "",
) )
: await skillStore.removeScript( : await skillStore.removeScript(args.skill_path, args.file_path ?? "");
args.skill_path,
args.file_path ?? "",
);
return JSON.stringify({ return JSON.stringify({
ok: true, ok: true,
@@ -150,5 +113,3 @@ export const createSkillManagerTool = (
}); });
}, },
}); });
export default createSkillManagerTool();
+5 -11
View File
@@ -1,7 +1,6 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
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 ?? "";
export default tool({ export default tool({
@@ -10,9 +9,7 @@ export default tool({
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe( .describe("Why this local render payload should be persisted as a render_ref."),
"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,20 +17,17 @@ export default tool({
), ),
}, },
async execute(args, context) { async execute(args, context) {
const response = await fetch( const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: context.sessionID, runtimeSessionId: context.sessionID,
file_path: args.file_path, file_path: args.file_path,
}), }),
}, });
);
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
-48
View File
@@ -1,48 +0,0 @@
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;
},
});
+3 -11
View File
@@ -5,23 +5,15 @@ export default tool({
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe( .describe("Why this history panel should be opened for the current task."),
"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 start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."),
.string() end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."),
.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 参数完成。
+6 -11
View File
@@ -10,18 +10,13 @@ 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 device_id: tool.schema.string().optional().describe("Single SCADA device id."),
.string() feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.optional() .optional()
.describe("Single SCADA device id."), .describe("Legacy [id, type] pairs."),
start_time: tool.schema start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."),
.string() end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."),
.optional()
.describe("Optional ISO8601 start time."),
end_time: tool.schema
.string()
.optional()
.describe("Optional ISO8601 end time."),
}, },
async execute() { async execute() {
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。 // SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。
+3 -5
View File
@@ -15,7 +15,6 @@ 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 && \
@@ -52,9 +51,7 @@ RUN bun install --frozen-lockfile
FROM base AS build FROM base AS build
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY tsconfig.json opencode.json README.md ./
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
@@ -69,7 +66,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 .gitignore ./ COPY tsconfig.json opencode.json ./
COPY src ./src COPY src ./src
COPY .opencode ./.opencode COPY .opencode ./.opencode
@@ -77,6 +74,7 @@ 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"]
+12 -23
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/tjwater_cli.ts` 提供内部回调接口。 6. 为 `.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。
7. 代理调用真实 TJWater 后端 API。 7. 代理调用真实 TJWater 后端 API。
当前 Agent API 的主入口: 当前 Agent API 的主入口:
@@ -84,45 +84,34 @@ src/
```text ```text
.opencode/tools/ .opencode/tools/
tjwater_cli.ts dynamic_http_call.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 可以调用的自定义工具。
`tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令 `dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端。`arguments` 会编码为 query 参数;`body` 可用于 `POST/PUT/PATCH/DELETE` 的 JSON 请求体
`store_render_ref.ts` 用于把大型 junction 渲染 payload 存成 `render_ref`,再由 `render_junctions.ts` 交给前端回读并渲染 前端类工具如 `locate_features``view_history``view_scada``show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具
前端类工具如 `locate_features``view_history``view_scada``show_chart``render_junctions``apply_layer_style` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
### skills ### skills
```text ```text
.opencode/skills/ .opencode/skills/tjwater-skills-root-index/
SKILL.md SKILL.md
examples.md ai/
runbook.md analytics/
tjwater-cli/ ← tjwater-cli 可执行文件 business/
workflow/ ← 可复用分析工作流 data/
SKILL.md platform/
simulation-diagnosis/
bottleneck-analysis/
source-service-area-analysis/
``` ```
Skills 仅保留可复用的多步工作流。Agent 通过 `tjwater-cli help` 自行发现原子命令,无需逐接口技能树 这里保存 TJWater 技能树,并保持树结构,符合渐进式披露设计
agent 加载技能树时按需取用对应 workflow skill agent 需要某个领域知识时再按需加载对应 skill,不把整棵技能树作为 always-loaded prompt 一次性注入
## 依赖边界 ## 依赖边界
+30
View File
@@ -6,9 +6,11 @@
"name": "tjwater-agent", "name": "tjwater-agent",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.14.29", "@opencode-ai/sdk": "^1.14.29",
"@types/pg": "^8.20.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"pg": "^8.21.0",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"zod": "^3.25.76", "zod": "^3.25.76",
@@ -41,6 +43,8 @@
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
@@ -181,6 +185,22 @@
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
"pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
@@ -189,6 +209,14 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -259,6 +287,8 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+3
View File
@@ -11,15 +11,18 @@
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
"migrate:storage": "bun scripts/migrate-file-storage-to-postgres.ts",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
"start": "bun src/server.ts", "start": "bun src/server.ts",
"start:prod": "bun run check && bun src/server.ts" "start:prod": "bun run check && bun src/server.ts"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.14.29", "@opencode-ai/sdk": "^1.14.29",
"@types/pg": "^8.20.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"pg": "^8.21.0",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"zod": "^3.25.76" "zod": "^3.25.76"
+578
View File
@@ -0,0 +1,578 @@
import { readdir, readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import { config } from "../src/config.js";
import { AgentDatabase } from "../src/db/index.js";
import {
RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
RESULT_REF_PATTERN,
type ResultPreview,
} from "../src/results/store.js";
import { sanitizePersistentDocument, sanitizePersistentLine } from "../src/utils/persistencePolicy.js";
import { toProjectKey, toStableId } from "../src/utils/fileStore.js";
type JsonRecord = Record<string, unknown>;
const db = new AgentDatabase();
const runtimeConversationMap = new Map<string, string>();
const counters = {
conversations: 0,
states: 0,
turns: 0,
runtimeSessions: 0,
learningStates: 0,
resultRefs: 0,
memories: 0,
skippedResultRefs: 0,
};
await db.initialize();
await migrateConversations();
await migrateConversationStates();
await migrateConversationTurns();
await migrateRuntimeSessions();
await migrateLearningStates();
await migrateResultRefs();
await migrateMemories();
await db.close();
console.log(JSON.stringify(counters, null, 2));
async function migrateConversations() {
for (const filePath of await listFiles(config.CONVERSATION_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.sessionId !== "string" || typeof row.actorKey !== "string") {
continue;
}
await upsertConversation({
sessionId: row.sessionId,
actorKey: row.actorKey,
ownerUserId: stringOrUndefined(row.ownerUserId),
projectId: stringOrUndefined(row.projectId),
projectKey:
stringOrUndefined(row.projectKey) ??
toProjectKey(stringOrUndefined(row.projectId)),
parentSessionId: stringOrUndefined(row.parentSessionId),
title: stringOrUndefined(row.title),
status: row.status === "archived" ? "archived" : "active",
createdAt: stringOrUndefined(row.createdAt),
updatedAt: stringOrUndefined(row.updatedAt),
});
counters.conversations += 1;
}
}
async function migrateConversationStates() {
for (const filePath of await listFiles(config.CONVERSATION_STATE_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.sessionId !== "string") {
continue;
}
await db.query(
`
INSERT INTO ${db.table("conversation_states")} (
session_id,
is_title_manually_edited,
messages,
branch_groups
)
VALUES ($1, $2, $3::jsonb, $4::jsonb)
ON CONFLICT (session_id)
DO UPDATE SET
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
messages = EXCLUDED.messages,
branch_groups = EXCLUDED.branch_groups
`,
[
row.sessionId,
Boolean(row.isTitleManuallyEdited),
JSON.stringify(Array.isArray(row.messages) ? row.messages : []),
JSON.stringify(Array.isArray(row.branchGroups) ? row.branchGroups : []),
],
);
counters.states += 1;
}
}
async function migrateConversationTurns() {
for (const filePath of await listFiles(config.SESSION_HISTORY_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.actorKey !== "string" || typeof row.projectKey !== "string") {
continue;
}
const sessionId =
stringOrUndefined(row.clientSessionId) ?? stringOrUndefined(row.sessionId);
if (!sessionId) {
continue;
}
const turns = Array.isArray(row.turns) ? row.turns : [];
const lastTimestamp = stringOrUndefined(row.updatedAt);
await upsertConversation({
sessionId,
actorKey: row.actorKey,
projectKey: row.projectKey,
projectId: undefined,
createdAt:
turns.length > 0 && isRecord(turns[0]) ? stringOrUndefined(turns[0].timestamp) : undefined,
updatedAt:
lastTimestamp ??
(turns.length > 0 && isRecord(turns[turns.length - 1])
? stringOrUndefined(turns[turns.length - 1].timestamp)
: undefined),
});
for (const [turnIndex, turn] of turns.entries()) {
if (!isRecord(turn)) {
continue;
}
const userMessage = sanitizePersistentDocument(String(turn.userMessage ?? ""), 4000);
const assistantMessage = sanitizePersistentDocument(
String(turn.assistantMessage ?? ""),
4000,
);
if (!userMessage || !assistantMessage) {
continue;
}
const timestamp = stringOrUndefined(turn.timestamp) ?? new Date().toISOString();
await db.query(
`
INSERT INTO ${db.table("conversation_turns")} (
turn_id,
session_id,
turn_index,
actor_key,
project_key,
user_message,
assistant_message,
tool_call_count,
turn_timestamp
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (turn_id) DO NOTHING
`,
[
typeof turn.id === "string"
? turn.id
: toStableId(sessionId, String(turnIndex), timestamp, userMessage, assistantMessage),
sessionId,
turnIndex,
row.actorKey,
row.projectKey,
userMessage,
assistantMessage,
Math.max(0, numberOrZero(turn.toolCallCount)),
timestamp,
],
);
counters.turns += 1;
}
}
}
async function migrateRuntimeSessions() {
const seen = new Set<string>();
for (const filePath of await listFiles(config.SESSION_CONTEXT_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (
!row ||
typeof row.sessionId !== "string" ||
typeof row.clientSessionId !== "string" ||
typeof row.actorKey !== "string" ||
typeof row.projectKey !== "string"
) {
continue;
}
if (seen.has(row.sessionId)) {
continue;
}
seen.add(row.sessionId);
runtimeConversationMap.set(row.sessionId, row.clientSessionId);
await upsertConversation({
sessionId: row.clientSessionId,
actorKey: row.actorKey,
projectId: stringOrUndefined(row.projectId),
projectKey: row.projectKey,
});
await db.query(
`
INSERT INTO ${db.table("runtime_sessions")} (
runtime_session_id,
session_id,
actor_key,
allow_learning_write,
learning_mode,
project_id,
project_key,
trace_id,
released_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (runtime_session_id)
DO UPDATE SET
session_id = EXCLUDED.session_id,
actor_key = EXCLUDED.actor_key,
allow_learning_write = EXCLUDED.allow_learning_write,
learning_mode = EXCLUDED.learning_mode,
project_id = EXCLUDED.project_id,
project_key = EXCLUDED.project_key,
trace_id = EXCLUDED.trace_id,
released_at = NOW()
`,
[
row.sessionId,
row.clientSessionId,
row.actorKey,
Boolean(row.allowLearningWrite),
row.learningMode === "review" ? "review" : row.learningMode === "interactive" ? "interactive" : null,
stringOrUndefined(row.projectId) ?? null,
row.projectKey,
typeof row.traceId === "string" ? row.traceId : `trace-${row.sessionId}`,
],
);
counters.runtimeSessions += 1;
}
}
async function migrateLearningStates() {
for (const filePath of await listFiles(config.LEARNING_STATE_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.sessionId !== "string") {
continue;
}
const sessionId = runtimeConversationMap.get(row.sessionId) ?? row.sessionId;
const existingConversation = await db.query(
`SELECT 1 FROM ${db.table("conversations")} WHERE session_id = $1 LIMIT 1`,
[sessionId],
);
if (existingConversation.rowCount === 0) {
continue;
}
await db.query(
`
INSERT INTO ${db.table("learning_states")} (
session_id,
last_gated_turn,
last_reviewed_turn,
pending_review
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (session_id)
DO UPDATE SET
last_gated_turn = EXCLUDED.last_gated_turn,
last_reviewed_turn = EXCLUDED.last_reviewed_turn,
pending_review = EXCLUDED.pending_review
`,
[
sessionId,
numberOrZero(row.lastGatedTurn),
numberOrZero(row.lastReviewedTurn),
Boolean(row.pendingReview),
],
);
counters.learningStates += 1;
}
}
async function migrateResultRefs() {
for (const filePath of await listFiles(config.RESULT_REF_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row) {
continue;
}
const resultRef = normalizeResultRef(stringOrUndefined(row.resultRef) ?? basename(filePath, extname(filePath)));
const actorKey = stringOrUndefined(row.actorKey);
const sessionId =
stringOrUndefined(row.clientSessionId) ?? stringOrUndefined(row.sessionId);
if (!resultRef || !actorKey || !sessionId) {
counters.skippedResultRefs += 1;
continue;
}
const projectId = stringOrUndefined(row.projectId);
const projectKey = stringOrUndefined(row.projectKey) ?? toProjectKey(projectId);
await upsertConversation({
sessionId,
actorKey,
projectId,
projectKey,
});
const payload = unwrapPayload(row);
await db.query(
`
INSERT INTO ${db.table("result_refs")} (
result_ref,
actor_key,
session_id,
created_at,
kind,
preview,
project_id,
project_key,
schema_version,
size_bytes,
source,
trace_id,
payload_path,
object_key
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, NULL)
ON CONFLICT (result_ref)
DO UPDATE SET
actor_key = EXCLUDED.actor_key,
session_id = EXCLUDED.session_id,
created_at = EXCLUDED.created_at,
kind = EXCLUDED.kind,
preview = EXCLUDED.preview,
project_id = EXCLUDED.project_id,
project_key = EXCLUDED.project_key,
schema_version = EXCLUDED.schema_version,
size_bytes = EXCLUDED.size_bytes,
source = EXCLUDED.source,
trace_id = EXCLUDED.trace_id,
payload_path = EXCLUDED.payload_path
`,
[
resultRef,
actorKey,
sessionId,
stringOrUndefined(row.createdAt) ?? new Date().toISOString(),
inferResultKind(row, payload),
JSON.stringify(isResultPreview(row.preview) ? row.preview : buildPreview(payload)),
projectId ?? null,
projectKey,
positiveIntegerOrOne(row.schemaVersion),
estimateBytes(payload),
inferResultSource(row.source),
stringOrUndefined(row.traceId) ?? `trace-${resultRef}`,
filePath,
],
);
counters.resultRefs += 1;
}
}
async function migrateMemories() {
await migrateMemoryScope("user", join(config.MEMORY_STORAGE_DIR, "users"));
await migrateMemoryScope("workspace", join(config.MEMORY_STORAGE_DIR, "workspaces"));
}
async function migrateMemoryScope(scope: "user" | "workspace", dir: string) {
for (const filePath of await listFiles(dir, ".md")) {
const markdown = await readText(filePath);
if (!markdown) {
continue;
}
const scopeKey = basename(filePath, ".md");
for (const entry of parseMemoryMarkdown(markdown)) {
await db.query(
`
INSERT INTO ${db.table("memories")} (
memory_id,
scope,
scope_key,
content,
source
)
VALUES ($1, $2, $3, $4, 'tool')
ON CONFLICT (memory_id)
DO UPDATE SET content = EXCLUDED.content
`,
[entry.id, scope, scopeKey, entry.content],
);
counters.memories += 1;
}
}
}
async function upsertConversation(input: {
sessionId: string;
actorKey: string;
projectKey: string;
projectId?: string;
ownerUserId?: string;
parentSessionId?: string;
title?: string;
status?: "active" | "archived";
createdAt?: string;
updatedAt?: string;
}) {
await db.query(
`
INSERT INTO ${db.table("conversations")} (
session_id,
actor_key,
owner_user_id,
project_id,
project_key,
parent_session_id,
title,
status,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, NOW()), COALESCE($10, COALESCE($9, NOW())))
ON CONFLICT (session_id)
DO UPDATE SET
actor_key = EXCLUDED.actor_key,
owner_user_id = COALESCE(EXCLUDED.owner_user_id, ${db.table("conversations")}.owner_user_id),
project_id = COALESCE(EXCLUDED.project_id, ${db.table("conversations")}.project_id),
project_key = EXCLUDED.project_key,
parent_session_id = COALESCE(EXCLUDED.parent_session_id, ${db.table("conversations")}.parent_session_id),
title = COALESCE(EXCLUDED.title, ${db.table("conversations")}.title),
status = EXCLUDED.status,
created_at = LEAST(${db.table("conversations")}.created_at, EXCLUDED.created_at),
updated_at = GREATEST(${db.table("conversations")}.updated_at, EXCLUDED.updated_at)
`,
[
input.sessionId,
input.actorKey,
input.ownerUserId ?? null,
input.projectId ?? null,
input.projectKey,
input.parentSessionId ?? null,
input.title ?? null,
input.status ?? "active",
input.createdAt ?? null,
input.updatedAt ?? null,
],
);
}
async function listFiles(dir: string, extension: string) {
try {
const names = await readdir(dir);
return names
.filter((name) => name.endsWith(extension))
.map((name) => join(dir, name));
} catch {
return [];
}
}
async function readJson(path: string): Promise<JsonRecord | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as JsonRecord;
} catch {
return null;
}
}
async function readText(path: string): Promise<string | null> {
try {
return await readFile(path, "utf8");
} catch {
return null;
}
}
function parseMemoryMarkdown(content: string) {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim())
.map((line) => {
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
const entryContent = sanitizePersistentLine(match?.[2] ?? line, 240);
return {
id: match?.[1] ?? toStableId("memory-entry", entryContent.toLowerCase()),
content: entryContent,
};
})
.filter((entry) => entry.content);
}
function inferResultKind(row: JsonRecord, payload: unknown) {
if (row.kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
return RESULT_REFERENCE_KIND.renderJunctionsPayload;
}
if (isRecord(payload) && isRecord(payload.node_area_map)) {
return RESULT_REFERENCE_KIND.renderJunctionsPayload;
}
return RESULT_REFERENCE_KIND.dynamicHttpResult;
}
function inferResultSource(value: unknown) {
return value === RESULT_REFERENCE_SOURCE.dynamicHttp ||
value === RESULT_REFERENCE_SOURCE.agentGenerated ||
value === RESULT_REFERENCE_SOURCE.migration
? value
: RESULT_REFERENCE_SOURCE.legacy;
}
function unwrapPayload(row: JsonRecord) {
if ("data" in row) {
return row.data;
}
return row;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isResultPreview(value: unknown): value is ResultPreview {
return (
isRecord(value) &&
typeof value.count === "number" &&
Array.isArray(value.fields) &&
typeof value.summary === "string" &&
"sample" in value
);
}
function buildPreview(data: unknown): ResultPreview {
if (Array.isArray(data)) {
const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS);
const fields =
sample.length > 0 && isRecord(sample[0])
? Object.keys(sample[0]).slice(0, 30)
: [];
return {
count: data.length,
fields,
sample,
summary: `list[${data.length}]`,
};
}
if (isRecord(data)) {
const fields = Object.keys(data).slice(0, 30);
const sample = Object.fromEntries(
fields
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
.map((field) => [field, data[field]]),
);
return {
count: fields.length,
fields,
sample,
summary: `object<${fields.length} fields>`,
};
}
return {
count: 1,
fields: [],
sample: String(data).slice(0, 300),
summary: `scalar<${typeof data}>`,
};
}
function estimateBytes(data: unknown) {
return Buffer.byteLength(JSON.stringify(data));
}
function normalizeResultRef(value?: string) {
return value && RESULT_REF_PATTERN.test(value) ? value : null;
}
function stringOrUndefined(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function numberOrZero(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function positiveIntegerOrOne(value: unknown) {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1;
}
-1
View File
@@ -6,7 +6,6 @@ import { config } from "../config.js";
export type LlmRequestAuditEntry = { export type LlmRequestAuditEntry = {
kind: "tool" | "skill"; kind: "tool" | "skill";
sessionId: string; sessionId: string;
clientSessionId: string;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
target: string; target: string;
-60
View File
@@ -1,60 +0,0 @@
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);
}
};
+55 -80
View File
@@ -2,20 +2,17 @@ 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 { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = { export type SessionBinding = {
clientSessionId: string;
sessionId: string; sessionId: string;
runtimeSessionId: string;
startedAt: number; startedAt: number;
}; };
export type SessionContext = { export type SessionContext = {
clientSessionId: string; sessionId: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
userId?: string; userId?: string;
@@ -28,7 +25,10 @@ export type ChatRequestContext = SessionContext & {
}; };
export class ChatSessionBridge { export class ChatSessionBridge {
private readonly abortControllers = new Map<string, AbortController>(); // runtime session 仅在单次请求生命周期内有效;线程连续性由 sessionId 对应的持久状态承担。
private readonly activeRuntimeSessions = new Map<string, string>();
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
private readonly runtimeSessionStore = new RuntimeSessionStore();
constructor(private readonly runtime: OpencodeRuntimeAdapter) {} constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
@@ -43,99 +43,77 @@ export class ChatSessionBridge {
requestContext: ChatRequestContext; requestContext: ChatRequestContext;
created: boolean; created: boolean;
}> { }> {
let requestContext = this.buildRequestContext(context); const requestContext = this.buildRequestContext(context);
const existingSessionId = context.sessionId?.trim(); await this.abortActiveRuntime(requestContext.sessionId);
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
let sessionId = existingSessionId; const session = await this.runtime.createSession(requestContext.sessionId);
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, sessionId: requestContext.sessionId,
sessionId, runtimeSessionId: session.id,
startedAt: Date.now(), startedAt: Date.now(),
}; };
setRuntimeSessionContext({ this.activeRuntimeSessions.set(requestContext.sessionId, session.id);
accessToken: requestContext.accessToken, this.activeSensitiveContexts.set(session.id, requestContext);
await this.runtimeSessionStore.write({
runtimeSessionId: session.id,
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
allowLearningWrite: true, allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId, sessionId: requestContext.sessionId,
learningMode: "interactive", learningMode: "interactive",
projectId: requestContext.projectId, projectId: requestContext.projectId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId,
traceId: requestContext.traceId, traceId: requestContext.traceId,
}); });
return { binding, requestContext, created }; return { binding, requestContext, created: true };
} }
count(): number { count(): number {
return this.abortControllers.size; return this.activeRuntimeSessions.size;
} }
createClientSessionId() { createSessionId() {
return `agent-${randomUUID().slice(0, 12)}`; return `agent-${randomUUID().slice(0, 12)}`;
} }
registerAbortController(clientSessionId: string, controller: AbortController) { getActiveSensitiveContext(runtimeSessionId: string) {
this.abortControllers.set(clientSessionId, controller); return this.activeSensitiveContexts.get(runtimeSessionId) ?? null;
} }
finalizeRequest(clientSessionId: string) { async abort(context: { sessionId?: string }): Promise<SessionBinding | null> {
this.abortControllers.delete(clientSessionId);
}
async abort(context: {
clientSessionId?: string;
sessionId?: string;
}): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim();
const sessionId = context.sessionId?.trim(); const sessionId = context.sessionId?.trim();
if (!clientSessionId || !sessionId) { if (!sessionId) {
return null; return null;
} }
await this.abortActiveRuntime(clientSessionId, sessionId); const runtimeSessionId = this.activeRuntimeSessions.get(sessionId);
if (!runtimeSessionId) {
return null;
}
await this.abortActiveRuntime(sessionId);
return { return {
clientSessionId,
sessionId, sessionId,
runtimeSessionId,
startedAt: Date.now(), startedAt: Date.now(),
}; };
} }
async deleteSession(context: { async releaseRuntimeSession(sessionId: string, runtimeSessionId: string) {
clientSessionId: string; const activeSessionId = this.activeRuntimeSessions.get(sessionId);
sessionId: string; if (activeSessionId === runtimeSessionId) {
}) { this.activeRuntimeSessions.delete(sessionId);
const clientSessionId = context.clientSessionId.trim();
const sessionId = context.sessionId.trim();
const controller = this.abortControllers.get(clientSessionId);
if (controller) {
this.abortControllers.delete(clientSessionId);
controller.abort();
} }
await this.runtime.abortSession(sessionId).catch((error) => { this.activeSensitiveContexts.delete(runtimeSessionId);
logger.warn( await this.runtimeSessionStore.release(runtimeSessionId).catch((error) => {
{ clientSessionId, sessionId, err: error }, logger.debug(
"failed to abort runtime session", { runtimeSessionId, err: error },
"failed to cleanup persisted runtime session",
); );
}); });
await this.runtime.waitForSessionIdle(sessionId).catch((error) => { await this.runtime.abortSession(runtimeSessionId).catch((error) => {
logger.warn( logger.debug({ runtimeSessionId, err: error }, "failed to cleanup runtime session");
{ clientSessionId, sessionId, err: error },
"failed while waiting for runtime session to become idle",
);
}); });
removeRuntimeSessionContext(sessionId);
} }
private buildRequestContext(context: { private buildRequestContext(context: {
@@ -145,9 +123,8 @@ export class ChatSessionBridge {
traceId?: string; traceId?: string;
userId?: string; userId?: string;
}): ChatRequestContext { }): ChatRequestContext {
const sessionId = context.sessionId?.trim();
return { return {
clientSessionId: sessionId || this.createClientSessionId(), sessionId: context.sessionId?.trim() || this.createSessionId(),
accessToken: context.accessToken, accessToken: context.accessToken,
actorKey: toActorKey(context.userId), actorKey: toActorKey(context.userId),
projectId: context.projectId, projectId: context.projectId,
@@ -157,26 +134,24 @@ export class ChatSessionBridge {
}; };
} }
private async abortActiveRuntime(clientSessionId: string, sessionId?: string) { private async abortActiveRuntime(sessionId: string) {
const controller = this.abortControllers.get(clientSessionId); const activeRuntimeSessionId = this.activeRuntimeSessions.get(sessionId);
if (controller) { if (!activeRuntimeSessionId) {
this.abortControllers.delete(clientSessionId);
controller.abort();
}
if (!sessionId) {
return; return;
} }
await this.runtime.abortSession(sessionId).catch((error) => { this.activeRuntimeSessions.delete(sessionId);
this.activeSensitiveContexts.delete(activeRuntimeSessionId);
await this.runtimeSessionStore.release(activeRuntimeSessionId).catch(() => undefined);
await this.runtime.abortSession(activeRuntimeSessionId).catch((error) => {
logger.warn( logger.warn(
{ clientSessionId, sessionId, err: error }, { sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
"failed to abort active runtime session", "failed to abort previous active runtime session",
); );
}); });
await this.runtime.waitForSessionIdle(sessionId).catch((error) => { await this.runtime.waitForSessionIdle(activeRuntimeSessionId).catch((error) => {
logger.warn( logger.warn(
{ clientSessionId, sessionId, err: error }, { sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
"failed while waiting for active runtime session to become idle", "failed while waiting for previous runtime session to become idle",
); );
}); });
} }
+44 -16
View File
@@ -25,6 +25,22 @@ const envSchema = z
PORT: z.coerce.number().int().positive().default(8787), PORT: z.coerce.number().int().positive().default(8787),
// HTTP 服务监听地址。 // HTTP 服务监听地址。
HOST: z.string().default("0.0.0.0"), HOST: z.string().default("0.0.0.0"),
// PostgreSQL connection string; if omitted, PG* env vars are used.
DATABASE_URL: optionalString(),
// PostgreSQL host.
PGHOST: optionalString(),
// PostgreSQL port.
PGPORT: z.coerce.number().int().positive().optional(),
// PostgreSQL user.
PGUSER: optionalString(),
// PostgreSQL password.
PGPASSWORD: optionalString(),
// PostgreSQL database name.
PGDATABASE: optionalString(),
// PostgreSQL SSL mode.
PGSSLMODE: z.enum(["disable", "prefer", "require"]).default("disable"),
// PostgreSQL schema used by TJWaterAgent.
AGENT_DB_SCHEMA: z.string().default("public"),
// Pino 日志级别。 // Pino 日志级别。
LOG_LEVEL: z.string().default("info"), LOG_LEVEL: z.string().default("info"),
// LLM 工具/技能调用审计日志路径。 // LLM 工具/技能调用审计日志路径。
@@ -33,8 +49,6 @@ 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 的监听地址。
@@ -45,12 +59,10 @@ 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(),
// tjwater-cli 可执行文件路径 // 提供给本地 opencode tools 读取的会话上下文目录
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"), SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
// 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 的超时时间(毫秒)。
@@ -61,18 +73,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_BACKUP_DIR: z.string().default("./data/backup"), PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"),
// 注入到 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_TRANSCRIPT_STORAGE_DIR: z.string().default("./data/session-transcripts"), SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
// session metadata 持久化目录。 // conversation metadata 持久化目录。
SESSION_METADATA_STORAGE_DIR: z.string().default("./data/session-metadata"), CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
// session UI state 持久化目录。 // conversation UI state 持久化目录。
SESSION_UI_STATE_STORAGE_DIR: z.string().default("./data/session-ui-states"), CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。 // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION: z.coerce SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
.number() .number()
.int() .int()
.positive() .positive()
@@ -81,8 +93,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),
// 当前 session 的 learning 进度状态目录。 // learning review 会话状态目录。
SESSION_LEARNING_STATE_STORAGE_DIR: z.string().default("./data/session-learning-state"), LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"),
// learning audit 日志路径。 // learning audit 日志路径。
LEARNING_AUDIT_LOG_PATH: z LEARNING_AUDIT_LOG_PATH: z
.string() .string()
@@ -105,6 +117,12 @@ 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) {
@@ -114,6 +132,16 @@ const envSchema = z
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client", message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
}); });
} }
if (
!env.DATABASE_URL &&
(!env.PGHOST || !env.PGUSER || !env.PGPASSWORD || !env.PGDATABASE)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DATABASE_URL"],
message: "DATABASE_URL or PGHOST/PGUSER/PGPASSWORD/PGDATABASE must be set",
});
}
}); });
export type AppConfig = z.infer<typeof envSchema>; export type AppConfig = z.infer<typeof envSchema>;
+89
View File
@@ -0,0 +1,89 @@
import { type QueryResultRow } from "pg";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
export type ConversationStateRecord = {
sessionId: string;
isTitleManuallyEdited?: boolean;
messages: unknown[];
branchGroups: unknown[];
};
type ConversationStateRow = QueryResultRow & {
session_id: string;
is_title_manually_edited: boolean;
messages: unknown[];
branch_groups: unknown[];
};
export class ConversationStateStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async read(sessionId: string) {
const result = await this.db.query<ConversationStateRow>(
`
SELECT session_id, is_title_manually_edited, messages, branch_groups
FROM ${this.db.table("conversation_states")}
WHERE session_id = $1
LIMIT 1
`,
[sessionId],
);
return mapConversationStateRow(result.rows[0]);
}
async write(sessionId: string, state: ConversationStateRecord) {
const result = await this.db.query<ConversationStateRow>(
`
INSERT INTO ${this.db.table("conversation_states")} (
session_id,
is_title_manually_edited,
messages,
branch_groups
)
VALUES ($1, $2, $3::jsonb, $4::jsonb)
ON CONFLICT (session_id)
DO UPDATE SET
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
messages = EXCLUDED.messages,
branch_groups = EXCLUDED.branch_groups
RETURNING session_id, is_title_manually_edited, messages, branch_groups
`,
[
sessionId,
state.isTitleManuallyEdited ?? false,
JSON.stringify(state.messages),
JSON.stringify(state.branchGroups),
],
);
return mapConversationStateRow(result.rows[0]) ?? state;
}
async remove(sessionId: string) {
await this.db.query(
`
DELETE FROM ${this.db.table("conversation_states")}
WHERE session_id = $1
`,
[sessionId],
);
}
}
const mapConversationStateRow = (
row?: ConversationStateRow | null,
): ConversationStateRecord | null => {
if (!row) {
return null;
}
return {
sessionId: row.session_id,
isTitleManuallyEdited: row.is_title_manually_edited,
messages: Array.isArray(row.messages) ? row.messages : [],
branchGroups: Array.isArray(row.branch_groups) ? row.branch_groups : [],
};
};
+256
View File
@@ -0,0 +1,256 @@
import { randomUUID } from "node:crypto";
import { type QueryResultRow } from "pg";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
export type ConversationStatus = "active" | "archived";
export type ConversationRecord = {
sessionId: string;
actorKey: string;
ownerUserId?: string;
projectId?: string;
projectKey: string;
parentSessionId?: string;
createdAt: string;
updatedAt: string;
isStreaming: boolean;
streamingStartedAt?: string;
status: ConversationStatus;
title?: string;
};
type ConversationContext = {
actorKey: string;
userId?: string;
projectId?: string;
projectKey: string;
};
type EnsureConversationInput = ConversationContext & {
sessionId?: string;
parentSessionId?: string;
};
type ConversationRow = QueryResultRow & {
session_id: string;
actor_key: string;
owner_user_id: string | null;
project_id: string | null;
project_key: string;
parent_session_id: string | null;
created_at: Date | string;
updated_at: Date | string;
is_streaming: boolean;
streaming_started_at: Date | string | null;
status: ConversationStatus;
title: string | null;
};
export class ConversationStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async ensure(input: EnsureConversationInput) {
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
const existing = await this.get(input, sessionId);
if (existing) {
return { created: false, record: existing };
}
const inserted = await this.db.query<ConversationRow>(
`
INSERT INTO ${this.db.table("conversations")} (
session_id,
actor_key,
owner_user_id,
project_id,
project_key,
parent_session_id,
status
)
VALUES ($1, $2, $3, $4, $5, $6, 'active')
RETURNING *
`,
[
sessionId,
input.actorKey,
input.userId?.trim() || null,
input.projectId ?? null,
input.projectKey,
normalizeSessionId(input.parentSessionId) ?? null,
],
);
return { created: true, record: requireConversationRow(inserted.rows[0]) };
}
async get(context: ConversationContext, sessionId: string) {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) {
return null;
}
const result = await this.db.query<ConversationRow>(
`
SELECT *
FROM ${this.db.table("conversations")}
WHERE session_id = $1
AND actor_key = $2
AND project_key = $3
LIMIT 1
`,
[normalizedSessionId, context.actorKey, context.projectKey],
);
return mapConversationRow(result.rows[0]);
}
async touch(
record: ConversationRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
) {
const normalized = normalizeConversationUpdates(updates);
const result = await this.db.query<ConversationRow>(
`
UPDATE ${this.db.table("conversations")}
SET
title = COALESCE($2, title),
status = COALESCE($3, status)
WHERE session_id = $1
RETURNING *
`,
[record.sessionId, normalized.title ?? null, normalized.status ?? null],
);
return requireConversationRow(result.rows[0]);
}
async list(context: ConversationContext) {
const result = await this.db.query<ConversationRow>(
`
SELECT *
FROM ${this.db.table("conversations")}
WHERE actor_key = $1
AND project_key = $2
ORDER BY updated_at DESC
`,
[context.actorKey, context.projectKey],
);
return result.rows
.map(mapConversationRow)
.filter((record): record is ConversationRecord => Boolean(record));
}
async remove(record: ConversationRecord) {
await this.db.query(
`
DELETE FROM ${this.db.table("conversations")}
WHERE session_id = $1
`,
[record.sessionId],
);
}
async markStreaming(record: ConversationRecord, runtimeSessionId: string) {
const result = await this.db.query<ConversationRow>(
`
UPDATE ${this.db.table("conversations")}
SET
is_streaming = TRUE,
active_runtime_session_id = $2,
streaming_started_at = NOW()
WHERE session_id = $1
RETURNING *
`,
[record.sessionId, runtimeSessionId],
);
return requireConversationRow(result.rows[0]);
}
async clearStreaming(sessionId: string, runtimeSessionId: string) {
const result = await this.db.query<ConversationRow>(
`
UPDATE ${this.db.table("conversations")}
SET
is_streaming = FALSE,
active_runtime_session_id = NULL,
streaming_started_at = NULL
WHERE session_id = $1
AND active_runtime_session_id = $2
RETURNING *
`,
[sessionId, runtimeSessionId],
);
return mapConversationRow(result.rows[0]);
}
async resetStreamingSessions() {
await this.db.query(
`
UPDATE ${this.db.table("conversations")}
SET
is_streaming = FALSE,
active_runtime_session_id = NULL,
streaming_started_at = NULL
WHERE is_streaming = TRUE
OR active_runtime_session_id IS NOT NULL
OR streaming_started_at IS NOT NULL
`,
);
}
}
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
const normalizeSessionId = (value?: string) => {
const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined;
};
const normalizeConversationUpdates = (
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
) => {
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
if (typeof updates.title === "string") {
const trimmed = updates.title.trim();
if (trimmed) {
normalized.title = trimmed.slice(0, 120);
}
}
return normalized;
};
const mapConversationRow = (row?: ConversationRow | null): ConversationRecord | null => {
if (!row) {
return null;
}
return {
sessionId: row.session_id,
actorKey: row.actor_key,
ownerUserId: row.owner_user_id ?? undefined,
projectId: row.project_id ?? undefined,
projectKey: row.project_key,
parentSessionId: row.parent_session_id ?? undefined,
createdAt: toIsoString(row.created_at),
updatedAt: toIsoString(row.updated_at),
isStreaming: row.is_streaming,
streamingStartedAt: row.streaming_started_at ? toIsoString(row.streaming_started_at) : undefined,
status: row.status,
title: row.title ?? undefined,
};
};
const requireConversationRow = (row?: ConversationRow | null) => {
const record = mapConversationRow(row);
if (!record) {
throw new Error("conversation row not found");
}
return record;
};
const toIsoString = (value: Date | string) =>
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
+486
View File
@@ -0,0 +1,486 @@
import { Pool, type PoolClient, type QueryResultRow } from "pg";
import { config } from "../config.js";
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
export class AgentDatabase {
private readonly pool: Pool;
private readonly schemaName: string;
private initialized = false;
private initializePromise: Promise<void> | null = null;
constructor(options?: {
connectionString?: string;
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
sslmode?: "disable" | "prefer" | "require";
schema?: string;
}) {
const schema = options?.schema ?? config.AGENT_DB_SCHEMA;
if (!IDENTIFIER_PATTERN.test(schema)) {
throw new Error(`invalid PostgreSQL schema name: ${schema}`);
}
this.schemaName = schema;
this.pool = new Pool({
...(options?.connectionString ?? config.DATABASE_URL
? {
connectionString: options?.connectionString ?? config.DATABASE_URL,
}
: {
host: options?.host ?? config.PGHOST,
port: options?.port ?? config.PGPORT ?? 5432,
user: options?.user ?? config.PGUSER,
password: options?.password ?? config.PGPASSWORD,
database: options?.database ?? config.PGDATABASE,
}),
ssl:
(options?.sslmode ?? config.PGSSLMODE) === "require"
? { rejectUnauthorized: false }
: undefined,
});
}
get schema() {
return this.schemaName;
}
table(name: string) {
if (!IDENTIFIER_PATTERN.test(name)) {
throw new Error(`invalid PostgreSQL table name: ${name}`);
}
return `"${this.schemaName}"."${name}"`;
}
async initialize() {
if (this.initialized) {
return;
}
if (!this.initializePromise) {
this.initializePromise = (async () => {
await this.query(`CREATE SCHEMA IF NOT EXISTS "${this.schemaName}"`);
await this.query(buildSchemaSql(this.schemaName));
this.initialized = true;
})().catch((error) => {
this.initializePromise = null;
throw error;
});
}
await this.initializePromise;
}
async query<T extends QueryResultRow>(
text: string,
values?: unknown[],
client?: PoolClient,
) {
const executor = client ?? this.pool;
return await executor.query<T>(text, values);
}
async withTransaction<T>(task: (client: PoolClient) => Promise<T>) {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await task(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async close() {
await this.pool.end();
}
}
let defaultDatabase: AgentDatabase | undefined;
export const getAgentDatabase = () => {
if (!defaultDatabase) {
defaultDatabase = new AgentDatabase();
}
return defaultDatabase;
};
const buildSchemaSql = (schema: string) => `
CREATE OR REPLACE FUNCTION "${schema}".set_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE IF NOT EXISTS "${schema}"."conversations" (
session_id TEXT PRIMARY KEY,
actor_key TEXT NOT NULL,
owner_user_id TEXT,
project_id TEXT,
project_key TEXT NOT NULL,
parent_session_id TEXT REFERENCES "${schema}"."conversations"(session_id) ON DELETE SET NULL,
title VARCHAR(120),
is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
active_runtime_session_id TEXT,
streaming_started_at TIMESTAMPTZ,
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE "${schema}"."conversations"
ADD COLUMN IF NOT EXISTS is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS active_runtime_session_id TEXT,
ADD COLUMN IF NOT EXISTS streaming_started_at TIMESTAMPTZ;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'conversations'
AND column_name = 'session_scope_key'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."conversations" DROP COLUMN session_scope_key';
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_conversations_actor_project_updated
ON "${schema}"."conversations" (actor_key, project_key, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_conversations_parent_session
ON "${schema}"."conversations" (parent_session_id);
CREATE TABLE IF NOT EXISTS "${schema}"."conversation_states" (
session_id TEXT PRIMARY KEY REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
is_title_manually_edited BOOLEAN NOT NULL DEFAULT FALSE,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
branch_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE "${schema}"."conversation_states"
ADD COLUMN IF NOT EXISTS is_title_manually_edited BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS messages JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS branch_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'conversation_states'
AND column_name = 'session_scope_key'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."conversation_states" DROP COLUMN session_scope_key';
END IF;
END $$;
CREATE TABLE IF NOT EXISTS "${schema}"."conversation_turns" (
turn_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
turn_index INTEGER NOT NULL CHECK (turn_index >= 0),
actor_key TEXT NOT NULL,
project_key TEXT NOT NULL,
user_message TEXT NOT NULL,
assistant_message TEXT NOT NULL,
tool_call_count INTEGER NOT NULL DEFAULT 0 CHECK (tool_call_count >= 0),
turn_timestamp TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (session_id, turn_index)
);
ALTER TABLE "${schema}"."conversation_turns"
ADD COLUMN IF NOT EXISTS turn_index INTEGER;
WITH ranked_turns AS (
SELECT
turn_id,
ROW_NUMBER() OVER (
PARTITION BY session_id
ORDER BY turn_timestamp ASC, turn_id ASC
) - 1 AS computed_turn_index
FROM "${schema}"."conversation_turns"
WHERE turn_index IS NULL
)
UPDATE "${schema}"."conversation_turns" turns
SET turn_index = ranked_turns.computed_turn_index
FROM ranked_turns
WHERE turns.turn_id = ranked_turns.turn_id;
ALTER TABLE "${schema}"."conversation_turns"
ALTER COLUMN turn_index SET NOT NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'conversation_turns'
AND column_name = 'client_session_id'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."conversation_turns" DROP COLUMN client_session_id';
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_conversation_turns_session_time
ON "${schema}"."conversation_turns" (session_id, turn_index DESC);
CREATE INDEX IF NOT EXISTS idx_conversation_turns_actor_project_time
ON "${schema}"."conversation_turns" (actor_key, project_key, turn_timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_conversation_turns_search
ON "${schema}"."conversation_turns"
USING GIN (to_tsvector('simple', coalesce(user_message, '') || ' ' || coalesce(assistant_message, '')));
CREATE TABLE IF NOT EXISTS "${schema}"."runtime_sessions" (
runtime_session_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
actor_key TEXT NOT NULL,
allow_learning_write BOOLEAN NOT NULL DEFAULT FALSE,
learning_mode TEXT CHECK (learning_mode IN ('interactive', 'review') OR learning_mode IS NULL),
project_id TEXT,
project_key TEXT NOT NULL,
trace_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
released_at TIMESTAMPTZ
);
ALTER TABLE "${schema}"."runtime_sessions"
ADD COLUMN IF NOT EXISTS session_id TEXT;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'runtime_sessions'
AND column_name = 'conversation_id'
) THEN
EXECUTE 'UPDATE "${schema}"."runtime_sessions" SET session_id = COALESCE(session_id, conversation_id) WHERE session_id IS NULL';
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'runtime_sessions_session_id_fkey'
AND connamespace = to_regnamespace('${schema}')
) THEN
EXECUTE 'ALTER TABLE "${schema}"."runtime_sessions" ADD CONSTRAINT runtime_sessions_session_id_fkey FOREIGN KEY (session_id) REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE';
END IF;
END $$;
ALTER TABLE "${schema}"."runtime_sessions"
ALTER COLUMN session_id SET NOT NULL;
DO $$
BEGIN
IF to_regclass('"${schema}"."tool_session_contexts"') IS NOT NULL THEN
INSERT INTO "${schema}"."runtime_sessions" (
runtime_session_id,
session_id,
actor_key,
allow_learning_write,
learning_mode,
project_id,
project_key,
trace_id,
released_at
)
SELECT
runtime_session_id,
client_session_id,
actor_key,
allow_learning_write,
learning_mode,
project_id,
project_key,
trace_id,
NOW()
FROM "${schema}"."tool_session_contexts"
WHERE client_session_id IS NOT NULL
ON CONFLICT (runtime_session_id) DO NOTHING;
END IF;
END $$;
DROP INDEX IF EXISTS "${schema}"."idx_runtime_sessions_conversation";
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_session
ON "${schema}"."runtime_sessions" (session_id);
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_trace_id
ON "${schema}"."runtime_sessions" (trace_id);
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'runtime_sessions'
AND column_name = 'conversation_id'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."runtime_sessions" DROP COLUMN conversation_id';
END IF;
END $$;
DROP TABLE IF EXISTS "${schema}"."tool_session_contexts";
CREATE TABLE IF NOT EXISTS "${schema}"."learning_states" (
session_id TEXT PRIMARY KEY REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
last_gated_turn INTEGER NOT NULL DEFAULT 0 CHECK (last_gated_turn >= 0),
last_reviewed_turn INTEGER NOT NULL DEFAULT 0 CHECK (last_reviewed_turn >= 0),
pending_review BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "${schema}"."result_refs" (
result_ref TEXT PRIMARY KEY,
actor_key TEXT NOT NULL,
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('dynamic-http-result', 'render-junctions-payload')),
preview JSONB NOT NULL,
project_id TEXT,
project_key TEXT NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1 CHECK (schema_version > 0),
size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
source TEXT NOT NULL CHECK (source IN ('dynamic_http', 'agent_generated', 'legacy', 'migration')),
trace_id TEXT NOT NULL,
payload_path TEXT,
object_key TEXT,
CONSTRAINT chk_result_refs_payload_locator CHECK (
num_nonnulls(payload_path, object_key) = 1
)
);
ALTER TABLE "${schema}"."result_refs"
ADD COLUMN IF NOT EXISTS session_id TEXT,
ADD COLUMN IF NOT EXISTS payload_path TEXT,
ADD COLUMN IF NOT EXISTS object_key TEXT;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'result_refs'
AND column_name = 'conversation_id'
) THEN
EXECUTE 'UPDATE "${schema}"."result_refs" SET session_id = COALESCE(session_id, conversation_id) WHERE session_id IS NULL';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'result_refs'
AND column_name = 'client_session_id'
) THEN
EXECUTE 'UPDATE "${schema}"."result_refs" SET session_id = COALESCE(session_id, client_session_id) WHERE session_id IS NULL';
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'result_refs_session_id_fkey'
AND connamespace = to_regnamespace('${schema}')
) THEN
EXECUTE 'ALTER TABLE "${schema}"."result_refs" ADD CONSTRAINT result_refs_session_id_fkey FOREIGN KEY (session_id) REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'result_refs'
AND column_name = 'client_session_id'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."result_refs" DROP COLUMN client_session_id';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'result_refs'
AND column_name = 'data'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."result_refs" ALTER COLUMN data DROP NOT NULL';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${schema}'
AND table_name = 'result_refs'
AND column_name = 'conversation_id'
) THEN
EXECUTE 'ALTER TABLE "${schema}"."result_refs" DROP COLUMN conversation_id';
END IF;
END $$;
ALTER TABLE "${schema}"."result_refs"
ALTER COLUMN session_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_result_refs_session_created
ON "${schema}"."result_refs" (session_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_result_refs_actor_project_created
ON "${schema}"."result_refs" (actor_key, project_key, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_result_refs_trace_id
ON "${schema}"."result_refs" (trace_id);
CREATE TABLE IF NOT EXISTS "${schema}"."memories" (
memory_id TEXT PRIMARY KEY,
scope TEXT NOT NULL CHECK (scope IN ('user', 'workspace')),
scope_key TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('review', 'tool')),
session_id TEXT REFERENCES "${schema}"."conversations"(session_id) ON DELETE SET NULL,
trace_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (scope, scope_key, content)
);
CREATE INDEX IF NOT EXISTS idx_memories_scope_key
ON "${schema}"."memories" (scope, scope_key, updated_at DESC);
DROP TRIGGER IF EXISTS trg_conversations_set_updated_at ON "${schema}"."conversations";
CREATE TRIGGER trg_conversations_set_updated_at
BEFORE UPDATE ON "${schema}"."conversations"
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
DROP TRIGGER IF EXISTS trg_conversation_states_set_updated_at ON "${schema}"."conversation_states";
CREATE TRIGGER trg_conversation_states_set_updated_at
BEFORE UPDATE ON "${schema}"."conversation_states"
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
DROP TRIGGER IF EXISTS trg_runtime_sessions_set_updated_at ON "${schema}"."runtime_sessions";
CREATE TRIGGER trg_runtime_sessions_set_updated_at
BEFORE UPDATE ON "${schema}"."runtime_sessions"
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
DROP TRIGGER IF EXISTS trg_learning_states_set_updated_at ON "${schema}"."learning_states";
CREATE TRIGGER trg_learning_states_set_updated_at
BEFORE UPDATE ON "${schema}"."learning_states"
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
DROP TRIGGER IF EXISTS trg_memories_set_updated_at ON "${schema}"."memories";
CREATE TRIGGER trg_memories_set_updated_at
BEFORE UPDATE ON "${schema}"."memories"
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
`;
+326
View File
@@ -0,0 +1,326 @@
import { type QueryResultRow } from "pg";
import { config } from "../config.js";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
import { sanitizePersistentDocument } from "../utils/persistencePolicy.js";
import { toStableId } from "../utils/fileStore.js";
export type SessionTurnRecord = {
id: string;
assistantMessage: string;
timestamp: string;
toolCallCount: number;
userMessage: string;
};
type SessionTranscriptRecord = {
actorKey: string;
projectKey: string;
sessionId: string;
turns: SessionTurnRecord[];
updatedAt: string;
};
export type SessionSearchHit = {
matchedField: "assistant" | "user";
score: number;
sessionId: string;
snippet: string;
timestamp: string;
turnId: string;
};
type SessionHistoryContext = {
actorKey: string;
projectKey: string;
sessionId: string;
};
type ConversationTurnRow = QueryResultRow & {
turn_id: string;
session_id: string;
turn_index: number;
actor_key: string;
project_key: string;
user_message: string;
assistant_message: string;
tool_call_count: number;
turn_timestamp: Date | string;
created_at: Date | string;
};
export class SessionHistoryStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async appendTurn(
context: SessionHistoryContext,
turn: {
assistantMessage: string;
toolCallCount: number;
userMessage: string;
},
): Promise<SessionTranscriptRecord> {
const userMessage = sanitizePersistentDocument(turn.userMessage, 4000);
const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000);
if (!userMessage || !assistantMessage) {
return (await this.readTranscript(context)) ?? emptyTranscript(context);
}
const timestamp = new Date().toISOString();
await this.db.withTransaction(async (client) => {
await this.db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [context.sessionId], client);
const nextIndexResult = await this.db.query<{ next_index: number }>(
`
SELECT COALESCE(MAX(turn_index), -1) + 1 AS next_index
FROM ${this.db.table("conversation_turns")}
WHERE session_id = $1
`,
[context.sessionId],
client,
);
const nextIndex = nextIndexResult.rows[0]?.next_index ?? 0;
await this.db.query(
`
INSERT INTO ${this.db.table("conversation_turns")} (
turn_id,
session_id,
turn_index,
actor_key,
project_key,
user_message,
assistant_message,
tool_call_count,
turn_timestamp
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
[
toStableId(context.sessionId, String(nextIndex), timestamp, userMessage, assistantMessage),
context.sessionId,
nextIndex,
context.actorKey,
context.projectKey,
userMessage,
assistantMessage,
Math.max(0, turn.toolCallCount),
timestamp,
],
client,
);
});
return (await this.readTranscript(context)) ?? emptyTranscript(context);
}
async getRecentTurns(
context: SessionHistoryContext,
limit: number,
): Promise<SessionTurnRecord[]> {
const result = await this.db.query<ConversationTurnRow>(
`
SELECT *
FROM ${this.db.table("conversation_turns")}
WHERE session_id = $1
AND actor_key = $2
AND project_key = $3
ORDER BY turn_index DESC
LIMIT $4
`,
[context.sessionId, context.actorKey, context.projectKey, Math.max(1, limit)],
);
return result.rows
.slice()
.reverse()
.map(mapTurnRow);
}
async cloneThread(
sourceContext: SessionHistoryContext,
targetContext: SessionHistoryContext,
keepMessageCount: number,
): Promise<SessionTranscriptRecord> {
const keepTurnCount = Math.floor(keepMessageCount / 2);
const sourceTurns = keepTurnCount
? await this.db.query<ConversationTurnRow>(
`
SELECT *
FROM ${this.db.table("conversation_turns")}
WHERE session_id = $1
AND actor_key = $2
AND project_key = $3
ORDER BY turn_index ASC
LIMIT $4
`,
[
sourceContext.sessionId,
sourceContext.actorKey,
sourceContext.projectKey,
keepTurnCount,
],
)
: { rows: [] as ConversationTurnRow[] };
await this.db.withTransaction(async (client) => {
for (const [turnIndex, row] of sourceTurns.rows.entries()) {
await this.db.query(
`
INSERT INTO ${this.db.table("conversation_turns")} (
turn_id,
session_id,
turn_index,
actor_key,
project_key,
user_message,
assistant_message,
tool_call_count,
turn_timestamp
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
[
toStableId(
targetContext.sessionId,
String(turnIndex),
toIsoString(row.turn_timestamp),
row.user_message,
row.assistant_message,
),
targetContext.sessionId,
turnIndex,
targetContext.actorKey,
targetContext.projectKey,
row.user_message,
row.assistant_message,
row.tool_call_count,
row.turn_timestamp,
],
client,
);
}
});
return (await this.readTranscript(targetContext)) ?? emptyTranscript(targetContext);
}
async search(
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
query: string,
maxResults = config.SESSION_SEARCH_MAX_RESULTS,
): Promise<SessionSearchHit[]> {
const normalizedQuery = query.trim().toLowerCase().slice(0, config.SESSION_SEARCH_MAX_QUERY_CHARS);
if (!normalizedQuery) {
return [];
}
const rows = await this.db.query<ConversationTurnRow>(
`
SELECT *
FROM ${this.db.table("conversation_turns")}
WHERE actor_key = $1
AND project_key = $2
AND (
LOWER(user_message) LIKE $3
OR LOWER(assistant_message) LIKE $3
)
ORDER BY turn_timestamp DESC
`,
[context.actorKey, context.projectKey, `%${normalizedQuery}%`],
);
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
const hits: SessionSearchHit[] = [];
for (const row of rows.rows) {
const candidates: Array<["user" | "assistant", string]> = [
["user", row.user_message],
["assistant", row.assistant_message],
];
for (const [matchedField, text] of candidates) {
const score = scoreText(text, normalizedQuery, queryTokens);
if (score <= 0) {
continue;
}
hits.push({
matchedField,
score,
sessionId: row.session_id,
snippet: buildSnippet(text, normalizedQuery),
timestamp: toIsoString(row.turn_timestamp),
turnId: row.turn_id,
});
}
}
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
}
private async readTranscript(context: SessionHistoryContext) {
const result = await this.db.query<ConversationTurnRow>(
`
SELECT *
FROM ${this.db.table("conversation_turns")}
WHERE session_id = $1
AND actor_key = $2
AND project_key = $3
ORDER BY turn_index ASC
`,
[context.sessionId, context.actorKey, context.projectKey],
);
if (result.rows.length === 0) {
return null;
}
return {
actorKey: context.actorKey,
projectKey: context.projectKey,
sessionId: context.sessionId,
turns: result.rows.map(mapTurnRow),
updatedAt: toIsoString(result.rows[result.rows.length - 1]?.turn_timestamp ?? new Date()),
};
}
}
const emptyTranscript = (context: SessionHistoryContext): SessionTranscriptRecord => ({
actorKey: context.actorKey,
projectKey: context.projectKey,
sessionId: context.sessionId,
turns: [],
updatedAt: new Date().toISOString(),
});
const mapTurnRow = (row: ConversationTurnRow): SessionTurnRecord => ({
id: row.turn_id,
assistantMessage: row.assistant_message,
timestamp: toIsoString(row.turn_timestamp),
toolCallCount: row.tool_call_count,
userMessage: row.user_message,
});
const scoreText = (text: string, query: string, queryTokens: string[]) => {
const normalized = text.toLowerCase();
let score = 0;
if (normalized.includes(query)) {
score += Math.max(10, query.length);
}
for (const token of queryTokens) {
if (token.length >= 2 && normalized.includes(token)) {
score += 1;
}
}
return score;
};
const buildSnippet = (text: string, query: string) => {
const compact = text.replace(/\s+/g, " ").trim();
const idx = compact.toLowerCase().indexOf(query);
if (idx === -1) {
return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact;
}
const start = Math.max(0, idx - 60);
const end = Math.min(compact.length, idx + query.length + 100);
const snippet = compact.slice(start, end).trim();
const prefix = start > 0 ? "..." : "";
const suffix = end < compact.length ? "..." : "";
return `${prefix}${snippet}${suffix}`;
};
const toIsoString = (value: Date | string) =>
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
+48 -94
View File
@@ -3,16 +3,13 @@ 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, SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionTurnRecord, SessionHistoryStore } from "../history/store.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { SessionLearningStateStore } from "./sessionStateStore.js"; import { LearningStateStore } from "./stateStore.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 { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { import {
sanitizePersistentDocument, sanitizePersistentDocument,
sanitizePersistentLine, sanitizePersistentLine,
@@ -41,13 +38,7 @@ const reviewResultSchema = z.object({
skills: z skills: z
.array( .array(
z.object({ z.object({
action: z.enum([ action: z.enum(["append_pattern", "remove_pattern", "write_reference"]),
"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(""),
@@ -77,24 +68,27 @@ type TurnReviewInput = {
export class LearningOrchestrator { export class LearningOrchestrator {
private readonly activeReviews = new Set<string>(); private readonly activeReviews = new Set<string>();
private readonly sessionLearningStateStore = new SessionLearningStateStore(); private readonly learningStateStore = new LearningStateStore();
private readonly skillStore = new SkillStore(); private readonly skillStore = new SkillStore();
private readonly runtimeSessionStore = new RuntimeSessionStore();
constructor( constructor(
private readonly runtime: OpencodeRuntimeAdapter, private readonly runtime: OpencodeRuntimeAdapter,
private readonly memoryStore: MemoryStore, private readonly memoryStore: MemoryStore,
private readonly transcriptStore: SessionTranscriptStore, private readonly historyStore: SessionHistoryStore,
) {} ) {}
async initialize() { async initialize() {
await this.sessionLearningStateStore.initialize(); await Promise.all([
this.learningStateStore.initialize(),
this.runtimeSessionStore.initialize(),
]);
} }
async onTurnCompleted(input: TurnReviewInput) { async onTurnCompleted(input: TurnReviewInput) {
const transcript = await this.transcriptStore.appendTurn( const transcript = await this.historyStore.appendTurn(
{ {
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
clientSessionId: input.requestContext.clientSessionId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: input.sessionId, sessionId: input.sessionId,
}, },
@@ -110,12 +104,13 @@ export class LearningOrchestrator {
} }
this.activeReviews.add(input.sessionId); this.activeReviews.add(input.sessionId);
try { try {
const state = await this.sessionLearningStateStore.read(input.sessionId); const state = await this.learningStateStore.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) { if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN || state.pendingReview) {
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;
@@ -143,17 +138,17 @@ export class LearningOrchestrator {
let gateSessionId: string | null = null; let gateSessionId: string | null = null;
try { try {
const gateSession = await this.runtime.createSession( const gateSession = await this.runtime.createSession(
`learning-gate-${input.requestContext.clientSessionId}`, `learning-gate-${input.requestContext.sessionId}`,
); );
gateSessionId = gateSession.id; gateSessionId = gateSession.id;
setRuntimeSessionContext({ await this.runtimeSessionStore.write({
runtimeSessionId: gateSession.id,
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
allowLearningWrite: false, allowLearningWrite: false,
clientSessionId: `gate-${input.requestContext.clientSessionId}`, sessionId: input.requestContext.sessionId,
learningMode: "review", learningMode: "review",
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: gateSession.id,
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
await this.runtime.prompt( await this.runtime.prompt(
@@ -168,7 +163,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.sessionLearningStateStore.completeGate(input.sessionId, turnCount); await this.learningStateStore.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",
@@ -193,7 +188,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
if (!shouldPromote) { if (!shouldPromote) {
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount); await this.learningStateStore.completeGate(input.sessionId, turnCount);
return; return;
} }
await this.runReview({ await this.runReview({
@@ -203,6 +198,7 @@ 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",
@@ -214,7 +210,7 @@ export class LearningOrchestrator {
}); });
} finally { } finally {
if (gateSessionId) { if (gateSessionId) {
removeRuntimeSessionContext(gateSessionId); await this.runtimeSessionStore.release(gateSessionId).catch(() => undefined);
await this.runtime.abortSession(gateSessionId).catch(() => undefined); await this.runtime.abortSession(gateSessionId).catch(() => undefined);
} }
} }
@@ -232,23 +228,22 @@ export class LearningOrchestrator {
turnCount: number; turnCount: number;
}) { }) {
const reviewSession = await this.runtime.createSession( const reviewSession = await this.runtime.createSession(
`learning-review-${input.requestContext.clientSessionId}`, `learning-review-${input.requestContext.sessionId}`,
); );
setRuntimeSessionContext({ await this.runtimeSessionStore.write({
runtimeSessionId: reviewSession.id,
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
allowLearningWrite: false, allowLearningWrite: false,
clientSessionId: `review-${input.requestContext.clientSessionId}`, sessionId: input.requestContext.sessionId,
learningMode: "review", learningMode: "review",
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: reviewSession.id,
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({ existingMemory, focus, recentTurns }), buildReviewPrompt({ 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);
@@ -258,7 +253,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.sessionLearningStateStore.completeGate(input.sessionId, turnCount); await this.learningStateStore.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",
@@ -270,8 +265,9 @@ export class LearningOrchestrator {
return; return;
} }
await this.applyReviewResult(input, parsed, turnCount); await this.applyReviewResult(input, parsed, turnCount);
await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount); await this.learningStateStore.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",
@@ -282,7 +278,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
} finally { } finally {
removeRuntimeSessionContext(reviewSession.id); await this.runtimeSessionStore.release(reviewSession.id).catch(() => undefined);
await this.runtime.abortSession(reviewSession.id).catch(() => undefined); await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
} }
} }
@@ -318,14 +314,6 @@ 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],
@@ -353,33 +341,28 @@ export class LearningOrchestrator {
source: "review" as const, source: "review" as const,
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}; };
let accepted = false; const result =
let detail = "memory rejected"; proposal.action === "add"
if (proposal.action === "add") { ? await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft)
const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft); : proposal.action === "replace"
accepted = Boolean(result.entry); ? await this.memoryStore.replace(
detail = result.detail;
} else if (proposal.action === "replace") {
const result = await this.memoryStore.replace(
proposal.scope as MemoryScope, proposal.scope as MemoryScope,
scopeKey, scopeKey,
proposal.target_id ?? "", proposal.target_id ?? "",
draft, draft,
); )
accepted = Boolean(result.changed); : await this.memoryStore.remove(
detail = result.detail;
} else {
const result = await this.memoryStore.remove(
proposal.scope as MemoryScope, proposal.scope as MemoryScope,
scopeKey, scopeKey,
proposal.target_id ?? "", proposal.target_id ?? "",
); );
accepted = Boolean(result.changed); const accepted =
detail = result.detail; "entry" in result ? Boolean(result.entry) : Boolean(result.changed);
}
await writeLearningAuditLog({ await writeLearningAuditLog({
action: `memory-${proposal.action}`, action: `memory-${proposal.action}`,
detail: sanitizeAuditDetail(detail), detail: sanitizeAuditDetail(
"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),
@@ -414,15 +397,11 @@ export class LearningOrchestrator {
proposal.skill_path, proposal.skill_path,
proposal.target_id ?? "", proposal.target_id ?? "",
) )
: proposal.action === "write_reference" : await this.skillStore.writeReference(
? await this.skillStore.writeReference(
proposal.skill_path, proposal.skill_path,
proposal.file_path ?? "", proposal.file_path ?? "",
proposal.content ?? "", 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),
@@ -458,14 +437,9 @@ 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[];
}) => { }) => {
@@ -475,16 +449,6 @@ 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.",
@@ -495,26 +459,16 @@ 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.",
"- 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.", "- Do not edit frontmatter or arbitrary sections.",
"- 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|write_skill|remove_skill","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","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.",
"", "",
-69
View File
@@ -1,69 +0,0 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
} from "../utils/fileStore.js";
export type SessionLearningState = {
lastGatedTurn: number;
lastReviewedTurn: number;
sessionId: string;
updatedAt: string;
};
export class SessionLearningStateStore {
constructor(private readonly baseDir = config.SESSION_LEARNING_STATE_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async read(sessionId: string): Promise<SessionLearningState> {
const existing = await readJsonFile<SessionLearningState>(this.filePath(sessionId));
if (existing) {
return {
lastGatedTurn: existing.lastGatedTurn,
lastReviewedTurn: existing.lastReviewedTurn,
sessionId: existing.sessionId,
updatedAt: existing.updatedAt,
};
}
return {
lastGatedTurn: 0,
lastReviewedTurn: 0,
sessionId,
updatedAt: new Date(0).toISOString(),
};
}
async write(state: SessionLearningState) {
await atomicWriteJson(this.filePath(state.sessionId), {
...state,
updatedAt: new Date().toISOString(),
});
}
async completeReview(sessionId: string, reviewedTurnCount: number) {
const current = await this.read(sessionId);
await this.write({
...current,
lastGatedTurn: Math.max(current.lastGatedTurn, reviewedTurnCount),
lastReviewedTurn: reviewedTurnCount,
});
}
async completeGate(sessionId: string, gatedTurnCount: number) {
const current = await this.read(sessionId);
await this.write({
...current,
lastGatedTurn: gatedTurnCount,
});
}
private filePath(sessionId: string) {
return join(this.baseDir, `${sessionId}.json`);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { type QueryResultRow } from "pg";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
export type LearningSessionState = {
lastGatedTurn: number;
lastReviewedTurn: number;
pendingReview: boolean;
sessionId: string;
updatedAt: string;
};
type LearningStateRow = QueryResultRow & {
session_id: string;
last_gated_turn: number;
last_reviewed_turn: number;
pending_review: boolean;
updated_at: Date | string;
};
export class LearningStateStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async read(sessionId: string): Promise<LearningSessionState> {
const result = await this.db.query<LearningStateRow>(
`
SELECT session_id, last_gated_turn, last_reviewed_turn, pending_review, updated_at
FROM ${this.db.table("learning_states")}
WHERE session_id = $1
LIMIT 1
`,
[sessionId],
);
return (
mapLearningStateRow(result.rows[0]) ?? {
lastGatedTurn: 0,
lastReviewedTurn: 0,
pendingReview: false,
sessionId,
updatedAt: new Date(0).toISOString(),
}
);
}
async write(state: LearningSessionState) {
await this.db.query(
`
INSERT INTO ${this.db.table("learning_states")} (
session_id,
last_gated_turn,
last_reviewed_turn,
pending_review
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (session_id)
DO UPDATE SET
last_gated_turn = EXCLUDED.last_gated_turn,
last_reviewed_turn = EXCLUDED.last_reviewed_turn,
pending_review = EXCLUDED.pending_review
`,
[
state.sessionId,
state.lastGatedTurn,
state.lastReviewedTurn,
state.pendingReview,
],
);
}
async markPending(sessionId: string, pendingReview: boolean) {
const current = await this.read(sessionId);
await this.write({
...current,
pendingReview,
});
}
async completeReview(sessionId: string, reviewedTurnCount: number) {
const current = await this.read(sessionId);
await this.write({
...current,
lastGatedTurn: Math.max(current.lastGatedTurn, reviewedTurnCount),
lastReviewedTurn: reviewedTurnCount,
pendingReview: false,
});
}
async completeGate(sessionId: string, gatedTurnCount: number) {
const current = await this.read(sessionId);
await this.write({
...current,
lastGatedTurn: gatedTurnCount,
pendingReview: false,
});
}
}
const mapLearningStateRow = (row?: LearningStateRow | null): LearningSessionState | null => {
if (!row) {
return null;
}
return {
sessionId: row.session_id,
lastGatedTurn: row.last_gated_turn,
lastReviewedTurn: row.last_reviewed_turn,
pendingReview: row.pending_review,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
};
};
-26
View File
@@ -2,40 +2,14 @@ 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,
+107 -136
View File
@@ -1,13 +1,9 @@
import { join } from "node:path"; import { type QueryResultRow } from "pg";
import { config } from "../config.js"; import { config } from "../config.js";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
import { sanitizePersistentLine } from "../utils/persistencePolicy.js"; import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
import { import { toStableId } from "../utils/fileStore.js";
atomicWriteFileWithHistory,
ensureDirectory,
readTextFile,
toStableId,
} from "../utils/fileStore.js";
export type MemoryScope = "user" | "workspace"; export type MemoryScope = "user" | "workspace";
export type MemoryEntrySource = "review" | "tool"; export type MemoryEntrySource = "review" | "tool";
@@ -29,6 +25,17 @@ type MemoryContext = {
projectKey: string; projectKey: string;
}; };
type MemoryRow = QueryResultRow & {
memory_id: string;
scope: MemoryScope;
scope_key: string;
content: string;
source: MemoryEntrySource;
session_id: string | null;
trace_id: string | null;
updated_at: Date | string;
};
const SUSPICIOUS_MEMORY_PATTERNS = [ const SUSPICIOUS_MEMORY_PATTERNS = [
/ignore\s+(all|previous|prior|above)\s+instructions/i, /ignore\s+(all|previous|prior|above)\s+instructions/i,
/system\s+prompt/i, /system\s+prompt/i,
@@ -37,125 +44,118 @@ const SUSPICIOUS_MEMORY_PATTERNS = [
]; ];
export class MemoryStore { export class MemoryStore {
// Memory 文件可能被多次连续追加,串行化可避免并发覆盖掉刚写入的条目。 constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
private writeQueue: Promise<void> = Promise.resolve();
constructor(
private readonly baseDir = config.MEMORY_STORAGE_DIR,
private readonly backupDir = join(config.PERSISTENCE_BACKUP_DIR, "memory"),
) {}
async initialize() { async initialize() {
await ensureDirectory(this.baseDir); await this.db.initialize();
await ensureDirectory(join(this.baseDir, "users"));
await ensureDirectory(join(this.baseDir, "workspaces"));
// 备份与正式数据分目录存放,便于排查和手工恢复。
await ensureDirectory(this.backupDir);
} }
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) { async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content); const content = normalizeMemoryContent(draft.content);
if (!content) { if (!content) {
return { return { changed: false, entry: null as MemoryEntry | null };
changed: false,
detail: "content rejected by persistence policy",
entry: null as MemoryEntry | null,
};
} }
const existing = await this.findByContent(scope, key, content);
const entries = await this.readEntries(scope, key);
const existing = entries.find((entry) => entry.content === content);
if (existing) { if (existing) {
return { return { changed: false, entry: existing };
changed: false,
detail: "memory already existed",
entry: existing,
};
} }
const id = toStableId(scope, key, content.toLowerCase());
const entry: MemoryEntry = { await this.db.query(
`
INSERT INTO ${this.db.table("memories")} (
memory_id,
scope,
scope_key,
content, content,
id: toStableId(scope, key, content.toLowerCase()), source,
}; session_id,
entries.unshift(entry); trace_id
// 每次覆盖 memory 文件前先保留上一版,写入失败时由底层工具恢复。 )
await atomicWriteFileWithHistory( VALUES ($1, $2, $3, $4, $5, $6, $7)
this.filePath(scope, key), `,
renderMemoryMarkdown(scope, entries), [id, scope, key, content, draft.source, draft.sessionId ?? null, draft.traceId ?? null],
{
backupDir: this.backupDir,
rootDir: this.baseDir,
},
); );
return { return {
changed: true, changed: true,
detail: "memory stored", entry: {
entry, id,
content,
},
}; };
});
} }
async list(scope: MemoryScope, key: string) { async list(scope: MemoryScope, key: string) {
return await this.readEntries(scope, key); const rows = await this.db.query<MemoryRow>(
`
SELECT memory_id, content
FROM ${this.db.table("memories")}
WHERE scope = $1
AND scope_key = $2
ORDER BY updated_at DESC, created_at DESC
`,
[scope, key],
);
return rows.rows.map((row) => ({
id: row.memory_id,
content: row.content,
}));
} }
async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) { async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) {
return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content); const content = normalizeMemoryContent(draft.content);
if (!content) { if (!content) {
return { changed: false, detail: "content rejected by persistence policy" }; return { changed: false, detail: "content rejected by persistence policy" };
} }
const entries = await this.readEntries(scope, key); const duplicate = await this.findByContent(scope, key, content);
const index = entries.findIndex((entry) => entry.id === targetId.trim()); if (duplicate && duplicate.id !== targetId.trim()) {
if (index === -1) {
return { changed: false, detail: "memory entry not found" };
}
const duplicate = entries.find(
(entry, currentIndex) => currentIndex !== index && entry.content === content,
);
if (duplicate) {
return { changed: false, detail: "replacement would duplicate an existing memory" }; return { changed: false, detail: "replacement would duplicate an existing memory" };
} }
entries[index] = { const result = await this.db.query(
`
UPDATE ${this.db.table("memories")}
SET
content = $4,
source = $5,
session_id = $6,
trace_id = $7
WHERE memory_id = $1
AND scope = $2
AND scope_key = $3
`,
[
targetId.trim(),
scope,
key,
content, content,
id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()), draft.source,
}; draft.sessionId ?? null,
await atomicWriteFileWithHistory( draft.traceId ?? null,
this.filePath(scope, key), ],
renderMemoryMarkdown(scope, entries),
{
backupDir: this.backupDir,
rootDir: this.baseDir,
},
); );
return { changed: true, detail: "memory replaced" }; return result.rowCount === 0
}); ? { changed: false, detail: "memory entry not found" }
: { changed: true, detail: "memory replaced" };
} }
async remove(scope: MemoryScope, key: string, targetId: string) { async remove(scope: MemoryScope, key: string, targetId: string) {
return this.serializeWrite(async () => { const result = await this.db.query(
const entries = await this.readEntries(scope, key); `
const next = entries.filter((entry) => entry.id !== targetId.trim()); DELETE FROM ${this.db.table("memories")}
if (next.length === entries.length) { WHERE memory_id = $1
return { changed: false, detail: "memory entry not found" }; AND scope = $2
} AND scope_key = $3
await atomicWriteFileWithHistory( `,
this.filePath(scope, key), [targetId.trim(), scope, key],
renderMemoryMarkdown(scope, next),
{
backupDir: this.backupDir,
rootDir: this.baseDir,
},
); );
return { changed: true, detail: "memory removed" }; return result.rowCount === 0
}); ? { changed: false, detail: "memory entry not found" }
: { changed: true, detail: "memory removed" };
} }
async buildPromptSnapshot(context: MemoryContext) { async buildPromptSnapshot(context: MemoryContext) {
const [userMemory, workspaceMemory] = await Promise.all([ const [userMemory, workspaceMemory] = await Promise.all([
this.readEntries("user", context.actorKey), this.list("user", context.actorKey),
this.readEntries("workspace", context.projectKey), this.list("workspace", context.projectKey),
]); ]);
const sections: string[] = []; const sections: string[] = [];
@@ -192,26 +192,25 @@ export class MemoryStore {
: block; : block;
} }
private async readEntries(scope: MemoryScope, key: string) { private async findByContent(scope: MemoryScope, key: string, content: string) {
const markdown = await readTextFile(this.filePath(scope, key)); const result = await this.db.query<MemoryRow>(
if (!markdown) { `
return []; SELECT memory_id, content
} FROM ${this.db.table("memories")}
return parseMemoryMarkdown(markdown); WHERE scope = $1
} AND scope_key = $2
AND content = $3
private filePath(scope: MemoryScope, key: string) { LIMIT 1
const dir = scope === "user" ? "users" : "workspaces"; `,
return join(this.baseDir, dir, `${key}.md`); [scope, key, content],
}
private async serializeWrite<T>(task: () => Promise<T>) {
const run = this.writeQueue.catch(() => undefined).then(task);
this.writeQueue = run.then(
() => undefined,
() => undefined,
); );
return run; const row = result.rows[0];
return row
? {
id: row.memory_id,
content: row.content,
}
: null;
} }
} }
@@ -225,31 +224,3 @@ const normalizeMemoryContent = (content: string) => {
} }
return normalized; return normalized;
}; };
const parseMemoryMarkdown = (content: string): MemoryEntry[] =>
content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim())
.map((line) => {
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
if (match) {
return {
content: normalizeMemoryContent(match[2]),
id: match[1],
};
}
const normalized = normalizeMemoryContent(line);
return {
content: normalized,
id: normalized ? toStableId("memory-entry", normalized.toLowerCase()) : "",
};
})
.filter((entry) => entry.content);
const renderMemoryMarkdown = (scope: MemoryScope, entries: MemoryEntry[]) => {
const title = scope === "user" ? "# User Memory" : "# Workspace Memory";
const bullets = entries.map((entry) => `- [${entry.id}] ${entry.content}`);
return [title, "", ...bullets, ""].join("\n");
};
+119 -22
View File
@@ -1,23 +1,24 @@
import { readJsonFile } from "../utils/fileStore.js"; import { config } from "../config.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 = {
actorKey: string; actorKey: string;
clientSessionId: string;
data: unknown; data: unknown;
kind: ResultReferenceKind; kind: ResultReferenceKind;
payloadPath?: string;
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
schemaVersion: number; schemaVersion: number;
@@ -35,7 +36,6 @@ 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,
@@ -47,9 +47,9 @@ export class ResultReferenceResolver {
} }
return this.store.store({ return this.store.store({
actorKey: input.actorKey, actorKey: input.actorKey,
clientSessionId: input.clientSessionId,
data: normalizedData, data: normalizedData,
kind: input.kind, kind: input.kind,
payloadPath: input.payloadPath,
projectId: input.projectId, projectId: input.projectId,
projectKey: input.projectKey, projectKey: input.projectKey,
schemaVersion: input.schemaVersion, schemaVersion: input.schemaVersion,
@@ -68,25 +68,51 @@ export class ResultReferenceResolver {
throw new Error(`render payload file not found: ${filePath}`); throw new Error(`render payload file not found: ${filePath}`);
} }
const wrapper = normalizeRenderPayloadFile(raw, filePath); const payloadCandidate = normalizeRenderPayloadFile(raw, {
if (!wrapper) { filePath,
throw new Error("render payload file must use the wrapped { metadata, location, data } format"); projectId: input.projectId,
});
if (payloadCandidate.repaired) {
await atomicWriteJson(filePath, payloadCandidate.file);
} }
const payload = extractRenderJunctionPayload(wrapper.data); const payload = extractRenderJunctionPayload(payloadCandidate.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");
} }
return this.register({ return this.register({
...input, actorKey: input.actorKey,
data: payload, data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
payloadPath: filePath,
projectId: input.projectId,
projectKey: input.projectKey,
schemaVersion: 1, schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated, sessionId: input.sessionId,
source: input.source,
traceId: input.traceId,
}); });
} }
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,
@@ -144,7 +170,6 @@ 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;
@@ -183,18 +208,35 @@ const normalizeDataForKind = (
const normalizeRenderPayloadFile = ( const normalizeRenderPayloadFile = (
value: unknown, value: unknown,
filePath: string, context: { filePath: string; projectId?: string },
): { data: unknown } | null => { ): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
if (!isRecord(value) || !("data" in value)) { if (!isRecord(value) || !("data" in value)) {
return null; return {
data: value,
file: {
metadata: buildWrapperMetadata({}, value, context.projectId),
location: buildWrapperLocation(undefined, context.filePath),
data: value,
},
repaired: false,
};
} }
if (!isRecord(value.metadata) || !isRecord(value.location)) {
return null; const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
} const location = buildWrapperLocation(value.location, context.filePath);
if (value.location.file_path !== filePath) { const next: Record<string, unknown> = {
return null; ...value,
} metadata,
return { data: value.data }; location,
};
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 => {
@@ -214,5 +256,60 @@ 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,
};
};
+209 -143
View File
@@ -1,26 +1,30 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { join } from "node:path"; import { join, resolve } from "node:path";
import { type QueryResultRow } from "pg";
import { config } from "../config.js"; import { config } from "../config.js";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { import {
atomicWriteJson, atomicWriteJson,
ensureDirectory, ensureDirectory,
getFileStat,
listJsonFiles,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
} 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)?$/;
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 =
@@ -39,7 +43,6 @@ export type ResultPreview = {
export type ResultReferenceRecord = { export type ResultReferenceRecord = {
resultRef: string; resultRef: string;
actorKey: string; actorKey: string;
clientSessionId: string;
createdAt: string; createdAt: string;
data: unknown; data: unknown;
kind: ResultReferenceKind; kind: ResultReferenceKind;
@@ -51,11 +54,12 @@ export type ResultReferenceRecord = {
sizeBytes: number; sizeBytes: number;
source: ResultReferenceSource; source: ResultReferenceSource;
traceId: string; traceId: string;
payloadPath?: string;
objectKey?: string;
}; };
export type StoreResultInput = { export type StoreResultInput = {
actorKey: string; actorKey: string;
clientSessionId: string;
data: unknown; data: unknown;
kind: ResultReferenceKind; kind: ResultReferenceKind;
projectId?: string; projectId?: string;
@@ -64,11 +68,13 @@ export type StoreResultInput = {
sessionId: string; sessionId: string;
source: ResultReferenceSource; source: ResultReferenceSource;
traceId: string; traceId: string;
payloadPath?: string;
objectKey?: string;
}; };
export type RetrievalContext = { export type RetrievalContext = {
actorKey: string; actorKey: string;
clientSessionId?: string; sessionId?: string;
projectId?: string; projectId?: string;
}; };
@@ -79,18 +85,37 @@ export type ResultReferencePeek = {
storedAt: string; storedAt: string;
}; };
type PartialRecord = Partial<ResultReferenceRecord> & { data?: unknown }; type ResultReferenceRow = QueryResultRow & {
result_ref: string;
actor_key: string;
session_id: string;
created_at: Date | string;
kind: ResultReferenceKind;
preview: ResultPreview;
project_id: string | null;
project_key: string;
schema_version: number;
size_bytes: number;
source: ResultReferenceSource;
trace_id: string;
payload_path: string | null;
object_key: string | null;
};
export class ResultReferenceStore { export class ResultReferenceStore {
private cleanupTimer: NodeJS.Timeout | null = null; private cleanupTimer: NodeJS.Timeout | null = null;
private readonly managedPayloadDir: string;
constructor( constructor(
private readonly db: AgentDatabase = getAgentDatabase(),
private readonly baseDir = config.RESULT_REF_STORAGE_DIR, private readonly baseDir = config.RESULT_REF_STORAGE_DIR,
private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000, private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000,
) {} ) {
this.managedPayloadDir = join(this.baseDir, "payloads");
}
async initialize() { async initialize() {
await ensureDirectory(this.baseDir); await Promise.all([this.db.initialize(), ensureDirectory(this.managedPayloadDir)]);
} }
startCleanupLoop() { startCleanupLoop() {
@@ -114,25 +139,77 @@ export class ResultReferenceStore {
async store(input: StoreResultInput) { async store(input: StoreResultInput) {
const resultRef = `res-${randomUUID().slice(0, 16)}`; const resultRef = `res-${randomUUID().slice(0, 16)}`;
const record: ResultReferenceRecord = { const createdAt = new Date().toISOString();
const payloadPath =
input.payloadPath ??
(input.objectKey
? undefined
: this.managedPayloadPath(resultRef));
if (!payloadPath && !input.objectKey) {
throw new Error("result ref requires payloadPath or objectKey");
}
if (!input.payloadPath && payloadPath) {
await atomicWriteJson(payloadPath, wrapPayload(input.data, createdAt, input.projectId));
}
const preview = buildPreview(input.data);
const sizeBytes = estimateBytes(input.data);
await this.db.query(
`
INSERT INTO ${this.db.table("result_refs")} (
result_ref,
actor_key,
session_id,
created_at,
kind,
preview,
project_id,
project_key,
schema_version,
size_bytes,
source,
trace_id,
payload_path,
object_key
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, $14)
`,
[
resultRef,
input.actorKey,
input.sessionId,
createdAt,
input.kind,
JSON.stringify(preview),
input.projectId ?? null,
input.projectKey,
input.schemaVersion,
sizeBytes,
input.source,
input.traceId,
payloadPath ?? null,
input.objectKey ?? null,
],
);
return {
resultRef, resultRef,
actorKey: input.actorKey, actorKey: input.actorKey,
clientSessionId: input.clientSessionId, createdAt,
createdAt: new Date().toISOString(),
data: input.data, data: input.data,
kind: input.kind, kind: input.kind,
preview: buildPreview(input.data), preview,
projectId: input.projectId, projectId: input.projectId,
projectKey: input.projectKey, projectKey: input.projectKey,
schemaVersion: input.schemaVersion, schemaVersion: input.schemaVersion,
sessionId: input.sessionId, sessionId: input.sessionId,
sizeBytes: estimateBytes(input.data), sizeBytes,
source: input.source, source: input.source,
traceId: input.traceId, traceId: input.traceId,
}; payloadPath,
// result_ref 对外暴露短引用,完整数据落盘;这样可以避免大结果直接塞进模型上下文。 objectKey: input.objectKey,
await atomicWriteJson(this.filePath(resultRef), record); } satisfies ResultReferenceRecord;
return record;
} }
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) { async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
@@ -141,26 +218,49 @@ export class ResultReferenceStore {
return null; return null;
} }
const record = normalizeResultReferenceRecord( const result = await this.db.query<ResultReferenceRow>(
await readJsonFile<unknown>(this.filePath(normalizedResultRef)), `
SELECT *
FROM ${this.db.table("result_refs")}
WHERE result_ref = $1
LIMIT 1
`,
[normalizedResultRef],
); );
if (!record) { const row = result.rows[0];
if (!row) {
return null; return null;
} }
// 读取 result_ref 时按用户、项目和可选会话三层校验,防止跨项目/跨用户取数。 if (row.actor_key !== context.actorKey) {
if (record.actorKey !== context.actorKey) {
return null; return null;
} }
if ((record.projectId ?? "") !== (context.projectId ?? "")) { if ((row.project_id ?? "") !== (context.projectId ?? "")) {
return null; return null;
} }
if ( if (context.sessionId && row.session_id !== context.sessionId) {
context.clientSessionId &&
record.clientSessionId !== context.clientSessionId
) {
return null; return null;
} }
return record; const data = await this.readPayload(row);
if (data === null) {
return null;
}
return {
resultRef: row.result_ref,
actorKey: row.actor_key,
createdAt: toIsoString(row.created_at),
data,
kind: row.kind,
preview: row.preview,
projectId: row.project_id ?? undefined,
projectKey: row.project_key,
schemaVersion: row.schema_version,
sessionId: row.session_id,
sizeBytes: row.size_bytes,
source: row.source,
traceId: row.trace_id,
payloadPath: row.payload_path ?? undefined,
objectKey: row.object_key ?? undefined,
} satisfies ResultReferenceRecord;
} }
async peekAuthorized( async peekAuthorized(
@@ -180,137 +280,82 @@ export class ResultReferenceStore {
} }
async listBySession(sessionId: string) { async listBySession(sessionId: string) {
const files = await listJsonFiles(this.baseDir); const result = await this.db.query<ResultReferenceRow>(
const records = await Promise.all( `
files.map(async (filePath) => SELECT *
normalizeResultReferenceRecord(await readJsonFile<unknown>(filePath)), FROM ${this.db.table("result_refs")}
), WHERE session_id = $1
ORDER BY created_at DESC
`,
[sessionId],
); );
return records const records = await Promise.all(
.filter((record): record is ResultReferenceRecord => Boolean(record)) result.rows.map(async (row) => {
.filter((record) => record.sessionId === sessionId) const data = await this.readPayload(row);
.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); if (data === null) {
return null;
}
return {
resultRef: row.result_ref,
actorKey: row.actor_key,
createdAt: toIsoString(row.created_at),
data,
kind: row.kind,
preview: row.preview,
projectId: row.project_id ?? undefined,
projectKey: row.project_key,
schemaVersion: row.schema_version,
sessionId: row.session_id,
sizeBytes: row.size_bytes,
source: row.source,
traceId: row.trace_id,
payloadPath: row.payload_path ?? undefined,
objectKey: row.object_key ?? undefined,
} satisfies ResultReferenceRecord;
}),
);
return records.filter(Boolean) as ResultReferenceRecord[];
} }
async cleanupExpired() { async cleanupExpired() {
const files = await listJsonFiles(this.baseDir); const result = await this.db.query<ResultReferenceRow>(
const now = Date.now(); `
for (const filePath of files) { DELETE FROM ${this.db.table("result_refs")}
const stats = await getFileStat(filePath); WHERE created_at < NOW() - ($1 * INTERVAL '1 millisecond')
if (!stats) { RETURNING *
continue; `,
} [this.ttlMs],
// TTL 以文件修改时间为准,清理长期无人访问的 result_ref 文件。 );
if (now - stats.mtimeMs > this.ttlMs) { await Promise.all(
await removeFileIfExists(filePath); result.rows.map(async (row) => {
} if (row.payload_path && isManagedPayloadPath(row.payload_path, this.managedPayloadDir)) {
await removeFileIfExists(row.payload_path);
} }
}),
);
} }
private filePath(resultRef: string) { private managedPayloadPath(resultRef: string) {
return join(this.baseDir, `${resultRef}.json`); return join(this.managedPayloadDir, `${resultRef}.json`);
}
} }
export const normalizeResultReferenceRecord = ( private async readPayload(row: ResultReferenceRow) {
value: unknown, if (row.payload_path) {
): ResultReferenceRecord | null => { return unwrapStoredPayload(await readJsonFile<unknown>(row.payload_path));
if (!isRecord(value)) { }
if (row.object_key) {
logger.warn({ resultRef: row.result_ref, objectKey: row.object_key }, "object storage payloads are not implemented");
return null; return null;
} }
const partial = value as PartialRecord;
if (
!isValidResultRef(partial.resultRef) ||
typeof partial.actorKey !== "string" ||
typeof partial.clientSessionId !== "string" ||
typeof partial.createdAt !== "string" ||
!("data" in partial) ||
!isResultPreview(partial.preview) ||
typeof partial.projectKey !== "string" ||
typeof partial.sessionId !== "string" ||
typeof partial.sizeBytes !== "number" ||
!Number.isFinite(partial.sizeBytes) ||
typeof partial.traceId !== "string"
) {
return null; return null;
} }
const kind = normalizeResultReferenceKind(partial.kind);
const source = normalizeResultReferenceSource(partial.source);
const schemaVersion =
typeof partial.schemaVersion === "number" &&
Number.isInteger(partial.schemaVersion) &&
partial.schemaVersion > 0
? partial.schemaVersion
: 1;
if (!kind || !source) {
return null;
} }
if (
partial.projectId !== undefined &&
typeof partial.projectId !== "string"
) {
return null;
}
return {
resultRef: partial.resultRef,
actorKey: partial.actorKey,
clientSessionId: partial.clientSessionId,
createdAt: partial.createdAt,
data: partial.data,
kind,
preview: partial.preview,
projectId: partial.projectId,
projectKey: partial.projectKey,
schemaVersion,
sessionId: partial.sessionId,
sizeBytes: partial.sizeBytes,
source,
traceId: partial.traceId,
};
};
const normalizeResultReferenceKind = (
value: unknown,
): ResultReferenceKind | null => {
return Object.values(RESULT_REFERENCE_KIND).includes(
value as ResultReferenceKind,
)
? (value as ResultReferenceKind)
: null;
};
const normalizeResultReferenceSource = (
value: unknown,
): ResultReferenceSource | null => {
return Object.values(RESULT_REFERENCE_SOURCE).includes(
value as ResultReferenceSource,
)
? (value as ResultReferenceSource)
: null;
};
const isValidResultRef = (value: unknown): value is string =>
typeof value === "string" && RESULT_REF_PATTERN.test(value);
const normalizeResultRef = (value: string) => { const normalizeResultRef = (value: string) => {
const match = value.trim().match(RESULT_REF_FILE_PATTERN); const normalized = value.trim();
return match?.[1] ?? null; return RESULT_REF_PATTERN.test(normalized) ? normalized : null;
}; };
const isResultPreview = (value: unknown): value is ResultPreview =>
isRecord(value) &&
typeof value.count === "number" &&
Number.isFinite(value.count) &&
Array.isArray(value.fields) &&
value.fields.every((field) => typeof field === "string") &&
typeof value.summary === "string" &&
"sample" in value;
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data)); const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
const buildPreview = (data: unknown): ResultPreview => { const buildPreview = (data: unknown): ResultPreview => {
@@ -351,5 +396,26 @@ const buildPreview = (data: unknown): ResultPreview => {
}; };
}; };
const wrapPayload = (data: unknown, createdAt: string, projectId?: string) => ({
metadata: {
createdAt,
...(projectId ? { projectId } : {}),
},
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 unwrapStoredPayload = (value: unknown) => {
if (!isRecord(value)) {
return value;
}
return "data" in value ? value.data : value;
};
const toIsoString = (value: Date | string) =>
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
const isManagedPayloadPath = (payloadPath: string, managedPayloadDir: string) =>
resolve(payloadPath).startsWith(resolve(managedPayloadDir));
+125 -517
View File
@@ -2,20 +2,18 @@ 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 SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionHistoryStore } from "../history/store.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 SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type ConversationStateStore } from "../conversations/stateStore.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type ConversationStore } from "../conversations/store.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";
@@ -46,153 +44,20 @@ const forkPayloadSchema = z.object({
keep_message_count: z.coerce.number().int().min(0), keep_message_count: z.coerce.number().int().min(0),
}); });
const sessionStateSchema = z.object({ const conversationStateSchema = 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,
sessionMetadataStore: SessionMetadataStore, conversationStore: ConversationStore,
sessionUiStateStore: SessionUiStateStore, conversationStateStore: ConversationStateStore,
memoryStore: MemoryStore, memoryStore: MemoryStore,
sessionTranscriptStore: SessionTranscriptStore, sessionHistoryStore: SessionHistoryStore,
learningOrchestrator: LearningOrchestrator, learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver, resultReferenceResolver: ResultReferenceResolver,
) => { ) => {
@@ -212,15 +77,13 @@ 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 sessionMetadataStore.ensure({ const { record, created } = await conversationStore.ensure({
actorKey, actorKey,
parentSessionId: parsed.data.parent_session_id, parentSessionId: parsed.data.parent_session_id,
projectId, projectId,
projectKey, projectKey,
sessionId, sessionId: parsed.data.session_id,
userId, userId,
}); });
@@ -228,6 +91,8 @@ export const buildChatRouter = (
session_id: record.sessionId, session_id: record.sessionId,
created_at: record.createdAt, created_at: record.createdAt,
updated_at: record.updatedAt, updated_at: record.updatedAt,
is_streaming: record.isStreaming,
streaming_started_at: record.streamingStartedAt,
status: record.status, status: record.status,
title: record.title, title: record.title,
parent_session_id: record.parentSessionId, parent_session_id: record.parentSessionId,
@@ -239,7 +104,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 sessionMetadataStore.list({ const records = await conversationStore.list({
actorKey, actorKey,
projectId, projectId,
projectKey, projectKey,
@@ -251,10 +116,10 @@ export const buildChatRouter = (
title: record.title ?? "新对话", title: record.title ?? "新对话",
created_at: record.createdAt, created_at: record.createdAt,
updated_at: record.updatedAt, updated_at: record.updatedAt,
is_streaming: record.isStreaming,
streaming_started_at: record.streamingStartedAt,
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),
})), })),
}); });
}); });
@@ -270,7 +135,7 @@ export const buildChatRouter = (
return; return;
} }
const sessionRecord = await sessionMetadataStore.get( const conversation = await conversationStore.get(
{ {
actorKey, actorKey,
projectId, projectId,
@@ -279,97 +144,31 @@ export const buildChatRouter = (
}, },
sessionId, sessionId,
); );
if (!sessionRecord) { if (!conversation) {
res.status(404).json({ message: "session not found" }); res.status(404).json({ message: "session not found" });
return; return;
} }
const state = await sessionUiStateStore.read( const state = await conversationStateStore.read(conversation.sessionId);
toSessionUiStateContext(sessionRecord),
);
res.json({ res.json({
id: sessionRecord.sessionId, id: conversation.sessionId,
title: sessionRecord.title ?? "新对话", title: conversation.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false, is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: sessionRecord.createdAt, created_at: conversation.createdAt,
updated_at: sessionRecord.updatedAt, updated_at: conversation.updatedAt,
status: sessionRecord.status, is_streaming: conversation.isStreaming,
session_id: sessionRecord.sessionId, streaming_started_at: conversation.streamingStartedAt,
status: conversation.status,
session_id: conversation.sessionId,
messages: state?.messages ?? [], messages: state?.messages ?? [],
branch_groups: state?.branchGroups ?? [], branch_groups: state?.branchGroups ?? [],
parent_session_id: sessionRecord.parentSessionId, parent_session_id: conversation.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 = sessionStateSchema.safeParse(req.body ?? {}); const parsed = conversationStateSchema.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",
@@ -387,47 +186,29 @@ export const buildChatRouter = (
return; return;
} }
const { record } = await sessionMetadataStore.ensure({ const { record } = await conversationStore.ensure({
actorKey, actorKey,
projectId, projectId,
projectKey, projectKey,
sessionId, sessionId,
userId, userId,
}); });
const nextRecord = await sessionMetadataStore.touch(record, { const nextRecord = await conversationStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}), ...(parsed.data.title ? { title: parsed.data.title } : {}),
}); });
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), { await conversationStateStore.write(nextRecord.sessionId, {
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 ?? "新对话",
created_at: nextRecord.createdAt, created_at: nextRecord.createdAt,
updated_at: nextRecord.updatedAt, updated_at: nextRecord.updatedAt,
is_streaming: nextRecord.isStreaming,
streaming_started_at: nextRecord.streamingStartedAt,
status: nextRecord.status, status: nextRecord.status,
session_id: nextRecord.sessionId, session_id: nextRecord.sessionId,
}); });
@@ -449,32 +230,27 @@ 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 sessionRecord = await sessionMetadataStore.get( const conversation = await conversationStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
sessionId, sessionId,
); );
if (!sessionRecord) { if (!conversation) {
res.status(404).json({ message: "session not found" }); res.status(404).json({ message: "session not found" });
return; return;
} }
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title }); const nextConversation = await conversationStore.touch(conversation, { title });
const state = await sessionUiStateStore.read( const state = await conversationStateStore.read(nextConversation.sessionId);
toSessionUiStateContext(nextSessionRecord),
);
if (state) { if (state) {
await sessionUiStateStore.write( await conversationStateStore.write(nextConversation.sessionId, {
toSessionUiStateContext(nextSessionRecord),
{
...state, ...state,
isTitleManuallyEdited: isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited, isTitleManuallyEdited ?? state.isTitleManuallyEdited,
}, });
);
} }
res.json({ res.json({
id: nextSessionRecord.sessionId, id: nextConversation.sessionId,
title: nextSessionRecord.title, title: nextConversation.title,
updated_at: nextSessionRecord.updatedAt, updated_at: nextConversation.updatedAt,
}); });
}); });
@@ -488,22 +264,16 @@ export const buildChatRouter = (
res.status(400).json({ message: "session_id is required" }); res.status(400).json({ message: "session_id is required" });
return; return;
} }
const sessionRecord = await sessionMetadataStore.get( const conversation = await conversationStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
sessionId, sessionId,
); );
if (!sessionRecord) { if (!conversation) {
res.status(204).end(); res.status(204).end();
return; return;
} }
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord)); await conversationStateStore.remove(conversation.sessionId);
await sessionBridge.deleteSession({ await conversationStore.remove(conversation);
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();
}); });
@@ -511,7 +281,7 @@ export const buildChatRouter = (
const renderRef = req.params.renderRef?.trim(); const renderRef = req.params.renderRef?.trim();
const userId = req.header("x-user-id")?.trim(); const userId = req.header("x-user-id")?.trim();
const projectId = req.header("x-project-id") ?? undefined; const projectId = req.header("x-project-id") ?? undefined;
const clientSessionId = const sessionId =
typeof req.query.session_id === "string" typeof req.query.session_id === "string"
? req.query.session_id.trim() ? req.query.session_id.trim()
: undefined; : undefined;
@@ -534,7 +304,7 @@ export const buildChatRouter = (
renderRef, renderRef,
{ {
actorKey: toActorKey(userId), actorKey: toActorKey(userId),
clientSessionId, sessionId,
projectId, projectId,
}, },
{ {
@@ -561,64 +331,19 @@ export const buildChatRouter = (
} }
try { try {
const projectId = req.header("x-project-id") ?? undefined; const binding = await sessionBridge.abort({
const userId = req.header("x-user-id") ?? undefined; sessionId: parsed.data.session_id,
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
branchGroups: currentState?.branchGroups ?? [],
}); });
}
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
}
if (!binding && !run) { if (!binding) {
res.status(204).end(); res.status(204).end();
return; return;
} }
logger.info( logger.info(
{ {
clientSessionId: parsed.data.session_id, sessionId: parsed.data.session_id,
sessionId: binding?.sessionId ?? parsed.data.session_id, runtimeSessionId: binding.runtimeSessionId,
}, },
"aborted chat session by client request", "aborted chat session by client request",
); );
@@ -654,8 +379,8 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const sourceSessionId = parsed.data.session_id?.trim(); const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId const sourceConversation = sourceSessionId
? await sessionMetadataStore.get( ? await conversationStore.get(
{ {
actorKey, actorKey,
projectId, projectId,
@@ -665,36 +390,32 @@ export const buildChatRouter = (
sourceSessionId, sourceSessionId,
) )
: null; : null;
const forkSession = await runtime.createSession(); const { record: targetConversation } = await conversationStore.ensure({
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey, actorKey,
parentSessionId: sourceSessionId, parentSessionId: sourceSessionId,
projectId, projectId,
projectKey, projectKey,
sessionId: forkSession.id,
userId, userId,
}); });
const nextSessionId = targetSessionRecord.sessionId; const nextSessionId = targetConversation.sessionId;
if (sourceSessionId && parsed.data.keep_message_count > 0) { if (sourceSessionId && parsed.data.keep_message_count > 0) {
await sessionTranscriptStore.cloneThread( await sessionHistoryStore.cloneThread(
{ {
actorKey, actorKey,
clientSessionId: sourceSessionId,
projectKey, projectKey,
sessionId: sourceSessionId, sessionId: sourceSessionId,
}, },
{ {
actorKey, actorKey,
clientSessionId: nextSessionId,
projectKey, projectKey,
sessionId: nextSessionId, sessionId: nextSessionId,
}, },
parsed.data.keep_message_count, parsed.data.keep_message_count,
); );
if (sourceSessionRecord?.title) { if (sourceConversation?.title) {
await sessionMetadataStore.touch(targetSessionRecord, { await conversationStore.touch(targetConversation, {
title: sourceSessionRecord.title, title: sourceConversation.title,
}); });
} }
} }
@@ -743,54 +464,36 @@ 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 { record: conversation, created: conversationCreated } =
const existingSessionRecord = requestedSessionId await conversationStore.ensure({
? await sessionMetadataStore.get( actorKey,
{ actorKey, projectId, projectKey, userId }, projectId,
requestedSessionId, projectKey,
) sessionId: parsed.data.session_id,
: null; userId,
const hadExistingRuntimeSession = Boolean(existingSessionRecord); });
const activeConversation = await conversationStore.touch(conversation);
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
sessionId: requestedSessionId, sessionId: activeConversation.sessionId,
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,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId, sessionId: requestContext.sessionId,
}; };
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8); await conversationStore.markStreaming(activeConversation, binding.runtimeSessionId);
const initialSessionState = await sessionUiStateStore.read( const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
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, sessionId: requestContext.sessionId,
sessionId: binding.sessionId, runtimeSessionId: binding.runtimeSessionId,
created: created || sessionCreated, created: created || conversationCreated,
model: parsed.data.model, model: parsed.data.model,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
@@ -805,209 +508,114 @@ export const buildChatRouter = (
res.setHeader("X-Accel-Buffering", "no"); res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.(); res.flushHeaders?.();
const clientSessionId = requestContext.clientSessionId; const sessionId = requestContext.sessionId;
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 = () => {
streamClosed = true; if (streamClosed || abortController.signal.aborted) {
activeRun.subscribers.delete(primarySubscriber); return;
}
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, recentTurns,
persistedMessages: initialSessionState?.messages, parsed.data.message,
message: parsed.data.message,
restoreConversation: !hadExistingRuntimeSession,
},
); );
const streamResult = await streamPromptResponse({ const streamResult = await streamPromptResponse({
runtime, runtime,
sessionId: binding.sessionId, opencodeSessionId: binding.runtimeSessionId,
clientSessionId, sessionId,
message: preparedMessage, message: preparedMessage,
model: parsed.data.model, model: parsed.data.model,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
signal: abortController.signal, signal: abortController.signal,
write: (event, data) => { write: (event, data) => {
publish(event, data); if (streamClosed || res.writableEnded || res.destroyed) {
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.runtimeSessionId, 60);
const assistantMessage = [...messages] const assistantMessage = [...messages]
.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 latestSessionRecord = const latestConversation =
(await sessionMetadataStore.get( (await conversationStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
activeSessionRecord.sessionId, activeConversation.sessionId,
)) ?? activeSessionRecord; )) ?? activeConversation;
const latestSessionState = await sessionUiStateStore.read( const latestConversationState = await conversationStateStore.read(
toSessionUiStateContext(latestSessionRecord), latestConversation.sessionId,
); );
const existingSessionTitle = latestSessionRecord.title; const existingSessionTitle = latestConversation.title;
let sessionTitle = existingSessionTitle; let sessionTitle = existingSessionTitle;
const shouldGenerateTitle = shouldGenerateSessionTitle({ const shouldGenerateTitle = shouldGenerateSessionTitle({
recentTurnCount: recentTurns.length, recentTurnCount: recentTurns.length,
isTitleManuallyEdited: isTitleManuallyEdited:
latestSessionState?.isTitleManuallyEdited ?? false, latestConversationState?.isTitleManuallyEdited ?? false,
}); });
if (shouldGenerateTitle) { if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, { sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId, sessionId: binding.runtimeSessionId,
latestAssistantMessage: assistantText, latestAssistantMessage: assistantText,
latestUserMessage: parsed.data.message, latestUserMessage: parsed.data.message,
fallbackTitle: existingSessionTitle, fallbackTitle: existingSessionTitle,
}); });
} }
const nextSessionRecord = await sessionMetadataStore.touch(latestSessionRecord, { const nextConversation = await conversationStore.touch(latestConversation, {
...(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", {
session_id: sessionId,
title: sessionTitle, 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,
toolCallCount: streamResult.toolCallCount,
userMessage: parsed.data.message,
}).catch((error) => {
logger.warn(
{ err: error, sessionId },
"post-turn learning failed",
);
}); });
} }
} }
} finally { } finally {
await persistQueue.catch((error) => { await sessionBridge.releaseRuntimeSession(
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); sessionId,
}); binding.runtimeSessionId,
sessionBridge.finalizeRequest(clientSessionId); );
activeRun.status = abortController.signal.aborted await conversationStore.clearStreaming(sessionId, binding.runtimeSessionId);
? 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);
+6 -118
View File
@@ -1,5 +1,5 @@
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type SessionTurnRecord } from "../sessions/transcriptStore.js"; import { type SessionTurnRecord } from "../history/store.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,22 +192,15 @@ export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore, memoryStore: MemoryStore,
actorKey: string, actorKey: string,
projectKey: string, projectKey: string,
options: { recentTurns: SessionTurnRecord[],
recentTurns: SessionTurnRecord[]; message: string,
persistedMessages?: unknown[];
message: string;
restoreConversation?: boolean;
},
) => { ) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey }); const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
const restoredConversation = options.restoreConversation === false const restoredConversation = buildRestoredConversationContext(recentTurns);
? ""
: buildRestoredConversationFromMessages(options.persistedMessages) ||
buildRestoredConversationContext(options.recentTurns);
if (!snapshot && !restoredConversation) { if (!snapshot && !restoredConversation) {
return options.message; return message;
} }
return [snapshot, restoredConversation, `[Current user request]\n${options.message}`] return [snapshot, restoredConversation, `[Current user request]\n${message}`]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
}; };
@@ -247,108 +240,3 @@ 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);
};
+26 -26
View File
@@ -13,8 +13,8 @@ export type SupportedModel = (typeof supportedModels)[number];
type StreamPromptOptions = { type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
opencodeSessionId: string;
sessionId: string; sessionId: string;
clientSessionId: string;
message: string; message: string;
model?: SupportedModel; model?: SupportedModel;
traceId?: string; traceId?: string;
@@ -36,6 +36,8 @@ 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: "流程沉淀",
@@ -166,11 +168,11 @@ export const collectTextContent = (parts: Part[]) =>
const emitFallbackMessage = async ( const emitFallbackMessage = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
opencodeSessionId: string,
sessionId: string, sessionId: string,
clientSessionId: string,
write: (event: string, data: Record<string, unknown>) => void, write: (event: string, data: Record<string, unknown>) => void,
) => { ) => {
const messages = await runtime.messages(sessionId); const messages = await runtime.messages(opencodeSessionId);
const assistantMessage = [...messages] const assistantMessage = [...messages]
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .find((message) => message.info.role === "assistant");
@@ -178,7 +180,7 @@ const emitFallbackMessage = async (
const text = collectTextContent(parts); const text = collectTextContent(parts);
if (text) { if (text) {
write("token", { write("token", {
session_id: clientSessionId, session_id: sessionId,
content: text, content: text,
}); });
} }
@@ -291,8 +293,8 @@ const getToolProgressTitle = (tool: string, status: string) => {
export const streamPromptResponse = async ({ export const streamPromptResponse = async ({
runtime, runtime,
opencodeSessionId,
sessionId, sessionId,
clientSessionId,
message, message,
model, model,
traceId, traceId,
@@ -330,8 +332,8 @@ 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, sessionId,
clientSessionId,
traceId, traceId,
projectId, projectId,
model: model ?? null, model: model ?? null,
@@ -367,7 +369,7 @@ export const streamPromptResponse = async ({
if (status === "running") { if (status === "running") {
write("progress", { write("progress", {
session_id: clientSessionId, session_id: sessionId,
id, id,
phase, phase,
status, status,
@@ -383,7 +385,7 @@ export const streamPromptResponse = async ({
finalizedProgressIds.add(id); finalizedProgressIds.add(id);
progressStartedAtMap.delete(id); progressStartedAtMap.delete(id);
write("progress", { write("progress", {
session_id: clientSessionId, session_id: sessionId,
id, id,
phase, phase,
status, status,
@@ -404,7 +406,7 @@ export const streamPromptResponse = async ({
}); });
const promptPromise = runtime const promptPromise = runtime
.prompt(sessionId, message, toRuntimeModel(model)) .prompt(opencodeSessionId, message, toRuntimeModel(model))
.then(() => { .then(() => {
promptSettled = true; promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", { logDevelopmentDebug("runtime.prompt resolved", {
@@ -469,7 +471,7 @@ export const streamPromptResponse = async ({
} }
const event = next.result.value as OpencodeEvent; const event = next.result.value as OpencodeEvent;
if (!isSessionEvent(event, sessionId)) { if (!isSessionEvent(event, opencodeSessionId)) {
continue; continue;
} }
@@ -539,8 +541,7 @@ export const streamPromptResponse = async ({
}); });
void writeLlmRequestAuditLog({ void writeLlmRequestAuditLog({
kind: "skill", kind: "skill",
sessionId: sessionId, sessionId: opencodeSessionId,
clientSessionId,
traceId, traceId,
projectId, projectId,
target: name, target: name,
@@ -566,7 +567,7 @@ export const streamPromptResponse = async ({
} }
emittedText = true; emittedText = true;
write("token", { write("token", {
session_id: clientSessionId, session_id: sessionId,
content: event.properties.delta, content: event.properties.delta,
}); });
} else if (partType === "reasoning") { } else if (partType === "reasoning") {
@@ -599,7 +600,7 @@ export const streamPromptResponse = async ({
for (const content of pending) { for (const content of pending) {
emittedText = true; emittedText = true;
write("token", { write("token", {
session_id: clientSessionId, session_id: sessionId,
content, content,
}); });
} }
@@ -689,16 +690,15 @@ export const streamPromptResponse = async ({
logger.warn( logger.warn(
{ {
tool: part.tool, tool: part.tool,
sessionId: sessionId, sessionId: opencodeSessionId,
clientSessionId, requestSessionId: sessionId,
}, },
"llm tool request missing reason", "llm tool request missing reason",
); );
} }
void writeLlmRequestAuditLog({ void writeLlmRequestAuditLog({
kind: "tool", kind: "tool",
sessionId: sessionId, sessionId: opencodeSessionId,
clientSessionId,
traceId, traceId,
projectId, projectId,
target: part.tool, target: part.tool,
@@ -709,7 +709,7 @@ export const streamPromptResponse = async ({
logger.warn({ err: error }, "failed to write tool audit log"); logger.warn({ err: error }, "failed to write tool audit log");
}); });
write("tool_call", { write("tool_call", {
session_id: clientSessionId, session_id: sessionId,
tool: part.tool, tool: part.tool,
params: toolParams, params: toolParams,
reason, reason,
@@ -744,7 +744,7 @@ export const streamPromptResponse = async ({
: "opencode session error", : "opencode session error",
}); });
write("error", { write("error", {
session_id: clientSessionId, session_id: sessionId,
message: event.properties.error message: event.properties.error
? getErrorMessage(event.properties.error) ? getErrorMessage(event.properties.error)
: "opencode session error", : "opencode session error",
@@ -779,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(sessionId).catch((error) => { await runtime.abortSession(opencodeSessionId).catch((error) => {
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session"); logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session");
}); });
await runtime.waitForSessionIdle(sessionId).catch((error) => { await runtime.waitForSessionIdle(opencodeSessionId).catch((error) => {
logger.warn( logger.warn(
{ sessionId: sessionId, err: error }, { sessionId: opencodeSessionId, err: error },
"failed while waiting for aborted opencode session to become idle", "failed while waiting for aborted opencode session to become idle",
); );
}); });
@@ -801,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, sessionId, clientSessionId, write); await emitFallbackMessage(runtime, opencodeSessionId, sessionId, write);
} }
emitProgress({ emitProgress({
id: "request-received", id: "request-received",
@@ -820,7 +820,7 @@ export const streamPromptResponse = async ({
: "已完成分析,并通过兜底消息补发最终回答内容。", : "已完成分析,并通过兜底消息补发最终回答内容。",
}); });
write("done", { write("done", {
session_id: clientSessionId, session_id: sessionId,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt), total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
}); });
logDevelopmentDebug("chat stream completed", { logDevelopmentDebug("chat stream completed", {
-11
View File
@@ -167,7 +167,6 @@ 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({
@@ -187,16 +186,6 @@ 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
@@ -1,27 +0,0 @@
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);
};
+118 -123
View File
@@ -1,44 +1,40 @@
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 { requireAgentAuth } from "./auth/agentAuth.js"; import { SessionHistoryStore } from "./history/store.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 { SessionUiStateStore } from "./sessions/uiStateStore.js"; import { ConversationStateStore } from "./conversations/stateStore.js";
import { SessionMetadataStore } from "./sessions/metadataStore.js"; import { ConversationStore } from "./conversations/store.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 { import { ResultReferenceStore } from "./results/store.js";
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 { getRuntimeSessionContext } from "./runtime/sessionContext.js"; import { RuntimeSessionStore } from "./session/runtimeSessionStore.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 sessionMetadataStore = new SessionMetadataStore(); const conversationStore = new ConversationStore();
const sessionUiStateStore = new SessionUiStateStore(); const conversationStateStore = new ConversationStateStore();
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const sessionTranscriptStore = new SessionTranscriptStore(); const sessionHistoryStore = new SessionHistoryStore();
const runtimeSessionStore = new RuntimeSessionStore();
const learningOrchestrator = new LearningOrchestrator( const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime, opencodeRuntime,
memoryStore, memoryStore,
sessionTranscriptStore, sessionHistoryStore,
); );
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 只用于仍需服务端上下文的工具桥(store_render_ref)。 // 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors()); app.use(cors());
@@ -63,123 +59,122 @@ app.get("/health", async (_req, res) => {
} }
}); });
app.post("/internal/tools/tjwater-cli-call", async (req, res) => { app.post("/internal/tools/dynamic-http-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 sessionId = const runtimeSessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null; const persistedContext = runtimeSessionId
? await runtimeSessionStore.read(runtimeSessionId)
: null;
const runtimeContext = runtimeSessionId
? sessionBridge.getActiveSensitiveContext(runtimeSessionId)
: null;
if (!persistedContext && !runtimeContext) {
res.status(404).json({
message: "runtime or session context not found",
detail: runtimeSessionId,
});
return;
}
const context = persistedContext;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "runtime or session context not found",
detail: sessionId, detail: runtimeSessionId,
});
return;
}
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
if (!command) {
res.status(400).json({ message: "command is required" });
return;
}
const timeoutSec =
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
const authJson = JSON.stringify({
server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken,
project_id: context.projectId,
network:"tjwater",
});
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
const child = spawn(config.TJWATER_CLI_PATH, cliArgs, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString("utf-8");
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf-8");
});
child.stdin.write(authJson);
child.stdin.end();
const exitCode = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve(-1);
}, timeoutSec * 1000);
child.on("close", (code) => {
clearTimeout(timer);
resolve(code);
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
if (exitCode === -1) {
res.status(504).json({
ok: false,
schema_version: "tjwater-cli/v1",
summary: "命令超时",
error: {
code: "TIMEOUT",
message: `command timed out after ${timeoutSec}s`,
retryable: true,
},
});
return;
}
if (exitCode !== 0) {
res.status(502).json({
ok: false,
exit_code: exitCode,
stderr: stderr.slice(0, 2000),
stdout: stdout.slice(0, 2000),
message: `CLI exited with code ${exitCode}`,
}); });
return; return;
} }
try { try {
res.json(JSON.parse(stdout)); // opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
} catch { const result = await dynamicHttpExecutor.execute(
res.json({ {
ok: true, reason: req.body?.reason,
schema_version: "tjwater-cli/v1", path: req.body?.path,
raw: stdout, method: req.body?.method,
stderr: stderr || undefined, arguments: req.body?.arguments,
body: req.body?.body,
},
{
accessToken: runtimeContext?.accessToken,
actorKey: context.actorKey,
sessionId: context.sessionId,
projectId: context.projectId,
projectKey: context.projectKey,
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 runtimeSessionId =
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: runtimeSessionId,
});
return;
}
if (!resultRef) {
res.status(400).json({ message: "result_ref is required" });
return;
}
const result = await resultReferenceResolver.getAuthorized(
resultRef,
{
actorKey: context.actorKey,
sessionId: context.sessionId,
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 sessionId = const runtimeSessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.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 = sessionId ? getRuntimeSessionContext(sessionId) : null; const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionId, detail: runtimeSessionId,
}); });
return; return;
} }
@@ -191,11 +186,10 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
try { try {
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, { const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
actorKey: context.actorKey, actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectId: context.projectId, projectId: context.projectId,
projectKey: context.projectKey, projectKey: context.projectKey,
sessionId: context.clientSessionId, sessionId: context.sessionId,
source: RESULT_REFERENCE_SOURCE.agentGenerated, source: "migration",
traceId: context.traceId, traceId: context.traceId,
}); });
res.json({ res.json({
@@ -222,14 +216,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
return; return;
} }
const sessionId = const runtimeSessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
const query = typeof req.body?.query === "string" ? req.body.query : ""; const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null; const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionId, detail: runtimeSessionId,
}); });
return; return;
} }
@@ -237,7 +231,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 sessionTranscriptStore.search( const hits = await sessionHistoryStore.search(
{ {
actorKey: context.actorKey, actorKey: context.actorKey,
projectKey: context.projectKey, projectKey: context.projectKey,
@@ -253,14 +247,13 @@ 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,
sessionMetadataStore, conversationStore,
sessionUiStateStore, conversationStateStore,
memoryStore, memoryStore,
sessionTranscriptStore, sessionHistoryStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
), ),
@@ -268,13 +261,15 @@ app.use(
const bootstrap = async () => { const bootstrap = async () => {
await Promise.all([ await Promise.all([
sessionMetadataStore.initialize(), conversationStore.initialize(),
sessionUiStateStore.initialize(), conversationStateStore.initialize(),
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
sessionTranscriptStore.initialize(), sessionHistoryStore.initialize(),
runtimeSessionStore.initialize(),
]); ]);
await conversationStore.resetStreamingSessions();
resultReferenceStore.startCleanupLoop(); resultReferenceStore.startCleanupLoop();
}; };
+123
View File
@@ -0,0 +1,123 @@
import { type QueryResultRow } from "pg";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
export type RuntimeSessionContext = {
runtimeSessionId: string;
actorKey: string;
allowLearningWrite?: boolean;
sessionId: string;
learningMode?: "interactive" | "review";
projectId?: string;
projectKey: string;
traceId: string;
releasedAt?: string;
};
type RuntimeSessionRow = QueryResultRow & {
runtime_session_id: string;
actor_key: string;
allow_learning_write: boolean;
session_id: string;
learning_mode: "interactive" | "review" | null;
project_id: string | null;
project_key: string;
trace_id: string;
released_at: Date | string | null;
};
export class RuntimeSessionStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async write(context: RuntimeSessionContext) {
const result = await this.db.query<RuntimeSessionRow>(
`
INSERT INTO ${this.db.table("runtime_sessions")} (
runtime_session_id,
actor_key,
allow_learning_write,
session_id,
learning_mode,
project_id,
project_key,
trace_id,
released_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL)
ON CONFLICT (runtime_session_id)
DO UPDATE SET
actor_key = EXCLUDED.actor_key,
allow_learning_write = EXCLUDED.allow_learning_write,
session_id = EXCLUDED.session_id,
learning_mode = EXCLUDED.learning_mode,
project_id = EXCLUDED.project_id,
project_key = EXCLUDED.project_key,
trace_id = EXCLUDED.trace_id,
released_at = NULL
RETURNING *
`,
[
context.runtimeSessionId,
context.actorKey,
context.allowLearningWrite ?? false,
context.sessionId,
context.learningMode ?? null,
context.projectId ?? null,
context.projectKey,
context.traceId,
],
);
return mapRuntimeSessionRow(result.rows[0]);
}
async read(runtimeSessionId: string, options: { includeReleased?: boolean } = {}) {
const result = await this.db.query<RuntimeSessionRow>(
`
SELECT *
FROM ${this.db.table("runtime_sessions")}
WHERE runtime_session_id = $1
AND ($2::boolean OR released_at IS NULL)
LIMIT 1
`,
[runtimeSessionId, options.includeReleased ?? false],
);
return mapRuntimeSessionRow(result.rows[0]);
}
async release(runtimeSessionId: string) {
await this.db.query(
`
UPDATE ${this.db.table("runtime_sessions")}
SET released_at = NOW()
WHERE runtime_session_id = $1
`,
[runtimeSessionId],
);
}
}
const mapRuntimeSessionRow = (
row?: RuntimeSessionRow | null,
): RuntimeSessionContext | null => {
if (!row) {
return null;
}
return {
runtimeSessionId: row.runtime_session_id,
actorKey: row.actor_key,
allowLearningWrite: row.allow_learning_write,
sessionId: row.session_id,
learningMode: row.learning_mode ?? undefined,
projectId: row.project_id ?? undefined,
projectKey: row.project_key,
traceId: row.trace_id,
releasedAt:
row.released_at instanceof Date
? row.released_at.toISOString()
: row.released_at ?? undefined,
};
};

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