Compare commits
21
Commits
801f611ce5
...
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94529cb141 | ||
|
|
2415f75841 | ||
|
|
d7faaa2ecb | ||
|
|
78f9f6fb42 | ||
|
|
9aad38acb1 | ||
|
|
d782b7fa11 | ||
|
|
40c0395fb1 | ||
|
|
d75b98a61b | ||
|
|
ead76185b7 | ||
|
|
8857b18dc9 | ||
|
|
4b572d9264 | ||
|
|
fc7393fa2b | ||
|
|
c823e3935e | ||
|
|
366c05b752 | ||
|
|
fa2c28c1c0 | ||
|
|
cf6cada538 | ||
|
|
d1b91a4b1e | ||
|
|
20b93c688f | ||
|
|
873c169c2c | ||
|
|
8ed73b1da6 | ||
|
|
60b9080c47 |
@@ -148,12 +148,14 @@ jobs:
|
||||
|
||||
if [ "${IMAGE_TAG}" = "latest" ]; then
|
||||
docker build \
|
||||
--network=host \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
else
|
||||
docker build \
|
||||
--network=host \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: TJWater Agent,用于供水网络分析和操作员工作流
|
||||
mode: primary
|
||||
model: deepseek/deepseek-v4-pro
|
||||
model: deepseek/deepseek-v4-flash
|
||||
temperature: 0.2
|
||||
---
|
||||
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
||||
@@ -30,7 +30,9 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
|
||||
|------|------|
|
||||
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
|
||||
| 发现可用命令 | `tjwater_cli(command="help")` |
|
||||
| UI 操作 / 可视化 | `locate_features`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` |
|
||||
| 查询实时公开网页信息 | `web_search` |
|
||||
| 地址/地点转经纬度 | `geocode` |
|
||||
| UI 操作 / 可视化 | `locate_features`、`zoom_to_map`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` |
|
||||
| 持久化渲染数据 | ①准备 { node_area_map } JSON → ②`store_render_ref` 存为受控 ref → ③`render_junctions` 渲染到前端 |
|
||||
|
||||
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
|
||||
|
||||
@@ -28,8 +28,11 @@ export default tool({
|
||||
.describe("Classification method."),
|
||||
segments: tool.schema
|
||||
.number()
|
||||
.int()
|
||||
.min(2)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe("Number of segments."),
|
||||
.describe("Number of rendered intervals, from 2 to 10."),
|
||||
min_size: tool.schema
|
||||
.number()
|
||||
.optional()
|
||||
@@ -54,9 +57,9 @@ export default tool({
|
||||
.enum(["single", "gradient", "rainbow", "custom"])
|
||||
.optional()
|
||||
.describe("Color strategy."),
|
||||
single_palette_index: tool.schema.number().optional(),
|
||||
gradient_palette_index: tool.schema.number().optional(),
|
||||
rainbow_palette_index: tool.schema.number().optional(),
|
||||
single_palette_index: tool.schema.number().int().min(0).max(6).optional(),
|
||||
gradient_palette_index: tool.schema.number().int().min(0).max(2).optional(),
|
||||
rainbow_palette_index: tool.schema.number().int().min(0).max(1).optional(),
|
||||
show_labels: tool.schema
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -65,7 +68,7 @@ export default tool({
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether to show ids."),
|
||||
opacity: tool.schema.number().optional().describe("Opacity in [0, 1]."),
|
||||
opacity: tool.schema.number().min(0).max(1).optional().describe("Opacity in [0, 1]."),
|
||||
adjust_width_by_property: tool.schema
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -73,11 +76,11 @@ export default tool({
|
||||
custom_breaks: tool.schema
|
||||
.array(tool.schema.number())
|
||||
.optional()
|
||||
.describe("Custom break values."),
|
||||
.describe("Strictly increasing boundaries. Length must equal segments + 1."),
|
||||
custom_colors: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Custom rgba colors."),
|
||||
.describe("Custom CSS colors. Length must equal segments."),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -87,8 +90,24 @@ export default tool({
|
||||
async execute(args) {
|
||||
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
|
||||
if (args.reset_to_default) {
|
||||
return `已将${layerLabel}图层重置为默认样式。`;
|
||||
return `已提交${layerLabel}图层默认样式重置请求。`;
|
||||
}
|
||||
return `已对${layerLabel}图层应用样式。`;
|
||||
const style = args.style_config;
|
||||
if (style?.custom_breaks) {
|
||||
if (
|
||||
style.custom_breaks.some(
|
||||
(value, index, values) => index > 0 && value <= values[index - 1],
|
||||
)
|
||||
) {
|
||||
throw new Error("custom_breaks must be strictly increasing");
|
||||
}
|
||||
if (style.segments && style.custom_breaks.length !== style.segments + 1) {
|
||||
throw new Error("custom_breaks length must equal segments + 1");
|
||||
}
|
||||
}
|
||||
if (style?.segments && style.custom_colors && style.custom_colors.length !== style.segments) {
|
||||
throw new Error("custom_colors length must equal segments");
|
||||
}
|
||||
return `已提交${layerLabel}图层样式请求。`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const internalBaseUrl =
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe("Why geocoding is required for the current user request."),
|
||||
keyword: tool.schema
|
||||
.string()
|
||||
.describe("Address or place name to geocode, such as 北京市人民政府."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/geocode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: context.sessionID,
|
||||
keyword: args.keyword,
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
export default tool({
|
||||
description: "在前端对话界面中渲染图表。",
|
||||
description:
|
||||
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
@@ -11,12 +12,16 @@ export default tool({
|
||||
.enum(["line", "bar", "pie"])
|
||||
.optional()
|
||||
.describe("Chart type."),
|
||||
x_data: tool.schema.array(tool.schema.string()).describe("X-axis labels."),
|
||||
x_data: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.describe("X-axis labels. For line charts, put time/category labels here."),
|
||||
series: tool.schema
|
||||
.array(
|
||||
tool.schema.object({
|
||||
name: tool.schema.string(),
|
||||
data: tool.schema.array(tool.schema.number()),
|
||||
data: tool.schema
|
||||
.array(tool.schema.number())
|
||||
.describe("Y values only. Must align by index with x_data."),
|
||||
type: tool.schema.enum(["line", "bar"]).optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -100,7 +100,11 @@ export const createSkillManagerTool = (
|
||||
kind: "skill",
|
||||
decision: "accepted",
|
||||
detail: "skill listed",
|
||||
...result,
|
||||
references: result.references,
|
||||
scripts: result.scripts,
|
||||
skill_path: result.skillPath,
|
||||
target: result.target,
|
||||
patterns: result.patterns,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const internalBaseUrl =
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe("Why web search is required for the current user request."),
|
||||
query: tool.schema.string().describe("Search query text."),
|
||||
freshness: tool.schema
|
||||
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
|
||||
.optional()
|
||||
.describe("Optional freshness filter. Defaults to no_limit."),
|
||||
summary: tool.schema
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether the backend should include page summaries."),
|
||||
count: tool.schema
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Optional result count, backend accepts 1 to 50."),
|
||||
include: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Optional domains to include."),
|
||||
exclude: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Optional domains to exclude."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/web-search`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: context.sessionID,
|
||||
query: args.query,
|
||||
freshness: args.freshness,
|
||||
summary: args.summary,
|
||||
count: args.count,
|
||||
include: args.include,
|
||||
exclude: args.exclude,
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe("Why this map zoom action is needed for the current request."),
|
||||
x: tool.schema
|
||||
.number()
|
||||
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
|
||||
y: tool.schema
|
||||
.number()
|
||||
.describe("Y coordinate. For EPSG:4326 this is latitude; for EPSG:3857 this is meters."),
|
||||
source_crs: tool.schema
|
||||
.enum(["EPSG:3857", "EPSG:4326"])
|
||||
.optional()
|
||||
.describe("Input coordinate CRS. Defaults to EPSG:3857."),
|
||||
zoom: tool.schema
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Optional OpenLayers zoom level. Defaults to 18."),
|
||||
duration_ms: tool.schema
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Optional animation duration in milliseconds. Defaults to 1000."),
|
||||
},
|
||||
async execute() {
|
||||
return "已缩放到指定地图坐标。";
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This repository is the internal TJWater agent service. Runtime TypeScript code is organized under `src/`; CLI-specific code is under `cli/src`; Node test files live in `node-tests/`. Agent extensions and OpenCode integration files are under `.opencode/`, including `.opencode/agents`, `.opencode/skills`, and `.opencode/tools`. Runtime data, session metadata, logs, and result references are stored under `data/` and `logs/` and should be treated as local/generated state.
|
||||
|
||||
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use Bun for this project:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
bun run check
|
||||
bun run test:cli
|
||||
bun run start
|
||||
```
|
||||
|
||||
`bun run dev` starts `src/server.ts` in watch mode. `bun run check` runs TypeScript checks for the main project and `.opencode`. `bun run test:cli` runs Node CLI tests. `bun run start` starts the service without watch mode.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use TypeScript ESM and keep types explicit at module boundaries. Use two-space indentation, `camelCase` for functions and variables, `PascalCase` for classes/types, and kebab-case or descriptive lowercase names for scripts and data files. Prefer existing `src/` service, route, and runtime patterns before introducing new structure.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use Node's built-in test runner for CLI coverage. Name test files with `.node.mjs` when using `node --test`, matching `node-tests/cli/*.node.mjs`. Add focused tests for CLI parsing, tool behavior, and session/runtime changes. Do not depend on mutable local files under `data/`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses Conventional Commit messages such as `feat(tools): add search and map tools`, `fix(agent): warm up opencode on startup`, and `refactor(chat): ...`. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
|
||||
|
||||
PRs should describe runtime behavior changes, list `bun run check` and any test commands run, and mention changes to `.opencode`, secrets, ports, or deploy workflow behavior.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit `.env`, logs, session transcripts, generated result references, or `node_modules/`. Keep registry and deploy credentials in Gitea secrets.
|
||||
+11
-7
@@ -1,8 +1,6 @@
|
||||
FROM oven/bun:canary-slim AS bun-bin
|
||||
|
||||
FROM smanx/opencode:latest AS base
|
||||
USER root
|
||||
ARG UBUNTU_APT_MIRROR=mirrors.aliyun.com
|
||||
ARG UBUNTU_APT_MIRROR=
|
||||
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
@@ -11,14 +9,22 @@ ENV PIP_INDEX_URL=${PYPI_INDEX_URL}
|
||||
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
|
||||
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
|
||||
|
||||
RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true && \
|
||||
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
|
||||
COPY vendor/bun-linux-x64.zip /tmp/bun.zip
|
||||
|
||||
RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
|
||||
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
|
||||
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
jq \
|
||||
unzip \
|
||||
python3 \
|
||||
python3-venv && \
|
||||
unzip -q /tmp/bun.zip -d /usr/local && \
|
||||
mv /usr/local/bun-linux-x64 /usr/local/bun && \
|
||||
ln -sf /usr/local/bun/bun /usr/local/bin/bun && \
|
||||
rm -f /tmp/bun.zip && \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
||||
@@ -39,8 +45,6 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
|
||||
pytest && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,323 +1,96 @@
|
||||
# TJWaterAgent 目录结构说明
|
||||
# TJWaterAgent 内部智能体服务
|
||||
|
||||
`TJWaterAgent/` 是新的 opencode Agent 服务工程目录,负责对外提供 TJWater 智能助手接口,并通过 opencode SDK 启动或连接 opencode 运行时。
|
||||
`TJWaterAgent` 是 TJWater 内部版智能体服务,负责连接前端聊天界面、OpenCode 运行时、MCP 工具和 TJWater 后端 API。它面向内部研发与部署,保留完整的 Agent 编排、会话上下文、工具调用和运行时调试能力。
|
||||
|
||||
## 总体边界
|
||||
## 主要能力
|
||||
|
||||
- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
|
||||
- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。
|
||||
- 管理前端 `session_id` 与 OpenCode session 的映射。
|
||||
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
|
||||
- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。
|
||||
- 通过 `data/` 保存运行时会话元数据、结果引用和本地状态。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
TJWaterAgent/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
opencode.json
|
||||
.opencode/
|
||||
agents/
|
||||
tools/
|
||||
skills/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/ 服务端 TypeScript 源码
|
||||
src/routes/ HTTP 路由
|
||||
src/chat/ 聊天流和 SSE 事件适配
|
||||
src/runtime/ OpenCode 运行时管理
|
||||
src/session/ 会话映射和运行上下文
|
||||
src/mcp/ MCP 服务与工具桥接
|
||||
.opencode/agents/ Agent prompt 和模型行为配置
|
||||
.opencode/tools/ OpenCode 自定义工具
|
||||
.opencode/skills/ 可复用分析工作流
|
||||
node-tests/ Node CLI 测试
|
||||
data/ 本地运行时数据,禁止提交
|
||||
logs/ 本地日志,禁止提交
|
||||
```
|
||||
|
||||
| 位置 | 主要作用 | 典型内容 |
|
||||
| --- | --- | --- |
|
||||
| `TJWaterAgent/` | 服务宿主、API 层和编排层 | Express 服务、SSE 接口、会话管理、鉴权上下文、后端 API 代理、opencode SDK 启动逻辑 |
|
||||
| `TJWaterAgent/.opencode/` | opencode 项目资产目录 | agent prompt、自定义 tools、skills 树、plugins 相关依赖 |
|
||||
## 本地开发
|
||||
|
||||
## `TJWaterAgent/` 根目录的职责
|
||||
|
||||
根目录是 Node/TypeScript 服务本体,主要负责:
|
||||
|
||||
1. 启动 HTTP 服务。
|
||||
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
|
||||
3. 管理前端 `session_id -> opencode sessionId` 的映射。
|
||||
4. 保存并传递用户 `Authorization`、`x-user-id`、`x-project-id`、`x-trace-id`。
|
||||
5. 把 opencode 输出适配成前端需要的 SSE 事件。
|
||||
6. 为 `.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
|
||||
7. 代理调用真实 TJWater 后端 API。
|
||||
|
||||
当前 Agent API 的主入口:
|
||||
|
||||
```text
|
||||
POST /api/v1/agent/chat/stream
|
||||
```
|
||||
|
||||
该接口返回 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/` 的职责
|
||||
|
||||
`.opencode/` 是给 opencode 运行时读取的项目资产目录,不是对外 HTTP 服务的主代码目录。
|
||||
|
||||
### agents
|
||||
|
||||
```text
|
||||
.opencode/agents/agent.md
|
||||
```
|
||||
|
||||
这里定义默认 agent 的角色、行为规则、模型配置和工具使用策略。
|
||||
|
||||
当前项目已将 always-loaded instructions 收敛到 `agent.md`,`opencode.json` 不再额外配置 `instructions` 数组。
|
||||
|
||||
### tools
|
||||
|
||||
```text
|
||||
.opencode/tools/
|
||||
tjwater_cli.ts
|
||||
store_render_ref.ts
|
||||
locate_features.ts
|
||||
view_history.ts
|
||||
view_scada.ts
|
||||
show_chart.ts
|
||||
render_junctions.ts
|
||||
apply_layer_style.ts
|
||||
memory_manager.ts
|
||||
session_search.ts
|
||||
skill_manager.ts
|
||||
```
|
||||
|
||||
这些是 opencode 可以调用的自定义工具。
|
||||
|
||||
`tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令。
|
||||
|
||||
`store_render_ref.ts` 用于把大型 junction 渲染 payload 存成 `render_ref`,再由 `render_junctions.ts` 交给前端回读并渲染。
|
||||
|
||||
前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart`、`render_junctions`、`apply_layer_style` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
|
||||
|
||||
### skills
|
||||
|
||||
```text
|
||||
.opencode/skills/
|
||||
SKILL.md
|
||||
examples.md
|
||||
runbook.md
|
||||
tjwater-cli/ ← tjwater-cli 可执行文件
|
||||
workflow/ ← 可复用分析工作流
|
||||
SKILL.md
|
||||
simulation-diagnosis/
|
||||
bottleneck-analysis/
|
||||
source-service-area-analysis/
|
||||
```
|
||||
|
||||
Skills 仅保留可复用的多步工作流。Agent 通过 `tjwater-cli help` 自行发现原子命令,无需逐接口技能树。
|
||||
|
||||
agent 加载技能树时按需取用对应 workflow skill。
|
||||
|
||||
## 依赖边界
|
||||
|
||||
根目录和 `.opencode/` 使用两组 npm 依赖,职责不同。
|
||||
|
||||
### 根目录依赖
|
||||
|
||||
```text
|
||||
TJWaterAgent/package.json
|
||||
```
|
||||
|
||||
用于服务本体,例如:
|
||||
|
||||
```text
|
||||
@opencode-ai/sdk
|
||||
express
|
||||
zod
|
||||
pino
|
||||
```
|
||||
|
||||
### `.opencode` 依赖
|
||||
|
||||
```text
|
||||
TJWaterAgent/.opencode/package.json
|
||||
```
|
||||
|
||||
用于 opencode 自定义 tools/plugins,例如:
|
||||
|
||||
```text
|
||||
@opencode-ai/plugin
|
||||
typescript
|
||||
@types/node
|
||||
```
|
||||
|
||||
这两组依赖不要混在一起:根目录负责服务运行,`.opencode` 负责 opencode 扩展资产的类型检查和运行依赖。
|
||||
|
||||
## 启动与部署
|
||||
|
||||
支持两种 opencode 接入方式:
|
||||
|
||||
1. Embedded 模式:服务通过 `@opencode-ai/sdk` 调用 `createOpencode`,启动本地 `opencode` CLI 子进程并自动创建 client。
|
||||
2. Client 模式:服务通过 `createOpencodeClient` 直接连接一个已经存在的 opencode server。
|
||||
|
||||
因此,只有 Embedded 模式要求运行环境已安装 `opencode` CLI;Client 模式不依赖本地 CLI。
|
||||
|
||||
根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
|
||||
|
||||
### 本地开发
|
||||
项目使用 Bun:
|
||||
|
||||
```bash
|
||||
cd TJWaterAgent
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
`bun install` 会通过 `postinstall` 自动执行 `.opencode` 依赖安装;`bun run dev` 启动前会检查 `.opencode/tools` 的类型。
|
||||
`bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**`、`.opencode/**`、`opencode.json` 或 `.local.env` 后会自动重启。
|
||||
|
||||
开发模式支持热重载,以下文件变化会触发服务重启并重新拉起 embedded opencode:
|
||||
## 常用命令
|
||||
|
||||
```text
|
||||
src/**
|
||||
.opencode/**
|
||||
opencode.json
|
||||
.local.env
|
||||
```bash
|
||||
bun run check
|
||||
bun run contract:generate
|
||||
bun run test:api
|
||||
bun run test:cli
|
||||
bun run start
|
||||
bun run start:prod
|
||||
docker build -t tjwater-agent:local .
|
||||
```
|
||||
|
||||
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev`。
|
||||
- `bun run check`:检查主项目和 `.opencode` 的 TypeScript 类型。
|
||||
- `bun run contract:generate`:生成 `contracts/agent-v1.openapi.json`。
|
||||
- `bun run test:api`:验证公开 REST 契约和聊天路由。
|
||||
- `bun run test:cli`:运行 `node-tests/cli/*.node.mjs`。
|
||||
- `bun run start`:直接启动服务。
|
||||
- `bun run start:prod`:先类型检查,再启动服务。
|
||||
|
||||
本地开发可以在项目根目录的 `.local.env` 中配置环境变量。
|
||||
## 运行模式
|
||||
|
||||
Embedded 模式示例:
|
||||
Embedded 模式由服务进程拉起本机 OpenCode:
|
||||
|
||||
```bash
|
||||
OPENCODE_MODE=embedded
|
||||
DEEPSEEK_API_KEY=sk-xxx
|
||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
Client 模式示例:
|
||||
Client 模式连接外部 OpenCode server:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
服务启动时会自动读取 `.local.env`,但系统环境变量优先级更高,适合在本机保存开发用 key。
|
||||
本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。
|
||||
|
||||
### 生产启动
|
||||
## 配置与安全
|
||||
|
||||
不要提交 `.env`、`.local.env`、`data/`、`logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
|
||||
|
||||
## 发布
|
||||
|
||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`。发布前至少运行:
|
||||
|
||||
```bash
|
||||
cd TJWaterAgent
|
||||
bun install
|
||||
bun run check
|
||||
bun run start
|
||||
```
|
||||
|
||||
也可以使用一条命令完成构建并启动:
|
||||
如修改 CLI 或工具调用逻辑,同时运行:
|
||||
|
||||
```bash
|
||||
cd TJWaterAgent
|
||||
bun install
|
||||
bun run start:prod
|
||||
bun run test:cli
|
||||
```
|
||||
|
||||
### Docker Compose 启动
|
||||
|
||||
项目根目录已提供 `Dockerfile` 和 `docker-compose.yml`,可直接使用:
|
||||
|
||||
```bash
|
||||
cd TJWaterAgent
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
docker compose logs -f tjwater-agent
|
||||
```
|
||||
|
||||
停止并清理容器:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 常用脚本
|
||||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `bun run dev` | 类型检查 `.opencode` tools 后,以 watch 模式直接运行 `src/server.ts` |
|
||||
| `bun run check` | 执行完整类型检查(服务与 `.opencode` tools) |
|
||||
| `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 配置
|
||||
|
||||
默认 Agent 模型为:
|
||||
|
||||
```text
|
||||
deepseek/deepseek-v4-pro
|
||||
```
|
||||
|
||||
涉及位置:
|
||||
|
||||
```text
|
||||
opencode.json
|
||||
.opencode/agents/tjwater-assistant.md
|
||||
src/config.ts 的 OPENCODE_MODEL 默认值
|
||||
```
|
||||
|
||||
如果需要临时覆盖模型,可以在启动时设置:
|
||||
|
||||
```bash
|
||||
OPENCODE_MODEL=deepseek/deepseek-v4-pro bun run start
|
||||
```
|
||||
|
||||
DeepSeek API key 不写入代码,部署时通过环境变量设置:
|
||||
|
||||
```bash
|
||||
DEEPSEEK_API_KEY=sk-xxx bun run start
|
||||
```
|
||||
|
||||
`opencode.json` 已配置从环境变量读取:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"deepseek": {
|
||||
"options": {
|
||||
"apiKey": "{env:DEEPSEEK_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果需要自定义 DeepSeek 兼容 API 地址,可以通过 opencode 的 provider 配置增加 `baseURL`,例如在部署环境使用 `OPENCODE_CONFIG_CONTENT` 覆盖:
|
||||
|
||||
```bash
|
||||
OPENCODE_CONFIG_CONTENT='{"provider":{"deepseek":{"options":{"baseURL":"https://your-api.example.com/v1"}}}}' \
|
||||
DEEPSEEK_API_KEY=sk-xxx \
|
||||
bun run start
|
||||
```
|
||||
|
||||
也可以使用 opencode 的 `/connect` 命令写入用户级凭据,但服务部署更推荐使用环境变量。
|
||||
|
||||
如果需要连接外部独立运行的 opencode server,可以配置:
|
||||
|
||||
```bash
|
||||
OPENCODE_MODE=client
|
||||
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
|
||||
```
|
||||
|
||||
配置后,`TJWaterAgent` 会连接该外部 opencode server,而不是自行启动 embedded opencode server。
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"zod": "^3.25.76",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "7.3.4",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.7.2",
|
||||
@@ -23,6 +24,8 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
@@ -175,6 +178,8 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -259,6 +264,8 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CliError } from "../core/errors.js";
|
||||
import { emitApi, requestJson } from "../core/http.js";
|
||||
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
|
||||
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
|
||||
import { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js";
|
||||
import { resolveScheme } from "../core/runtime.js";
|
||||
import { parseTime } from "../core/time.js";
|
||||
import { success } from "../core/output.js";
|
||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||
@@ -12,17 +12,16 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
|
||||
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
|
||||
return emitApi(ctx, "爆管分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/burst_analysis/",
|
||||
method: "POST",
|
||||
path: "/burst-analyses",
|
||||
params: {
|
||||
network: requireNetwork(ctx),
|
||||
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
burst_ID: ids,
|
||||
burst_id: ids,
|
||||
burst_size: sizes,
|
||||
modify_total_duration: requiredNumber(values, "duration"),
|
||||
scheme_name: schemeName,
|
||||
},
|
||||
requireNetworkCtx: true,
|
||||
requireProject: true,
|
||||
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
|
||||
}
|
||||
|
||||
@@ -34,25 +33,24 @@ function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const startTime = optionalString(values, "start-time");
|
||||
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
|
||||
return emitApi(ctx, "阀门关闭分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/valve_close_analysis/",
|
||||
method: "POST",
|
||||
path: "/valve-isolation-analyses",
|
||||
params: {
|
||||
network: requireNetwork(ctx),
|
||||
start_time: parseTime(startTime, "--start-time"),
|
||||
valves,
|
||||
duration: optionalNumber(values, "duration") || 900,
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
},
|
||||
requireNetworkCtx: true,
|
||||
requireProject: true,
|
||||
});
|
||||
}
|
||||
const elements = optionalStringArray(values, "element");
|
||||
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
|
||||
return emitApi(ctx, "阀门隔离分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/valve_isolation_analysis/",
|
||||
params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
|
||||
requireNetworkCtx: true,
|
||||
method: "POST",
|
||||
path: "/valve-isolation-analyses",
|
||||
params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
|
||||
requireProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,36 +58,34 @@ function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
|
||||
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
|
||||
return emitApi(ctx, "冲洗分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/flushing_analysis/",
|
||||
method: "POST",
|
||||
path: "/flushing-analyses",
|
||||
params: {
|
||||
network: requireNetwork(ctx),
|
||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
valves,
|
||||
valves_k: openings,
|
||||
drainage_node_ID: requiredString(values, "drainage-node"),
|
||||
drainage_node_id: requiredString(values, "drainage-node"),
|
||||
flush_flow: requiredNumber(values, "flow"),
|
||||
duration: optionalNumber(values, "duration") || 900,
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
},
|
||||
requireNetworkCtx: true,
|
||||
requireProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv, { duration: "integer" });
|
||||
return emitApi(ctx, "水龄分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/age_analysis/",
|
||||
params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
|
||||
requireNetworkCtx: true,
|
||||
method: "POST",
|
||||
path: "/water-age-analyses",
|
||||
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
|
||||
requireProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
|
||||
const params: Record<string, unknown> = {
|
||||
network: requireNetwork(ctx),
|
||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
source: requiredString(values, "source-node"),
|
||||
concentration: requiredNumber(values, "concentration"),
|
||||
@@ -98,55 +94,50 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
|
||||
};
|
||||
const pattern = optionalString(values, "pattern");
|
||||
if (pattern) params.pattern = pattern;
|
||||
return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant_simulation/", params, requireNetworkCtx: true });
|
||||
return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
|
||||
}
|
||||
|
||||
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
|
||||
return emitApi(ctx, "传感器选址执行成功", {
|
||||
method: "POST",
|
||||
path: "/pressure_sensor_placement_kmeans/",
|
||||
path: "/pressure-sensor-placement-kmeans",
|
||||
body: {
|
||||
name: requireNetwork(ctx),
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
sensor_number: requiredNumber(values, "count"),
|
||||
min_diameter: optionalNumber(values, "min-diameter") || 0,
|
||||
username: requireUsername(ctx),
|
||||
},
|
||||
requireNetworkCtx: true,
|
||||
requireUsernameCtx: true,
|
||||
requireProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, networkKey: string, startKey: string, endKey: string): Promise<void> {
|
||||
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, startKey: string, endKey: string): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, summary, {
|
||||
method: "POST",
|
||||
path,
|
||||
body: {
|
||||
[networkKey]: requireNetwork(ctx),
|
||||
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
},
|
||||
requireNetworkCtx: true,
|
||||
requireProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
||||
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||
function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> {
|
||||
return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true });
|
||||
}
|
||||
|
||||
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise<void> {
|
||||
const { positionals } = parseOptions(argv);
|
||||
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
|
||||
return emitApi(ctx, summary, { method: "GET", path: `${path}${positionals[0]}`, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||
return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true });
|
||||
}
|
||||
|
||||
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" });
|
||||
const body: Record<string, unknown> = {
|
||||
network: requireNetwork(ctx),
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
data_source: optionalString(values, "data-source") || "monitoring",
|
||||
scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
@@ -162,18 +153,17 @@ function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const flowFile = optionalString(values, "flow-file");
|
||||
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
|
||||
if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow");
|
||||
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-location/locate/", body, requireNetworkCtx: true });
|
||||
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
|
||||
}
|
||||
|
||||
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), pipe_id: requiredString(values, "pipe") }, requireNetworkCtx: true });
|
||||
return emitApi(ctx, summary, { method: "GET", path, params: { pipe_id: requiredString(values, "pipe") }, requireProject: true });
|
||||
}
|
||||
|
||||
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
|
||||
const network = requireNetwork(ctx);
|
||||
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true });
|
||||
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true });
|
||||
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
|
||||
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
|
||||
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
|
||||
}
|
||||
|
||||
@@ -184,16 +174,16 @@ export const analysisHandlers: HandlerMap = {
|
||||
"analysis age": analysisAge,
|
||||
"analysis contaminant": analysisContaminant,
|
||||
"analysis sensor-placement kmeans": sensorKmeans,
|
||||
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"),
|
||||
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"),
|
||||
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"),
|
||||
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"),
|
||||
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"),
|
||||
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"),
|
||||
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
|
||||
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
|
||||
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
|
||||
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
|
||||
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
|
||||
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
|
||||
"analysis burst-location locate": burstLocation,
|
||||
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"),
|
||||
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"),
|
||||
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"),
|
||||
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"),
|
||||
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
|
||||
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
|
||||
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
|
||||
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
|
||||
"analysis risk network": riskNetwork,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CliError } from "../core/errors.js";
|
||||
import { emitApi } from "../core/http.js";
|
||||
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
|
||||
import { requireNetwork } from "../core/runtime.js";
|
||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||
|
||||
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
|
||||
@@ -10,22 +9,22 @@ function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean):
|
||||
const { values } = parseOptions(argv);
|
||||
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
|
||||
const routes: Record<`${ComponentKind}:${boolean}`, string> = {
|
||||
"time:true": "/gettimeschema",
|
||||
"time:false": "/gettimeproperties/",
|
||||
"energy:true": "/getenergyschema/",
|
||||
"energy:false": "/getenergyproperties/",
|
||||
"pump-energy:true": "/getpumpenergyschema/",
|
||||
"pump-energy:false": "/getpumpenergyproperties//",
|
||||
"network:true": "/getoptionschema/",
|
||||
"network:false": "/getoptionproperties/",
|
||||
"time:true": "/network-schemas/time",
|
||||
"time:false": "/network-options/time",
|
||||
"energy:true": "/network-schemas/energy",
|
||||
"energy:false": "/network-options/energy",
|
||||
"pump-energy:true": "/network-schemas/pump-energy",
|
||||
"pump-energy:false": "/network-options/pump-energy",
|
||||
"network:true": "/network-schemas/option",
|
||||
"network:false": "/network-options",
|
||||
};
|
||||
const params: Record<string, unknown> = { network: requireNetwork(ctx) };
|
||||
const params: Record<string, unknown> = {};
|
||||
const pump = optionalString(values, "pump");
|
||||
if (kind === "pump-energy") {
|
||||
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
|
||||
if (pump) params.pump = pump;
|
||||
}
|
||||
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireNetworkCtx: true });
|
||||
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireProject: true });
|
||||
}
|
||||
|
||||
export const componentHandlers: HandlerMap = {
|
||||
|
||||
+23
-19
@@ -2,7 +2,7 @@ import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
|
||||
import { CliError } from "../core/errors.js";
|
||||
import { emitApi } from "../core/http.js";
|
||||
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
|
||||
import { requireNetwork, resolveScheme } from "../core/runtime.js";
|
||||
import { resolveScheme } from "../core/runtime.js";
|
||||
import { parseTime } from "../core/time.js";
|
||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||
|
||||
@@ -20,7 +20,7 @@ function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, "读取实时模拟数据成功", {
|
||||
method: "GET",
|
||||
path: "/realtime/query/by-id-time",
|
||||
path: "/timeseries/realtime/simulation-results",
|
||||
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
|
||||
requireProject: true,
|
||||
});
|
||||
@@ -31,7 +31,7 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
|
||||
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
||||
return emitApi(ctx, "读取实时属性聚合数据成功", {
|
||||
method: "GET",
|
||||
path: "/realtime/query/by-time-property",
|
||||
path: "/timeseries/realtime/records",
|
||||
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
|
||||
requireProject: true,
|
||||
});
|
||||
@@ -41,7 +41,7 @@ function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, "读取方案管道数据成功", {
|
||||
method: "GET",
|
||||
path: "/scheme/links",
|
||||
path: "/timeseries/schemes/links",
|
||||
params: {
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
||||
@@ -56,7 +56,7 @@ function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, "读取方案节点字段成功", {
|
||||
method: "GET",
|
||||
path: `/scheme/nodes/${requiredString(values, "node")}/field`,
|
||||
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
|
||||
params: {
|
||||
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
|
||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||
@@ -80,10 +80,10 @@ function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
};
|
||||
if (query === "by-id-time") {
|
||||
params.id = requiredString(values, "id");
|
||||
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/scheme/query/by-id-time", params, requireProject: true });
|
||||
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/timeseries/schemes/simulation-results", params, requireProject: true });
|
||||
}
|
||||
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
|
||||
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/scheme/query/by-scheme-time-property", params, requireProject: true });
|
||||
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/timeseries/schemes/records", params, requireProject: true });
|
||||
}
|
||||
|
||||
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
@@ -95,7 +95,7 @@ function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
};
|
||||
const field = optionalString(values, "field");
|
||||
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field");
|
||||
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range", params, requireProject: true });
|
||||
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/timeseries/scada-readings/fields" : "/timeseries/scada-readings", params, requireProject: true });
|
||||
}
|
||||
|
||||
function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
@@ -115,7 +115,12 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
params.element_id = feature[0];
|
||||
params.use_cleaned = Boolean(values["use-cleaned"]);
|
||||
}
|
||||
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: `/composite/${kind}`, params, requireProject: true });
|
||||
const paths = {
|
||||
"scada-simulation": "/timeseries/views/scada-simulations",
|
||||
"element-simulation": "/timeseries/views/element-simulations",
|
||||
"element-scada": "/timeseries/views/element-scada-readings",
|
||||
} as const;
|
||||
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: paths[kind], params, requireProject: true });
|
||||
}
|
||||
|
||||
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
@@ -124,33 +129,32 @@ function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
requiredString(values, "start-time");
|
||||
return emitApi(ctx, "读取管道健康预测成功", {
|
||||
method: "GET",
|
||||
path: "/composite/pipeline-health-prediction",
|
||||
params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
||||
path: "/pipeline-health-predictions",
|
||||
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
||||
requireProject: true,
|
||||
requireNetworkCtx: true,
|
||||
});
|
||||
}
|
||||
|
||||
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
||||
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/getscadainfo/", params: { network: requireNetwork(ctx), id: requiredString(values, "id") }, requireNetworkCtx: true });
|
||||
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
|
||||
}
|
||||
|
||||
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
||||
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/getallscadainfo/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true });
|
||||
}
|
||||
|
||||
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/getscheme/", params: { network: requireNetwork(ctx), schema_name: requiredString(values, "name") }, requireNetworkCtx: true });
|
||||
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true });
|
||||
}
|
||||
|
||||
export const dataHandlers: HandlerMap = {
|
||||
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"),
|
||||
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"),
|
||||
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/timeseries/realtime/links"),
|
||||
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
|
||||
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
|
||||
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
|
||||
"data timeseries scheme links": schemeLinks,
|
||||
@@ -161,7 +165,7 @@ export const dataHandlers: HandlerMap = {
|
||||
"data timeseries composite pipeline-health": pipelineHealth,
|
||||
"data scada get": dataScadaGet,
|
||||
"data scada list": dataScadaList,
|
||||
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }),
|
||||
"data scheme get": dataSchemeGet,
|
||||
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/getallschemes/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
|
||||
};
|
||||
|
||||
+15
-16
@@ -1,27 +1,26 @@
|
||||
import { emitApi } from "../core/http.js";
|
||||
import { parseOptions, requiredString } from "../core/options.js";
|
||||
import { requireNetwork } from "../core/runtime.js";
|
||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||
|
||||
function legacyGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
|
||||
function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
|
||||
const { values } = parseOptions(argv);
|
||||
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), [key]: requiredString(values, key) }, requireNetworkCtx: true });
|
||||
return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
|
||||
}
|
||||
|
||||
function legacyGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
||||
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||
function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
||||
return emitApi(ctx, summary, { method: "GET", path, requireProject: true });
|
||||
}
|
||||
|
||||
export const networkHandlers: HandlerMap = {
|
||||
"network get-junction-properties": (ctx, argv) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"),
|
||||
"network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"),
|
||||
"network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"),
|
||||
"network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"),
|
||||
"network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"),
|
||||
"network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"),
|
||||
"network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"),
|
||||
"network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"),
|
||||
"network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"),
|
||||
"network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"),
|
||||
"network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"),
|
||||
"network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"),
|
||||
"network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"),
|
||||
"network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"),
|
||||
"network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
|
||||
"network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"),
|
||||
"network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"),
|
||||
"network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"),
|
||||
"network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
|
||||
"network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"),
|
||||
"network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"),
|
||||
"network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { emitApi } from "../core/http.js";
|
||||
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
|
||||
import { requireNetwork } from "../core/runtime.js";
|
||||
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
|
||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||
|
||||
@@ -9,11 +8,10 @@ function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const start = parseTime(requiredString(values, "start-time"), "--start-time");
|
||||
const duration = requiredNumber(values, "duration");
|
||||
const end = addMinutesPreservingOffset(start, duration);
|
||||
const network = requireNetwork(ctx);
|
||||
return emitApi(
|
||||
ctx,
|
||||
"触发模拟成功",
|
||||
{ method: "POST", path: "/runsimulationmanuallybydate/", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true },
|
||||
{ method: "POST", path: "/simulation-runs", body: { start_time: start.replace(/\.\d+/, ""), duration }, requireProject: true },
|
||||
[
|
||||
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`,
|
||||
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
|
||||
|
||||
@@ -20,10 +20,10 @@ export function readJsonFile(path: string, label: string): unknown {
|
||||
export function parseBurstFile(path: string): [string[], number[]] {
|
||||
let raw = readJsonFile(path, "burst");
|
||||
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
|
||||
if (isRecord(raw) && "burst_ID" in raw && "burst_size" in raw && Array.isArray(raw.burst_ID) && Array.isArray(raw.burst_size)) {
|
||||
const ids = raw.burst_ID.map(String);
|
||||
if (isRecord(raw) && "burst_id" in raw && "burst_size" in raw && Array.isArray(raw.burst_id) && Array.isArray(raw.burst_size)) {
|
||||
const ids = raw.burst_id.map(String);
|
||||
const sizes = raw.burst_size.map(Number);
|
||||
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_ID and burst_size must have the same length", 2);
|
||||
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_id and burst_size must have the same length", 2);
|
||||
return [ids, sizes];
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
@@ -35,7 +35,7 @@ export function parseBurstFile(path: string): [string[], number[]] {
|
||||
raw.map((item) => Number((item as Record<string, unknown>).size)),
|
||||
];
|
||||
}
|
||||
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_ID/burst_size", 2);
|
||||
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_id/burst_size", 2);
|
||||
}
|
||||
|
||||
export function parseValveSettingFile(path: string): [string[], number[]] {
|
||||
@@ -66,4 +66,3 @@ export function assignDatasetKeys(target: Record<string, unknown>, path: string,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CliError, errorMessage } from "./errors.js";
|
||||
import { requireNetwork, requireUsername } from "./runtime.js";
|
||||
import { success } from "./output.js";
|
||||
import type { RequestOptions, RuntimeContext } from "./types.js";
|
||||
|
||||
@@ -19,7 +18,6 @@ function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: bool
|
||||
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
|
||||
out["X-Project-Id"] = ctx.auth.projectId;
|
||||
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
|
||||
if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -37,9 +35,7 @@ function appendParams(url: URL, params: Record<string, unknown> = {}): void {
|
||||
}
|
||||
|
||||
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
|
||||
const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request;
|
||||
if (requireNetworkCtx) requireNetwork(ctx);
|
||||
if (requireUsernameCtx) requireUsername(ctx);
|
||||
const { method, path, params, body, requireAuth = true, requireProject = false } = request;
|
||||
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
||||
appendParams(url, params);
|
||||
const started = performance.now();
|
||||
@@ -93,4 +89,3 @@ export async function emitApi(ctx: RuntimeContext, summary: string, request: Req
|
||||
const [data, durationMs] = await requestJson(ctx, request);
|
||||
success(summary, data, ctx, durationMs, nextCommands);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
||||
server: process.env.TJWATER_SERVER,
|
||||
access_token: process.env.TJWATER_ACCESS_TOKEN,
|
||||
project_id: process.env.TJWATER_PROJECT_ID,
|
||||
user_id: process.env.TJWATER_USER_ID,
|
||||
username: process.env.TJWATER_USERNAME,
|
||||
network: process.env.TJWATER_NETWORK,
|
||||
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
|
||||
};
|
||||
const headers = (raw.headers ?? {}) as unknown;
|
||||
@@ -44,9 +41,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
||||
server: pick(raw, "server", "base_url"),
|
||||
accessToken: pick(raw, "access_token", "token", "accessToken"),
|
||||
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
|
||||
userId: pick(raw, "user_id", "userId", "x_user_id"),
|
||||
username: pick(raw, "username", "preferred_username"),
|
||||
network: pick(raw, "network", "project_code", "projectCode", "project"),
|
||||
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
|
||||
};
|
||||
}
|
||||
@@ -83,19 +77,8 @@ export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext>
|
||||
};
|
||||
}
|
||||
|
||||
export function requireNetwork(ctx: RuntimeContext): string {
|
||||
if (ctx.auth.network) return ctx.auth.network;
|
||||
throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]);
|
||||
}
|
||||
|
||||
export function requireUsername(ctx: RuntimeContext): string {
|
||||
if (ctx.auth.username) return ctx.auth.username;
|
||||
throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]);
|
||||
}
|
||||
|
||||
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
|
||||
const scheme = explicit || ctx.scheme;
|
||||
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
|
||||
return scheme ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@ export interface AuthContext {
|
||||
server: string | null;
|
||||
accessToken: string | null;
|
||||
projectId: string | null;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
network: string | null;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -49,8 +46,6 @@ export interface RequestOptions {
|
||||
body?: unknown;
|
||||
requireAuth?: boolean;
|
||||
requireProject?: boolean;
|
||||
requireNetworkCtx?: boolean;
|
||||
requireUsernameCtx?: boolean;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
|
||||
@@ -86,4 +81,3 @@ export type HelpPayload =
|
||||
menu_level?: number;
|
||||
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"contract_version": "1.0.0",
|
||||
"contracts": {
|
||||
"agent": {
|
||||
"file": "agent-v1.openapi.json",
|
||||
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,6 @@ function normalizeSeenRequest(request) {
|
||||
headers: {
|
||||
authorization: request.headers.authorization,
|
||||
"x-project-id": request.headers["x-project-id"],
|
||||
"x-user-id": request.headers["x-user-id"],
|
||||
},
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
@@ -206,18 +205,17 @@ test("sends auth headers and simulation body through the backend API contract",
|
||||
{
|
||||
server: server.url,
|
||||
access_token: "token-1",
|
||||
network: "tjwater",
|
||||
project_id: "project-1",
|
||||
headers: { "x-extra": "extra" },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(server.seen[0].method, "POST");
|
||||
assert.equal(server.seen[0].url, "/api/v1/runsimulationmanuallybydate/");
|
||||
assert.equal(server.seen[0].url, "/api/v1/simulation-runs");
|
||||
assert.equal(server.seen[0].headers.authorization, "Bearer token-1");
|
||||
assert.equal(server.seen[0].headers["x-extra"], "extra");
|
||||
assert.deepEqual(server.seen[0].body, {
|
||||
name: "tjwater",
|
||||
start_time: "2025-01-02T03:00:00+08:00",
|
||||
duration: 60,
|
||||
});
|
||||
@@ -255,7 +253,7 @@ test("uses project scoped headers for realtime data commands", async () => {
|
||||
assert.equal(server.seen[0].method, "GET");
|
||||
assert.equal(
|
||||
server.seen[0].url,
|
||||
"/api/v1/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
|
||||
"/api/v1/timeseries/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
|
||||
);
|
||||
assert.equal(server.seen[0].headers.authorization, "Bearer token-2");
|
||||
assert.equal(server.seen[0].headers["x-project-id"], "project-1");
|
||||
@@ -280,7 +278,6 @@ test("matches Python CLI backend request shape for every command and key variant
|
||||
access_token: "token",
|
||||
network: "tjwater",
|
||||
project_id: "project-1",
|
||||
user_id: "user-1",
|
||||
username: "alice",
|
||||
headers: { "x-extra": "extra" },
|
||||
};
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"model": "deepseek/deepseek-v4-flash",
|
||||
"server": {
|
||||
"hostname": "127.0.0.1",
|
||||
"port": 4096
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "bun run check",
|
||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||
"contract:generate": "bun scripts/generate-contract.ts",
|
||||
"contract:check": "bun scripts/generate-contract.ts --check",
|
||||
"test:api": "bun test tests/routes tests/contracts",
|
||||
"migrate:session-identity": "bun scripts/migrate-session-identity.ts",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
|
||||
"start": "bun src/server.ts",
|
||||
"start:prod": "bun run check && bun src/server.ts"
|
||||
@@ -26,6 +30,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "7.3.4",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.7.2",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { generateAgentOpenApi } from "../src/contracts/openapi.js";
|
||||
|
||||
const canonicalJson = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
||||
const document = generateAgentOpenApi();
|
||||
const payload = canonicalJson(document);
|
||||
const sha256 = createHash("sha256").update(payload).digest("hex");
|
||||
const manifestPayload = canonicalJson({
|
||||
contract_version: "1.0.0",
|
||||
contracts: {
|
||||
agent: {
|
||||
file: "agent-v1.openapi.json",
|
||||
sha256,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const currentContract = await readFile(
|
||||
"contracts/agent-v1.openapi.json",
|
||||
"utf8",
|
||||
);
|
||||
const currentManifest = await readFile("contracts/manifest.json", "utf8");
|
||||
if (currentContract !== payload || currentManifest !== manifestPayload) {
|
||||
throw new Error(
|
||||
"Agent OpenAPI contract is stale: run `bun run contract:generate`",
|
||||
);
|
||||
}
|
||||
console.log(`validated Agent OpenAPI contract (${sha256})`);
|
||||
} else {
|
||||
await mkdir("contracts", { recursive: true });
|
||||
await writeFile("contracts/agent-v1.openapi.json", payload, "utf8");
|
||||
await writeFile("contracts/manifest.json", manifestPayload, "utf8");
|
||||
console.log(`generated Agent OpenAPI contract (${sha256})`);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type Options = {
|
||||
backupDir: string;
|
||||
dataDir: string;
|
||||
dryRun: boolean;
|
||||
fromActorKey: string;
|
||||
fromProjectId?: string;
|
||||
fromProjectKey?: string;
|
||||
fromUserId?: string;
|
||||
metadataDir: string;
|
||||
memoryDir: string;
|
||||
resultRefsDir: string;
|
||||
toActorKey: string;
|
||||
toProjectId?: string;
|
||||
toProjectKey?: string;
|
||||
toUserId?: string;
|
||||
transcriptsDir: string;
|
||||
};
|
||||
|
||||
type Change = {
|
||||
detail: string;
|
||||
file: string;
|
||||
kind: "metadata" | "transcript" | "result-ref" | "memory";
|
||||
};
|
||||
|
||||
const usage = `Usage:
|
||||
bun scripts/migrate-session-identity.ts --from-user-id <old> --to-user-id <new> [--write]
|
||||
bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]
|
||||
|
||||
Optional:
|
||||
--from-project-id <old> Match old project id
|
||||
--to-project-id <new> Rewrite project id
|
||||
--from-project-key <old> Match old project key
|
||||
--to-project-key <new> Rewrite project key
|
||||
--data-dir <dir> Default: ./data
|
||||
--backup-dir <dir> Default: <data-dir>/backup/session-identity-migration/<timestamp>
|
||||
--write Apply changes. Without this, dry-run only.
|
||||
|
||||
Examples:
|
||||
bun scripts/migrate-session-identity.ts \\
|
||||
--from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\
|
||||
--to-actor-key actor-49a0275a85079f9d
|
||||
|
||||
bun scripts/migrate-session-identity.ts \\
|
||||
--from-actor-key actor-da98d99c20c386e7 \\
|
||||
--to-actor-key actor-49a0275a85079f9d \\
|
||||
--write
|
||||
`;
|
||||
|
||||
const main = async () => {
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
const changes: Change[] = [];
|
||||
|
||||
changes.push(...(await migrateMetadata(options)));
|
||||
changes.push(...(await migrateTranscripts(options)));
|
||||
changes.push(...(await migrateResultRefs(options)));
|
||||
changes.push(...(await migrateUserMemory(options)));
|
||||
|
||||
printSummary(options, changes);
|
||||
};
|
||||
|
||||
const parseOptions = (args: string[]): Options => {
|
||||
const values = new Map<string, string | boolean>();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
throw new Error(`unexpected argument: ${arg}\n\n${usage}`);
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
if (key === "help" || key === "h") {
|
||||
console.log(usage);
|
||||
process.exit(0);
|
||||
}
|
||||
if (key === "write") {
|
||||
values.set(key, true);
|
||||
continue;
|
||||
}
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`missing value for --${key}\n\n${usage}`);
|
||||
}
|
||||
values.set(key, value);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const dataDir = stringOption(values, "data-dir") ?? "./data";
|
||||
const fromUserId = stringOption(values, "from-user-id");
|
||||
const toUserId = stringOption(values, "to-user-id");
|
||||
const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId);
|
||||
const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId);
|
||||
const fromProjectId = stringOption(values, "from-project-id");
|
||||
const toProjectId = stringOption(values, "to-project-id");
|
||||
const fromProjectKey =
|
||||
stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId);
|
||||
const toProjectKey =
|
||||
stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId);
|
||||
|
||||
if (!fromActorKey || !toActorKey) {
|
||||
throw new Error(
|
||||
"provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" +
|
||||
usage,
|
||||
);
|
||||
}
|
||||
if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) {
|
||||
throw new Error("source and target identity are identical");
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupDir =
|
||||
stringOption(values, "backup-dir") ??
|
||||
join(dataDir, "backup", "session-identity-migration", timestamp);
|
||||
|
||||
return {
|
||||
backupDir,
|
||||
dataDir,
|
||||
dryRun: !values.has("write"),
|
||||
fromActorKey,
|
||||
fromProjectId,
|
||||
fromProjectKey,
|
||||
fromUserId,
|
||||
metadataDir: join(dataDir, "session-metadata"),
|
||||
memoryDir: join(dataDir, "memory"),
|
||||
resultRefsDir: join(dataDir, "result-refs"),
|
||||
toActorKey,
|
||||
toProjectId,
|
||||
toProjectKey,
|
||||
toUserId,
|
||||
transcriptsDir: join(dataDir, "session-transcripts"),
|
||||
};
|
||||
};
|
||||
|
||||
const stringOption = (values: Map<string, string | boolean>, key: string) => {
|
||||
const value = values.get(key);
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
};
|
||||
|
||||
const actorKeyFromUserId = (userId?: string) =>
|
||||
userId ? scopedKey("actor", userId) : undefined;
|
||||
|
||||
const projectKeyFromProjectId = (projectId?: string) =>
|
||||
projectId ? scopedKey("project", projectId) : undefined;
|
||||
|
||||
const scopedKey = (prefix: string, value: string) =>
|
||||
`${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`;
|
||||
|
||||
const migrateMetadata = async (options: Options): Promise<Change[]> => {
|
||||
const changes: Change[] = [];
|
||||
for (const file of await listJsonFiles(options.metadataDir)) {
|
||||
const record = await readJson(file);
|
||||
if (!record || record.actorKey !== options.fromActorKey) {
|
||||
continue;
|
||||
}
|
||||
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
|
||||
continue;
|
||||
}
|
||||
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const next: JsonRecord = {
|
||||
...record,
|
||||
actorKey: options.toActorKey,
|
||||
...(options.toUserId ? { ownerUserId: options.toUserId } : {}),
|
||||
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
|
||||
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
|
||||
};
|
||||
|
||||
await writeJsonWithBackup(options, file, next);
|
||||
changes.push({
|
||||
detail: `${record.actorKey} -> ${next.actorKey}`,
|
||||
file,
|
||||
kind: "metadata",
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
};
|
||||
|
||||
const migrateTranscripts = async (options: Options): Promise<Change[]> => {
|
||||
const changes: Change[] = [];
|
||||
for (const file of await listJsonFiles(options.transcriptsDir)) {
|
||||
const transcript = await readJson(file);
|
||||
if (!transcript || transcript.actorKey !== options.fromActorKey) {
|
||||
continue;
|
||||
}
|
||||
if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const next: JsonRecord = {
|
||||
...transcript,
|
||||
actorKey: options.toActorKey,
|
||||
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
|
||||
};
|
||||
const nextPath = join(
|
||||
dirname(file),
|
||||
`${next.actorKey}__${next.projectKey}__${next.sessionId}.json`,
|
||||
);
|
||||
if (nextPath !== file && (await exists(nextPath))) {
|
||||
throw new Error(`refusing to overwrite existing transcript: ${nextPath}`);
|
||||
}
|
||||
|
||||
await writeJsonWithBackup(options, file, next);
|
||||
if (nextPath !== file) {
|
||||
await renameWithBackup(options, file, nextPath);
|
||||
}
|
||||
changes.push({
|
||||
detail: `${basename(file)} -> ${basename(nextPath)}`,
|
||||
file,
|
||||
kind: "transcript",
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
};
|
||||
|
||||
const migrateResultRefs = async (options: Options): Promise<Change[]> => {
|
||||
const changes: Change[] = [];
|
||||
for (const file of await listJsonFiles(options.resultRefsDir)) {
|
||||
const record = await readJson(file);
|
||||
if (!record || record.actorKey !== options.fromActorKey) {
|
||||
continue;
|
||||
}
|
||||
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
|
||||
continue;
|
||||
}
|
||||
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const next: JsonRecord = {
|
||||
...record,
|
||||
actorKey: options.toActorKey,
|
||||
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
|
||||
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
|
||||
};
|
||||
await writeJsonWithBackup(options, file, next);
|
||||
changes.push({
|
||||
detail: `${record.actorKey} -> ${next.actorKey}`,
|
||||
file,
|
||||
kind: "result-ref",
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
};
|
||||
|
||||
const migrateUserMemory = async (options: Options): Promise<Change[]> => {
|
||||
const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`);
|
||||
if (!(await exists(fromFile))) {
|
||||
return [];
|
||||
}
|
||||
const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`);
|
||||
if (fromFile === toFile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (await exists(toFile)) {
|
||||
const [fromContent, toContent] = await Promise.all([
|
||||
readFile(fromFile, "utf8"),
|
||||
readFile(toFile, "utf8"),
|
||||
]);
|
||||
const merged = mergeMemoryMarkdown(toContent, fromContent);
|
||||
await writeTextWithBackup(options, toFile, merged);
|
||||
await removeWithBackup(options, fromFile);
|
||||
} else {
|
||||
await renameWithBackup(options, fromFile, toFile);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
detail: `${basename(fromFile)} -> ${basename(toFile)}`,
|
||||
file: fromFile,
|
||||
kind: "memory",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const mergeMemoryMarkdown = (target: string, source: string) => {
|
||||
const lines = target.split("\n");
|
||||
const existing = new Set(lines.map((line) => line.trim()).filter(Boolean));
|
||||
for (const line of source.split("\n")) {
|
||||
const normalized = line.trim();
|
||||
if (!normalized || existing.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
lines.push(line);
|
||||
existing.add(normalized);
|
||||
}
|
||||
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
|
||||
};
|
||||
|
||||
const listJsonFiles = async (dir: string) => {
|
||||
try {
|
||||
const names = await readdir(dir);
|
||||
return names
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => join(dir, name));
|
||||
} catch (error) {
|
||||
if (isErrno(error, "ENOENT")) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async (file: string): Promise<JsonRecord | null> => {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||
return isRecord(value) ? value : null;
|
||||
} catch (error) {
|
||||
if (isErrno(error, "ENOENT")) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeJsonWithBackup = async (
|
||||
options: Options,
|
||||
file: string,
|
||||
value: JsonRecord,
|
||||
) => {
|
||||
await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const writeTextWithBackup = async (
|
||||
options: Options,
|
||||
file: string,
|
||||
content: string,
|
||||
) => {
|
||||
if (options.dryRun) {
|
||||
return;
|
||||
}
|
||||
await backupFile(options, file);
|
||||
await writeFile(file, content, "utf8");
|
||||
};
|
||||
|
||||
const renameWithBackup = async (options: Options, from: string, to: string) => {
|
||||
if (options.dryRun) {
|
||||
return;
|
||||
}
|
||||
await backupFile(options, from);
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
await rename(from, to);
|
||||
};
|
||||
|
||||
const removeWithBackup = async (options: Options, file: string) => {
|
||||
if (options.dryRun) {
|
||||
return;
|
||||
}
|
||||
await backupFile(options, file);
|
||||
await rm(file, { force: true });
|
||||
};
|
||||
|
||||
const backupFile = async (options: Options, file: string) => {
|
||||
const relativePath = relative(process.cwd(), file);
|
||||
const target = join(options.backupDir, relativePath);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await copyFile(file, target);
|
||||
};
|
||||
|
||||
const exists = async (file: string) => {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isErrno(error, "ENOENT")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isErrno = (error: unknown, code: string) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === code;
|
||||
|
||||
const printSummary = (options: Options, changes: Change[]) => {
|
||||
const counts = changes.reduce<Record<string, number>>((acc, change) => {
|
||||
acc[change.kind] = (acc[change.kind] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied");
|
||||
console.log(`from actor: ${options.fromActorKey}`);
|
||||
console.log(`to actor: ${options.toActorKey}`);
|
||||
if (options.fromProjectKey || options.toProjectKey) {
|
||||
console.log(`project: ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`);
|
||||
}
|
||||
if (!options.dryRun) {
|
||||
console.log(`backup dir: ${options.backupDir}`);
|
||||
}
|
||||
console.log(`changes: ${changes.length}`);
|
||||
for (const [kind, count] of Object.entries(counts)) {
|
||||
console.log(` ${kind}: ${count}`);
|
||||
}
|
||||
for (const change of changes.slice(0, 40)) {
|
||||
console.log(`- [${change.kind}] ${change.detail}`);
|
||||
}
|
||||
if (changes.length > 40) {
|
||||
console.log(`... ${changes.length - 40} more`);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
+112
-13
@@ -3,6 +3,39 @@ import type { NextFunction, Request, Response } from "express";
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export type AgentAuthContext = {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
keycloakSub: string;
|
||||
username: string;
|
||||
role: string;
|
||||
isSuperuser: boolean;
|
||||
projectId: string;
|
||||
network: string;
|
||||
projectRole: string;
|
||||
tokenExpiresAt?: string;
|
||||
};
|
||||
|
||||
type AgentAuthContextResponse = {
|
||||
user_id?: unknown;
|
||||
keycloak_sub?: unknown;
|
||||
username?: unknown;
|
||||
role?: unknown;
|
||||
is_superuser?: unknown;
|
||||
project_id?: unknown;
|
||||
network?: unknown;
|
||||
project_role?: unknown;
|
||||
token_expires_at?: unknown;
|
||||
};
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
agentAuth?: AgentAuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extractBearerToken = (authorization?: string) => {
|
||||
const value = authorization?.trim();
|
||||
if (!value) {
|
||||
@@ -11,8 +44,41 @@ export const extractBearerToken = (authorization?: string) => {
|
||||
return value.replace(/^Bearer\s+/i, "").trim();
|
||||
};
|
||||
|
||||
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token,
|
||||
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。
|
||||
const normalizeAgentAuthContext = (
|
||||
payload: AgentAuthContextResponse,
|
||||
accessToken: string,
|
||||
): AgentAuthContext | null => {
|
||||
if (
|
||||
typeof payload.user_id !== "string" ||
|
||||
typeof payload.keycloak_sub !== "string" ||
|
||||
typeof payload.username !== "string" ||
|
||||
typeof payload.role !== "string" ||
|
||||
typeof payload.is_superuser !== "boolean" ||
|
||||
typeof payload.project_id !== "string" ||
|
||||
typeof payload.network !== "string" ||
|
||||
typeof payload.project_role !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
userId: payload.user_id,
|
||||
keycloakSub: payload.keycloak_sub,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
isSuperuser: payload.is_superuser,
|
||||
projectId: payload.project_id,
|
||||
network: payload.network,
|
||||
projectRole: payload.project_role,
|
||||
tokenExpiresAt:
|
||||
typeof payload.token_expires_at === "string"
|
||||
? payload.token_expires_at
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Agent API 使用 TJWater 后端专用认证上下文:后端负责校验 Keycloak token、
|
||||
// metadata 用户状态和项目 membership,Agent 只信任后端返回的 user/project。
|
||||
export const requireAgentAuth = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -24,37 +90,70 @@ export const requireAgentAuth = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.header("x-project-id")?.trim();
|
||||
if (!projectId) {
|
||||
res.status(400).json({ message: "x-project-id is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(new URL("/api/v1/auth/me", config.TJWATER_API_BASE_URL), {
|
||||
const response = await fetch(new URL("/api/v1/agent-auth-context", config.TJWATER_API_BASE_URL), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Project-Id": projectId,
|
||||
...(req.header("x-trace-id") ? { "X-Trace-Id": req.header("x-trace-id") as string } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const context = normalizeAgentAuthContext(
|
||||
(await response.json()) as AgentAuthContextResponse,
|
||||
token,
|
||||
);
|
||||
if (!context) {
|
||||
res.status(502).json({ message: "invalid authentication context" });
|
||||
return;
|
||||
}
|
||||
req.agentAuth = context;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await response.text();
|
||||
res.status(response.status === 403 ? 403 : 401).json({
|
||||
message: response.status === 403 ? "forbidden" : "unauthorized",
|
||||
detail: detail || undefined,
|
||||
});
|
||||
const status =
|
||||
response.status === 400 ||
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
response.status === 404
|
||||
? response.status
|
||||
: response.status === 503
|
||||
? 503
|
||||
: 502;
|
||||
const messages: Record<number, string> = {
|
||||
400: "invalid authentication request",
|
||||
401: "unauthorized",
|
||||
403: "forbidden",
|
||||
404: "authentication context not found",
|
||||
502: "invalid authentication service response",
|
||||
503: "authentication service unavailable",
|
||||
};
|
||||
res.status(status).json({ message: messages[status] });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ err: error }, "agent auth validation failed");
|
||||
res.status(503).json({
|
||||
message: "authentication service unavailable",
|
||||
detail,
|
||||
});
|
||||
res.status(503).json({ message: "authentication service unavailable" });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
export const getAgentAuthContext = (req: Request) => {
|
||||
if (!req.agentAuth) {
|
||||
throw new Error("agent auth context is missing");
|
||||
}
|
||||
return req.agentAuth;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export type SupportedModel = string;
|
||||
export type AgentModelIcon = "bolt" | "sparkle";
|
||||
|
||||
export type AgentModelOption = {
|
||||
id: SupportedModel;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: AgentModelIcon;
|
||||
};
|
||||
|
||||
export const defaultAgentModelOptions: AgentModelOption[] = [
|
||||
{
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
label: "快速",
|
||||
description: "快速回答和任务执行",
|
||||
icon: "bolt",
|
||||
},
|
||||
{
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
label: "专家",
|
||||
description: "探索、解决复杂任务",
|
||||
icon: "sparkle",
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions);
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const parseAgentModelOptions = (value: string): AgentModelOption[] => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON");
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array");
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return parsed.map((item, index) => {
|
||||
if (!isObjectRecord(item)) {
|
||||
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}] must be an object`);
|
||||
}
|
||||
const id = typeof item.id === "string" ? item.id.trim() : "";
|
||||
if (!id) {
|
||||
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}].id is required`);
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
throw new Error(`duplicate OPENCODE_MODEL_OPTIONS id: ${id}`);
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const label =
|
||||
typeof item.label === "string" && item.label.trim()
|
||||
? item.label.trim()
|
||||
: id;
|
||||
const description =
|
||||
typeof item.description === "string" && item.description.trim()
|
||||
? item.description.trim()
|
||||
: label;
|
||||
const icon = item.icon === "bolt" || item.icon === "sparkle"
|
||||
? item.icon
|
||||
: "sparkle";
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getAgentModelIds = (options: AgentModelOption[]) =>
|
||||
options.map((option) => option.id);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
getAgentModelIds,
|
||||
parseAgentModelOptions,
|
||||
type SupportedModel,
|
||||
} from "./modelConfig.js";
|
||||
|
||||
export {
|
||||
type AgentModelIcon,
|
||||
type AgentModelOption,
|
||||
type SupportedModel,
|
||||
} from "./modelConfig.js";
|
||||
|
||||
export const agentModelOptions = parseAgentModelOptions(
|
||||
config.OPENCODE_MODEL_OPTIONS,
|
||||
);
|
||||
|
||||
export const supportedModels = getAgentModelIds(agentModelOptions);
|
||||
|
||||
export const isSupportedModel = (model: string): model is SupportedModel =>
|
||||
supportedModels.includes(model);
|
||||
|
||||
export const resolveDefaultModel = (model: string): SupportedModel => {
|
||||
if (!isSupportedModel(model)) {
|
||||
throw new Error(`unsupported default agent model: ${model}`);
|
||||
}
|
||||
return model;
|
||||
};
|
||||
@@ -17,7 +17,9 @@ export type SessionBinding = {
|
||||
export type SessionContext = {
|
||||
clientSessionId: string;
|
||||
accessToken?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
tokenExpiresAt?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
@@ -35,8 +37,10 @@ export class ChatSessionBridge {
|
||||
async resolve(context: {
|
||||
sessionId?: string;
|
||||
accessToken?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
tokenExpiresAt?: string;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
binding: SessionBinding;
|
||||
@@ -69,9 +73,11 @@ export class ChatSessionBridge {
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
learningMode: "interactive",
|
||||
network: requestContext.network,
|
||||
projectId: requestContext.projectId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId,
|
||||
tokenExpiresAt: requestContext.tokenExpiresAt,
|
||||
traceId: requestContext.traceId,
|
||||
});
|
||||
|
||||
@@ -141,8 +147,10 @@ export class ChatSessionBridge {
|
||||
private buildRequestContext(context: {
|
||||
sessionId?: string;
|
||||
accessToken?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
tokenExpiresAt?: string;
|
||||
userId?: string;
|
||||
}): ChatRequestContext {
|
||||
const sessionId = context.sessionId?.trim();
|
||||
@@ -150,8 +158,10 @@ export class ChatSessionBridge {
|
||||
clientSessionId: sessionId || this.createClientSessionId(),
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
network: context.network,
|
||||
projectId: context.projectId,
|
||||
projectKey: toProjectKey(context.projectId),
|
||||
tokenExpiresAt: context.tokenExpiresAt,
|
||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||
userId: context.userId?.trim(),
|
||||
};
|
||||
|
||||
+36
-2
@@ -1,6 +1,12 @@
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
defaultAgentModelOptionsJson,
|
||||
getAgentModelIds,
|
||||
parseAgentModelOptions,
|
||||
} from "./chat/modelConfig.js";
|
||||
|
||||
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
|
||||
dotenv.config({ path: ".local.env", override: false });
|
||||
|
||||
@@ -33,7 +39,7 @@ const envSchema = z
|
||||
.default("./logs/llm-request-audit.log"),
|
||||
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
|
||||
AGENT_INTERNAL_TOKEN: optionalString(),
|
||||
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。
|
||||
// Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
|
||||
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
|
||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
||||
@@ -44,7 +50,9 @@ const envSchema = z
|
||||
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
||||
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// 默认使用的 opencode 模型标识。
|
||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-flash"),
|
||||
// 聊天 UI 和 /stream 允许选择的 opencode 模型完整配置,JSON 数组。
|
||||
OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson),
|
||||
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
|
||||
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
||||
// client 模式下,目标 opencode server 的基础地址。
|
||||
@@ -116,6 +124,32 @@ const envSchema = z
|
||||
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
||||
});
|
||||
}
|
||||
let modelOptions;
|
||||
try {
|
||||
modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["OPENCODE_MODEL_OPTIONS"],
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const supportedModels = getAgentModelIds(modelOptions);
|
||||
if (supportedModels.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["OPENCODE_MODEL_OPTIONS"],
|
||||
message: "OPENCODE_MODEL_OPTIONS must include at least one model",
|
||||
});
|
||||
}
|
||||
if (!supportedModels.includes(env.OPENCODE_MODEL)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["OPENCODE_MODEL"],
|
||||
message: "OPENCODE_MODEL must be included in OPENCODE_MODEL_OPTIONS",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
extendZodWithOpenApi,
|
||||
OpenAPIRegistry,
|
||||
OpenApiGeneratorV3,
|
||||
} from "@asteasolutions/zod-to-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
const registry = new OpenAPIRegistry();
|
||||
registry.registerComponent("securitySchemes", "bearerAuth", {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
});
|
||||
|
||||
const SessionId = z.object({ session_id: z.string().max(128) });
|
||||
const RenderRef = z.object({ render_ref: z.string().min(1) });
|
||||
const RenderReferenceQuery = z.object({
|
||||
session_id: z.string().max(128).optional(),
|
||||
});
|
||||
const SessionCreate = z
|
||||
.object({
|
||||
session_id: z.string(),
|
||||
title: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
status: z.string(),
|
||||
parent_session_id: z.string().nullable().optional(),
|
||||
})
|
||||
.openapi("AgentSessionCreate");
|
||||
const SessionSummary = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
status: z.string(),
|
||||
parent_session_id: z.string().nullable().optional(),
|
||||
is_streaming: z.boolean(),
|
||||
run_status: z.string().nullable().optional(),
|
||||
})
|
||||
.openapi("AgentSessionSummary");
|
||||
const SessionDetail = SessionSummary.extend({
|
||||
session_id: z.string(),
|
||||
is_title_manually_edited: z.boolean(),
|
||||
messages: z.array(z.unknown()),
|
||||
}).openapi("AgentSessionDetail");
|
||||
const SessionUpdate = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.openapi("AgentSessionUpdate");
|
||||
const SessionFork = z
|
||||
.object({
|
||||
session_id: z.string(),
|
||||
})
|
||||
.openapi("AgentSessionFork");
|
||||
const Problem = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
status: z.number().int(),
|
||||
detail: z.string(),
|
||||
instance: z.string(),
|
||||
code: z.string(),
|
||||
trace_id: z.string(),
|
||||
errors: z.array(z.unknown()),
|
||||
})
|
||||
.openapi("ProblemDetails");
|
||||
const JsonObject = z.record(z.unknown());
|
||||
const errorResponses = {
|
||||
400: {
|
||||
description: "Invalid request",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
401: {
|
||||
description: "Authentication required",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
403: {
|
||||
description: "Insufficient permission",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
404: {
|
||||
description: "Resource not found",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
409: {
|
||||
description: "Resource conflict",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
422: {
|
||||
description: "Validation error",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
500: {
|
||||
description: "Internal server error",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
502: {
|
||||
description: "Upstream dependency error",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
503: {
|
||||
description: "Dependency unavailable",
|
||||
content: { "application/problem+json": { schema: Problem } },
|
||||
},
|
||||
};
|
||||
const jsonResponse = (schema: z.ZodTypeAny, description = "Successful response") => ({
|
||||
description,
|
||||
content: { "application/json": { schema } },
|
||||
});
|
||||
type RegisterConfig = {
|
||||
summary: string;
|
||||
request?: Record<string, unknown>;
|
||||
responses: Record<number, unknown>;
|
||||
};
|
||||
|
||||
export const createRouteRegistrar = (targetRegistry: OpenAPIRegistry) => {
|
||||
const operations = new Set<string>();
|
||||
|
||||
return (
|
||||
path: string,
|
||||
method: "get" | "post" | "patch" | "delete",
|
||||
config: RegisterConfig,
|
||||
) => {
|
||||
const key = `${method.toUpperCase()} ${path}`;
|
||||
if (operations.has(key)) {
|
||||
throw new Error(`Duplicate Agent OpenAPI operation: ${key}`);
|
||||
}
|
||||
operations.add(key);
|
||||
targetRegistry.registerPath({
|
||||
path,
|
||||
method,
|
||||
operationId: `${method}_${path
|
||||
.replace(/^\/api\/v1\/agent\/?/, "")
|
||||
.replaceAll(/[{}]/g, "")
|
||||
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
|
||||
.replaceAll(/^_|_$/g, "")}`,
|
||||
tags: ["Agent"],
|
||||
security: [{ bearerAuth: [] }],
|
||||
...config,
|
||||
responses: { ...config.responses, ...errorResponses },
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const register = createRouteRegistrar(registry);
|
||||
|
||||
register("/api/v1/agent/models", "get", {
|
||||
summary: "List available agent models",
|
||||
responses: { 200: jsonResponse(JsonObject) },
|
||||
});
|
||||
register("/api/v1/agent/sessions", "get", {
|
||||
summary: "List agent sessions",
|
||||
responses: { 200: jsonResponse(z.object({ sessions: z.array(SessionSummary) })) },
|
||||
});
|
||||
register("/api/v1/agent/sessions", "post", {
|
||||
summary: "Create an agent session",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
session_id: z.string().max(128).optional(),
|
||||
parent_session_id: z.string().max(128).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(SessionCreate, "Existing session returned"),
|
||||
201: jsonResponse(SessionCreate, "Session created"),
|
||||
},
|
||||
});
|
||||
register("/api/v1/agent/sessions/{session_id}", "get", {
|
||||
summary: "Get an agent session",
|
||||
request: { params: SessionId },
|
||||
responses: { 200: jsonResponse(SessionDetail) },
|
||||
});
|
||||
register("/api/v1/agent/sessions/{session_id}", "patch", {
|
||||
summary: "Update an agent session",
|
||||
request: {
|
||||
params: SessionId,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
is_title_manually_edited: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: jsonResponse(SessionUpdate) },
|
||||
});
|
||||
register("/api/v1/agent/sessions/{session_id}", "delete", {
|
||||
summary: "Delete an agent session",
|
||||
request: { params: SessionId },
|
||||
responses: { 204: { description: "Session deleted" } },
|
||||
});
|
||||
register("/api/v1/agent/sessions/{session_id}/runs", "post", {
|
||||
summary: "Run an agent session",
|
||||
request: {
|
||||
params: SessionId,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
message: z.string().min(1).max(10000),
|
||||
model: z.string().optional(),
|
||||
approval_mode: z.enum(["request", "always"]).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Agent event stream",
|
||||
content: { "text/event-stream": { schema: z.string() } },
|
||||
},
|
||||
},
|
||||
});
|
||||
register(
|
||||
"/api/v1/agent/sessions/{session_id}/runs/current/events",
|
||||
"get",
|
||||
{
|
||||
summary: "Resume the current agent event stream",
|
||||
request: { params: SessionId },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Agent event stream",
|
||||
content: { "text/event-stream": { schema: z.string() } },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
|
||||
summary: "Abort the current agent run",
|
||||
request: { params: SessionId },
|
||||
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
|
||||
});
|
||||
register(
|
||||
"/api/v1/agent/sessions/{session_id}/permission-responses",
|
||||
"post",
|
||||
{
|
||||
summary: "Reply to an agent permission request",
|
||||
request: {
|
||||
params: SessionId,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
request_id: z.string(),
|
||||
reply: z.enum(["once", "always", "reject"]),
|
||||
message: z.string().max(1000).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 202: jsonResponse(JsonObject) },
|
||||
},
|
||||
);
|
||||
register(
|
||||
"/api/v1/agent/sessions/{session_id}/question-responses",
|
||||
"post",
|
||||
{
|
||||
summary: "Reply to or reject an agent question",
|
||||
request: {
|
||||
params: SessionId,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
request_id: z.string(),
|
||||
action: z.enum(["reply", "reject"]).default("reply"),
|
||||
answers: z.array(z.array(z.string().max(2000))).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 202: jsonResponse(JsonObject) },
|
||||
},
|
||||
);
|
||||
register("/api/v1/agent/sessions/{session_id}/forks", "post", {
|
||||
summary: "Fork an agent session",
|
||||
request: {
|
||||
params: SessionId,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
keep_message_count: z.number().int().nonnegative(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: jsonResponse(SessionFork) },
|
||||
});
|
||||
register("/api/v1/agent/render-references/{render_ref}", "get", {
|
||||
summary: "Resolve a render reference",
|
||||
request: { params: RenderRef, query: RenderReferenceQuery },
|
||||
responses: { 200: jsonResponse(JsonObject) },
|
||||
});
|
||||
|
||||
export const generateAgentOpenApi = () => {
|
||||
const generator = new OpenApiGeneratorV3(registry.definitions);
|
||||
return generator.generateDocument({
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "TJWater Agent API",
|
||||
version: "1.0.0",
|
||||
description: "Public REST API for TJWater Agent sessions and runs",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { writeLearningAuditLog } from "../audit/learningAudit.js";
|
||||
import { type SupportedModel } from "../chat/models.js";
|
||||
import { type ChatRequestContext } from "../chat/sessionBridge.js";
|
||||
import { config } from "../config.js";
|
||||
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
@@ -64,8 +65,6 @@ const reviewResultSchema = z.object({
|
||||
type GateResult = z.infer<typeof gateResultSchema>;
|
||||
type ReviewResult = z.infer<typeof reviewResultSchema>;
|
||||
|
||||
type SupportedModel = "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro";
|
||||
|
||||
type TurnReviewInput = {
|
||||
assistantMessage: string;
|
||||
model?: SupportedModel;
|
||||
|
||||
+157
-135
@@ -1,6 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||
import {
|
||||
agentModelOptions,
|
||||
isSupportedModel,
|
||||
resolveDefaultModel,
|
||||
type SupportedModel,
|
||||
} from "../chat/models.js";
|
||||
import { config } from "../config.js";
|
||||
import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import { logger } from "../logger.js";
|
||||
@@ -11,6 +19,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||
import {
|
||||
type OpencodeRuntimeAdapter,
|
||||
} from "../runtime/opencode.js";
|
||||
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
@@ -28,14 +37,13 @@ import {
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
streamPromptResponse,
|
||||
supportedModels,
|
||||
type SupportedModel,
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStream.js";
|
||||
import {
|
||||
type ActiveRun,
|
||||
type RunStatus,
|
||||
type StreamSubscriber,
|
||||
appendBackendToolArtifact,
|
||||
cancelBackendTodos,
|
||||
completeBackendProgress,
|
||||
createInitialStreamingMessages,
|
||||
@@ -53,7 +61,9 @@ import {
|
||||
const payloadSchema = z.object({
|
||||
message: z.string().min(1).max(10000),
|
||||
session_id: z.string().max(128).optional(),
|
||||
model: z.enum(supportedModels).optional(),
|
||||
model: z.string().refine(isSupportedModel, {
|
||||
message: "unsupported model",
|
||||
}).optional(),
|
||||
approval_mode: z.enum(["request", "always"]).optional().default("request"),
|
||||
});
|
||||
|
||||
@@ -67,13 +77,6 @@ const forkPayloadSchema = z.object({
|
||||
keep_message_count: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
const sessionStateSchema = z.object({
|
||||
title: z.string().max(120).optional(),
|
||||
is_title_manually_edited: z.boolean().optional(),
|
||||
messages: z.array(z.unknown()).default([]),
|
||||
branch_groups: z.array(z.unknown()).default([]),
|
||||
});
|
||||
|
||||
const activeRuns = new Map<string, ActiveRun>();
|
||||
const lastRunStatuses = new Map<string, RunStatus>();
|
||||
|
||||
@@ -81,6 +84,20 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
|
||||
export const buildForkedSessionUiState = (
|
||||
sourceState: { messages?: unknown[] } | null | undefined,
|
||||
input: {
|
||||
keepMessageCount: number;
|
||||
targetSessionId: string;
|
||||
},
|
||||
) => ({
|
||||
sessionId: input.targetSessionId,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: Array.isArray(sourceState?.messages)
|
||||
? sourceState.messages.slice(0, input.keepMessageCount)
|
||||
: [],
|
||||
});
|
||||
|
||||
const getSessionRunStatus = (sessionId: string) =>
|
||||
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
|
||||
|
||||
@@ -107,7 +124,14 @@ export const buildChatRouter = (
|
||||
) => {
|
||||
const chatRouter = Router();
|
||||
|
||||
chatRouter.post("/session", async (req, res) => {
|
||||
chatRouter.get("/models", (_req, res) => {
|
||||
res.json({
|
||||
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
|
||||
models: agentModelOptions,
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.post("/sessions", async (req, res) => {
|
||||
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
@@ -117,8 +141,9 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
@@ -144,8 +169,9 @@ export const buildChatRouter = (
|
||||
});
|
||||
|
||||
chatRouter.get("/sessions", async (req, res) => {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const records = await sessionMetadataStore.list({
|
||||
@@ -168,10 +194,11 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
chatRouter.get("/sessions/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
@@ -205,17 +232,17 @@ export const buildChatRouter = (
|
||||
status: sessionRecord.status,
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: state?.messages ?? [],
|
||||
branch_groups: state?.branchGroups ?? [],
|
||||
parent_session_id: sessionRecord.parentSessionId,
|
||||
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
|
||||
run_status: getSessionRunStatus(sessionRecord.sessionId),
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:sessionId/stream", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
@@ -276,82 +303,17 @@ export const buildChatRouter = (
|
||||
res.on("close", cleanup);
|
||||
});
|
||||
|
||||
chatRouter.put("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const parsed = sessionStateSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
detail: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ message: "session_id is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { record } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
const nextRecord = await sessionMetadataStore.touch(record, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
});
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
|
||||
sessionId: nextRecord.sessionId,
|
||||
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
|
||||
messages: parsed.data.messages,
|
||||
branchGroups: parsed.data.branch_groups,
|
||||
});
|
||||
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
|
||||
if (latestTurn) {
|
||||
void learningOrchestrator.onTurnCompleted({
|
||||
...latestTurn,
|
||||
requestContext: {
|
||||
actorKey,
|
||||
clientSessionId: nextRecord.sessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
|
||||
userId,
|
||||
},
|
||||
sessionId: nextRecord.sessionId,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: nextRecord.sessionId },
|
||||
"post-save learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
id: nextRecord.sessionId,
|
||||
title: nextRecord.title ?? "新对话",
|
||||
created_at: nextRecord.createdAt,
|
||||
updated_at: nextRecord.updatedAt,
|
||||
status: nextRecord.status,
|
||||
session_id: nextRecord.sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.patch("/session/:sessionId/title", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
chatRouter.patch("/sessions/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const title =
|
||||
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
||||
const isTitleManuallyEdited =
|
||||
typeof req.body?.is_title_manually_edited === "boolean"
|
||||
? req.body.is_title_manually_edited
|
||||
: undefined;
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId || !title) {
|
||||
@@ -387,10 +349,11 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.delete("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
chatRouter.delete("/sessions/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
@@ -432,8 +395,11 @@ export const buildChatRouter = (
|
||||
sessionUiStateStore,
|
||||
});
|
||||
|
||||
chatRouter.post("/fork", async (req, res) => {
|
||||
const parsed = forkPayloadSchema.safeParse(req.body);
|
||||
chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
|
||||
const parsed = forkPayloadSchema.safeParse({
|
||||
...req.body,
|
||||
session_id: req.params.session_id,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -443,9 +409,10 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const traceId = req.header("x-trace-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const userId = authContext.userId;
|
||||
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
@@ -461,6 +428,10 @@ export const buildChatRouter = (
|
||||
sourceSessionId,
|
||||
)
|
||||
: null;
|
||||
if (!sourceSessionId || !sourceSessionRecord) {
|
||||
res.status(404).json({ message: "source session not found" });
|
||||
return;
|
||||
}
|
||||
const forkSession = await runtime.createSession();
|
||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
@@ -472,28 +443,38 @@ export const buildChatRouter = (
|
||||
});
|
||||
const nextSessionId = targetSessionRecord.sessionId;
|
||||
|
||||
if (sourceSessionId && parsed.data.keep_message_count > 0) {
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextSessionId,
|
||||
projectKey,
|
||||
sessionId: nextSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
if (sourceSessionRecord?.title) {
|
||||
await sessionMetadataStore.touch(targetSessionRecord, {
|
||||
title: sourceSessionRecord.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextSessionId,
|
||||
projectKey,
|
||||
sessionId: nextSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
const sourceState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sourceSessionRecord),
|
||||
);
|
||||
const forkTitle = sourceSessionRecord.title
|
||||
? `${sourceSessionRecord.title} 副本`
|
||||
: "新对话副本";
|
||||
const titledTargetSessionRecord = await sessionMetadataStore.touch(
|
||||
targetSessionRecord,
|
||||
{ title: forkTitle },
|
||||
);
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(titledTargetSessionRecord),
|
||||
buildForkedSessionUiState(sourceState, {
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
targetSessionId: nextSessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
@@ -519,8 +500,11 @@ export const buildChatRouter = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/stream", async (req, res) => {
|
||||
const parsed = payloadSchema.safeParse(req.body);
|
||||
chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
|
||||
const parsed = payloadSchema.safeParse({
|
||||
...req.body,
|
||||
session_id: req.params.session_id,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -530,13 +514,11 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.header("authorization");
|
||||
const accessToken = authHeader?.startsWith("Bearer ")
|
||||
? authHeader.slice("Bearer ".length)
|
||||
: authHeader;
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const accessToken = authContext.accessToken;
|
||||
const projectId = authContext.projectId;
|
||||
const traceId = req.header("x-trace-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
@@ -551,8 +533,10 @@ export const buildChatRouter = (
|
||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||
sessionId: requestedSessionId,
|
||||
accessToken,
|
||||
network: authContext.network,
|
||||
projectId,
|
||||
traceId,
|
||||
tokenExpiresAt: authContext.tokenExpiresAt,
|
||||
userId,
|
||||
});
|
||||
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||
@@ -619,7 +603,6 @@ export const buildChatRouter = (
|
||||
baseMessages,
|
||||
parsed.data.message,
|
||||
);
|
||||
const branchGroups = initialSessionState?.branchGroups ?? [];
|
||||
const activeRun: ActiveRun = {
|
||||
clientSessionId,
|
||||
controller: abortController,
|
||||
@@ -636,14 +619,12 @@ export const buildChatRouter = (
|
||||
sessionId: activeSessionRecord.sessionId,
|
||||
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||
messages: initialMessages,
|
||||
branchGroups,
|
||||
});
|
||||
const queueSessionUiStatePersist = () => {
|
||||
const snapshot = {
|
||||
sessionId: activeSessionRecord.sessionId,
|
||||
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||
messages: activeRun.messages,
|
||||
branchGroups,
|
||||
};
|
||||
persistQueue = persistQueue
|
||||
.catch((error) => {
|
||||
@@ -712,6 +693,19 @@ export const buildChatRouter = (
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
} else if (event === "auth_required") {
|
||||
activeRun.status = "error";
|
||||
lastRunStatuses.set(clientSessionId, "error");
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof data.message === "string"
|
||||
? `⚠️ **${data.message}**`
|
||||
: "⚠️ **登录态已过期,请刷新登录后重试**",
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
} else if (event === "permission_request") {
|
||||
const payload = data as PermissionRequestPayload;
|
||||
activeRun.pendingPermissions.set(payload.request_id, payload);
|
||||
@@ -795,6 +789,11 @@ export const buildChatRouter = (
|
||||
...message,
|
||||
todos: upsertBackendTodoUpdate(message.todos, payload),
|
||||
}));
|
||||
} else if (event === "tool_call") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
artifacts: appendBackendToolArtifact(message.artifacts, data),
|
||||
}));
|
||||
}
|
||||
|
||||
for (const subscriber of activeRun.subscribers) {
|
||||
@@ -835,6 +834,16 @@ export const buildChatRouter = (
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
|
||||
const latestRuntimeContext = getRuntimeSessionContext(binding.sessionId);
|
||||
if (latestRuntimeContext?.authExpired) {
|
||||
publish("auth_required", {
|
||||
session_id: clientSessionId,
|
||||
reason: latestRuntimeContext.authExpired.reason,
|
||||
message: latestRuntimeContext.authExpired.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streamResult.aborted && !streamResult.failed) {
|
||||
const messages = await runtime.messages(binding.sessionId, 60);
|
||||
const assistantMessage = [...messages]
|
||||
@@ -882,6 +891,19 @@ export const buildChatRouter = (
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
}
|
||||
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
|
||||
if (latestTurn) {
|
||||
void learningOrchestrator.onTurnCompleted({
|
||||
...latestTurn,
|
||||
requestContext,
|
||||
sessionId: clientSessionId,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: clientSessionId },
|
||||
"stream-completed learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (abortController.signal.aborted) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
updateLastAssistantMessage,
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const abortPayloadSchema = z.object({
|
||||
const abortParamsSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
});
|
||||
|
||||
@@ -44,22 +45,16 @@ export const registerChatAuxiliaryRoutes = (
|
||||
sessionUiStateStore,
|
||||
}: RegisterAuxiliaryRoutesOptions,
|
||||
) => {
|
||||
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
|
||||
const renderRef = req.params.renderRef?.trim();
|
||||
const userId = req.header("x-user-id")?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
chatRouter.get("/render-references/:render_ref", async (req, res) => {
|
||||
const renderRef = req.params.render_ref?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const userId = authContext.userId;
|
||||
const projectId = authContext.projectId;
|
||||
const clientSessionId =
|
||||
typeof req.query.session_id === "string"
|
||||
? req.query.session_id.trim()
|
||||
: undefined;
|
||||
|
||||
if (!userId) {
|
||||
res.status(400).json({
|
||||
message: "x-user-id is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderRef) {
|
||||
res.status(400).json({
|
||||
message: "render_ref is required",
|
||||
@@ -87,8 +82,8 @@ export const registerChatAuxiliaryRoutes = (
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
chatRouter.post("/abort", async (req, res) => {
|
||||
const parsed = abortPayloadSchema.safeParse(req.body);
|
||||
chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
|
||||
const parsed = abortParamsSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -98,8 +93,9 @@ export const registerChatAuxiliaryRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
@@ -135,7 +131,6 @@ export const registerChatAuxiliaryRoutes = (
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
branchGroups: currentState?.branchGroups ?? [],
|
||||
});
|
||||
}
|
||||
for (const subscriber of run.subscribers) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
@@ -14,20 +15,17 @@ import {
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const permissionReplyPayloadSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
request_id: z.string().min(1),
|
||||
reply: z.enum(["once", "always", "reject"]),
|
||||
message: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const questionReplyPayloadSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
request_id: z.string().min(1),
|
||||
action: z.enum(["reply", "reject"]).default("reply"),
|
||||
answers: z.array(z.array(z.string().max(2000))).default([]),
|
||||
});
|
||||
|
||||
const questionRejectPayloadSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
});
|
||||
|
||||
type RegisterInteractionRoutesOptions = {
|
||||
activeRuns: Map<string, ActiveRun>;
|
||||
runtime: OpencodeRuntimeAdapter;
|
||||
@@ -48,13 +46,8 @@ export const registerChatInteractionRoutes = (
|
||||
sessionUiStateStore,
|
||||
}: RegisterInteractionRoutesOptions,
|
||||
) => {
|
||||
chatRouter.post("/permission/:requestId/reply", async (req, res) => {
|
||||
const requestId = req.params.requestId?.trim();
|
||||
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
|
||||
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -64,13 +57,15 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestId = parsed.data.request_id;
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
req.params.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
@@ -96,7 +91,6 @@ export const registerChatInteractionRoutes = (
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
branchGroups: currentState?.branchGroups ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -173,13 +167,8 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:requestId/reply", async (req, res) => {
|
||||
const requestId = req.params.requestId?.trim();
|
||||
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
|
||||
const parsed = questionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -189,13 +178,15 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestId = parsed.data.request_id;
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
req.params.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
@@ -221,16 +212,22 @@ export const registerChatInteractionRoutes = (
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
branchGroups: currentState?.branchGroups ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await runtime.replyQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
if (parsed.data.action === "reject") {
|
||||
await runtime.rejectQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
} else {
|
||||
await runtime.replyQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
@@ -241,7 +238,7 @@ export const registerChatInteractionRoutes = (
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reply question",
|
||||
: `failed to ${parsed.data.action} question`,
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
@@ -251,7 +248,7 @@ export const registerChatInteractionRoutes = (
|
||||
);
|
||||
});
|
||||
res.status(502).json({
|
||||
message: "question reply failed",
|
||||
message: `question ${parsed.data.action} failed`,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
@@ -263,8 +260,9 @@ export const registerChatInteractionRoutes = (
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "answered",
|
||||
answers: parsed.data.answers,
|
||||
status: parsed.data.action === "reject" ? "rejected" : "answered",
|
||||
answers:
|
||||
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
@@ -279,7 +277,9 @@ export const registerChatInteractionRoutes = (
|
||||
subscriber.write("question_response", {
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
answers: parsed.data.answers,
|
||||
...(parsed.data.action === "reject"
|
||||
? { rejected: true }
|
||||
: { answers: parsed.data.answers }),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -293,143 +293,15 @@ export const registerChatInteractionRoutes = (
|
||||
res.status(202).json({
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
answers: parsed.data.answers,
|
||||
...(parsed.data.action === "reject"
|
||||
? { rejected: true }
|
||||
: { answers: parsed.data.answers }),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error }, "question reply route failed");
|
||||
logger.error({ err: error }, "question response route failed");
|
||||
res.status(500).json({
|
||||
message: "question reply route failed",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:requestId/reject", async (req, res) => {
|
||||
const requestId = req.params.requestId?.trim();
|
||||
const parsed = questionRejectPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
detail: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
if (!run) {
|
||||
res.status(409).json({ message: "session is not waiting for questions" });
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingQuestion = run.pendingQuestions.get(requestId);
|
||||
if (!pendingQuestion) {
|
||||
res.status(404).json({ message: "question request not found" });
|
||||
return;
|
||||
}
|
||||
const persistQuestionState = async () => {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord.sessionId),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
branchGroups: currentState?.branchGroups ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await runtime.rejectQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reject question",
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question error state",
|
||||
);
|
||||
});
|
||||
res.status(502).json({
|
||||
message: "question reject failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
run.pendingQuestions.delete(requestId);
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "rejected",
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question reject state",
|
||||
);
|
||||
});
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("question_response", {
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
rejected: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
run.status !== "running" &&
|
||||
run.pendingPermissions.size === 0 &&
|
||||
run.pendingQuestions.size === 0
|
||||
) {
|
||||
activeRuns.delete(sessionRecord.sessionId);
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
rejected: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error }, "question reject route failed");
|
||||
res.status(500).json({
|
||||
message: "question reject route failed",
|
||||
message: "question response route failed",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||
import { type SupportedModel } from "../chat/models.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
type PermissionReply,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
isPermissionV2AskedEvent,
|
||||
isPermissionV2RepliedEvent,
|
||||
isQuestionAskedEvent,
|
||||
isObjectRecord,
|
||||
isQuestionRejectedEvent,
|
||||
isQuestionRepliedEvent,
|
||||
isQuestionV2AskedEvent,
|
||||
@@ -53,12 +55,6 @@ export {
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
|
||||
export const supportedModels = [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
] as const;
|
||||
|
||||
export type SupportedModel = (typeof supportedModels)[number];
|
||||
export type ApprovalMode = "request" | "always";
|
||||
|
||||
type StreamPromptOptions = {
|
||||
@@ -84,6 +80,19 @@ type ProgressPayload = {
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const getPermissionTarget = (metadata: unknown) => {
|
||||
if (!isObjectRecord(metadata)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of ["command", "path", "file", "filepath", "directory"]) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toRuntimeModel = (model?: SupportedModel) => {
|
||||
if (!model) {
|
||||
return undefined;
|
||||
@@ -398,7 +407,7 @@ export const streamPromptResponse = async ({
|
||||
request_id: event.properties.id,
|
||||
permission: event.properties.permission,
|
||||
patterns: event.properties.patterns,
|
||||
metadata: event.properties.metadata,
|
||||
target: getPermissionTarget(event.properties.metadata),
|
||||
always: event.properties.always,
|
||||
tool: event.properties.tool,
|
||||
created_at: Date.now(),
|
||||
@@ -443,7 +452,7 @@ export const streamPromptResponse = async ({
|
||||
request_id: event.properties.id,
|
||||
permission: event.properties.action,
|
||||
patterns: event.properties.resources,
|
||||
metadata: event.properties.metadata ?? {},
|
||||
target: getPermissionTarget(event.properties.metadata),
|
||||
always: event.properties.save ?? [],
|
||||
tool: undefined,
|
||||
created_at: Date.now(),
|
||||
|
||||
@@ -8,7 +8,7 @@ export type PermissionRequestPayload = {
|
||||
request_id: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
target?: string;
|
||||
always: string[];
|
||||
tool?: {
|
||||
messageID: string;
|
||||
@@ -61,9 +61,12 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
memory_manager: "记忆写入",
|
||||
geocode: "地理编码",
|
||||
session_search: "历史会话检索",
|
||||
skill_manager: "流程沉淀",
|
||||
web_search: "网页搜索",
|
||||
locate_features: "地图定位",
|
||||
zoom_to_map: "地图缩放",
|
||||
view_history: "历史数据面板",
|
||||
view_scada: "SCADA 面板",
|
||||
show_chart: "图表渲染",
|
||||
|
||||
@@ -22,6 +22,15 @@ export type ActiveRun = {
|
||||
subscribers: Set<StreamSubscriber>;
|
||||
};
|
||||
|
||||
type ToolArtifactKind = "chart" | "map" | "panel" | "tool";
|
||||
|
||||
type ToolCallPayload = {
|
||||
session_id?: string;
|
||||
tool?: string;
|
||||
params?: unknown;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
@@ -196,6 +205,59 @@ export const updateLastAssistantQuestion = (
|
||||
};
|
||||
});
|
||||
|
||||
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
|
||||
if (tool === "show_chart" || tool === "chart") return "chart";
|
||||
if (
|
||||
tool === "locate_features" ||
|
||||
tool === "zoom_to_map" ||
|
||||
tool === "render_junctions" ||
|
||||
tool === "apply_layer_style" ||
|
||||
tool.startsWith("locate_")
|
||||
) {
|
||||
return "map";
|
||||
}
|
||||
if (tool === "view_history" || tool === "view_scada") return "panel";
|
||||
return "tool";
|
||||
};
|
||||
|
||||
const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) => {
|
||||
if (typeof params.title === "string" && params.title.trim()) {
|
||||
return params.title.trim();
|
||||
}
|
||||
if (tool === "show_chart" || tool === "chart") return "生成图表";
|
||||
if (tool === "zoom_to_map") return "缩放到地图坐标";
|
||||
if (tool === "render_junctions") return "渲染节点分区";
|
||||
if (tool === "view_history") return "打开计算结果曲线";
|
||||
if (tool === "view_scada") return "打开 SCADA 数据面板";
|
||||
if (tool === "apply_layer_style") return "应用图层样式";
|
||||
if (tool === "locate_features" || tool.startsWith("locate_")) return "地图定位";
|
||||
return tool || "工具调用";
|
||||
};
|
||||
|
||||
export const appendBackendToolArtifact = (
|
||||
artifacts: unknown,
|
||||
payload: ToolCallPayload,
|
||||
) => {
|
||||
const tool = typeof payload.tool === "string" ? payload.tool.trim() : "";
|
||||
if (!tool) {
|
||||
return artifacts;
|
||||
}
|
||||
const params = isObjectRecord(payload.params) ? payload.params : {};
|
||||
const next = Array.isArray(artifacts) ? [...artifacts] : [];
|
||||
next.push({
|
||||
id: `${tool}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
tool,
|
||||
kind: getToolArtifactKind(tool),
|
||||
title: getToolArtifactTitle(tool, params),
|
||||
description:
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: undefined,
|
||||
params,
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
export const toFrontendPermission = (
|
||||
payload: PermissionRequestPayload,
|
||||
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
|
||||
@@ -204,7 +266,7 @@ export const toFrontendPermission = (
|
||||
sessionId: payload.session_id,
|
||||
permission: payload.permission,
|
||||
patterns: payload.patterns,
|
||||
metadata: payload.metadata,
|
||||
target: payload.target,
|
||||
always: payload.always,
|
||||
tool: payload.tool,
|
||||
createdAt: payload.created_at,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
|
||||
const problemDetails = (req: Request, status: number, body: unknown) => {
|
||||
const record =
|
||||
typeof body === "object" && body !== null
|
||||
? (body as Record<string, unknown>)
|
||||
: {};
|
||||
const detail =
|
||||
typeof record.detail === "string"
|
||||
? record.detail
|
||||
: typeof record.message === "string"
|
||||
? record.message
|
||||
: `Request failed with status ${status}`;
|
||||
const codeByStatus: Record<number, string> = {
|
||||
400: "invalid_request",
|
||||
401: "unauthenticated",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
409: "conflict",
|
||||
422: "validation_error",
|
||||
503: "dependency_unavailable",
|
||||
};
|
||||
const code = codeByStatus[status] ?? "request_error";
|
||||
return {
|
||||
type: `https://tjwater.example/problems/${code.replaceAll("_", "-")}`,
|
||||
title: code.replaceAll("_", " "),
|
||||
status,
|
||||
detail,
|
||||
instance: req.originalUrl.split("?")[0],
|
||||
code,
|
||||
trace_id: req.header("x-request-id") ?? randomUUID(),
|
||||
errors: record.detail && typeof record.detail === "object" ? [record.detail] : [],
|
||||
};
|
||||
};
|
||||
|
||||
export const buildAgentPublicRouter = (chatRouter: Router) => {
|
||||
const router = Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const json = res.json.bind(res);
|
||||
res.json = ((body: unknown) => {
|
||||
if (res.statusCode >= 400) {
|
||||
res.type("application/problem+json");
|
||||
return json(problemDetails(req, res.statusCode, body));
|
||||
}
|
||||
return json(body);
|
||||
}) as Response["json"];
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(chatRouter);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -50,7 +50,10 @@ export class OpencodeRuntimeAdapter {
|
||||
|
||||
async ensureClient(): Promise<OpencodeClient> {
|
||||
if (!this.clientPromise) {
|
||||
this.clientPromise = this.bootstrapClient();
|
||||
this.clientPromise = this.bootstrapClient().catch((error) => {
|
||||
this.clientPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this.clientPromise;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
export type RuntimeSessionContext = {
|
||||
accessToken?: string;
|
||||
actorKey: string;
|
||||
authExpired?: {
|
||||
message: string;
|
||||
reason: "access_token_expired" | "access_token_rejected";
|
||||
};
|
||||
allowLearningWrite?: boolean;
|
||||
clientSessionId: string;
|
||||
learningMode?: "interactive" | "review";
|
||||
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
tokenExpiresAt?: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
@@ -22,6 +28,23 @@ export const getRuntimeSessionContext = (sessionId: string) => {
|
||||
return context ? { ...context } : null;
|
||||
};
|
||||
|
||||
export const markRuntimeSessionAuthExpired = (
|
||||
sessionId: string,
|
||||
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
|
||||
message = "登录态已过期,请刷新登录后重试",
|
||||
) => {
|
||||
const context = contexts.get(sessionId);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
const nextContext: RuntimeSessionContext = {
|
||||
...context,
|
||||
authExpired: { message, reason },
|
||||
};
|
||||
contexts.set(sessionId, nextContext);
|
||||
return { ...nextContext };
|
||||
};
|
||||
|
||||
export const removeRuntimeSessionContext = (sessionId: string) => {
|
||||
contexts.delete(sessionId);
|
||||
};
|
||||
|
||||
+252
-13
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
|
||||
import { requireAgentAuth } from "./auth/agentAuth.js";
|
||||
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||
import { config } from "./config.js";
|
||||
@@ -17,8 +18,13 @@ import {
|
||||
ResultReferenceStore,
|
||||
} from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
markRuntimeSessionAuthExpired,
|
||||
type RuntimeSessionContext,
|
||||
} from "./runtime/sessionContext.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -78,6 +84,14 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isRuntimeAuthExpired(context)) {
|
||||
markAuthExpired(context, "access_token_expired");
|
||||
res.status(401).json({
|
||||
message: "access token expired; refresh chat context",
|
||||
detail: sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
||||
if (!command) {
|
||||
@@ -88,11 +102,18 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
const timeoutSec =
|
||||
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
||||
|
||||
if (!context.network) {
|
||||
res.status(400).json({
|
||||
message: "runtime network missing; refresh chat context",
|
||||
detail: sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const authJson = JSON.stringify({
|
||||
server: config.TJWATER_API_BASE_URL,
|
||||
access_token: context.accessToken,
|
||||
project_id: context.projectId,
|
||||
network:"tjwater",
|
||||
});
|
||||
|
||||
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
|
||||
@@ -250,18 +271,212 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const callBackendJson = async (
|
||||
path: string,
|
||||
context: RuntimeSessionContext,
|
||||
payload: unknown,
|
||||
) => {
|
||||
if (isRuntimeAuthExpired(context)) {
|
||||
markAuthExpired(context, "access_token_expired");
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: JSON.stringify({
|
||||
message: "access token expired; refresh chat context",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (context.accessToken) {
|
||||
headers.Authorization = `Bearer ${context.accessToken}`;
|
||||
}
|
||||
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (response.status === 401) {
|
||||
markAuthExpired(context, "access_token_rejected");
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
text,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const parseStringArray = (value: unknown) =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: undefined;
|
||||
|
||||
const webSearchFreshnessMap: Record<string, string> = {
|
||||
no_limit: "noLimit",
|
||||
one_day: "oneDay",
|
||||
one_week: "oneWeek",
|
||||
one_month: "oneMonth",
|
||||
one_year: "oneYear",
|
||||
};
|
||||
|
||||
const normalizeWebSearchFreshness = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return webSearchFreshnessMap[value] ?? value;
|
||||
};
|
||||
|
||||
const AUTH_EXPIRY_SKEW_MS = 30_000;
|
||||
|
||||
function isRuntimeAuthExpired(context: RuntimeSessionContext) {
|
||||
if (!context.tokenExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = Date.parse(context.tokenExpiresAt);
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() >= expiresAt - AUTH_EXPIRY_SKEW_MS;
|
||||
}
|
||||
|
||||
function markAuthExpired(
|
||||
context: RuntimeSessionContext,
|
||||
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
|
||||
) {
|
||||
markRuntimeSessionAuthExpired(context.sessionId, reason);
|
||||
void opencodeRuntime.abortSession(context.sessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: context.sessionId },
|
||||
"failed to abort runtime after auth expired",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
app.post("/internal/tools/web-search", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const query = typeof req.body?.query === "string" ? req.body.query.trim() : "";
|
||||
if (!query) {
|
||||
res.status(400).json({ message: "query is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const count =
|
||||
typeof req.body?.count === "number" && Number.isFinite(req.body.count)
|
||||
? Math.trunc(req.body.count)
|
||||
: undefined;
|
||||
const payload = {
|
||||
query,
|
||||
freshness: normalizeWebSearchFreshness(req.body?.freshness),
|
||||
summary:
|
||||
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
|
||||
count,
|
||||
include: parseStringArray(req.body?.include),
|
||||
exclude: parseStringArray(req.body?.exclude),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await callBackendJson(
|
||||
"/api/v1/web-searches",
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
res
|
||||
.status(response.ok ? 200 : response.status)
|
||||
.type("application/json")
|
||||
.send(response.text);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
res.status(503).json({
|
||||
message: "web search service unavailable",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/internal/tools/geocode", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const keyword =
|
||||
typeof req.body?.keyword === "string" ? req.body.keyword.trim() : "";
|
||||
if (!keyword) {
|
||||
res.status(400).json({ message: "keyword is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await callBackendJson(
|
||||
"/api/v1/geocoding-requests",
|
||||
context,
|
||||
{ keyword },
|
||||
);
|
||||
res
|
||||
.status(response.ok ? 200 : response.status)
|
||||
.type("application/json")
|
||||
.send(response.text);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
res.status(503).json({
|
||||
message: "geocoding service unavailable",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const chatRouter = buildChatRouter(
|
||||
sessionBridge,
|
||||
opencodeRuntime,
|
||||
sessionMetadataStore,
|
||||
sessionUiStateStore,
|
||||
memoryStore,
|
||||
sessionTranscriptStore,
|
||||
learningOrchestrator,
|
||||
resultReferenceResolver,
|
||||
);
|
||||
const authenticatedChatRouter = express.Router();
|
||||
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
|
||||
app.use(
|
||||
"/api/v1/agent/chat",
|
||||
buildChatRouter(
|
||||
sessionBridge,
|
||||
opencodeRuntime,
|
||||
sessionMetadataStore,
|
||||
sessionUiStateStore,
|
||||
memoryStore,
|
||||
sessionTranscriptStore,
|
||||
learningOrchestrator,
|
||||
resultReferenceResolver,
|
||||
),
|
||||
"/api/v1/agent",
|
||||
buildAgentPublicRouter(authenticatedChatRouter),
|
||||
);
|
||||
|
||||
const bootstrap = async () => {
|
||||
@@ -283,8 +498,32 @@ const server = app.listen(config.PORT, config.HOST, () => {
|
||||
{ host: config.HOST, port: config.PORT },
|
||||
"TJWaterAgent listening",
|
||||
);
|
||||
void warmupOpencodeRuntime();
|
||||
});
|
||||
|
||||
const warmupOpencodeRuntime = async () => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await opencodeRuntime.ensureClient();
|
||||
logger.info(
|
||||
{
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"opencode runtime warmed up",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"failed to warm up opencode runtime",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const shutdown = async () => {
|
||||
logger.info("shutting down TJWaterAgent");
|
||||
server.close();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
slugify,
|
||||
toStableId,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type SessionStatus = "active" | "archived";
|
||||
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
|
||||
parentSessionId?: string;
|
||||
};
|
||||
|
||||
export class SessionMetadataOwnershipError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`session metadata ownership mismatch: ${sessionId}`);
|
||||
this.name = "SessionMetadataOwnershipError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionMetadataStore {
|
||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
|
||||
|
||||
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required");
|
||||
}
|
||||
const existing = await readJsonFile<SessionRecord>(
|
||||
this.filePath(sessionId),
|
||||
);
|
||||
const existing = await this.readRecord(sessionId);
|
||||
if (existing) {
|
||||
if (!matchesContext(existing, input)) {
|
||||
throw new SessionMetadataOwnershipError(sessionId);
|
||||
}
|
||||
await atomicWriteJson(this.filePath(sessionId), existing);
|
||||
await removeFileIfExists(this.legacyFilePath(sessionId));
|
||||
return { created: false, record: existing };
|
||||
}
|
||||
|
||||
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
return await readJsonFile<SessionRecord>(
|
||||
this.filePath(normalizedSessionId),
|
||||
);
|
||||
const record = await this.readRecord(normalizedSessionId);
|
||||
return record && matchesContext(record, context) ? record : null;
|
||||
}
|
||||
|
||||
async touch(
|
||||
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
|
||||
this.filePath(record.sessionId),
|
||||
next,
|
||||
);
|
||||
await removeFileIfExists(this.legacyFilePath(record.sessionId));
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
|
||||
const records = await Promise.all(
|
||||
files.map((file) => readJsonFile<SessionRecord>(file)),
|
||||
);
|
||||
return records
|
||||
const uniqueRecords = new Map<string, SessionRecord>();
|
||||
for (const record of records) {
|
||||
if (record && matchesContext(record, context)) {
|
||||
const previous = uniqueRecords.get(record.sessionId);
|
||||
if (!previous || record.updatedAt > previous.updatedAt) {
|
||||
uniqueRecords.set(record.sessionId, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...uniqueRecords.values()]
|
||||
.filter((record): record is SessionRecord => Boolean(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey,
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
async remove(record: SessionRecord) {
|
||||
await removeFileIfExists(
|
||||
this.filePath(record.sessionId),
|
||||
await Promise.all(
|
||||
this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
|
||||
);
|
||||
}
|
||||
|
||||
private filePath(sessionId: string) {
|
||||
return join(
|
||||
this.baseDir,
|
||||
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
private legacyFilePath(sessionId: string) {
|
||||
return join(this.baseDir, `${slugify(sessionId)}.json`);
|
||||
}
|
||||
|
||||
private filePaths(sessionId: string) {
|
||||
return [this.filePath(sessionId), this.legacyFilePath(sessionId)];
|
||||
}
|
||||
|
||||
private async readRecord(sessionId: string) {
|
||||
for (const path of this.filePaths(sessionId)) {
|
||||
const record = await readJsonFile<SessionRecord>(path);
|
||||
if (record?.sessionId === sessionId) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const matchesContext = (
|
||||
record: SessionRecord,
|
||||
context: SessionMetadataContext,
|
||||
) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey &&
|
||||
(!record.ownerUserId || record.ownerUserId === context.userId?.trim()) &&
|
||||
(!record.projectId || record.projectId === context.projectId);
|
||||
|
||||
const normalizeSessionId = (value?: string) => {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized.slice(0, 128) : undefined;
|
||||
|
||||
@@ -147,29 +147,6 @@ export class SessionTranscriptStore {
|
||||
return nextTranscript;
|
||||
}
|
||||
|
||||
async truncateThread(
|
||||
context: SessionTranscriptContext,
|
||||
keepMessageCount: number,
|
||||
) {
|
||||
const key = this.filePath(context);
|
||||
return this.serializeWrite(key, async () => {
|
||||
const transcript = await this.readTranscript(context);
|
||||
if (!transcript) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTranscript: SessionTranscriptRecord = {
|
||||
...transcript,
|
||||
clientSessionId: context.clientSessionId ?? transcript.clientSessionId,
|
||||
sessionId: context.sessionId,
|
||||
turns: projectTurnsForFork(transcript.turns, keepMessageCount),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteJson(key, nextTranscript);
|
||||
return nextTranscript;
|
||||
});
|
||||
}
|
||||
|
||||
async search(
|
||||
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
|
||||
query: string,
|
||||
|
||||
@@ -13,7 +13,6 @@ export type SessionUiStateRecord = {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited?: boolean;
|
||||
messages: unknown[];
|
||||
branchGroups: unknown[];
|
||||
};
|
||||
|
||||
type SessionUiStateContext = {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import { requireAgentAuth } from "../../src/auth/agentAuth.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("requireAgentAuth", () => {
|
||||
it("preserves dependency outages without exposing upstream response text", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("postgresql://secret-host/internal-table", {
|
||||
status: 503,
|
||||
})) as unknown as typeof fetch;
|
||||
const headers: Record<string, string> = {
|
||||
authorization: "Bearer token",
|
||||
"x-project-id": "project-id",
|
||||
};
|
||||
const req = {
|
||||
header: (name: string) => headers[name.toLowerCase()],
|
||||
} as Request;
|
||||
let status = 200;
|
||||
let body: unknown;
|
||||
const res = {
|
||||
status: (value: number) => {
|
||||
status = value;
|
||||
return res;
|
||||
},
|
||||
json: (value: unknown) => {
|
||||
body = value;
|
||||
return res;
|
||||
},
|
||||
} as unknown as Response;
|
||||
let nextCalled = false;
|
||||
|
||||
await requireAgentAuth(req, res, (() => {
|
||||
nextCalled = true;
|
||||
}) as NextFunction);
|
||||
|
||||
expect(status).toBe(503);
|
||||
expect(body).toEqual({ message: "authentication service unavailable" });
|
||||
expect(JSON.stringify(body)).not.toContain("secret-host");
|
||||
expect(nextCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
agentModelOptions,
|
||||
isSupportedModel,
|
||||
resolveDefaultModel,
|
||||
supportedModels,
|
||||
} from "../../src/chat/models.js";
|
||||
import { parseAgentModelOptions } from "../../src/chat/modelConfig.js";
|
||||
|
||||
describe("agent model config", () => {
|
||||
it("keeps every exposed option in the supported model list", () => {
|
||||
expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels);
|
||||
expect(
|
||||
agentModelOptions.every(
|
||||
(model) => model.label && model.description && model.icon,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("validates supported model ids", () => {
|
||||
expect(isSupportedModel("deepseek/deepseek-v4-flash")).toBe(true);
|
||||
expect(isSupportedModel("unknown/model")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported default models", () => {
|
||||
expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe(
|
||||
"deepseek/deepseek-v4-pro",
|
||||
);
|
||||
expect(() => resolveDefaultModel("unknown/model")).toThrow(
|
||||
"unsupported default agent model",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses full model option config from JSON", () => {
|
||||
expect(
|
||||
parseAgentModelOptions(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "provider/model",
|
||||
label: "自定义",
|
||||
description: "自定义模型",
|
||||
icon: "bolt",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: "provider/model",
|
||||
label: "自定义",
|
||||
description: "自定义模型",
|
||||
icon: "bolt",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid model option config", () => {
|
||||
expect(() => parseAgentModelOptions("not-json")).toThrow(
|
||||
"OPENCODE_MODEL_OPTIONS must be valid JSON",
|
||||
);
|
||||
expect(() =>
|
||||
parseAgentModelOptions(
|
||||
JSON.stringify([
|
||||
{ id: "provider/model", label: "模型" },
|
||||
{ id: "provider/model", label: "重复模型" },
|
||||
]),
|
||||
),
|
||||
).toThrow("duplicate OPENCODE_MODEL_OPTIONS id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
||||
|
||||
import {
|
||||
createRouteRegistrar,
|
||||
generateAgentOpenApi,
|
||||
} from "../../src/contracts/openapi.js";
|
||||
|
||||
const KEBAB_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
describe("Agent REST OpenAPI", () => {
|
||||
test("rejects duplicate method and path registrations", () => {
|
||||
const register = createRouteRegistrar(new OpenAPIRegistry());
|
||||
const config = {
|
||||
summary: "Test operation",
|
||||
responses: { 200: { description: "Successful response" } },
|
||||
};
|
||||
|
||||
register("/api/v1/agent/test", "get", config);
|
||||
|
||||
expect(() => register("/api/v1/agent/test", "get", config)).toThrow(
|
||||
"Duplicate Agent OpenAPI operation: GET /api/v1/agent/test",
|
||||
);
|
||||
});
|
||||
|
||||
test("publishes only unique kebab-case public operations", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const operationIds = new Set<string>();
|
||||
let operationCount = 0;
|
||||
|
||||
for (const [path, pathItem] of Object.entries(document.paths)) {
|
||||
expect(path.endsWith("/")).toBe(false);
|
||||
for (const segment of path.split("/").filter(Boolean)) {
|
||||
if (!segment.startsWith("{")) {
|
||||
expect(segment).toMatch(KEBAB_SEGMENT);
|
||||
}
|
||||
}
|
||||
for (const operation of Object.values(pathItem ?? {})) {
|
||||
if (!operation || typeof operation !== "object" || !("responses" in operation)) {
|
||||
continue;
|
||||
}
|
||||
operationCount += 1;
|
||||
expect(operation.operationId).toBeTruthy();
|
||||
expect(operationIds.has(operation.operationId!)).toBe(false);
|
||||
operationIds.add(operation.operationId!);
|
||||
expect(operation.security).toEqual([{ bearerAuth: [] }]);
|
||||
}
|
||||
}
|
||||
|
||||
expect(operationCount).toBe(13);
|
||||
});
|
||||
|
||||
test("models runs as session subresources", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
expect(document.paths["/api/v1/agent/sessions/{session_id}/runs"]?.post).toBeTruthy();
|
||||
expect(
|
||||
document.paths[
|
||||
"/api/v1/agent/sessions/{session_id}/runs/current/events"
|
||||
]?.get,
|
||||
).toBeTruthy();
|
||||
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("matches the public session runtime response shapes", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const schemas = document.components?.schemas ?? {};
|
||||
const createResponses =
|
||||
document.paths["/api/v1/agent/sessions"]?.post?.responses ?? {};
|
||||
const patchOperation =
|
||||
document.paths["/api/v1/agent/sessions/{session_id}"]?.patch;
|
||||
const patchRequestBody = patchOperation?.requestBody;
|
||||
const forkResponses =
|
||||
document.paths["/api/v1/agent/sessions/{session_id}/forks"]?.post
|
||||
?.responses ?? {};
|
||||
|
||||
expect(Object.keys(createResponses)).toContain("200");
|
||||
expect(Object.keys(createResponses)).toContain("201");
|
||||
expect(schemas.AgentSessionSummary).toMatchObject({
|
||||
required: expect.arrayContaining(["id", "created_at", "updated_at", "status"]),
|
||||
});
|
||||
expect(schemas.AgentSessionSummary).not.toMatchObject({
|
||||
required: expect.arrayContaining(["session_id"]),
|
||||
});
|
||||
expect(
|
||||
patchRequestBody && !("$ref" in patchRequestBody)
|
||||
? patchRequestBody.content["application/json"]?.schema
|
||||
: undefined,
|
||||
).toMatchObject({
|
||||
properties: {
|
||||
is_title_manually_edited: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(patchOperation?.responses["200"]).toMatchObject({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentSessionUpdate" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(forkResponses["200"]).toMatchObject({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentSessionFork" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(forkResponses["201"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("documents runtime query parameters and error statuses", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const renderOperation =
|
||||
document.paths["/api/v1/agent/render-references/{render_ref}"]?.get;
|
||||
|
||||
expect(renderOperation?.parameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
in: "query",
|
||||
name: "session_id",
|
||||
required: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(Object.keys(renderOperation?.responses ?? {})).toEqual(
|
||||
expect.arrayContaining(["400", "500", "502"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
buildForkedSessionUiState,
|
||||
} from "../../src/routes/chat.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
extractLatestFrontendTurn,
|
||||
@@ -199,3 +202,79 @@ describe("extractLatestFrontendTurn", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForkedSessionUiState", () => {
|
||||
it("copies truncated source messages and preserves tool artifacts", () => {
|
||||
const forked = buildForkedSessionUiState(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "画压力曲线" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "已生成图表",
|
||||
artifacts: [
|
||||
{
|
||||
id: "chart-1",
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
params: { chart_type: "line" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "继续分析" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keepMessageCount: 2,
|
||||
targetSessionId: "forked-session",
|
||||
},
|
||||
);
|
||||
|
||||
expect(forked).toEqual({
|
||||
sessionId: "forked-session",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [
|
||||
{ role: "user", content: "画压力曲线" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "已生成图表",
|
||||
artifacts: [
|
||||
{
|
||||
id: "chart-1",
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
params: { chart_type: "line" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an empty branch state when source UI state is missing or keep count is zero", () => {
|
||||
expect(
|
||||
buildForkedSessionUiState(null, {
|
||||
keepMessageCount: 3,
|
||||
targetSessionId: "forked-without-source",
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: "forked-without-source",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildForkedSessionUiState(
|
||||
{ messages: [{ role: "user", content: "不保留" }] },
|
||||
{
|
||||
keepMessageCount: 0,
|
||||
targetSessionId: "forked-empty",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
sessionId: "forked-empty",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("streamPromptResponse", () => {
|
||||
request_id: "perm-1",
|
||||
permission: "bash",
|
||||
patterns: ["rm *"],
|
||||
metadata: { command: "rm tmp.txt" },
|
||||
target: "rm tmp.txt",
|
||||
always: ["rm *"],
|
||||
} satisfies Partial<PermissionRequestPayload>);
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe("streamPromptResponse", () => {
|
||||
request_id: "perm-v2-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp"],
|
||||
metadata: { path: "/tmp" },
|
||||
target: "/tmp",
|
||||
always: ["/tmp"],
|
||||
} satisfies Partial<PermissionRequestPayload>);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
appendBackendToolArtifact,
|
||||
cancelBackendTodos,
|
||||
upsertBackendQuestion,
|
||||
} from "../../src/routes/chatUiState.js";
|
||||
|
||||
describe("appendBackendToolArtifact", () => {
|
||||
it("persists show_chart tool calls as chart artifacts", () => {
|
||||
const artifacts = appendBackendToolArtifact([], {
|
||||
session_id: "session-1",
|
||||
tool: "show_chart",
|
||||
reason: "测试折线图渲染",
|
||||
params: {
|
||||
title: "压力曲线",
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-101", data: [0.42, 0.41] }],
|
||||
},
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
title: "压力曲线",
|
||||
description: "测试折线图渲染",
|
||||
params: {
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-101", data: [0.42, 0.41] }],
|
||||
},
|
||||
});
|
||||
expect(artifacts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^show_chart-/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertBackendQuestion", () => {
|
||||
it("replaces a tool-call placeholder with the actionable question request", () => {
|
||||
const questions = upsertBackendQuestion(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import express, { Router } from "express";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { generateAgentOpenApi } from "../../src/contracts/openapi.js";
|
||||
import { buildChatRouter } from "../../src/routes/chat.js";
|
||||
import { buildAgentPublicRouter } from "../../src/routes/publicApi.js";
|
||||
|
||||
describe("Agent public REST router", () => {
|
||||
let baseUrl = "";
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
const chatRouter = Router();
|
||||
chatRouter.use(express.json());
|
||||
chatRouter.post("/sessions/:session_id/runs", (req, res) => {
|
||||
res.json({ method: req.method, path: req.path, body: req.body });
|
||||
});
|
||||
chatRouter.delete("/sessions/:session_id/runs/current", (req, res) => {
|
||||
res.json({ method: req.method, path: req.path, body: req.body });
|
||||
});
|
||||
chatRouter.get("/auth-failure", (_req, res) => {
|
||||
res.status(503).json({ message: "authentication service unavailable" });
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/v1/agent", buildAgentPublicRouter(chatRouter));
|
||||
server = app.listen(0);
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server did not expose a TCP port");
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("matches the published OpenAPI method and path set", () => {
|
||||
type RouterLayer = {
|
||||
route?: {
|
||||
methods: Record<string, boolean>;
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
const router = buildChatRouter(
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
|
||||
const runtimeOperations = layers
|
||||
.flatMap((layer) => {
|
||||
if (!layer.route) {
|
||||
return [];
|
||||
}
|
||||
const path = `/api/v1/agent${layer.route.path.replace(
|
||||
/:([^/]+)/g,
|
||||
"{$1}",
|
||||
)}`;
|
||||
return Object.entries(layer.route.methods)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([method]) => `${method.toUpperCase()} ${path}`);
|
||||
})
|
||||
.sort();
|
||||
const document = generateAgentOpenApi();
|
||||
const contractOperations = Object.entries(document.paths)
|
||||
.flatMap(([path, pathItem]) =>
|
||||
Object.entries(pathItem ?? {})
|
||||
.filter(([, operation]) => {
|
||||
return (
|
||||
operation &&
|
||||
typeof operation === "object" &&
|
||||
"responses" in operation
|
||||
);
|
||||
})
|
||||
.map(([method]) => `${method.toUpperCase()} ${path}`),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(runtimeOperations).toEqual(contractOperations);
|
||||
});
|
||||
|
||||
test("serves session runs without rewriting the REST request", async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/agent/sessions/session-1/runs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "hello" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(await response.json()).toEqual({
|
||||
method: "POST",
|
||||
path: "/sessions/session-1/runs",
|
||||
body: { message: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
test("serves DELETE current run without rewriting the HTTP method", async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/agent/sessions/session-2/runs/current`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
expect(await response.json()).toEqual({
|
||||
method: "DELETE",
|
||||
path: "/sessions/session-2/runs/current",
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("wraps authentication middleware failures as Problem Details", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/agent/auth-failure`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/problem+json",
|
||||
);
|
||||
expect(body).toMatchObject({
|
||||
status: 503,
|
||||
code: "dependency_unavailable",
|
||||
detail: "authentication service unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
@@ -60,3 +61,27 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
||||
it("retries bootstrap after a failed startup attempt", async () => {
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
global: { health: async () => ({ data: { healthy: true } }) },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
clientPromise: null,
|
||||
closeServer: null,
|
||||
bootstrapClient: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("startup failed");
|
||||
}
|
||||
return client;
|
||||
},
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await expect(runtime.ensureClient()).rejects.toThrow("startup failed");
|
||||
await expect(runtime.ensureClient()).resolves.toBe(client);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("runtime session context", () => {
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "chat-session-1",
|
||||
learningMode: "interactive",
|
||||
network: "fengyang",
|
||||
projectId: "project-id-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "runtime-session-1",
|
||||
@@ -24,6 +25,7 @@ describe("runtime session context", () => {
|
||||
|
||||
expect(runtimeContext?.accessToken).toBe("token-1");
|
||||
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
||||
expect(runtimeContext?.network).toBe("fengyang");
|
||||
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
||||
|
||||
removeRuntimeSessionContext("runtime-session-1");
|
||||
|
||||
@@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => {
|
||||
);
|
||||
expect(fetched?.title).toBe("新标题");
|
||||
});
|
||||
|
||||
it("does not expose a session across users or projects", async () => {
|
||||
await store.ensure({
|
||||
actorKey: "actor-a",
|
||||
projectId: "project-a",
|
||||
projectKey: "project-key-a",
|
||||
sessionId: "private-session",
|
||||
userId: "user-a",
|
||||
});
|
||||
|
||||
expect(
|
||||
await store.get(
|
||||
{
|
||||
actorKey: "actor-b",
|
||||
projectId: "project-a",
|
||||
projectKey: "project-key-a",
|
||||
userId: "user-b",
|
||||
},
|
||||
"private-session",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await store.get(
|
||||
{
|
||||
actorKey: "actor-a",
|
||||
projectId: "project-b",
|
||||
projectKey: "project-key-b",
|
||||
userId: "user-a",
|
||||
},
|
||||
"private-session",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects idempotent creation when the session belongs to another scope", async () => {
|
||||
await store.ensure({
|
||||
actorKey: "actor-a",
|
||||
projectId: "project-a",
|
||||
projectKey: "project-key-a",
|
||||
sessionId: "claimed-session",
|
||||
userId: "user-a",
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.ensure({
|
||||
actorKey: "actor-b",
|
||||
projectId: "project-b",
|
||||
projectKey: "project-key-b",
|
||||
sessionId: "claimed-session",
|
||||
userId: "user-b",
|
||||
}),
|
||||
).rejects.toThrow("session metadata ownership mismatch");
|
||||
});
|
||||
|
||||
it("keeps long session ids with the same slug prefix distinct", async () => {
|
||||
const prefix = "s".repeat(64);
|
||||
const firstSessionId = `${prefix}-first`;
|
||||
const secondSessionId = `${prefix}-second`;
|
||||
const context = {
|
||||
actorKey: "actor-long",
|
||||
projectId: "project-long",
|
||||
projectKey: "project-key-long",
|
||||
userId: "user-long",
|
||||
};
|
||||
|
||||
await store.ensure({ ...context, sessionId: firstSessionId });
|
||||
await store.ensure({ ...context, sessionId: secondSessionId });
|
||||
|
||||
expect((await store.get(context, firstSessionId))?.sessionId).toBe(
|
||||
firstSessionId,
|
||||
);
|
||||
expect((await store.get(context, secondSessionId))?.sessionId).toBe(
|
||||
secondSessionId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user