42 Commits
Author SHA1 Message Date
jiang 7b79c4034d 移除验证作业,简化 CI/CD 流程
Agent CI/CD / docker-image (push) Has been skipped
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-19 15:28:52 +08:00
jiang 2e6f4b0a10 添加部署 webhook 调用,优化 HTTP 请求处理
Agent CI/CD / validate (push) Successful in 23s
Agent CI/CD / docker-image (push) Successful in 8s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-19 15:26:02 +08:00
jiang 65a1a11b39 添加 .gitignore 和 bun.lock 文件以管理依赖
Agent CI/CD / docker-image (push) Has been cancelled
Agent CI/CD / deploy-fallback-log (push) Has been cancelled
Agent CI/CD / validate (push) Has been cancelled
2026-05-19 12:11:04 +08:00
jiang 90d591b7bb 更新 Bun 镜像版本为 canary-slim
Agent CI/CD / validate (push) Successful in 33s
Agent CI/CD / docker-image (push) Failing after 1s
Agent CI/CD / deploy-fallback-log (push) Successful in 0s
2026-05-19 12:07:58 +08:00
jiang aa8f801a04 添加环境变量检查,确保注册信息完整
Agent CI/CD / validate (push) Successful in 31s
Agent CI/CD / docker-image (push) Failing after 31s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-05-19 11:55:17 +08:00
jiang f1fae1819f 添加缺失的环境变量和密钥检查
Agent CI/CD / validate (push) Successful in 31s
Agent CI/CD / docker-image (push) Failing after 16s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-05-19 11:51:06 +08:00
jiang cdeba0a725 优化 Bun 下载逻辑,简化代码结构 2026-05-19 11:43:31 +08:00
jiang 1c226afb69 添加 package.json 文件,配置依赖和脚本
Agent CI/CD / validate (push) Successful in 33s
Agent CI/CD / docker-image (push) Failing after 16s
Agent CI/CD / deploy-fallback-log (push) Successful in 0s
2026-05-19 11:42:06 +08:00
jiang be5e4c87de 优化 Bun 安装脚本,支持多个下载源
Agent CI/CD / validate (push) Failing after 1m9s
Agent CI/CD / docker-image (push) Has been skipped
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-19 11:38:03 +08:00
jiang 9373a42841 优化 Bun 安装脚本,简化代码结构
Agent CI/CD / validate (push) Failing after 24s
Agent CI/CD / docker-image (push) Has been skipped
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-19 11:32:24 +08:00
jiang baec7940a5 更新 Gitea CI/CD 触发逻辑,支持通过标签触发 2026-05-19 11:31:38 +08:00
jiang ebb0743fcb 更新 Gitea CI/CD 触发方式,支持分支触发 2026-05-19 11:27:52 +08:00
jiang 50c44ddc2d 添加触发 Gitea CI/CD 的脚本和命令 2026-05-19 11:21:08 +08:00
jiang ad34cbeab3 优化 Bun 安装脚本,支持多架构下载 2026-05-19 11:16:50 +08:00
jiang 38644bf5a9 更新环境变量配置,简化 OPENCODE_MODE 逻辑 2026-05-19 10:57:19 +08:00
jiang 6547a87391 chore: update
Agent CI/CD / validate (push) Failing after 14m39s
Agent CI/CD / docker-image (push) Has been skipped
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-19 10:55:15 +08:00
jiang 45435c8f1b chore: add push script to package.json 2026-05-19 10:55:15 +08:00
jiang 3dfbc7c33e chore: use ghproxy to accelerate bun installation 2026-05-19 10:55:15 +08:00
jiang 60e5b37913 更新 docker-compose 配置,移除客户端模式注释 2026-05-19 10:55:15 +08:00
jiang 160136014e 整理 opencode 接入方式,embedded 和 client 模式 2026-05-19 10:27:12 +08:00
jiang 4cbddb9e0c 新增调用前端分区渲染功能,节点通过 ref 文件传输,并增加简单认证 2026-05-18 17:12:33 +08:00
jiang 2f83add134 skill manager 添加脚本管理功能,支持写入和删除可复用脚本 2026-05-18 17:12:33 +08:00
jiang 4ec6cbed16 LLM-driven 设计,添加学习审计和会话历史存储至目录的功能 2026-05-18 17:12:33 +08:00
jiang 2ba4f35a2d 提示词 新增中文回复 2026-05-18 17:12:33 +08:00
jiang 5315ff1902 更新提示词和skills 2026-05-18 17:12:33 +08:00
jiang 59270b6b29 添加模型支持,更新提示功能以接收模型参数 2026-05-18 17:12:33 +08:00
jiang 3021fc42ec 优化进度事件处理,添加请求持续时间统计 2026-05-18 17:12:33 +08:00
jiang 319b3c8ea5 更新 Dockerfile,添加 bun-bin 镜像并复制 bun 2026-05-18 17:12:33 +08:00
jiang 0dcb04ee89 更新 tool 的传入参数,指定传入关键字名称 2026-05-18 17:12:33 +08:00
jiang cbe13dd1df 更新 docker 打包,增加 python 运行环境 2026-05-18 17:12:33 +08:00
jiang 3efd2e2871 新增 gitea 工作流 2026-05-18 17:12:33 +08:00
jiang c5801bbf41 添加注释 2026-05-18 17:12:33 +08:00
jiang 37bee1e775 更新 docker-compose.yml,添加数据卷映射 2026-05-18 17:12:33 +08:00
jiang 3c7e02f974 增加历史版本保存功能 2026-05-18 17:12:33 +08:00
jiang f049712b68 新增 memory 和 skill 存储,实现 Agent 持续学习,并增加工具支持;增加 LLM progress detail 输出 2026-05-18 17:12:33 +08:00
jiangandCopilot 883faa2d54 LLM 请求透明化
Co-authored-by: Copilot <copilot@github.com>
2026-05-18 17:12:33 +08:00
jiangandCopilot fb2b4fad9f 修正构建和启动环境配置
Co-authored-by: Copilot <copilot@github.com>
2026-05-18 17:12:33 +08:00
jiang 1afd0d9f3e 移除确保本地 bin 路径的功能 2026-05-18 17:12:33 +08:00
jiangandCopilot f20c131bec 新增确保本地 bin 路径的功能
Co-authored-by: Copilot <copilot@github.com>
2026-05-18 17:12:33 +08:00
jiangandCopilot ac2870a938 优化 Dockerfile,统一基础镜像定义
Co-authored-by: Copilot <copilot@github.com>
2026-05-18 17:12:33 +08:00
jiang 32babdd8a2 更新 opencode-ai 依赖版本至 1.14.30 2026-05-18 17:12:33 +08:00
jiang f6c45f1ba5 更新 Dockerfile 和新增 docker-compose.yml 文件 2026-05-18 17:12:33 +08:00
86 changed files with 1791 additions and 5864 deletions
+4 -1
View File
@@ -1,11 +1,14 @@
name: Agent CI/CD name: Agent CI/CD
on: on:
pull_request:
push: push:
branches:
- main
- master
tags: tags:
- "v*" - "v*"
- "latest" - "latest"
workflow_dispatch: {}
jobs: jobs:
docker-image: docker-image:
-124
View File
@@ -1,124 +0,0 @@
# 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
View File
@@ -2,6 +2,5 @@ node_modules/
.opencode/node_modules/ .opencode/node_modules/
.local.env .local.env
.vscode .vscode
docker-compose.yml
data/ data/
logs/ logs/
+11 -14
View File
@@ -4,7 +4,7 @@ mode: primary
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-pro
temperature: 0.2 temperature: 0.2
--- ---
您是运行在 opencode 上的默认 TJWater Agent运用水力相关知识,使用简体中文回复用户的问题。 您是运行在 opencode 上的默认 TJWater Agent,使用简体中文回复用户的问题。
按照以下规则操作: 按照以下规则操作:
@@ -17,18 +17,15 @@ temperature: 0.2
7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。 7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。
8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。默认遵循 **workflow-first**:先查固定工作流 skill,再按需回落到原子 API skills。 8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。默认遵循 **workflow-first**:先查固定工作流 skill,再按需回落到原子 API skills。
9.`dynamic_http_call` 返回 `result_mode = referenced``result_ref` 时,说明当前只拿到了预览;如果后续推理仍需要完整结果,必须调用 `fetch_result_ref` 回读,不能把 preview 当成完整数据。 9.`dynamic_http_call` 返回 `result_mode = referenced``result_ref` 时,说明当前只拿到了预览;如果后续推理仍需要完整结果,必须调用 `fetch_result_ref` 回读,不能把 preview 当成完整数据。
10. `render_ref``result_ref` 或其他引用型结果,默认只使用 preview、摘要、局部字段,或直接把引用传给前端工具;如果引用仅用于渲染/展示(例如 `render_junctions`),直接传引用,不要先读取完整内容再重组。 10. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
11. 对任何可能很大的引用文件、结果文件或普通大文件,禁止完整读取;优先使用预览、分页、截断、按字段读取、按片段读取或采样读取。只有在没有其他办法且当前推理确实必须依赖完整内容时,才允许读取完整内容,并先明确说明必要性。
12. 不得通过 sub-agent、并行代理或任何间接方式,去读取引用文件或大文件的完整内容;主 agent 与其调用链中的其他代理都必须遵守同样限制。
13. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
- `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实 - `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实
- `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置 - `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置
14. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。 11. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。
15. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager``skill_manager` 12. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager``skill_manager`
16. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。 13. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。
17. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。 14. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。
18. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。 15. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
19. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。 16. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
20. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。 17. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
21. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns``references/``scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。 18. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns``references/``scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
22. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。 19. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
@@ -7,101 +7,44 @@ version: 3.0.0
# scada Action Skill # scada Action Skill
## 简介 ## 简介
负责 `analytics/scada-operations` 场景下 `scada` 的具体接口调用,分为**设备配置(静态元数据)**、**时序监测数据(TimescaleDB**、**实时模拟数据**、**方案数据**和**复合查询**五类 负责 `analytics/scada-operations` 场景下 `scada` 的具体接口调用。
## 子模块索引 (渐进式引导) ## 子模块索引 (渐进式引导)
- 当前为叶子节点,直接使用下方接口目录。 - 当前为叶子节点,直接使用下方接口目录。
## 接口目录 ## 接口目录
### SCADA 设备配置(静态元数据)
| Method | Path | Summary | Required Params | Optional Params | | Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---| |---|---|---|---|---|
| GET | `/api/v1/getscadadeviceschema/` | 获取SCADA设备架构 | network (query) | - | | POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | network (query) | - |
| GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | network (query), id (query) | - | | POST | `/api/v1/addscadadevicedata/` | 添加SCADA设备数据 | network (query) | - |
| POST | `/api/v1/addscadaelement/` | 添加SCADA元素映射 | network (query) | - |
| POST | `/api/v1/cleanscadadevice/` | 清空SCADA设备表 | network (query) | - |
| POST | `/api/v1/cleanscadadevicedata/` | 清空SCADA设备数据表 | network (query) | - |
| POST | `/api/v1/cleanscadaelement/` | 清空SCADA元素映射表 | network (query) | - |
| POST | `/api/v1/deletescadadevice/` | 删除SCADA设备 | network (query) | - |
| POST | `/api/v1/deletescadadevicedata/` | 删除SCADA设备数据 | network (query) | - |
| POST | `/api/v1/deletescadaelement/` | 删除SCADA元素映射 | network (query) | - |
| GET | `/api/v1/getallscadadeviceids/` | 获取所有SCADA设备ID | network (query) | - | | GET | `/api/v1/getallscadadeviceids/` | 获取所有SCADA设备ID | network (query) | - |
| GET | `/api/v1/getallscadadevices/` | 获取所有SCADA设备 | network (query) | - | | GET | `/api/v1/getallscadadevices/` | 获取所有SCADA设备 | network (query) | - |
| POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | network (query) | - | | GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | network (query) | - |
| POST | `/api/v1/setscadadevice/` | 更新SCADA设备 | network (query) | - | | GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA属性 | network (query) | - |
| POST | `/api/v1/deletescadadevice/` | 删除SCADA设备 | network (query) | - | | GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | network (query), id (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) | - | | GET | `/api/v1/getscadadevicedata/` | 获取SCADA设备数据 | network (query), device_id (query) | - |
| POST | `/api/v1/addscadadevicedata/` | 添加SCADA设备数据 | network (query) | - | | GET | `/api/v1/getscadadevicedataschema/` | 获取SCADA设备数据架构 | network (query) | - |
| POST | `/api/v1/setscadadevicedata/` | 更新SCADA设备数据 | network (query) | - | | GET | `/api/v1/getscadadeviceschema/` | 获取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/getscadaelement/` | 获取单个SCADA元素映射 | network (query), id (query) | - |
| GET | `/api/v1/getscadaelements/` | 获取所有SCADA元素映射 | network (query) | - | | GET | `/api/v1/getscadaelements/` | 获取所有SCADA元素映射 | network (query) | - |
| POST | `/api/v1/addscadaelement/` | 添加SCADA元素映射 | network (query) | - | | GET | `/api/v1/getscadaelementschema/` | 获取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/getscadainfo/` | 获取SCADA信息 | network (query), id (query) | - |
| GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | network (query) | - | | GET | `/api/v1/getscadainfoschema/` | 获取SCADA信息架构 | network (query) | - |
| GET | `/api/v1/getscadaproperties/` | 获取SCADA属性 | network (query), scada (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) | - | | 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) | - | | DELETE | `/api/v1/scada/by-id-time-range` | 按设备ID和时间范围删除SCADA数据 | device_id (query), start_time (query), end_time (query) | - |
| GET | `/api/v1/scada/by-ids-field-time-range` | 按设备ID、字段和时间范围查询SCADA数据 | start_time (query), end_time (query), field (query), device_ids (query) | - |
### 实时模拟数据(TimescaleDB - Realtime | GET | `/api/v1/scada/by-ids-time-range` | 按设备ID和时间范围查询SCADA数据 | start_time (query), end_time (query), device_ids (query) | - |
| Method | Path | Summary | Required Params | Optional Params | | PATCH | `/api/v1/scada/{device_id}/field` | 更新SCADA设备字段 | device_id (path), time (query), field (query), value (query) | - |
|---|---|---|---|---| | POST | `/api/v1/setscadadevice/` | 更新SCADA设备 | network (query) | - |
| POST | `/api/v1/realtime/links/batch` | 批量插入实时管道数据 | data (body) | - | | POST | `/api/v1/setscadadevicedata/` | 更新SCADA设备数据 | network (query) | - |
| GET | `/api/v1/realtime/links` | 查询实时管道数据 | start_time (query), end_time (query) | link_ids (query) | | POST | `/api/v1/setscadaelement/` | 更新SCADA元素映射 | network (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` - 覆盖方法:`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' 清洗所有设备 |
@@ -20,24 +20,3 @@ version: 3.0.0
| GET | `/api/v1/burst-detection/schemes/{scheme_name}` | 获取爆管检测方案详情 | network (query), scheme_name (path) | - | | GET | `/api/v1/burst-detection/schemes/{scheme_name}` | 获取爆管检测方案详情 | network (query), scheme_name (path) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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 |
@@ -20,28 +20,3 @@ version: 3.0.0
| GET | `/api/v1/burst-location/schemes/{scheme_name}` | 获取爆管定位方案详情 | network (query), scheme_name (path) | - | | GET | `/api/v1/burst-location/schemes/{scheme_name}` | 获取爆管定位方案详情 | network (query), scheme_name (path) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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 |
@@ -20,27 +20,3 @@ version: 3.0.0
| GET | `/api/v1/leakage/schemes/{scheme_name}` | 获取漏损识别方案详情 | network (query), scheme_name (path) | - | | GET | `/api/v1/leakage/schemes/{scheme_name}` | 获取漏损识别方案详情 | network (query), scheme_name (path) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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为全部 |
@@ -15,20 +15,10 @@ version: 3.0.0
## 接口目录 ## 接口目录
| Method | Path | Summary | Required Params | Optional Params | | 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/getnetworkpiperiskprobabilitynow/` | 获取整个网络的管道风险概率 | network (query) | - |
| GET | `/api/v1/getpiperiskprobability/` | 获取管道风险概率历史 | network (query), pipe_id (query) | - |
| GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | network (query) | - | | GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | network (query) | - |
| GET | `/api/v1/getpiperiskprobabilitynow/` | 获取管道当前风险概率 | network (query), pipe_id (query) | - |
| GET | `/api/v1/getpipesriskprobability/` | 批量获取多条管道风险概率 | network (query), pipe_ids (query) | - |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpiperiskprobabilitynow/` | 查询指定管道在当前时刻的风险概率值 |
| `GET /getpiperiskprobability/` | 查询指定管道的历史风险概率时间序列数据 |
| `GET /getpipesriskprobability/` | 批量查询多条管道的风险概率,pipe_ids为逗号分隔的ID字符串(如 `pipe1,pipe2,pipe3` |
| `GET /getnetworkpiperiskprobabilitynow/` | 查询整个管网中所有管道的当前风险概率,返回列表 |
| `GET /getpiperiskprobabilitygeometries/` | 查询管网中管道的地理位置和风险相关几何数据,适合地图可视化 |
@@ -15,60 +15,34 @@ version: 3.0.0
## 接口目录 ## 接口目录
| Method | Path | Summary | Required Params | Optional Params | | Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---| |---|---|---|---|---|
| GET | `/api/v1/age_analysis/` | 水龄分析(高级) | network (query), start_time (query), end_time (query), duration (query) | - |
| GET | `/api/v1/ageanalysis/` | 水龄分析(基础) | network (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/burstanalysis/` | 爆管分析(基础) | network (query), pipe_id (query), start_time (query), end_time (query), burst_flow (query) | - |
| GET | `/api/v1/contaminant_simulation/` | 污染物模拟 | network (query), start_time (query), source (query), concentration (query), duration (query) | scheme_name (query), pattern (query) |
| POST | `/api/v1/daily_scheduling_analysis/` | 日排程分析 | data (body) | - |
| GET | `/api/v1/dumpoutput/` | 导出模拟输出 | output (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/flushinganalysis/` | 冲洗分析(基础) | network (query), pipe_id (query), start_time (query), duration (query), flow (query) | - |
| POST | `/api/v1/network_project/` | 导入网络项目 | file (file) | - |
| POST | `/api/v1/network_update/` | 管网更新(高级) | file (file) | - |
| GET | `/api/v1/networkupdate/` | 管网更新(基础) | network (query) | - |
| POST | `/api/v1/pressure_regulation/` | 压力调节(高级) | data (body) | - |
| POST | `/api/v1/pressure_sensor_placement_kmeans/` | 压力传感器放置-KMeans聚类分析(高级) | data (body) | - |
| POST | `/api/v1/pressure_sensor_placement_sensitivity/` | 压力传感器放置-灵敏度分析(高级) | data (body) | - |
| GET | `/api/v1/pressureregulation/` | 压力调节(基础) | network (query), target_node (query), target_pressure (query) | - |
| GET | `/api/v1/pressuresensorplacementkmeans/` | 压力传感器放置-KMeans聚类分析(基础) | name (query), scheme_name (query), sensor_number (query), min_diameter (query), username (query) | - |
| GET | `/api/v1/pressuresensorplacementsensitivity/` | 压力传感器放置-灵敏度分析(基础) | name (query), scheme_name (query), sensor_number (query), min_diameter (query), username (query) | - |
| POST | `/api/v1/project_management/` | 项目管理(高级) | data (body) | - |
| GET | `/api/v1/projectmanagement/` | 项目管理(基础) | network (query) | - |
| POST | `/api/v1/pump_failure/` | 泵故障管理 | data (body) | - |
| GET | `/api/v1/runinp/` | 运行INP文件 | network (query) | - |
| GET | `/api/v1/runproject/` | 运行项目模拟 | network (query) | - | | GET | `/api/v1/runproject/` | 运行项目模拟 | network (query) | - |
| GET | `/api/v1/runprojectreturndict/` | 运行项目模拟(返回字典) | network (query) | - | | GET | `/api/v1/runprojectreturndict/` | 运行项目模拟(返回字典) | network (query) | - |
| GET | `/api/v1/runinp/` | 运行INP文件 | network (query) | - | | POST | `/api/v1/runsimulationmanuallybydate/` | 手动运行日期指定模拟 | data (body) | - |
| GET | `/api/v1/dumpoutput/` | 导出模拟输出 | output (query) | - | | POST | `/api/v1/scheduling_analysis/` | 排程分析 | data (body) | - |
| GET | `/api/v1/burstanalysis/` | 爆管分析(基础) | network (query), pipe_id (query), start_time (query), end_time (query), burst_flow (query) | - | | POST | `/api/v1/sensorplacementscheme/create` | 传感器放置方案创建 | network (query), scheme_name (query), sensor_type (query), method (query), sensor_count (query), user_name (query) | min_diameter (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_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/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/valvecloseanalysis/` | 阀门关闭分析(基础) | network (query), valve_id (query), start_time (query), end_time (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/` | 按日期手动运行模拟:根据指定日期、开始时间和持续时间查询管网参数并执行水力模拟 |
@@ -23,14 +23,3 @@ version: 3.0.0
| POST | `/api/v1/setruleproperties/` | 设置规则属性 | network (query) | - | | POST | `/api/v1/setruleproperties/` | 设置规则属性 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getcontrolschema/` | 返回简单控制(Control)数据模型的字段定义 |
| `GET /getcontrolproperties/` | 获取管网中所有简单控制规则的属性列表 |
| `GET /getruleschema/` | 返回规则控制(Rule)数据模型的字段定义 |
| `GET /getruleproperties/` | 获取管网中所有基于规则的复杂控制条件列表 |
| `POST /setcontrolproperties/` | 设置/更新简单控制规则的属性 |
| `POST /setruleproperties/` | 设置/更新规则控制的属性 |
@@ -24,14 +24,3 @@ version: 3.0.0
| POST | `/api/v1/setcurveproperties/` | 设置曲线属性 | network (query), curve (query) | - | | POST | `/api/v1/setcurveproperties/` | 设置曲线属性 | network (query), curve (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getcurveschema` | 返回曲线(Curve)数据模型的字段定义(水泵特性曲线、效率曲线等) |
| `GET /getcurves/` | 获取管网中所有曲线的ID列表 |
| `GET /getcurveproperties/` | 查询指定曲线的详细属性(类型、控制点数据等) |
| `POST /addcurve/` | 向管网添加一条新曲线 |
| `POST /deletecurve/` | 从管网删除指定曲线 |
| `POST /setcurveproperties/` | 设置/更新曲线的属性(控制点坐标等) |
@@ -29,15 +29,3 @@ version: 3.0.0
| POST | `/api/v1/settimeproperties/` | 设置时间选项属性 | network (query) | - | | POST | `/api/v1/settimeproperties/` | 设置时间选项属性 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getoptionschema/` | 返回模拟选项(Options)数据模型的字段定义 |
| `GET /getoptionproperties/` | 获取管网模拟参数设置(时步、精度、单位系统等) |
| `GET /getenergyschema/` | 返回能耗选项(Energy)数据模型的字段定义 |
| `GET /getenergyproperties/` | 获取全局能耗设置(电价、效率等) |
| `GET /getpumpenergyproperties/` | 获取单台水泵的能耗参数 |
| `POST /setoptionproperties/` | 设置管网模拟参数 |
| `POST /setenergyproperties/` | 设置全局能耗参数 |
@@ -24,14 +24,3 @@ version: 3.0.0
| POST | `/api/v1/setpatternproperties/` | 设置模式属性 | network (query), pattern (query) | - | | POST | `/api/v1/setpatternproperties/` | 设置模式属性 | network (query), pattern (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpatternschema` | 返回模式(Pattern)数据模型的字段定义 |
| `GET /getpatterns/` | 获取管网中所有时间模式的ID列表 |
| `GET /getpatternproperties/` | 查询指定模式的属性(时间序列乘数值等) |
| `POST /addpattern/` | 向管网添加一个新时间模式 |
| `POST /deletepattern/` | 从管网删除指定时间模式 |
| `POST /setpatternproperties/` | 设置/更新时间模式的属性(乘数序列) |
@@ -42,18 +42,3 @@ version: 3.0.0
| POST | `/api/v1/settankreaction/` | 设置水池反应属性 | network (query) | - | | POST | `/api/v1/settankreaction/` | 设置水池反应属性 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getemitterproperties/` | 查询节点处发射器(用于模拟管漏)的属性 |
| `GET /getmixingproperties/` | 查询水箱水质混合模型属性 |
| `GET /getsourceproperties/` | 查询水质污染源的属性(位置、类型、浓度等) |
| `GET /getreactionproperties/` | 获取全局水质反应参数(管网反应系数等) |
| `GET /getwaterqualityresult/` | 查询水质模拟结果 |
| `POST /addsource/` | 向管网添加一个水质污染源 |
| `POST /deletesource/` | 删除指定水质污染源 |
| `POST /addmixing/` | 为水箱添加水质混合模型 |
| `POST /deletemixing/` | 删除水箱水质混合模型 |
| `POST /setemitter*/` | 设置发射器属性(流量系数等) |
@@ -32,14 +32,3 @@ version: 3.0.0
| POST | `/api/v1/setvertexproperties/` | 设置图形元素属性 | network (query) | - | | POST | `/api/v1/setvertexproperties/` | 设置图形元素属性 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallvertexlinks/` | 获取所有管线的折点列表(用于地图还原管线真实走向) |
| `GET /getvertexlink/` | 获取单条管线的折点坐标序列 |
| `POST /addvertex/` | 为管线添加一个折点(改变管线显示路径) |
| `POST /deletevertex/` | 删除管线上的指定折点 |
| `POST /addlabel/` | 在地图上添加文字标注 |
| `POST /deletelabel/` | 删除地图文字标注 |
@@ -15,18 +15,10 @@ version: 3.0.0
## 接口目录 ## 接口目录
| Method | Path | Summary | Required Params | Optional Params | | Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---| |---|---|---|---|---|
| POST | `/api/v1/auth/login` | 用户登录 | form_data (body) | - | | POST | `/api/v1/auth/login` | login | form_data (body) | - |
| POST | `/api/v1/auth/login/simple` | 简化版登录 | username (query), password (query) | - | | POST | `/api/v1/auth/login/simple` | login_simple | username (query), password (query) | - |
| GET | `/api/v1/auth/me` | 获取当前用户信息 | - | - | | GET | `/api/v1/auth/me` | get_current_user_info | - | - |
| POST | `/api/v1/auth/refresh` | 刷新AccessToken | refresh_token (query) | - | | POST | `/api/v1/auth/refresh` | refresh_token | refresh_token (query) | - |
| POST | `/api/v1/auth/register` | register | user_data (body) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `POST /login` | OAuth2标准格式登录,提交form-datausername+password),返回JWT Access Token和Refresh Token |
| `POST /login/simple` | 简化版登录,直接通过query参数传递username和password,保持向后兼容 |
| `GET /me` | 返回当前已登录用户的详细信息(需携带Access Token |
| `POST /refresh` | 使用Refresh Token换取新的Access Token,延续会话 |
@@ -23,14 +23,3 @@ version: 3.0.0
| POST | `/api/v1/users/{user_id}/deactivate` | 停用用户 | user_id (path) | - | | POST | `/api/v1/users/{user_id}/deactivate` | 停用用户 | user_id (path) | - |
- 覆盖方法:`DELETE, GET, POST, PUT` - 覆盖方法:`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` | 停用指定用户账号(禁止登录,管理员操作) |
@@ -20,11 +20,3 @@ version: 3.0.0
| GET | `/api/v1/getuserschema/` | 获取用户模式 | network (query) | - | | GET | `/api/v1/getuserschema/` | 获取用户模式 | network (query) | - |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallusers/` | 获取指定管网下的所有用户列表(旧版接口,返回管网级别用户信息) |
| `GET /getuser/` | 按用户名查询指定管网下的单个用户信息 |
| `GET /getuserschema/` | 获取用户数据模型的字段定义(Schema) |
@@ -23,14 +23,3 @@ version: 3.0.0
| POST | `/api/v1/setdemandproperties/` | 设置需水量属性 | network (query), junction (query) | - | | POST | `/api/v1/setdemandproperties/` | 设置需水量属性 | network (query), junction (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getdemandschema` | 返回需水量(Demand)数据模型的字段定义 |
| `GET /getdemandproperties/` | 查询指定节点的需水量属性(基础需水量、模式等) |
| `POST /setdemandproperties/` | 设置节点的需水量属性 |
| `POST /calculatedemandtonodes/` | 将指定总需水量计算分配到各节点 |
| `POST /calculatedemandtoregion/` | 将指定总需水量计算分配到指定区域内的节点 |
| `POST /calculatedemandtonetwork/` | 将指定总需水量按比例分配到整个管网的所有节点 |
@@ -46,17 +46,3 @@ version: 3.0.0
| GET | `/api/v1/settitle/` | 设置水网标题属性 | network (query) | - | | GET | `/api/v1/settitle/` | 设置水网标题属性 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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/` | 删除节点(节点/水箱/水库) |
@@ -23,14 +23,3 @@ version: 3.0.0
| GET | `/api/v1/getnodecoord/` | 获取节点坐标 | network (query), node (query) | - | | GET | `/api/v1/getnodecoord/` | 获取节点坐标 | network (query), node (query) | - |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getnodecoord/` | 查询单个节点(任意类型)的坐标(x, y) |
| `GET /getmajornodecoords/` | 获取管网主要节点(干管节点)的坐标列表,用于快速渲染 |
| `GET /getmajorpipenodes/` | 获取主要管道的起终节点列表 |
| `GET /getnetworklinknodes/` | 获取管网中所有管线的起终节点信息 |
| `GET /getnetworkgeometries/` | 获取整个管网的完整几何信息(节点坐标 + 管线折点),适合地图绘制 |
| `GET /getnetworkinextent/` | 查询指定地理范围(bbox)内的管网节点和管线 |
@@ -35,18 +35,3 @@ version: 3.0.0
| POST | `/api/v1/setjunctiony/` | 设置节点 Y 坐标 | network (query), junction (query), y (query) | - | | POST | `/api/v1/setjunctiony/` | 设置节点 Y 坐标 | network (query), junction (query), y (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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*/` | 设置节点某个具体属性(坐标、标高、需水量等) |
@@ -37,18 +37,3 @@ version: 3.0.0
| POST | `/api/v1/setpipestatus/` | 设置管道状态 | network (query), pipe (query), status (query) | - | | POST | `/api/v1/setpipestatus/` | 设置管道状态 | network (query), pipe (query), status (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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*/` | 设置管道某个具体属性(管径、长度、状态等) |
@@ -27,16 +27,3 @@ version: 3.0.0
| POST | `/api/v1/setpumpproperties/` | 设置水泵属性 | network (query), pump (query) | - | | POST | `/api/v1/setpumpproperties/` | 设置水泵属性 | network (query), pump (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getpumpschema` | 返回水泵(Pump)数据模型的所有字段定义 |
| `GET /getpumpproperties/` | 查询单台水泵的所有属性(曲线名称、起终节点等) |
| `GET /getallpumpproperties/` | 批量获取管网中所有水泵的属性列表 |
| `POST /addpump/` | 向管网添加一台新水泵,需提供ID和起终节点 |
| `POST /deletepump/` | 从管网删除指定水泵 |
| `POST /setpumpproperties/` | 批量设置水泵属性(曲线、初始状态、效率等) |
| `POST /setpumpnode1/` | 设置水泵起始节点 |
| `POST /setpumpnode2/` | 设置水泵终止节点 |
@@ -1,7 +1,7 @@
--- ---
name: tjwater-action-business-network-assets-regions name: tjwater-action-business-network-assets-regions
description: business/network-assets 下 regions 操作技能。 description: business/network-assets 下 regions 操作技能。
version: 3.0.1 version: 3.0.0
--- ---
# regions Action Skill # regions Action Skill
@@ -19,21 +19,25 @@ version: 3.0.1
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - | | POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
| POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - | | POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - |
| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - | | POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringarea/` | 计算DMA分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - | | GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - | | GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - | | GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
| GET | `/api/v1/calculateservicearea/` | 计算服务区(返回全部时间步) | network (query) | - | | GET | `/api/v1/calculateregion/` | 计算区域 | network (query), time_index (query) | - |
| GET | `/api/v1/calculateservicearea/` | 计算服务区 | network (query), time_index (query) | - |
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - | | GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - | | POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - | | POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - | | POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | 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/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
| POST | `/api/v1/generateregion/` | 生成区域分区 | network (query), inflate_delta (query) | - |
| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | network (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/generatesubdistrictmeteringarea/` | 生成DMA子分区 | network (query), dma (query), part_count (query), part_type (query), inflate_delta (query) | - |
| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - | | POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - | | GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - | | GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
| GET | `/api/v1/getallregions/` | 获取所有区域 | network (query) | - |
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - | | GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - | | GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - | | GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
@@ -50,20 +54,3 @@ version: 3.0.1
| POST | `/api/v1/setvirtualdistrict/` | 设置虚拟分区属性 | network (query) | - | | POST | `/api/v1/setvirtualdistrict/` | 设置虚拟分区属性 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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/` | 计算服务区,返回全部时间步结果 |
@@ -33,16 +33,3 @@ version: 3.0.0
| POST | `/api/v1/setreservoiry/` | 设置水库Y坐标 | network (query), reservoir (query), y (query) | - | | POST | `/api/v1/setreservoiry/` | 设置水库Y坐标 | network (query), reservoir (query), y (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getreservoirschema` | 返回水库(Reservoir)数据模型的所有字段定义 |
| `GET /getreservoirproperties/` | 查询单个水库的所有属性(水头、模式、坐标等) |
| `GET /getallreservoirproperties/` | 批量获取管网中所有水库的属性列表 |
| `GET /getreservoirhead/` | 查询水库水头(即水库水位高度) |
| `GET /getreservoirpattern/` | 查询水库关联的时间模式名称 |
| `POST /addreservoir/` | 向管网添加一个新水库,需提供ID、坐标和水头 |
| `POST /deletereservoir/` | 从管网删除指定水库 |
| `POST /setreservoir*/` | 设置水库某个具体属性(水头、模式、坐标等) |
@@ -21,12 +21,3 @@ version: 3.0.0
| POST | `/api/v1/settag/` | 设置标签 | network (query) | - | | POST | `/api/v1/settag/` | 设置标签 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /gettagschema/` | 返回标签(Tag)数据模型的字段定义 |
| `GET /gettag/` | 查询单个元素绑定的标签信息 |
| `GET /gettags/` | 获取管网中所有标签列表 |
| `POST /settag/` | 为管网元素设置/更新标签(支持自定义键值对属性) |
@@ -45,17 +45,3 @@ version: 3.0.0
| POST | `/api/v1/settanky/` | 设置水箱Y坐标 | network (query), tank (query), y (query) | - | | POST | `/api/v1/settanky/` | 设置水箱Y坐标 | network (query), tank (query), y (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /gettankschema` | 返回水箱(Tank)数据模型的所有字段定义 |
| `GET /gettankproperties/` | 查询单个水箱的所有属性(标高、直径、初始/最大/最小水位等) |
| `GET /getalltankproperties/` | 批量获取管网中所有水箱的属性列表 |
| `GET /gettankelevation/` | 查询水箱底部标高 |
| `GET /gettankdiameter/` | 查询水箱直径 |
| `GET /gettankinitlevel/` | 查询水箱初始水位 |
| `POST /addtank/` | 向管网添加一个新水箱,需提供ID、坐标、标高和水位参数 |
| `POST /deletetank/` | 从管网删除指定水箱 |
| `POST /settank*/` | 设置水箱某个具体属性(坐标、标高、直径、水位等) |
@@ -34,17 +34,3 @@ version: 3.0.0
| POST | `/api/v1/setvalvetype/` | 设置阀门类型 | network (query), valve (query), type (query) | - | | POST | `/api/v1/setvalvetype/` | 设置阀门类型 | network (query), valve (query), type (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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*/` | 设置阀门某个具体属性(类型、设定值、管径等) |
@@ -21,12 +21,3 @@ version: 3.0.0
| POST | `/api/v1/setextensiondata/` | 设置扩展数据 | network (query) | - | | POST | `/api/v1/setextensiondata/` | 设置扩展数据 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallextensiondatakeys/` | 获取当前管网中所有已存储的自定义扩展数据的键名列表 |
| `GET /getallextensiondata/` | 获取当前管网所有自定义扩展数据(键值对集合) |
| `GET /getextensiondata/` | 按 key 查询指定的自定义扩展数据值 |
| `POST /setextensiondata/` | 设置或更新一个自定义扩展数据键值对(可用于存储任意业务自定义信息) |
@@ -23,14 +23,3 @@ version: 3.0.0
| POST | `/api/v1/test_dict/` | 测试字典处理 | data (body) | - | | POST | `/api/v1/test_dict/` | 测试字典处理 | data (body) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getallburstlocateresults/` | 获取当前管网所有历史爆管定位分析结果(旧版接口) |
| `GET /getallsensorplacements/` | 获取当前管网所有传感器布置方案的结果列表 |
| `GET /getsimulationresult/` | 获取最近一次水力模拟结果(旧版接口) |
| `GET /getrealtimedata/` | 获取管网实时监测数据(旧版接口) |
| `GET /getjson/` | 返回示例 JSON 数据结构,用于开发调试 |
| `POST /test_dict/` | 测试字典类型请求体的接口,用于开发调试 |
@@ -21,12 +21,3 @@ version: 3.0.0
| GET | `/api/v1/scheme-list` | 获取方案列表 | - | - | | GET | `/api/v1/scheme-list` | 获取方案列表 | - | - |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /burst-locate-result` | 获取所有爆管定位事件的历史结果列表(新版REST接口) |
| `GET /burst-locate-result/{burst_incident}` | 查询指定爆管事件(burst_incident ID)的详细定位结果 |
| `GET /scada-info` | 获取当前项目关联的 SCADA 设备和监测点信息汇总 |
| `GET /scheme-list` | 获取当前项目中所有可用的水力计算方案列表 |
@@ -15,41 +15,34 @@ version: 3.0.0
## 接口目录 ## 接口目录
| Method | Path | Summary | Required Params | Optional Params | | Method | Path | Summary | Required Params | Optional Params |
|---|---|---|---|---| |---|---|---|---|---|
| GET | `/api/v1/listprojects/` | 获取项目列表 | - | - | | POST | `/api/v1/closeproject/` | 关闭项目 | network (query) | - |
| GET | `/api/v1/project_info/` | 获取项目信息 | network (query) | - | | GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | - | - |
| GET | `/api/v1/haveproject/` | 检查项目是否存在 | network (query) | - | | GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | - | - |
| POST | `/api/v1/copyproject/` | 复制项目 | source (query), target (query) | - |
| POST | `/api/v1/createproject/` | 创建新项目 | network (query) | - | | POST | `/api/v1/createproject/` | 创建新项目 | network (query) | - |
| POST | `/api/v1/deleteproject/` | 删除项目 | network (query) | - | | POST | `/api/v1/deleteproject/` | 删除项目 | network (query) | - |
| POST | `/api/v1/copyproject/` | 复制项目 | source (query), target (query) | - | | GET | `/api/v1/downloadinp/` | 下载 INP 文件 | name (query) | - |
| GET | `/api/v1/isprojectopen/` | 检查项目是否已打开 | network (query) | - | | GET | `/api/v1/downloadinp/` | 下载 INP 文件 | name (query) | - |
| POST | `/api/v1/openproject/` | 打开项目 | network (query) | - | | GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | network (query), inp (query) | - |
| POST | `/api/v1/closeproject/` | 关闭项目 | network (query) | - | | GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | network (query), inp (query) | - |
| GET | `/api/v1/exportinp/` | 导出项目为 ChangeSet | network (query), version (query) | - |
| GET | `/api/v1/haveproject/` | 检查项目是否存在 | network (query) | - |
| POST | `/api/v1/importinp/` | 导入 INP 文件内容 | network (query) | - |
| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | network (query) | - |
| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | network (query) | - | | GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | network (query) | - |
| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - | | GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - |
| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - |
| GET | `/api/v1/isprojectopen/` | 检查项目是否已打开 | network (query) | - |
| GET | `/api/v1/listprojects/` | 获取项目列表 | - | - |
| POST | `/api/v1/lockproject/` | 锁定项目 | network (query) | - | | POST | `/api/v1/lockproject/` | 锁定项目 | network (query) | - |
| POST | `/api/v1/unlockproject/` | 锁项目 | network (query) | - | | POST | `/api/v1/lockproject/` | 锁项目 | network (query) | - |
| POST | `/api/v1/importinp/` | 导入 INP 文件内容 | network (query) | - | | POST | `/api/v1/openproject/` | 打开项目 | network (query) | - |
| GET | `/api/v1/exportinp/` | 导出项目为 ChangeSet | network (query), version (query) | - | | GET | `/api/v1/project_info/` | 获取项目信息 | network (query) | - |
| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | network (query), inp (query) | - | | POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | network (query), inp (query) | - |
| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | network (query), inp (query) | - | | POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | network (query), inp (query) | - |
| POST | `/api/v1/unlockproject/` | 解锁项目 | network (query) | - |
| POST | `/api/v1/unlockproject/` | 解锁项目 | network (query) | - |
| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | afile (body), name (query) | - |
| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | afile (body), name (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, 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 格式 |
@@ -20,11 +20,3 @@ version: 3.0.0
| GET | `/api/v1/getschemeschema/` | 获取方案模式 | network (query) | - | | GET | `/api/v1/getschemeschema/` | 获取方案模式 | network (query) | - |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /getschemeschema/` | 返回方案(Scheme)数据模型的字段定义 |
| `GET /getallschemes/` | 获取当前管网下所有已保存方案的列表 |
| `GET /getscheme/` | 查询指定方案名称(schema_name)的详细属性和配置 |
@@ -35,20 +35,3 @@ version: 3.0.0
| POST | `/api/v1/undo/` | 撤销操作 | network (query) | - | | POST | `/api/v1/undo/` | 撤销操作 | network (query) | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`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 创建快照(保存当前操作节点状态) |
@@ -22,13 +22,3 @@ version: 3.0.0
| GET | `/api/v1/composite/scada-simulation` | 获取SCADA关联的模拟数据 | start_time (query), end_time (query), device_ids (query) | scheme_type (query), scheme_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, 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` | 在指定时刻对整个管网的管道健康状态进行预测,返回各管道健康评分 |
@@ -27,18 +27,3 @@ version: 3.0.0
| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | node_result_list (body), link_result_list (body), result_start_time (query) | - | | POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | node_result_list (body), link_result_list (body), result_start_time (query) | - |
- 覆盖方法:`DELETE, GET, PATCH, POST` - 覆盖方法:`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、类型和时间点查询其实时模拟值 |
@@ -29,20 +29,3 @@ version: 3.0.0
| POST | `/api/v1/scheme/simulation/store` | 存储方案模拟结果 | scheme_type (query), scheme_name (query), node_result_list (body), link_result_list (body), result_start_time (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` - 覆盖方法:`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` | 按方案、时间点和属性名查询全网在指定方案下的模拟值 |
-15
View File
@@ -44,21 +44,6 @@
- 第二步(可选)工具调用:查询补充数据接口。 - 第二步(可选)工具调用:查询补充数据接口。
- opencode agent 汇总工具结果,持续通过 SSE 输出 `progress``token`,最终返回 `done` - opencode agent 汇总工具结果,持续通过 SSE 输出 `progress``token`,最终返回 `done`
若接口要求 JSON 请求体,可这样调用:
```json
{
"path": "/api/v1/runproject/simulate",
"method": "POST",
"arguments": {
"network": "demo-network"
},
"body": {
"duration_minutes": 15
}
}
```
`progress` 示例: `progress` 示例:
```json ```json
@@ -20,11 +20,3 @@ version: 3.0.0
| GET | `/api/v1/audit/logs/my` | 查询我的审计日志 | - | action (query), start_time (query), end_time (query), skip (query), limit (query) | | GET | `/api/v1/audit/logs/my` | 查询我的审计日志 | - | action (query), start_time (query), end_time (query), skip (query), limit (query) |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /audit/logs` | 查询系统审计日志(仅管理员),支持按用户ID、项目ID、操作类型、资源类型、时间范围过滤;支持分页(skip/limit |
| `GET /audit/logs/count` | 获取满足过滤条件的审计日志总条数,用于分页显示 |
| `GET /audit/logs/my` | 查询当前登录用户自己的操作日志,支持按操作类型和时间范围过滤 |
@@ -21,12 +21,3 @@ version: 3.0.0
| GET | `/api/v1/queryredis/` | 查询缓存键列表 | - | - | | GET | `/api/v1/queryredis/` | 查询缓存键列表 | - | - |
- 覆盖方法:`GET, POST` - 覆盖方法:`GET, POST`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /queryredis/` | 查询当前 Redis 中所有缓存键列表,用于检查缓存状态 |
| `POST /clearrediskey/` | 删除指定单个键的缓存(精确匹配) |
| `POST /clearrediskeys/` | 批量删除多个匹配的缓存键(支持模式匹配) |
| `POST /clearallredis/` | 清除 Redis 中所有缓存数据(慎用,会影响所有会话) |
@@ -20,11 +20,3 @@ version: 3.0.0
| GET | `/api/v1/meta/projects` | 列出用户项目 | - | - | | GET | `/api/v1/meta/projects` | 列出用户项目 | - | - |
- 覆盖方法:`GET` - 覆盖方法:`GET`
## 接口说明
| 接口 | 说明 |
|---|---|
| `GET /meta/db/health` | 检查数据库(PostgreSQL/TimescaleDB)连接健康状态,返回 ok/error |
| `GET /meta/project` | 获取当前用户当前项目的元数据(名称、创建时间、所有者等) |
| `GET /meta/projects` | 列出当前登录用户有权限访问的所有项目信息 |
-1
View File
@@ -66,7 +66,6 @@ SSE 事件:
- `path` 必须以 `/` 开头。 - `path` 必须以 `/` 开头。
- `method` 支持:`GET/POST/PUT/PATCH/DELETE` - `method` 支持:`GET/POST/PUT/PATCH/DELETE`
- `arguments` 会编码为 query 参数(列表会转为逗号拼接)。 - `arguments` 会编码为 query 参数(列表会转为逗号拼接)。
- `body` 会作为 JSON request body 透传;`GET` 不支持传 `body`
## 3.1) 学习工具约定 ## 3.1) 学习工具约定
+4 -9
View File
@@ -5,7 +5,7 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({ export default tool({
description: description:
"通过本地 Agent 桥接调用 TJWater 后端 API。支持 query 参数,且在 POST/PUT/PATCH/DELETE 时可选传递 JSON body。", "通过本地 Agent 桥接调用 TJWater 后端 API。需提供 API 路径、可选的请求方法以及查询参数。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
@@ -18,14 +18,10 @@ export default tool({
arguments: tool.schema arguments: tool.schema
.record(tool.schema.string(), tool.schema.unknown()) .record(tool.schema.string(), tool.schema.unknown())
.optional() .optional()
.describe("Query arguments object. Encoded into URL query string."), .describe("Query arguments object."),
body: tool.schema
.unknown()
.optional()
.describe("Optional JSON body for POST/PUT/PATCH/DELETE requests."),
}, },
async execute(args, context) { async execute(args, context) {
// 工具本身不直接持有用户 token;通过 runtimeSessionId 回调 Agent 服务,由服务侧补齐用户上下文。 // 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, { const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -33,12 +29,11 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
runtimeSessionId: context.sessionID, sessionId: context.sessionID,
reason: args.reason, reason: args.reason,
path: args.path, path: args.path,
method: args.method, method: args.method,
arguments: args.arguments, arguments: args.arguments,
body: args.body,
}), }),
}); });
+1 -1
View File
@@ -26,7 +26,7 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
runtimeSessionId: context.sessionID, sessionId: context.sessionID,
result_ref: args.result_ref, result_ref: args.result_ref,
max_items: args.max_items, max_items: args.max_items,
}), }),
+6 -6
View File
@@ -1,12 +1,12 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js"; import { MemoryStore } from "../../src/memory/store.js";
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js"; import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const runtimeSessionStore = new RuntimeSessionStore(); const toolContextStore = new ToolSessionContextStore();
const initializePromise = Promise.all([ const initializePromise = Promise.all([
memoryStore.initialize(), memoryStore.initialize(),
runtimeSessionStore.initialize(), toolContextStore.initialize(),
]); ]);
export default tool({ export default tool({
@@ -37,7 +37,7 @@ export default tool({
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; await initializePromise;
const sessionContext = await runtimeSessionStore.read(context.sessionID); const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) { if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`); throw new Error(`session context not found for ${context.sessionID}`);
} }
@@ -80,7 +80,7 @@ export default tool({
if (args.action === "add") { if (args.action === "add") {
const result = await memoryStore.upsert(scope, scopeKey, { const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "", content: args.content ?? "",
sessionId: sessionContext.sessionId, sessionId: context.sessionID,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}); });
@@ -105,7 +105,7 @@ export default tool({
if (args.action === "replace") { if (args.action === "replace") {
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", { const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
content: args.content ?? "", content: args.content ?? "",
sessionId: sessionContext.sessionId, sessionId: context.sessionID,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}); });
+2 -6
View File
@@ -1,20 +1,16 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: description: "在前端地图上对 junctions 图层应用分区渲染。",
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
.describe("Why this junction rendering action is needed for the user request."), .describe("Why this junction rendering action is needed for the user request."),
render_ref: tool.schema render_ref: tool.schema
.string() .string()
.describe( .describe("Reference to a stored junction rendering payload resolved by the Agent service."),
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } }node_area_map 必填,area_ids / area_colors 可选。",
),
}, },
async execute() { async execute() {
// 工具参数里只需要 render_ref;浏览器端会再用该引用回读完整 payload.data 并完成渲染。
return "已在地图上应用节点分区渲染。"; return "已在地图上应用节点分区渲染。";
}, },
}); });
+1 -1
View File
@@ -31,7 +31,7 @@ export default tool({
body: JSON.stringify({ body: JSON.stringify({
max_results: args.max_results, max_results: args.max_results,
query: args.query, query: args.query,
runtimeSessionId: context.sessionID, sessionId: context.sessionID,
}), }),
}); });
const text = await response.text(); const text = await response.text();
+4 -4
View File
@@ -1,10 +1,10 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js"; import { SkillStore } from "../../src/skills/store.js";
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js"; import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const runtimeSessionStore = new RuntimeSessionStore(); const toolContextStore = new ToolSessionContextStore();
const initializePromise = runtimeSessionStore.initialize(); const initializePromise = toolContextStore.initialize();
const skillStore = new SkillStore(); const skillStore = new SkillStore();
export default tool({ export default tool({
@@ -51,7 +51,7 @@ export default tool({
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; await initializePromise;
const sessionContext = await runtimeSessionStore.read(context.sessionID); const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) { if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`); throw new Error(`session context not found for ${context.sessionID}`);
} }
-38
View File
@@ -1,38 +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:
"把本地 JSON 渲染文件迁移成受控的 render_ref。仅适用于需要通过链接引用传递的大型 junction render payload。",
args: {
reason: tool.schema
.string()
.describe("Why this local render payload should be persisted as a render_ref."),
file_path: tool.schema
.string()
.describe(
"Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.",
),
},
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({
runtimeSessionId: context.sessionID,
file_path: args.file_path,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+1 -1
View File
@@ -93,7 +93,7 @@ src/
这些是 opencode 可以调用的自定义工具。 这些是 opencode 可以调用的自定义工具。
`dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端。`arguments` 会编码为 query 参数;`body` 可用于 `POST/PUT/PATCH/DELETE` 的 JSON 请求体。 `dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端。
前端类工具如 `locate_features``view_history``view_scada``show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。 前端类工具如 `locate_features``view_history``view_scada``show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
-33
View File
@@ -6,11 +6,9 @@
"name": "tjwater-agent", "name": "tjwater-agent",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.14.29", "@opencode-ai/sdk": "^1.14.29",
"@types/pg": "^8.20.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"pg": "^8.21.0",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"zod": "^3.25.76", "zod": "^3.25.76",
@@ -19,7 +17,6 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
"bun-types": "^1.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
}, },
@@ -43,8 +40,6 @@
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
@@ -61,8 +56,6 @@
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -185,22 +178,6 @@
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
"pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
@@ -209,14 +186,6 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -287,8 +256,6 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+35
View File
@@ -0,0 +1,35 @@
services:
tjwater-agent:
container_name: tjwater-agent
build:
context: .
dockerfile: Dockerfile
args:
UBUNTU_APT_MIRROR: mirrors.aliyun.com
PYPI_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
PYPI_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
image: tjwater-agent:latest
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 8787
DEEPSEEK_API_KEY: "sk-8941428ad9be4c789becfa8d66534aba"
TJWATER_API_BASE_URL: "http://127.0.0.1:8000"
# Embedded 模式:容器内启动 opencode CLI 子进程
OPENCODE_MODE: embedded
OPENCODE_HOSTNAME: 0.0.0.0
OPENCODE_PORT: 4096
# Client 模式:连接外部服务地址,不依赖容器内 CLI
# OPENCODE_MODE: client
# OPENCODE_CLIENT_BASE_URL: "http://host.docker.internal:4096"
volumes:
- /home/ubuntu/.config/opencode:/root/.config/opencode
- /home/ubuntu/.local/share/opencode:/root/.local/share/opencode
- ./opencode/agents:/app/.opencode/agents
- ./opencode/skills:/app/.opencode/skills
- ./logs:/app/logs
- ./data:/app/data
ports:
- "8787:8787"
# - "4096:4096"
restart: unless-stopped
-4
View File
@@ -11,18 +11,15 @@
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
"migrate:storage": "bun scripts/migrate-file-storage-to-postgres.ts",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
"start": "bun src/server.ts", "start": "bun src/server.ts",
"start:prod": "bun run check && bun src/server.ts" "start:prod": "bun run check && bun src/server.ts"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.14.29", "@opencode-ai/sdk": "^1.14.29",
"@types/pg": "^8.20.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"pg": "^8.21.0",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"zod": "^3.25.76" "zod": "^3.25.76"
@@ -31,7 +28,6 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
"bun-types": "^1.3.3",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }
-578
View File
@@ -1,578 +0,0 @@
import { readdir, readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import { config } from "../src/config.js";
import { AgentDatabase } from "../src/db/index.js";
import {
RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
RESULT_REF_PATTERN,
type ResultPreview,
} from "../src/results/store.js";
import { sanitizePersistentDocument, sanitizePersistentLine } from "../src/utils/persistencePolicy.js";
import { toProjectKey, toStableId } from "../src/utils/fileStore.js";
type JsonRecord = Record<string, unknown>;
const db = new AgentDatabase();
const runtimeConversationMap = new Map<string, string>();
const counters = {
conversations: 0,
states: 0,
turns: 0,
runtimeSessions: 0,
learningStates: 0,
resultRefs: 0,
memories: 0,
skippedResultRefs: 0,
};
await db.initialize();
await migrateConversations();
await migrateConversationStates();
await migrateConversationTurns();
await migrateRuntimeSessions();
await migrateLearningStates();
await migrateResultRefs();
await migrateMemories();
await db.close();
console.log(JSON.stringify(counters, null, 2));
async function migrateConversations() {
for (const filePath of await listFiles(config.CONVERSATION_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.sessionId !== "string" || typeof row.actorKey !== "string") {
continue;
}
await upsertConversation({
sessionId: row.sessionId,
actorKey: row.actorKey,
ownerUserId: stringOrUndefined(row.ownerUserId),
projectId: stringOrUndefined(row.projectId),
projectKey:
stringOrUndefined(row.projectKey) ??
toProjectKey(stringOrUndefined(row.projectId)),
parentSessionId: stringOrUndefined(row.parentSessionId),
title: stringOrUndefined(row.title),
status: row.status === "archived" ? "archived" : "active",
createdAt: stringOrUndefined(row.createdAt),
updatedAt: stringOrUndefined(row.updatedAt),
});
counters.conversations += 1;
}
}
async function migrateConversationStates() {
for (const filePath of await listFiles(config.CONVERSATION_STATE_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.sessionId !== "string") {
continue;
}
await db.query(
`
INSERT INTO ${db.table("conversation_states")} (
session_id,
is_title_manually_edited,
messages,
branch_groups
)
VALUES ($1, $2, $3::jsonb, $4::jsonb)
ON CONFLICT (session_id)
DO UPDATE SET
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
messages = EXCLUDED.messages,
branch_groups = EXCLUDED.branch_groups
`,
[
row.sessionId,
Boolean(row.isTitleManuallyEdited),
JSON.stringify(Array.isArray(row.messages) ? row.messages : []),
JSON.stringify(Array.isArray(row.branchGroups) ? row.branchGroups : []),
],
);
counters.states += 1;
}
}
async function migrateConversationTurns() {
for (const filePath of await listFiles(config.SESSION_HISTORY_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.actorKey !== "string" || typeof row.projectKey !== "string") {
continue;
}
const sessionId =
stringOrUndefined(row.clientSessionId) ?? stringOrUndefined(row.sessionId);
if (!sessionId) {
continue;
}
const turns = Array.isArray(row.turns) ? row.turns : [];
const lastTimestamp = stringOrUndefined(row.updatedAt);
await upsertConversation({
sessionId,
actorKey: row.actorKey,
projectKey: row.projectKey,
projectId: undefined,
createdAt:
turns.length > 0 && isRecord(turns[0]) ? stringOrUndefined(turns[0].timestamp) : undefined,
updatedAt:
lastTimestamp ??
(turns.length > 0 && isRecord(turns[turns.length - 1])
? stringOrUndefined(turns[turns.length - 1].timestamp)
: undefined),
});
for (const [turnIndex, turn] of turns.entries()) {
if (!isRecord(turn)) {
continue;
}
const userMessage = sanitizePersistentDocument(String(turn.userMessage ?? ""), 4000);
const assistantMessage = sanitizePersistentDocument(
String(turn.assistantMessage ?? ""),
4000,
);
if (!userMessage || !assistantMessage) {
continue;
}
const timestamp = stringOrUndefined(turn.timestamp) ?? new Date().toISOString();
await db.query(
`
INSERT INTO ${db.table("conversation_turns")} (
turn_id,
session_id,
turn_index,
actor_key,
project_key,
user_message,
assistant_message,
tool_call_count,
turn_timestamp
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (turn_id) DO NOTHING
`,
[
typeof turn.id === "string"
? turn.id
: toStableId(sessionId, String(turnIndex), timestamp, userMessage, assistantMessage),
sessionId,
turnIndex,
row.actorKey,
row.projectKey,
userMessage,
assistantMessage,
Math.max(0, numberOrZero(turn.toolCallCount)),
timestamp,
],
);
counters.turns += 1;
}
}
}
async function migrateRuntimeSessions() {
const seen = new Set<string>();
for (const filePath of await listFiles(config.SESSION_CONTEXT_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (
!row ||
typeof row.sessionId !== "string" ||
typeof row.clientSessionId !== "string" ||
typeof row.actorKey !== "string" ||
typeof row.projectKey !== "string"
) {
continue;
}
if (seen.has(row.sessionId)) {
continue;
}
seen.add(row.sessionId);
runtimeConversationMap.set(row.sessionId, row.clientSessionId);
await upsertConversation({
sessionId: row.clientSessionId,
actorKey: row.actorKey,
projectId: stringOrUndefined(row.projectId),
projectKey: row.projectKey,
});
await db.query(
`
INSERT INTO ${db.table("runtime_sessions")} (
runtime_session_id,
session_id,
actor_key,
allow_learning_write,
learning_mode,
project_id,
project_key,
trace_id,
released_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (runtime_session_id)
DO UPDATE SET
session_id = EXCLUDED.session_id,
actor_key = EXCLUDED.actor_key,
allow_learning_write = EXCLUDED.allow_learning_write,
learning_mode = EXCLUDED.learning_mode,
project_id = EXCLUDED.project_id,
project_key = EXCLUDED.project_key,
trace_id = EXCLUDED.trace_id,
released_at = NOW()
`,
[
row.sessionId,
row.clientSessionId,
row.actorKey,
Boolean(row.allowLearningWrite),
row.learningMode === "review" ? "review" : row.learningMode === "interactive" ? "interactive" : null,
stringOrUndefined(row.projectId) ?? null,
row.projectKey,
typeof row.traceId === "string" ? row.traceId : `trace-${row.sessionId}`,
],
);
counters.runtimeSessions += 1;
}
}
async function migrateLearningStates() {
for (const filePath of await listFiles(config.LEARNING_STATE_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row || typeof row.sessionId !== "string") {
continue;
}
const sessionId = runtimeConversationMap.get(row.sessionId) ?? row.sessionId;
const existingConversation = await db.query(
`SELECT 1 FROM ${db.table("conversations")} WHERE session_id = $1 LIMIT 1`,
[sessionId],
);
if (existingConversation.rowCount === 0) {
continue;
}
await db.query(
`
INSERT INTO ${db.table("learning_states")} (
session_id,
last_gated_turn,
last_reviewed_turn,
pending_review
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (session_id)
DO UPDATE SET
last_gated_turn = EXCLUDED.last_gated_turn,
last_reviewed_turn = EXCLUDED.last_reviewed_turn,
pending_review = EXCLUDED.pending_review
`,
[
sessionId,
numberOrZero(row.lastGatedTurn),
numberOrZero(row.lastReviewedTurn),
Boolean(row.pendingReview),
],
);
counters.learningStates += 1;
}
}
async function migrateResultRefs() {
for (const filePath of await listFiles(config.RESULT_REF_STORAGE_DIR, ".json")) {
const row = await readJson(filePath);
if (!row) {
continue;
}
const resultRef = normalizeResultRef(stringOrUndefined(row.resultRef) ?? basename(filePath, extname(filePath)));
const actorKey = stringOrUndefined(row.actorKey);
const sessionId =
stringOrUndefined(row.clientSessionId) ?? stringOrUndefined(row.sessionId);
if (!resultRef || !actorKey || !sessionId) {
counters.skippedResultRefs += 1;
continue;
}
const projectId = stringOrUndefined(row.projectId);
const projectKey = stringOrUndefined(row.projectKey) ?? toProjectKey(projectId);
await upsertConversation({
sessionId,
actorKey,
projectId,
projectKey,
});
const payload = unwrapPayload(row);
await db.query(
`
INSERT INTO ${db.table("result_refs")} (
result_ref,
actor_key,
session_id,
created_at,
kind,
preview,
project_id,
project_key,
schema_version,
size_bytes,
source,
trace_id,
payload_path,
object_key
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, NULL)
ON CONFLICT (result_ref)
DO UPDATE SET
actor_key = EXCLUDED.actor_key,
session_id = EXCLUDED.session_id,
created_at = EXCLUDED.created_at,
kind = EXCLUDED.kind,
preview = EXCLUDED.preview,
project_id = EXCLUDED.project_id,
project_key = EXCLUDED.project_key,
schema_version = EXCLUDED.schema_version,
size_bytes = EXCLUDED.size_bytes,
source = EXCLUDED.source,
trace_id = EXCLUDED.trace_id,
payload_path = EXCLUDED.payload_path
`,
[
resultRef,
actorKey,
sessionId,
stringOrUndefined(row.createdAt) ?? new Date().toISOString(),
inferResultKind(row, payload),
JSON.stringify(isResultPreview(row.preview) ? row.preview : buildPreview(payload)),
projectId ?? null,
projectKey,
positiveIntegerOrOne(row.schemaVersion),
estimateBytes(payload),
inferResultSource(row.source),
stringOrUndefined(row.traceId) ?? `trace-${resultRef}`,
filePath,
],
);
counters.resultRefs += 1;
}
}
async function migrateMemories() {
await migrateMemoryScope("user", join(config.MEMORY_STORAGE_DIR, "users"));
await migrateMemoryScope("workspace", join(config.MEMORY_STORAGE_DIR, "workspaces"));
}
async function migrateMemoryScope(scope: "user" | "workspace", dir: string) {
for (const filePath of await listFiles(dir, ".md")) {
const markdown = await readText(filePath);
if (!markdown) {
continue;
}
const scopeKey = basename(filePath, ".md");
for (const entry of parseMemoryMarkdown(markdown)) {
await db.query(
`
INSERT INTO ${db.table("memories")} (
memory_id,
scope,
scope_key,
content,
source
)
VALUES ($1, $2, $3, $4, 'tool')
ON CONFLICT (memory_id)
DO UPDATE SET content = EXCLUDED.content
`,
[entry.id, scope, scopeKey, entry.content],
);
counters.memories += 1;
}
}
}
async function upsertConversation(input: {
sessionId: string;
actorKey: string;
projectKey: string;
projectId?: string;
ownerUserId?: string;
parentSessionId?: string;
title?: string;
status?: "active" | "archived";
createdAt?: string;
updatedAt?: string;
}) {
await db.query(
`
INSERT INTO ${db.table("conversations")} (
session_id,
actor_key,
owner_user_id,
project_id,
project_key,
parent_session_id,
title,
status,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, NOW()), COALESCE($10, COALESCE($9, NOW())))
ON CONFLICT (session_id)
DO UPDATE SET
actor_key = EXCLUDED.actor_key,
owner_user_id = COALESCE(EXCLUDED.owner_user_id, ${db.table("conversations")}.owner_user_id),
project_id = COALESCE(EXCLUDED.project_id, ${db.table("conversations")}.project_id),
project_key = EXCLUDED.project_key,
parent_session_id = COALESCE(EXCLUDED.parent_session_id, ${db.table("conversations")}.parent_session_id),
title = COALESCE(EXCLUDED.title, ${db.table("conversations")}.title),
status = EXCLUDED.status,
created_at = LEAST(${db.table("conversations")}.created_at, EXCLUDED.created_at),
updated_at = GREATEST(${db.table("conversations")}.updated_at, EXCLUDED.updated_at)
`,
[
input.sessionId,
input.actorKey,
input.ownerUserId ?? null,
input.projectId ?? null,
input.projectKey,
input.parentSessionId ?? null,
input.title ?? null,
input.status ?? "active",
input.createdAt ?? null,
input.updatedAt ?? null,
],
);
}
async function listFiles(dir: string, extension: string) {
try {
const names = await readdir(dir);
return names
.filter((name) => name.endsWith(extension))
.map((name) => join(dir, name));
} catch {
return [];
}
}
async function readJson(path: string): Promise<JsonRecord | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as JsonRecord;
} catch {
return null;
}
}
async function readText(path: string): Promise<string | null> {
try {
return await readFile(path, "utf8");
} catch {
return null;
}
}
function parseMemoryMarkdown(content: string) {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim())
.map((line) => {
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
const entryContent = sanitizePersistentLine(match?.[2] ?? line, 240);
return {
id: match?.[1] ?? toStableId("memory-entry", entryContent.toLowerCase()),
content: entryContent,
};
})
.filter((entry) => entry.content);
}
function inferResultKind(row: JsonRecord, payload: unknown) {
if (row.kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
return RESULT_REFERENCE_KIND.renderJunctionsPayload;
}
if (isRecord(payload) && isRecord(payload.node_area_map)) {
return RESULT_REFERENCE_KIND.renderJunctionsPayload;
}
return RESULT_REFERENCE_KIND.dynamicHttpResult;
}
function inferResultSource(value: unknown) {
return value === RESULT_REFERENCE_SOURCE.dynamicHttp ||
value === RESULT_REFERENCE_SOURCE.agentGenerated ||
value === RESULT_REFERENCE_SOURCE.migration
? value
: RESULT_REFERENCE_SOURCE.legacy;
}
function unwrapPayload(row: JsonRecord) {
if ("data" in row) {
return row.data;
}
return row;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isResultPreview(value: unknown): value is ResultPreview {
return (
isRecord(value) &&
typeof value.count === "number" &&
Array.isArray(value.fields) &&
typeof value.summary === "string" &&
"sample" in value
);
}
function buildPreview(data: unknown): ResultPreview {
if (Array.isArray(data)) {
const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS);
const fields =
sample.length > 0 && isRecord(sample[0])
? Object.keys(sample[0]).slice(0, 30)
: [];
return {
count: data.length,
fields,
sample,
summary: `list[${data.length}]`,
};
}
if (isRecord(data)) {
const fields = Object.keys(data).slice(0, 30);
const sample = Object.fromEntries(
fields
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
.map((field) => [field, data[field]]),
);
return {
count: fields.length,
fields,
sample,
summary: `object<${fields.length} fields>`,
};
}
return {
count: 1,
fields: [],
sample: String(data).slice(0, 300),
summary: `scalar<${typeof data}>`,
};
}
function estimateBytes(data: unknown) {
return Buffer.byteLength(JSON.stringify(data));
}
function normalizeResultRef(value?: string) {
return value && RESULT_REF_PATTERN.test(value) ? value : null;
}
function stringOrUndefined(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function numberOrZero(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function positiveIntegerOrOne(value: unknown) {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1;
}
+1
View File
@@ -6,6 +6,7 @@ import { config } from "../config.js";
export type LlmRequestAuditEntry = { export type LlmRequestAuditEntry = {
kind: "tool" | "skill"; kind: "tool" | "skill";
sessionId: string; sessionId: string;
clientSessionId: string;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
target: string; target: string;
+239 -117
View File
@@ -2,22 +2,10 @@ import { randomUUID } from "node:crypto";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js"; import { type SessionBinding, type SessionContext, SessionRegistry } from "../session/registry.js";
import { ToolSessionContextStore } from "../session/toolContextStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = {
sessionId: string;
runtimeSessionId: string;
startedAt: number;
};
export type SessionContext = {
sessionId: string;
accessToken?: string;
projectId?: string;
userId?: string;
};
export type ChatRequestContext = SessionContext & { export type ChatRequestContext = SessionContext & {
actorKey: string; actorKey: string;
projectKey: string; projectKey: string;
@@ -25,15 +13,18 @@ export type ChatRequestContext = SessionContext & {
}; };
export class ChatSessionBridge { export class ChatSessionBridge {
// runtime session 仅在单次请求生命周期内有效;线程连续性由 sessionId 对应的持久状态承担 // 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用
private readonly activeRuntimeSessions = new Map<string, string>(); private readonly sessionContexts = new Map<string, ChatRequestContext>();
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>(); private readonly sessionTitles = new Map<string, string>();
private readonly runtimeSessionStore = new RuntimeSessionStore(); private readonly toolContextStore = new ToolSessionContextStore();
constructor(private readonly runtime: OpencodeRuntimeAdapter) {} constructor(
private readonly registry: SessionRegistry,
private readonly runtime: OpencodeRuntimeAdapter,
) {}
async resolve(context: { async resolve(context: {
sessionId?: string; clientSessionId?: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
@@ -43,88 +34,9 @@ export class ChatSessionBridge {
requestContext: ChatRequestContext; requestContext: ChatRequestContext;
created: boolean; created: boolean;
}> { }> {
const requestContext = this.buildRequestContext(context); const requestContext: ChatRequestContext = {
await this.abortActiveRuntime(requestContext.sessionId); clientSessionId:
context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
const session = await this.runtime.createSession(requestContext.sessionId);
const binding: SessionBinding = {
sessionId: requestContext.sessionId,
runtimeSessionId: session.id,
startedAt: Date.now(),
};
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,
sessionId: requestContext.sessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
traceId: requestContext.traceId,
});
return { binding, requestContext, created: true };
}
count(): number {
return this.activeRuntimeSessions.size;
}
createSessionId() {
return `agent-${randomUUID().slice(0, 12)}`;
}
getActiveSensitiveContext(runtimeSessionId: string) {
return this.activeSensitiveContexts.get(runtimeSessionId) ?? null;
}
async abort(context: { sessionId?: string }): Promise<SessionBinding | null> {
const sessionId = context.sessionId?.trim();
if (!sessionId) {
return null;
}
const runtimeSessionId = this.activeRuntimeSessions.get(sessionId);
if (!runtimeSessionId) {
return null;
}
await this.abortActiveRuntime(sessionId);
return {
sessionId,
runtimeSessionId,
startedAt: Date.now(),
};
}
async releaseRuntimeSession(sessionId: string, runtimeSessionId: string) {
const activeSessionId = this.activeRuntimeSessions.get(sessionId);
if (activeSessionId === runtimeSessionId) {
this.activeRuntimeSessions.delete(sessionId);
}
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.abortSession(runtimeSessionId).catch((error) => {
logger.debug({ runtimeSessionId, err: error }, "failed to cleanup runtime session");
});
}
private buildRequestContext(context: {
sessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
userId?: string;
}): ChatRequestContext {
return {
sessionId: context.sessionId?.trim() || this.createSessionId(),
accessToken: context.accessToken, accessToken: context.accessToken,
actorKey: toActorKey(context.userId), actorKey: toActorKey(context.userId),
projectId: context.projectId, projectId: context.projectId,
@@ -132,27 +44,237 @@ export class ChatSessionBridge {
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(), userId: context.userId?.trim(),
}; };
this.cleanupExpired();
const current = this.registry.get(requestContext);
if (current) {
this.sessionContexts.set(current.sessionId, requestContext);
await this.toolContextStore.write({
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: current.sessionId,
traceId: requestContext.traceId,
});
try {
// 只有 opencode 侧 session 仍存在时,才复用本地映射。
await this.runtime.getSession(current.sessionId);
return { binding: current, requestContext, created: false };
} catch (error) {
logger.warn(
{
clientSessionId: requestContext.clientSessionId,
sessionId: current.sessionId,
err: error,
},
"existing opencode session lookup failed, creating a new session",
);
}
} }
private async abortActiveRuntime(sessionId: string) { const session = await this.runtime.createSession(requestContext.clientSessionId);
const activeRuntimeSessionId = this.activeRuntimeSessions.get(sessionId); const binding = this.registry.upsert(requestContext, session.id);
if (!activeRuntimeSessionId) { this.sessionContexts.set(binding.sessionId, requestContext);
await this.toolContextStore.write({
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: binding.sessionId,
traceId: requestContext.traceId,
});
return { binding, requestContext, created: true };
}
count(): number {
return this.registry.count();
}
getSessionContext(sessionId: string) {
return this.sessionContexts.get(sessionId) ?? null;
}
getSessionTitle(sessionId: string) {
return this.sessionTitles.get(sessionId);
}
setSessionTitle(sessionId: string, title: string) {
const normalized = title.trim();
if (!normalized) {
return; return;
} }
this.activeRuntimeSessions.delete(sessionId); this.sessionTitles.set(sessionId, normalized);
this.activeSensitiveContexts.delete(activeRuntimeSessionId); }
await this.runtimeSessionStore.release(activeRuntimeSessionId).catch(() => undefined);
await this.runtime.abortSession(activeRuntimeSessionId).catch((error) => { cloneSessionTitle(sourceSessionId: string, targetSessionId: string) {
logger.warn( const existingTitle = this.sessionTitles.get(sourceSessionId);
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error }, if (!existingTitle) {
"failed to abort previous active runtime session", return;
); }
this.sessionTitles.set(targetSessionId, existingTitle);
}
async abort(context: {
clientSessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
userId?: string;
}): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) {
return null;
}
const requestContext: ChatRequestContext = {
clientSessionId,
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(),
};
this.cleanupExpired();
const binding = this.registry.get(requestContext);
if (!binding) {
return null;
}
this.sessionContexts.set(binding.sessionId, requestContext);
await this.toolContextStore.write({
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: binding.sessionId,
traceId: requestContext.traceId,
}); });
await this.runtime.waitForSessionIdle(activeRuntimeSessionId).catch((error) => { await this.runtime.abortSession(binding.sessionId);
logger.warn( return binding;
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error }, }
"failed while waiting for previous runtime session to become idle",
async fork(context: {
clientSessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
keepMessageCount: number;
userId?: string;
}): Promise<{
binding: SessionBinding;
requestContext: ChatRequestContext;
created: boolean;
}> {
const currentClientSessionId = context.clientSessionId?.trim();
const nextRequestContext: ChatRequestContext = {
clientSessionId: `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(),
};
this.cleanupExpired();
if (!currentClientSessionId || context.keepMessageCount <= 0) {
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
await this.toolContextStore.write({
actorKey: nextRequestContext.actorKey,
allowLearningWrite: true,
clientSessionId: nextRequestContext.clientSessionId,
learningMode: "interactive",
projectId: nextRequestContext.projectId,
projectKey: nextRequestContext.projectKey,
sessionId: binding.sessionId,
traceId: nextRequestContext.traceId,
});
return { binding, requestContext: nextRequestContext, created: true };
}
const currentContext: ChatRequestContext = {
clientSessionId: currentClientSessionId,
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
traceId: nextRequestContext.traceId,
userId: context.userId?.trim(),
};
const current = this.registry.get(currentContext);
if (!current) {
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
await this.toolContextStore.write({
actorKey: nextRequestContext.actorKey,
allowLearningWrite: true,
clientSessionId: nextRequestContext.clientSessionId,
learningMode: "interactive",
projectId: nextRequestContext.projectId,
projectKey: nextRequestContext.projectKey,
sessionId: binding.sessionId,
traceId: nextRequestContext.traceId,
});
return { binding, requestContext: nextRequestContext, created: true };
}
await this.runtime.getSession(current.sessionId);
const messages = await this.runtime.messages(
current.sessionId,
Math.max(100, context.keepMessageCount + 20),
); );
const chatMessages = messages.filter(
(message) => message.info.role === "user" || message.info.role === "assistant",
);
const keepMessage = chatMessages[context.keepMessageCount - 1];
if (!keepMessage) {
throw new Error(`fork keep point not found for message count ${context.keepMessageCount}`);
}
const session = await this.runtime.forkSession(current.sessionId, keepMessage.info.id);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
await this.toolContextStore.write({
actorKey: nextRequestContext.actorKey,
allowLearningWrite: true,
clientSessionId: nextRequestContext.clientSessionId,
learningMode: "interactive",
projectId: nextRequestContext.projectId,
projectKey: nextRequestContext.projectKey,
sessionId: binding.sessionId,
traceId: nextRequestContext.traceId,
});
this.cloneSessionTitle(current.sessionId, binding.sessionId);
return { binding, requestContext: nextRequestContext, created: true };
}
cleanupExpired(): void {
const expiredSessionIds = this.registry.evictExpired();
for (const sessionId of expiredSessionIds) {
this.sessionContexts.delete(sessionId);
this.sessionTitles.delete(sessionId);
void this.toolContextStore.remove(sessionId);
// 这里用 abort 做轻量清理;即使失败,也不阻断本地过期回收。
void this.runtime.abortSession(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "ignoring failed abort for expired session");
}); });
} }
}
} }
+2 -30
View File
@@ -25,22 +25,6 @@ const envSchema = z
PORT: z.coerce.number().int().positive().default(8787), PORT: z.coerce.number().int().positive().default(8787),
// HTTP 服务监听地址。 // HTTP 服务监听地址。
HOST: z.string().default("0.0.0.0"), HOST: z.string().default("0.0.0.0"),
// PostgreSQL connection string; if omitted, PG* env vars are used.
DATABASE_URL: optionalString(),
// PostgreSQL host.
PGHOST: optionalString(),
// PostgreSQL port.
PGPORT: z.coerce.number().int().positive().optional(),
// PostgreSQL user.
PGUSER: optionalString(),
// PostgreSQL password.
PGPASSWORD: optionalString(),
// PostgreSQL database name.
PGDATABASE: optionalString(),
// PostgreSQL SSL mode.
PGSSLMODE: z.enum(["disable", "prefer", "require"]).default("disable"),
// PostgreSQL schema used by TJWaterAgent.
AGENT_DB_SCHEMA: z.string().default("public"),
// Pino 日志级别。 // Pino 日志级别。
LOG_LEVEL: z.string().default("info"), LOG_LEVEL: z.string().default("info"),
// LLM 工具/技能调用审计日志路径。 // LLM 工具/技能调用审计日志路径。
@@ -61,6 +45,8 @@ const envSchema = z
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
// client 模式下,目标 opencode server 的基础地址。 // client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// chat session 在本地注册表中的保活时长(秒)。
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
// 提供给本地 opencode tools 读取的会话上下文目录。 // 提供给本地 opencode tools 读取的会话上下文目录。
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"), SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
// TJWater 后端 API 的基础地址。 // TJWater 后端 API 的基础地址。
@@ -79,10 +65,6 @@ const envSchema = z
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
// session transcript 持久化目录。 // session transcript 持久化目录。
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"), SESSION_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,超过后裁剪旧记录。 // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
.number() .number()
@@ -132,16 +114,6 @@ const envSchema = z
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client", message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
}); });
} }
if (
!env.DATABASE_URL &&
(!env.PGHOST || !env.PGUSER || !env.PGPASSWORD || !env.PGDATABASE)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DATABASE_URL"],
message: "DATABASE_URL or PGHOST/PGUSER/PGPASSWORD/PGDATABASE must be set",
});
}
}); });
export type AppConfig = z.infer<typeof envSchema>; export type AppConfig = z.infer<typeof envSchema>;
-89
View File
@@ -1,89 +0,0 @@
import { type QueryResultRow } from "pg";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
export type ConversationStateRecord = {
sessionId: string;
isTitleManuallyEdited?: boolean;
messages: unknown[];
branchGroups: unknown[];
};
type ConversationStateRow = QueryResultRow & {
session_id: string;
is_title_manually_edited: boolean;
messages: unknown[];
branch_groups: unknown[];
};
export class ConversationStateStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async read(sessionId: string) {
const result = await this.db.query<ConversationStateRow>(
`
SELECT session_id, is_title_manually_edited, messages, branch_groups
FROM ${this.db.table("conversation_states")}
WHERE session_id = $1
LIMIT 1
`,
[sessionId],
);
return mapConversationStateRow(result.rows[0]);
}
async write(sessionId: string, state: ConversationStateRecord) {
const result = await this.db.query<ConversationStateRow>(
`
INSERT INTO ${this.db.table("conversation_states")} (
session_id,
is_title_manually_edited,
messages,
branch_groups
)
VALUES ($1, $2, $3::jsonb, $4::jsonb)
ON CONFLICT (session_id)
DO UPDATE SET
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
messages = EXCLUDED.messages,
branch_groups = EXCLUDED.branch_groups
RETURNING session_id, is_title_manually_edited, messages, branch_groups
`,
[
sessionId,
state.isTitleManuallyEdited ?? false,
JSON.stringify(state.messages),
JSON.stringify(state.branchGroups),
],
);
return mapConversationStateRow(result.rows[0]) ?? state;
}
async remove(sessionId: string) {
await this.db.query(
`
DELETE FROM ${this.db.table("conversation_states")}
WHERE session_id = $1
`,
[sessionId],
);
}
}
const mapConversationStateRow = (
row?: ConversationStateRow | null,
): ConversationStateRecord | null => {
if (!row) {
return null;
}
return {
sessionId: row.session_id,
isTitleManuallyEdited: row.is_title_manually_edited,
messages: Array.isArray(row.messages) ? row.messages : [],
branchGroups: Array.isArray(row.branch_groups) ? row.branch_groups : [],
};
};
-256
View File
@@ -1,256 +0,0 @@
import { randomUUID } from "node:crypto";
import { type QueryResultRow } from "pg";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
export type ConversationStatus = "active" | "archived";
export type ConversationRecord = {
sessionId: string;
actorKey: string;
ownerUserId?: string;
projectId?: string;
projectKey: string;
parentSessionId?: string;
createdAt: string;
updatedAt: string;
isStreaming: boolean;
streamingStartedAt?: string;
status: ConversationStatus;
title?: string;
};
type ConversationContext = {
actorKey: string;
userId?: string;
projectId?: string;
projectKey: string;
};
type EnsureConversationInput = ConversationContext & {
sessionId?: string;
parentSessionId?: string;
};
type ConversationRow = QueryResultRow & {
session_id: string;
actor_key: string;
owner_user_id: string | null;
project_id: string | null;
project_key: string;
parent_session_id: string | null;
created_at: Date | string;
updated_at: Date | string;
is_streaming: boolean;
streaming_started_at: Date | string | null;
status: ConversationStatus;
title: string | null;
};
export class ConversationStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
async initialize() {
await this.db.initialize();
}
async ensure(input: EnsureConversationInput) {
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
const existing = await this.get(input, sessionId);
if (existing) {
return { created: false, record: existing };
}
const inserted = await this.db.query<ConversationRow>(
`
INSERT INTO ${this.db.table("conversations")} (
session_id,
actor_key,
owner_user_id,
project_id,
project_key,
parent_session_id,
status
)
VALUES ($1, $2, $3, $4, $5, $6, 'active')
RETURNING *
`,
[
sessionId,
input.actorKey,
input.userId?.trim() || null,
input.projectId ?? null,
input.projectKey,
normalizeSessionId(input.parentSessionId) ?? null,
],
);
return { created: true, record: requireConversationRow(inserted.rows[0]) };
}
async get(context: ConversationContext, sessionId: string) {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) {
return null;
}
const result = await this.db.query<ConversationRow>(
`
SELECT *
FROM ${this.db.table("conversations")}
WHERE session_id = $1
AND actor_key = $2
AND project_key = $3
LIMIT 1
`,
[normalizedSessionId, context.actorKey, context.projectKey],
);
return mapConversationRow(result.rows[0]);
}
async touch(
record: ConversationRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
) {
const normalized = normalizeConversationUpdates(updates);
const result = await this.db.query<ConversationRow>(
`
UPDATE ${this.db.table("conversations")}
SET
title = COALESCE($2, title),
status = COALESCE($3, status)
WHERE session_id = $1
RETURNING *
`,
[record.sessionId, normalized.title ?? null, normalized.status ?? null],
);
return requireConversationRow(result.rows[0]);
}
async list(context: ConversationContext) {
const result = await this.db.query<ConversationRow>(
`
SELECT *
FROM ${this.db.table("conversations")}
WHERE actor_key = $1
AND project_key = $2
ORDER BY updated_at DESC
`,
[context.actorKey, context.projectKey],
);
return result.rows
.map(mapConversationRow)
.filter((record): record is ConversationRecord => Boolean(record));
}
async remove(record: ConversationRecord) {
await this.db.query(
`
DELETE FROM ${this.db.table("conversations")}
WHERE session_id = $1
`,
[record.sessionId],
);
}
async markStreaming(record: ConversationRecord, runtimeSessionId: string) {
const result = await this.db.query<ConversationRow>(
`
UPDATE ${this.db.table("conversations")}
SET
is_streaming = TRUE,
active_runtime_session_id = $2,
streaming_started_at = NOW()
WHERE session_id = $1
RETURNING *
`,
[record.sessionId, runtimeSessionId],
);
return requireConversationRow(result.rows[0]);
}
async clearStreaming(sessionId: string, runtimeSessionId: string) {
const result = await this.db.query<ConversationRow>(
`
UPDATE ${this.db.table("conversations")}
SET
is_streaming = FALSE,
active_runtime_session_id = NULL,
streaming_started_at = NULL
WHERE session_id = $1
AND active_runtime_session_id = $2
RETURNING *
`,
[sessionId, runtimeSessionId],
);
return mapConversationRow(result.rows[0]);
}
async resetStreamingSessions() {
await this.db.query(
`
UPDATE ${this.db.table("conversations")}
SET
is_streaming = FALSE,
active_runtime_session_id = NULL,
streaming_started_at = NULL
WHERE is_streaming = TRUE
OR active_runtime_session_id IS NOT NULL
OR streaming_started_at IS NOT NULL
`,
);
}
}
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
const normalizeSessionId = (value?: string) => {
const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined;
};
const normalizeConversationUpdates = (
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
) => {
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
if (typeof updates.title === "string") {
const trimmed = updates.title.trim();
if (trimmed) {
normalized.title = trimmed.slice(0, 120);
}
}
return normalized;
};
const mapConversationRow = (row?: ConversationRow | null): ConversationRecord | null => {
if (!row) {
return null;
}
return {
sessionId: row.session_id,
actorKey: row.actor_key,
ownerUserId: row.owner_user_id ?? undefined,
projectId: row.project_id ?? undefined,
projectKey: row.project_key,
parentSessionId: row.parent_session_id ?? undefined,
createdAt: toIsoString(row.created_at),
updatedAt: toIsoString(row.updated_at),
isStreaming: row.is_streaming,
streamingStartedAt: row.streaming_started_at ? toIsoString(row.streaming_started_at) : undefined,
status: row.status,
title: row.title ?? undefined,
};
};
const requireConversationRow = (row?: ConversationRow | null) => {
const record = mapConversationRow(row);
if (!record) {
throw new Error("conversation row not found");
}
return record;
};
const toIsoString = (value: Date | string) =>
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
-486
View File
@@ -1,486 +0,0 @@
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();
`;
+86 -199
View File
@@ -1,9 +1,14 @@
import { type QueryResultRow } from "pg"; import { join } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
import { AgentDatabase, getAgentDatabase } from "../db/index.js"; import {
atomicWriteJson,
ensureDirectory,
listJsonFiles,
readJsonFile,
toStableId,
} from "../utils/fileStore.js";
import { sanitizePersistentDocument } from "../utils/persistencePolicy.js"; import { sanitizePersistentDocument } from "../utils/persistencePolicy.js";
import { toStableId } from "../utils/fileStore.js";
export type SessionTurnRecord = { export type SessionTurnRecord = {
id: string; id: string;
@@ -15,6 +20,7 @@ export type SessionTurnRecord = {
type SessionTranscriptRecord = { type SessionTranscriptRecord = {
actorKey: string; actorKey: string;
clientSessionId?: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
turns: SessionTurnRecord[]; turns: SessionTurnRecord[];
@@ -32,28 +38,18 @@ export type SessionSearchHit = {
type SessionHistoryContext = { type SessionHistoryContext = {
actorKey: string; actorKey: string;
clientSessionId?: string;
projectKey: string; projectKey: string;
sessionId: 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 { export class SessionHistoryStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {} private readonly writeQueues = new Map<string, Promise<void>>();
constructor(private readonly baseDir = config.SESSION_HISTORY_STORAGE_DIR) {}
async initialize() { async initialize() {
await this.db.initialize(); await ensureDirectory(this.baseDir);
} }
async appendTurn( async appendTurn(
@@ -63,146 +59,53 @@ export class SessionHistoryStore {
toolCallCount: number; toolCallCount: number;
userMessage: string; userMessage: string;
}, },
): Promise<SessionTranscriptRecord> { ) {
const key = this.filePath(context);
return this.serializeWrite(key, async () => {
const transcript = (await this.readTranscript(context)) ?? {
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectKey: context.projectKey,
sessionId: context.sessionId,
turns: [],
updatedAt: new Date().toISOString(),
};
const userMessage = sanitizePersistentDocument(turn.userMessage, 4000); const userMessage = sanitizePersistentDocument(turn.userMessage, 4000);
const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000); const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000);
if (!userMessage || !assistantMessage) { if (!userMessage || !assistantMessage) {
return (await this.readTranscript(context)) ?? emptyTranscript(context); return transcript;
} }
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
await this.db.withTransaction(async (client) => { const record: SessionTurnRecord = {
await this.db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [context.sessionId], client); id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage),
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, assistantMessage,
Math.max(0, turn.toolCallCount),
timestamp, timestamp,
], toolCallCount: Math.max(0, turn.toolCallCount),
client, userMessage,
};
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.turns.push(record);
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
transcript.turns = transcript.turns.slice(
transcript.turns.length - config.SESSION_HISTORY_MAX_TURNS_PER_SESSION,
); );
}
transcript.updatedAt = timestamp;
await atomicWriteJson(key, transcript);
return transcript;
}); });
return (await this.readTranscript(context)) ?? emptyTranscript(context);
} }
async getRecentTurns( async getRecentTurns(
context: SessionHistoryContext, context: SessionHistoryContext,
limit: number, limit: number,
): Promise<SessionTurnRecord[]> { ): Promise<SessionTurnRecord[]> {
const result = await this.db.query<ConversationTurnRow>( const transcript = await this.readTranscript(context);
` if (!transcript) {
SELECT * return [];
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);
} }
return transcript.turns.slice(-Math.max(1, limit));
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( async search(
@@ -214,26 +117,24 @@ export class SessionHistoryStore {
if (!normalizedQuery) { if (!normalizedQuery) {
return []; 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 queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
const hits: SessionSearchHit[] = []; const hits: SessionSearchHit[] = [];
for (const row of rows.rows) { const files = await listJsonFiles(this.baseDir);
for (const file of files) {
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
if (!transcript) {
continue;
}
if (
transcript.actorKey !== context.actorKey ||
transcript.projectKey !== context.projectKey
) {
continue;
}
for (const turn of transcript.turns) {
const candidates: Array<["user" | "assistant", string]> = [ const candidates: Array<["user" | "assistant", string]> = [
["user", row.user_message], ["user", turn.userMessage],
["assistant", row.assistant_message], ["assistant", turn.assistantMessage],
]; ];
for (const [matchedField, text] of candidates) { for (const [matchedField, text] of candidates) {
const score = scoreText(text, normalizedQuery, queryTokens); const score = scoreText(text, normalizedQuery, queryTokens);
@@ -243,57 +144,46 @@ export class SessionHistoryStore {
hits.push({ hits.push({
matchedField, matchedField,
score, score,
sessionId: row.session_id, sessionId: transcript.sessionId,
snippet: buildSnippet(text, normalizedQuery), snippet: buildSnippet(text, normalizedQuery),
timestamp: toIsoString(row.turn_timestamp), timestamp: turn.timestamp,
turnId: row.turn_id, turnId: turn.id,
}); });
} }
} }
}
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults)); return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
} }
private async readTranscript(context: SessionHistoryContext) { private async readTranscript(context: SessionHistoryContext) {
const result = await this.db.query<ConversationTurnRow>( return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
` }
SELECT *
FROM ${this.db.table("conversation_turns")} private filePath(context: SessionHistoryContext) {
WHERE session_id = $1 return join(
AND actor_key = $2 this.baseDir,
AND project_key = $3 `${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
ORDER BY turn_index ASC
`,
[context.sessionId, context.actorKey, context.projectKey],
); );
if (result.rows.length === 0) { }
return null;
private async serializeWrite<T>(key: string, task: () => Promise<T>) {
const previous = this.writeQueues.get(key) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(task);
const next = run.then(
() => undefined,
() => undefined,
);
this.writeQueues.set(key, next);
try {
return await run;
} finally {
if (this.writeQueues.get(key) === next) {
this.writeQueues.delete(key);
}
} }
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 scoreText = (text: string, query: string, queryTokens: string[]) => {
const normalized = text.toLowerCase(); const normalized = text.toLowerCase();
let score = 0; let score = 0;
@@ -321,6 +211,3 @@ const buildSnippet = (text: string, query: string) => {
const suffix = end < compact.length ? "..." : ""; const suffix = end < compact.length ? "..." : "";
return `${prefix}${snippet}${suffix}`; return `${prefix}${snippet}${suffix}`;
}; };
const toIsoString = (value: Date | string) =>
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
+14 -13
View File
@@ -9,7 +9,7 @@ import { LearningStateStore } from "./stateStore.js";
import { MemoryStore, type MemoryScope } from "../memory/store.js"; import { MemoryStore, type MemoryScope } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SkillStore } from "../skills/store.js"; import { SkillStore } from "../skills/store.js";
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js"; import { ToolSessionContextStore } from "../session/toolContextStore.js";
import { import {
sanitizePersistentDocument, sanitizePersistentDocument,
sanitizePersistentLine, sanitizePersistentLine,
@@ -70,7 +70,7 @@ export class LearningOrchestrator {
private readonly activeReviews = new Set<string>(); private readonly activeReviews = new Set<string>();
private readonly learningStateStore = new LearningStateStore(); private readonly learningStateStore = new LearningStateStore();
private readonly skillStore = new SkillStore(); private readonly skillStore = new SkillStore();
private readonly runtimeSessionStore = new RuntimeSessionStore(); private readonly toolContextStore = new ToolSessionContextStore();
constructor( constructor(
private readonly runtime: OpencodeRuntimeAdapter, private readonly runtime: OpencodeRuntimeAdapter,
@@ -81,7 +81,7 @@ export class LearningOrchestrator {
async initialize() { async initialize() {
await Promise.all([ await Promise.all([
this.learningStateStore.initialize(), this.learningStateStore.initialize(),
this.runtimeSessionStore.initialize(), this.toolContextStore.initialize(),
]); ]);
} }
@@ -89,6 +89,7 @@ export class LearningOrchestrator {
const transcript = await this.historyStore.appendTurn( const transcript = await this.historyStore.appendTurn(
{ {
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
clientSessionId: input.requestContext.clientSessionId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: input.sessionId, sessionId: input.sessionId,
}, },
@@ -138,17 +139,17 @@ export class LearningOrchestrator {
let gateSessionId: string | null = null; let gateSessionId: string | null = null;
try { try {
const gateSession = await this.runtime.createSession( const gateSession = await this.runtime.createSession(
`learning-gate-${input.requestContext.sessionId}`, `learning-gate-${input.requestContext.clientSessionId}`,
); );
gateSessionId = gateSession.id; gateSessionId = gateSession.id;
await this.runtimeSessionStore.write({ await this.toolContextStore.write({
runtimeSessionId: gateSession.id,
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
allowLearningWrite: false, allowLearningWrite: false,
sessionId: input.requestContext.sessionId, clientSessionId: `gate-${input.requestContext.clientSessionId}`,
learningMode: "review", learningMode: "review",
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: gateSession.id,
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
await this.runtime.prompt( await this.runtime.prompt(
@@ -210,7 +211,7 @@ export class LearningOrchestrator {
}); });
} finally { } finally {
if (gateSessionId) { if (gateSessionId) {
await this.runtimeSessionStore.release(gateSessionId).catch(() => undefined); await this.toolContextStore.remove(gateSessionId).catch(() => undefined);
await this.runtime.abortSession(gateSessionId).catch(() => undefined); await this.runtime.abortSession(gateSessionId).catch(() => undefined);
} }
} }
@@ -228,16 +229,16 @@ export class LearningOrchestrator {
turnCount: number; turnCount: number;
}) { }) {
const reviewSession = await this.runtime.createSession( const reviewSession = await this.runtime.createSession(
`learning-review-${input.requestContext.sessionId}`, `learning-review-${input.requestContext.clientSessionId}`,
); );
await this.runtimeSessionStore.write({ await this.toolContextStore.write({
runtimeSessionId: reviewSession.id,
actorKey: input.requestContext.actorKey, actorKey: input.requestContext.actorKey,
allowLearningWrite: false, allowLearningWrite: false,
sessionId: input.requestContext.sessionId, clientSessionId: `review-${input.requestContext.clientSessionId}`,
learningMode: "review", learningMode: "review",
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: reviewSession.id,
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
try { try {
@@ -278,7 +279,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
} finally { } finally {
await this.runtimeSessionStore.release(reviewSession.id).catch(() => undefined); await this.toolContextStore.remove(reviewSession.id).catch(() => undefined);
await this.runtime.abortSession(reviewSession.id).catch(() => undefined); await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
} }
} }
+22 -59
View File
@@ -1,6 +1,11 @@
import { type QueryResultRow } from "pg"; import { join } from "node:path";
import { AgentDatabase, getAgentDatabase } from "../db/index.js"; import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
} from "../utils/fileStore.js";
export type LearningSessionState = { export type LearningSessionState = {
lastGatedTurn: number; lastGatedTurn: number;
@@ -10,65 +15,32 @@ export type LearningSessionState = {
updatedAt: 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 { export class LearningStateStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {} constructor(private readonly baseDir = config.LEARNING_STATE_STORAGE_DIR) {}
async initialize() { async initialize() {
await this.db.initialize(); await ensureDirectory(this.baseDir);
} }
async read(sessionId: string): Promise<LearningSessionState> { async read(sessionId: string): Promise<LearningSessionState> {
const result = await this.db.query<LearningStateRow>( const existing = await readJsonFile<LearningSessionState>(this.filePath(sessionId));
` if (existing) {
SELECT session_id, last_gated_turn, last_reviewed_turn, pending_review, updated_at return existing;
FROM ${this.db.table("learning_states")} }
WHERE session_id = $1 return {
LIMIT 1
`,
[sessionId],
);
return (
mapLearningStateRow(result.rows[0]) ?? {
lastGatedTurn: 0, lastGatedTurn: 0,
lastReviewedTurn: 0, lastReviewedTurn: 0,
pendingReview: false, pendingReview: false,
sessionId, sessionId,
updatedAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(),
} };
);
} }
async write(state: LearningSessionState) { async write(state: LearningSessionState) {
await this.db.query( await atomicWriteJson(this.filePath(state.sessionId), {
` ...state,
INSERT INTO ${this.db.table("learning_states")} ( updatedAt: new Date().toISOString(),
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) { async markPending(sessionId: string, pendingReview: boolean) {
@@ -97,17 +69,8 @@ export class LearningStateStore {
pendingReview: false, pendingReview: false,
}); });
} }
}
const mapLearningStateRow = (row?: LearningStateRow | null): LearningSessionState | null => { private filePath(sessionId: string) {
if (!row) { return join(this.baseDir, `${sessionId}.json`);
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,
};
};
+125 -108
View File
@@ -1,9 +1,13 @@
import { type QueryResultRow } from "pg"; import { join } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
import { sanitizePersistentLine } from "../utils/persistencePolicy.js"; import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
import { toStableId } from "../utils/fileStore.js"; import {
atomicWriteFileWithHistory,
ensureDirectory,
readTextFile,
toStableId,
} from "../utils/fileStore.js";
export type MemoryScope = "user" | "workspace"; export type MemoryScope = "user" | "workspace";
export type MemoryEntrySource = "review" | "tool"; export type MemoryEntrySource = "review" | "tool";
@@ -25,17 +29,6 @@ type MemoryContext = {
projectKey: string; projectKey: string;
}; };
type MemoryRow = QueryResultRow & {
memory_id: string;
scope: MemoryScope;
scope_key: string;
content: string;
source: MemoryEntrySource;
session_id: string | null;
trace_id: string | null;
updated_at: Date | string;
};
const SUSPICIOUS_MEMORY_PATTERNS = [ const SUSPICIOUS_MEMORY_PATTERNS = [
/ignore\s+(all|previous|prior|above)\s+instructions/i, /ignore\s+(all|previous|prior|above)\s+instructions/i,
/system\s+prompt/i, /system\s+prompt/i,
@@ -44,118 +37,113 @@ const SUSPICIOUS_MEMORY_PATTERNS = [
]; ];
export class MemoryStore { export class MemoryStore {
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {} // Memory 文件可能被多次连续追加,串行化可避免并发覆盖掉刚写入的条目。
private writeQueue: Promise<void> = Promise.resolve();
constructor(
private readonly baseDir = config.MEMORY_STORAGE_DIR,
private readonly historyDir = join(config.PERSISTENCE_HISTORY_DIR, "memory"),
) {}
async initialize() { async initialize() {
await this.db.initialize(); await ensureDirectory(this.baseDir);
await ensureDirectory(join(this.baseDir, "users"));
await ensureDirectory(join(this.baseDir, "workspaces"));
// 历史备份与正式数据分目录存放,便于排查和手工恢复。
await ensureDirectory(this.historyDir);
} }
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) { async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content); const content = normalizeMemoryContent(draft.content);
if (!content) { if (!content) {
return { changed: false, entry: null as MemoryEntry | null }; return { changed: false, entry: null as MemoryEntry | null };
} }
const existing = await this.findByContent(scope, key, content);
const entries = await this.readEntries(scope, key);
const existing = entries.find((entry) => entry.content === content);
if (existing) { if (existing) {
return { changed: false, entry: existing }; return { changed: false, entry: existing };
} }
const id = toStableId(scope, key, content.toLowerCase());
await this.db.query( const entry: MemoryEntry = {
`
INSERT INTO ${this.db.table("memories")} (
memory_id,
scope,
scope_key,
content, content,
source, id: toStableId(scope, key, content.toLowerCase()),
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,
},
}; };
entries.unshift(entry);
// 每次覆盖 memory 文件前先保留上一版,写入失败时由底层工具恢复。
await atomicWriteFileWithHistory(
this.filePath(scope, key),
renderMemoryMarkdown(scope, entries),
{
historyDir: this.historyDir,
rootDir: this.baseDir,
},
);
return { changed: true, entry };
});
} }
async list(scope: MemoryScope, key: string) { async list(scope: MemoryScope, key: string) {
const rows = await this.db.query<MemoryRow>( return await this.readEntries(scope, key);
`
SELECT memory_id, content
FROM ${this.db.table("memories")}
WHERE scope = $1
AND scope_key = $2
ORDER BY updated_at DESC, created_at DESC
`,
[scope, key],
);
return rows.rows.map((row) => ({
id: row.memory_id,
content: row.content,
}));
} }
async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) { async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) {
return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content); const content = normalizeMemoryContent(draft.content);
if (!content) { if (!content) {
return { changed: false, detail: "content rejected by persistence policy" }; return { changed: false, detail: "content rejected by persistence policy" };
} }
const duplicate = await this.findByContent(scope, key, content); const entries = await this.readEntries(scope, key);
if (duplicate && duplicate.id !== targetId.trim()) { 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" }; return { changed: false, detail: "replacement would duplicate an existing memory" };
} }
const result = await this.db.query( entries[index] = {
`
UPDATE ${this.db.table("memories")}
SET
content = $4,
source = $5,
session_id = $6,
trace_id = $7
WHERE memory_id = $1
AND scope = $2
AND scope_key = $3
`,
[
targetId.trim(),
scope,
key,
content, content,
draft.source, id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()),
draft.sessionId ?? null, };
draft.traceId ?? null, await atomicWriteFileWithHistory(
], this.filePath(scope, key),
renderMemoryMarkdown(scope, entries),
{
historyDir: this.historyDir,
rootDir: this.baseDir,
},
); );
return result.rowCount === 0 return { changed: true, detail: "memory replaced" };
? { changed: false, detail: "memory entry not found" } });
: { changed: true, detail: "memory replaced" };
} }
async remove(scope: MemoryScope, key: string, targetId: string) { async remove(scope: MemoryScope, key: string, targetId: string) {
const result = await this.db.query( return this.serializeWrite(async () => {
` const entries = await this.readEntries(scope, key);
DELETE FROM ${this.db.table("memories")} const next = entries.filter((entry) => entry.id !== targetId.trim());
WHERE memory_id = $1 if (next.length === entries.length) {
AND scope = $2 return { changed: false, detail: "memory entry not found" };
AND scope_key = $3 }
`, await atomicWriteFileWithHistory(
[targetId.trim(), scope, key], this.filePath(scope, key),
renderMemoryMarkdown(scope, next),
{
historyDir: this.historyDir,
rootDir: this.baseDir,
},
); );
return result.rowCount === 0 return { changed: true, detail: "memory removed" };
? { changed: false, detail: "memory entry not found" } });
: { changed: true, detail: "memory removed" };
} }
async buildPromptSnapshot(context: MemoryContext) { async buildPromptSnapshot(context: MemoryContext) {
const [userMemory, workspaceMemory] = await Promise.all([ const [userMemory, workspaceMemory] = await Promise.all([
this.list("user", context.actorKey), this.readEntries("user", context.actorKey),
this.list("workspace", context.projectKey), this.readEntries("workspace", context.projectKey),
]); ]);
const sections: string[] = []; const sections: string[] = [];
@@ -192,25 +180,26 @@ export class MemoryStore {
: block; : block;
} }
private async findByContent(scope: MemoryScope, key: string, content: string) { private async readEntries(scope: MemoryScope, key: string) {
const result = await this.db.query<MemoryRow>( const markdown = await readTextFile(this.filePath(scope, key));
` if (!markdown) {
SELECT memory_id, content return [];
FROM ${this.db.table("memories")}
WHERE scope = $1
AND scope_key = $2
AND content = $3
LIMIT 1
`,
[scope, key, content],
);
const row = result.rows[0];
return row
? {
id: row.memory_id,
content: row.content,
} }
: null; 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,
);
return run;
} }
} }
@@ -224,3 +213,31 @@ const normalizeMemoryContent = (content: string) => {
} }
return normalized; return normalized;
}; };
const parseMemoryMarkdown = (content: string): MemoryEntry[] =>
content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim())
.map((line) => {
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
if (match) {
return {
content: normalizeMemoryContent(match[2]),
id: match[1],
};
}
const normalized = normalizeMemoryContent(line);
return {
content: normalized,
id: normalized ? toStableId("memory-entry", normalized.toLowerCase()) : "",
};
})
.filter((entry) => entry.content);
const renderMemoryMarkdown = (scope: MemoryScope, entries: MemoryEntry[]) => {
const title = scope === "user" ? "# User Memory" : "# Workspace Memory";
const bullets = entries.map((entry) => `- [${entry.id}] ${entry.content}`);
return [title, "", ...bullets, ""].join("\n");
};
-315
View File
@@ -1,315 +0,0 @@
import { config } from "../config.js";
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
import {
type ResultReferenceKind,
type ResultReferenceRecord,
type ResultReferenceSource,
type RetrievalContext,
RESULT_REFERENCE_KIND,
type ResultReferenceStore,
} from "./store.js";
type ResolveOptions = {
expectedKind?: ResultReferenceKind;
maxItems?: number;
};
type RegisterResultReferenceInput = {
actorKey: string;
data: unknown;
kind: ResultReferenceKind;
payloadPath?: string;
projectId?: string;
projectKey: string;
schemaVersion: number;
sessionId: string;
source: ResultReferenceSource;
traceId: string;
};
export type RenderJunctionPayload = {
node_area_map: Record<string, string>;
area_ids?: string[];
area_colors?: Record<string, string>;
};
export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {}
async register(input: RegisterResultReferenceInput) {
const normalizedData = normalizeDataForKind(
input.kind,
input.data,
input.schemaVersion,
);
if (!normalizedData) {
throw new Error(`invalid payload for result ref kind '${input.kind}'`);
}
return this.store.store({
actorKey: input.actorKey,
data: normalizedData,
kind: input.kind,
payloadPath: input.payloadPath,
projectId: input.projectId,
projectKey: input.projectKey,
schemaVersion: input.schemaVersion,
sessionId: input.sessionId,
source: input.source,
traceId: input.traceId,
});
}
async registerRenderPayloadFile(
filePath: string,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
) {
const raw = await readJsonFile<unknown>(filePath);
if (raw === null) {
throw new Error(`render payload file not found: ${filePath}`);
}
const payloadCandidate = normalizeRenderPayloadFile(raw, {
filePath,
projectId: input.projectId,
});
if (payloadCandidate.repaired) {
await atomicWriteJson(filePath, payloadCandidate.file);
}
const payload = extractRenderJunctionPayload(payloadCandidate.data);
if (!payload) {
throw new Error("render payload file does not contain a valid junction render payload");
}
return this.register({
actorKey: input.actorKey,
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
payloadPath: filePath,
projectId: input.projectId,
projectKey: input.projectKey,
schemaVersion: 1,
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,
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: record.data,
preview: record.preview,
kind: record.kind,
schema_version: record.schemaVersion,
source: record.source,
};
}
private async getResolvedRecord(
resultRef: string,
context: RetrievalContext,
options: ResolveOptions,
): Promise<ResultReferenceRecord | null> {
const record = await this.store.getAuthorizedRecord(resultRef, context);
if (!record) {
return null;
}
if (options.expectedKind && record.kind !== options.expectedKind) {
return null;
}
const normalizedData = normalizeDataForKind(
record.kind,
record.data,
record.schemaVersion,
);
if (!normalizedData) {
return null;
}
return {
...record,
data: normalizedData,
};
}
}
export const extractRenderJunctionPayload = (
value: unknown,
): RenderJunctionPayload | null => {
const candidate = unwrapReferencePayload(value);
if (!candidate || !isRecord(candidate.node_area_map)) {
return null;
}
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
if (Object.keys(nodeAreaMap).length === 0) {
return null;
}
const areaIds = Array.isArray(candidate.area_ids)
? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean)
: undefined;
const areaColors = isRecord(candidate.area_colors)
? normalizeStringRecord(candidate.area_colors)
: undefined;
return {
node_area_map: nodeAreaMap,
...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}),
...(areaColors && Object.keys(areaColors).length > 0
? { area_colors: areaColors }
: {}),
};
};
const normalizeDataForKind = (
kind: ResultReferenceKind,
data: unknown,
schemaVersion: number,
): unknown | null => {
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
return null;
}
if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
return extractRenderJunctionPayload(data);
}
return data;
};
const normalizeRenderPayloadFile = (
value: unknown,
context: { filePath: string; projectId?: string },
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
if (!isRecord(value) || !("data" in value)) {
return {
data: value,
file: {
metadata: buildWrapperMetadata({}, value, context.projectId),
location: buildWrapperLocation(undefined, context.filePath),
data: value,
},
repaired: false,
};
}
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 => {
if (!isRecord(value)) {
return null;
}
if ("data" in value && value.data !== undefined && value.data !== null) {
return isRecord(value.data) ? value.data : null;
}
return value;
};
const normalizeStringRecord = (value: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(value)
.map(([key, entry]) => [String(key), String(entry ?? "").trim()])
.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,
};
};
+97 -267
View File
@@ -1,37 +1,30 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { join, resolve } from "node:path"; import { join } from "node:path";
import { type QueryResultRow } from "pg";
import { config } from "../config.js"; import { config } from "../config.js";
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { import {
atomicWriteJson, atomicWriteJson,
ensureDirectory, ensureDirectory,
getFileStat,
listJsonFiles,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/; export type ResultReferenceRecord = {
resultRef: string;
export const RESULT_REFERENCE_KIND = { actorKey: string;
dynamicHttpResult: "dynamic-http-result", clientSessionId: string;
renderJunctionsPayload: "render-junctions-payload", createdAt: string;
} as const; data: unknown;
preview: ResultPreview;
export const RESULT_REFERENCE_SOURCE = { projectId?: string;
dynamicHttp: "dynamic_http", projectKey: string;
agentGenerated: "agent_generated", sessionId: string;
legacy: "legacy", sizeBytes: number;
migration: "migration", traceId: string;
} as const; };
export type ResultReferenceKind =
(typeof RESULT_REFERENCE_KIND)[keyof typeof RESULT_REFERENCE_KIND];
export type ResultReferenceSource =
(typeof RESULT_REFERENCE_SOURCE)[keyof typeof RESULT_REFERENCE_SOURCE];
export type ResultPreview = { export type ResultPreview = {
count: number; count: number;
@@ -40,82 +33,39 @@ export type ResultPreview = {
summary: string; summary: string;
}; };
export type ResultReferenceRecord = {
resultRef: string;
actorKey: string;
createdAt: string;
data: unknown;
kind: ResultReferenceKind;
preview: ResultPreview;
projectId?: string;
projectKey: string;
schemaVersion: number;
sessionId: string;
sizeBytes: number;
source: ResultReferenceSource;
traceId: string;
payloadPath?: string;
objectKey?: string;
};
export type StoreResultInput = { export type StoreResultInput = {
actorKey: string; actorKey: string;
clientSessionId: string;
data: unknown; data: unknown;
kind: ResultReferenceKind;
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
schemaVersion: number;
sessionId: string; sessionId: string;
source: ResultReferenceSource;
traceId: string; traceId: string;
payloadPath?: string;
objectKey?: string;
}; };
export type RetrievalContext = { export type RetrievalContext = {
actorKey: string; actorKey: string;
sessionId?: string; clientSessionId?: string;
maxItems?: number;
projectId?: string; projectId?: string;
}; };
export type ResultReferencePeek = { export type ResultReferencePeek = {
resultRef: string; resultRef: string;
kind: ResultReferenceKind;
preview: ResultPreview; preview: ResultPreview;
storedAt: string; storedAt: string;
}; };
type ResultReferenceRow = QueryResultRow & {
result_ref: string;
actor_key: string;
session_id: string;
created_at: Date | string;
kind: ResultReferenceKind;
preview: ResultPreview;
project_id: string | null;
project_key: string;
schema_version: number;
size_bytes: number;
source: ResultReferenceSource;
trace_id: string;
payload_path: string | null;
object_key: string | null;
};
export class ResultReferenceStore { export class ResultReferenceStore {
private cleanupTimer: NodeJS.Timeout | null = null; private cleanupTimer: NodeJS.Timeout | null = null;
private readonly managedPayloadDir: string;
constructor( constructor(
private readonly db: AgentDatabase = getAgentDatabase(),
private readonly baseDir = config.RESULT_REF_STORAGE_DIR, private readonly baseDir = config.RESULT_REF_STORAGE_DIR,
private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000, private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000,
) { ) {}
this.managedPayloadDir = join(this.baseDir, "payloads");
}
async initialize() { async initialize() {
await Promise.all([this.db.initialize(), ensureDirectory(this.managedPayloadDir)]); await ensureDirectory(this.baseDir);
} }
startCleanupLoop() { startCleanupLoop() {
@@ -139,223 +89,116 @@ export class ResultReferenceStore {
async store(input: StoreResultInput) { async store(input: StoreResultInput) {
const resultRef = `res-${randomUUID().slice(0, 16)}`; const resultRef = `res-${randomUUID().slice(0, 16)}`;
const createdAt = new Date().toISOString(); const record: ResultReferenceRecord = {
const payloadPath =
input.payloadPath ??
(input.objectKey
? undefined
: this.managedPayloadPath(resultRef));
if (!payloadPath && !input.objectKey) {
throw new Error("result ref requires payloadPath or objectKey");
}
if (!input.payloadPath && payloadPath) {
await atomicWriteJson(payloadPath, wrapPayload(input.data, createdAt, input.projectId));
}
const preview = buildPreview(input.data);
const sizeBytes = estimateBytes(input.data);
await this.db.query(
`
INSERT INTO ${this.db.table("result_refs")} (
result_ref,
actor_key,
session_id,
created_at,
kind,
preview,
project_id,
project_key,
schema_version,
size_bytes,
source,
trace_id,
payload_path,
object_key
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, $14)
`,
[
resultRef,
input.actorKey,
input.sessionId,
createdAt,
input.kind,
JSON.stringify(preview),
input.projectId ?? null,
input.projectKey,
input.schemaVersion,
sizeBytes,
input.source,
input.traceId,
payloadPath ?? null,
input.objectKey ?? null,
],
);
return {
resultRef, resultRef,
actorKey: input.actorKey, actorKey: input.actorKey,
createdAt, clientSessionId: input.clientSessionId,
createdAt: new Date().toISOString(),
data: input.data, data: input.data,
kind: input.kind, preview: buildPreview(input.data),
preview,
projectId: input.projectId, projectId: input.projectId,
projectKey: input.projectKey, projectKey: input.projectKey,
schemaVersion: input.schemaVersion,
sessionId: input.sessionId, sessionId: input.sessionId,
sizeBytes, sizeBytes: estimateBytes(input.data),
source: input.source,
traceId: input.traceId, traceId: input.traceId,
payloadPath, };
objectKey: input.objectKey, await atomicWriteJson(this.filePath(resultRef), record);
} satisfies ResultReferenceRecord; return record;
} }
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) { async getAuthorized(resultRef: string, context: RetrievalContext) {
const normalizedResultRef = normalizeResultRef(resultRef); const record = await this.readAuthorizedRecord(resultRef, context);
if (!normalizedResultRef) { if (!record) {
return null; return null;
} }
const data = projectData(record.data, context.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS);
return {
ok: true,
result_ref: record.resultRef,
result_size_bytes: record.sizeBytes,
stored_at: record.createdAt,
data,
preview: record.preview,
};
}
const result = await this.db.query<ResultReferenceRow>( async getFullAuthorized(resultRef: string, context: RetrievalContext) {
` const record = await this.readAuthorizedRecord(resultRef, context);
SELECT * if (!record) {
FROM ${this.db.table("result_refs")}
WHERE result_ref = $1
LIMIT 1
`,
[normalizedResultRef],
);
const row = result.rows[0];
if (!row) {
return null;
}
if (row.actor_key !== context.actorKey) {
return null;
}
if ((row.project_id ?? "") !== (context.projectId ?? "")) {
return null;
}
if (context.sessionId && row.session_id !== context.sessionId) {
return null;
}
const data = await this.readPayload(row);
if (data === null) {
return null; return null;
} }
return { return {
resultRef: row.result_ref, ok: true,
actorKey: row.actor_key, result_ref: record.resultRef,
createdAt: toIsoString(row.created_at), result_size_bytes: record.sizeBytes,
data, stored_at: record.createdAt,
kind: row.kind, data: record.data,
preview: row.preview, preview: record.preview,
projectId: row.project_id ?? undefined, };
projectKey: row.project_key,
schemaVersion: row.schema_version,
sessionId: row.session_id,
sizeBytes: row.size_bytes,
source: row.source,
traceId: row.trace_id,
payloadPath: row.payload_path ?? undefined,
objectKey: row.object_key ?? undefined,
} satisfies ResultReferenceRecord;
} }
async peekAuthorized( async peekAuthorized(resultRef: string, context: RetrievalContext): Promise<ResultReferencePeek | null> {
resultRef: string, const record = await this.readAuthorizedRecord(resultRef, context);
context: RetrievalContext,
): Promise<ResultReferencePeek | null> {
const record = await this.getAuthorizedRecord(resultRef, context);
if (!record) { if (!record) {
return null; return null;
} }
return { return {
resultRef: record.resultRef, resultRef: record.resultRef,
kind: record.kind,
preview: record.preview, preview: record.preview,
storedAt: record.createdAt, storedAt: record.createdAt,
}; };
} }
async listBySession(sessionId: string) { async listBySession(sessionId: string) {
const result = await this.db.query<ResultReferenceRow>( const files = await listJsonFiles(this.baseDir);
`
SELECT *
FROM ${this.db.table("result_refs")}
WHERE session_id = $1
ORDER BY created_at DESC
`,
[sessionId],
);
const records = await Promise.all( const records = await Promise.all(
result.rows.map(async (row) => { files.map(async (filePath) => readJsonFile<ResultReferenceRecord>(filePath)),
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[]; return records
.filter((record): record is ResultReferenceRecord => Boolean(record))
.filter((record) => record.sessionId === sessionId)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
} }
async cleanupExpired() { async cleanupExpired() {
const result = await this.db.query<ResultReferenceRow>( const files = await listJsonFiles(this.baseDir);
` const now = Date.now();
DELETE FROM ${this.db.table("result_refs")} for (const filePath of files) {
WHERE created_at < NOW() - ($1 * INTERVAL '1 millisecond') const stats = await getFileStat(filePath);
RETURNING * if (!stats) {
`, continue;
[this.ttlMs], }
); if (now - stats.mtimeMs > this.ttlMs) {
await Promise.all( await removeFileIfExists(filePath);
result.rows.map(async (row) => { }
if (row.payload_path && isManagedPayloadPath(row.payload_path, this.managedPayloadDir)) {
await removeFileIfExists(row.payload_path);
} }
}),
);
} }
private managedPayloadPath(resultRef: string) { private filePath(resultRef: string) {
return join(this.managedPayloadDir, `${resultRef}.json`); return join(this.baseDir, `${resultRef}.json`);
} }
private async readPayload(row: ResultReferenceRow) { private async readAuthorizedRecord(resultRef: string, context: RetrievalContext) {
if (row.payload_path) { const record = await readJsonFile<ResultReferenceRecord>(this.filePath(resultRef));
return unwrapStoredPayload(await readJsonFile<unknown>(row.payload_path)); if (!record) {
}
if (row.object_key) {
logger.warn({ resultRef: row.result_ref, objectKey: row.object_key }, "object storage payloads are not implemented");
return null; return null;
} }
if (record.actorKey !== context.actorKey) {
return null; return null;
} }
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
return null;
}
if (
context.clientSessionId &&
record.clientSessionId !== context.clientSessionId
) {
return null;
}
return record;
}
} }
const normalizeResultRef = (value: string) => {
const normalized = value.trim();
return RESULT_REF_PATTERN.test(normalized) ? normalized : null;
};
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data)); const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
const buildPreview = (data: unknown): ResultPreview => { const buildPreview = (data: unknown): ResultPreview => {
@@ -376,9 +219,7 @@ const buildPreview = (data: unknown): ResultPreview => {
if (isRecord(data)) { if (isRecord(data)) {
const fields = Object.keys(data).slice(0, 30); const fields = Object.keys(data).slice(0, 30);
const sample = Object.fromEntries( const sample = Object.fromEntries(
fields fields.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS).map((field) => [field, data[field]]),
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
.map((field) => [field, data[field]]),
); );
return { return {
count: fields.length, count: fields.length,
@@ -396,26 +237,15 @@ const buildPreview = (data: unknown): ResultPreview => {
}; };
}; };
const wrapPayload = (data: unknown, createdAt: string, projectId?: string) => ({ const projectData = (data: unknown, maxItems: number) => {
metadata: { if (Array.isArray(data)) {
createdAt, return data.slice(0, maxItems);
...(projectId ? { projectId } : {}), }
}, if (isRecord(data)) {
data, return Object.fromEntries(Object.entries(data).slice(0, maxItems));
}); }
return data;
};
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
const 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));
+820 -367
View File
File diff suppressed because it is too large Load Diff
-242
View File
@@ -1,242 +0,0 @@
import { logger } from "../logger.js";
import { type SessionTurnRecord } from "../history/store.js";
import { MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { collectTextContent } from "./chatStream.js";
const TITLE_PROMPT_TIMEOUT_MS = 5000;
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
const TITLE_CONTEXT_CHAR_LIMIT = 2400;
const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
const RESTORE_TURN_LIMIT = 8;
const RESTORE_MESSAGE_CHAR_LIMIT = 480;
const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
const DEFAULT_SESSION_TITLE = "新对话";
const buildSessionTitle = (message: string) => {
const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) {
return DEFAULT_SESSION_TITLE;
}
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
};
const appendTitleContextMessage = (
lines: string[],
role: "用户" | "助手",
content: string | undefined,
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
) => {
const normalized = content?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
lines.push(`${role}${normalized.slice(0, maxLength)}`);
};
const buildTitleConversationContext = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, TITLE_CONTEXT_MESSAGE_LIMIT);
const recentMessages = messages
.filter(
(message) =>
message.info.role === "user" || message.info.role === "assistant",
)
.map((message) => ({
role: message.info.role,
content: collectTextContent(message.parts)
.replace(/\s+/g, " ")
.trim()
.slice(0, TITLE_CONTEXT_MESSAGE_CHAR_LIMIT),
}))
.filter((message) => message.content.length > 0);
if (recentMessages.length === 0) {
return "";
}
const formattedMessages = recentMessages.map(
(message) => `${message.role === "user" ? "用户" : "助手"}${message.content}`,
);
const fullConversation = formattedMessages.join("\n");
if (fullConversation.length <= TITLE_CONTEXT_CHAR_LIMIT) {
return fullConversation;
}
const headCount = Math.min(4, formattedMessages.length);
const tailCount = Math.min(8, Math.max(0, formattedMessages.length - headCount));
const middleOmitted = formattedMessages.length > headCount + tailCount;
const summary = [
...formattedMessages.slice(0, headCount),
...(middleOmitted ? ["……(中间省略若干轮对话)"] : []),
...formattedMessages.slice(-tailCount),
].join("\n");
return summary.slice(0, TITLE_CONTEXT_CHAR_LIMIT);
};
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
const normalized = rawTitle
.replace(/\s+/g, " ")
.replace(/^标题[:]\s*/i, "")
.replace(/["'“”‘’`]/g, "")
.replace(/[。!?!?,,、;;:]+$/g, "")
.trim();
if (!normalized) {
return fallback;
}
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
};
export const shouldGenerateSessionTitle = (options: {
recentTurnCount: number;
isTitleManuallyEdited: boolean;
}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited;
export const generateSessionTitle = async (
runtime: OpencodeRuntimeAdapter,
options: {
sessionId: string;
latestUserMessage: string;
latestAssistantMessage?: string;
fallbackTitle?: string;
},
) => {
const fallbackTitle = options.fallbackTitle?.trim();
const fallback =
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
? fallbackTitle
: buildSessionTitle(options.latestUserMessage);
let titleSessionId: string | undefined;
try {
const scopedContext: string[] = [];
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
const conversation =
scopedContext.length > 0
? scopedContext.join("\n")
: await buildTitleConversationContext(runtime, options.sessionId);
if (!conversation) {
return fallback;
}
const titleSession = await runtime.createSession(`title-${Date.now().toString(36)}`);
titleSessionId = titleSession.id;
const request = runtime
.prompt(
titleSession.id,
[
"你是会话标题生成器。",
"请根据下面整段多轮对话生成一个 8-16 字中文标题。",
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
"优先概括用户当前真实需求和助手最终结论。",
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
"不要直接照抄用户任一条消息原文。",
"只输出标题本身。",
"",
conversation,
].join("\n"),
)
.then(async () => {
await runtime.waitForSessionIdle(titleSession.id, TITLE_PROMPT_TIMEOUT_MS);
const messages = await runtime.messages(titleSession.id, 20);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const title = collectTextContent(assistantMessage?.parts ?? []);
return normalizeGeneratedTitle(title, fallback);
});
const timeout = new Promise<string>((resolve) => {
setTimeout(() => resolve(fallback), TITLE_PROMPT_TIMEOUT_MS);
});
return await Promise.race([request, timeout]);
} catch (error) {
logger.warn({ err: error }, "failed to generate session title, using fallback");
return fallback;
} finally {
if (titleSessionId) {
await runtime.abortSession(titleSessionId).catch((error) => {
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
});
}
}
};
export const getConversationTurnStats = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, 12);
return messages.reduce(
(stats, message) => {
if (message.info.role === "user") {
stats.userMessageCount += 1;
} else if (message.info.role === "assistant") {
stats.assistantMessageCount += 1;
}
return stats;
},
{
userMessageCount: 0,
assistantMessageCount: 0,
},
);
};
export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore,
actorKey: string,
projectKey: string,
recentTurns: SessionTurnRecord[],
message: string,
) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
const restoredConversation = buildRestoredConversationContext(recentTurns);
if (!snapshot && !restoredConversation) {
return message;
}
return [snapshot, restoredConversation, `[Current user request]\n${message}`]
.filter(Boolean)
.join("\n\n");
};
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
const formattedTurns = recentTurns
.slice(-RESTORE_TURN_LIMIT)
.flatMap((turn) => [
`用户:${compactMessage(turn.userMessage)}`,
`助手:${compactMessage(turn.assistantMessage)}`,
])
.filter((entry) => entry.length > 0);
if (formattedTurns.length === 0) {
return "";
}
const conversation = formattedTurns.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 compactMessage = (value: string) => {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: normalized;
};
-844
View File
@@ -1,844 +0,0 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
export const supportedModels = [
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
] as const;
export type SupportedModel = (typeof supportedModels)[number];
type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter;
opencodeSessionId: string;
sessionId: string;
message: string;
model?: SupportedModel;
traceId?: string;
projectId?: string;
signal?: AbortSignal;
write: (event: string, data: Record<string, unknown>) => void;
};
type ProgressStatus = "running" | "completed" | "error";
type ProgressPayload = {
id: string;
phase: string;
status: ProgressStatus;
title: string;
detail?: string;
};
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
dynamic_http_call: "后端数据查询",
fetch_result_ref: "结果引用回读",
memory_manager: "记忆写入",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
locate_features: "地图定位",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
};
const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
if (!isDevelopmentDebugLoggingEnabled) {
return;
}
logger.info(metadata, message);
};
const getErrorMessage = (error: {
name: string;
data?: { message?: string };
}) => error.data?.message ?? error.name;
const getUnknownErrorMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
typeof error.name === "string"
) {
const maybeData = "data" in error ? error.data : undefined;
return getErrorMessage({
name: error.name,
data:
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
: undefined,
});
}
return error instanceof Error ? error.message : String(error);
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
};
const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill");
const extractSkillAuditInfo = (event: OpencodeEvent) => {
const payload = isObjectRecord(event.properties)
? (event.properties as Record<string, unknown>)
: {};
const candidateName =
typeof payload.skill === "string"
? payload.skill
: typeof payload.skillName === "string"
? payload.skillName
: typeof payload.name === "string"
? payload.name
: event.type;
const reason = extractRequestReason(payload);
return {
name: candidateName,
reason,
payload,
};
};
const hasToolParams = (params: Record<string, unknown>) =>
Object.keys(params).length > 0;
const toRuntimeModel = (model?: SupportedModel) => {
if (!model) {
return undefined;
}
const [providerID, modelID] = model.split("/");
if (!providerID || !modelID) {
return undefined;
}
return {
providerID,
modelID,
};
};
const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
"properties" in event &&
typeof event.properties === "object" &&
event.properties !== null &&
"sessionID" in event.properties &&
event.properties.sessionID === sessionId;
export const collectTextContent = (parts: Part[]) =>
parts
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("");
const emitFallbackMessage = async (
runtime: OpencodeRuntimeAdapter,
opencodeSessionId: string,
sessionId: string,
write: (event: string, data: Record<string, unknown>) => void,
) => {
const messages = await runtime.messages(opencodeSessionId);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const parts = assistantMessage?.parts ?? [];
const text = collectTextContent(parts);
if (text) {
write("token", {
session_id: sessionId,
content: text,
});
}
};
const normalizeToolStatus = (status: string) => {
if (status === "completed") return "completed";
if (status === "error") return "error";
return "running";
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const normalizeProgressText = (chunks: string[]) => chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
const buildReasoningProgressDetail = (chunks: string[], ended?: string | number | Date | null) => {
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
export const streamPromptResponse = async ({
runtime,
opencodeSessionId,
sessionId,
message,
model,
traceId,
projectId,
signal,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
failed: boolean;
toolCallCount: number;
}> => {
const eventStream = await runtime.subscribeEvents();
const iterator = eventStream[Symbol.asyncIterator]();
const requestStartedAt = Date.now();
const promptStartedAt = Date.now();
const progressStartedAtMap = new Map<string, number>();
const finalizedProgressIds = new Set<string>();
const emittedToolParts = new Set<string>();
const partTypes = new Map<string, Part["type"]>();
const pendingPartTextDeltas = new Map<string, string[]>();
const reasoningDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">();
const toolStatuses = new Map<string, string>();
let firstSessionEventLogged = false;
let firstNonStatusEventLogged = false;
let firstTokenLogged = false;
let firstReasoningLogged = false;
let firstToolEventLogged = false;
let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null;
let emittedText = false;
let toolCallCount = 0;
let done = false;
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
const debugContext = {
opencodeSessionId,
sessionId,
traceId,
projectId,
model: model ?? null,
};
logDevelopmentDebug("chat stream started", {
...debugContext,
messageChars: message.length,
});
const abortPromise = signal
? new Promise<{ type: "abort" }>((resolve) => {
if (signal.aborted) {
resolve({ type: "abort" });
return;
}
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
once: true,
});
})
: null;
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
if (status === "running" && finalizedProgressIds.has(id)) {
return;
}
const now = Date.now();
const startedAt = progressStartedAtMap.get(id) ?? now;
if (!progressStartedAtMap.has(id)) {
progressStartedAtMap.set(id, startedAt);
}
if (status === "running") {
write("progress", {
session_id: sessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
elapsed_ms: Math.max(0, now - startedAt),
});
return;
}
const durationMs = Math.max(0, now - startedAt);
finalizedProgressIds.add(id);
progressStartedAtMap.delete(id);
write("progress", {
session_id: sessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
ended_at: now,
duration_ms: durationMs,
});
};
emitProgress({
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
});
const promptPromise = runtime
.prompt(opencodeSessionId, message, toRuntimeModel(model))
.then(() => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
});
})
.catch((error: unknown) => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt failed", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
error: getUnknownErrorMessage(error),
});
throw error;
});
logDevelopmentDebug("runtime.prompt dispatched", {
...debugContext,
});
try {
while (!done) {
if (signal?.aborted) {
aborted = true;
logDevelopmentDebug("chat stream noticed abort signal", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
break;
}
const nextEvent = iterator
.next()
.then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled
? null
: promptPromise.then(
() => ({ type: "prompt" as const }),
(error: unknown) => ({ type: "prompt-error" as const, error }),
);
const next = await Promise.race(
[
...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]),
...(abortPromise ? [abortPromise] : []),
],
);
if (next.type === "abort") {
aborted = true;
break;
}
if (next.type === "prompt-error") {
throw next.error;
}
if (next.type === "prompt") {
continue;
}
if (next.result.done) {
break;
}
const event = next.result.value as OpencodeEvent;
if (!isSessionEvent(event, opencodeSessionId)) {
continue;
}
if (!firstSessionEventLogged) {
firstSessionEventLogged = true;
logDevelopmentDebug("first session event received", {
...debugContext,
eventType: event.type,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
if (event.type === "session.status") {
const nextStatus = event.properties.status.type;
const nextStatusMessage =
"message" in event.properties.status &&
typeof event.properties.status.message === "string"
? event.properties.status.message
: null;
if (
nextStatus !== lastSessionStatus ||
nextStatusMessage !== lastSessionStatusMessage
) {
lastSessionStatus = nextStatus;
lastSessionStatusMessage = nextStatusMessage;
logDevelopmentDebug("session status updated", {
...debugContext,
status: nextStatus,
statusMessage: nextStatusMessage,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
emitProgress({
id: "session-status",
phase: "session",
status: event.properties.status.type === "idle" ? "completed" : "running",
title:
event.properties.status.type === "retry"
? `模型请求重试中:${event.properties.status.message}`
: event.properties.status.type === "busy"
? "Agent 正在处理请求"
: "Agent 已空闲",
detail: buildSessionStatusDetail(event.properties.status),
});
continue;
}
if (!firstNonStatusEventLogged) {
firstNonStatusEventLogged = true;
logDevelopmentDebug("first non-status session event received", {
...debugContext,
eventType: event.type,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
if (isSkillEvent(event)) {
const { name, reason, payload } = extractSkillAuditInfo(event);
logDevelopmentDebug("skill event received", {
...debugContext,
skill: name,
reason: reason || null,
payloadKeys: Object.keys(payload).slice(0, 8),
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
void writeLlmRequestAuditLog({
kind: "skill",
sessionId: opencodeSessionId,
traceId,
projectId,
target: name,
reason,
reasonProvided: Boolean(reason),
payload,
}).catch((error) => {
logger.warn({ err: error }, "failed to write skill audit log");
});
}
if (event.type === "message.part.delta" && event.properties.field === "text") {
const partType = partTypes.get(event.properties.partID);
if (partType === "text") {
if (!firstTokenLogged) {
firstTokenLogged = true;
logDevelopmentDebug("first response token emitted", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
emittedText = true;
write("token", {
session_id: sessionId,
content: event.properties.delta,
});
} else if (partType === "reasoning") {
if (!firstReasoningLogged) {
firstReasoningLogged = true;
logDevelopmentDebug("first reasoning delta received", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
reasoningDeltas.set(event.properties.partID, pending);
} else if (!partType) {
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
pendingPartTextDeltas.set(event.properties.partID, pending);
}
continue;
}
if (event.type === "message.part.updated") {
const part = event.properties.part;
partTypes.set(part.id, part.type);
if (part.type === "text") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
pendingPartTextDeltas.delete(part.id);
for (const content of pending) {
emittedText = true;
write("token", {
session_id: sessionId,
content,
});
}
} else if (part.type === "reasoning") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
if (pending.length > 0) {
const existing = reasoningDeltas.get(part.id) ?? [];
reasoningDeltas.set(part.id, existing.concat(pending));
}
pendingPartTextDeltas.delete(part.id);
const reasoningStatus = part.time.end ? "completed" : "running";
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
reasoningStatuses.set(part.id, reasoningStatus);
logDevelopmentDebug("reasoning part status changed", {
...debugContext,
partId: part.id,
status: reasoningStatus,
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
const reasoningDetail = buildReasoningProgressDetail(
reasoningDeltas.get(part.id) ?? [],
part.time.end,
);
emitProgress({
id: part.id,
phase: "planning",
status: part.time.end ? "completed" : "running",
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
detail: reasoningDetail,
});
}
if (part.type === "tool") {
if (!firstToolEventLogged) {
firstToolEventLogged = true;
logDevelopmentDebug("first tool event received", {
...debugContext,
partId: part.id,
tool: part.tool,
status: part.state.status,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const toolParams = normalizeToolParams(part.state.input);
const reason = extractRequestReason(toolParams);
const isToolFinalState =
part.state.status === "completed" || part.state.status === "error";
const nextToolStatus = String(part.state.status);
if (toolStatuses.get(part.id) !== nextToolStatus) {
toolStatuses.set(part.id, nextToolStatus);
logDevelopmentDebug("tool part status changed", {
...debugContext,
partId: part.id,
tool: part.tool,
status: nextToolStatus,
reason: reason || null,
inputKeys: Object.keys(toolParams).slice(0, 8),
error:
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
emitProgress({
id: part.id,
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
detail: buildToolProgressDetail(
part.tool,
part.state.status,
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
),
});
if (
!emittedToolParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState)
) {
emittedToolParts.add(part.id);
toolCallCount += 1;
if (!reason) {
logger.warn(
{
tool: part.tool,
sessionId: opencodeSessionId,
requestSessionId: sessionId,
},
"llm tool request missing reason",
);
}
void writeLlmRequestAuditLog({
kind: "tool",
sessionId: opencodeSessionId,
traceId,
projectId,
target: part.tool,
reason,
reasonProvided: Boolean(reason),
payload: toolParams,
}).catch((error) => {
logger.warn({ err: error }, "failed to write tool audit log");
});
write("tool_call", {
session_id: sessionId,
tool: part.tool,
params: toolParams,
reason,
});
}
}
continue;
}
if (event.type === "todo.updated") {
const completed = event.properties.todos.filter(
(todo) => todo.status === "completed",
).length;
emitProgress({
id: "todo-progress",
phase: "planning",
status: completed === event.properties.todos.length ? "completed" : "running",
title: `计划进度 ${completed}/${event.properties.todos.length}`,
detail: event.properties.todos
.map((todo) => `${todo.status}: ${todo.content}`)
.join("\n"),
});
continue;
}
if (event.type === "session.error") {
logDevelopmentDebug("session error received", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
error: event.properties.error
? getErrorMessage(event.properties.error)
: "opencode session error",
});
write("error", {
session_id: sessionId,
message: event.properties.error
? getErrorMessage(event.properties.error)
: "opencode session error",
detail: event.properties.error?.name,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
});
failed = true;
done = true;
continue;
}
if (event.type === "session.idle") {
logDevelopmentDebug("session idle received", {
...debugContext,
emittedText,
toolCallCount,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: "session-status",
phase: "session",
status: "completed",
title: "Agent 已完成处理",
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
});
done = true;
}
}
if (aborted) {
logDevelopmentDebug("chat stream aborting session", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await runtime.abortSession(opencodeSessionId).catch((error) => {
logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session");
});
await runtime.waitForSessionIdle(opencodeSessionId).catch((error) => {
logger.warn(
{ sessionId: opencodeSessionId, err: error },
"failed while waiting for aborted opencode session to become idle",
);
});
return { aborted: true, failed: false, toolCallCount };
}
if (failed) {
return { aborted: false, failed: true, toolCallCount };
}
await promptPromise;
if (!emittedText) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await emitFallbackMessage(runtime, opencodeSessionId, sessionId, write);
}
emitProgress({
id: "request-received",
phase: "start",
status: "completed",
title: "请求处理完成",
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
});
emitProgress({
id: "request-completed",
phase: "complete",
status: "completed",
title: "分析完成",
detail: emittedText
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
});
write("done", {
session_id: sessionId,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
});
logDevelopmentDebug("chat stream completed", {
...debugContext,
emittedText,
toolCallCount,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
});
return { aborted: false, failed: false, toolCallCount };
} finally {
await iterator.return?.(undefined);
if (!promptSettled) {
await promptPromise.catch(() => undefined);
}
logDevelopmentDebug("chat stream cleanup finished", {
...debugContext,
promptSettled,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
});
}
};
+17 -54
View File
@@ -7,18 +7,6 @@ import {
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
if (!isDevelopmentDebugLoggingEnabled) {
return;
}
logger.info(metadata, message);
};
export type RuntimeHealth = { export type RuntimeHealth = {
healthy: boolean; healthy: boolean;
version: string; version: string;
@@ -54,6 +42,14 @@ export class OpencodeRuntimeAdapter {
return requireData(response.data, "session.create"); return requireData(response.data, "session.create");
} }
async getSession(id: string) {
const client = await this.ensureClient();
const response = await client.session.get({
sessionID: id,
});
return requireData(response.data, "session.get");
}
async sendPrompt(sessionId: string, text: string) { async sendPrompt(sessionId: string, text: string) {
await this.prompt(sessionId, text); await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts // 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts
@@ -63,27 +59,11 @@ export class OpencodeRuntimeAdapter {
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) { async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const startedAt = Date.now();
logDevelopmentDebug(
"dispatching opencode session.prompt",
{
sessionId,
model: model ?? null,
textChars: text.length,
},
);
await client.session.prompt({ await client.session.prompt({
sessionID: sessionId, sessionID: sessionId,
model, model,
parts: [{ type: "text", text }], parts: [{ type: "text", text }],
}); });
logDevelopmentDebug(
"opencode session.prompt returned",
{
sessionId,
elapsedMs: Math.max(0, Date.now() - startedAt),
},
);
} }
async messages(sessionId: string, limit = 20) { async messages(sessionId: string, limit = 20) {
@@ -95,6 +75,15 @@ export class OpencodeRuntimeAdapter {
return requireData(messages.data, "session.messages"); return requireData(messages.data, "session.messages");
} }
async forkSession(sessionId: string, messageId?: string) {
const client = await this.ensureClient();
const response = await client.session.fork({
sessionID: sessionId,
messageID: messageId,
});
return requireData(response.data, "session.fork");
}
async abortSession(sessionId: string) { async abortSession(sessionId: string) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.session.abort({ const response = await client.session.abort({
@@ -103,26 +92,6 @@ export class OpencodeRuntimeAdapter {
return requireData(response.data, "session.abort"); return requireData(response.data, "session.abort");
} }
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
const client = await this.ensureClient();
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const response = await client.session.status({});
const statuses = requireData(response.data, "session.status");
const status = statuses[sessionId];
if (!status || status.type === "idle") {
return;
}
await delay(100);
}
logger.warn(
{ sessionId, timeoutMs },
"timed out waiting for opencode session to become idle",
);
}
async subscribeEvents() { async subscribeEvents() {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.event.subscribe(); const response = await client.event.subscribe();
@@ -211,9 +180,3 @@ function requireData<T>(data: T | undefined, operation: string): T {
} }
return data; return data;
} }
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
+25 -106
View File
@@ -5,36 +5,32 @@ import express from "express";
import { SessionHistoryStore } from "./history/store.js"; import { SessionHistoryStore } from "./history/store.js";
import { ChatSessionBridge } from "./chat/sessionBridge.js"; import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { ConversationStateStore } from "./conversations/stateStore.js";
import { ConversationStore } from "./conversations/store.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js"; import { LearningOrchestrator } from "./learning/orchestrator.js";
import { MemoryStore } from "./memory/store.js"; import { MemoryStore } from "./memory/store.js";
import { ResultReferenceResolver } from "./results/resolver.js";
import { ResultReferenceStore } from "./results/store.js"; import { ResultReferenceStore } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { RuntimeSessionStore } from "./session/runtimeSessionStore.js"; import { SessionRegistry } from "./session/registry.js";
import { ToolSessionContextStore } from "./session/toolContextStore.js";
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js"; import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
const app = express(); const app = express();
const sessionBridge = new ChatSessionBridge(opencodeRuntime); const registry = new SessionRegistry(config.SESSION_TTL_SECONDS);
const conversationStore = new ConversationStore(); const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime);
const conversationStateStore = new ConversationStateStore();
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const sessionHistoryStore = new SessionHistoryStore(); const sessionHistoryStore = new SessionHistoryStore();
const runtimeSessionStore = new RuntimeSessionStore(); const toolContextStore = new ToolSessionContextStore();
const learningOrchestrator = new LearningOrchestrator( const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime, opencodeRuntime,
memoryStore, memoryStore,
sessionHistoryStore, sessionHistoryStore,
); );
const resultReferenceStore = new ResultReferenceStore(); const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore); const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。 // 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref)。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors()); app.use(cors());
@@ -65,26 +61,12 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
return; return;
} }
const runtimeSessionId = const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : ""; const context = sessionBridge.getSessionContext(sessionId);
const persistedContext = runtimeSessionId
? await runtimeSessionStore.read(runtimeSessionId)
: null;
const runtimeContext = runtimeSessionId
? sessionBridge.getActiveSensitiveContext(runtimeSessionId)
: null;
if (!persistedContext && !runtimeContext) {
res.status(404).json({
message: "runtime or session context not found",
detail: runtimeSessionId,
});
return;
}
const context = persistedContext;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "runtime or session context not found", message: "session context not found",
detail: runtimeSessionId, detail: sessionId,
}); });
return; return;
} }
@@ -97,14 +79,14 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
path: req.body?.path, path: req.body?.path,
method: req.body?.method, method: req.body?.method,
arguments: req.body?.arguments, arguments: req.body?.arguments,
body: req.body?.body,
}, },
{ {
accessToken: runtimeContext?.accessToken, accessToken: context.accessToken,
actorKey: context.actorKey, actorKey: context.actorKey,
sessionId: context.sessionId, clientSessionId: context.clientSessionId,
projectId: context.projectId, projectId: context.projectId,
projectKey: context.projectKey, projectKey: context.projectKey,
sessionId,
traceId: context.traceId, traceId: context.traceId,
}, },
); );
@@ -124,14 +106,13 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
return; return;
} }
const runtimeSessionId = const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : ""; const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null; const context = sessionBridge.getSessionContext(sessionId);
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: runtimeSessionId, detail: sessionId,
}); });
return; return;
} }
@@ -140,18 +121,12 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
return; return;
} }
const result = await resultReferenceResolver.getAuthorized( const result = await resultReferenceStore.getAuthorized(resultRef, {
resultRef,
{
actorKey: context.actorKey, actorKey: context.actorKey,
sessionId: context.sessionId,
projectId: context.projectId,
},
{
maxItems: maxItems:
typeof req.body?.max_items === "number" ? req.body.max_items : undefined, typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
}, projectId: context.projectId,
); });
if (!result) { if (!result) {
res.status(404).json({ message: "result_ref not found" }); res.status(404).json({ message: "result_ref not found" });
@@ -161,69 +136,19 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
res.json(result); 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 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 = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: runtimeSessionId,
});
return;
}
if (!filePath) {
res.status(400).json({ message: "file_path is required" });
return;
}
try {
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
actorKey: context.actorKey,
projectId: context.projectId,
projectKey: context.projectKey,
sessionId: context.sessionId,
source: "migration",
traceId: context.traceId,
});
res.json({
ok: true,
render_ref: record.resultRef,
stored_at: record.createdAt,
preview: record.preview,
kind: record.kind,
schema_version: record.schemaVersion,
source: record.source,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(400).json({
message: "store render ref failed",
detail,
});
}
});
app.post("/internal/tools/session-search", async (req, res) => { app.post("/internal/tools/session-search", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) { if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
return; return;
} }
const runtimeSessionId = const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
const query = typeof req.body?.query === "string" ? req.body.query : ""; const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null; const context = await toolContextStore.read(sessionId);
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "tool session context not found",
detail: runtimeSessionId, detail: sessionId,
}); });
return; return;
} }
@@ -250,26 +175,20 @@ app.use(
buildChatRouter( buildChatRouter(
sessionBridge, sessionBridge,
opencodeRuntime, opencodeRuntime,
conversationStore,
conversationStateStore,
memoryStore, memoryStore,
sessionHistoryStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceStore,
), ),
); );
const bootstrap = async () => { const bootstrap = async () => {
await Promise.all([ await Promise.all([
conversationStore.initialize(),
conversationStateStore.initialize(),
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
sessionHistoryStore.initialize(), sessionHistoryStore.initialize(),
runtimeSessionStore.initialize(), toolContextStore.initialize(),
]); ]);
await conversationStore.resetStreamingSessions();
resultReferenceStore.startCleanupLoop(); resultReferenceStore.startCleanupLoop();
}; };
+80
View File
@@ -0,0 +1,80 @@
import crypto from "node:crypto";
export type SessionBinding = {
clientSessionId: string;
sessionId: string;
lastUsedAt: number;
};
export type SessionContext = {
clientSessionId: string;
accessToken?: string;
projectId?: string;
userId?: string;
};
export class SessionRegistry {
private readonly ttlMs: number;
private readonly bindings = new Map<string, SessionBinding>();
constructor(ttlSeconds: number) {
this.ttlMs = ttlSeconds * 1000;
}
upsert(context: SessionContext, sessionId: string): SessionBinding {
const binding: SessionBinding = {
clientSessionId: context.clientSessionId,
sessionId,
lastUsedAt: Date.now(),
};
this.bindings.set(this.makeKey(context), binding);
return binding;
}
get(context: SessionContext): SessionBinding | null {
const key = this.makeKey(context);
const binding = this.bindings.get(key);
if (!binding) {
return null;
}
if (Date.now() - binding.lastUsedAt > this.ttlMs) {
this.bindings.delete(key);
return null;
}
binding.lastUsedAt = Date.now();
return binding;
}
count(): number {
this.evictExpired();
return this.bindings.size;
}
evictExpired(): string[] {
const expired: string[] = [];
const now = Date.now();
for (const [key, binding] of this.bindings.entries()) {
if (now - binding.lastUsedAt > this.ttlMs) {
expired.push(binding.sessionId);
this.bindings.delete(key);
}
}
return expired;
}
private makeKey(context: SessionContext): string {
// 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
const digest = crypto
.createHash("sha256")
.update(
[
context.clientSessionId,
context.userId?.trim() ?? "",
context.projectId ?? "",
].join("|"),
)
.digest("hex");
return digest;
}
}
-123
View File
@@ -1,123 +0,0 @@
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,
};
};
+44
View File
@@ -0,0 +1,44 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
removeFileIfExists,
} from "../utils/fileStore.js";
export type ToolSessionContext = {
actorKey: string;
allowLearningWrite?: boolean;
clientSessionId: string;
learningMode?: "interactive" | "review";
projectId?: string;
projectKey: string;
sessionId: string;
traceId: string;
};
export class ToolSessionContextStore {
constructor(private readonly baseDir = config.SESSION_CONTEXT_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async write(context: ToolSessionContext) {
await atomicWriteJson(this.filePath(context.sessionId), context);
}
async read(sessionId: string) {
return await readJsonFile<ToolSessionContext>(this.filePath(sessionId));
}
async remove(sessionId: string) {
await removeFileIfExists(this.filePath(sessionId));
}
private filePath(sessionId: string) {
return join(this.baseDir, `${sessionId}.json`);
}
}
+2 -26
View File
@@ -1,6 +1,5 @@
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
import { ResultReferenceStore } from "../results/store.js"; import { ResultReferenceStore } from "../results/store.js";
export type DynamicHttpInput = { export type DynamicHttpInput = {
@@ -8,12 +7,12 @@ export type DynamicHttpInput = {
path: string; path: string;
method?: string; method?: string;
arguments?: Record<string, unknown>; arguments?: Record<string, unknown>;
body?: unknown;
}; };
export type SessionToolContext = { export type SessionToolContext = {
accessToken?: string; accessToken?: string;
actorKey: string; actorKey: string;
clientSessionId: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
projectId?: string; projectId?: string;
@@ -53,13 +52,11 @@ export class DynamicHttpExecutor {
if (context.projectId) { if (context.projectId) {
headers.set("x-project-id", context.projectId); headers.set("x-project-id", context.projectId);
} }
const body = buildRequestBody(method, input.body, headers);
const startedAt = Date.now(); const startedAt = Date.now();
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers, headers,
body,
signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS), signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS),
}); });
const durationMs = Date.now() - startedAt; const durationMs = Date.now() - startedAt;
@@ -130,25 +127,6 @@ const buildQuery = (argumentsObject: Record<string, unknown>) => {
return pairs; return pairs;
}; };
const buildRequestBody = (
method: string,
body: unknown,
headers: Headers,
) => {
if (body === undefined) {
return undefined;
}
if (method === "GET") {
throw new Error("GET requests do not support body");
}
const serialized = JSON.stringify(body);
if (serialized === undefined) {
throw new Error("body must be JSON-serializable");
}
headers.set("Content-Type", "application/json");
return serialized;
};
const normalizeSuccessResult = async ( const normalizeSuccessResult = async (
data: unknown, data: unknown,
context: SessionToolContext, context: SessionToolContext,
@@ -166,13 +144,11 @@ const normalizeSuccessResult = async (
// 大结果转成持久化引用,支持 review 和跨重启回读。 // 大结果转成持久化引用,支持 review 和跨重启回读。
const record = await resultStore.store({ const record = await resultStore.store({
actorKey: context.actorKey, actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
data, data,
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
projectId: context.projectId, projectId: context.projectId,
projectKey: context.projectKey, projectKey: context.projectKey,
schemaVersion: 1,
sessionId: context.sessionId, sessionId: context.sessionId,
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
traceId: context.traceId, traceId: context.traceId,
}); });
-6
View File
@@ -149,12 +149,6 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
export const toStableId = (...parts: string[]) => export const toStableId = (...parts: string[]) =>
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24); createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
export const toConversationScopeKey = (
actorKey: string,
projectKey: string,
sessionId: string,
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
export const slugify = (value: string) => export const slugify = (value: string) =>
value value
.toLowerCase() .toLowerCase()
-104
View File
@@ -1,104 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { ConversationStore } from "../../src/conversations/store.js";
import { createTestDatabase } from "../helpers/postgres.js";
describe("ConversationStore", () => {
let store: ConversationStore;
let dispose: (() => Promise<void>) | undefined;
beforeEach(async () => {
const testDb = await createTestDatabase();
store = new ConversationStore(testDb.db);
dispose = testDb.dispose;
await store.initialize();
});
afterEach(async () => {
await dispose?.();
});
it("issues backend-managed session ids when absent", async () => {
const { record, created } = await store.ensure({
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-key-1",
userId: "user-1",
});
expect(created).toBe(true);
expect(record.sessionId).toStartWith("chat-");
expect(record.ownerUserId).toBe("user-1");
expect(record.status).toBe("active");
});
it("touches metadata and preserves scoped ownership", async () => {
const { record } = await store.ensure({
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-key-2",
sessionId: "existing-session",
userId: "user-2",
});
const touched = await store.touch(record, {
title: "新标题",
});
expect(touched.title).toBe("新标题");
expect(touched.updatedAt >= record.updatedAt).toBe(true);
const fetched = await store.get(
{
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-key-2",
userId: "user-2",
},
"existing-session",
);
expect(fetched?.sessionId).toBe(record.sessionId);
expect(fetched?.title).toBe("新标题");
});
it("tracks streaming state and clears it with runtime-session guarding", async () => {
const { record } = await store.ensure({
actorKey: "actor-3",
projectId: "project-3",
projectKey: "project-key-3",
sessionId: "streaming-session",
userId: "user-3",
});
const streaming = await store.markStreaming(record, "runtime-1");
expect(streaming.isStreaming).toBe(true);
expect(streaming.streamingStartedAt).toBeDefined();
const skipped = await store.clearStreaming(record.sessionId, "runtime-2");
expect(skipped).toBeNull();
const stillStreaming = await store.get(
{
actorKey: "actor-3",
projectId: "project-3",
projectKey: "project-key-3",
userId: "user-3",
},
record.sessionId,
);
expect(stillStreaming?.isStreaming).toBe(true);
await store.clearStreaming(record.sessionId, "runtime-1");
const cleared = await store.get(
{
actorKey: "actor-3",
projectId: "project-3",
projectKey: "project-key-3",
userId: "user-3",
},
record.sessionId,
);
expect(cleared?.isStreaming).toBe(false);
expect(cleared?.streamingStartedAt).toBeUndefined();
});
});
-17
View File
@@ -1,17 +0,0 @@
import { randomUUID } from "node:crypto";
import { AgentDatabase } from "../../src/db/index.js";
export const createTestDatabase = async () => {
const schema = `test_${randomUUID().replace(/-/g, "")}`;
const db = new AgentDatabase({ schema });
await db.initialize();
return {
db,
schema,
async dispose() {
await db.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
await db.close();
},
};
};
-136
View File
@@ -1,136 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { ConversationStore } from "../../src/conversations/store.js";
import { SessionHistoryStore } from "../../src/history/store.js";
import { createTestDatabase } from "../helpers/postgres.js";
describe("SessionHistoryStore", () => {
let conversationStore: ConversationStore;
let store: SessionHistoryStore;
let dispose: (() => Promise<void>) | undefined;
beforeEach(async () => {
const testDb = await createTestDatabase();
conversationStore = new ConversationStore(testDb.db);
store = new SessionHistoryStore(testDb.db);
dispose = testDb.dispose;
await Promise.all([conversationStore.initialize(), store.initialize()]);
});
afterEach(async () => {
await dispose?.();
});
it("stores ordered turns for the durable conversation id", async () => {
await conversationStore.ensure({
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-1",
sessionId: "thread-1",
userId: "user-1",
});
const first = await store.appendTurn(
{
actorKey: "actor-1",
projectKey: "project-1",
sessionId: "thread-1",
},
{
assistantMessage: "先检查泵站流量。",
toolCallCount: 1,
userMessage: "帮我看一下当前异常。",
},
);
expect(first.turns).toHaveLength(1);
const second = await store.appendTurn(
{
actorKey: "actor-1",
projectKey: "project-1",
sessionId: "thread-1",
},
{
assistantMessage: "已经定位到 3 条疑似异常支路。",
toolCallCount: 2,
userMessage: "继续分析这些支路。",
},
);
expect(second.sessionId).toBe("thread-1");
expect(second.turns).toHaveLength(2);
expect(second.turns[0]?.userMessage).toBe("帮我看一下当前异常。");
expect(second.turns[1]?.assistantMessage).toBe("已经定位到 3 条疑似异常支路。");
});
it("clones only the kept prefix when forking a thread", async () => {
await conversationStore.ensure({
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-2",
sessionId: "thread-source",
userId: "user-2",
});
await conversationStore.ensure({
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-2",
sessionId: "thread-fork",
userId: "user-2",
});
await store.appendTurn(
{
actorKey: "actor-2",
projectKey: "project-2",
sessionId: "thread-source",
},
{
assistantMessage: "第一轮回复",
toolCallCount: 0,
userMessage: "第一轮提问",
},
);
await store.appendTurn(
{
actorKey: "actor-2",
projectKey: "project-2",
sessionId: "thread-source",
},
{
assistantMessage: "第二轮回复",
toolCallCount: 0,
userMessage: "第二轮提问",
},
);
const cloned = await store.cloneThread(
{
actorKey: "actor-2",
projectKey: "project-2",
sessionId: "thread-source",
},
{
actorKey: "actor-2",
projectKey: "project-2",
sessionId: "thread-fork",
},
2,
);
expect(cloned.turns).toHaveLength(1);
expect(cloned.turns[0]?.userMessage).toBe("第一轮提问");
const forkRecentTurns = await store.getRecentTurns(
{
actorKey: "actor-2",
projectKey: "project-2",
sessionId: "thread-fork",
},
5,
);
expect(forkRecentTurns).toHaveLength(1);
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
});
});
-148
View File
@@ -1,148 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ConversationStore } from "../../src/conversations/store.js";
import { ResultReferenceResolver } from "../../src/results/resolver.js";
import {
RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
ResultReferenceStore,
} from "../../src/results/store.js";
import { createTestDatabase } from "../helpers/postgres.js";
describe("ResultReferenceResolver", () => {
let payloadDir: string;
let conversationStore: ConversationStore;
let store: ResultReferenceStore;
let resolver: ResultReferenceResolver;
let dispose: (() => Promise<void>) | undefined;
beforeEach(async () => {
payloadDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
const testDb = await createTestDatabase();
conversationStore = new ConversationStore(testDb.db);
store = new ResultReferenceStore(testDb.db, payloadDir, 60_000);
resolver = new ResultReferenceResolver(store);
dispose = async () => {
await testDb.dispose();
await rm(payloadDir, { force: true, recursive: true });
};
await Promise.all([conversationStore.initialize(), store.initialize()]);
});
afterEach(async () => {
await dispose?.();
});
it("stores referenced results as metadata plus an external payload file", async () => {
await conversationStore.ensure({
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-key-1",
sessionId: "client-1",
userId: "user-1",
});
const record = await resolver.register({
actorKey: "actor-1",
data: [{ id: "J1" }, { id: "J2" }],
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
projectId: "project-1",
projectKey: "project-key-1",
schemaVersion: 1,
sessionId: "client-1",
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
traceId: "trace-1",
});
expect(record.payloadPath).toContain("/payloads/");
const payload = JSON.parse(await readFile(record.payloadPath!, "utf8")) as {
data?: unknown;
};
expect(payload.data).toEqual([{ id: "J1" }, { id: "J2" }]);
const result = await resolver.getAuthorized(
record.resultRef,
{
actorKey: "actor-1",
projectId: "project-1",
},
{
maxItems: 1,
},
);
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
expect(result?.data).toEqual([{ id: "J1" }]);
});
it("reuses an existing external render payload file as the payload locator", async () => {
await conversationStore.ensure({
actorKey: "actor-3",
projectId: "project-3",
projectKey: "project-key-3",
sessionId: "client-3",
userId: "user-3",
});
const filePath = join(payloadDir, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify(
{
metadata: {
createdAt: "2026-05-21T00:00:00.000Z",
projectId: "project-3",
},
location: {
file_path: filePath,
},
data: {
node_area_map: {
J1: "DMA-1",
J2: 2,
},
area_ids: ["DMA-1", " DMA-2 "],
},
},
null,
2,
),
"utf8",
);
const record = await resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-3",
projectId: "project-3",
projectKey: "project-key-3",
sessionId: "client-3",
source: RESULT_REFERENCE_SOURCE.migration,
traceId: "trace-3",
});
expect(record.payloadPath).toBe(filePath);
const result = await resolver.getFullAuthorized(
record.resultRef,
{
actorKey: "actor-3",
projectId: "project-3",
sessionId: "client-3",
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "2",
},
area_ids: ["DMA-1", "DMA-2"],
});
});
});
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
generateSessionTitle,
shouldGenerateSessionTitle,
} from "../../src/routes/chatSession.js";
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
describe("shouldGenerateSessionTitle", () => {
it("allows auto-title generation for the first turn when the title was not edited", () => {
expect(
shouldGenerateSessionTitle({
recentTurnCount: 0,
isTitleManuallyEdited: false,
}),
).toBe(true);
});
it("blocks auto-title generation after the user edits the title manually", () => {
expect(
shouldGenerateSessionTitle({
recentTurnCount: 0,
isTitleManuallyEdited: true,
}),
).toBe(false);
});
it("only allows auto-title generation during the first two turns", () => {
expect(
shouldGenerateSessionTitle({
recentTurnCount: 1,
isTitleManuallyEdited: false,
}),
).toBe(true);
expect(
shouldGenerateSessionTitle({
recentTurnCount: 2,
isTitleManuallyEdited: false,
}),
).toBe(false);
});
});
describe("generateSessionTitle", () => {
it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => {
let titlePrompt = "";
const runtime = {
createSession: async () => ({ id: "title-session" }),
prompt: async (_sessionId: string, prompt: string) => {
titlePrompt = prompt;
},
waitForSessionIdle: async () => undefined,
messages: async () => [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
},
],
abortSession: async () => undefined,
} as unknown as OpencodeRuntimeAdapter;
const title = await generateSessionTitle(runtime, {
sessionId: "chat-session",
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
fallbackTitle: "新对话",
});
expect(title).toBe("泵站压力异常排查");
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
});
});
-52
View File
@@ -1,52 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { ConversationStore } from "../../src/conversations/store.js";
import { RuntimeSessionStore } from "../../src/session/runtimeSessionStore.js";
import { createTestDatabase } from "../helpers/postgres.js";
describe("RuntimeSessionStore", () => {
let conversationStore: ConversationStore;
let store: RuntimeSessionStore;
let dispose: (() => Promise<void>) | undefined;
beforeEach(async () => {
const testDb = await createTestDatabase();
conversationStore = new ConversationStore(testDb.db);
store = new RuntimeSessionStore(testDb.db);
dispose = testDb.dispose;
await Promise.all([conversationStore.initialize(), store.initialize()]);
});
afterEach(async () => {
await dispose?.();
});
it("stores and releases runtime sessions by runtime session id", async () => {
await conversationStore.ensure({
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-1",
sessionId: "chat-session-1",
userId: "user-1",
});
await store.write({
runtimeSessionId: "runtime-session-1",
actorKey: "actor-1",
allowLearningWrite: true,
sessionId: "chat-session-1",
learningMode: "interactive",
projectId: "project-id-1",
projectKey: "project-1",
traceId: "trace-1",
});
const runtimeContext = await store.read("runtime-session-1");
expect(runtimeContext?.sessionId).toBe("chat-session-1");
expect(runtimeContext?.runtimeSessionId).toBe("runtime-session-1");
await store.release("runtime-session-1");
expect(await store.read("runtime-session-1")).toBeNull();
expect((await store.read("runtime-session-1", { includeReleased: true }))?.releasedAt).toBeDefined();
});
});
-110
View File
@@ -1,110 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ResultReferenceStore } from "../../src/results/store.js";
import { DynamicHttpExecutor } from "../../src/tools/dynamicHttpExecutor.js";
import { createTestDatabase } from "../helpers/postgres.js";
describe("DynamicHttpExecutor", () => {
const originalFetch = globalThis.fetch;
let payloadDir: string;
let store: ResultReferenceStore;
let executor: DynamicHttpExecutor;
let dispose: (() => Promise<void>) | undefined;
beforeEach(async () => {
payloadDir = await mkdtemp(join(tmpdir(), "tjwater-dynamic-http-"));
const testDb = await createTestDatabase();
store = new ResultReferenceStore(testDb.db, payloadDir, 60_000);
executor = new DynamicHttpExecutor(store);
dispose = async () => {
globalThis.fetch = originalFetch;
await testDb.dispose();
await rm(payloadDir, { force: true, recursive: true });
};
await store.initialize();
});
afterEach(async () => {
await dispose?.();
});
it("sends JSON body for POST requests while preserving query arguments", async () => {
let capturedUrl = "";
let capturedInit: RequestInit | undefined;
globalThis.fetch = Object.assign(
async (input: URL | RequestInfo, init?: RequestInit | BunFetchRequestInit) => {
capturedUrl = String(input);
capturedInit = init;
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
originalFetch,
);
const result = await executor.execute(
{
path: "/api/v1/runproject/simulate",
method: "POST",
arguments: { network: "demo-network" },
body: { duration_minutes: 15 },
},
{
accessToken: "token-1",
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-key-1",
sessionId: "session-1",
traceId: "trace-1",
},
);
const url = new URL(capturedUrl);
const headers = new Headers(capturedInit?.headers);
expect(url.pathname).toBe("/api/v1/runproject/simulate");
expect(url.searchParams.get("network")).toBe("demo-network");
expect(capturedInit?.method).toBe("POST");
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("authorization")).toBe("Bearer token-1");
expect(headers.get("x-project-id")).toBe("project-1");
expect(headers.get("x-trace-id")).toBe("trace-1");
expect(capturedInit?.body).toBe(JSON.stringify({ duration_minutes: 15 }));
expect(result).toEqual({
ok: true,
trace_id: "trace-1",
upstream: {
method: "POST",
path: "/api/v1/runproject/simulate",
status_code: 200,
},
result_mode: "inline",
result_size_bytes: expect.any(Number),
data: { status: "ok" },
});
});
it("rejects GET requests that include a body", async () => {
await expect(
executor.execute(
{
path: "/api/v1/runproject/",
method: "GET",
arguments: { network: "demo-network" },
body: { duration_minutes: 15 },
},
{
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-key-1",
sessionId: "session-1",
traceId: "trace-1",
},
),
).rejects.toThrow("GET requests do not support body");
});
});
+3 -4
View File
@@ -8,10 +8,9 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"rootDir": ".", "rootDir": "src",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true
"types": ["node", "bun-types"]
}, },
"include": ["src/**/*.ts", "tests/**/*.ts"] "include": ["src/**/*.ts"]
} }