Compare commits
1
Commits
741e39b444
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ac50bfeaa |
@@ -1,4 +1,5 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
.opencode/node_modules
|
||||
.local.env
|
||||
|
||||
@@ -18,11 +18,6 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Setup tools
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq jq
|
||||
jq --version
|
||||
|
||||
- name: Checkout code
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,12 +1,7 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
.opencode/node_modules/
|
||||
.opencode/skills/
|
||||
.local.env
|
||||
.vscode
|
||||
docker-compose.yml
|
||||
data/
|
||||
logs/
|
||||
cli/
|
||||
AGENT_HARNESS_REPORT.md
|
||||
HARNESS_INTRODUCTION.md
|
||||
|
||||
@@ -4,93 +4,31 @@ mode: primary
|
||||
model: deepseek/deepseek-v4-pro
|
||||
temperature: 0.2
|
||||
---
|
||||
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
||||
您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题。
|
||||
|
||||
## 工作流生命周期
|
||||
按照以下规则操作:
|
||||
|
||||
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
|
||||
```
|
||||
初次遇到问题 → tjwater_cli + Python 脚本拼装 → 验证有效 →
|
||||
→ 立即调用 skill_manager 保存到 skills/workflow/<name>/
|
||||
→ 下次遇到同类问题直接加载该 skill,按既定步骤执行
|
||||
```
|
||||
|
||||
## 任务执行决策
|
||||
|
||||
收到用户请求时,按以下顺序决策:
|
||||
|
||||
1. **查已有工作流** — 检查 `skills/workflow/` 下是否存在匹配的 SKILL.md,有则加载并按步骤执行
|
||||
2. **历史参考** — 用 `session_search` 检索历史相似案例,避免重复试错
|
||||
3. **从零拼装** — 无匹配工作流时,自行组合 `tjwater_cli` 命令 + Python 脚本完成
|
||||
4. **完成后复盘** — 判断当前流程是否稳定、可复用,决定是否沉淀为 workflow
|
||||
|
||||
## 工具选择
|
||||
|
||||
| 场景 | 工具 |
|
||||
|------|------|
|
||||
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
|
||||
| 发现可用命令 | `tjwater_cli(command="help")` |
|
||||
| UI 操作 / 可视化 | `locate_features`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` |
|
||||
| 持久化渲染数据 | `store_render_ref` → `render_junctions` |
|
||||
|
||||
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
|
||||
|
||||
## 执行约束
|
||||
|
||||
1. 每次工具调用必须在 `reason` 字段填写具体理由
|
||||
2. `tjwater-cli` 输出为 JSON(`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code`
|
||||
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
|
||||
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
|
||||
5. 无可用数据时不得编造结果
|
||||
|
||||
## 工作流沉淀(skill_manager)
|
||||
|
||||
**写入条件**(必须同时满足):
|
||||
- 经过当前对话验证有效
|
||||
- 可被未来同类任务复用
|
||||
- 非一次性/临时/猜测
|
||||
|
||||
**写入位置**:`skills/workflow/<name>/`,包含 SKILL.md(步骤说明)、references/*.md(参考材料)和 scripts/*.py(分析脚本)。
|
||||
|
||||
**工具动作**:`write_skill / remove_skill` 维护主 SKILL.md;`append_pattern / remove_pattern` 维护 `## Learned Patterns`;`write_reference / remove_reference` 维护 references/*.md;`write_script / remove_script` 维护 scripts/*.py。`write_skill` 可创建或覆盖完整 SKILL.md。
|
||||
|
||||
目录入口也通过 `skill_manager` 维护:更新 `skills/workflow/SKILL.md` 时使用 `write_skill(skill_path="workflow", ...)`,更新根入口 `skills/SKILL.md` 时使用 `write_skill(skill_path="__root__", ...)`。
|
||||
|
||||
**脚本编写要求——优先用 pipe 串联**:
|
||||
|
||||
workflow skill 脚本应尽量用 shell pipe 在一次 subprocess 调用中串联多个 CLI 命令。减少 tool calling 次数,提升执行效率。
|
||||
|
||||
```python
|
||||
import subprocess, os
|
||||
|
||||
# env dict 仅用于当前子进程,不污染 os.environ,多用户安全
|
||||
env = {**os.environ,
|
||||
"TJWATER_SERVER": auth["server"],
|
||||
"TJWATER_ACCESS_TOKEN": auth["access_token"], ...}
|
||||
|
||||
# 好:一次 shell 调用,pipe 串联
|
||||
cmd = "tjwater-cli net list-pipes | jq '...' | xargs tjwater-cli analysis calc"
|
||||
result = subprocess.run(cmd, shell=True, env=env, capture_output=True, text=True)
|
||||
|
||||
# 差:多次 subprocess.run
|
||||
step1 = subprocess.run(["tjwater-cli", "net", "list-pipes"], ...)
|
||||
step2 = subprocess.run(["tjwater-cli", "analysis", "calc"], ...)
|
||||
```
|
||||
|
||||
管道场景下用子进程隔离的 env dict 传认证,释放 stdin 给管道数据流。不修改全局 `os.environ`。认证 JSON 由内部桥接注入,脚本不硬编码。
|
||||
|
||||
CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell 管道中完成。
|
||||
|
||||
**触发时机**:
|
||||
- 用户明确说"保存/沉淀/记录工作流"
|
||||
- 任务完成且所有工具调用已结束、产生最终结果后,再判断当前流程是否稳定可复用
|
||||
- **禁止**在规划任务未完成、工具调用链中间(即仍有 pending 步骤时)触发沉淀
|
||||
- 严禁写入:token、password、secret、API key、system prompt、隐私数据
|
||||
|
||||
## 用户偏好持久化(memory_manager)
|
||||
|
||||
仅保存长期有效的稳定事实,写成简短陈述句。严格区分:
|
||||
- `memory_manager` = 用户偏好 / 项目事实(如"用户要简洁风格"、"当前项目管网规模 5000 管段")
|
||||
- `skill_manager` = 可复用操作流程
|
||||
- `session_search` = 检索历史案例(只读)
|
||||
- 修改 memory 前先 `list` 当前 scope 的已有内容,先通读,再决定 `add / replace / remove`
|
||||
1. 使用 `.opencode/skills/tjwater-skills-root-index` 作为 TJWater 技能树,仅在任务需要该领域知识时加载特定技能。对分析类问题,优先检查 `workflow` 域下是否已有固定工作流(例如 `bottleneck-analysis`);只有在 workflow 不存在、信息不足或需要补充原子能力时,才继续查询其他 API / action skills。
|
||||
2. 当您需要后端数据用于推理、总结、诊断或分析时,优先使用 `dynamic_http_call`。
|
||||
3. 当用户主要需要 UI 操作或可视化时,优先使用前端工具(`locate_features`、`view_history`、`view_scada`、`show_chart`)。
|
||||
4. 仅将前端工具视为显示/交互工具,不要假设它们返回数据。
|
||||
5. 保持回复准确、简洁,对供水网络用户在操作上有用。
|
||||
6. 尊重用户授权和项目隔离,工具调用失败或无可用数据时,切勿编造后端结果。
|
||||
7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。
|
||||
8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。默认遵循 **workflow-first**:先查固定工作流 skill,再按需回落到原子 API skills。
|
||||
9. 当 `dynamic_http_call` 返回 `result_mode = referenced` 和 `result_ref` 时,说明当前只拿到了预览;如果后续推理仍需要完整结果,必须调用 `fetch_result_ref` 回读,不能把 preview 当成完整数据。
|
||||
10. 对 `render_ref`、`result_ref` 或其他引用型结果,默认只使用 preview、摘要、局部字段,或直接把引用传给前端工具;如果引用仅用于渲染/展示(例如 `render_junctions`),直接传引用,不要先读取完整内容再重组。
|
||||
11. 对任何可能很大的引用文件、结果文件或普通大文件,禁止完整读取;优先使用预览、分页、截断、按字段读取、按片段读取或采样读取。只有在没有其他办法且当前推理确实必须依赖完整内容时,才允许读取完整内容,并先明确说明必要性。
|
||||
12. 不得通过 sub-agent、并行代理或任何间接方式,去读取引用文件或大文件的完整内容;主 agent 与其调用链中的其他代理都必须遵守同样限制。
|
||||
13. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
|
||||
- `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实
|
||||
- `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置
|
||||
14. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。
|
||||
15. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager` 或 `skill_manager`。
|
||||
16. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。
|
||||
17. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。
|
||||
18. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
|
||||
19. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
|
||||
20. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
|
||||
21. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns`、`references/` 或 `scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
|
||||
22. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
|
||||
|
||||
@@ -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`
|
||||
@@ -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/` | 按日期手动运行模拟:根据指定日期、开始时间和持续时间查询管网参数并执行水力模拟 |
|
||||
@@ -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-data(username+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 创建快照(保存当前操作节点状态) |
|
||||
@@ -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` | 按方案、时间点和属性名查询全网在指定方案下的模拟值 |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 2:opencode 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 目录。
|
||||
@@ -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` | 列出当前登录用户有权限访问的所有项目信息 |
|
||||
@@ -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`
|
||||
@@ -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/s,DN≥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` 的短管)后再计算,否则容易被极端值放大。
|
||||
- 阈值和评分权重应视为可调启发式,而不是唯一真理;输出时要区分“数据直接支持的结论”和“工程经验推断的建议”。
|
||||
- 地图定位、图表展示属于证据呈现层,不能替代分析层;瓶颈判定必须基于后端原始结果或完整回读数据。
|
||||
- 常见坑点:短管导致单位水头损失虚高、节点或链路映射缺失导致误判、模拟结果不完整时误把局部结果当全量结论。
|
||||
@@ -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}图层应用样式。`;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -5,12 +5,8 @@ export default tool({
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Why this map positioning action is needed for the user request.",
|
||||
),
|
||||
ids: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.describe("Feature ids to locate."),
|
||||
.describe("Why this map positioning action is needed for the user request."),
|
||||
ids: tool.schema.array(tool.schema.string()).describe("Feature ids to locate."),
|
||||
feature_type: tool.schema
|
||||
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
|
||||
.describe("Type of feature to locate."),
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { MemoryStore } from "../../src/memory/store.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
setRuntimeSessionContext,
|
||||
} from "../../src/runtime/sessionContext.js";
|
||||
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
|
||||
|
||||
const memoryStore = new MemoryStore();
|
||||
const initializePromise = memoryStore.initialize();
|
||||
const runtimeSessionStore = new RuntimeSessionStore();
|
||||
const initializePromise = Promise.all([
|
||||
memoryStore.initialize(),
|
||||
runtimeSessionStore.initialize(),
|
||||
]);
|
||||
|
||||
export default tool({
|
||||
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: {
|
||||
action: tool.schema
|
||||
.enum(["add", "list", "replace", "remove"])
|
||||
@@ -36,7 +37,7 @@ export default tool({
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
const sessionContext = getRuntimeSessionContext(context.sessionID);
|
||||
const sessionContext = await runtimeSessionStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
@@ -56,94 +57,68 @@ export default tool({
|
||||
}
|
||||
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "rejected",
|
||||
detail: "memory writes are disabled for this session",
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "rejected",
|
||||
detail: "memory writes are disabled for this session",
|
||||
});
|
||||
}
|
||||
|
||||
const scopeKey =
|
||||
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
|
||||
if (args.action === "list") {
|
||||
const readScopes = {
|
||||
...(sessionContext.memoryListReadScopes ?? {}),
|
||||
[scope]: true,
|
||||
};
|
||||
setRuntimeSessionContext({
|
||||
...sessionContext,
|
||||
memoryListReadScopes: readScopes,
|
||||
});
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "accepted",
|
||||
detail: "memory listed",
|
||||
items: await memoryStore.list(scope, scopeKey),
|
||||
target: scope,
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "accepted",
|
||||
detail: "memory listed",
|
||||
items: await memoryStore.list(scope, scopeKey),
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
|
||||
if (args.action === "add") {
|
||||
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "rejected",
|
||||
detail: `must list ${scope} memory and review existing entries before add`,
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||
content: args.content ?? "",
|
||||
sessionId: sessionContext.clientSessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
content: args.content ?? "",
|
||||
sessionId: sessionContext.sessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
if (!result.entry) {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "rejected",
|
||||
detail: "content rejected by persistence policy",
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "deduped",
|
||||
detail: result.detail,
|
||||
entry: result.entry,
|
||||
target: scope,
|
||||
decision: "rejected",
|
||||
detail: "content rejected by persistence policy",
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "deduped",
|
||||
detail: result.changed ? "memory stored" : "memory already existed",
|
||||
entry: result.entry,
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
|
||||
if (args.action === "replace") {
|
||||
const result = await memoryStore.replace(
|
||||
scope,
|
||||
scopeKey,
|
||||
args.target_id ?? "",
|
||||
{
|
||||
content: args.content ?? "",
|
||||
sessionId: sessionContext.clientSessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
},
|
||||
);
|
||||
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
||||
content: args.content ?? "",
|
||||
sessionId: sessionContext.sessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: scope,
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await memoryStore.remove(
|
||||
scope,
|
||||
scopeKey,
|
||||
args.target_id ?? "",
|
||||
);
|
||||
const result = await memoryStore.remove(scope, scopeKey, args.target_id ?? "");
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
|
||||
@@ -6,13 +6,11 @@ export default tool({
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Why this junction rendering action is needed for the user request.",
|
||||
),
|
||||
.describe("Why this junction rendering action is needed for the user request."),
|
||||
render_ref: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染。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() {
|
||||
|
||||
@@ -22,21 +22,18 @@ export default tool({
|
||||
.describe("Optional maximum number of hits to return."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const response = await fetch(
|
||||
`${internalBaseUrl}/internal/tools/session-search`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
max_results: args.max_results,
|
||||
query: args.query,
|
||||
session_id: context.sessionID,
|
||||
}),
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
max_results: args.max_results,
|
||||
query: args.query,
|
||||
runtimeSessionId: context.sessionID,
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text);
|
||||
|
||||
@@ -21,14 +21,8 @@ export default tool({
|
||||
}),
|
||||
)
|
||||
.describe("Series data."),
|
||||
x_axis_name: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("X-axis display name."),
|
||||
y_axis_name: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Y-axis display name."),
|
||||
x_axis_name: tool.schema.string().optional().describe("X-axis display name."),
|
||||
y_axis_name: tool.schema.string().optional().describe("Y-axis display name."),
|
||||
},
|
||||
async execute() {
|
||||
// 图表数据已经在工具参数里,前端收到 tool_call 后直接渲染,不再二次请求后端。
|
||||
|
||||
@@ -1,154 +1,115 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
type RuntimeSessionContext,
|
||||
} from "../../src/runtime/sessionContext.js";
|
||||
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
|
||||
|
||||
type ToolContextReader = {
|
||||
read(sessionId: string): RuntimeSessionContext | null;
|
||||
};
|
||||
const runtimeSessionStore = new RuntimeSessionStore();
|
||||
const initializePromise = runtimeSessionStore.initialize();
|
||||
const skillStore = new SkillStore();
|
||||
|
||||
const runtimeContextReader: ToolContextReader = {
|
||||
read: getRuntimeSessionContext,
|
||||
};
|
||||
|
||||
export const createSkillManagerTool = (
|
||||
skillStore = new SkillStore(),
|
||||
toolContextStore: ToolContextReader = runtimeContextReader,
|
||||
initializePromise: Promise<unknown> = Promise.resolve(),
|
||||
) =>
|
||||
tool({
|
||||
description:
|
||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
||||
args: {
|
||||
action: tool.schema
|
||||
.enum([
|
||||
"list",
|
||||
"write_skill",
|
||||
"remove_skill",
|
||||
"append_pattern",
|
||||
"remove_pattern",
|
||||
"write_reference",
|
||||
"remove_reference",
|
||||
"write_script",
|
||||
"remove_script",
|
||||
])
|
||||
.describe("Skill maintenance operation."),
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Why this skill maintenance action is justified for future reuse.",
|
||||
),
|
||||
skill_path: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
|
||||
),
|
||||
pattern: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Pattern text used by append_pattern."),
|
||||
target_id: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Stable learned pattern id used by remove_pattern."),
|
||||
file_path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
|
||||
),
|
||||
content: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Content used by write_skill, write_reference, or write_script.",
|
||||
),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
const sessionContext = toolContextStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
if (
|
||||
sessionContext.allowLearningWrite === false &&
|
||||
args.action !== "list"
|
||||
) {
|
||||
export default tool({
|
||||
description:
|
||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
||||
args: {
|
||||
action: tool.schema
|
||||
.enum([
|
||||
"list",
|
||||
"append_pattern",
|
||||
"remove_pattern",
|
||||
"write_reference",
|
||||
"remove_reference",
|
||||
"write_script",
|
||||
"remove_script",
|
||||
])
|
||||
.describe("Skill maintenance operation."),
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Why this skill maintenance action is justified for future reuse.",
|
||||
),
|
||||
skill_path: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta.",
|
||||
),
|
||||
pattern: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Pattern text used by append_pattern."),
|
||||
target_id: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Stable learned pattern id used by remove_pattern."),
|
||||
file_path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
|
||||
content: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Asset content used by write_reference or write_script."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
const sessionContext = await runtimeSessionStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "rejected",
|
||||
detail: "skill writes are disabled for this session",
|
||||
});
|
||||
}
|
||||
if (args.action === "list") {
|
||||
const result = await skillStore.list(args.skill_path);
|
||||
if (!result) {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "rejected",
|
||||
detail: "skill writes are disabled for this session",
|
||||
detail:
|
||||
"invalid skill_path; expected a relative path under .opencode/skills",
|
||||
});
|
||||
}
|
||||
if (args.action === "list") {
|
||||
const result = await skillStore.list(args.skill_path);
|
||||
if (!result) {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "rejected",
|
||||
detail:
|
||||
"invalid skill_path; expected a relative path under .opencode/skills",
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "accepted",
|
||||
detail: "skill listed",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
const result =
|
||||
args.action === "write_skill"
|
||||
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
|
||||
: args.action === "remove_skill"
|
||||
? await skillStore.removeSkill(args.skill_path)
|
||||
: args.action === "append_pattern"
|
||||
? await skillStore.appendPattern(
|
||||
args.skill_path,
|
||||
args.pattern ?? "",
|
||||
)
|
||||
: args.action === "remove_pattern"
|
||||
? await skillStore.removePattern(
|
||||
args.skill_path,
|
||||
args.target_id ?? "",
|
||||
)
|
||||
: args.action === "write_reference"
|
||||
? await skillStore.writeReference(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: args.action === "remove_reference"
|
||||
? await skillStore.removeReference(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
)
|
||||
: args.action === "write_script"
|
||||
? await skillStore.writeScript(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: await skillStore.removeScript(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
);
|
||||
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: result.target,
|
||||
decision: "accepted",
|
||||
detail: "skill listed",
|
||||
...result,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createSkillManagerTool();
|
||||
const result =
|
||||
args.action === "append_pattern"
|
||||
? await skillStore.appendPattern(args.skill_path, args.pattern ?? "")
|
||||
: args.action === "remove_pattern"
|
||||
? await skillStore.removePattern(args.skill_path, args.target_id ?? "")
|
||||
: args.action === "write_reference"
|
||||
? await skillStore.writeReference(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: args.action === "remove_reference"
|
||||
? await skillStore.removeReference(args.skill_path, args.file_path ?? "")
|
||||
: args.action === "write_script"
|
||||
? await skillStore.writeScript(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: await skillStore.removeScript(args.skill_path, args.file_path ?? "");
|
||||
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: result.target,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const internalBaseUrl =
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
|
||||
export default tool({
|
||||
@@ -10,9 +9,7 @@ export default tool({
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Why this local render payload should be persisted as a render_ref.",
|
||||
),
|
||||
.describe("Why this local render payload should be persisted as a render_ref."),
|
||||
file_path: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
@@ -20,20 +17,17 @@ export default tool({
|
||||
),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const response = await fetch(
|
||||
`${internalBaseUrl}/internal/tools/store-render-ref`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: context.sessionID,
|
||||
file_path: args.file_path,
|
||||
}),
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
runtimeSessionId: context.sessionID,
|
||||
file_path: args.file_path,
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -5,23 +5,15 @@ export default tool({
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Why this history panel should be opened for the current task.",
|
||||
),
|
||||
.describe("Why this history panel should be opened for the current task."),
|
||||
feature_infos: tool.schema
|
||||
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
|
||||
.describe("List of [id, type] pairs."),
|
||||
data_type: tool.schema
|
||||
.enum(["realtime", "scheme", "none"])
|
||||
.describe("History data source type."),
|
||||
start_time: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 start time."),
|
||||
end_time: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 end time."),
|
||||
start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."),
|
||||
end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."),
|
||||
},
|
||||
async execute() {
|
||||
// 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。
|
||||
|
||||
@@ -10,18 +10,13 @@ export default tool({
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Preferred SCADA device ids."),
|
||||
device_id: tool.schema
|
||||
.string()
|
||||
device_id: tool.schema.string().optional().describe("Single SCADA device id."),
|
||||
feature_infos: tool.schema
|
||||
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
|
||||
.optional()
|
||||
.describe("Single SCADA device id."),
|
||||
start_time: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 start time."),
|
||||
end_time: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional ISO8601 end time."),
|
||||
.describe("Legacy [id, type] pairs."),
|
||||
start_time: tool.schema.string().optional().describe("Optional ISO8601 start time."),
|
||||
end_time: tool.schema.string().optional().describe("Optional ISO8601 end time."),
|
||||
},
|
||||
async execute() {
|
||||
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。
|
||||
|
||||
+3
-5
@@ -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 && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
jq \
|
||||
unzip \
|
||||
python3 \
|
||||
python3-venv && \
|
||||
@@ -52,9 +51,7 @@ RUN bun install --frozen-lockfile
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||
COPY tsconfig.json opencode.json README.md .gitignore ./
|
||||
COPY tsconfig.json opencode.json README.md ./
|
||||
COPY src ./src
|
||||
COPY .opencode ./.opencode
|
||||
RUN bun run check
|
||||
@@ -69,7 +66,7 @@ ENV PORT=8787
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||
COPY package.json bun.lock ./
|
||||
COPY tsconfig.json opencode.json .gitignore ./
|
||||
COPY tsconfig.json opencode.json ./
|
||||
COPY src ./src
|
||||
COPY .opencode ./.opencode
|
||||
|
||||
@@ -77,6 +74,7 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
COPY .opencode ./.opencode
|
||||
|
||||
EXPOSE 8787
|
||||
CMD ["bun", "src/server.ts"]
|
||||
|
||||
@@ -32,7 +32,7 @@ TJWaterAgent/
|
||||
3. 管理前端 `session_id -> opencode sessionId` 的映射。
|
||||
4. 保存并传递用户 `Authorization`、`x-user-id`、`x-project-id`、`x-trace-id`。
|
||||
5. 把 opencode 输出适配成前端需要的 SSE 事件。
|
||||
6. 为 `.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
|
||||
6. 为 `.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。
|
||||
7. 代理调用真实 TJWater 后端 API。
|
||||
|
||||
当前 Agent API 的主入口:
|
||||
@@ -84,45 +84,34 @@ src/
|
||||
|
||||
```text
|
||||
.opencode/tools/
|
||||
tjwater_cli.ts
|
||||
store_render_ref.ts
|
||||
dynamic_http_call.ts
|
||||
locate_features.ts
|
||||
view_history.ts
|
||||
view_scada.ts
|
||||
show_chart.ts
|
||||
render_junctions.ts
|
||||
apply_layer_style.ts
|
||||
memory_manager.ts
|
||||
session_search.ts
|
||||
skill_manager.ts
|
||||
```
|
||||
|
||||
这些是 opencode 可以调用的自定义工具。
|
||||
|
||||
`tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令。
|
||||
`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`、`render_junctions`、`apply_layer_style` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
|
||||
前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
|
||||
|
||||
### skills
|
||||
|
||||
```text
|
||||
.opencode/skills/
|
||||
.opencode/skills/tjwater-skills-root-index/
|
||||
SKILL.md
|
||||
examples.md
|
||||
runbook.md
|
||||
tjwater-cli/ ← tjwater-cli 可执行文件
|
||||
workflow/ ← 可复用分析工作流
|
||||
SKILL.md
|
||||
simulation-diagnosis/
|
||||
bottleneck-analysis/
|
||||
source-service-area-analysis/
|
||||
ai/
|
||||
analytics/
|
||||
business/
|
||||
data/
|
||||
platform/
|
||||
```
|
||||
|
||||
Skills 仅保留可复用的多步工作流。Agent 通过 `tjwater-cli help` 自行发现原子命令,无需逐接口技能树。
|
||||
这里保存 TJWater 技能树,并保持树结构,符合渐进式披露设计。
|
||||
|
||||
agent 加载技能树时按需取用对应 workflow skill。
|
||||
agent 需要某个领域知识时再按需加载对应 skill,不把整棵技能树作为 always-loaded prompt 一次性注入。
|
||||
|
||||
## 依赖边界
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
"name": "tjwater-agent",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "^1.14.29",
|
||||
"@types/pg": "^8.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"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/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/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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "bun run check",
|
||||
"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",
|
||||
"start": "bun src/server.ts",
|
||||
"start:prod": "bun run check && bun src/server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "^1.14.29",
|
||||
"@types/pg": "^8.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { config } from "../config.js";
|
||||
export type LlmRequestAuditEntry = {
|
||||
kind: "tool" | "skill";
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
target: string;
|
||||
|
||||
@@ -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
@@ -2,20 +2,17 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import {
|
||||
removeRuntimeSessionContext,
|
||||
setRuntimeSessionContext,
|
||||
} from "../runtime/sessionContext.js";
|
||||
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
|
||||
export type SessionBinding = {
|
||||
clientSessionId: string;
|
||||
sessionId: string;
|
||||
runtimeSessionId: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export type SessionContext = {
|
||||
clientSessionId: string;
|
||||
sessionId: string;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
@@ -28,7 +25,10 @@ export type ChatRequestContext = SessionContext & {
|
||||
};
|
||||
|
||||
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) {}
|
||||
|
||||
@@ -43,99 +43,77 @@ export class ChatSessionBridge {
|
||||
requestContext: ChatRequestContext;
|
||||
created: boolean;
|
||||
}> {
|
||||
let requestContext = this.buildRequestContext(context);
|
||||
const existingSessionId = context.sessionId?.trim();
|
||||
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
|
||||
const requestContext = this.buildRequestContext(context);
|
||||
await this.abortActiveRuntime(requestContext.sessionId);
|
||||
|
||||
let sessionId = existingSessionId;
|
||||
let created = false;
|
||||
if (!sessionId) {
|
||||
const session = await this.runtime.createSession();
|
||||
sessionId = session.id;
|
||||
requestContext = {
|
||||
...requestContext,
|
||||
clientSessionId: sessionId,
|
||||
};
|
||||
created = true;
|
||||
}
|
||||
const session = await this.runtime.createSession(requestContext.sessionId);
|
||||
const binding: SessionBinding = {
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId,
|
||||
sessionId: requestContext.sessionId,
|
||||
runtimeSessionId: session.id,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
setRuntimeSessionContext({
|
||||
accessToken: requestContext.accessToken,
|
||||
this.activeRuntimeSessions.set(requestContext.sessionId, session.id);
|
||||
this.activeSensitiveContexts.set(session.id, requestContext);
|
||||
await this.runtimeSessionStore.write({
|
||||
runtimeSessionId: session.id,
|
||||
actorKey: requestContext.actorKey,
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: requestContext.sessionId,
|
||||
learningMode: "interactive",
|
||||
projectId: requestContext.projectId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId,
|
||||
traceId: requestContext.traceId,
|
||||
});
|
||||
|
||||
return { binding, requestContext, created };
|
||||
return { binding, requestContext, created: true };
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.abortControllers.size;
|
||||
return this.activeRuntimeSessions.size;
|
||||
}
|
||||
|
||||
createClientSessionId() {
|
||||
createSessionId() {
|
||||
return `agent-${randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
registerAbortController(clientSessionId: string, controller: AbortController) {
|
||||
this.abortControllers.set(clientSessionId, controller);
|
||||
getActiveSensitiveContext(runtimeSessionId: string) {
|
||||
return this.activeSensitiveContexts.get(runtimeSessionId) ?? null;
|
||||
}
|
||||
|
||||
finalizeRequest(clientSessionId: string) {
|
||||
this.abortControllers.delete(clientSessionId);
|
||||
}
|
||||
|
||||
async abort(context: {
|
||||
clientSessionId?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<SessionBinding | null> {
|
||||
const clientSessionId = context.clientSessionId?.trim();
|
||||
async abort(context: { sessionId?: string }): Promise<SessionBinding | null> {
|
||||
const sessionId = context.sessionId?.trim();
|
||||
if (!clientSessionId || !sessionId) {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.abortActiveRuntime(clientSessionId, sessionId);
|
||||
const runtimeSessionId = this.activeRuntimeSessions.get(sessionId);
|
||||
if (!runtimeSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.abortActiveRuntime(sessionId);
|
||||
return {
|
||||
clientSessionId,
|
||||
sessionId,
|
||||
runtimeSessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSession(context: {
|
||||
clientSessionId: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const clientSessionId = context.clientSessionId.trim();
|
||||
const sessionId = context.sessionId.trim();
|
||||
const controller = this.abortControllers.get(clientSessionId);
|
||||
if (controller) {
|
||||
this.abortControllers.delete(clientSessionId);
|
||||
controller.abort();
|
||||
async releaseRuntimeSession(sessionId: string, runtimeSessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(sessionId);
|
||||
if (activeSessionId === runtimeSessionId) {
|
||||
this.activeRuntimeSessions.delete(sessionId);
|
||||
}
|
||||
await this.runtime.abortSession(sessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ clientSessionId, sessionId, err: error },
|
||||
"failed to abort runtime session",
|
||||
this.activeSensitiveContexts.delete(runtimeSessionId);
|
||||
await this.runtimeSessionStore.release(runtimeSessionId).catch((error) => {
|
||||
logger.debug(
|
||||
{ runtimeSessionId, err: error },
|
||||
"failed to cleanup persisted runtime session",
|
||||
);
|
||||
});
|
||||
await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ clientSessionId, sessionId, err: error },
|
||||
"failed while waiting for runtime session to become idle",
|
||||
);
|
||||
await this.runtime.abortSession(runtimeSessionId).catch((error) => {
|
||||
logger.debug({ runtimeSessionId, err: error }, "failed to cleanup runtime session");
|
||||
});
|
||||
removeRuntimeSessionContext(sessionId);
|
||||
}
|
||||
|
||||
private buildRequestContext(context: {
|
||||
@@ -145,9 +123,8 @@ export class ChatSessionBridge {
|
||||
traceId?: string;
|
||||
userId?: string;
|
||||
}): ChatRequestContext {
|
||||
const sessionId = context.sessionId?.trim();
|
||||
return {
|
||||
clientSessionId: sessionId || this.createClientSessionId(),
|
||||
sessionId: context.sessionId?.trim() || this.createSessionId(),
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
projectId: context.projectId,
|
||||
@@ -157,26 +134,24 @@ export class ChatSessionBridge {
|
||||
};
|
||||
}
|
||||
|
||||
private async abortActiveRuntime(clientSessionId: string, sessionId?: string) {
|
||||
const controller = this.abortControllers.get(clientSessionId);
|
||||
if (controller) {
|
||||
this.abortControllers.delete(clientSessionId);
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
private async abortActiveRuntime(sessionId: string) {
|
||||
const activeRuntimeSessionId = this.activeRuntimeSessions.get(sessionId);
|
||||
if (!activeRuntimeSessionId) {
|
||||
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(
|
||||
{ clientSessionId, sessionId, err: error },
|
||||
"failed to abort active runtime session",
|
||||
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
|
||||
"failed to abort previous active runtime session",
|
||||
);
|
||||
});
|
||||
await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
|
||||
await this.runtime.waitForSessionIdle(activeRuntimeSessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ clientSessionId, sessionId, err: error },
|
||||
"failed while waiting for active runtime session to become idle",
|
||||
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
|
||||
"failed while waiting for previous runtime session to become idle",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+44
-16
@@ -25,6 +25,22 @@ const envSchema = z
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
// HTTP 服务监听地址。
|
||||
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 日志级别。
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
// LLM 工具/技能调用审计日志路径。
|
||||
@@ -33,8 +49,6 @@ const envSchema = z
|
||||
.default("./logs/llm-request-audit.log"),
|
||||
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
|
||||
AGENT_INTERNAL_TOKEN: optionalString(),
|
||||
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。
|
||||
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
|
||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
||||
// embedded opencode server 的监听地址。
|
||||
@@ -45,12 +59,10 @@ const envSchema = z
|
||||
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// 默认使用的 opencode 模型标识。
|
||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
|
||||
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
||||
// client 模式下,目标 opencode server 的基础地址。
|
||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
||||
// tjwater-cli 可执行文件路径。
|
||||
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
|
||||
// 提供给本地 opencode tools 读取的会话上下文目录。
|
||||
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
||||
// TJWater 后端 API 的基础地址。
|
||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||
@@ -61,18 +73,18 @@ const envSchema = z
|
||||
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
||||
// memory 持久化存储目录。
|
||||
MEMORY_STORAGE_DIR: z.string().default("./data/memory"),
|
||||
// 持久化文件写入前保留备份版本的目录。
|
||||
PERSISTENCE_BACKUP_DIR: z.string().default("./data/backup"),
|
||||
// 持久化文件写入前保留历史版本的目录。
|
||||
PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"),
|
||||
// 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
|
||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||
// session transcript 持久化目录。
|
||||
SESSION_TRANSCRIPT_STORAGE_DIR: z.string().default("./data/session-transcripts"),
|
||||
// session metadata 持久化目录。
|
||||
SESSION_METADATA_STORAGE_DIR: z.string().default("./data/session-metadata"),
|
||||
// session UI state 持久化目录。
|
||||
SESSION_UI_STATE_STORAGE_DIR: z.string().default("./data/session-ui-states"),
|
||||
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
||||
// conversation metadata 持久化目录。
|
||||
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
|
||||
// conversation UI state 持久化目录。
|
||||
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
|
||||
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
||||
SESSION_TRANSCRIPT_MAX_TURNS_PER_SESSION: z.coerce
|
||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
@@ -81,8 +93,8 @@ const envSchema = z
|
||||
SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8),
|
||||
// session_search 查询文本最大长度。
|
||||
SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240),
|
||||
// 当前 session 的 learning 进度状态目录。
|
||||
SESSION_LEARNING_STATE_STORAGE_DIR: z.string().default("./data/session-learning-state"),
|
||||
// learning review 会话状态目录。
|
||||
LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"),
|
||||
// learning audit 日志路径。
|
||||
LEARNING_AUDIT_LOG_PATH: z
|
||||
.string()
|
||||
@@ -105,6 +117,12 @@ const envSchema = z
|
||||
.int()
|
||||
.positive()
|
||||
.default(3600000),
|
||||
// fetch_result_ref 默认最多返回的顶层项/字段数量。
|
||||
RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(50),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
|
||||
@@ -114,6 +132,16 @@ const envSchema = z
|
||||
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>;
|
||||
|
||||
@@ -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 : [],
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
`;
|
||||
@@ -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();
|
||||
+59
-105
@@ -3,16 +3,13 @@ import { z } from "zod";
|
||||
import { writeLearningAuditLog } from "../audit/learningAudit.js";
|
||||
import { type ChatRequestContext } from "../chat/sessionBridge.js";
|
||||
import { config } from "../config.js";
|
||||
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import { type SessionTurnRecord, SessionHistoryStore } from "../history/store.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 { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { SkillStore } from "../skills/store.js";
|
||||
import {
|
||||
removeRuntimeSessionContext,
|
||||
setRuntimeSessionContext,
|
||||
} from "../runtime/sessionContext.js";
|
||||
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
|
||||
import {
|
||||
sanitizePersistentDocument,
|
||||
sanitizePersistentLine,
|
||||
@@ -41,13 +38,7 @@ const reviewResultSchema = z.object({
|
||||
skills: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.enum([
|
||||
"append_pattern",
|
||||
"remove_pattern",
|
||||
"write_reference",
|
||||
"write_skill",
|
||||
"remove_skill",
|
||||
]),
|
||||
action: z.enum(["append_pattern", "remove_pattern", "write_reference"]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
content: z.string().optional(),
|
||||
evidence: z.string().default(""),
|
||||
@@ -77,24 +68,27 @@ type TurnReviewInput = {
|
||||
|
||||
export class LearningOrchestrator {
|
||||
private readonly activeReviews = new Set<string>();
|
||||
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
||||
private readonly learningStateStore = new LearningStateStore();
|
||||
private readonly skillStore = new SkillStore();
|
||||
private readonly runtimeSessionStore = new RuntimeSessionStore();
|
||||
|
||||
constructor(
|
||||
private readonly runtime: OpencodeRuntimeAdapter,
|
||||
private readonly memoryStore: MemoryStore,
|
||||
private readonly transcriptStore: SessionTranscriptStore,
|
||||
private readonly historyStore: SessionHistoryStore,
|
||||
) {}
|
||||
|
||||
async initialize() {
|
||||
await this.sessionLearningStateStore.initialize();
|
||||
await Promise.all([
|
||||
this.learningStateStore.initialize(),
|
||||
this.runtimeSessionStore.initialize(),
|
||||
]);
|
||||
}
|
||||
|
||||
async onTurnCompleted(input: TurnReviewInput) {
|
||||
const transcript = await this.transcriptStore.appendTurn(
|
||||
const transcript = await this.historyStore.appendTurn(
|
||||
{
|
||||
actorKey: input.requestContext.actorKey,
|
||||
clientSessionId: input.requestContext.clientSessionId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: input.sessionId,
|
||||
},
|
||||
@@ -110,12 +104,13 @@ export class LearningOrchestrator {
|
||||
}
|
||||
this.activeReviews.add(input.sessionId);
|
||||
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);
|
||||
if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN) {
|
||||
if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN || state.pendingReview) {
|
||||
this.activeReviews.delete(input.sessionId);
|
||||
return;
|
||||
}
|
||||
await this.learningStateStore.markPending(input.sessionId, true);
|
||||
} catch (error) {
|
||||
this.activeReviews.delete(input.sessionId);
|
||||
throw error;
|
||||
@@ -143,17 +138,17 @@ export class LearningOrchestrator {
|
||||
let gateSessionId: string | null = null;
|
||||
try {
|
||||
const gateSession = await this.runtime.createSession(
|
||||
`learning-gate-${input.requestContext.clientSessionId}`,
|
||||
`learning-gate-${input.requestContext.sessionId}`,
|
||||
);
|
||||
gateSessionId = gateSession.id;
|
||||
setRuntimeSessionContext({
|
||||
await this.runtimeSessionStore.write({
|
||||
runtimeSessionId: gateSession.id,
|
||||
actorKey: input.requestContext.actorKey,
|
||||
allowLearningWrite: false,
|
||||
clientSessionId: `gate-${input.requestContext.clientSessionId}`,
|
||||
sessionId: input.requestContext.sessionId,
|
||||
learningMode: "review",
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: gateSession.id,
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
await this.runtime.prompt(
|
||||
@@ -168,7 +163,7 @@ export class LearningOrchestrator {
|
||||
const gateText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const gate = parseGateResult(gateText);
|
||||
if (!gate) {
|
||||
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await this.learningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await writeLearningAuditLog({
|
||||
action: "review-gate",
|
||||
detail: "gate result was not valid JSON",
|
||||
@@ -193,7 +188,7 @@ export class LearningOrchestrator {
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
if (!shouldPromote) {
|
||||
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await this.learningStateStore.completeGate(input.sessionId, turnCount);
|
||||
return;
|
||||
}
|
||||
await this.runReview({
|
||||
@@ -203,6 +198,7 @@ export class LearningOrchestrator {
|
||||
turnCount,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.learningStateStore.markPending(input.sessionId, false);
|
||||
logger.warn({ err: error, sessionId: input.sessionId }, "learning gate failed");
|
||||
await writeLearningAuditLog({
|
||||
action: "review-gate",
|
||||
@@ -214,7 +210,7 @@ export class LearningOrchestrator {
|
||||
});
|
||||
} finally {
|
||||
if (gateSessionId) {
|
||||
removeRuntimeSessionContext(gateSessionId);
|
||||
await this.runtimeSessionStore.release(gateSessionId).catch(() => undefined);
|
||||
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -232,23 +228,22 @@ export class LearningOrchestrator {
|
||||
turnCount: number;
|
||||
}) {
|
||||
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,
|
||||
allowLearningWrite: false,
|
||||
clientSessionId: `review-${input.requestContext.clientSessionId}`,
|
||||
sessionId: input.requestContext.sessionId,
|
||||
learningMode: "review",
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: reviewSession.id,
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
try {
|
||||
const existingMemory = await this.loadMemoryContext(input.requestContext);
|
||||
await this.runtime.prompt(
|
||||
reviewSession.id,
|
||||
buildReviewPrompt({ existingMemory, focus, recentTurns }),
|
||||
buildReviewPrompt({ focus, recentTurns }),
|
||||
toRuntimeModel(input.model),
|
||||
);
|
||||
const messages = await this.runtime.messages(reviewSession.id, 20);
|
||||
@@ -258,7 +253,7 @@ export class LearningOrchestrator {
|
||||
const reviewText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const parsed = parseReviewResult(reviewText);
|
||||
if (!parsed) {
|
||||
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await this.learningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await writeLearningAuditLog({
|
||||
action: "review-parse",
|
||||
detail: "review result was not valid JSON",
|
||||
@@ -270,8 +265,9 @@ export class LearningOrchestrator {
|
||||
return;
|
||||
}
|
||||
await this.applyReviewResult(input, parsed, turnCount);
|
||||
await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount);
|
||||
await this.learningStateStore.completeReview(input.sessionId, turnCount);
|
||||
} catch (error) {
|
||||
await this.learningStateStore.markPending(input.sessionId, false);
|
||||
logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed");
|
||||
await writeLearningAuditLog({
|
||||
action: "review-run",
|
||||
@@ -282,7 +278,7 @@ export class LearningOrchestrator {
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
} finally {
|
||||
removeRuntimeSessionContext(reviewSession.id);
|
||||
await this.runtimeSessionStore.release(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(
|
||||
input: TurnReviewInput,
|
||||
proposal: ReviewResult["memories"][number],
|
||||
@@ -353,33 +341,28 @@ export class LearningOrchestrator {
|
||||
source: "review" as const,
|
||||
traceId: input.requestContext.traceId,
|
||||
};
|
||||
let accepted = false;
|
||||
let detail = "memory rejected";
|
||||
if (proposal.action === "add") {
|
||||
const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft);
|
||||
accepted = Boolean(result.entry);
|
||||
detail = result.detail;
|
||||
} else if (proposal.action === "replace") {
|
||||
const result = await this.memoryStore.replace(
|
||||
proposal.scope as MemoryScope,
|
||||
scopeKey,
|
||||
proposal.target_id ?? "",
|
||||
draft,
|
||||
);
|
||||
accepted = Boolean(result.changed);
|
||||
detail = result.detail;
|
||||
} else {
|
||||
const result = await this.memoryStore.remove(
|
||||
proposal.scope as MemoryScope,
|
||||
scopeKey,
|
||||
proposal.target_id ?? "",
|
||||
);
|
||||
accepted = Boolean(result.changed);
|
||||
detail = result.detail;
|
||||
}
|
||||
const result =
|
||||
proposal.action === "add"
|
||||
? await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft)
|
||||
: proposal.action === "replace"
|
||||
? await this.memoryStore.replace(
|
||||
proposal.scope as MemoryScope,
|
||||
scopeKey,
|
||||
proposal.target_id ?? "",
|
||||
draft,
|
||||
)
|
||||
: await this.memoryStore.remove(
|
||||
proposal.scope as MemoryScope,
|
||||
scopeKey,
|
||||
proposal.target_id ?? "",
|
||||
);
|
||||
const accepted =
|
||||
"entry" in result ? Boolean(result.entry) : Boolean(result.changed);
|
||||
await writeLearningAuditLog({
|
||||
action: `memory-${proposal.action}`,
|
||||
detail: sanitizeAuditDetail(detail),
|
||||
detail: sanitizeAuditDetail(
|
||||
"detail" in result ? result.detail : result.changed ? "memory stored" : "memory deduped",
|
||||
),
|
||||
outcome: accepted ? "accepted" : "rejected",
|
||||
projectId: input.requestContext.projectId,
|
||||
proposal: sanitizeMemoryProposalForAudit(proposal),
|
||||
@@ -414,15 +397,11 @@ export class LearningOrchestrator {
|
||||
proposal.skill_path,
|
||||
proposal.target_id ?? "",
|
||||
)
|
||||
: proposal.action === "write_reference"
|
||||
? await this.skillStore.writeReference(
|
||||
proposal.skill_path,
|
||||
proposal.file_path ?? "",
|
||||
proposal.content ?? "",
|
||||
)
|
||||
: proposal.action === "write_skill"
|
||||
? await this.skillStore.writeSkill(proposal.skill_path, proposal.content ?? "")
|
||||
: await this.skillStore.removeSkill(proposal.skill_path);
|
||||
: await this.skillStore.writeReference(
|
||||
proposal.skill_path,
|
||||
proposal.file_path ?? "",
|
||||
proposal.content ?? "",
|
||||
);
|
||||
await writeLearningAuditLog({
|
||||
action: `skill-${proposal.action}`,
|
||||
detail: sanitizeAuditDetail(result.detail),
|
||||
@@ -458,14 +437,9 @@ const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] })
|
||||
};
|
||||
|
||||
const buildReviewPrompt = ({
|
||||
existingMemory,
|
||||
focus,
|
||||
recentTurns,
|
||||
}: {
|
||||
existingMemory: {
|
||||
userMemory: Array<{ content: string; id: string }>;
|
||||
workspaceMemory: Array<{ content: string; id: string }>;
|
||||
};
|
||||
focus: GateResult["focus"];
|
||||
recentTurns: SessionTurnRecord[];
|
||||
}) => {
|
||||
@@ -475,16 +449,6 @@ const buildReviewPrompt = ({
|
||||
`Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
const userMemoryBlock =
|
||||
existingMemory.userMemory.length > 0
|
||||
? existingMemory.userMemory.map((entry) => `- [${entry.id}] ${entry.content}`).join("\n")
|
||||
: "(empty)";
|
||||
const workspaceMemoryBlock =
|
||||
existingMemory.workspaceMemory.length > 0
|
||||
? existingMemory.workspaceMemory
|
||||
.map((entry) => `- [${entry.id}] ${entry.content}`)
|
||||
.join("\n")
|
||||
: "(empty)";
|
||||
return [
|
||||
"You are doing an internal self-improvement review for TJWaterAgent.",
|
||||
"Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.",
|
||||
@@ -495,26 +459,16 @@ const buildReviewPrompt = ({
|
||||
"- Keep only stable user preferences, durable constraints, or stable workspace facts.",
|
||||
"- Use scope='user' for user preferences and constraints.",
|
||||
"- Use scope='workspace' for project or environment facts.",
|
||||
"- Read the existing memories first before proposing changes.",
|
||||
"- If a new lesson overlaps, refines, or supersedes an existing memory, prefer replace/remove using target_id instead of add.",
|
||||
"- Use add only when the lesson is genuinely new after reviewing the existing memory list.",
|
||||
"- Do not store one-off task outcomes, temporary facts, or speculative conclusions.",
|
||||
"",
|
||||
"Current persisted memories:",
|
||||
"[User memory]",
|
||||
userMemoryBlock,
|
||||
"[Workspace memory]",
|
||||
workspaceMemoryBlock,
|
||||
"",
|
||||
"Skill rules:",
|
||||
"- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.",
|
||||
"- Prefer append_pattern for concise reusable lessons.",
|
||||
"- Use write_reference only for compact durable supporting notes under references/*.md.",
|
||||
"- Use write_skill only when the conversation establishes a complete reusable SKILL.md with frontmatter name and description; it creates or overwrites the main SKILL.md.",
|
||||
"- Use remove_skill only when the conversation clearly establishes the whole skill is obsolete or invalid.",
|
||||
"- Do not edit frontmatter or arbitrary sections.",
|
||||
"",
|
||||
"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.",
|
||||
"",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -2,40 +2,14 @@ import pino from "pino";
|
||||
|
||||
import { config } from "./config.js";
|
||||
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
|
||||
const padMilliseconds = (value: number) => value.toString().padStart(3, "0");
|
||||
|
||||
const formatLocalTimestamp = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
const seconds = pad(date.getSeconds());
|
||||
const milliseconds = padMilliseconds(date.getMilliseconds());
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
|
||||
const offsetHours = pad(Math.floor(absoluteOffsetMinutes / 60));
|
||||
const offsetRemainderMinutes = pad(absoluteOffsetMinutes % 60);
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${sign}${offsetHours}:${offsetRemainderMinutes}`;
|
||||
};
|
||||
|
||||
export const logger = pino({
|
||||
level: config.LOG_LEVEL,
|
||||
formatters: {
|
||||
level: (label) => ({ level: label }),
|
||||
},
|
||||
timestamp: () => `,"time":"${formatLocalTimestamp(new Date())}"`,
|
||||
transport:
|
||||
config.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l o",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
+124
-153
@@ -1,13 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { type QueryResultRow } from "pg";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
|
||||
import {
|
||||
atomicWriteFileWithHistory,
|
||||
ensureDirectory,
|
||||
readTextFile,
|
||||
toStableId,
|
||||
} from "../utils/fileStore.js";
|
||||
import { toStableId } from "../utils/fileStore.js";
|
||||
|
||||
export type MemoryScope = "user" | "workspace";
|
||||
export type MemoryEntrySource = "review" | "tool";
|
||||
@@ -29,6 +25,17 @@ type MemoryContext = {
|
||||
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 = [
|
||||
/ignore\s+(all|previous|prior|above)\s+instructions/i,
|
||||
/system\s+prompt/i,
|
||||
@@ -37,125 +44,118 @@ const SUSPICIOUS_MEMORY_PATTERNS = [
|
||||
];
|
||||
|
||||
export class MemoryStore {
|
||||
// Memory 文件可能被多次连续追加,串行化可避免并发覆盖掉刚写入的条目。
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly baseDir = config.MEMORY_STORAGE_DIR,
|
||||
private readonly backupDir = join(config.PERSISTENCE_BACKUP_DIR, "memory"),
|
||||
) {}
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
await ensureDirectory(join(this.baseDir, "users"));
|
||||
await ensureDirectory(join(this.baseDir, "workspaces"));
|
||||
// 备份与正式数据分目录存放,便于排查和手工恢复。
|
||||
await ensureDirectory(this.backupDir);
|
||||
await this.db.initialize();
|
||||
}
|
||||
|
||||
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
|
||||
return this.serializeWrite(async () => {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return {
|
||||
changed: false,
|
||||
detail: "content rejected by persistence policy",
|
||||
entry: null as MemoryEntry | null,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await this.readEntries(scope, key);
|
||||
const existing = entries.find((entry) => entry.content === content);
|
||||
if (existing) {
|
||||
return {
|
||||
changed: false,
|
||||
detail: "memory already existed",
|
||||
entry: existing,
|
||||
};
|
||||
}
|
||||
|
||||
const entry: MemoryEntry = {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, entry: null as MemoryEntry | null };
|
||||
}
|
||||
const existing = await this.findByContent(scope, key, content);
|
||||
if (existing) {
|
||||
return { changed: false, entry: existing };
|
||||
}
|
||||
const id = toStableId(scope, key, content.toLowerCase());
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO ${this.db.table("memories")} (
|
||||
memory_id,
|
||||
scope,
|
||||
scope_key,
|
||||
content,
|
||||
source,
|
||||
session_id,
|
||||
trace_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
[id, scope, key, content, draft.source, draft.sessionId ?? null, draft.traceId ?? null],
|
||||
);
|
||||
return {
|
||||
changed: true,
|
||||
entry: {
|
||||
id,
|
||||
content,
|
||||
id: toStableId(scope, key, content.toLowerCase()),
|
||||
};
|
||||
entries.unshift(entry);
|
||||
// 每次覆盖 memory 文件前先保留上一版,写入失败时由底层工具恢复。
|
||||
await atomicWriteFileWithHistory(
|
||||
this.filePath(scope, key),
|
||||
renderMemoryMarkdown(scope, entries),
|
||||
{
|
||||
backupDir: this.backupDir,
|
||||
rootDir: this.baseDir,
|
||||
},
|
||||
);
|
||||
return {
|
||||
changed: true,
|
||||
detail: "memory stored",
|
||||
entry,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
return this.serializeWrite(async () => {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, detail: "content rejected by persistence policy" };
|
||||
}
|
||||
const entries = await this.readEntries(scope, key);
|
||||
const index = entries.findIndex((entry) => entry.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" };
|
||||
}
|
||||
entries[index] = {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, detail: "content rejected by persistence policy" };
|
||||
}
|
||||
const duplicate = await this.findByContent(scope, key, content);
|
||||
if (duplicate && duplicate.id !== targetId.trim()) {
|
||||
return { changed: false, detail: "replacement would duplicate an existing memory" };
|
||||
}
|
||||
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,
|
||||
id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()),
|
||||
};
|
||||
await atomicWriteFileWithHistory(
|
||||
this.filePath(scope, key),
|
||||
renderMemoryMarkdown(scope, entries),
|
||||
{
|
||||
backupDir: this.backupDir,
|
||||
rootDir: this.baseDir,
|
||||
},
|
||||
);
|
||||
return { changed: true, detail: "memory replaced" };
|
||||
});
|
||||
draft.source,
|
||||
draft.sessionId ?? null,
|
||||
draft.traceId ?? null,
|
||||
],
|
||||
);
|
||||
return result.rowCount === 0
|
||||
? { changed: false, detail: "memory entry not found" }
|
||||
: { changed: true, detail: "memory replaced" };
|
||||
}
|
||||
|
||||
async remove(scope: MemoryScope, key: string, targetId: string) {
|
||||
return this.serializeWrite(async () => {
|
||||
const entries = await this.readEntries(scope, key);
|
||||
const next = entries.filter((entry) => entry.id !== targetId.trim());
|
||||
if (next.length === entries.length) {
|
||||
return { changed: false, detail: "memory entry not found" };
|
||||
}
|
||||
await atomicWriteFileWithHistory(
|
||||
this.filePath(scope, key),
|
||||
renderMemoryMarkdown(scope, next),
|
||||
{
|
||||
backupDir: this.backupDir,
|
||||
rootDir: this.baseDir,
|
||||
},
|
||||
);
|
||||
return { changed: true, detail: "memory removed" };
|
||||
});
|
||||
const result = await this.db.query(
|
||||
`
|
||||
DELETE FROM ${this.db.table("memories")}
|
||||
WHERE memory_id = $1
|
||||
AND scope = $2
|
||||
AND scope_key = $3
|
||||
`,
|
||||
[targetId.trim(), scope, key],
|
||||
);
|
||||
return result.rowCount === 0
|
||||
? { changed: false, detail: "memory entry not found" }
|
||||
: { changed: true, detail: "memory removed" };
|
||||
}
|
||||
|
||||
async buildPromptSnapshot(context: MemoryContext) {
|
||||
const [userMemory, workspaceMemory] = await Promise.all([
|
||||
this.readEntries("user", context.actorKey),
|
||||
this.readEntries("workspace", context.projectKey),
|
||||
this.list("user", context.actorKey),
|
||||
this.list("workspace", context.projectKey),
|
||||
]);
|
||||
|
||||
const sections: string[] = [];
|
||||
@@ -192,26 +192,25 @@ export class MemoryStore {
|
||||
: block;
|
||||
}
|
||||
|
||||
private async readEntries(scope: MemoryScope, key: string) {
|
||||
const markdown = await readTextFile(this.filePath(scope, key));
|
||||
if (!markdown) {
|
||||
return [];
|
||||
}
|
||||
return parseMemoryMarkdown(markdown);
|
||||
}
|
||||
|
||||
private filePath(scope: MemoryScope, key: string) {
|
||||
const dir = scope === "user" ? "users" : "workspaces";
|
||||
return join(this.baseDir, dir, `${key}.md`);
|
||||
}
|
||||
|
||||
private async serializeWrite<T>(task: () => Promise<T>) {
|
||||
const run = this.writeQueue.catch(() => undefined).then(task);
|
||||
this.writeQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
private async findByContent(scope: MemoryScope, key: string, content: string) {
|
||||
const result = await this.db.query<MemoryRow>(
|
||||
`
|
||||
SELECT memory_id, content
|
||||
FROM ${this.db.table("memories")}
|
||||
WHERE scope = $1
|
||||
AND scope_key = $2
|
||||
AND content = $3
|
||||
LIMIT 1
|
||||
`,
|
||||
[scope, key, content],
|
||||
);
|
||||
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;
|
||||
};
|
||||
|
||||
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
@@ -1,23 +1,24 @@
|
||||
import { readJsonFile } from "../utils/fileStore.js";
|
||||
import { config } from "../config.js";
|
||||
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
||||
import {
|
||||
type ResultReferenceKind,
|
||||
type ResultReferenceRecord,
|
||||
type ResultReferenceSource,
|
||||
type RetrievalContext,
|
||||
RESULT_REFERENCE_KIND,
|
||||
RESULT_REFERENCE_SOURCE,
|
||||
type ResultReferenceStore,
|
||||
} from "./store.js";
|
||||
|
||||
type ResolveOptions = {
|
||||
expectedKind?: ResultReferenceKind;
|
||||
maxItems?: number;
|
||||
};
|
||||
|
||||
type RegisterResultReferenceInput = {
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
payloadPath?: string;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
schemaVersion: number;
|
||||
@@ -35,7 +36,6 @@ export type RenderJunctionPayload = {
|
||||
export class ResultReferenceResolver {
|
||||
constructor(private readonly store: ResultReferenceStore) {}
|
||||
|
||||
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
||||
async register(input: RegisterResultReferenceInput) {
|
||||
const normalizedData = normalizeDataForKind(
|
||||
input.kind,
|
||||
@@ -47,9 +47,9 @@ export class ResultReferenceResolver {
|
||||
}
|
||||
return this.store.store({
|
||||
actorKey: input.actorKey,
|
||||
clientSessionId: input.clientSessionId,
|
||||
data: normalizedData,
|
||||
kind: input.kind,
|
||||
payloadPath: input.payloadPath,
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: input.schemaVersion,
|
||||
@@ -68,25 +68,51 @@ export class ResultReferenceResolver {
|
||||
throw new Error(`render payload file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const wrapper = normalizeRenderPayloadFile(raw, filePath);
|
||||
if (!wrapper) {
|
||||
throw new Error("render payload file must use the wrapped { metadata, location, data } format");
|
||||
const payloadCandidate = normalizeRenderPayloadFile(raw, {
|
||||
filePath,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
if (payloadCandidate.repaired) {
|
||||
await atomicWriteJson(filePath, payloadCandidate.file);
|
||||
}
|
||||
|
||||
const payload = extractRenderJunctionPayload(wrapper.data);
|
||||
const payload = extractRenderJunctionPayload(payloadCandidate.data);
|
||||
if (!payload) {
|
||||
throw new Error("render payload file does not contain a valid junction render payload");
|
||||
}
|
||||
|
||||
return this.register({
|
||||
...input,
|
||||
actorKey: input.actorKey,
|
||||
data: payload,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
payloadPath: filePath,
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
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(
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
@@ -144,7 +170,6 @@ export const extractRenderJunctionPayload = (
|
||||
return null;
|
||||
}
|
||||
|
||||
// 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。
|
||||
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
||||
if (Object.keys(nodeAreaMap).length === 0) {
|
||||
return null;
|
||||
@@ -183,18 +208,35 @@ const normalizeDataForKind = (
|
||||
|
||||
const normalizeRenderPayloadFile = (
|
||||
value: unknown,
|
||||
filePath: string,
|
||||
): { data: unknown } | null => {
|
||||
context: { filePath: string; projectId?: string },
|
||||
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
|
||||
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;
|
||||
}
|
||||
if (value.location.file_path !== filePath) {
|
||||
return null;
|
||||
}
|
||||
return { data: value.data };
|
||||
|
||||
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
|
||||
const location = buildWrapperLocation(value.location, context.filePath);
|
||||
const next: Record<string, unknown> = {
|
||||
...value,
|
||||
metadata,
|
||||
location,
|
||||
};
|
||||
|
||||
return {
|
||||
data: next.data,
|
||||
file: next,
|
||||
repaired:
|
||||
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
|
||||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
||||
@@ -214,5 +256,60 @@ const normalizeStringRecord = (value: Record<string, unknown>) =>
|
||||
.filter(([, entry]) => entry.length > 0),
|
||||
);
|
||||
|
||||
const projectData = (data: unknown, maxItems: number) => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.slice(0, maxItems);
|
||||
}
|
||||
if (isRecord(data)) {
|
||||
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const buildWrapperMetadata = (
|
||||
value: unknown,
|
||||
root: unknown,
|
||||
fallbackProjectId?: string,
|
||||
) => {
|
||||
const metadata = isRecord(value) ? { ...value } : {};
|
||||
const source = isRecord(root) ? root : {};
|
||||
|
||||
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
|
||||
const createdAt =
|
||||
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
|
||||
? source.createdAt.trim()
|
||||
: new Date().toISOString();
|
||||
metadata.createdAt = createdAt;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof metadata.projectId !== "string" ||
|
||||
metadata.projectId.trim().length === 0
|
||||
) {
|
||||
const projectId =
|
||||
typeof source.projectId === "string" && source.projectId.trim().length > 0
|
||||
? source.projectId.trim()
|
||||
: fallbackProjectId;
|
||||
if (projectId) {
|
||||
metadata.projectId = projectId;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const buildWrapperLocation = (value: unknown, filePath: string) => {
|
||||
if (isRecord(value)) {
|
||||
return {
|
||||
...value,
|
||||
file_path: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
file_path: filePath,
|
||||
};
|
||||
};
|
||||
|
||||
+215
-149
@@ -1,26 +1,30 @@
|
||||
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 { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
getFileStat,
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
||||
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||
|
||||
export const RESULT_REFERENCE_KIND = {
|
||||
dynamicHttpResult: "dynamic-http-result",
|
||||
renderJunctionsPayload: "render-junctions-payload",
|
||||
} as const;
|
||||
|
||||
export const RESULT_REFERENCE_SOURCE = {
|
||||
dynamicHttp: "dynamic_http",
|
||||
agentGenerated: "agent_generated",
|
||||
legacy: "legacy",
|
||||
migration: "migration",
|
||||
} as const;
|
||||
|
||||
export type ResultReferenceKind =
|
||||
@@ -39,7 +43,6 @@ export type ResultPreview = {
|
||||
export type ResultReferenceRecord = {
|
||||
resultRef: string;
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
createdAt: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
@@ -51,11 +54,12 @@ export type ResultReferenceRecord = {
|
||||
sizeBytes: number;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
payloadPath?: string;
|
||||
objectKey?: string;
|
||||
};
|
||||
|
||||
export type StoreResultInput = {
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
projectId?: string;
|
||||
@@ -64,11 +68,13 @@ export type StoreResultInput = {
|
||||
sessionId: string;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
payloadPath?: string;
|
||||
objectKey?: string;
|
||||
};
|
||||
|
||||
export type RetrievalContext = {
|
||||
actorKey: string;
|
||||
clientSessionId?: string;
|
||||
sessionId?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
@@ -79,18 +85,37 @@ export type ResultReferencePeek = {
|
||||
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 {
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
private readonly managedPayloadDir: string;
|
||||
|
||||
constructor(
|
||||
private readonly db: AgentDatabase = getAgentDatabase(),
|
||||
private readonly baseDir = config.RESULT_REF_STORAGE_DIR,
|
||||
private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000,
|
||||
) {}
|
||||
) {
|
||||
this.managedPayloadDir = join(this.baseDir, "payloads");
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
await Promise.all([this.db.initialize(), ensureDirectory(this.managedPayloadDir)]);
|
||||
}
|
||||
|
||||
startCleanupLoop() {
|
||||
@@ -114,25 +139,77 @@ export class ResultReferenceStore {
|
||||
|
||||
async store(input: StoreResultInput) {
|
||||
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,
|
||||
actorKey: input.actorKey,
|
||||
clientSessionId: input.clientSessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt,
|
||||
data: input.data,
|
||||
kind: input.kind,
|
||||
preview: buildPreview(input.data),
|
||||
preview,
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: input.schemaVersion,
|
||||
sessionId: input.sessionId,
|
||||
sizeBytes: estimateBytes(input.data),
|
||||
sizeBytes,
|
||||
source: input.source,
|
||||
traceId: input.traceId,
|
||||
};
|
||||
// result_ref 对外暴露短引用,完整数据落盘;这样可以避免大结果直接塞进模型上下文。
|
||||
await atomicWriteJson(this.filePath(resultRef), record);
|
||||
return record;
|
||||
payloadPath,
|
||||
objectKey: input.objectKey,
|
||||
} satisfies ResultReferenceRecord;
|
||||
}
|
||||
|
||||
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||
@@ -141,26 +218,49 @@ export class ResultReferenceStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = normalizeResultReferenceRecord(
|
||||
await readJsonFile<unknown>(this.filePath(normalizedResultRef)),
|
||||
const result = await this.db.query<ResultReferenceRow>(
|
||||
`
|
||||
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;
|
||||
}
|
||||
// 读取 result_ref 时按用户、项目和可选会话三层校验,防止跨项目/跨用户取数。
|
||||
if (record.actorKey !== context.actorKey) {
|
||||
if (row.actor_key !== context.actorKey) {
|
||||
return null;
|
||||
}
|
||||
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
||||
if ((row.project_id ?? "") !== (context.projectId ?? "")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
context.clientSessionId &&
|
||||
record.clientSessionId !== context.clientSessionId
|
||||
) {
|
||||
if (context.sessionId && row.session_id !== context.sessionId) {
|
||||
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(
|
||||
@@ -180,137 +280,82 @@ export class ResultReferenceStore {
|
||||
}
|
||||
|
||||
async listBySession(sessionId: string) {
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(
|
||||
files.map(async (filePath) =>
|
||||
normalizeResultReferenceRecord(await readJsonFile<unknown>(filePath)),
|
||||
),
|
||||
const result = await this.db.query<ResultReferenceRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("result_refs")}
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
[sessionId],
|
||||
);
|
||||
return records
|
||||
.filter((record): record is ResultReferenceRecord => Boolean(record))
|
||||
.filter((record) => record.sessionId === sessionId)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||
const records = await Promise.all(
|
||||
result.rows.map(async (row) => {
|
||||
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;
|
||||
}),
|
||||
);
|
||||
return records.filter(Boolean) as ResultReferenceRecord[];
|
||||
}
|
||||
|
||||
async cleanupExpired() {
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const now = Date.now();
|
||||
for (const filePath of files) {
|
||||
const stats = await getFileStat(filePath);
|
||||
if (!stats) {
|
||||
continue;
|
||||
}
|
||||
// TTL 以文件修改时间为准,清理长期无人访问的 result_ref 文件。
|
||||
if (now - stats.mtimeMs > this.ttlMs) {
|
||||
await removeFileIfExists(filePath);
|
||||
}
|
||||
}
|
||||
const result = await this.db.query<ResultReferenceRow>(
|
||||
`
|
||||
DELETE FROM ${this.db.table("result_refs")}
|
||||
WHERE created_at < NOW() - ($1 * INTERVAL '1 millisecond')
|
||||
RETURNING *
|
||||
`,
|
||||
[this.ttlMs],
|
||||
);
|
||||
await Promise.all(
|
||||
result.rows.map(async (row) => {
|
||||
if (row.payload_path && isManagedPayloadPath(row.payload_path, this.managedPayloadDir)) {
|
||||
await removeFileIfExists(row.payload_path);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private filePath(resultRef: string) {
|
||||
return join(this.baseDir, `${resultRef}.json`);
|
||||
private managedPayloadPath(resultRef: string) {
|
||||
return join(this.managedPayloadDir, `${resultRef}.json`);
|
||||
}
|
||||
|
||||
private async readPayload(row: ResultReferenceRow) {
|
||||
if (row.payload_path) {
|
||||
return unwrapStoredPayload(await readJsonFile<unknown>(row.payload_path));
|
||||
}
|
||||
if (row.object_key) {
|
||||
logger.warn({ resultRef: row.result_ref, objectKey: row.object_key }, "object storage payloads are not implemented");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeResultReferenceRecord = (
|
||||
value: unknown,
|
||||
): ResultReferenceRecord | null => {
|
||||
if (!isRecord(value)) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 match = value.trim().match(RESULT_REF_FILE_PATTERN);
|
||||
return match?.[1] ?? null;
|
||||
const normalized = value.trim();
|
||||
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 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> =>
|
||||
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));
|
||||
|
||||
+133
-525
@@ -2,20 +2,18 @@ import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
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 { MemoryStore } from "../memory/store.js";
|
||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import { type ConversationStateStore } from "../conversations/stateStore.js";
|
||||
import { type ConversationStore } from "../conversations/store.js";
|
||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
extractLatestFrontendTurn,
|
||||
generateSessionTitle,
|
||||
shouldGenerateSessionTitle,
|
||||
} from "./chatSession.js";
|
||||
@@ -46,153 +44,20 @@ const forkPayloadSchema = z.object({
|
||||
keep_message_count: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
const sessionStateSchema = z.object({
|
||||
const conversationStateSchema = z.object({
|
||||
title: z.string().max(120).optional(),
|
||||
is_title_manually_edited: z.boolean().optional(),
|
||||
messages: z.array(z.unknown()).default([]),
|
||||
branch_groups: z.array(z.unknown()).default([]),
|
||||
});
|
||||
|
||||
type RunStatus = "running" | "completed" | "error" | "aborted";
|
||||
|
||||
type StreamSubscriber = {
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type ActiveRun = {
|
||||
clientSessionId: string;
|
||||
controller: AbortController;
|
||||
messages: unknown[];
|
||||
status: RunStatus;
|
||||
subscribers: Set<StreamSubscriber>;
|
||||
};
|
||||
|
||||
const activeRuns = new Map<string, ActiveRun>();
|
||||
const lastRunStatuses = new Map<string, RunStatus>();
|
||||
|
||||
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
|
||||
const getSessionRunStatus = (sessionId: string) =>
|
||||
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const createFrontendMessageId = () =>
|
||||
`msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const createInitialStreamingMessages = (existingMessages: unknown[], userContent: string) => {
|
||||
const userMessage = {
|
||||
id: createFrontendMessageId(),
|
||||
role: "user",
|
||||
content: userContent,
|
||||
};
|
||||
return [
|
||||
...existingMessages,
|
||||
{
|
||||
...userMessage,
|
||||
branchRootId: userMessage.id,
|
||||
},
|
||||
{
|
||||
id: createFrontendMessageId(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
progress: [
|
||||
{
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
status: "running",
|
||||
title: "已收到请求,正在启动 Agent 分析",
|
||||
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
||||
startedAt: Date.now(),
|
||||
elapsedMs: 0,
|
||||
elapsedSnapshotAt: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const upsertBackendProgress = (
|
||||
progress: unknown,
|
||||
payload: Record<string, unknown>,
|
||||
) => {
|
||||
const next = Array.isArray(progress) ? [...progress] : [];
|
||||
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
|
||||
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
|
||||
const nextItem = {
|
||||
id,
|
||||
phase: typeof payload.phase === "string" ? payload.phase : "progress",
|
||||
status:
|
||||
payload.status === "completed" || payload.status === "error"
|
||||
? payload.status
|
||||
: "running",
|
||||
title: typeof payload.title === "string" ? payload.title : "正在处理",
|
||||
detail: typeof payload.detail === "string" ? payload.detail : undefined,
|
||||
startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined,
|
||||
endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined,
|
||||
elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined,
|
||||
elapsedSnapshotAt:
|
||||
typeof payload.elapsed_ms === "number" ? Date.now() : undefined,
|
||||
durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined,
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = nextItem;
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const completeBackendProgress = (progress: unknown) =>
|
||||
Array.isArray(progress)
|
||||
? progress.map((item) => {
|
||||
if (!isObjectRecord(item) || item.status !== "running") {
|
||||
return item;
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined;
|
||||
return {
|
||||
...item,
|
||||
status: "completed",
|
||||
endedAt,
|
||||
elapsedMs: undefined,
|
||||
elapsedSnapshotAt: undefined,
|
||||
durationMs:
|
||||
typeof item.durationMs === "number"
|
||||
? item.durationMs
|
||||
: startedAt !== undefined
|
||||
? Math.max(0, endedAt - startedAt)
|
||||
: item.elapsedMs,
|
||||
};
|
||||
})
|
||||
: progress;
|
||||
|
||||
const updateLastAssistantMessage = (
|
||||
messages: unknown[],
|
||||
updater: (message: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (isObjectRecord(message) && message.role === "assistant") {
|
||||
const next = [...messages];
|
||||
next[index] = updater(message);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
export const buildChatRouter = (
|
||||
sessionBridge: ChatSessionBridge,
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
sessionMetadataStore: SessionMetadataStore,
|
||||
sessionUiStateStore: SessionUiStateStore,
|
||||
conversationStore: ConversationStore,
|
||||
conversationStateStore: ConversationStateStore,
|
||||
memoryStore: MemoryStore,
|
||||
sessionTranscriptStore: SessionTranscriptStore,
|
||||
sessionHistoryStore: SessionHistoryStore,
|
||||
learningOrchestrator: LearningOrchestrator,
|
||||
resultReferenceResolver: ResultReferenceResolver,
|
||||
) => {
|
||||
@@ -212,15 +77,13 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
const sessionId = requestedSessionId || (await runtime.createSession()).id;
|
||||
|
||||
const { record, created } = await sessionMetadataStore.ensure({
|
||||
const { record, created } = await conversationStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: parsed.data.parent_session_id,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
sessionId: parsed.data.session_id,
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -228,6 +91,8 @@ export const buildChatRouter = (
|
||||
session_id: record.sessionId,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
is_streaming: record.isStreaming,
|
||||
streaming_started_at: record.streamingStartedAt,
|
||||
status: record.status,
|
||||
title: record.title,
|
||||
parent_session_id: record.parentSessionId,
|
||||
@@ -239,7 +104,7 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const records = await sessionMetadataStore.list({
|
||||
const records = await conversationStore.list({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
@@ -251,10 +116,10 @@ export const buildChatRouter = (
|
||||
title: record.title ?? "新对话",
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
is_streaming: record.isStreaming,
|
||||
streaming_started_at: record.streamingStartedAt,
|
||||
status: record.status,
|
||||
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;
|
||||
}
|
||||
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
const conversation = await conversationStore.get(
|
||||
{
|
||||
actorKey,
|
||||
projectId,
|
||||
@@ -279,97 +144,31 @@ export const buildChatRouter = (
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
if (!conversation) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
);
|
||||
const state = await conversationStateStore.read(conversation.sessionId);
|
||||
res.json({
|
||||
id: sessionRecord.sessionId,
|
||||
title: sessionRecord.title ?? "新对话",
|
||||
id: conversation.sessionId,
|
||||
title: conversation.title ?? "新对话",
|
||||
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||
created_at: sessionRecord.createdAt,
|
||||
updated_at: sessionRecord.updatedAt,
|
||||
status: sessionRecord.status,
|
||||
session_id: sessionRecord.sessionId,
|
||||
created_at: conversation.createdAt,
|
||||
updated_at: conversation.updatedAt,
|
||||
is_streaming: conversation.isStreaming,
|
||||
streaming_started_at: conversation.streamingStartedAt,
|
||||
status: conversation.status,
|
||||
session_id: conversation.sessionId,
|
||||
messages: state?.messages ?? [],
|
||||
branch_groups: state?.branchGroups ?? [],
|
||||
parent_session_id: sessionRecord.parentSessionId,
|
||||
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
|
||||
run_status: getSessionRunStatus(sessionRecord.sessionId),
|
||||
parent_session_id: conversation.parentSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:sessionId/stream", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ message: "session_id is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
sessionId,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
|
||||
res.write(
|
||||
toSse("state", {
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: state?.messages ?? run?.messages ?? [],
|
||||
is_streaming: run?.status === "running",
|
||||
run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed",
|
||||
}),
|
||||
);
|
||||
|
||||
if (!run || run.status !== "running") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriber: StreamSubscriber = {
|
||||
write: (event, data) => {
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.write(toSse(event, data));
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
run.subscribers.add(subscriber);
|
||||
|
||||
const cleanup = () => {
|
||||
run.subscribers.delete(subscriber);
|
||||
};
|
||||
req.on("close", cleanup);
|
||||
res.on("close", cleanup);
|
||||
});
|
||||
|
||||
chatRouter.put("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const parsed = sessionStateSchema.safeParse(req.body ?? {});
|
||||
const parsed = conversationStateSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -387,47 +186,29 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const { record } = await sessionMetadataStore.ensure({
|
||||
const { record } = await conversationStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
const nextRecord = await sessionMetadataStore.touch(record, {
|
||||
const nextRecord = await conversationStore.touch(record, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
});
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
|
||||
await conversationStateStore.write(nextRecord.sessionId, {
|
||||
sessionId: nextRecord.sessionId,
|
||||
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
|
||||
messages: parsed.data.messages,
|
||||
branchGroups: parsed.data.branch_groups,
|
||||
});
|
||||
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
|
||||
if (latestTurn) {
|
||||
void learningOrchestrator.onTurnCompleted({
|
||||
...latestTurn,
|
||||
requestContext: {
|
||||
actorKey,
|
||||
clientSessionId: nextRecord.sessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
|
||||
userId,
|
||||
},
|
||||
sessionId: nextRecord.sessionId,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: nextRecord.sessionId },
|
||||
"post-save learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
id: nextRecord.sessionId,
|
||||
title: nextRecord.title ?? "新对话",
|
||||
created_at: nextRecord.createdAt,
|
||||
updated_at: nextRecord.updatedAt,
|
||||
is_streaming: nextRecord.isStreaming,
|
||||
streaming_started_at: nextRecord.streamingStartedAt,
|
||||
status: nextRecord.status,
|
||||
session_id: nextRecord.sessionId,
|
||||
});
|
||||
@@ -449,32 +230,27 @@ export const buildChatRouter = (
|
||||
res.status(400).json({ message: "session_id and title are required" });
|
||||
return;
|
||||
}
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
const conversation = await conversationStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
sessionId,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
if (!conversation) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title });
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(nextSessionRecord),
|
||||
);
|
||||
const nextConversation = await conversationStore.touch(conversation, { title });
|
||||
const state = await conversationStateStore.read(nextConversation.sessionId);
|
||||
if (state) {
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(nextSessionRecord),
|
||||
{
|
||||
await conversationStateStore.write(nextConversation.sessionId, {
|
||||
...state,
|
||||
isTitleManuallyEdited:
|
||||
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
id: nextSessionRecord.sessionId,
|
||||
title: nextSessionRecord.title,
|
||||
updated_at: nextSessionRecord.updatedAt,
|
||||
id: nextConversation.sessionId,
|
||||
title: nextConversation.title,
|
||||
updated_at: nextConversation.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,22 +264,16 @@ export const buildChatRouter = (
|
||||
res.status(400).json({ message: "session_id is required" });
|
||||
return;
|
||||
}
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
const conversation = await conversationStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
sessionId,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
if (!conversation) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
|
||||
await sessionBridge.deleteSession({
|
||||
clientSessionId: sessionRecord.sessionId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
activeRuns.delete(sessionRecord.sessionId);
|
||||
lastRunStatuses.delete(sessionRecord.sessionId);
|
||||
await sessionMetadataStore.remove(sessionRecord);
|
||||
await conversationStateStore.remove(conversation.sessionId);
|
||||
await conversationStore.remove(conversation);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
@@ -511,7 +281,7 @@ export const buildChatRouter = (
|
||||
const renderRef = req.params.renderRef?.trim();
|
||||
const userId = req.header("x-user-id")?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const clientSessionId =
|
||||
const sessionId =
|
||||
typeof req.query.session_id === "string"
|
||||
? req.query.session_id.trim()
|
||||
: undefined;
|
||||
@@ -534,7 +304,7 @@ export const buildChatRouter = (
|
||||
renderRef,
|
||||
{
|
||||
actorKey: toActorKey(userId),
|
||||
clientSessionId,
|
||||
sessionId,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
@@ -561,64 +331,19 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
);
|
||||
const binding = sessionRecord
|
||||
? await sessionBridge.abort({
|
||||
clientSessionId: sessionRecord.sessionId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
})
|
||||
: null;
|
||||
const run = activeRuns.get(parsed.data.session_id);
|
||||
if (run && run.status === "running") {
|
||||
run.status = "aborted";
|
||||
lastRunStatuses.set(parsed.data.session_id, "aborted");
|
||||
run.controller.abort();
|
||||
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
}));
|
||||
if (sessionRecord) {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
branchGroups: currentState?.branchGroups ?? [],
|
||||
});
|
||||
}
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("error", {
|
||||
session_id: parsed.data.session_id,
|
||||
message: "请求已中断",
|
||||
});
|
||||
subscriber.close();
|
||||
}
|
||||
run.subscribers.clear();
|
||||
}
|
||||
const binding = await sessionBridge.abort({
|
||||
sessionId: parsed.data.session_id,
|
||||
});
|
||||
|
||||
if (!binding && !run) {
|
||||
if (!binding) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
clientSessionId: parsed.data.session_id,
|
||||
sessionId: binding?.sessionId ?? parsed.data.session_id,
|
||||
sessionId: parsed.data.session_id,
|
||||
runtimeSessionId: binding.runtimeSessionId,
|
||||
},
|
||||
"aborted chat session by client request",
|
||||
);
|
||||
@@ -654,8 +379,8 @@ export const buildChatRouter = (
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sourceSessionId = parsed.data.session_id?.trim();
|
||||
const sourceSessionRecord = sourceSessionId
|
||||
? await sessionMetadataStore.get(
|
||||
const sourceConversation = sourceSessionId
|
||||
? await conversationStore.get(
|
||||
{
|
||||
actorKey,
|
||||
projectId,
|
||||
@@ -665,36 +390,32 @@ export const buildChatRouter = (
|
||||
sourceSessionId,
|
||||
)
|
||||
: null;
|
||||
const forkSession = await runtime.createSession();
|
||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||
const { record: targetConversation } = await conversationStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: sourceSessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: forkSession.id,
|
||||
userId,
|
||||
});
|
||||
const nextSessionId = targetSessionRecord.sessionId;
|
||||
const nextSessionId = targetConversation.sessionId;
|
||||
|
||||
if (sourceSessionId && parsed.data.keep_message_count > 0) {
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
await sessionHistoryStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextSessionId,
|
||||
projectKey,
|
||||
sessionId: nextSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
if (sourceSessionRecord?.title) {
|
||||
await sessionMetadataStore.touch(targetSessionRecord, {
|
||||
title: sourceSessionRecord.title,
|
||||
if (sourceConversation?.title) {
|
||||
await conversationStore.touch(targetConversation, {
|
||||
title: sourceConversation.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -743,54 +464,36 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
const existingSessionRecord = requestedSessionId
|
||||
? await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
requestedSessionId,
|
||||
)
|
||||
: null;
|
||||
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
|
||||
const { record: conversation, created: conversationCreated } =
|
||||
await conversationStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: parsed.data.session_id,
|
||||
userId,
|
||||
});
|
||||
const activeConversation = await conversationStore.touch(conversation);
|
||||
|
||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||
sessionId: requestedSessionId,
|
||||
sessionId: activeConversation.sessionId,
|
||||
accessToken,
|
||||
projectId,
|
||||
traceId,
|
||||
userId,
|
||||
});
|
||||
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||
await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: binding.sessionId,
|
||||
userId,
|
||||
});
|
||||
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
|
||||
const historyContext = {
|
||||
actorKey: requestContext.actorKey,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: requestContext.clientSessionId,
|
||||
sessionId: requestContext.sessionId,
|
||||
};
|
||||
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
|
||||
const initialSessionState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(activeSessionRecord),
|
||||
);
|
||||
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
|
||||
res.status(409).json({
|
||||
message: "session is already streaming",
|
||||
session_id: activeSessionRecord.sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await conversationStore.markStreaming(activeConversation, binding.runtimeSessionId);
|
||||
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: binding.sessionId,
|
||||
created: created || sessionCreated,
|
||||
sessionId: requestContext.sessionId,
|
||||
runtimeSessionId: binding.runtimeSessionId,
|
||||
created: created || conversationCreated,
|
||||
model: parsed.data.model,
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
@@ -805,209 +508,114 @@ export const buildChatRouter = (
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
|
||||
const clientSessionId = requestContext.clientSessionId;
|
||||
const sessionId = requestContext.sessionId;
|
||||
let streamClosed = false;
|
||||
const abortController = new AbortController();
|
||||
sessionBridge.registerAbortController(clientSessionId, abortController);
|
||||
const initialMessages = createInitialStreamingMessages(
|
||||
initialSessionState?.messages ?? [],
|
||||
parsed.data.message,
|
||||
);
|
||||
const branchGroups = initialSessionState?.branchGroups ?? [];
|
||||
const activeRun: ActiveRun = {
|
||||
clientSessionId,
|
||||
controller: abortController,
|
||||
messages: initialMessages,
|
||||
status: "running",
|
||||
subscribers: new Set(),
|
||||
};
|
||||
activeRuns.set(clientSessionId, activeRun);
|
||||
lastRunStatuses.set(clientSessionId, "running");
|
||||
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
|
||||
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
|
||||
sessionId: activeSessionRecord.sessionId,
|
||||
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||
messages: initialMessages,
|
||||
branchGroups,
|
||||
});
|
||||
const queueSessionUiStatePersist = () => {
|
||||
const snapshot = {
|
||||
sessionId: activeSessionRecord.sessionId,
|
||||
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||
messages: activeRun.messages,
|
||||
branchGroups,
|
||||
};
|
||||
persistQueue = persistQueue
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: clientSessionId },
|
||||
"failed to persist previous chat stream state",
|
||||
);
|
||||
})
|
||||
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
|
||||
return persistQueue;
|
||||
};
|
||||
const primarySubscriber: StreamSubscriber = {
|
||||
write: (event, data) => {
|
||||
if (!streamClosed && !res.writableEnded && !res.destroyed) {
|
||||
res.write(toSse(event, data));
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
activeRun.subscribers.add(primarySubscriber);
|
||||
const handleClientClose = () => {
|
||||
streamClosed = true;
|
||||
activeRun.subscribers.delete(primarySubscriber);
|
||||
if (streamClosed || abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
|
||||
req.on("close", handleClientClose);
|
||||
res.on("close", handleClientClose);
|
||||
|
||||
const publish = (event: string, data: Record<string, unknown>) => {
|
||||
if (event === "token") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
|
||||
isError: false,
|
||||
}));
|
||||
} else if (event === "progress") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
progress: upsertBackendProgress(message.progress, data),
|
||||
}));
|
||||
} else if (event === "done") {
|
||||
activeRun.status = "completed";
|
||||
lastRunStatuses.set(clientSessionId, "completed");
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||
progress: completeBackendProgress(message.progress),
|
||||
}));
|
||||
} else if (event === "error") {
|
||||
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
|
||||
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
}));
|
||||
}
|
||||
|
||||
for (const subscriber of activeRun.subscribers) {
|
||||
subscriber.write(event, data);
|
||||
}
|
||||
void queueSessionUiStatePersist().catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const preparedMessage = await buildPromptWithLearningContext(
|
||||
memoryStore,
|
||||
requestContext.actorKey,
|
||||
requestContext.projectKey,
|
||||
{
|
||||
recentTurns,
|
||||
persistedMessages: initialSessionState?.messages,
|
||||
message: parsed.data.message,
|
||||
restoreConversation: !hadExistingRuntimeSession,
|
||||
},
|
||||
recentTurns,
|
||||
parsed.data.message,
|
||||
);
|
||||
const streamResult = await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: binding.sessionId,
|
||||
clientSessionId,
|
||||
opencodeSessionId: binding.runtimeSessionId,
|
||||
sessionId,
|
||||
message: preparedMessage,
|
||||
model: parsed.data.model,
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
signal: abortController.signal,
|
||||
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) {
|
||||
const messages = await runtime.messages(binding.sessionId, 60);
|
||||
const messages = await runtime.messages(binding.runtimeSessionId, 60);
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const latestSessionRecord =
|
||||
(await sessionMetadataStore.get(
|
||||
const latestConversation =
|
||||
(await conversationStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
activeSessionRecord.sessionId,
|
||||
)) ?? activeSessionRecord;
|
||||
const latestSessionState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(latestSessionRecord),
|
||||
activeConversation.sessionId,
|
||||
)) ?? activeConversation;
|
||||
const latestConversationState = await conversationStateStore.read(
|
||||
latestConversation.sessionId,
|
||||
);
|
||||
const existingSessionTitle = latestSessionRecord.title;
|
||||
const existingSessionTitle = latestConversation.title;
|
||||
let sessionTitle = existingSessionTitle;
|
||||
const shouldGenerateTitle = shouldGenerateSessionTitle({
|
||||
recentTurnCount: recentTurns.length,
|
||||
isTitleManuallyEdited:
|
||||
latestSessionState?.isTitleManuallyEdited ?? false,
|
||||
latestConversationState?.isTitleManuallyEdited ?? false,
|
||||
});
|
||||
if (shouldGenerateTitle) {
|
||||
sessionTitle = await generateSessionTitle(runtime, {
|
||||
sessionId: binding.sessionId,
|
||||
sessionId: binding.runtimeSessionId,
|
||||
latestAssistantMessage: assistantText,
|
||||
latestUserMessage: parsed.data.message,
|
||||
fallbackTitle: existingSessionTitle,
|
||||
});
|
||||
}
|
||||
const nextSessionRecord = await sessionMetadataStore.touch(latestSessionRecord, {
|
||||
const nextConversation = await conversationStore.touch(latestConversation, {
|
||||
...(sessionTitle && sessionTitle !== existingSessionTitle
|
||||
? { title: sessionTitle }
|
||||
: {}),
|
||||
});
|
||||
if (
|
||||
shouldGenerateTitle &&
|
||||
sessionTitle &&
|
||||
sessionTitle !== existingSessionTitle
|
||||
) {
|
||||
publish("session_title", {
|
||||
session_id: clientSessionId,
|
||||
title: sessionTitle,
|
||||
});
|
||||
await persistQueue.catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
if (!streamClosed && !res.writableEnded && !res.destroyed) {
|
||||
if (
|
||||
shouldGenerateTitle &&
|
||||
sessionTitle &&
|
||||
sessionTitle !== existingSessionTitle
|
||||
) {
|
||||
res.write(
|
||||
toSse("session_title", {
|
||||
session_id: sessionId,
|
||||
title: sessionTitle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
await persistQueue.catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
sessionBridge.finalizeRequest(clientSessionId);
|
||||
activeRun.status = abortController.signal.aborted
|
||||
? activeRun.status === "aborted"
|
||||
? "aborted"
|
||||
: "aborted"
|
||||
: activeRun.status === "running"
|
||||
? "completed"
|
||||
: activeRun.status;
|
||||
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||
for (const subscriber of activeRun.subscribers) {
|
||||
subscriber.close();
|
||||
}
|
||||
activeRun.subscribers.clear();
|
||||
activeRuns.delete(clientSessionId);
|
||||
await sessionBridge.releaseRuntimeSession(
|
||||
sessionId,
|
||||
binding.runtimeSessionId,
|
||||
);
|
||||
await conversationStore.clearStreaming(sessionId, binding.runtimeSessionId);
|
||||
streamClosed = true;
|
||||
req.off("close", handleClientClose);
|
||||
res.off("close", handleClientClose);
|
||||
|
||||
+7
-119
@@ -1,5 +1,5 @@
|
||||
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 { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
|
||||
@@ -192,22 +192,15 @@ export const buildPromptWithLearningContext = async (
|
||||
memoryStore: MemoryStore,
|
||||
actorKey: string,
|
||||
projectKey: string,
|
||||
options: {
|
||||
recentTurns: SessionTurnRecord[];
|
||||
persistedMessages?: unknown[];
|
||||
message: string;
|
||||
restoreConversation?: boolean;
|
||||
},
|
||||
recentTurns: SessionTurnRecord[],
|
||||
message: string,
|
||||
) => {
|
||||
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
|
||||
const restoredConversation = options.restoreConversation === false
|
||||
? ""
|
||||
: buildRestoredConversationFromMessages(options.persistedMessages) ||
|
||||
buildRestoredConversationContext(options.recentTurns);
|
||||
const restoredConversation = buildRestoredConversationContext(recentTurns);
|
||||
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)
|
||||
.join("\n\n");
|
||||
};
|
||||
@@ -246,109 +239,4 @@ const compactMessage = (value: string) => {
|
||||
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
|
||||
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
|
||||
: normalized;
|
||||
};
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isSyntheticAssistantError = (content: string) =>
|
||||
/^⚠️\s*\*\*(请求已中断|错误[::]?)/.test(content);
|
||||
|
||||
export const extractLatestFrontendTurn = (messages: unknown[] | undefined) => {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const assistant = messages[index];
|
||||
if (!isObjectRecord(assistant) || assistant.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const assistantMessage =
|
||||
typeof assistant.content === "string"
|
||||
? assistant.content.replace(/\s+/g, " ").trim()
|
||||
: "";
|
||||
if (!assistantMessage || isSyntheticAssistantError(assistantMessage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const user = messages
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((message) => isObjectRecord(message) && message.role === "user");
|
||||
if (!isObjectRecord(user) || typeof user.content !== "string") {
|
||||
continue;
|
||||
}
|
||||
const userMessage = user.content.replace(/\s+/g, " ").trim();
|
||||
if (!userMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
assistantMessage,
|
||||
toolCallCount: estimateFrontendToolCallCount(assistant),
|
||||
userMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const formattedMessages = messages
|
||||
.slice(-(RESTORE_TURN_LIMIT * 2 + 2))
|
||||
.flatMap((message) => {
|
||||
if (!isObjectRecord(message)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const role = message.role;
|
||||
const content = message.content;
|
||||
if ((role !== "user" && role !== "assistant") || typeof content !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedContent = compactMessage(content);
|
||||
if (!normalizedContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [`${role === "user" ? "用户" : "助手"}:${normalizedContent}`];
|
||||
});
|
||||
|
||||
if (formattedMessages.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const conversation = formattedMessages.join("\n");
|
||||
const trimmedConversation =
|
||||
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
|
||||
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
|
||||
: conversation;
|
||||
|
||||
return [
|
||||
"[Previous conversation context]",
|
||||
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
|
||||
trimmedConversation,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const estimateFrontendToolCallCount = (assistant: Record<string, unknown>) => {
|
||||
const progress = Array.isArray(assistant.progress) ? assistant.progress : [];
|
||||
const artifacts = Array.isArray(assistant.artifacts) ? assistant.artifacts : [];
|
||||
const toolProgressCount = progress.filter(
|
||||
(item) =>
|
||||
isObjectRecord(item) &&
|
||||
(item.phase === "tool" ||
|
||||
(typeof item.id === "string" && item.id.startsWith("tool-"))),
|
||||
).length;
|
||||
return Math.max(toolProgressCount, artifacts.length);
|
||||
};
|
||||
};
|
||||
+27
-27
@@ -13,8 +13,8 @@ export type SupportedModel = (typeof supportedModels)[number];
|
||||
|
||||
type StreamPromptOptions = {
|
||||
runtime: OpencodeRuntimeAdapter;
|
||||
opencodeSessionId: string;
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
message: string;
|
||||
model?: SupportedModel;
|
||||
traceId?: string;
|
||||
@@ -36,6 +36,8 @@ type ProgressPayload = {
|
||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
dynamic_http_call: "后端数据查询",
|
||||
fetch_result_ref: "结果引用回读",
|
||||
memory_manager: "记忆写入",
|
||||
session_search: "历史会话检索",
|
||||
skill_manager: "流程沉淀",
|
||||
@@ -166,11 +168,11 @@ export const collectTextContent = (parts: Part[]) =>
|
||||
|
||||
const emitFallbackMessage = async (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
opencodeSessionId: string,
|
||||
sessionId: string,
|
||||
clientSessionId: string,
|
||||
write: (event: string, data: Record<string, unknown>) => void,
|
||||
) => {
|
||||
const messages = await runtime.messages(sessionId);
|
||||
const messages = await runtime.messages(opencodeSessionId);
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
@@ -178,7 +180,7 @@ const emitFallbackMessage = async (
|
||||
const text = collectTextContent(parts);
|
||||
if (text) {
|
||||
write("token", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
@@ -291,8 +293,8 @@ const getToolProgressTitle = (tool: string, status: string) => {
|
||||
|
||||
export const streamPromptResponse = async ({
|
||||
runtime,
|
||||
opencodeSessionId,
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
message,
|
||||
model,
|
||||
traceId,
|
||||
@@ -330,8 +332,8 @@ export const streamPromptResponse = async ({
|
||||
let aborted = signal?.aborted ?? false;
|
||||
let failed = false;
|
||||
const debugContext = {
|
||||
opencodeSessionId,
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
model: model ?? null,
|
||||
@@ -367,7 +369,7 @@ export const streamPromptResponse = async ({
|
||||
|
||||
if (status === "running") {
|
||||
write("progress", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
@@ -383,7 +385,7 @@ export const streamPromptResponse = async ({
|
||||
finalizedProgressIds.add(id);
|
||||
progressStartedAtMap.delete(id);
|
||||
write("progress", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
@@ -404,7 +406,7 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
|
||||
const promptPromise = runtime
|
||||
.prompt(sessionId, message, toRuntimeModel(model))
|
||||
.prompt(opencodeSessionId, message, toRuntimeModel(model))
|
||||
.then(() => {
|
||||
promptSettled = true;
|
||||
logDevelopmentDebug("runtime.prompt resolved", {
|
||||
@@ -469,7 +471,7 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
|
||||
const event = next.result.value as OpencodeEvent;
|
||||
if (!isSessionEvent(event, sessionId)) {
|
||||
if (!isSessionEvent(event, opencodeSessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -539,8 +541,7 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "skill",
|
||||
sessionId: sessionId,
|
||||
clientSessionId,
|
||||
sessionId: opencodeSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: name,
|
||||
@@ -566,7 +567,7 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
emittedText = true;
|
||||
write("token", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
content: event.properties.delta,
|
||||
});
|
||||
} else if (partType === "reasoning") {
|
||||
@@ -599,7 +600,7 @@ export const streamPromptResponse = async ({
|
||||
for (const content of pending) {
|
||||
emittedText = true;
|
||||
write("token", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
@@ -689,16 +690,15 @@ export const streamPromptResponse = async ({
|
||||
logger.warn(
|
||||
{
|
||||
tool: part.tool,
|
||||
sessionId: sessionId,
|
||||
clientSessionId,
|
||||
sessionId: opencodeSessionId,
|
||||
requestSessionId: sessionId,
|
||||
},
|
||||
"llm tool request missing reason",
|
||||
);
|
||||
}
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "tool",
|
||||
sessionId: sessionId,
|
||||
clientSessionId,
|
||||
sessionId: opencodeSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: part.tool,
|
||||
@@ -709,7 +709,7 @@ export const streamPromptResponse = async ({
|
||||
logger.warn({ err: error }, "failed to write tool audit log");
|
||||
});
|
||||
write("tool_call", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
tool: part.tool,
|
||||
params: toolParams,
|
||||
reason,
|
||||
@@ -744,7 +744,7 @@ export const streamPromptResponse = async ({
|
||||
: "opencode session error",
|
||||
});
|
||||
write("error", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
message: event.properties.error
|
||||
? getErrorMessage(event.properties.error)
|
||||
: "opencode session error",
|
||||
@@ -779,12 +779,12 @@ export const streamPromptResponse = async ({
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
await runtime.abortSession(sessionId).catch((error) => {
|
||||
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
|
||||
await runtime.abortSession(opencodeSessionId).catch((error) => {
|
||||
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(
|
||||
{ sessionId: sessionId, err: error },
|
||||
{ sessionId: opencodeSessionId, err: error },
|
||||
"failed while waiting for aborted opencode session to become idle",
|
||||
);
|
||||
});
|
||||
@@ -801,7 +801,7 @@ export const streamPromptResponse = async ({
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
|
||||
await emitFallbackMessage(runtime, opencodeSessionId, sessionId, write);
|
||||
}
|
||||
emitProgress({
|
||||
id: "request-received",
|
||||
@@ -820,7 +820,7 @@ export const streamPromptResponse = async ({
|
||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
||||
});
|
||||
write("done", {
|
||||
session_id: clientSessionId,
|
||||
session_id: sessionId,
|
||||
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
logDevelopmentDebug("chat stream completed", {
|
||||
@@ -841,4 +841,4 @@ export const streamPromptResponse = async ({
|
||||
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -167,7 +167,6 @@ export class OpencodeRuntimeAdapter {
|
||||
"starting opencode server in embedded mode",
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let runtime;
|
||||
try {
|
||||
runtime = await createOpencode({
|
||||
@@ -187,16 +186,6 @@ export class OpencodeRuntimeAdapter {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
hostname: config.OPENCODE_HOSTNAME,
|
||||
port: config.OPENCODE_PORT,
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"opencode server started in embedded mode",
|
||||
);
|
||||
|
||||
this.closeServer = () => {
|
||||
runtime.server.close();
|
||||
};
|
||||
|
||||
@@ -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
@@ -1,44 +1,40 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
|
||||
import { requireAgentAuth } from "./auth/agentAuth.js";
|
||||
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||
import { SessionHistoryStore } from "./history/store.js";
|
||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||
import { config } from "./config.js";
|
||||
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
|
||||
import { SessionMetadataStore } from "./sessions/metadataStore.js";
|
||||
import { ConversationStateStore } from "./conversations/stateStore.js";
|
||||
import { ConversationStore } from "./conversations/store.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||
import { MemoryStore } from "./memory/store.js";
|
||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||
import {
|
||||
RESULT_REFERENCE_SOURCE,
|
||||
ResultReferenceStore,
|
||||
} from "./results/store.js";
|
||||
import { ResultReferenceStore } from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.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();
|
||||
|
||||
// 这里集中组装 Agent 服务的运行期依赖,路由层只通过接口调用,便于测试时替换实现。
|
||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||
const sessionMetadataStore = new SessionMetadataStore();
|
||||
const sessionUiStateStore = new SessionUiStateStore();
|
||||
const conversationStore = new ConversationStore();
|
||||
const conversationStateStore = new ConversationStateStore();
|
||||
const memoryStore = new MemoryStore();
|
||||
const sessionTranscriptStore = new SessionTranscriptStore();
|
||||
const sessionHistoryStore = new SessionHistoryStore();
|
||||
const runtimeSessionStore = new RuntimeSessionStore();
|
||||
const learningOrchestrator = new LearningOrchestrator(
|
||||
opencodeRuntime,
|
||||
memoryStore,
|
||||
sessionTranscriptStore,
|
||||
sessionHistoryStore,
|
||||
);
|
||||
const resultReferenceStore = new ResultReferenceStore();
|
||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
|
||||
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;
|
||||
|
||||
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) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
||||
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) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
});
|
||||
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}`,
|
||||
message: "runtime or session context not found",
|
||||
detail: runtimeSessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(JSON.parse(stdout));
|
||||
} catch {
|
||||
res.json({
|
||||
ok: true,
|
||||
schema_version: "tjwater-cli/v1",
|
||||
raw: stdout,
|
||||
stderr: stderr || undefined,
|
||||
// opencode 工具运行在 .opencode 侧,这里负责把工具调用重新绑定到当前用户/项目上下文。
|
||||
const result = await dynamicHttpExecutor.execute(
|
||||
{
|
||||
reason: req.body?.reason,
|
||||
path: req.body?.path,
|
||||
method: req.body?.method,
|
||||
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) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.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) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
detail: runtimeSessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -191,11 +186,10 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
try {
|
||||
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.clientSessionId,
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
sessionId: context.sessionId,
|
||||
source: "migration",
|
||||
traceId: context.traceId,
|
||||
});
|
||||
res.json({
|
||||
@@ -222,14 +216,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
||||
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) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
detail: runtimeSessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -237,7 +231,7 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
res.status(400).json({ message: "query is required" });
|
||||
return;
|
||||
}
|
||||
const hits = await sessionTranscriptStore.search(
|
||||
const hits = await sessionHistoryStore.search(
|
||||
{
|
||||
actorKey: context.actorKey,
|
||||
projectKey: context.projectKey,
|
||||
@@ -253,14 +247,13 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
|
||||
app.use(
|
||||
"/api/v1/agent/chat",
|
||||
requireAgentAuth,
|
||||
buildChatRouter(
|
||||
sessionBridge,
|
||||
opencodeRuntime,
|
||||
sessionMetadataStore,
|
||||
sessionUiStateStore,
|
||||
conversationStore,
|
||||
conversationStateStore,
|
||||
memoryStore,
|
||||
sessionTranscriptStore,
|
||||
sessionHistoryStore,
|
||||
learningOrchestrator,
|
||||
resultReferenceResolver,
|
||||
),
|
||||
@@ -268,13 +261,15 @@ app.use(
|
||||
|
||||
const bootstrap = async () => {
|
||||
await Promise.all([
|
||||
sessionMetadataStore.initialize(),
|
||||
sessionUiStateStore.initialize(),
|
||||
conversationStore.initialize(),
|
||||
conversationStateStore.initialize(),
|
||||
learningOrchestrator.initialize(),
|
||||
memoryStore.initialize(),
|
||||
resultReferenceStore.initialize(),
|
||||
sessionTranscriptStore.initialize(),
|
||||
sessionHistoryStore.initialize(),
|
||||
runtimeSessionStore.initialize(),
|
||||
]);
|
||||
await conversationStore.resetStreamingSessions();
|
||||
resultReferenceStore.startCleanupLoop();
|
||||
};
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user