Compare commits
60 Commits
80d93d9b21
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c47841483 | |||
| ab12d79d91 | |||
| 7427d08d6c | |||
| f7122d1260 | |||
| 5d80961930 | |||
| 7e63d38cf5 | |||
| e0b81c2114 | |||
| cb298f2099 | |||
| 4870e8a577 | |||
| f24e8109a0 | |||
| 725935e270 | |||
| 6c53e12962 | |||
| 0b5004fc2c | |||
| 872570ac3a | |||
| 23d8249286 | |||
| bd04444d9d | |||
| 96d894d1e0 | |||
| 105dfea18e | |||
| 53a423cafe | |||
| 9dffa59603 | |||
| 97fea698f0 | |||
| dbeb2084cf | |||
| 3eb5829053 | |||
| fe09b02393 | |||
| a6f6e633f0 | |||
| 61702d095a | |||
| 1234d28536 | |||
| 5e5f2494ac | |||
| 4690a0980b | |||
| 0ad3bd4d89 | |||
| 7b4f479aad | |||
| 6584239e75 | |||
| d56f516161 | |||
| d0cb19c521 | |||
| 8b74e98291 | |||
| 1ac46814ad | |||
| ef3253d895 | |||
| 8439d56b42 | |||
| 8b02cae2af | |||
| 69a90de9a1 | |||
| 3e3deaa724 | |||
| eebf802e31 | |||
| f150c602e5 | |||
| 3ebcd98ec5 | |||
| 61b1018900 | |||
| f58abe8003 | |||
| 3d85f13f26 | |||
| 0d5435022a | |||
| 59de5c672f | |||
| 93cba2f391 | |||
| 61e9fa94ac | |||
| cbaa1099de | |||
| 5fbe8ae40c | |||
| a27c45910c | |||
| 37f5bd8a80 | |||
| 65fb368f40 | |||
| 9fa24b39f3 | |||
| a9bab86d64 | |||
| 2473117198 | |||
| e5d780efce |
@@ -1,73 +1,15 @@
|
||||
name: Agent CI/CD
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
- "latest"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
GIT_USERNAME: ${{ github.actor }}
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$SERVER_URL" in
|
||||
http://*)
|
||||
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
||||
;;
|
||||
https://*)
|
||||
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
||||
;;
|
||||
*)
|
||||
AUTH_SERVER_URL="$SERVER_URL"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
git init .
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
else
|
||||
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
fi
|
||||
|
||||
git fetch --depth=1 origin "$COMMIT_SHA"
|
||||
git checkout --force --detach FETCH_HEAD
|
||||
git clean -ffdx
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
GITHUB="https://ghproxy.net/https://github.com" curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --registry=https://registry.npmmirror.com
|
||||
|
||||
- name: Run checks
|
||||
run: bun run check
|
||||
|
||||
docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: validate
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -114,14 +56,29 @@ jobs:
|
||||
env:
|
||||
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
||||
RAW_REPOSITORY: ${{ github.repository }}
|
||||
IMAGE_TAG: ${{ github.ref_name }}
|
||||
RAW_REF: ${{ github.ref }}
|
||||
RAW_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
|
||||
if [ -z "${RAW_REGISTRY_HOST}" ]; then
|
||||
echo "Missing required repository variable: REGISTRY_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
||||
|
||||
if [ -z "${REGISTRY_HOST}" ]; then
|
||||
echo "Repository variable REGISTRY_HOST resolves to an empty host"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
||||
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
||||
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
||||
IMAGE_TAG="${RAW_REF_NAME}"
|
||||
{
|
||||
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
||||
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
||||
@@ -132,13 +89,38 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \
|
||||
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||
if [ -z "${REGISTRY_HOST:-}" ]; then
|
||||
echo "Missing resolved environment value: REGISTRY_HOST"
|
||||
echo "The previous step should write REGISTRY_HOST into GITHUB_ENV."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${REGISTRY_USERNAME}" ]; then
|
||||
echo "Missing required repository secret: REGISTRY_USERNAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${REGISTRY_PASSWORD}" ]; then
|
||||
echo "Missing required repository secret: REGISTRY_PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Logging into registry host: ${REGISTRY_HOST}"
|
||||
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
|
||||
--username "${REGISTRY_USERNAME}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
|
||||
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
push_with_retry() {
|
||||
image_ref="$1"
|
||||
attempt=1
|
||||
@@ -159,23 +141,38 @@ jobs:
|
||||
done
|
||||
}
|
||||
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
if [ "${IMAGE_TAG}" = "latest" ]; then
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
else
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
- name: Notify Deploy Server
|
||||
run: |
|
||||
post_deploy_webhook() {
|
||||
label="$1"
|
||||
payload="$2"
|
||||
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
|
||||
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
|
||||
|
||||
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
|
||||
# Trim whitespace
|
||||
webhook_url=$(echo "$webhook_url" | xargs)
|
||||
|
||||
echo "[$label] Calling webhook: $webhook_url"
|
||||
|
||||
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-d "$payload")
|
||||
|
||||
echo "[$label] webhook HTTP status: ${http_code}"
|
||||
|
||||
@@ -2,5 +2,6 @@ node_modules/
|
||||
.opencode/node_modules/
|
||||
.local.env
|
||||
.vscode
|
||||
docker-compose.yml
|
||||
data/
|
||||
logs/
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
.gitignore
|
||||
@@ -4,7 +4,7 @@ mode: primary
|
||||
model: deepseek/deepseek-v4-pro
|
||||
temperature: 0.2
|
||||
---
|
||||
您是运行在 opencode 上的默认 TJWater Agent,使用简体中文回复用户的问题。
|
||||
您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题。
|
||||
|
||||
按照以下规则操作:
|
||||
|
||||
@@ -17,15 +17,18 @@ temperature: 0.2
|
||||
7. 每次调用任意工具时,必须在工具参数 `reason` 字段中填写本次调用理由,理由需具体且与当前用户问题直接相关。
|
||||
8. 每次按需加载技能(skills)前,先明确说明加载理由,并只加载与当前任务直接相关的最小技能集合。默认遵循 **workflow-first**:先查固定工作流 skill,再按需回落到原子 API skills。
|
||||
9. 当 `dynamic_http_call` 返回 `result_mode = referenced` 和 `result_ref` 时,说明当前只拿到了预览;如果后续推理仍需要完整结果,必须调用 `fetch_result_ref` 回读,不能把 preview 当成完整数据。
|
||||
10. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
|
||||
10. 对 `render_ref`、`result_ref` 或其他引用型结果,默认只使用 preview、摘要、局部字段,或直接把引用传给前端工具;如果引用仅用于渲染/展示(例如 `render_junctions`),直接传引用,不要先读取完整内容再重组。
|
||||
11. 对任何可能很大的引用文件、结果文件或普通大文件,禁止完整读取;优先使用预览、分页、截断、按字段读取、按片段读取或采样读取。只有在没有其他办法且当前推理确实必须依赖完整内容时,才允许读取完整内容,并先明确说明必要性。
|
||||
12. 不得通过 sub-agent、并行代理或任何间接方式,去读取引用文件或大文件的完整内容;主 agent 与其调用链中的其他代理都必须遵守同样限制。
|
||||
13. 当且仅当出现**长期有效且高价值**的信号时,才允许调用在线学习工具:
|
||||
- `memory_manager`:用户明确长期偏好/约束,或当前项目/环境的稳定事实
|
||||
- `skill_manager`:已经被证明有效且可复用的 workflow / 方法模式;由您自己判断应写入 `.opencode/skills` 树中的哪个 skill 位置
|
||||
11. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。
|
||||
12. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager` 或 `skill_manager`。
|
||||
13. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。
|
||||
14. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。
|
||||
15. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
|
||||
16. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
|
||||
17. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
|
||||
18. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns`、`references/` 或 `scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
|
||||
19. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
|
||||
14. 不要把一次性问题、临时上下文、未经验证的猜测写入任何学习工具。
|
||||
15. 严禁把 token、password、secret、API key、system prompt、隐私数据写入 `memory_manager` 或 `skill_manager`。
|
||||
16. 如果内容只是一次性案例、临时纠错或局部证据,当前不要持久化。
|
||||
17. 只有在 workflow 经过验证、足够稳定、可被未来同类任务复用时,才调用 `skill_manager`;并优先写入最贴近现有 skill 树语义的位置,中低置信度内容不要落库。
|
||||
18. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
|
||||
19. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
|
||||
20. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
|
||||
21. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns`、`references/` 或 `scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
|
||||
22. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
|
||||
|
||||
+11
-21
@@ -4,7 +4,7 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.14.28",
|
||||
"@opencode-ai/plugin": "1.14.41",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
@@ -13,33 +13,23 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.41", "", { "dependencies": { "@opencode-ai/sdk": "1.14.41", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.2", "@opentui/solid": ">=0.2.2" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.28", "", { "dependencies": { "@opencode-ai/sdk": "1.14.28", "effect": "4.0.0-beta.48", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.105", "@opentui/solid": ">=0.1.105" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-cHJo7t1jwrzbkIVmNgggdWh4cyOVGw5fnbSpuYeL6qwfmH3g/6YLWtw5ZYEP6detUkEebT08mHXDGmsMUpQa+A=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.28", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.41", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="],
|
||||
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
|
||||
|
||||
"fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="],
|
||||
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
||||
|
||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||
|
||||
@@ -49,9 +39,9 @@
|
||||
|
||||
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||
|
||||
"msgpackr": ["msgpackr@1.11.10", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA=="],
|
||||
"msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
||||
|
||||
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
||||
|
||||
@@ -71,12 +61,12 @@
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": "dist-node/bin/uuid" }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
"uuid": ["uuid@13.0.2", "", { "bin": "dist-node/bin/uuid" }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"yaml": ["yaml@2.8.3", "", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": "bin.mjs" }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
"zod": ["zod@4.1.8", "", {}, ""],
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-413
@@ -1,413 +0,0 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.14.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.14.28",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.28.tgz",
|
||||
"integrity": "sha512-cHJo7t1jwrzbkIVmNgggdWh4cyOVGw5fnbSpuYeL6qwfmH3g/6YLWtw5ZYEP6detUkEebT08mHXDGmsMUpQa+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.14.28",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.105",
|
||||
"@opentui/solid": ">=0.1.105"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.14.28",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.28.tgz",
|
||||
"integrity": "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
|
||||
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.14.28"
|
||||
"@opencode-ai/plugin": "1.14.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
|
||||
@@ -7,44 +7,101 @@ version: 3.0.0
|
||||
# scada Action Skill
|
||||
|
||||
## 简介
|
||||
负责 `analytics/scada-operations` 场景下 `scada` 的具体接口调用。
|
||||
负责 `analytics/scada-operations` 场景下 `scada` 的具体接口调用,分为**设备配置(静态元数据)**、**时序监测数据(TimescaleDB)**、**实时模拟数据**、**方案数据**和**复合查询**五类。
|
||||
|
||||
## 子模块索引 (渐进式引导)
|
||||
- 当前为叶子节点,直接使用下方接口目录。
|
||||
|
||||
## 接口目录
|
||||
|
||||
### SCADA 设备配置(静态元数据)
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | network (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/getscadadeviceschema/` | 获取SCADA设备架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | network (query), id (query) | - |
|
||||
| GET | `/api/v1/getallscadadeviceids/` | 获取所有SCADA设备ID | network (query) | - |
|
||||
| GET | `/api/v1/getallscadadevices/` | 获取所有SCADA设备 | network (query) | - |
|
||||
| GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | network (query) | - |
|
||||
| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA属性 | network (query) | - |
|
||||
| GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | network (query), id (query) | - |
|
||||
| GET | `/api/v1/getscadadevicedata/` | 获取SCADA设备数据 | network (query), device_id (query) | - |
|
||||
| POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | network (query) | - |
|
||||
| POST | `/api/v1/setscadadevice/` | 更新SCADA设备 | network (query) | - |
|
||||
| POST | `/api/v1/deletescadadevice/` | 删除SCADA设备 | network (query) | - |
|
||||
| POST | `/api/v1/cleanscadadevice/` | 清空SCADA设备表 | network (query) | - |
|
||||
| GET | `/api/v1/getscadadevicedataschema/` | 获取SCADA设备数据架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadadeviceschema/` | 获取SCADA设备架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadadevicedata/` | 获取SCADA设备数据 | network (query), device_id (query) | - |
|
||||
| POST | `/api/v1/addscadadevicedata/` | 添加SCADA设备数据 | network (query) | - |
|
||||
| POST | `/api/v1/setscadadevicedata/` | 更新SCADA设备数据 | network (query) | - |
|
||||
| POST | `/api/v1/deletescadadevicedata/` | 删除SCADA设备数据 | network (query) | - |
|
||||
| POST | `/api/v1/cleanscadadevicedata/` | 清空SCADA设备数据表 | network (query) | - |
|
||||
| GET | `/api/v1/getscadaelementschema/` | 获取SCADA元素架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadaelement/` | 获取单个SCADA元素映射 | network (query), id (query) | - |
|
||||
| GET | `/api/v1/getscadaelements/` | 获取所有SCADA元素映射 | network (query) | - |
|
||||
| GET | `/api/v1/getscadaelementschema/` | 获取SCADA元素架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadainfo/` | 获取SCADA信息 | network (query), id (query) | - |
|
||||
| GET | `/api/v1/getscadainfoschema/` | 获取SCADA信息架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadaproperties/` | 获取SCADA属性 | network (query), scada (query) | - |
|
||||
| POST | `/api/v1/scada/batch` | 批量插入SCADA监测数据 | data (body) | - |
|
||||
| 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) | - |
|
||||
| GET | `/api/v1/scada/by-ids-time-range` | 按设备ID和时间范围查询SCADA数据 | start_time (query), end_time (query), device_ids (query) | - |
|
||||
| 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/setscadadevicedata/` | 更新SCADA设备数据 | network (query) | - |
|
||||
| POST | `/api/v1/addscadaelement/` | 添加SCADA元素映射 | network (query) | - |
|
||||
| POST | `/api/v1/setscadaelement/` | 更新SCADA元素映射 | network (query) | - |
|
||||
| POST | `/api/v1/deletescadaelement/` | 删除SCADA元素映射 | network (query) | - |
|
||||
| POST | `/api/v1/cleanscadaelement/` | 清空SCADA元素映射表 | network (query) | - |
|
||||
| GET | `/api/v1/getscadainfoschema/` | 获取SCADA信息架构 | network (query) | - |
|
||||
| GET | `/api/v1/getscadainfo/` | 获取SCADA信息 | network (query), id (query) | - |
|
||||
| GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | network (query) | - |
|
||||
| GET | `/api/v1/getscadaproperties/` | 获取SCADA属性 | network (query), scada (query) | - |
|
||||
| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA属性 | network (query) | - |
|
||||
|
||||
### SCADA 时序监测数据(TimescaleDB)
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/scada/batch` | 批量插入SCADA监测数据 | data (body) | - |
|
||||
| GET | `/api/v1/scada/by-ids-time-range` | 按设备ID和时间范围查询SCADA数据 | start_time (query), end_time (query), device_ids (query) | - |
|
||||
| GET | `/api/v1/scada/by-ids-field-time-range` | 按设备ID、字段和时间范围查询SCADA数据 | start_time (query), end_time (query), field (query), device_ids (query) | - |
|
||||
| PATCH | `/api/v1/scada/{device_id}/field` | 更新SCADA设备字段 | device_id (path), time (query), field (query), value (query) | - |
|
||||
| DELETE | `/api/v1/scada/by-id-time-range` | 按设备ID和时间范围删除SCADA数据 | device_id (query), start_time (query), end_time (query) | - |
|
||||
|
||||
### 实时模拟数据(TimescaleDB - Realtime)
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/realtime/links/batch` | 批量插入实时管道数据 | data (body) | - |
|
||||
| GET | `/api/v1/realtime/links` | 查询实时管道数据 | start_time (query), end_time (query) | link_ids (query) |
|
||||
| DELETE | `/api/v1/realtime/links` | 删除实时管道数据 | start_time (query), end_time (query) | - |
|
||||
| PATCH | `/api/v1/realtime/links/{link_id}/field` | 更新实时管道字段 | link_id (path), time (query), field (query), value (query) | - |
|
||||
| POST | `/api/v1/realtime/nodes/batch` | 批量插入实时节点数据 | data (body) | - |
|
||||
| GET | `/api/v1/realtime/nodes` | 查询实时节点数据 | start_time (query), end_time (query) | node_ids (query) |
|
||||
| DELETE | `/api/v1/realtime/nodes` | 删除实时节点数据 | start_time (query), end_time (query) | - |
|
||||
| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | data (body) | - |
|
||||
| GET | `/api/v1/realtime/query/by-time-property` | 按时间和属性查询实时数据 | time (query), property (query) | - |
|
||||
| GET | `/api/v1/realtime/query/by-id-time` | 按ID和时间查询实时模拟数据 | element_id (query), time (query) | - |
|
||||
|
||||
### 方案模拟数据(TimescaleDB - Scheme)
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/scheme/links/batch` | 批量插入方案管道数据 | data (body) | - |
|
||||
| GET | `/api/v1/scheme/links` | 查询方案管道数据 | scheme_type (query) | link_ids (query) |
|
||||
| GET | `/api/v1/scheme/links/{link_id}/field` | 查询方案管道字段数据 | link_id (path), scheme_type (query), field (query) | - |
|
||||
| PATCH | `/api/v1/scheme/links/{link_id}/field` | 更新方案管道字段 | link_id (path), scheme_type (query), field (query), value (query) | - |
|
||||
| DELETE | `/api/v1/scheme/links` | 删除方案管道数据 | scheme_type (query) | - |
|
||||
| POST | `/api/v1/scheme/nodes/batch` | 批量插入方案节点数据 | data (body) | - |
|
||||
| GET | `/api/v1/scheme/nodes/{node_id}/field` | 查询方案节点字段数据 | node_id (path), scheme_type (query), field (query) | - |
|
||||
| PATCH | `/api/v1/scheme/nodes/{node_id}/field` | 更新方案节点字段 | node_id (path), scheme_type (query), field (query), value (query) | - |
|
||||
| DELETE | `/api/v1/scheme/nodes` | 删除方案节点数据 | scheme_type (query) | - |
|
||||
| POST | `/api/v1/scheme/simulation/store` | 存储方案模拟结果 | scheme_type (query), data (body) | - |
|
||||
| GET | `/api/v1/scheme/query/by-id-time` | 按ID和时间查询方案模拟数据 | element_id (query), scheme_type (query), time (query) | - |
|
||||
|
||||
### 复合查询(TimescaleDB - Composite)
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/v1/composite/scada-simulation` | 获取SCADA关联的模拟数据 | network (query), start_time (query) | end_time (query) |
|
||||
| GET | `/api/v1/composite/element-simulation` | 获取管网元素的模拟数据 | network (query), element_id (query), start_time (query) | end_time (query) |
|
||||
| GET | `/api/v1/composite/element-scada` | 获取管网元素关联的SCADA监测数据 | element_id (query), start_time (query) | end_time (query) |
|
||||
| POST | `/api/v1/composite/clean-scada` | 清洗SCADA监测数据 | data (body) | - |
|
||||
| GET | `/api/v1/composite/pipeline-health-prediction` | 预测管道健康状况 | network (query), time (query) | - |
|
||||
|
||||
- 覆盖方法:`DELETE, GET, PATCH, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /scada/by-ids-time-range` | 查询多个设备在指定时间范围内的所有监测字段数据,device_ids 为逗号分隔的ID字符串 |
|
||||
| `GET /scada/by-ids-field-time-range` | 查询多个设备在指定时间范围内的特定字段数据(如只查压力或只查流量) |
|
||||
| `POST /realtime/simulation/store` | 将水力模拟结果以实时数据形式存入TimescaleDB,供前端实时展示 |
|
||||
| `GET /realtime/query/by-time-property` | 按特定时间点和属性名查询管网实时模拟结果 |
|
||||
| `GET /composite/scada-simulation` | 同时返回指定管网的SCADA监测数据和对应的水力模拟数据,便于对比分析 |
|
||||
| `GET /composite/element-scada` | 查询特定管网元素(管道或节点)关联的SCADA监测时序数据 |
|
||||
| `GET /composite/pipeline-health-prediction` | 基于历史SCADA数据和模型预测管道健康状态 |
|
||||
| `POST /composite/clean-scada` | 对指定设备的SCADA原始数据进行清洗处理(去异常值等),支持传 'all' 清洗所有设备 |
|
||||
|
||||
@@ -20,3 +20,24 @@ version: 3.0.0
|
||||
| GET | `/api/v1/burst-detection/schemes/{scheme_name}` | 获取爆管检测方案详情 | network (query), scheme_name (path) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `POST /detect/` | 基于压力观测数据执行爆管检测分析。使用异常检测算法(隔离森林 IsolationForest)识别压力时间序列中的异常,判定为潜在爆管事件。请求体支持列式字典、逐时刻对象数组、二维数组三种格式的压力数据,可指定数据来源(monitoring 监测 / simulation 模拟)。 |
|
||||
| `GET /schemes/` | 获取指定管网的所有爆管检测方案列表,可通过 query_date 按日期筛选。 |
|
||||
| `GET /schemes/{scheme_name}` | 获取指定名称的爆管检测方案详细配置信息,包含传感器节点、算法参数等。 |
|
||||
|
||||
## 请求体关键字段(POST /detect/)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `network` | str | 管网名称(数据库名) |
|
||||
| `observed_pressure_data` | dict/list/null | 压力观测数据,支持列式字典 `{sensor_id: [values]}` 或逐行数组 |
|
||||
| `points_per_day` | int | 每天数据点数,默认1440 |
|
||||
| `mu` | int | 异常值检测参数,默认100 |
|
||||
| `iforest_params` | dict/null | 隔离森林算法参数,可选 |
|
||||
| `scada_start` / `scada_end` | datetime/null | 从SCADA数据库查询的时间范围 |
|
||||
| `sensor_nodes` | list/null | 指定传感器节点,null为全部 |
|
||||
| `data_source` | str | 数据来源:`monitoring`(监测)或 `simulation`(模拟),默认monitoring |
|
||||
|
||||
@@ -20,3 +20,28 @@ version: 3.0.0
|
||||
| GET | `/api/v1/burst-location/schemes/{scheme_name}` | 获取爆管定位方案详情 | network (query), scheme_name (path) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `POST /locate/` | 基于压力和流量SCADA数据定位管网中的爆管位置。通过对比爆管时与正常状态下的压力/流量差异,计算最可能的爆管节点。 |
|
||||
| `GET /schemes/` | 获取指定管网的所有爆管定位方案列表,可通过 query_date 按日期筛选。 |
|
||||
| `GET /schemes/{scheme_name}` | 获取指定名称的爆管定位方案详细配置,包含传感器布置、阈值参数等。 |
|
||||
|
||||
## 请求体关键字段(POST /locate/)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `network` | str | 管网名称(数据库名) |
|
||||
| `data_source` | str | 数据来源:`monitoring`(监测)或 `simulation`(模拟),默认monitoring |
|
||||
| `pressure_scada_ids` | list/null | 压力SCADA传感器ID列表 |
|
||||
| `burst_pressure` | dict/list/null | 爆管时的压力数据 |
|
||||
| `normal_pressure` | dict/list/null | 正常时的压力数据 |
|
||||
| `burst_leakage` | float | 爆管时的漏水量(必填) |
|
||||
| `flow_scada_ids` | list/null | 流量SCADA传感器ID列表 |
|
||||
| `burst_flow` / `normal_flow` | dict/list/null | 爆管/正常时的流量数据 |
|
||||
| `min_dpressure` | float | 最小压力差(bar),默认2.0 |
|
||||
| `basic_pressure` | float | 基准压力(bar),默认10.0 |
|
||||
| `scada_burst_start` / `scada_burst_end` | datetime/null | 从SCADA数据库查询的爆管时间范围 |
|
||||
| `use_scada_flow` | bool | 是否使用SCADA流量数据,默认false |
|
||||
|
||||
@@ -20,3 +20,27 @@ version: 3.0.0
|
||||
| GET | `/api/v1/leakage/schemes/{scheme_name}` | 获取漏损识别方案详情 | network (query), scheme_name (path) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `POST /identify/` | 基于压力观测数据和遗传算法识别管网中的漏损位置和大小。通过对比模型计算与实测压力数据,迭代优化找到最匹配的漏损节点和漏水量。 |
|
||||
| `GET /schemes/` | 获取指定管网的所有漏损识别方案列表,可通过 query_date 按日期筛选。 |
|
||||
| `GET /schemes/{scheme_name}` | 获取指定名称的漏损识别方案详细配置,包含传感器节点、算法参数等。 |
|
||||
|
||||
## 请求体关键字段(POST /identify/)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `network` | str | 管网名称(数据库名) |
|
||||
| `observed_pressure_data` | str/dict/list/null | 观测压力数据 |
|
||||
| `start_time` | float | 起始时间(小时),默认0 |
|
||||
| `duration` | float | 持续时间(小时),默认24 |
|
||||
| `timestep` | float | 时间步长(分钟),默认5 |
|
||||
| `q_sum` | float | 总流量(m³/s),默认0.2 |
|
||||
| `pop_size` | int | 遗传算法种群大小,默认50 |
|
||||
| `max_gen` | int | 遗传算法最大代数,默认100 |
|
||||
| `n_workers` | int | 并行工作线程数,默认CPU数-1(最大4) |
|
||||
| `scada_start` / `scada_end` | datetime/null | 从SCADA数据库查询的时间范围 |
|
||||
| `sensor_nodes` | list/null | 传感器节点列表,null为全部 |
|
||||
|
||||
@@ -15,10 +15,20 @@ version: 3.0.0
|
||||
## 接口目录
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/v1/getnetworkpiperiskprobabilitynow/` | 获取整个网络的管道风险概率 | network (query) | - |
|
||||
| GET | `/api/v1/getpiperiskprobability/` | 获取管道风险概率历史 | network (query), pipe_id (query) | - |
|
||||
| GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | network (query) | - |
|
||||
| GET | `/api/v1/getpiperiskprobabilitynow/` | 获取管道当前风险概率 | network (query), pipe_id (query) | - |
|
||||
| GET | `/api/v1/getpiperiskprobability/` | 获取管道风险概率历史 | network (query), pipe_id (query) | - |
|
||||
| GET | `/api/v1/getpipesriskprobability/` | 批量获取多条管道风险概率 | network (query), pipe_ids (query) | - |
|
||||
| GET | `/api/v1/getnetworkpiperiskprobabilitynow/` | 获取整个网络的管道风险概率 | network (query) | - |
|
||||
| GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getpiperiskprobabilitynow/` | 查询指定管道在当前时刻的风险概率值 |
|
||||
| `GET /getpiperiskprobability/` | 查询指定管道的历史风险概率时间序列数据 |
|
||||
| `GET /getpipesriskprobability/` | 批量查询多条管道的风险概率,pipe_ids为逗号分隔的ID字符串(如 `pipe1,pipe2,pipe3`) |
|
||||
| `GET /getnetworkpiperiskprobabilitynow/` | 查询整个管网中所有管道的当前风险概率,返回列表 |
|
||||
| `GET /getpiperiskprobabilitygeometries/` | 查询管网中管道的地理位置和风险相关几何数据,适合地图可视化 |
|
||||
|
||||
@@ -15,34 +15,60 @@ version: 3.0.0
|
||||
## 接口目录
|
||||
| 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/runprojectreturndict/` | 运行项目模拟(返回字典) | network (query) | - |
|
||||
| POST | `/api/v1/runsimulationmanuallybydate/` | 手动运行日期指定模拟 | data (body) | - |
|
||||
| POST | `/api/v1/scheduling_analysis/` | 排程分析 | 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) |
|
||||
| GET | `/api/v1/runinp/` | 运行INP文件 | network (query) | - |
|
||||
| GET | `/api/v1/dumpoutput/` | 导出模拟输出 | output (query) | - |
|
||||
| GET | `/api/v1/burstanalysis/` | 爆管分析(基础) | network (query), pipe_id (query), start_time (query), end_time (query), burst_flow (query) | - |
|
||||
| GET | `/api/v1/burst_analysis/` | 爆管分析(高级) | network (query), modify_pattern_start_time (query), burst_ID (query), burst_size (query), modify_total_duration (query), scheme_name (query) | - |
|
||||
| GET | `/api/v1/valvecloseanalysis/` | 阀门关闭分析(基础) | network (query), valve_id (query), start_time (query), end_time (query) | - |
|
||||
| GET | `/api/v1/valve_close_analysis/` | 阀门关闭分析(高级) | network (query), start_time (query), valves (query) | duration (query) |
|
||||
| GET | `/api/v1/valve_isolation_analysis/` | 阀门隔离分析 | network (query), accident_element (query) | disabled_valves (query) |
|
||||
| GET | `/api/v1/valvecloseanalysis/` | 阀门关闭分析(基础) | network (query), valve_id (query), start_time (query), end_time (query) | - |
|
||||
| GET | `/api/v1/flushinganalysis/` | 冲洗分析(基础) | network (query), pipe_id (query), start_time (query), duration (query), flow (query) | - |
|
||||
| GET | `/api/v1/flushing_analysis/` | 冲洗分析(高级) | network (query), start_time (query), valves (query), valves_k (query), drainage_node_ID (query) | flush_flow (query), duration (query), scheme_name (query) |
|
||||
| GET | `/api/v1/contaminant_simulation/` | 污染物模拟 | network (query), start_time (query), source (query), concentration (query), duration (query) | scheme_name (query), pattern (query) |
|
||||
| GET | `/api/v1/ageanalysis/` | 水龄分析(基础) | network (query) | - |
|
||||
| GET | `/api/v1/age_analysis/` | 水龄分析(高级) | network (query), start_time (query), end_time (query), duration (query) | - |
|
||||
| GET | `/api/v1/pressureregulation/` | 压力调节(基础) | network (query), target_node (query), target_pressure (query) | - |
|
||||
| POST | `/api/v1/pressure_regulation/` | 压力调节(高级) | data (body) | - |
|
||||
| GET | `/api/v1/projectmanagement/` | 项目管理(基础) | network (query) | - |
|
||||
| POST | `/api/v1/project_management/` | 项目管理(高级) | data (body) | - |
|
||||
| POST | `/api/v1/scheduling_analysis/` | 排程分析 | data (body) | - |
|
||||
| POST | `/api/v1/daily_scheduling_analysis/` | 日排程分析 | data (body) | - |
|
||||
| POST | `/api/v1/network_project/` | 导入网络项目 | file (file) | - |
|
||||
| GET | `/api/v1/networkupdate/` | 管网更新(基础) | network (query) | - |
|
||||
| POST | `/api/v1/network_update/` | 管网更新(高级) | file (file) | - |
|
||||
| POST | `/api/v1/pump_failure/` | 泵故障管理 | data (body) | - |
|
||||
| GET | `/api/v1/pressuresensorplacementsensitivity/` | 压力传感器放置-灵敏度分析(基础) | name (query), scheme_name (query), sensor_number (query), min_diameter (query), username (query) | - |
|
||||
| POST | `/api/v1/pressure_sensor_placement_sensitivity/` | 压力传感器放置-灵敏度分析(高级) | data (body) | - |
|
||||
| GET | `/api/v1/pressuresensorplacementkmeans/` | 压力传感器放置-KMeans聚类分析(基础) | name (query), scheme_name (query), sensor_number (query), min_diameter (query), username (query) | - |
|
||||
| POST | `/api/v1/pressure_sensor_placement_kmeans/` | 压力传感器放置-KMeans聚类分析(高级) | data (body) | - |
|
||||
| POST | `/api/v1/sensorplacementscheme/create` | 传感器放置方案创建 | network (query), scheme_name (query), sensor_type (query), method (query), sensor_count (query), user_name (query) | min_diameter (query) |
|
||||
| POST | `/api/v1/runsimulationmanuallybydate/` | 手动运行日期指定模拟 | data (body) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /runproject/` | 运行标准水力模拟,返回纯文本格式的模拟报告 |
|
||||
| `GET /runprojectreturndict/` | 运行标准水力模拟,返回JSON字典(含节点/管段结果数据),适合程序处理;结果可达30MB+ |
|
||||
| `GET /runinp/` | 运行指定INP文件(文件放在inp文件夹中,参数为不含扩展名的文件名)进行水力模拟 |
|
||||
| `GET /dumpoutput/` | 导出指定绝对路径的模拟输出文件内容 |
|
||||
| `GET /burstanalysis/` | 基础爆管分析:对指定管道指定时间范围内的爆管事件进行分析,评估对压力/流量的影响 |
|
||||
| `GET /burst_analysis/` | 高级爆管分析:支持在指定时间点修改泵控制模式和阀门开度,分析干预措施对爆管影响的作用;支持固定泵和变速泵独立控制 |
|
||||
| `GET /valve_close_analysis/` | 高级阀门关闭分析:支持同时关闭多个阀门,指定持续时间,返回纯文本格式结果 |
|
||||
| `GET /valve_isolation_analysis/` | 阀门隔离分析:分析突发事件时通过关闭指定阀门进行隔离,确定必须关闭阀门、可选关闭阀门及隔离可行性 |
|
||||
| `GET /flushing_analysis/` | 高级冲洗分析:支持同时开启多个阀门冲洗,指定排污节点,设置固定冲洗流量,返回纯文本结果 |
|
||||
| `GET /contaminant_simulation/` | 污染物模拟:评估污染源对管网的影响范围和浓度分布,支持指定污染位置、浓度和扩散模式 |
|
||||
| `GET /age_analysis/` | 高级水龄分析:在指定时间点分析水体停留时间,支持自定义模拟持续时间,返回纯文本结果 |
|
||||
| `POST /pressure_regulation/` | 高级压力调节:通过JSON体提供详细控制参数(固定泵/变速泵独立控制、水箱初始水位等)进行压力优化 |
|
||||
| `POST /project_management/` | 高级项目管理:通过JSON体提供详细参数(泵控制策略、水箱水位、区域需水量控制)进行管网管理 |
|
||||
| `POST /scheduling_analysis/` | 排程分析:优化泵运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求 |
|
||||
| `POST /daily_scheduling_analysis/` | 日排程分析:优化水库、水厂、水箱和用户需求协调,制定合理的每日排程方案 |
|
||||
| `POST /pump_failure/` | 泵故障管理:记录故障发生时间和受影响的泵列表,更新泵状态日志 |
|
||||
| `POST /pressure_sensor_placement_sensitivity/` | 高级传感器放置(灵敏度法):通过JSON体提供详细参数,基于灵敏度矩阵确定最优放置位置 |
|
||||
| `POST /pressure_sensor_placement_kmeans/` | 高级传感器放置(KMeans法):通过JSON体提供详细参数,基于聚类算法确定最优放置位置 |
|
||||
| `POST /sensorplacementscheme/create` | 创建传感器放置方案:支持 sensitivity 和 kmeans 两种算法,自动计算最优传感器位置并存储方案 |
|
||||
| `POST /runsimulationmanuallybydate/` | 按日期手动运行模拟:根据指定日期、开始时间和持续时间查询管网参数并执行水力模拟 |
|
||||
@@ -23,3 +23,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setruleproperties/` | 设置规则属性 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getcontrolschema/` | 返回简单控制(Control)数据模型的字段定义 |
|
||||
| `GET /getcontrolproperties/` | 获取管网中所有简单控制规则的属性列表 |
|
||||
| `GET /getruleschema/` | 返回规则控制(Rule)数据模型的字段定义 |
|
||||
| `GET /getruleproperties/` | 获取管网中所有基于规则的复杂控制条件列表 |
|
||||
| `POST /setcontrolproperties/` | 设置/更新简单控制规则的属性 |
|
||||
| `POST /setruleproperties/` | 设置/更新规则控制的属性 |
|
||||
|
||||
@@ -24,3 +24,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setcurveproperties/` | 设置曲线属性 | network (query), curve (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getcurveschema` | 返回曲线(Curve)数据模型的字段定义(水泵特性曲线、效率曲线等) |
|
||||
| `GET /getcurves/` | 获取管网中所有曲线的ID列表 |
|
||||
| `GET /getcurveproperties/` | 查询指定曲线的详细属性(类型、控制点数据等) |
|
||||
| `POST /addcurve/` | 向管网添加一条新曲线 |
|
||||
| `POST /deletecurve/` | 从管网删除指定曲线 |
|
||||
| `POST /setcurveproperties/` | 设置/更新曲线的属性(控制点坐标等) |
|
||||
|
||||
@@ -29,3 +29,15 @@ version: 3.0.0
|
||||
| POST | `/api/v1/settimeproperties/` | 设置时间选项属性 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getoptionschema/` | 返回模拟选项(Options)数据模型的字段定义 |
|
||||
| `GET /getoptionproperties/` | 获取管网模拟参数设置(时步、精度、单位系统等) |
|
||||
| `GET /getenergyschema/` | 返回能耗选项(Energy)数据模型的字段定义 |
|
||||
| `GET /getenergyproperties/` | 获取全局能耗设置(电价、效率等) |
|
||||
| `GET /getpumpenergyproperties/` | 获取单台水泵的能耗参数 |
|
||||
| `POST /setoptionproperties/` | 设置管网模拟参数 |
|
||||
| `POST /setenergyproperties/` | 设置全局能耗参数 |
|
||||
|
||||
@@ -24,3 +24,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setpatternproperties/` | 设置模式属性 | network (query), pattern (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getpatternschema` | 返回模式(Pattern)数据模型的字段定义 |
|
||||
| `GET /getpatterns/` | 获取管网中所有时间模式的ID列表 |
|
||||
| `GET /getpatternproperties/` | 查询指定模式的属性(时间序列乘数值等) |
|
||||
| `POST /addpattern/` | 向管网添加一个新时间模式 |
|
||||
| `POST /deletepattern/` | 从管网删除指定时间模式 |
|
||||
| `POST /setpatternproperties/` | 设置/更新时间模式的属性(乘数序列) |
|
||||
|
||||
@@ -42,3 +42,18 @@ version: 3.0.0
|
||||
| POST | `/api/v1/settankreaction/` | 设置水池反应属性 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getemitterproperties/` | 查询节点处发射器(用于模拟管漏)的属性 |
|
||||
| `GET /getmixingproperties/` | 查询水箱水质混合模型属性 |
|
||||
| `GET /getsourceproperties/` | 查询水质污染源的属性(位置、类型、浓度等) |
|
||||
| `GET /getreactionproperties/` | 获取全局水质反应参数(管网反应系数等) |
|
||||
| `GET /getwaterqualityresult/` | 查询水质模拟结果 |
|
||||
| `POST /addsource/` | 向管网添加一个水质污染源 |
|
||||
| `POST /deletesource/` | 删除指定水质污染源 |
|
||||
| `POST /addmixing/` | 为水箱添加水质混合模型 |
|
||||
| `POST /deletemixing/` | 删除水箱水质混合模型 |
|
||||
| `POST /setemitter*/` | 设置发射器属性(流量系数等) |
|
||||
|
||||
@@ -32,3 +32,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setvertexproperties/` | 设置图形元素属性 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getallvertexlinks/` | 获取所有管线的折点列表(用于地图还原管线真实走向) |
|
||||
| `GET /getvertexlink/` | 获取单条管线的折点坐标序列 |
|
||||
| `POST /addvertex/` | 为管线添加一个折点(改变管线显示路径) |
|
||||
| `POST /deletevertex/` | 删除管线上的指定折点 |
|
||||
| `POST /addlabel/` | 在地图上添加文字标注 |
|
||||
| `POST /deletelabel/` | 删除地图文字标注 |
|
||||
|
||||
@@ -15,10 +15,18 @@ version: 3.0.0
|
||||
## 接口目录
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/auth/login` | login | form_data (body) | - |
|
||||
| POST | `/api/v1/auth/login/simple` | login_simple | username (query), password (query) | - |
|
||||
| GET | `/api/v1/auth/me` | get_current_user_info | - | - |
|
||||
| POST | `/api/v1/auth/refresh` | refresh_token | refresh_token (query) | - |
|
||||
| POST | `/api/v1/auth/register` | register | user_data (body) | - |
|
||||
| POST | `/api/v1/auth/login` | 用户登录 | form_data (body) | - |
|
||||
| POST | `/api/v1/auth/login/simple` | 简化版登录 | username (query), password (query) | - |
|
||||
| GET | `/api/v1/auth/me` | 获取当前用户信息 | - | - |
|
||||
| POST | `/api/v1/auth/refresh` | 刷新AccessToken | refresh_token (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `POST /login` | OAuth2标准格式登录,提交form-data(username+password),返回JWT Access Token和Refresh Token |
|
||||
| `POST /login/simple` | 简化版登录,直接通过query参数传递username和password,保持向后兼容 |
|
||||
| `GET /me` | 返回当前已登录用户的详细信息(需携带Access Token) |
|
||||
| `POST /refresh` | 使用Refresh Token换取新的Access Token,延续会话 |
|
||||
|
||||
@@ -23,3 +23,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/users/{user_id}/deactivate` | 停用用户 | user_id (path) | - |
|
||||
|
||||
- 覆盖方法:`DELETE, GET, POST, PUT`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /users/` | 列出系统中所有用户(管理员权限),支持分页(skip/limit) |
|
||||
| `GET /users/{user_id}` | 按用户ID查询单个用户的详细信息 |
|
||||
| `PUT /users/{user_id}` | 更新指定用户的信息(邮箱、角色、密码等),请求体为 user_update 对象 |
|
||||
| `DELETE /users/{user_id}` | 删除指定用户(软删除或硬删除) |
|
||||
| `POST /users/{user_id}/activate` | 激活指定用户账号(管理员操作) |
|
||||
| `POST /users/{user_id}/deactivate` | 停用指定用户账号(禁止登录,管理员操作) |
|
||||
|
||||
@@ -20,3 +20,11 @@ version: 3.0.0
|
||||
| GET | `/api/v1/getuserschema/` | 获取用户模式 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getallusers/` | 获取指定管网下的所有用户列表(旧版接口,返回管网级别用户信息) |
|
||||
| `GET /getuser/` | 按用户名查询指定管网下的单个用户信息 |
|
||||
| `GET /getuserschema/` | 获取用户数据模型的字段定义(Schema) |
|
||||
|
||||
@@ -23,3 +23,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setdemandproperties/` | 设置需水量属性 | network (query), junction (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getdemandschema` | 返回需水量(Demand)数据模型的字段定义 |
|
||||
| `GET /getdemandproperties/` | 查询指定节点的需水量属性(基础需水量、模式等) |
|
||||
| `POST /setdemandproperties/` | 设置节点的需水量属性 |
|
||||
| `POST /calculatedemandtonodes/` | 将指定总需水量计算分配到各节点 |
|
||||
| `POST /calculatedemandtoregion/` | 将指定总需水量计算分配到指定区域内的节点 |
|
||||
| `POST /calculatedemandtonetwork/` | 将指定总需水量按比例分配到整个管网的所有节点 |
|
||||
|
||||
@@ -46,3 +46,17 @@ version: 3.0.0
|
||||
| GET | `/api/v1/settitle/` | 设置水网标题属性 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getelementtype/` | 查询指定ID元素的类型(Junction/Pipe/Pump/Tank/Valve/Reservoir) |
|
||||
| `GET /getelementtypevalue/` | 查询指定ID元素的类型编码值 |
|
||||
| `GET /getelementproperties/` | 查询指定ID元素的所有属性(自动识别类型) |
|
||||
| `GET /getelementpropertieswithtype/` | 查询指定类型和ID的元素属性 |
|
||||
| `GET /getlinkproperties/` | 查询管线(Pipe/Pump/Valve)的属性 |
|
||||
| `GET /getnodeproperties/` | 查询节点(Junction/Tank/Reservoir)的属性 |
|
||||
| `GET /settitle/` | 设置管网标题属性 |
|
||||
| `POST /deletelink/` | 删除管线(管道/水泵/阀门) |
|
||||
| `POST /deletenode/` | 删除节点(节点/水箱/水库) |
|
||||
|
||||
@@ -23,3 +23,14 @@ version: 3.0.0
|
||||
| GET | `/api/v1/getnodecoord/` | 获取节点坐标 | network (query), node (query) | - |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getnodecoord/` | 查询单个节点(任意类型)的坐标(x, y) |
|
||||
| `GET /getmajornodecoords/` | 获取管网主要节点(干管节点)的坐标列表,用于快速渲染 |
|
||||
| `GET /getmajorpipenodes/` | 获取主要管道的起终节点列表 |
|
||||
| `GET /getnetworklinknodes/` | 获取管网中所有管线的起终节点信息 |
|
||||
| `GET /getnetworkgeometries/` | 获取整个管网的完整几何信息(节点坐标 + 管线折点),适合地图绘制 |
|
||||
| `GET /getnetworkinextent/` | 查询指定地理范围(bbox)内的管网节点和管线 |
|
||||
|
||||
@@ -35,3 +35,18 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setjunctiony/` | 设置节点 Y 坐标 | network (query), junction (query), y (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getjunctionschema` | 返回节点(Junction)数据模型的所有字段定义 |
|
||||
| `GET /getjunctionproperties/` | 查询单个节点的所有属性(标高、需水量、坐标等) |
|
||||
| `GET /getalljunctionproperties/` | 批量获取管网中所有节点的属性列表 |
|
||||
| `GET /getjunctioncoord/` | 查询单个节点的坐标(x, y) |
|
||||
| `GET /getjunctionelevation/` | 查询节点标高值 |
|
||||
| `GET /getjunctiondemand/` | 查询节点基础需水量 |
|
||||
| `GET /getjunctionpattern/` | 查询节点关联的需水时间模式名称 |
|
||||
| `POST /addjunction/` | 向管网添加一个新节点,需提供ID、坐标和标高 |
|
||||
| `POST /deletejunction/` | 从管网删除指定节点 |
|
||||
| `POST /setjunction*/` | 设置节点某个具体属性(坐标、标高、需水量等) |
|
||||
|
||||
@@ -37,3 +37,18 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setpipestatus/` | 设置管道状态 | network (query), pipe (query), status (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getpipeschema` | 返回管道(Pipe)数据模型的所有字段定义 |
|
||||
| `GET /getpipeproperties/` | 查询单条管道的所有属性(管径、长度、起终节点等) |
|
||||
| `GET /getallpipeproperties/` | 批量获取管网中所有管道的属性列表 |
|
||||
| `GET /getpipelength/` | 查询管道长度 |
|
||||
| `GET /getpipediameter/` | 查询管道管径 |
|
||||
| `GET /getpipestatus/` | 查询管道当前状态(开/关/CV) |
|
||||
| `GET /getpiperoughness/` | 查询管道粗糙系数 |
|
||||
| `POST /addpipe/` | 向管网添加一条新管道,需提供ID、起终节点、长度和管径 |
|
||||
| `POST /deletepipe/` | 从管网删除指定管道 |
|
||||
| `POST /setpipe*/` | 设置管道某个具体属性(管径、长度、状态等) |
|
||||
|
||||
@@ -27,3 +27,16 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setpumpproperties/` | 设置水泵属性 | network (query), pump (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getpumpschema` | 返回水泵(Pump)数据模型的所有字段定义 |
|
||||
| `GET /getpumpproperties/` | 查询单台水泵的所有属性(曲线名称、起终节点等) |
|
||||
| `GET /getallpumpproperties/` | 批量获取管网中所有水泵的属性列表 |
|
||||
| `POST /addpump/` | 向管网添加一台新水泵,需提供ID和起终节点 |
|
||||
| `POST /deletepump/` | 从管网删除指定水泵 |
|
||||
| `POST /setpumpproperties/` | 批量设置水泵属性(曲线、初始状态、效率等) |
|
||||
| `POST /setpumpnode1/` | 设置水泵起始节点 |
|
||||
| `POST /setpumpnode2/` | 设置水泵终止节点 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: tjwater-action-business-network-assets-regions
|
||||
description: business/network-assets 下 regions 操作技能。
|
||||
version: 3.0.0
|
||||
version: 3.0.1
|
||||
---
|
||||
|
||||
# regions Action Skill
|
||||
@@ -19,25 +19,21 @@ version: 3.0.0
|
||||
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
|
||||
| POST | `/api/v1/addservicearea/` | 添加新服务区 | 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/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
|
||||
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
|
||||
| GET | `/api/v1/calculateregion/` | 计算区域 | network (query), time_index (query) | - |
|
||||
| GET | `/api/v1/calculateservicearea/` | 计算服务区 | network (query), time_index (query) | - |
|
||||
| GET | `/api/v1/calculateservicearea/` | 计算服务区(返回全部时间步) | network (query) | - |
|
||||
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
|
||||
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
|
||||
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
|
||||
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
|
||||
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | network (query) | - |
|
||||
| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
||||
| POST | `/api/v1/generateregion/` | 生成区域分区 | 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/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
|
||||
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
|
||||
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
|
||||
| GET | `/api/v1/getallregions/` | 获取所有区域 | network (query) | - |
|
||||
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
|
||||
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
|
||||
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
|
||||
@@ -54,3 +50,20 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setvirtualdistrict/` | 设置虚拟分区属性 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getregionschema` | 返回区域(Region)数据模型的字段定义 |
|
||||
| `GET /getregion/` | 查询单个区域的属性 |
|
||||
| `GET /getalldistrictmeteringareas/` | 获取所有 DMA(独立计量区)列表 |
|
||||
| `GET /getallserviceareas/` | 获取所有服务区列表 |
|
||||
| `POST /addregion/` | 新增区域(需提供名称和节点/管道列表) |
|
||||
| `POST /adddistrictmeteringarea/` | 新增 DMA 分区 |
|
||||
| `POST /addvirtualdistrict/` | 新增虚拟分区 |
|
||||
| `POST /addservicearea/` | 新增服务区 |
|
||||
| `GET /calculatedistrictmeteringareafornodes/` | 为指定节点集合计算其所属 DMA |
|
||||
| `GET /calculatedistrictmeteringareaforregion/` | 为指定区域内的所有节点计算 DMA 归属 |
|
||||
| `GET /calculatedistrictmeteringareafornetwork/` | 为整个管网的所有节点计算 DMA 归属 |
|
||||
| `GET /calculateservicearea/` | 计算服务区,返回全部时间步结果 |
|
||||
|
||||
@@ -33,3 +33,16 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setreservoiry/` | 设置水库Y坐标 | network (query), reservoir (query), y (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getreservoirschema` | 返回水库(Reservoir)数据模型的所有字段定义 |
|
||||
| `GET /getreservoirproperties/` | 查询单个水库的所有属性(水头、模式、坐标等) |
|
||||
| `GET /getallreservoirproperties/` | 批量获取管网中所有水库的属性列表 |
|
||||
| `GET /getreservoirhead/` | 查询水库水头(即水库水位高度) |
|
||||
| `GET /getreservoirpattern/` | 查询水库关联的时间模式名称 |
|
||||
| `POST /addreservoir/` | 向管网添加一个新水库,需提供ID、坐标和水头 |
|
||||
| `POST /deletereservoir/` | 从管网删除指定水库 |
|
||||
| `POST /setreservoir*/` | 设置水库某个具体属性(水头、模式、坐标等) |
|
||||
|
||||
@@ -21,3 +21,12 @@ version: 3.0.0
|
||||
| POST | `/api/v1/settag/` | 设置标签 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /gettagschema/` | 返回标签(Tag)数据模型的字段定义 |
|
||||
| `GET /gettag/` | 查询单个元素绑定的标签信息 |
|
||||
| `GET /gettags/` | 获取管网中所有标签列表 |
|
||||
| `POST /settag/` | 为管网元素设置/更新标签(支持自定义键值对属性) |
|
||||
|
||||
@@ -45,3 +45,17 @@ version: 3.0.0
|
||||
| POST | `/api/v1/settanky/` | 设置水箱Y坐标 | network (query), tank (query), y (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /gettankschema` | 返回水箱(Tank)数据模型的所有字段定义 |
|
||||
| `GET /gettankproperties/` | 查询单个水箱的所有属性(标高、直径、初始/最大/最小水位等) |
|
||||
| `GET /getalltankproperties/` | 批量获取管网中所有水箱的属性列表 |
|
||||
| `GET /gettankelevation/` | 查询水箱底部标高 |
|
||||
| `GET /gettankdiameter/` | 查询水箱直径 |
|
||||
| `GET /gettankinitlevel/` | 查询水箱初始水位 |
|
||||
| `POST /addtank/` | 向管网添加一个新水箱,需提供ID、坐标、标高和水位参数 |
|
||||
| `POST /deletetank/` | 从管网删除指定水箱 |
|
||||
| `POST /settank*/` | 设置水箱某个具体属性(坐标、标高、直径、水位等) |
|
||||
|
||||
@@ -34,3 +34,17 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setvalvetype/` | 设置阀门类型 | network (query), valve (query), type (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getvalveschema` | 返回阀门(Valve)数据模型的所有字段定义 |
|
||||
| `GET /getvalveproperties/` | 查询单个阀门的所有属性(管径、类型、设定值等) |
|
||||
| `GET /getallvalveproperties/` | 批量获取管网中所有阀门的属性列表 |
|
||||
| `GET /getvalvediameter/` | 查询阀门管径 |
|
||||
| `GET /getvalvetype/` | 查询阀门类型(PRV/PSV/TCV/FCV/PBV/GPV) |
|
||||
| `GET /getvalvesetting/` | 查询阀门设定值(压力设定或流量设定) |
|
||||
| `POST /addvalve/` | 向管网添加一个新阀门,需提供ID、起终节点、管径和类型 |
|
||||
| `POST /deletevalve/` | 从管网删除指定阀门 |
|
||||
| `POST /setvalve*/` | 设置阀门某个具体属性(类型、设定值、管径等) |
|
||||
|
||||
@@ -21,3 +21,12 @@ version: 3.0.0
|
||||
| POST | `/api/v1/setextensiondata/` | 设置扩展数据 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getallextensiondatakeys/` | 获取当前管网中所有已存储的自定义扩展数据的键名列表 |
|
||||
| `GET /getallextensiondata/` | 获取当前管网所有自定义扩展数据(键值对集合) |
|
||||
| `GET /getextensiondata/` | 按 key 查询指定的自定义扩展数据值 |
|
||||
| `POST /setextensiondata/` | 设置或更新一个自定义扩展数据键值对(可用于存储任意业务自定义信息) |
|
||||
|
||||
@@ -23,3 +23,14 @@ version: 3.0.0
|
||||
| POST | `/api/v1/test_dict/` | 测试字典处理 | data (body) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getallburstlocateresults/` | 获取当前管网所有历史爆管定位分析结果(旧版接口) |
|
||||
| `GET /getallsensorplacements/` | 获取当前管网所有传感器布置方案的结果列表 |
|
||||
| `GET /getsimulationresult/` | 获取最近一次水力模拟结果(旧版接口) |
|
||||
| `GET /getrealtimedata/` | 获取管网实时监测数据(旧版接口) |
|
||||
| `GET /getjson/` | 返回示例 JSON 数据结构,用于开发调试 |
|
||||
| `POST /test_dict/` | 测试字典类型请求体的接口,用于开发调试 |
|
||||
|
||||
@@ -21,3 +21,12 @@ version: 3.0.0
|
||||
| GET | `/api/v1/scheme-list` | 获取方案列表 | - | - |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /burst-locate-result` | 获取所有爆管定位事件的历史结果列表(新版REST接口) |
|
||||
| `GET /burst-locate-result/{burst_incident}` | 查询指定爆管事件(burst_incident ID)的详细定位结果 |
|
||||
| `GET /scada-info` | 获取当前项目关联的 SCADA 设备和监测点信息汇总 |
|
||||
| `GET /scheme-list` | 获取当前项目中所有可用的水力计算方案列表 |
|
||||
|
||||
@@ -15,34 +15,41 @@ version: 3.0.0
|
||||
## 接口目录
|
||||
| Method | Path | Summary | Required Params | Optional Params |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/closeproject/` | 关闭项目 | network (query) | - |
|
||||
| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | - | - |
|
||||
| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | - | - |
|
||||
| POST | `/api/v1/copyproject/` | 复制项目 | source (query), target (query) | - |
|
||||
| GET | `/api/v1/listprojects/` | 获取项目列表 | - | - |
|
||||
| GET | `/api/v1/project_info/` | 获取项目信息 | network (query) | - |
|
||||
| GET | `/api/v1/haveproject/` | 检查项目是否存在 | network (query) | - |
|
||||
| POST | `/api/v1/createproject/` | 创建新项目 | network (query) | - |
|
||||
| POST | `/api/v1/deleteproject/` | 删除项目 | network (query) | - |
|
||||
| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | name (query) | - |
|
||||
| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | name (query) | - |
|
||||
| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | network (query), inp (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/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - |
|
||||
| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - |
|
||||
| POST | `/api/v1/copyproject/` | 复制项目 | source (query), target (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/openproject/` | 打开项目 | network (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) | - |
|
||||
| POST | `/api/v1/unlockproject/` | 解锁项目 | network (query) | - |
|
||||
| POST | `/api/v1/closeproject/` | 关闭项目 | network (query) | - |
|
||||
| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | network (query) | - |
|
||||
| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | network (query) | - |
|
||||
| POST | `/api/v1/lockproject/` | 锁定项目 | network (query) | - |
|
||||
| POST | `/api/v1/unlockproject/` | 解锁项目 | network (query) | - |
|
||||
| POST | `/api/v1/importinp/` | 导入 INP 文件内容 | network (query) | - |
|
||||
| GET | `/api/v1/exportinp/` | 导出项目为 ChangeSet | network (query), version (query) | - |
|
||||
| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | network (query), inp (query) | - |
|
||||
| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | network (query), inp (query) | - |
|
||||
| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | afile (body), name (query) | - |
|
||||
| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | afile (body), name (query) | - |
|
||||
| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | name (query) | - |
|
||||
| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | - | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /listprojects/` | 获取服务器上所有可用的供水管网项目名称列表 |
|
||||
| `GET /project_info/` | 从数据库获取项目的详细信息,包括地图范围等配置 |
|
||||
| `POST /createproject/` | 创建一个新的供水管网项目;若已存在可能覆盖或报错 |
|
||||
| `POST /deleteproject/` | 永久删除指定项目,此操作不可恢复 |
|
||||
| `POST /openproject/` | 将指定项目加载到内存并初始化数据库连接池 |
|
||||
| `POST /closeproject/` | 将指定项目从内存中卸载,释放相关资源 |
|
||||
| `POST /lockproject/` | 锁定项目以防止并发修改 |
|
||||
| `POST /unlockproject/` | 释放对项目的锁定 |
|
||||
| `POST /importinp/` | 将 INP 格式文本内容导入到指定项目 |
|
||||
| `GET /exportinp/` | 导出项目变更集(ChangeSet),含顶点、SCADA元素、DMA、SA、VD等 |
|
||||
| `GET /convertv3tov2/` | 将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式 |
|
||||
|
||||
@@ -20,3 +20,11 @@ version: 3.0.0
|
||||
| GET | `/api/v1/getschemeschema/` | 获取方案模式 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getschemeschema/` | 返回方案(Scheme)数据模型的字段定义 |
|
||||
| `GET /getallschemes/` | 获取当前管网下所有已保存方案的列表 |
|
||||
| `GET /getscheme/` | 查询指定方案名称(schema_name)的详细属性和配置 |
|
||||
|
||||
@@ -35,3 +35,20 @@ version: 3.0.0
|
||||
| POST | `/api/v1/undo/` | 撤销操作 | network (query) | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /getsnapshots/` | 列出当前管网所有已保存的快照标签(tag)列表 |
|
||||
| `GET /havesnapshot/` | 检查指定 tag 的快照是否存在 |
|
||||
| `POST /takesnapshot/` | 保存当前管网状态为一个快照,tag 为快照名称 |
|
||||
| `POST /picksnapshot/` | 将管网状态回滚到指定快照,discard=true 时丢弃当前未保存修改 |
|
||||
| `GET /getcurrentoperationid/` | 获取当前管网的操作ID(用于追踪操作历史) |
|
||||
| `POST /undo/` | 撤销对管网的最近一次操作 |
|
||||
| `POST /redo/` | 重做上一次被撤销的操作 |
|
||||
| `POST /batch/` | 批量执行多个管网操作命令(原子事务) |
|
||||
| `POST /compressedbatch/` | 执行压缩格式的批量命令(减少网络传输量) |
|
||||
| `GET /syncwithserver/` | 将客户端的操作与服务端管网状态同步 |
|
||||
| `POST /pickoperation/` | 切换到指定 operation ID 的历史操作状态 |
|
||||
| `POST /takesnapshotforcurrentoperation` | 为当前 operation 创建快照(保存当前操作节点状态) |
|
||||
|
||||
@@ -22,3 +22,13 @@ 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, 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,3 +27,18 @@ version: 3.0.0
|
||||
| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | node_result_list (body), link_result_list (body), result_start_time (query) | - |
|
||||
|
||||
- 覆盖方法:`DELETE, GET, PATCH, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `POST /realtime/links/batch` | 批量将管道(link)模拟结果写入实时数据表(TimescaleDB) |
|
||||
| `GET /realtime/links` | 查询指定时间范围内的实时管道模拟数据 |
|
||||
| `DELETE /realtime/links` | 删除指定时间范围内的实时管道数据 |
|
||||
| `PATCH /realtime/links/{link_id}/field` | 修改某条管道在特定时刻的某个字段值 |
|
||||
| `POST /realtime/nodes/batch` | 批量将节点模拟结果写入实时数据表 |
|
||||
| `GET /realtime/nodes` | 查询指定时间范围内的实时节点模拟数据 |
|
||||
| `DELETE /realtime/nodes` | 删除指定时间范围内的实时节点数据 |
|
||||
| `POST /realtime/simulation/store` | 一次性存储一次完整模拟运行的节点和管道结果(含起始时间) |
|
||||
| `GET /realtime/query/by-time-property` | 按查询时间点和属性名(如 pressure/flow)查询全网实时模拟值 |
|
||||
| `GET /realtime/query/by-id-time` | 按单个元素ID、类型和时间点查询其实时模拟值 |
|
||||
|
||||
@@ -29,3 +29,20 @@ 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) | - |
|
||||
|
||||
- 覆盖方法:`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` | 按方案、时间点和属性名查询全网在指定方案下的模拟值 |
|
||||
|
||||
@@ -20,3 +20,11 @@ version: 3.0.0
|
||||
| GET | `/api/v1/audit/logs/my` | 查询我的审计日志 | - | action (query), start_time (query), end_time (query), skip (query), limit (query) |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /audit/logs` | 查询系统审计日志(仅管理员),支持按用户ID、项目ID、操作类型、资源类型、时间范围过滤;支持分页(skip/limit) |
|
||||
| `GET /audit/logs/count` | 获取满足过滤条件的审计日志总条数,用于分页显示 |
|
||||
| `GET /audit/logs/my` | 查询当前登录用户自己的操作日志,支持按操作类型和时间范围过滤 |
|
||||
|
||||
@@ -21,3 +21,12 @@ version: 3.0.0
|
||||
| GET | `/api/v1/queryredis/` | 查询缓存键列表 | - | - |
|
||||
|
||||
- 覆盖方法:`GET, POST`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /queryredis/` | 查询当前 Redis 中所有缓存键列表,用于检查缓存状态 |
|
||||
| `POST /clearrediskey/` | 删除指定单个键的缓存(精确匹配) |
|
||||
| `POST /clearrediskeys/` | 批量删除多个匹配的缓存键(支持模式匹配) |
|
||||
| `POST /clearallredis/` | 清除 Redis 中所有缓存数据(慎用,会影响所有会话) |
|
||||
|
||||
@@ -20,3 +20,11 @@ version: 3.0.0
|
||||
| GET | `/api/v1/meta/projects` | 列出用户项目 | - | - |
|
||||
|
||||
- 覆盖方法:`GET`
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /meta/db/health` | 检查数据库(PostgreSQL/TimescaleDB)连接健康状态,返回 ok/error |
|
||||
| `GET /meta/project` | 获取当前用户当前项目的元数据(名称、创建时间、所有者等) |
|
||||
| `GET /meta/projects` | 列出当前登录用户有权限访问的所有项目信息 |
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||
|
||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -21,6 +24,11 @@ export default tool({
|
||||
.describe("Query arguments object."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
// 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
|
||||
method: "POST",
|
||||
@@ -29,7 +37,7 @@ export default tool({
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
reason: args.reason,
|
||||
path: args.path,
|
||||
method: args.method,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||
|
||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -19,6 +22,11 @@ export default tool({
|
||||
.describe("Optional maximum number of top-level items or fields to return."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -26,7 +34,7 @@ export default tool({
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
result_ref: args.result_ref,
|
||||
max_items: args.max_items,
|
||||
}),
|
||||
|
||||
@@ -80,7 +80,7 @@ export default tool({
|
||||
if (args.action === "add") {
|
||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||
content: args.content ?? "",
|
||||
sessionId: context.sessionID,
|
||||
sessionId: sessionContext.clientSessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
@@ -105,7 +105,7 @@ export default tool({
|
||||
if (args.action === "replace") {
|
||||
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
||||
content: args.content ?? "",
|
||||
sessionId: context.sessionID,
|
||||
sessionId: sessionContext.clientSessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
export default tool({
|
||||
description: "在前端地图上对 junctions 图层应用分区渲染。",
|
||||
description:
|
||||
"在前端地图上对 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 id,value 是 area id。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe("Why this junction rendering action is needed for the user request."),
|
||||
render_ref: tool.schema
|
||||
.string()
|
||||
.describe("Reference to a stored junction rendering payload resolved by the Agent service."),
|
||||
.describe(
|
||||
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } };node_area_map 必填,area_ids / area_colors 可选。",
|
||||
),
|
||||
},
|
||||
async execute() {
|
||||
// 工具参数里只需要 render_ref;浏览器端会再用该引用回读完整 payload.data 并完成渲染。
|
||||
return "已在地图上应用节点分区渲染。";
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||
|
||||
const internalBaseUrl =
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -22,6 +25,11 @@ export default tool({
|
||||
.describe("Optional maximum number of hits to return."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -31,7 +39,7 @@ export default tool({
|
||||
body: JSON.stringify({
|
||||
max_results: args.max_results,
|
||||
query: args.query,
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||
|
||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"把本地 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) {
|
||||
await initializePromise;
|
||||
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
file_path: args.file_path,
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1 AS bun-bin
|
||||
FROM oven/bun:canary-slim AS bun-bin
|
||||
|
||||
FROM smanx/opencode:latest AS base
|
||||
USER root
|
||||
|
||||
@@ -30,30 +30,41 @@ TJWaterAgent/
|
||||
1. 启动 HTTP 服务。
|
||||
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
|
||||
3. 管理前端 `session_id -> opencode sessionId` 的映射。
|
||||
4. 保存并传递用户的鉴权信息(accessToken, actorKey)、项目上下文(projectId, projectKey)以及 traceId。
|
||||
4. 保存并传递用户 `Authorization`、`x-user-id`、`x-project-id`、`x-trace-id`。
|
||||
5. 把 opencode 输出适配成前端需要的 SSE 事件。
|
||||
6. 为 `.opencode/tools/dynamic_http_call.ts` 等工具提供内部回调接口。
|
||||
6. 为 `.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。
|
||||
7. 代理调用真实 TJWater 后端 API。
|
||||
|
||||
主要目录结构说明:
|
||||
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `src/routes/` | 路由定义,如 `chat.ts` 处理聊天请求 |
|
||||
| `src/chat/` | 会话桥接逻辑,负责 Express 与 opencode 会话的同步 |
|
||||
| `src/runtime/` | opencode 运行时管理,控制 SDK 的启动与状态检查 |
|
||||
| `src/session/` | 会话注册中心和工具执行时的上下文关联存储 |
|
||||
| `src/tools/` | 内部工具执行器,如 `dynamicHttpExecutor` 负责构造真实的业务请求 |
|
||||
| `src/learning/` | Agent 学习状态协调器,负责处理记忆累积和知识更新 |
|
||||
| `src/audit/` | 审计记录,包含学习过程和 LLM 请求审计 |
|
||||
|
||||
当前 Agent API 的主接口:
|
||||
当前 Agent API 的主入口:
|
||||
|
||||
```text
|
||||
POST /api/v1/chat/message
|
||||
POST /api/v1/agent/chat/stream
|
||||
```
|
||||
|
||||
该接口返回 SSE 格式数据。
|
||||
该接口返回 SSE,事件包括:
|
||||
|
||||
| event | 用途 |
|
||||
| --- | --- |
|
||||
| `progress` | 前端过程可视化,展示规划、工具调用和完成状态 |
|
||||
| `token` | 最终回答文本流 |
|
||||
| `tool_call` | 前端地图/面板/图表动作 |
|
||||
| `done` | 当前轮完成 |
|
||||
| `error` | 当前轮失败 |
|
||||
|
||||
主要目录和文件:
|
||||
|
||||
```text
|
||||
src/
|
||||
server.ts
|
||||
config.ts
|
||||
runtime/
|
||||
session/
|
||||
chat/
|
||||
routes/
|
||||
tools/
|
||||
```
|
||||
|
||||
其中 `src/` 是业务服务层,不直接放 opencode skill 或 agent prompt。
|
||||
|
||||
## `.opencode/` 的职责
|
||||
|
||||
@@ -62,52 +73,40 @@ POST /api/v1/chat/message
|
||||
### agents
|
||||
|
||||
```text
|
||||
.opencode/agents/instruction.md
|
||||
.opencode/agents/agent.md
|
||||
```
|
||||
|
||||
这里定义默认 agent 的角色、行为规则、模型配置和工具使用策略。目前的 `default_agent` 配置为 `instruction`。
|
||||
这里定义默认 agent 的角色、行为规则、模型配置和工具使用策略。
|
||||
|
||||
当前项目已将 always-loaded instructions 收敛到 `agent.md`,`opencode.json` 不再额外配置 `instructions` 数组。
|
||||
|
||||
### tools
|
||||
|
||||
```text
|
||||
.opencode/tools/
|
||||
dynamic_http_call.ts # 动态 HTTP 调用,通过回调 host 服务获取鉴权
|
||||
fetch_result_ref.ts # 获取结果引用
|
||||
locate_features.ts # 地图定位
|
||||
memory_manager.ts # 记忆管理
|
||||
skill_manager.ts # 技能管理
|
||||
show_chart.ts # 图表展示
|
||||
...
|
||||
dynamic_http_call.ts
|
||||
locate_features.ts
|
||||
view_history.ts
|
||||
view_scada.ts
|
||||
show_chart.ts
|
||||
```
|
||||
|
||||
这些是 opencode 可以调用的自定义工具。
|
||||
|
||||
`dynamic_http_call.ts` 不直接保存用户 token,也不直接访问后端。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 TJWater 后端。
|
||||
|
||||
前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
|
||||
|
||||
### skills
|
||||
|
||||
```text
|
||||
.opencode/skills/
|
||||
SKILL.md # 根索引
|
||||
analytics/ # 分析类技能(SCADA、水力模拟、爆管分析等)
|
||||
business/ # 业务类技能(模型配置、资产管理、项目环境等)
|
||||
data/ # 数据类技能(时序数据访问)
|
||||
platform/ # 平台类技能(审计、缓存、元数据等)
|
||||
```
|
||||
|
||||
这里保存 TJWater 技能树,保持树结构以符合渐进式披露设计。
|
||||
|
||||
## 启动与开发
|
||||
|
||||
项目使用 `bun` 作为包管理器和运行环境。
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 启动开发环境(带 watch 模式)
|
||||
bun run dev
|
||||
|
||||
# 类型检查
|
||||
bun run check
|
||||
.opencode/skills/tjwater-skills-root-index/
|
||||
SKILL.md
|
||||
ai/
|
||||
analytics/
|
||||
business/
|
||||
data/
|
||||
platform/
|
||||
```
|
||||
|
||||
这里保存 TJWater 技能树,并保持树结构,符合渐进式披露设计。
|
||||
@@ -151,7 +150,12 @@ typescript
|
||||
|
||||
## 启动与部署
|
||||
|
||||
默认部署不需要全局安装 `opencode` CLI。服务会通过 `@opencode-ai/sdk` 的 embedded 模式启动 opencode server。
|
||||
支持两种 opencode 接入方式:
|
||||
|
||||
1. Embedded 模式:服务通过 `@opencode-ai/sdk` 调用 `createOpencode`,启动本地 `opencode` CLI 子进程并自动创建 client。
|
||||
2. Client 模式:服务通过 `createOpencodeClient` 直接连接一个已经存在的 opencode server。
|
||||
|
||||
因此,只有 Embedded 模式要求运行环境已安装 `opencode` CLI;Client 模式不依赖本地 CLI。
|
||||
|
||||
根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
|
||||
|
||||
@@ -176,9 +180,21 @@ opencode.json
|
||||
|
||||
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev`。
|
||||
|
||||
本地开发可以在项目根目录的 `.local.env` 中配置环境变量:
|
||||
本地开发可以在项目根目录的 `.local.env` 中配置环境变量。
|
||||
|
||||
Embedded 模式示例:
|
||||
|
||||
```bash
|
||||
OPENCODE_MODE=embedded
|
||||
DEEPSEEK_API_KEY=sk-xxx
|
||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
Client 模式示例:
|
||||
|
||||
```bash
|
||||
OPENCODE_MODE=client
|
||||
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
|
||||
DEEPSEEK_API_KEY=sk-xxx
|
||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
||||
```
|
||||
@@ -232,6 +248,7 @@ docker compose down
|
||||
| `bun run start` | 直接运行 `src/server.ts` |
|
||||
| `bun run start:prod` | 先类型检查再启动 |
|
||||
| `bun run install:opencode` | 手动安装 `.opencode` 依赖 |
|
||||
| `bun run pipeline:trigger` | 通过重建并强推 annotated `latest` tag 触发 Gitea CI/CD,只发布/覆盖 `latest` 镜像 |
|
||||
|
||||
### 模型与 API 配置
|
||||
|
||||
@@ -288,5 +305,8 @@ bun run start
|
||||
如果需要连接外部独立运行的 opencode server,可以配置:
|
||||
|
||||
```bash
|
||||
OPENCODE_BASE_URL=http://127.0.0.1:4096
|
||||
OPENCODE_MODE=client
|
||||
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
|
||||
```
|
||||
|
||||
配置后,`TJWaterAgent` 会连接该外部 opencode server,而不是自行启动 embedded opencode server。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.7.2",
|
||||
"bun-types": "^1.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
@@ -56,6 +57,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
AGENT_INTERNAL_TOKEN: ${AGENT_INTERNAL_TOKEN:-}
|
||||
OPENCODE_BASE_URL: ${OPENCODE_BASE_URL:-http://127.0.0.1:4096}
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||
TJWATER_API_BASE_URL: ${TJWATER_API_BASE_URL:-http://127.0.0.1:8000}
|
||||
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"
|
||||
restart: unless-stopped
|
||||
+3
-2
@@ -8,10 +8,10 @@
|
||||
"install:opencode": "bun install --cwd .opencode",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"typecheck:opencode": "bun run --cwd .opencode typecheck",
|
||||
"dev": "bun run typecheck:opencode && bun --watch src/server.ts",
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "bun run check",
|
||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||
"push": "git add . && git commit -m \"chore: update\" && git push origin main",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
|
||||
"start": "bun src/server.ts",
|
||||
"start:prod": "bun run check && bun src/server.ts"
|
||||
},
|
||||
@@ -28,6 +28,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.7.2",
|
||||
"bun-types": "^1.3.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh origin latest"
|
||||
echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.19.1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
resolve_default_remote() {
|
||||
if git remote get-url gitea >/dev/null 2>&1; then
|
||||
echo "gitea"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
echo "origin"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
REMOTE="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "[ERROR] Current directory is not a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
if ! REMOTE="$(resolve_default_remote)"; then
|
||||
echo "[ERROR] No default remote found. Expected 'gitea' or 'origin'."
|
||||
echo "Available remotes:"
|
||||
git remote -v || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||
echo "[ERROR] Remote '$REMOTE' does not exist."
|
||||
echo "Available remotes:"
|
||||
git remote -v
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git rev-parse --short HEAD)"
|
||||
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
|
||||
|
||||
echo "[INFO] HEAD: ${HEAD_SHA}"
|
||||
echo "[INFO] Recreate annotated tag '${TAG}'"
|
||||
git tag -fa "$TAG" -m "$MESSAGE"
|
||||
|
||||
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
|
||||
git push "$REMOTE" "refs/tags/${TAG}" --force
|
||||
|
||||
echo "[INFO] Verify remote tag reference"
|
||||
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
|
||||
|
||||
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
|
||||
+100
-202
@@ -2,10 +2,25 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type SessionBinding, type SessionContext, SessionRegistry } from "../session/registry.js";
|
||||
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
||||
import {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../session/toolContextStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
|
||||
export type SessionBinding = {
|
||||
clientSessionId: string;
|
||||
sessionId: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export type SessionContext = {
|
||||
clientSessionId: string;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type ChatRequestContext = SessionContext & {
|
||||
actorKey: string;
|
||||
projectKey: string;
|
||||
@@ -13,15 +28,12 @@ export type ChatRequestContext = SessionContext & {
|
||||
};
|
||||
|
||||
export class ChatSessionBridge {
|
||||
// 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用。
|
||||
private readonly sessionContexts = new Map<string, ChatRequestContext>();
|
||||
private readonly sessionTitles = new Map<string, string>();
|
||||
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。
|
||||
private readonly activeRuntimeSessions = new Map<string, string>();
|
||||
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
|
||||
private readonly toolContextStore = new ToolSessionContextStore();
|
||||
|
||||
constructor(
|
||||
private readonly registry: SessionRegistry,
|
||||
private readonly runtime: OpencodeRuntimeAdapter,
|
||||
) {}
|
||||
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
|
||||
|
||||
async resolve(context: {
|
||||
clientSessionId?: string;
|
||||
@@ -34,51 +46,22 @@ export class ChatSessionBridge {
|
||||
requestContext: ChatRequestContext;
|
||||
created: boolean;
|
||||
}> {
|
||||
const requestContext: ChatRequestContext = {
|
||||
clientSessionId:
|
||||
context.clientSessionId?.trim() || `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();
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
const requestContext = this.buildRequestContext(context);
|
||||
await this.abortActiveRuntime(requestContext.clientSessionId);
|
||||
|
||||
const session = await this.runtime.createSession(requestContext.clientSessionId);
|
||||
const binding = this.registry.upsert(requestContext, session.id);
|
||||
this.sessionContexts.set(binding.sessionId, requestContext);
|
||||
const binding: SessionBinding = {
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: session.id,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
const sessionScopeKey = buildToolSessionScopeKey(
|
||||
requestContext.actorKey,
|
||||
requestContext.projectKey,
|
||||
requestContext.clientSessionId,
|
||||
);
|
||||
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
|
||||
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
|
||||
await this.toolContextStore.write({
|
||||
actorKey: requestContext.actorKey,
|
||||
allowLearningWrite: true,
|
||||
@@ -86,99 +69,70 @@ export class ChatSessionBridge {
|
||||
learningMode: "interactive",
|
||||
projectId: requestContext.projectId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: binding.sessionId,
|
||||
sessionId: session.id,
|
||||
sessionScopeKey,
|
||||
traceId: requestContext.traceId,
|
||||
});
|
||||
|
||||
return { binding, requestContext, created: true };
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.registry.count();
|
||||
return this.activeRuntimeSessions.size;
|
||||
}
|
||||
|
||||
getSessionContext(sessionId: string) {
|
||||
return this.sessionContexts.get(sessionId) ?? null;
|
||||
createClientSessionId() {
|
||||
return `agent-${randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
getSessionTitle(sessionId: string) {
|
||||
return this.sessionTitles.get(sessionId);
|
||||
}
|
||||
|
||||
setSessionTitle(sessionId: string, title: string) {
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
this.sessionTitles.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
cloneSessionTitle(sourceSessionId: string, targetSessionId: string) {
|
||||
const existingTitle = this.sessionTitles.get(sourceSessionId);
|
||||
if (!existingTitle) {
|
||||
return;
|
||||
}
|
||||
this.sessionTitles.set(targetSessionId, existingTitle);
|
||||
getActiveSensitiveContext(sessionScopeKey: string) {
|
||||
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
|
||||
}
|
||||
|
||||
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) {
|
||||
const sessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (!sessionId) {
|
||||
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.abortSession(binding.sessionId);
|
||||
return binding;
|
||||
await this.abortActiveRuntime(clientSessionId);
|
||||
return {
|
||||
clientSessionId,
|
||||
sessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async fork(context: {
|
||||
async releaseRuntimeSession(clientSessionId: string, sessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (activeSessionId === sessionId) {
|
||||
this.activeRuntimeSessions.delete(clientSessionId);
|
||||
}
|
||||
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||
await this.toolContextStore.remove(sessionId).catch((error) => {
|
||||
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context");
|
||||
});
|
||||
await this.runtime.abortSession(sessionId).catch((error) => {
|
||||
logger.debug({ sessionId, err: error }, "failed to cleanup runtime session");
|
||||
});
|
||||
}
|
||||
|
||||
private buildRequestContext(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)}`,
|
||||
}): ChatRequestContext {
|
||||
return {
|
||||
clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(),
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
projectId: context.projectId,
|
||||
@@ -186,95 +140,39 @@ export class ChatSessionBridge {
|
||||
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");
|
||||
});
|
||||
private async abortActiveRuntime(clientSessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
this.activeRuntimeSessions.delete(clientSessionId);
|
||||
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||
await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
|
||||
await this.runtime.abortSession(activeSessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ clientSessionId, sessionId: activeSessionId, err: error },
|
||||
"failed to abort previous active runtime session",
|
||||
);
|
||||
});
|
||||
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ clientSessionId, sessionId: activeSessionId, err: error },
|
||||
"failed while waiting for previous runtime session to become idle",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const findScopeKey = (
|
||||
contexts: Map<string, ChatRequestContext>,
|
||||
clientSessionId: string,
|
||||
) => {
|
||||
for (const [scopeKey, context] of contexts.entries()) {
|
||||
if (context.clientSessionId === clientSessionId) {
|
||||
return scopeKey;
|
||||
}
|
||||
}
|
||||
return clientSessionId;
|
||||
};
|
||||
|
||||
+120
-79
@@ -4,86 +4,127 @@ import { z } from "zod";
|
||||
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
|
||||
dotenv.config({ path: ".local.env", override: false });
|
||||
|
||||
const optionalString = () =>
|
||||
z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
},
|
||||
z.string().optional(),
|
||||
);
|
||||
|
||||
// 统一在启动时解析环境变量,避免业务代码里散落字符串默认值。
|
||||
const envSchema = z.object({
|
||||
// 运行环境标识,如 development / production。
|
||||
NODE_ENV: z.string().default("development"),
|
||||
// HTTP 服务监听端口。
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
// HTTP 服务监听地址。
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
// Pino 日志级别。
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
// LLM 工具/技能调用审计日志路径。
|
||||
LLM_REQUEST_AUDIT_LOG_PATH: z
|
||||
.string()
|
||||
.default("./logs/llm-request-audit.log"),
|
||||
// 外部 opencode server 回调本服务内部工具桥时使用的共享鉴权 token;可暂时留空。
|
||||
AGENT_INTERNAL_TOKEN: z.string().default(""),
|
||||
// 外部 opencode server 的基础地址。
|
||||
OPENCODE_BASE_URL: z.string().url(),
|
||||
// chat session 在本地注册表中的保活时长(秒)。
|
||||
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
||||
// 提供给本地 opencode tools 读取的会话上下文目录。
|
||||
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
||||
// TJWater 后端 API 的基础地址。
|
||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
||||
// 后端结果在直接内联返回给模型前允许的最大字节数。
|
||||
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
|
||||
// 生成结果 preview 时最多抽样的条目数。
|
||||
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
||||
// memory 持久化存储目录。
|
||||
MEMORY_STORAGE_DIR: z.string().default("./data/memory"),
|
||||
// 持久化文件写入前保留历史版本的目录。
|
||||
PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"),
|
||||
// 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
|
||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||
// session transcript 持久化目录。
|
||||
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
||||
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(120),
|
||||
// session_search 工具默认返回的最大命中数。
|
||||
SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8),
|
||||
// session_search 查询文本最大长度。
|
||||
SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240),
|
||||
// learning review 会话状态目录。
|
||||
LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"),
|
||||
// learning audit 日志路径。
|
||||
LEARNING_AUDIT_LOG_PATH: z
|
||||
.string()
|
||||
.default("./logs/learning-audit.log"),
|
||||
// learning gate 的最小 turn 冷却间隔;这是运行时节流,不参与内容判断。
|
||||
LEARNING_GATE_TURN_COOLDOWN: z.coerce.number().int().positive().default(2),
|
||||
// gate 结果被提升为 review 前的最低置信度。
|
||||
LEARNING_GATE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.65),
|
||||
// review prompt 最多携带多少轮最近 transcript。
|
||||
LEARNING_REVIEW_MAX_RECENT_TURNS: z.coerce.number().int().positive().default(8),
|
||||
// review proposal 的最低置信度阈值。
|
||||
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||
// result_ref 持久化存储目录。
|
||||
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
|
||||
// result_ref 保留时长(小时)。
|
||||
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
||||
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
||||
RESULT_REF_CLEANUP_INTERVAL_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(3600000),
|
||||
// fetch_result_ref 默认最多返回的顶层项/字段数量。
|
||||
RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(50),
|
||||
});
|
||||
const envSchema = z
|
||||
.object({
|
||||
// 运行环境标识,如 development / production。
|
||||
NODE_ENV: z.string().default("development"),
|
||||
// HTTP 服务监听端口。
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
// HTTP 服务监听地址。
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
// Pino 日志级别。
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
// LLM 工具/技能调用审计日志路径。
|
||||
LLM_REQUEST_AUDIT_LOG_PATH: z
|
||||
.string()
|
||||
.default("./logs/llm-request-audit.log"),
|
||||
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
|
||||
AGENT_INTERNAL_TOKEN: optionalString(),
|
||||
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
|
||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
||||
// embedded opencode server 的监听地址。
|
||||
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
||||
// embedded opencode server 的监听端口。
|
||||
OPENCODE_PORT: z.coerce.number().int().positive().default(4096),
|
||||
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
||||
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// 默认使用的 opencode 模型标识。
|
||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||
// client 模式下,目标 opencode server 的基础地址。
|
||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
||||
// 提供给本地 opencode tools 读取的会话上下文目录。
|
||||
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
||||
// TJWater 后端 API 的基础地址。
|
||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
||||
// 后端结果在直接内联返回给模型前允许的最大字节数。
|
||||
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
|
||||
// 生成结果 preview 时最多抽样的条目数。
|
||||
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
||||
// memory 持久化存储目录。
|
||||
MEMORY_STORAGE_DIR: z.string().default("./data/memory"),
|
||||
// 持久化文件写入前保留历史版本的目录。
|
||||
PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"),
|
||||
// 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。
|
||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||
// session transcript 持久化目录。
|
||||
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
||||
// conversation metadata 持久化目录。
|
||||
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
|
||||
// conversation UI state 持久化目录。
|
||||
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
|
||||
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(120),
|
||||
// session_search 工具默认返回的最大命中数。
|
||||
SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8),
|
||||
// session_search 查询文本最大长度。
|
||||
SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240),
|
||||
// learning review 会话状态目录。
|
||||
LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"),
|
||||
// learning audit 日志路径。
|
||||
LEARNING_AUDIT_LOG_PATH: z
|
||||
.string()
|
||||
.default("./logs/learning-audit.log"),
|
||||
// learning gate 的最小 turn 冷却间隔;这是运行时节流,不参与内容判断。
|
||||
LEARNING_GATE_TURN_COOLDOWN: z.coerce.number().int().positive().default(2),
|
||||
// gate 结果被提升为 review 前的最低置信度。
|
||||
LEARNING_GATE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.65),
|
||||
// review prompt 最多携带多少轮最近 transcript。
|
||||
LEARNING_REVIEW_MAX_RECENT_TURNS: z.coerce.number().int().positive().default(8),
|
||||
// review proposal 的最低置信度阈值。
|
||||
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||
// result_ref 持久化存储目录。
|
||||
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
|
||||
// result_ref 保留时长(小时)。
|
||||
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
||||
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
||||
RESULT_REF_CLEANUP_INTERVAL_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(3600000),
|
||||
// fetch_result_ref 默认最多返回的顶层项/字段数量。
|
||||
RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(50),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["OPENCODE_CLIENT_BASE_URL"],
|
||||
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export const config: AppConfig = envSchema.parse(process.env);
|
||||
const normalizedEnv = {
|
||||
...process.env,
|
||||
OPENCODE_MODE:
|
||||
process.env.OPENCODE_MODE ??
|
||||
(process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"),
|
||||
};
|
||||
|
||||
export const config: AppConfig = envSchema.parse(normalizedEnv);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type ConversationStateRecord = {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited?: boolean;
|
||||
messages: unknown[];
|
||||
branchGroups: unknown[];
|
||||
};
|
||||
|
||||
export class ConversationStateStore {
|
||||
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async read(sessionScopeKey: string) {
|
||||
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey));
|
||||
}
|
||||
|
||||
async write(sessionScopeKey: string, state: ConversationStateRecord) {
|
||||
await atomicWriteJson(this.filePath(sessionScopeKey), state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async remove(sessionScopeKey: string) {
|
||||
await removeFileIfExists(this.filePath(sessionScopeKey));
|
||||
}
|
||||
|
||||
private filePath(sessionScopeKey: string) {
|
||||
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||
|
||||
export type ConversationStatus = "active" | "archived";
|
||||
|
||||
export type ConversationRecord = {
|
||||
sessionId: string;
|
||||
sessionScopeKey: string;
|
||||
actorKey: string;
|
||||
ownerUserId?: string;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
parentSessionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: ConversationStatus;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ConversationContext = {
|
||||
actorKey: string;
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
};
|
||||
|
||||
type EnsureConversationInput = ConversationContext & {
|
||||
sessionId?: string;
|
||||
parentSessionId?: string;
|
||||
};
|
||||
|
||||
export class ConversationStore {
|
||||
constructor(private readonly baseDir = config.CONVERSATION_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async ensure(input: EnsureConversationInput) {
|
||||
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
|
||||
const sessionScopeKey = toConversationScopeKey(
|
||||
input.actorKey,
|
||||
input.projectKey,
|
||||
sessionId,
|
||||
);
|
||||
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
|
||||
if (existing) {
|
||||
return { created: false, record: existing };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: ConversationRecord = {
|
||||
sessionId,
|
||||
sessionScopeKey,
|
||||
actorKey: input.actorKey,
|
||||
ownerUserId: input.userId?.trim(),
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
parentSessionId: normalizeSessionId(input.parentSessionId),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "active",
|
||||
};
|
||||
await atomicWriteJson(this.filePath(sessionScopeKey), record);
|
||||
return { created: true, record };
|
||||
}
|
||||
|
||||
async get(context: ConversationContext, sessionId: string) {
|
||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
return await readJsonFile<ConversationRecord>(
|
||||
this.filePath(
|
||||
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async touch(
|
||||
record: ConversationRecord,
|
||||
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
|
||||
) {
|
||||
const next: ConversationRecord = {
|
||||
...record,
|
||||
...normalizeConversationUpdates(updates),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteJson(this.filePath(record.sessionScopeKey), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async list(context: ConversationContext) {
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(
|
||||
files.map((file) => readJsonFile<ConversationRecord>(file)),
|
||||
);
|
||||
return records
|
||||
.filter((record): record is ConversationRecord => Boolean(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey,
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
async remove(record: ConversationRecord) {
|
||||
await removeFileIfExists(this.filePath(record.sessionScopeKey));
|
||||
}
|
||||
|
||||
private filePath(sessionScopeKey: string) {
|
||||
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
|
||||
|
||||
const normalizeSessionId = (value?: string) => {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized.slice(0, 128) : undefined;
|
||||
};
|
||||
|
||||
const normalizeConversationUpdates = (
|
||||
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
|
||||
) => {
|
||||
const 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;
|
||||
};
|
||||
+68
-1
@@ -85,6 +85,7 @@ export class SessionHistoryStore {
|
||||
userMessage,
|
||||
};
|
||||
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
||||
transcript.sessionId = context.sessionId;
|
||||
transcript.turns.push(record);
|
||||
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
|
||||
transcript.turns = transcript.turns.slice(
|
||||
@@ -108,6 +109,25 @@ export class SessionHistoryStore {
|
||||
return transcript.turns.slice(-Math.max(1, limit));
|
||||
}
|
||||
|
||||
async cloneThread(
|
||||
sourceContext: SessionHistoryContext,
|
||||
targetContext: SessionHistoryContext,
|
||||
keepMessageCount: number,
|
||||
) {
|
||||
const sourceTranscript = await this.readTranscript(sourceContext);
|
||||
const timestamp = new Date().toISOString();
|
||||
const nextTranscript: SessionTranscriptRecord = {
|
||||
actorKey: targetContext.actorKey,
|
||||
clientSessionId: targetContext.clientSessionId,
|
||||
projectKey: targetContext.projectKey,
|
||||
sessionId: targetContext.sessionId,
|
||||
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await atomicWriteJson(this.filePath(targetContext), nextTranscript);
|
||||
return nextTranscript;
|
||||
}
|
||||
|
||||
async search(
|
||||
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
|
||||
query: string,
|
||||
@@ -156,7 +176,38 @@ export class SessionHistoryStore {
|
||||
}
|
||||
|
||||
private async readTranscript(context: SessionHistoryContext) {
|
||||
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||
const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const clientSessionId = context.clientSessionId?.trim();
|
||||
if (!clientSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const matches: SessionTranscriptRecord[] = [];
|
||||
for (const file of files) {
|
||||
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
||||
if (!transcript) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
transcript.actorKey !== context.actorKey ||
|
||||
transcript.projectKey !== context.projectKey ||
|
||||
transcript.clientSessionId !== clientSessionId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
matches.push(transcript);
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
private filePath(context: SessionHistoryContext) {
|
||||
@@ -211,3 +262,19 @@ const buildSnippet = (text: string, query: string) => {
|
||||
const suffix = end < compact.length ? "..." : "";
|
||||
return `${prefix}${snippet}${suffix}`;
|
||||
};
|
||||
|
||||
const projectTurnsForFork = (
|
||||
turns: SessionTurnRecord[],
|
||||
keepMessageCount: number,
|
||||
): SessionTurnRecord[] => {
|
||||
if (keepMessageCount <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keepTurnCount = Math.floor(keepMessageCount / 2);
|
||||
if (keepTurnCount <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return turns.slice(0, keepTurnCount);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,10 @@ import { LearningStateStore } from "./stateStore.js";
|
||||
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { SkillStore } from "../skills/store.js";
|
||||
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
||||
import {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../session/toolContextStore.js";
|
||||
import {
|
||||
sanitizePersistentDocument,
|
||||
sanitizePersistentLine,
|
||||
@@ -150,6 +153,11 @@ export class LearningOrchestrator {
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: gateSession.id,
|
||||
sessionScopeKey: buildToolSessionScopeKey(
|
||||
input.requestContext.actorKey,
|
||||
input.requestContext.projectKey,
|
||||
input.requestContext.clientSessionId,
|
||||
),
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
await this.runtime.prompt(
|
||||
@@ -239,6 +247,11 @@ export class LearningOrchestrator {
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: reviewSession.id,
|
||||
sessionScopeKey: buildToolSessionScopeKey(
|
||||
input.requestContext.actorKey,
|
||||
input.requestContext.projectKey,
|
||||
input.requestContext.clientSessionId,
|
||||
),
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
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;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
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,
|
||||
clientSessionId: input.clientSessionId,
|
||||
data: normalizedData,
|
||||
kind: input.kind,
|
||||
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({
|
||||
...input,
|
||||
data: payload,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
+240
-73
@@ -10,21 +10,29 @@ import {
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
toProjectKey,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type ResultReferenceRecord = {
|
||||
resultRef: string;
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
createdAt: string;
|
||||
data: unknown;
|
||||
preview: ResultPreview;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
sizeBytes: number;
|
||||
traceId: string;
|
||||
};
|
||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
||||
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||
|
||||
export const RESULT_REFERENCE_KIND = {
|
||||
dynamicHttpResult: "dynamic-http-result",
|
||||
renderJunctionsPayload: "render-junctions-payload",
|
||||
} as const;
|
||||
|
||||
export const RESULT_REFERENCE_SOURCE = {
|
||||
dynamicHttp: "dynamic_http",
|
||||
agentGenerated: "agent_generated",
|
||||
legacy: "legacy",
|
||||
migration: "migration",
|
||||
} as const;
|
||||
|
||||
export type ResultReferenceKind =
|
||||
(typeof RESULT_REFERENCE_KIND)[keyof typeof RESULT_REFERENCE_KIND];
|
||||
|
||||
export type ResultReferenceSource =
|
||||
(typeof RESULT_REFERENCE_SOURCE)[keyof typeof RESULT_REFERENCE_SOURCE];
|
||||
|
||||
export type ResultPreview = {
|
||||
count: number;
|
||||
@@ -33,29 +41,51 @@ export type ResultPreview = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type ResultReferenceRecord = {
|
||||
resultRef: string;
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
createdAt: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
preview: ResultPreview;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
schemaVersion: number;
|
||||
sessionId: string;
|
||||
sizeBytes: number;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export type StoreResultInput = {
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
schemaVersion: number;
|
||||
sessionId: string;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export type RetrievalContext = {
|
||||
actorKey: string;
|
||||
clientSessionId?: string;
|
||||
maxItems?: number;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export type ResultReferencePeek = {
|
||||
resultRef: string;
|
||||
kind: ResultReferenceKind;
|
||||
preview: ResultPreview;
|
||||
storedAt: string;
|
||||
};
|
||||
|
||||
type PartialRecord = Partial<ResultReferenceRecord> & { data?: unknown };
|
||||
|
||||
export class ResultReferenceStore {
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -95,55 +125,59 @@ export class ResultReferenceStore {
|
||||
clientSessionId: input.clientSessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: input.data,
|
||||
kind: input.kind,
|
||||
preview: buildPreview(input.data),
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: input.schemaVersion,
|
||||
sessionId: input.sessionId,
|
||||
sizeBytes: estimateBytes(input.data),
|
||||
source: input.source,
|
||||
traceId: input.traceId,
|
||||
};
|
||||
await atomicWriteJson(this.filePath(resultRef), record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async getAuthorized(resultRef: string, context: RetrievalContext) {
|
||||
const record = await this.readAuthorizedRecord(resultRef, context);
|
||||
if (!record) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||
const normalizedResultRef = normalizeResultRef(resultRef);
|
||||
if (!normalizedResultRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getFullAuthorized(resultRef: string, context: RetrievalContext) {
|
||||
const record = await this.readAuthorizedRecord(resultRef, context);
|
||||
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
|
||||
const record =
|
||||
normalizeResultReferenceRecord(rawRecord) ??
|
||||
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
|
||||
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,
|
||||
};
|
||||
if (record.actorKey !== context.actorKey) {
|
||||
return null;
|
||||
}
|
||||
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
context.clientSessionId &&
|
||||
record.clientSessionId !== context.clientSessionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async peekAuthorized(resultRef: string, context: RetrievalContext): Promise<ResultReferencePeek | null> {
|
||||
const record = await this.readAuthorizedRecord(resultRef, context);
|
||||
async peekAuthorized(
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
): Promise<ResultReferencePeek | null> {
|
||||
const record = await this.getAuthorizedRecord(resultRef, context);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
resultRef: record.resultRef,
|
||||
kind: record.kind,
|
||||
preview: record.preview,
|
||||
storedAt: record.createdAt,
|
||||
};
|
||||
@@ -152,7 +186,9 @@ export class ResultReferenceStore {
|
||||
async listBySession(sessionId: string) {
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(
|
||||
files.map(async (filePath) => readJsonFile<ResultReferenceRecord>(filePath)),
|
||||
files.map(async (filePath) =>
|
||||
normalizeResultReferenceRecord(await readJsonFile<unknown>(filePath)),
|
||||
),
|
||||
);
|
||||
return records
|
||||
.filter((record): record is ResultReferenceRecord => Boolean(record))
|
||||
@@ -177,28 +213,167 @@ export class ResultReferenceStore {
|
||||
private filePath(resultRef: string) {
|
||||
return join(this.baseDir, `${resultRef}.json`);
|
||||
}
|
||||
|
||||
private async readAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||
const record = await readJsonFile<ResultReferenceRecord>(this.filePath(resultRef));
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
if (record.actorKey !== context.actorKey) {
|
||||
return null;
|
||||
}
|
||||
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
context.clientSessionId &&
|
||||
record.clientSessionId !== context.clientSessionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeResultReferenceRecord = (
|
||||
value: unknown,
|
||||
): ResultReferenceRecord | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const partial = value as PartialRecord;
|
||||
if (
|
||||
!isValidResultRef(partial.resultRef) ||
|
||||
typeof partial.actorKey !== "string" ||
|
||||
typeof partial.clientSessionId !== "string" ||
|
||||
typeof partial.createdAt !== "string" ||
|
||||
!("data" in partial) ||
|
||||
!isResultPreview(partial.preview) ||
|
||||
typeof partial.projectKey !== "string" ||
|
||||
typeof partial.sessionId !== "string" ||
|
||||
typeof partial.sizeBytes !== "number" ||
|
||||
!Number.isFinite(partial.sizeBytes) ||
|
||||
typeof partial.traceId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = normalizeResultReferenceKind(partial.kind);
|
||||
const source = normalizeResultReferenceSource(partial.source);
|
||||
const schemaVersion =
|
||||
typeof partial.schemaVersion === "number" &&
|
||||
Number.isInteger(partial.schemaVersion) &&
|
||||
partial.schemaVersion > 0
|
||||
? partial.schemaVersion
|
||||
: 1;
|
||||
|
||||
if (!kind || !source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
partial.projectId !== undefined &&
|
||||
typeof partial.projectId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resultRef: partial.resultRef,
|
||||
actorKey: partial.actorKey,
|
||||
clientSessionId: partial.clientSessionId,
|
||||
createdAt: partial.createdAt,
|
||||
data: partial.data,
|
||||
kind,
|
||||
preview: partial.preview,
|
||||
projectId: partial.projectId,
|
||||
projectKey: partial.projectKey,
|
||||
schemaVersion,
|
||||
sessionId: partial.sessionId,
|
||||
sizeBytes: partial.sizeBytes,
|
||||
source,
|
||||
traceId: partial.traceId,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeResultReferenceKind = (
|
||||
value: unknown,
|
||||
): ResultReferenceKind | null => {
|
||||
if (value === undefined) {
|
||||
return RESULT_REFERENCE_KIND.dynamicHttpResult;
|
||||
}
|
||||
return Object.values(RESULT_REFERENCE_KIND).includes(
|
||||
value as ResultReferenceKind,
|
||||
)
|
||||
? (value as ResultReferenceKind)
|
||||
: null;
|
||||
};
|
||||
|
||||
const normalizeResultReferenceSource = (
|
||||
value: unknown,
|
||||
): ResultReferenceSource | null => {
|
||||
if (value === undefined) {
|
||||
return RESULT_REFERENCE_SOURCE.legacy;
|
||||
}
|
||||
return Object.values(RESULT_REFERENCE_SOURCE).includes(
|
||||
value as ResultReferenceSource,
|
||||
)
|
||||
? (value as ResultReferenceSource)
|
||||
: null;
|
||||
};
|
||||
|
||||
const isValidResultRef = (value: unknown): value is string =>
|
||||
typeof value === "string" && RESULT_REF_PATTERN.test(value);
|
||||
|
||||
const normalizeResultRef = (value: string) => {
|
||||
const match = value.trim().match(RESULT_REF_FILE_PATTERN);
|
||||
return match?.[1] ?? null;
|
||||
};
|
||||
|
||||
const normalizeLegacyRenderReferenceRecord = (
|
||||
value: unknown,
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
): ResultReferenceRecord | null => {
|
||||
const data = extractLegacyRenderPayload(value);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = isRecord(value) ? value : {};
|
||||
const metadata = isRecord(root.metadata) ? root.metadata : {};
|
||||
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
|
||||
const createdAt =
|
||||
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
|
||||
|
||||
return {
|
||||
resultRef,
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId ?? "",
|
||||
createdAt,
|
||||
data,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
preview: buildPreview(data),
|
||||
projectId,
|
||||
projectKey: toProjectKey(projectId),
|
||||
schemaVersion: 1,
|
||||
sessionId: context.clientSessionId ?? resultRef,
|
||||
sizeBytes: estimateBytes(data),
|
||||
source: RESULT_REFERENCE_SOURCE.legacy,
|
||||
traceId: "legacy-render-ref",
|
||||
};
|
||||
};
|
||||
|
||||
const extractLegacyRenderPayload = (value: unknown) => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = isRecord(value.data) ? value.data : value;
|
||||
if (!isRecord(candidate.node_area_map)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const firstNonEmptyString = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isResultPreview = (value: unknown): value is ResultPreview =>
|
||||
isRecord(value) &&
|
||||
typeof value.count === "number" &&
|
||||
Number.isFinite(value.count) &&
|
||||
Array.isArray(value.fields) &&
|
||||
value.fields.every((field) => typeof field === "string") &&
|
||||
typeof value.summary === "string" &&
|
||||
"sample" in value;
|
||||
|
||||
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
|
||||
|
||||
const buildPreview = (data: unknown): ResultPreview => {
|
||||
@@ -219,7 +394,9 @@ const buildPreview = (data: unknown): ResultPreview => {
|
||||
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]]),
|
||||
fields
|
||||
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
|
||||
.map((field) => [field, data[field]]),
|
||||
);
|
||||
return {
|
||||
count: fields.length,
|
||||
@@ -237,15 +414,5 @@ const buildPreview = (data: unknown): ResultPreview => {
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
+352
-815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,846 @@
|
||||
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;
|
||||
clientSessionId: 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,
|
||||
clientSessionId: 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: clientSessionId,
|
||||
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,
|
||||
clientSessionId,
|
||||
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,
|
||||
clientSessionId,
|
||||
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: clientSessionId,
|
||||
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: clientSessionId,
|
||||
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,
|
||||
clientSessionId,
|
||||
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: clientSessionId,
|
||||
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: clientSessionId,
|
||||
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,
|
||||
clientSessionId,
|
||||
},
|
||||
"llm tool request missing reason",
|
||||
);
|
||||
}
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "tool",
|
||||
sessionId: opencodeSessionId,
|
||||
clientSessionId,
|
||||
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: clientSessionId,
|
||||
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: clientSessionId,
|
||||
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, clientSessionId, 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: clientSessionId,
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
+118
-22
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
createOpencode,
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
@@ -6,6 +7,18 @@ import {
|
||||
import { config } from "../config.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 = {
|
||||
healthy: boolean;
|
||||
version: string;
|
||||
@@ -18,6 +31,7 @@ type RuntimeModelOverride = {
|
||||
|
||||
export class OpencodeRuntimeAdapter {
|
||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||
private closeServer: (() => void) | null = null;
|
||||
|
||||
async ensureClient(): Promise<OpencodeClient> {
|
||||
if (!this.clientPromise) {
|
||||
@@ -40,14 +54,6 @@ export class OpencodeRuntimeAdapter {
|
||||
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) {
|
||||
await this.prompt(sessionId, text);
|
||||
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
||||
@@ -57,11 +63,27 @@ export class OpencodeRuntimeAdapter {
|
||||
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||
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({
|
||||
sessionID: sessionId,
|
||||
model,
|
||||
parts: [{ type: "text", text }],
|
||||
});
|
||||
logDevelopmentDebug(
|
||||
"opencode session.prompt returned",
|
||||
{
|
||||
sessionId,
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async messages(sessionId: string, limit = 20) {
|
||||
@@ -73,15 +95,6 @@ export class OpencodeRuntimeAdapter {
|
||||
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) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.session.abort({
|
||||
@@ -90,6 +103,26 @@ export class OpencodeRuntimeAdapter {
|
||||
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() {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.event.subscribe();
|
||||
@@ -97,27 +130,90 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.closeServer?.();
|
||||
this.closeServer = null;
|
||||
this.clientPromise = null;
|
||||
}
|
||||
|
||||
private async bootstrapClient(): Promise<OpencodeClient> {
|
||||
if (config.OPENCODE_MODE === "client") {
|
||||
logger.info(
|
||||
{
|
||||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"connecting to opencode server in client mode",
|
||||
);
|
||||
return createOpencodeClient({
|
||||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN =
|
||||
config.AGENT_INTERNAL_TOKEN ??
|
||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
||||
"";
|
||||
|
||||
logger.info(
|
||||
{
|
||||
baseUrl: config.OPENCODE_BASE_URL,
|
||||
hostname: config.OPENCODE_HOSTNAME,
|
||||
port: config.OPENCODE_PORT,
|
||||
model: config.OPENCODE_MODEL,
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"connecting to opencode server",
|
||||
"starting opencode server in embedded mode",
|
||||
);
|
||||
return createOpencodeClient({
|
||||
baseUrl: config.OPENCODE_BASE_URL,
|
||||
});
|
||||
|
||||
let runtime;
|
||||
try {
|
||||
runtime = await createOpencode({
|
||||
hostname: config.OPENCODE_HOSTNAME,
|
||||
port: config.OPENCODE_PORT,
|
||||
timeout: config.OPENCODE_TIMEOUT_MS,
|
||||
config: {
|
||||
model: config.OPENCODE_MODEL,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingOpencodeCli(error)) {
|
||||
throw new Error(
|
||||
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.closeServer = () => {
|
||||
runtime.server.close();
|
||||
};
|
||||
|
||||
return runtime.client;
|
||||
}
|
||||
}
|
||||
|
||||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||||
|
||||
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function requireData<T>(data: T | undefined, operation: string): T {
|
||||
if (data === undefined) {
|
||||
throw new Error(`${operation} returned no data`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
+106
-24
@@ -1,22 +1,26 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
|
||||
import { SessionHistoryStore } from "./history/store.js";
|
||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||
import { config } from "./config.js";
|
||||
import { ConversationStateStore } from "./conversations/stateStore.js";
|
||||
import { ConversationStore } from "./conversations/store.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||
import { MemoryStore } from "./memory/store.js";
|
||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||
import { ResultReferenceStore } from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { SessionRegistry } from "./session/registry.js";
|
||||
import { ToolSessionContextStore } from "./session/toolContextStore.js";
|
||||
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
||||
|
||||
const app = express();
|
||||
const registry = new SessionRegistry(config.SESSION_TTL_SECONDS);
|
||||
const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime);
|
||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||
const conversationStore = new ConversationStore();
|
||||
const conversationStateStore = new ConversationStateStore();
|
||||
const memoryStore = new MemoryStore();
|
||||
const sessionHistoryStore = new SessionHistoryStore();
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
@@ -26,8 +30,12 @@ const learningOrchestrator = new LearningOrchestrator(
|
||||
sessionHistoryStore,
|
||||
);
|
||||
const resultReferenceStore = new ResultReferenceStore();
|
||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
|
||||
const internalToken = config.AGENT_INTERNAL_TOKEN;
|
||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||
|
||||
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。
|
||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
@@ -57,12 +65,22 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||
const context = sessionBridge.getSessionContext(sessionId);
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const threadContext = await toolContextStore.read(sessionScopeKey);
|
||||
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
|
||||
if (!threadContext && !runtimeContext) {
|
||||
res.status(404).json({
|
||||
message: "runtime or session context not found",
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const context = runtimeContext ?? threadContext;
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
message: "runtime or session context not found",
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -77,12 +95,12 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
arguments: req.body?.arguments,
|
||||
},
|
||||
{
|
||||
accessToken: context.accessToken,
|
||||
accessToken: runtimeContext?.accessToken,
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId,
|
||||
sessionId: context.clientSessionId,
|
||||
traceId: context.traceId,
|
||||
},
|
||||
);
|
||||
@@ -102,13 +120,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
|
||||
const context = sessionBridge.getSessionContext(sessionId);
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -117,12 +136,18 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resultReferenceStore.getAuthorized(resultRef, {
|
||||
actorKey: context.actorKey,
|
||||
maxItems:
|
||||
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
|
||||
projectId: context.projectId,
|
||||
});
|
||||
const result = await resultReferenceResolver.getAuthorized(
|
||||
resultRef,
|
||||
{
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
{
|
||||
maxItems:
|
||||
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ message: "result_ref not found" });
|
||||
@@ -132,19 +157,70 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!filePath) {
|
||||
res.status(400).json({ message: "file_path is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.clientSessionId,
|
||||
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) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
||||
const context = await toolContextStore.read(sessionId);
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "tool session context not found",
|
||||
detail: sessionId,
|
||||
message: "session context not found",
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -171,14 +247,19 @@ app.use(
|
||||
buildChatRouter(
|
||||
sessionBridge,
|
||||
opencodeRuntime,
|
||||
conversationStore,
|
||||
conversationStateStore,
|
||||
memoryStore,
|
||||
sessionHistoryStore,
|
||||
learningOrchestrator,
|
||||
resultReferenceStore,
|
||||
resultReferenceResolver,
|
||||
),
|
||||
);
|
||||
|
||||
const bootstrap = async () => {
|
||||
await Promise.all([
|
||||
conversationStore.initialize(),
|
||||
conversationStateStore.initialize(),
|
||||
learningOrchestrator.initialize(),
|
||||
memoryStore.initialize(),
|
||||
resultReferenceStore.initialize(),
|
||||
@@ -201,6 +282,7 @@ const shutdown = async () => {
|
||||
logger.info("shutting down TJWaterAgent");
|
||||
server.close();
|
||||
resultReferenceStore.stopCleanupLoop();
|
||||
// 同步关闭 embedded opencode server,避免本服务退出后留下孤儿进程。
|
||||
await opencodeRuntime.dispose();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||
|
||||
export type ToolSessionContext = {
|
||||
actorKey: string;
|
||||
@@ -16,6 +17,7 @@ export type ToolSessionContext = {
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
sessionScopeKey: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
@@ -28,6 +30,9 @@ export class ToolSessionContextStore {
|
||||
|
||||
async write(context: ToolSessionContext) {
|
||||
await atomicWriteJson(this.filePath(context.sessionId), context);
|
||||
if (context.learningMode === "interactive" && context.sessionScopeKey) {
|
||||
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
|
||||
}
|
||||
}
|
||||
|
||||
async read(sessionId: string) {
|
||||
@@ -42,3 +47,9 @@ export class ToolSessionContextStore {
|
||||
return join(this.baseDir, `${sessionId}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
export const buildToolSessionScopeKey = (
|
||||
actorKey: string,
|
||||
projectKey: string,
|
||||
clientSessionId: string,
|
||||
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
|
||||
import { ResultReferenceStore } from "../results/store.js";
|
||||
|
||||
export type DynamicHttpInput = {
|
||||
@@ -146,9 +147,12 @@ const normalizeSuccessResult = async (
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
data,
|
||||
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
schemaVersion: 1,
|
||||
sessionId: context.sessionId,
|
||||
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
|
||||
traceId: context.traceId,
|
||||
});
|
||||
|
||||
|
||||
@@ -149,6 +149,12 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
|
||||
export const toStableId = (...parts: string[]) =>
|
||||
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
|
||||
|
||||
export const toConversationScopeKey = (
|
||||
actorKey: string,
|
||||
projectKey: string,
|
||||
sessionId: string,
|
||||
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
|
||||
|
||||
export const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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 { ConversationStore } from "../../src/conversations/store.js";
|
||||
|
||||
describe("ConversationStore", () => {
|
||||
let tempDir: string;
|
||||
let store: ConversationStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-conversation-"));
|
||||
store = new ConversationStore(tempDir);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
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?.sessionScopeKey).toBe(record.sessionScopeKey);
|
||||
expect(fetched?.title).toBe("新标题");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SessionHistoryStore } from "../../src/history/store.js";
|
||||
|
||||
describe("SessionHistoryStore", () => {
|
||||
let tempDir: string;
|
||||
let store: SessionHistoryStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-history-"));
|
||||
store = new SessionHistoryStore(tempDir);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("falls back to legacy runtime-session transcripts by client session id and migrates on append", async () => {
|
||||
await writeFile(
|
||||
join(tempDir, "actor-1__project-1__runtime-session-1.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
actorKey: "actor-1",
|
||||
clientSessionId: "thread-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "runtime-session-1",
|
||||
turns: [
|
||||
{
|
||||
id: "turn-1",
|
||||
assistantMessage: "先检查泵站流量。",
|
||||
timestamp: "2026-05-21T00:00:00.000Z",
|
||||
toolCallCount: 1,
|
||||
userMessage: "帮我看一下当前异常。",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const recentTurns = await store.getRecentTurns(
|
||||
{
|
||||
actorKey: "actor-1",
|
||||
clientSessionId: "thread-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "thread-1",
|
||||
},
|
||||
5,
|
||||
);
|
||||
|
||||
expect(recentTurns).toHaveLength(1);
|
||||
expect(recentTurns[0]?.userMessage).toBe("帮我看一下当前异常。");
|
||||
|
||||
const transcript = await store.appendTurn(
|
||||
{
|
||||
actorKey: "actor-1",
|
||||
clientSessionId: "thread-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "thread-1",
|
||||
},
|
||||
{
|
||||
assistantMessage: "已经定位到 3 条疑似异常支路。",
|
||||
toolCallCount: 2,
|
||||
userMessage: "继续分析这些支路。",
|
||||
},
|
||||
);
|
||||
|
||||
expect(transcript.sessionId).toBe("thread-1");
|
||||
expect(transcript.turns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("clones only the kept prefix when forking a thread", async () => {
|
||||
await store.appendTurn(
|
||||
{
|
||||
actorKey: "actor-2",
|
||||
clientSessionId: "thread-source",
|
||||
projectKey: "project-2",
|
||||
sessionId: "thread-source",
|
||||
},
|
||||
{
|
||||
assistantMessage: "第一轮回复",
|
||||
toolCallCount: 0,
|
||||
userMessage: "第一轮提问",
|
||||
},
|
||||
);
|
||||
await store.appendTurn(
|
||||
{
|
||||
actorKey: "actor-2",
|
||||
clientSessionId: "thread-source",
|
||||
projectKey: "project-2",
|
||||
sessionId: "thread-source",
|
||||
},
|
||||
{
|
||||
assistantMessage: "第二轮回复",
|
||||
toolCallCount: 0,
|
||||
userMessage: "第二轮提问",
|
||||
},
|
||||
);
|
||||
|
||||
const cloned = await store.cloneThread(
|
||||
{
|
||||
actorKey: "actor-2",
|
||||
clientSessionId: "thread-source",
|
||||
projectKey: "project-2",
|
||||
sessionId: "thread-source",
|
||||
},
|
||||
{
|
||||
actorKey: "actor-2",
|
||||
clientSessionId: "thread-fork",
|
||||
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",
|
||||
clientSessionId: "thread-fork",
|
||||
projectKey: "project-2",
|
||||
sessionId: "thread-fork",
|
||||
},
|
||||
5,
|
||||
);
|
||||
expect(forkRecentTurns).toHaveLength(1);
|
||||
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
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 { ResultReferenceResolver } from "../../src/results/resolver.js";
|
||||
import {
|
||||
RESULT_REFERENCE_KIND,
|
||||
RESULT_REFERENCE_SOURCE,
|
||||
ResultReferenceStore,
|
||||
} from "../../src/results/store.js";
|
||||
|
||||
describe("ResultReferenceResolver", () => {
|
||||
let tempDir: string;
|
||||
let store: ResultReferenceStore;
|
||||
let resolver: ResultReferenceResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
||||
store = new ResultReferenceStore(tempDir, 60_000);
|
||||
resolver = new ResultReferenceResolver(store);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("stores metadata for new referenced results and resolves them", async () => {
|
||||
const record = await resolver.register({
|
||||
actorKey: "actor-1",
|
||||
clientSessionId: "client-1",
|
||||
data: [{ id: "J1" }, { id: "J2" }],
|
||||
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||
projectId: "project-1",
|
||||
projectKey: "project-key-1",
|
||||
schemaVersion: 1,
|
||||
sessionId: "session-1",
|
||||
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
|
||||
traceId: "trace-1",
|
||||
});
|
||||
|
||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
||||
expect(record.schemaVersion).toBe(1);
|
||||
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
|
||||
|
||||
const result = await resolver.getAuthorized(
|
||||
record.resultRef,
|
||||
{
|
||||
actorKey: "actor-1",
|
||||
projectId: "project-1",
|
||||
},
|
||||
{
|
||||
maxItems: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
||||
expect(result?.schema_version).toBe(1);
|
||||
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
|
||||
expect(result?.data).toEqual([{ id: "J1" }]);
|
||||
});
|
||||
|
||||
it("keeps legacy result refs readable while defaulting metadata", async () => {
|
||||
const legacyRef = "res-aaaaaaaaaaaaaaaa";
|
||||
await writeFile(
|
||||
join(tempDir, `${legacyRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
resultRef: legacyRef,
|
||||
actorKey: "actor-legacy",
|
||||
clientSessionId: "client-legacy",
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
data: { nodes: ["J1"] },
|
||||
preview: {
|
||||
count: 1,
|
||||
fields: ["nodes"],
|
||||
sample: { nodes: ["J1"] },
|
||||
summary: "object<1 fields>",
|
||||
},
|
||||
projectId: "project-legacy",
|
||||
projectKey: "project-key-legacy",
|
||||
sessionId: "session-legacy",
|
||||
sizeBytes: 16,
|
||||
traceId: "trace-legacy",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const record = await store.getAuthorizedRecord(legacyRef, {
|
||||
actorKey: "actor-legacy",
|
||||
projectId: "project-legacy",
|
||||
});
|
||||
|
||||
expect(record).not.toBeNull();
|
||||
expect(record?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
||||
expect(record?.schemaVersion).toBe(1);
|
||||
expect(record?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
|
||||
});
|
||||
|
||||
it("rejects malformed refs, mismatched kinds, and auth mismatches", async () => {
|
||||
const malformedRef = "res-bbbbbbbbbbbbbbbb";
|
||||
await writeFile(
|
||||
join(tempDir, `${malformedRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
resultRef: malformedRef,
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
data: { value: 1 },
|
||||
preview: {
|
||||
count: 1,
|
||||
fields: ["value"],
|
||||
sample: { value: 1 },
|
||||
summary: "object<1 fields>",
|
||||
},
|
||||
projectId: "project-1",
|
||||
projectKey: "project-key-1",
|
||||
sessionId: "session-1",
|
||||
sizeBytes: 10,
|
||||
traceId: "trace-1",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const malformed = await store.getAuthorizedRecord(malformedRef, {
|
||||
actorKey: "actor-1",
|
||||
projectId: "project-1",
|
||||
});
|
||||
expect(malformed).toBeNull();
|
||||
|
||||
const renderRecord = await resolver.register({
|
||||
actorKey: "actor-2",
|
||||
clientSessionId: "client-2",
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
projectId: "project-2",
|
||||
projectKey: "project-key-2",
|
||||
schemaVersion: 1,
|
||||
sessionId: "session-2",
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
traceId: "trace-2",
|
||||
});
|
||||
|
||||
const wrongKind = await resolver.getFullAuthorized(
|
||||
renderRecord.resultRef,
|
||||
{
|
||||
actorKey: "actor-2",
|
||||
projectId: "project-2",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||
},
|
||||
);
|
||||
expect(wrongKind).toBeNull();
|
||||
|
||||
const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
|
||||
actorKey: "actor-other",
|
||||
projectId: "project-2",
|
||||
});
|
||||
expect(wrongActor).toBeNull();
|
||||
});
|
||||
|
||||
it("registers render refs from local wrapper files and normalizes payloads", async () => {
|
||||
const filePath = join(tempDir, "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 "],
|
||||
area_colors: {
|
||||
"DMA-1": "#ff0000",
|
||||
"DMA-2": "#00ff00",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const record = await resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-3",
|
||||
clientSessionId: "client-3",
|
||||
projectId: "project-3",
|
||||
projectKey: "project-key-3",
|
||||
sessionId: "session-3",
|
||||
source: RESULT_REFERENCE_SOURCE.migration,
|
||||
traceId: "trace-3",
|
||||
});
|
||||
|
||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.migration);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
record.resultRef,
|
||||
{
|
||||
actorKey: "actor-3",
|
||||
projectId: "project-3",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.data).toEqual({
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: "2",
|
||||
},
|
||||
area_ids: ["DMA-1", "DMA-2"],
|
||||
area_colors: {
|
||||
"DMA-1": "#ff0000",
|
||||
"DMA-2": "#00ff00",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs wrapper files that omit metadata and location", async () => {
|
||||
const filePath = join(tempDir, "render-wrapper-missing-fields.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const record = await resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-4",
|
||||
clientSessionId: "client-4",
|
||||
projectId: "project-4",
|
||||
projectKey: "project-key-4",
|
||||
sessionId: "session-4",
|
||||
source: RESULT_REFERENCE_SOURCE.migration,
|
||||
traceId: "trace-4",
|
||||
});
|
||||
|
||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
|
||||
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
||||
metadata?: Record<string, unknown>;
|
||||
location?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(repaired.metadata).toEqual({
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-4",
|
||||
});
|
||||
expect(repaired.location).toEqual({
|
||||
file_path: filePath,
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs wrapper files whose location points elsewhere", async () => {
|
||||
const filePath = join(tempDir, "render-wrapper-wrong-location.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
metadata: {
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
location: {
|
||||
file_path: "/tmp/elsewhere.json",
|
||||
source: "legacy",
|
||||
},
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-4",
|
||||
clientSessionId: "client-4",
|
||||
projectId: "project-4",
|
||||
projectKey: "project-key-4",
|
||||
sessionId: "session-4",
|
||||
source: RESULT_REFERENCE_SOURCE.migration,
|
||||
traceId: "trace-4",
|
||||
});
|
||||
|
||||
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
||||
metadata?: Record<string, unknown>;
|
||||
location?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(repaired.metadata).toEqual({
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-4",
|
||||
});
|
||||
expect(repaired.location).toEqual({
|
||||
file_path: filePath,
|
||||
source: "legacy",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves legacy render payload files when callers include the json suffix", async () => {
|
||||
const legacyRef = "res-c2fcee33-577e";
|
||||
await writeFile(
|
||||
join(tempDir, `${legacyRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: 2,
|
||||
},
|
||||
area_ids: ["DMA-1"],
|
||||
},
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-legacy-render",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
`${legacyRef}.json`,
|
||||
{
|
||||
actorKey: "actor-legacy-render",
|
||||
clientSessionId: "chat-legacy-render",
|
||||
projectId: "project-legacy-render",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.result_ref).toBe(legacyRef);
|
||||
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
|
||||
expect(result?.data).toEqual({
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: "2",
|
||||
},
|
||||
area_ids: ["DMA-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy render payload files scoped to their project", async () => {
|
||||
const legacyRef = "res-dddddddddddddddd";
|
||||
await writeFile(
|
||||
join(tempDir, `${legacyRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
projectId: "project-allowed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
legacyRef,
|
||||
{
|
||||
actorKey: "actor-legacy-render",
|
||||
clientSessionId: "chat-legacy-render",
|
||||
projectId: "project-denied",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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 {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../../src/session/toolContextStore.js";
|
||||
|
||||
describe("ToolSessionContextStore", () => {
|
||||
let tempDir: string;
|
||||
let store: ToolSessionContextStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-tool-context-"));
|
||||
store = new ToolSessionContextStore(tempDir);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("writes interactive aliases under scoped session keys", async () => {
|
||||
const sessionScopeKey = buildToolSessionScopeKey(
|
||||
"actor-1",
|
||||
"project-1",
|
||||
"chat-session-1",
|
||||
);
|
||||
|
||||
await store.write({
|
||||
actorKey: "actor-1",
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "chat-session-1",
|
||||
learningMode: "interactive",
|
||||
projectId: "project-id-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "runtime-session-1",
|
||||
sessionScopeKey,
|
||||
traceId: "trace-1",
|
||||
});
|
||||
|
||||
const runtimeContext = await store.read("runtime-session-1");
|
||||
const scopedContext = await store.read(sessionScopeKey);
|
||||
|
||||
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
||||
expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey);
|
||||
expect(scopedContext?.sessionId).toBe("runtime-session-1");
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -8,9 +8,10 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"rootDir": "src",
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node", "bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user