Compare commits
60 Commits
5835df7263
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c47841483 | |||
| ab12d79d91 | |||
| 7427d08d6c | |||
| f7122d1260 | |||
| 5d80961930 | |||
| 7e63d38cf5 | |||
| e0b81c2114 | |||
| cb298f2099 | |||
| 4870e8a577 | |||
| f24e8109a0 | |||
| 725935e270 | |||
| 6c53e12962 | |||
| 0b5004fc2c | |||
| 872570ac3a | |||
| 23d8249286 | |||
| bd04444d9d | |||
| 96d894d1e0 | |||
| 105dfea18e | |||
| 53a423cafe | |||
| 9dffa59603 | |||
| 97fea698f0 | |||
| dbeb2084cf | |||
| 3eb5829053 | |||
| fe09b02393 | |||
| a6f6e633f0 | |||
| 61702d095a | |||
| 1234d28536 | |||
| 5e5f2494ac | |||
| 4690a0980b | |||
| 0ad3bd4d89 | |||
| 7b4f479aad | |||
| 6584239e75 | |||
| d56f516161 | |||
| d0cb19c521 | |||
| 8b74e98291 | |||
| 1ac46814ad | |||
| ef3253d895 | |||
| 8439d56b42 | |||
| 8b02cae2af | |||
| 69a90de9a1 | |||
| 3e3deaa724 | |||
| eebf802e31 | |||
| f150c602e5 | |||
| 3ebcd98ec5 | |||
| 61b1018900 | |||
| f58abe8003 | |||
| 3d85f13f26 | |||
| 0d5435022a | |||
| 59de5c672f | |||
| 93cba2f391 | |||
| 61e9fa94ac | |||
| cbaa1099de | |||
| 5fbe8ae40c | |||
| a27c45910c | |||
| 37f5bd8a80 | |||
| 65fb368f40 | |||
| 9fa24b39f3 | |||
| a9bab86d64 | |||
| 2473117198 | |||
| e5d780efce |
@@ -2,5 +2,6 @@ node_modules/
|
|||||||
.opencode/node_modules/
|
.opencode/node_modules/
|
||||||
.local.env
|
.local.env
|
||||||
.vscode
|
.vscode
|
||||||
|
docker-compose.yml
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mode: primary
|
|||||||
model: deepseek/deepseek-v4-pro
|
model: deepseek/deepseek-v4-pro
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
---
|
---
|
||||||
您是运行在 opencode 上的默认 TJWater Agent,使用简体中文回复用户的问题。
|
您是运行在 opencode 上的默认 TJWater Agent,运用水力相关知识,使用简体中文回复用户的问题。
|
||||||
|
|
||||||
按照以下规则操作:
|
按照以下规则操作:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: tjwater-action-business-network-assets-regions
|
name: tjwater-action-business-network-assets-regions
|
||||||
description: business/network-assets 下 regions 操作技能。
|
description: business/network-assets 下 regions 操作技能。
|
||||||
version: 3.0.0
|
version: 3.0.1
|
||||||
---
|
---
|
||||||
|
|
||||||
# regions Action Skill
|
# regions Action Skill
|
||||||
@@ -19,25 +19,21 @@ version: 3.0.0
|
|||||||
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
|
| POST | `/api/v1/addregion/` | 添加新区域 | network (query) | - |
|
||||||
| POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - |
|
| POST | `/api/v1/addservicearea/` | 添加新服务区 | network (query) | - |
|
||||||
| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - |
|
| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculatedistrictmeteringarea/` | 计算DMA分区 | network (query) | - |
|
|
||||||
| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - |
|
| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
|
| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
|
| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | network (query) | - |
|
||||||
| GET | `/api/v1/calculateregion/` | 计算区域 | network (query), time_index (query) | - |
|
| GET | `/api/v1/calculateservicearea/` | 计算服务区(返回全部时间步) | network (query) | - |
|
||||||
| GET | `/api/v1/calculateservicearea/` | 计算服务区 | network (query), time_index (query) | - |
|
|
||||||
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
|
| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | network (query), centers (query) | - |
|
||||||
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
|
| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | network (query) | - |
|
||||||
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
|
| POST | `/api/v1/deleteregion/` | 删除区域 | network (query) | - |
|
||||||
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
|
| POST | `/api/v1/deleteservicearea/` | 删除服务区 | network (query) | - |
|
||||||
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | network (query) | - |
|
| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | network (query) | - |
|
||||||
| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | network (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
||||||
| POST | `/api/v1/generateregion/` | 生成区域分区 | network (query), inflate_delta (query) | - |
|
|
||||||
| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | network (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | network (query), inflate_delta (query) | - |
|
||||||
| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | network (query), dma (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | network (query), dma (query), part_count (query), part_type (query), inflate_delta (query) | - |
|
||||||
| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
|
| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | network (query), inflate_delta (query) | - |
|
||||||
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
|
| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | network (query) | - |
|
||||||
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
|
| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | network (query) | - |
|
||||||
| GET | `/api/v1/getallregions/` | 获取所有区域 | network (query) | - |
|
|
||||||
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
|
| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | network (query) | - |
|
||||||
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
|
| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | network (query) | - |
|
||||||
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
|
| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | network (query), id (query) | - |
|
||||||
@@ -61,13 +57,13 @@ version: 3.0.0
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GET /getregionschema` | 返回区域(Region)数据模型的字段定义 |
|
| `GET /getregionschema` | 返回区域(Region)数据模型的字段定义 |
|
||||||
| `GET /getregion/` | 查询单个区域的属性 |
|
| `GET /getregion/` | 查询单个区域的属性 |
|
||||||
| `GET /getallregions/` | 获取管网中所有区域列表 |
|
|
||||||
| `GET /getalldistrictmeteringareas/` | 获取所有 DMA(独立计量区)列表 |
|
| `GET /getalldistrictmeteringareas/` | 获取所有 DMA(独立计量区)列表 |
|
||||||
| `GET /getallserviceareas/` | 获取所有服务区列表 |
|
| `GET /getallserviceareas/` | 获取所有服务区列表 |
|
||||||
| `POST /addregion/` | 新增区域(需提供名称和节点/管道列表) |
|
| `POST /addregion/` | 新增区域(需提供名称和节点/管道列表) |
|
||||||
| `POST /adddistrictmeteringarea/` | 新增 DMA 分区 |
|
| `POST /adddistrictmeteringarea/` | 新增 DMA 分区 |
|
||||||
| `POST /addvirtualdistrict/` | 新增虚拟分区 |
|
| `POST /addvirtualdistrict/` | 新增虚拟分区 |
|
||||||
| `POST /addservicearea/` | 新增服务区 |
|
| `POST /addservicearea/` | 新增服务区 |
|
||||||
| `POST /calculatedistrictmeteringarea/` | 为指定节点集合计算其所属 DMA |
|
| `GET /calculatedistrictmeteringareafornodes/` | 为指定节点集合计算其所属 DMA |
|
||||||
| `POST /calculatedistrictmeteringareaforregion/` | 为指定区域内的所有节点计算 DMA 归属 |
|
| `GET /calculatedistrictmeteringareaforregion/` | 为指定区域内的所有节点计算 DMA 归属 |
|
||||||
| `POST /calculatedistrictmeteringareafornetwork/` | 为整个管网的所有节点计算 DMA 归属 |
|
| `GET /calculatedistrictmeteringareafornetwork/` | 为整个管网的所有节点计算 DMA 归属 |
|
||||||
|
| `GET /calculateservicearea/` | 计算服务区,返回全部时间步结果 |
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
|
const initializePromise = toolContextStore.initialize();
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -21,6 +24,11 @@ export default tool({
|
|||||||
.describe("Query arguments object."),
|
.describe("Query arguments object."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
|
await initializePromise;
|
||||||
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
|
if (!sessionContext) {
|
||||||
|
throw new Error(`session context not found for ${context.sessionID}`);
|
||||||
|
}
|
||||||
// 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
|
// 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
|
||||||
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
|
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -29,7 +37,7 @@ export default tool({
|
|||||||
"x-agent-internal-token": internalToken,
|
"x-agent-internal-token": internalToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionId: context.sessionID,
|
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||||
reason: args.reason,
|
reason: args.reason,
|
||||||
path: args.path,
|
path: args.path,
|
||||||
method: args.method,
|
method: args.method,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
|
const initializePromise = toolContextStore.initialize();
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -19,6 +22,11 @@ export default tool({
|
|||||||
.describe("Optional maximum number of top-level items or fields to return."),
|
.describe("Optional maximum number of top-level items or fields to return."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
|
await initializePromise;
|
||||||
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
|
if (!sessionContext) {
|
||||||
|
throw new Error(`session context not found for ${context.sessionID}`);
|
||||||
|
}
|
||||||
const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, {
|
const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -26,7 +34,7 @@ export default tool({
|
|||||||
"x-agent-internal-token": internalToken,
|
"x-agent-internal-token": internalToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionId: context.sessionID,
|
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||||
result_ref: args.result_ref,
|
result_ref: args.result_ref,
|
||||||
max_items: args.max_items,
|
max_items: args.max_items,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default tool({
|
|||||||
if (args.action === "add") {
|
if (args.action === "add") {
|
||||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||||
content: args.content ?? "",
|
content: args.content ?? "",
|
||||||
sessionId: context.sessionID,
|
sessionId: sessionContext.clientSessionId,
|
||||||
source: "tool",
|
source: "tool",
|
||||||
traceId: sessionContext.traceId,
|
traceId: sessionContext.traceId,
|
||||||
});
|
});
|
||||||
@@ -105,7 +105,7 @@ export default tool({
|
|||||||
if (args.action === "replace") {
|
if (args.action === "replace") {
|
||||||
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
||||||
content: args.content ?? "",
|
content: args.content ?? "",
|
||||||
sessionId: context.sessionID,
|
sessionId: sessionContext.clientSessionId,
|
||||||
source: "tool",
|
source: "tool",
|
||||||
traceId: sessionContext.traceId,
|
traceId: sessionContext.traceId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用),不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若必须自行构造供 render_ref 引用的 JSON,其 data 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id。",
|
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
reason: tool.schema
|
||||||
.string()
|
.string()
|
||||||
@@ -10,7 +10,7 @@ export default tool({
|
|||||||
render_ref: tool.schema
|
render_ref: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"渲染引用 ID。直接传持久化 render_ref 即可,前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } };node_area_map 必填,area_ids / area_colors 可选。",
|
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } };node_area_map 必填,area_ids / area_colors 可选。",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
async execute() {
|
async execute() {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
const internalBaseUrl =
|
const internalBaseUrl =
|
||||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
|
const initializePromise = toolContextStore.initialize();
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -22,6 +25,11 @@ export default tool({
|
|||||||
.describe("Optional maximum number of hits to return."),
|
.describe("Optional maximum number of hits to return."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
|
await initializePromise;
|
||||||
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
|
if (!sessionContext) {
|
||||||
|
throw new Error(`session context not found for ${context.sessionID}`);
|
||||||
|
}
|
||||||
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
|
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -31,7 +39,7 @@ export default tool({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
max_results: args.max_results,
|
max_results: args.max_results,
|
||||||
query: args.query,
|
query: args.query,
|
||||||
sessionId: context.sessionID,
|
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
|
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
|
const initializePromise = toolContextStore.initialize();
|
||||||
|
|
||||||
|
export default tool({
|
||||||
|
description:
|
||||||
|
"把本地 JSON 渲染文件迁移成受控的 render_ref。仅适用于需要通过链接引用传递的大型 junction render payload。",
|
||||||
|
args: {
|
||||||
|
reason: tool.schema
|
||||||
|
.string()
|
||||||
|
.describe("Why this local render payload should be persisted as a render_ref."),
|
||||||
|
file_path: tool.schema
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async execute(args, context) {
|
||||||
|
await initializePromise;
|
||||||
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
|
if (!sessionContext) {
|
||||||
|
throw new Error(`session context not found for ${context.sessionID}`);
|
||||||
|
}
|
||||||
|
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-agent-internal-token": internalToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||||
|
file_path: args.file_path,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
"bun-types": "^1.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,6 +57,8 @@
|
|||||||
|
|
||||||
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
services:
|
|
||||||
tjwater-agent:
|
|
||||||
container_name: tjwater-agent
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
UBUNTU_APT_MIRROR: mirrors.aliyun.com
|
|
||||||
PYPI_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
PYPI_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn
|
|
||||||
image: tjwater-agent:latest
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
HOST: 0.0.0.0
|
|
||||||
PORT: 8787
|
|
||||||
DEEPSEEK_API_KEY: "your_deepseek_api_key"
|
|
||||||
TJWATER_API_BASE_URL: "http://127.0.0.1:8000"
|
|
||||||
# Embedded 模式:容器内启动 opencode CLI 子进程
|
|
||||||
OPENCODE_MODE: embedded
|
|
||||||
OPENCODE_HOSTNAME: 127.0.0.1
|
|
||||||
OPENCODE_PORT: 4096
|
|
||||||
# Client 模式:连接外部服务地址,不依赖容器内 CLI
|
|
||||||
# OPENCODE_MODE: client
|
|
||||||
# OPENCODE_CLIENT_BASE_URL: "http://host.docker.internal:4096"
|
|
||||||
volumes:
|
|
||||||
# - /home/ubuntu/.config/opencode:/root/.config/opencode
|
|
||||||
# - /home/ubuntu/.local/share/opencode:/root/.local/share/opencode
|
|
||||||
- ./opencode/agents:/app/.opencode/agents
|
|
||||||
- ./opencode/skills:/app/.opencode/skills
|
|
||||||
- ./opencode/tools:/app/.opencode/tools
|
|
||||||
- ./logs:/app/logs
|
|
||||||
- ./data:/app/data
|
|
||||||
# extra_hosts:
|
|
||||||
# - "host.docker.internal:host-gateway"
|
|
||||||
ports:
|
|
||||||
- "8787:8787"
|
|
||||||
# - "4096:4096"
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
"bun-types": "^1.3.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-199
@@ -2,10 +2,25 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
import { type SessionBinding, type SessionContext, SessionRegistry } from "../session/registry.js";
|
import {
|
||||||
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
buildToolSessionScopeKey,
|
||||||
|
ToolSessionContextStore,
|
||||||
|
} from "../session/toolContextStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
|
|
||||||
|
export type SessionBinding = {
|
||||||
|
clientSessionId: string;
|
||||||
|
sessionId: string;
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionContext = {
|
||||||
|
clientSessionId: string;
|
||||||
|
accessToken?: string;
|
||||||
|
projectId?: string;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatRequestContext = SessionContext & {
|
export type ChatRequestContext = SessionContext & {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
@@ -13,15 +28,12 @@ export type ChatRequestContext = SessionContext & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ChatSessionBridge {
|
export class ChatSessionBridge {
|
||||||
// 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用。
|
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。
|
||||||
private readonly sessionContexts = new Map<string, ChatRequestContext>();
|
private readonly activeRuntimeSessions = new Map<string, string>();
|
||||||
private readonly sessionTitles = new Map<string, string>();
|
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
|
||||||
private readonly toolContextStore = new ToolSessionContextStore();
|
private readonly toolContextStore = new ToolSessionContextStore();
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
|
||||||
private readonly registry: SessionRegistry,
|
|
||||||
private readonly runtime: OpencodeRuntimeAdapter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async resolve(context: {
|
async resolve(context: {
|
||||||
clientSessionId?: string;
|
clientSessionId?: string;
|
||||||
@@ -34,51 +46,22 @@ export class ChatSessionBridge {
|
|||||||
requestContext: ChatRequestContext;
|
requestContext: ChatRequestContext;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
}> {
|
}> {
|
||||||
const requestContext: ChatRequestContext = {
|
const requestContext = this.buildRequestContext(context);
|
||||||
clientSessionId:
|
await this.abortActiveRuntime(requestContext.clientSessionId);
|
||||||
context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
|
|
||||||
accessToken: context.accessToken,
|
|
||||||
actorKey: toActorKey(context.userId),
|
|
||||||
projectId: context.projectId,
|
|
||||||
projectKey: toProjectKey(context.projectId),
|
|
||||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
|
||||||
userId: context.userId?.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.cleanupExpired();
|
|
||||||
|
|
||||||
const current = this.registry.get(requestContext);
|
|
||||||
if (current) {
|
|
||||||
this.sessionContexts.set(current.sessionId, requestContext);
|
|
||||||
await this.toolContextStore.write({
|
|
||||||
actorKey: requestContext.actorKey,
|
|
||||||
allowLearningWrite: true,
|
|
||||||
clientSessionId: requestContext.clientSessionId,
|
|
||||||
learningMode: "interactive",
|
|
||||||
projectId: requestContext.projectId,
|
|
||||||
projectKey: requestContext.projectKey,
|
|
||||||
sessionId: current.sessionId,
|
|
||||||
traceId: requestContext.traceId,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
// 只有 opencode 侧 session 仍存在时,才复用本地映射。
|
|
||||||
await this.runtime.getSession(current.sessionId);
|
|
||||||
return { binding: current, requestContext, created: false };
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
clientSessionId: requestContext.clientSessionId,
|
|
||||||
sessionId: current.sessionId,
|
|
||||||
err: error,
|
|
||||||
},
|
|
||||||
"existing opencode session lookup failed, creating a new session",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.runtime.createSession(requestContext.clientSessionId);
|
const session = await this.runtime.createSession(requestContext.clientSessionId);
|
||||||
const binding = this.registry.upsert(requestContext, session.id);
|
const binding: SessionBinding = {
|
||||||
this.sessionContexts.set(binding.sessionId, requestContext);
|
clientSessionId: requestContext.clientSessionId,
|
||||||
|
sessionId: session.id,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const sessionScopeKey = buildToolSessionScopeKey(
|
||||||
|
requestContext.actorKey,
|
||||||
|
requestContext.projectKey,
|
||||||
|
requestContext.clientSessionId,
|
||||||
|
);
|
||||||
|
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
|
||||||
|
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
|
||||||
await this.toolContextStore.write({
|
await this.toolContextStore.write({
|
||||||
actorKey: requestContext.actorKey,
|
actorKey: requestContext.actorKey,
|
||||||
allowLearningWrite: true,
|
allowLearningWrite: true,
|
||||||
@@ -86,99 +69,70 @@ export class ChatSessionBridge {
|
|||||||
learningMode: "interactive",
|
learningMode: "interactive",
|
||||||
projectId: requestContext.projectId,
|
projectId: requestContext.projectId,
|
||||||
projectKey: requestContext.projectKey,
|
projectKey: requestContext.projectKey,
|
||||||
sessionId: binding.sessionId,
|
sessionId: session.id,
|
||||||
|
sessionScopeKey,
|
||||||
traceId: requestContext.traceId,
|
traceId: requestContext.traceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { binding, requestContext, created: true };
|
return { binding, requestContext, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
count(): number {
|
count(): number {
|
||||||
return this.registry.count();
|
return this.activeRuntimeSessions.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessionContext(sessionId: string) {
|
createClientSessionId() {
|
||||||
return this.sessionContexts.get(sessionId) ?? null;
|
return `agent-${randomUUID().slice(0, 12)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessionTitle(sessionId: string) {
|
getActiveSensitiveContext(sessionScopeKey: string) {
|
||||||
return this.sessionTitles.get(sessionId);
|
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
setSessionTitle(sessionId: string, title: string) {
|
|
||||||
const normalized = title.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.sessionTitles.set(sessionId, normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
cloneSessionTitle(sourceSessionId: string, targetSessionId: string) {
|
|
||||||
const existingTitle = this.sessionTitles.get(sourceSessionId);
|
|
||||||
if (!existingTitle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.sessionTitles.set(targetSessionId, existingTitle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async abort(context: {
|
async abort(context: {
|
||||||
clientSessionId?: string;
|
clientSessionId?: string;
|
||||||
accessToken?: string;
|
|
||||||
projectId?: string;
|
|
||||||
traceId?: string;
|
|
||||||
userId?: string;
|
|
||||||
}): Promise<SessionBinding | null> {
|
}): Promise<SessionBinding | null> {
|
||||||
const clientSessionId = context.clientSessionId?.trim();
|
const clientSessionId = context.clientSessionId?.trim();
|
||||||
if (!clientSessionId) {
|
if (!clientSessionId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestContext: ChatRequestContext = {
|
const sessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||||
clientSessionId,
|
if (!sessionId) {
|
||||||
accessToken: context.accessToken,
|
|
||||||
actorKey: toActorKey(context.userId),
|
|
||||||
projectId: context.projectId,
|
|
||||||
projectKey: toProjectKey(context.projectId),
|
|
||||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
|
||||||
userId: context.userId?.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.cleanupExpired();
|
|
||||||
|
|
||||||
const binding = this.registry.get(requestContext);
|
|
||||||
if (!binding) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessionContexts.set(binding.sessionId, requestContext);
|
await this.abortActiveRuntime(clientSessionId);
|
||||||
await this.toolContextStore.write({
|
return {
|
||||||
actorKey: requestContext.actorKey,
|
clientSessionId,
|
||||||
allowLearningWrite: true,
|
sessionId,
|
||||||
clientSessionId: requestContext.clientSessionId,
|
startedAt: Date.now(),
|
||||||
learningMode: "interactive",
|
};
|
||||||
projectId: requestContext.projectId,
|
|
||||||
projectKey: requestContext.projectKey,
|
|
||||||
sessionId: binding.sessionId,
|
|
||||||
traceId: requestContext.traceId,
|
|
||||||
});
|
|
||||||
await this.runtime.abortSession(binding.sessionId);
|
|
||||||
return binding;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fork(context: {
|
async releaseRuntimeSession(clientSessionId: string, sessionId: string) {
|
||||||
|
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||||
|
if (activeSessionId === sessionId) {
|
||||||
|
this.activeRuntimeSessions.delete(clientSessionId);
|
||||||
|
}
|
||||||
|
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||||
|
await this.toolContextStore.remove(sessionId).catch((error) => {
|
||||||
|
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context");
|
||||||
|
});
|
||||||
|
await this.runtime.abortSession(sessionId).catch((error) => {
|
||||||
|
logger.debug({ sessionId, err: error }, "failed to cleanup runtime session");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRequestContext(context: {
|
||||||
clientSessionId?: string;
|
clientSessionId?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
keepMessageCount: number;
|
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}): Promise<{
|
}): ChatRequestContext {
|
||||||
binding: SessionBinding;
|
return {
|
||||||
requestContext: ChatRequestContext;
|
clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(),
|
||||||
created: boolean;
|
|
||||||
}> {
|
|
||||||
const currentClientSessionId = context.clientSessionId?.trim();
|
|
||||||
const nextRequestContext: ChatRequestContext = {
|
|
||||||
clientSessionId: `agent-${randomUUID().slice(0, 12)}`,
|
|
||||||
accessToken: context.accessToken,
|
accessToken: context.accessToken,
|
||||||
actorKey: toActorKey(context.userId),
|
actorKey: toActorKey(context.userId),
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
@@ -186,95 +140,39 @@ export class ChatSessionBridge {
|
|||||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
userId: context.userId?.trim(),
|
userId: context.userId?.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cleanupExpired();
|
|
||||||
|
|
||||||
if (!currentClientSessionId || context.keepMessageCount <= 0) {
|
|
||||||
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
|
|
||||||
const binding = this.registry.upsert(nextRequestContext, session.id);
|
|
||||||
this.sessionContexts.set(binding.sessionId, nextRequestContext);
|
|
||||||
await this.toolContextStore.write({
|
|
||||||
actorKey: nextRequestContext.actorKey,
|
|
||||||
allowLearningWrite: true,
|
|
||||||
clientSessionId: nextRequestContext.clientSessionId,
|
|
||||||
learningMode: "interactive",
|
|
||||||
projectId: nextRequestContext.projectId,
|
|
||||||
projectKey: nextRequestContext.projectKey,
|
|
||||||
sessionId: binding.sessionId,
|
|
||||||
traceId: nextRequestContext.traceId,
|
|
||||||
});
|
|
||||||
return { binding, requestContext: nextRequestContext, created: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentContext: ChatRequestContext = {
|
private async abortActiveRuntime(clientSessionId: string) {
|
||||||
clientSessionId: currentClientSessionId,
|
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||||
accessToken: context.accessToken,
|
if (!activeSessionId) {
|
||||||
actorKey: toActorKey(context.userId),
|
return;
|
||||||
projectId: context.projectId,
|
|
||||||
projectKey: toProjectKey(context.projectId),
|
|
||||||
traceId: nextRequestContext.traceId,
|
|
||||||
userId: context.userId?.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const current = this.registry.get(currentContext);
|
|
||||||
if (!current) {
|
|
||||||
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
|
|
||||||
const binding = this.registry.upsert(nextRequestContext, session.id);
|
|
||||||
this.sessionContexts.set(binding.sessionId, nextRequestContext);
|
|
||||||
await this.toolContextStore.write({
|
|
||||||
actorKey: nextRequestContext.actorKey,
|
|
||||||
allowLearningWrite: true,
|
|
||||||
clientSessionId: nextRequestContext.clientSessionId,
|
|
||||||
learningMode: "interactive",
|
|
||||||
projectId: nextRequestContext.projectId,
|
|
||||||
projectKey: nextRequestContext.projectKey,
|
|
||||||
sessionId: binding.sessionId,
|
|
||||||
traceId: nextRequestContext.traceId,
|
|
||||||
});
|
|
||||||
return { binding, requestContext: nextRequestContext, created: true };
|
|
||||||
}
|
}
|
||||||
|
this.activeRuntimeSessions.delete(clientSessionId);
|
||||||
await this.runtime.getSession(current.sessionId);
|
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||||
const messages = await this.runtime.messages(
|
await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
|
||||||
current.sessionId,
|
await this.runtime.abortSession(activeSessionId).catch((error) => {
|
||||||
Math.max(100, context.keepMessageCount + 20),
|
logger.warn(
|
||||||
|
{ clientSessionId, sessionId: activeSessionId, err: error },
|
||||||
|
"failed to abort previous active runtime session",
|
||||||
);
|
);
|
||||||
const chatMessages = messages.filter(
|
});
|
||||||
(message) => message.info.role === "user" || message.info.role === "assistant",
|
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ clientSessionId, sessionId: activeSessionId, err: error },
|
||||||
|
"failed while waiting for previous runtime session to become idle",
|
||||||
);
|
);
|
||||||
const keepMessage = chatMessages[context.keepMessageCount - 1];
|
|
||||||
|
|
||||||
if (!keepMessage) {
|
|
||||||
throw new Error(`fork keep point not found for message count ${context.keepMessageCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.runtime.forkSession(current.sessionId, keepMessage.info.id);
|
|
||||||
const binding = this.registry.upsert(nextRequestContext, session.id);
|
|
||||||
this.sessionContexts.set(binding.sessionId, nextRequestContext);
|
|
||||||
await this.toolContextStore.write({
|
|
||||||
actorKey: nextRequestContext.actorKey,
|
|
||||||
allowLearningWrite: true,
|
|
||||||
clientSessionId: nextRequestContext.clientSessionId,
|
|
||||||
learningMode: "interactive",
|
|
||||||
projectId: nextRequestContext.projectId,
|
|
||||||
projectKey: nextRequestContext.projectKey,
|
|
||||||
sessionId: binding.sessionId,
|
|
||||||
traceId: nextRequestContext.traceId,
|
|
||||||
});
|
});
|
||||||
this.cloneSessionTitle(current.sessionId, binding.sessionId);
|
|
||||||
return { binding, requestContext: nextRequestContext, created: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupExpired(): void {
|
|
||||||
const expiredSessionIds = this.registry.evictExpired();
|
|
||||||
for (const sessionId of expiredSessionIds) {
|
|
||||||
this.sessionContexts.delete(sessionId);
|
|
||||||
this.sessionTitles.delete(sessionId);
|
|
||||||
void this.toolContextStore.remove(sessionId);
|
|
||||||
// 这里用 abort 做轻量清理;即使失败,也不阻断本地过期回收。
|
|
||||||
void this.runtime.abortSession(sessionId).catch((error) => {
|
|
||||||
logger.debug({ sessionId, err: error }, "ignoring failed abort for expired session");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findScopeKey = (
|
||||||
|
contexts: Map<string, ChatRequestContext>,
|
||||||
|
clientSessionId: string,
|
||||||
|
) => {
|
||||||
|
for (const [scopeKey, context] of contexts.entries()) {
|
||||||
|
if (context.clientSessionId === clientSessionId) {
|
||||||
|
return scopeKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clientSessionId;
|
||||||
|
};
|
||||||
|
|||||||
+4
-2
@@ -45,8 +45,6 @@ const envSchema = z
|
|||||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||||
// client 模式下,目标 opencode server 的基础地址。
|
// client 模式下,目标 opencode server 的基础地址。
|
||||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
||||||
// chat session 在本地注册表中的保活时长(秒)。
|
|
||||||
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
|
||||||
// 提供给本地 opencode tools 读取的会话上下文目录。
|
// 提供给本地 opencode tools 读取的会话上下文目录。
|
||||||
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
||||||
// TJWater 后端 API 的基础地址。
|
// TJWater 后端 API 的基础地址。
|
||||||
@@ -65,6 +63,10 @@ const envSchema = z
|
|||||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||||
// session transcript 持久化目录。
|
// session transcript 持久化目录。
|
||||||
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
||||||
|
// conversation metadata 持久化目录。
|
||||||
|
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
|
||||||
|
// conversation UI state 持久化目录。
|
||||||
|
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
|
||||||
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
||||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
readJsonFile,
|
||||||
|
removeFileIfExists,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
|
export type ConversationStateRecord = {
|
||||||
|
sessionId: string;
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
|
messages: unknown[];
|
||||||
|
branchGroups: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConversationStateStore {
|
||||||
|
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
await ensureDirectory(this.baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(sessionScopeKey: string) {
|
||||||
|
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(sessionScopeKey: string, state: ConversationStateRecord) {
|
||||||
|
await atomicWriteJson(this.filePath(sessionScopeKey), state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(sessionScopeKey: string) {
|
||||||
|
await removeFileIfExists(this.filePath(sessionScopeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath(sessionScopeKey: string) {
|
||||||
|
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
listJsonFiles,
|
||||||
|
readJsonFile,
|
||||||
|
removeFileIfExists,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
|
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||||
|
|
||||||
|
export type ConversationStatus = "active" | "archived";
|
||||||
|
|
||||||
|
export type ConversationRecord = {
|
||||||
|
sessionId: string;
|
||||||
|
sessionScopeKey: string;
|
||||||
|
actorKey: string;
|
||||||
|
ownerUserId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
projectKey: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
status: ConversationStatus;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationContext = {
|
||||||
|
actorKey: string;
|
||||||
|
userId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
projectKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnsureConversationInput = ConversationContext & {
|
||||||
|
sessionId?: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConversationStore {
|
||||||
|
constructor(private readonly baseDir = config.CONVERSATION_STORAGE_DIR) {}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
await ensureDirectory(this.baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensure(input: EnsureConversationInput) {
|
||||||
|
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
|
||||||
|
const sessionScopeKey = toConversationScopeKey(
|
||||||
|
input.actorKey,
|
||||||
|
input.projectKey,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
|
||||||
|
if (existing) {
|
||||||
|
return { created: false, record: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const record: ConversationRecord = {
|
||||||
|
sessionId,
|
||||||
|
sessionScopeKey,
|
||||||
|
actorKey: input.actorKey,
|
||||||
|
ownerUserId: input.userId?.trim(),
|
||||||
|
projectId: input.projectId,
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
parentSessionId: normalizeSessionId(input.parentSessionId),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
await atomicWriteJson(this.filePath(sessionScopeKey), record);
|
||||||
|
return { created: true, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(context: ConversationContext, sessionId: string) {
|
||||||
|
const normalizedSessionId = normalizeSessionId(sessionId);
|
||||||
|
if (!normalizedSessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await readJsonFile<ConversationRecord>(
|
||||||
|
this.filePath(
|
||||||
|
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async touch(
|
||||||
|
record: ConversationRecord,
|
||||||
|
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
|
||||||
|
) {
|
||||||
|
const next: ConversationRecord = {
|
||||||
|
...record,
|
||||||
|
...normalizeConversationUpdates(updates),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await atomicWriteJson(this.filePath(record.sessionScopeKey), next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(context: ConversationContext) {
|
||||||
|
const files = await listJsonFiles(this.baseDir);
|
||||||
|
const records = await Promise.all(
|
||||||
|
files.map((file) => readJsonFile<ConversationRecord>(file)),
|
||||||
|
);
|
||||||
|
return records
|
||||||
|
.filter((record): record is ConversationRecord => Boolean(record))
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
record.actorKey === context.actorKey &&
|
||||||
|
record.projectKey === context.projectKey,
|
||||||
|
)
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(record: ConversationRecord) {
|
||||||
|
await removeFileIfExists(this.filePath(record.sessionScopeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath(sessionScopeKey: string) {
|
||||||
|
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
|
||||||
|
|
||||||
|
const normalizeSessionId = (value?: string) => {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
return normalized ? normalized.slice(0, 128) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeConversationUpdates = (
|
||||||
|
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
|
||||||
|
) => {
|
||||||
|
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {};
|
||||||
|
if (updates.status === "active" || updates.status === "archived") {
|
||||||
|
normalized.status = updates.status;
|
||||||
|
}
|
||||||
|
if (typeof updates.title === "string") {
|
||||||
|
const trimmed = updates.title.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
normalized.title = trimmed.slice(0, 120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
+68
-1
@@ -85,6 +85,7 @@ export class SessionHistoryStore {
|
|||||||
userMessage,
|
userMessage,
|
||||||
};
|
};
|
||||||
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
||||||
|
transcript.sessionId = context.sessionId;
|
||||||
transcript.turns.push(record);
|
transcript.turns.push(record);
|
||||||
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
|
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
|
||||||
transcript.turns = transcript.turns.slice(
|
transcript.turns = transcript.turns.slice(
|
||||||
@@ -108,6 +109,25 @@ export class SessionHistoryStore {
|
|||||||
return transcript.turns.slice(-Math.max(1, limit));
|
return transcript.turns.slice(-Math.max(1, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cloneThread(
|
||||||
|
sourceContext: SessionHistoryContext,
|
||||||
|
targetContext: SessionHistoryContext,
|
||||||
|
keepMessageCount: number,
|
||||||
|
) {
|
||||||
|
const sourceTranscript = await this.readTranscript(sourceContext);
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nextTranscript: SessionTranscriptRecord = {
|
||||||
|
actorKey: targetContext.actorKey,
|
||||||
|
clientSessionId: targetContext.clientSessionId,
|
||||||
|
projectKey: targetContext.projectKey,
|
||||||
|
sessionId: targetContext.sessionId,
|
||||||
|
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
await atomicWriteJson(this.filePath(targetContext), nextTranscript);
|
||||||
|
return nextTranscript;
|
||||||
|
}
|
||||||
|
|
||||||
async search(
|
async search(
|
||||||
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
|
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -156,7 +176,38 @@ export class SessionHistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readTranscript(context: SessionHistoryContext) {
|
private async readTranscript(context: SessionHistoryContext) {
|
||||||
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSessionId = context.clientSessionId?.trim();
|
||||||
|
if (!clientSessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await listJsonFiles(this.baseDir);
|
||||||
|
const matches: SessionTranscriptRecord[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
||||||
|
if (!transcript) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
transcript.actorKey !== context.actorKey ||
|
||||||
|
transcript.projectKey !== context.projectKey ||
|
||||||
|
transcript.clientSessionId !== clientSessionId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matches.push(transcript);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private filePath(context: SessionHistoryContext) {
|
private filePath(context: SessionHistoryContext) {
|
||||||
@@ -211,3 +262,19 @@ const buildSnippet = (text: string, query: string) => {
|
|||||||
const suffix = end < compact.length ? "..." : "";
|
const suffix = end < compact.length ? "..." : "";
|
||||||
return `${prefix}${snippet}${suffix}`;
|
return `${prefix}${snippet}${suffix}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectTurnsForFork = (
|
||||||
|
turns: SessionTurnRecord[],
|
||||||
|
keepMessageCount: number,
|
||||||
|
): SessionTurnRecord[] => {
|
||||||
|
if (keepMessageCount <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepTurnCount = Math.floor(keepMessageCount / 2);
|
||||||
|
if (keepTurnCount <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return turns.slice(0, keepTurnCount);
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { LearningStateStore } from "./stateStore.js";
|
|||||||
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
import { SkillStore } from "../skills/store.js";
|
import { SkillStore } from "../skills/store.js";
|
||||||
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
import {
|
||||||
|
buildToolSessionScopeKey,
|
||||||
|
ToolSessionContextStore,
|
||||||
|
} from "../session/toolContextStore.js";
|
||||||
import {
|
import {
|
||||||
sanitizePersistentDocument,
|
sanitizePersistentDocument,
|
||||||
sanitizePersistentLine,
|
sanitizePersistentLine,
|
||||||
@@ -150,6 +153,11 @@ export class LearningOrchestrator {
|
|||||||
projectId: input.requestContext.projectId,
|
projectId: input.requestContext.projectId,
|
||||||
projectKey: input.requestContext.projectKey,
|
projectKey: input.requestContext.projectKey,
|
||||||
sessionId: gateSession.id,
|
sessionId: gateSession.id,
|
||||||
|
sessionScopeKey: buildToolSessionScopeKey(
|
||||||
|
input.requestContext.actorKey,
|
||||||
|
input.requestContext.projectKey,
|
||||||
|
input.requestContext.clientSessionId,
|
||||||
|
),
|
||||||
traceId: input.requestContext.traceId,
|
traceId: input.requestContext.traceId,
|
||||||
});
|
});
|
||||||
await this.runtime.prompt(
|
await this.runtime.prompt(
|
||||||
@@ -239,6 +247,11 @@ export class LearningOrchestrator {
|
|||||||
projectId: input.requestContext.projectId,
|
projectId: input.requestContext.projectId,
|
||||||
projectKey: input.requestContext.projectKey,
|
projectKey: input.requestContext.projectKey,
|
||||||
sessionId: reviewSession.id,
|
sessionId: reviewSession.id,
|
||||||
|
sessionScopeKey: buildToolSessionScopeKey(
|
||||||
|
input.requestContext.actorKey,
|
||||||
|
input.requestContext.projectKey,
|
||||||
|
input.requestContext.clientSessionId,
|
||||||
|
),
|
||||||
traceId: input.requestContext.traceId,
|
traceId: input.requestContext.traceId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { config } from "../config.js";
|
||||||
|
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
||||||
|
import {
|
||||||
|
type ResultReferenceKind,
|
||||||
|
type ResultReferenceRecord,
|
||||||
|
type ResultReferenceSource,
|
||||||
|
type RetrievalContext,
|
||||||
|
RESULT_REFERENCE_KIND,
|
||||||
|
type ResultReferenceStore,
|
||||||
|
} from "./store.js";
|
||||||
|
|
||||||
|
type ResolveOptions = {
|
||||||
|
expectedKind?: ResultReferenceKind;
|
||||||
|
maxItems?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterResultReferenceInput = {
|
||||||
|
actorKey: string;
|
||||||
|
clientSessionId: string;
|
||||||
|
data: unknown;
|
||||||
|
kind: ResultReferenceKind;
|
||||||
|
projectId?: string;
|
||||||
|
projectKey: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
sessionId: string;
|
||||||
|
source: ResultReferenceSource;
|
||||||
|
traceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenderJunctionPayload = {
|
||||||
|
node_area_map: Record<string, string>;
|
||||||
|
area_ids?: string[];
|
||||||
|
area_colors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ResultReferenceResolver {
|
||||||
|
constructor(private readonly store: ResultReferenceStore) {}
|
||||||
|
|
||||||
|
async register(input: RegisterResultReferenceInput) {
|
||||||
|
const normalizedData = normalizeDataForKind(
|
||||||
|
input.kind,
|
||||||
|
input.data,
|
||||||
|
input.schemaVersion,
|
||||||
|
);
|
||||||
|
if (!normalizedData) {
|
||||||
|
throw new Error(`invalid payload for result ref kind '${input.kind}'`);
|
||||||
|
}
|
||||||
|
return this.store.store({
|
||||||
|
actorKey: input.actorKey,
|
||||||
|
clientSessionId: input.clientSessionId,
|
||||||
|
data: normalizedData,
|
||||||
|
kind: input.kind,
|
||||||
|
projectId: input.projectId,
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
schemaVersion: input.schemaVersion,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
source: input.source,
|
||||||
|
traceId: input.traceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerRenderPayloadFile(
|
||||||
|
filePath: string,
|
||||||
|
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
||||||
|
) {
|
||||||
|
const raw = await readJsonFile<unknown>(filePath);
|
||||||
|
if (raw === null) {
|
||||||
|
throw new Error(`render payload file not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadCandidate = normalizeRenderPayloadFile(raw, {
|
||||||
|
filePath,
|
||||||
|
projectId: input.projectId,
|
||||||
|
});
|
||||||
|
if (payloadCandidate.repaired) {
|
||||||
|
await atomicWriteJson(filePath, payloadCandidate.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = extractRenderJunctionPayload(payloadCandidate.data);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("render payload file does not contain a valid junction render payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.register({
|
||||||
|
...input,
|
||||||
|
data: payload,
|
||||||
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
|
schemaVersion: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthorized(resultRef: string, context: RetrievalContext, options: ResolveOptions = {}) {
|
||||||
|
const record = await this.getResolvedRecord(resultRef, context, options);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result_ref: record.resultRef,
|
||||||
|
result_size_bytes: record.sizeBytes,
|
||||||
|
stored_at: record.createdAt,
|
||||||
|
data: projectData(record.data, options.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS),
|
||||||
|
preview: record.preview,
|
||||||
|
kind: record.kind,
|
||||||
|
schema_version: record.schemaVersion,
|
||||||
|
source: record.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFullAuthorized(
|
||||||
|
resultRef: string,
|
||||||
|
context: RetrievalContext,
|
||||||
|
options: ResolveOptions = {},
|
||||||
|
) {
|
||||||
|
const record = await this.getResolvedRecord(resultRef, context, options);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result_ref: record.resultRef,
|
||||||
|
result_size_bytes: record.sizeBytes,
|
||||||
|
stored_at: record.createdAt,
|
||||||
|
data: record.data,
|
||||||
|
preview: record.preview,
|
||||||
|
kind: record.kind,
|
||||||
|
schema_version: record.schemaVersion,
|
||||||
|
source: record.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getResolvedRecord(
|
||||||
|
resultRef: string,
|
||||||
|
context: RetrievalContext,
|
||||||
|
options: ResolveOptions,
|
||||||
|
): Promise<ResultReferenceRecord | null> {
|
||||||
|
const record = await this.store.getAuthorizedRecord(resultRef, context);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options.expectedKind && record.kind !== options.expectedKind) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedData = normalizeDataForKind(
|
||||||
|
record.kind,
|
||||||
|
record.data,
|
||||||
|
record.schemaVersion,
|
||||||
|
);
|
||||||
|
if (!normalizedData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
data: normalizedData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractRenderJunctionPayload = (
|
||||||
|
value: unknown,
|
||||||
|
): RenderJunctionPayload | null => {
|
||||||
|
const candidate = unwrapReferencePayload(value);
|
||||||
|
if (!candidate || !isRecord(candidate.node_area_map)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
||||||
|
if (Object.keys(nodeAreaMap).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaIds = Array.isArray(candidate.area_ids)
|
||||||
|
? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const areaColors = isRecord(candidate.area_colors)
|
||||||
|
? normalizeStringRecord(candidate.area_colors)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
node_area_map: nodeAreaMap,
|
||||||
|
...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}),
|
||||||
|
...(areaColors && Object.keys(areaColors).length > 0
|
||||||
|
? { area_colors: areaColors }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDataForKind = (
|
||||||
|
kind: ResultReferenceKind,
|
||||||
|
data: unknown,
|
||||||
|
schemaVersion: number,
|
||||||
|
): unknown | null => {
|
||||||
|
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
|
||||||
|
return extractRenderJunctionPayload(data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRenderPayloadFile = (
|
||||||
|
value: unknown,
|
||||||
|
context: { filePath: string; projectId?: string },
|
||||||
|
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
|
||||||
|
if (!isRecord(value) || !("data" in value)) {
|
||||||
|
return {
|
||||||
|
data: value,
|
||||||
|
file: {
|
||||||
|
metadata: buildWrapperMetadata({}, value, context.projectId),
|
||||||
|
location: buildWrapperLocation(undefined, context.filePath),
|
||||||
|
data: value,
|
||||||
|
},
|
||||||
|
repaired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
|
||||||
|
const location = buildWrapperLocation(value.location, context.filePath);
|
||||||
|
const next: Record<string, unknown> = {
|
||||||
|
...value,
|
||||||
|
metadata,
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: next.data,
|
||||||
|
file: next,
|
||||||
|
repaired:
|
||||||
|
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
|
||||||
|
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("data" in value && value.data !== undefined && value.data !== null) {
|
||||||
|
return isRecord(value.data) ? value.data : null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeStringRecord = (value: Record<string, unknown>) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.map(([key, entry]) => [String(key), String(entry ?? "").trim()])
|
||||||
|
.filter(([, entry]) => entry.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectData = (data: unknown, maxItems: number) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.slice(0, maxItems);
|
||||||
|
}
|
||||||
|
if (isRecord(data)) {
|
||||||
|
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const buildWrapperMetadata = (
|
||||||
|
value: unknown,
|
||||||
|
root: unknown,
|
||||||
|
fallbackProjectId?: string,
|
||||||
|
) => {
|
||||||
|
const metadata = isRecord(value) ? { ...value } : {};
|
||||||
|
const source = isRecord(root) ? root : {};
|
||||||
|
|
||||||
|
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
|
||||||
|
const createdAt =
|
||||||
|
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
|
||||||
|
? source.createdAt.trim()
|
||||||
|
: new Date().toISOString();
|
||||||
|
metadata.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof metadata.projectId !== "string" ||
|
||||||
|
metadata.projectId.trim().length === 0
|
||||||
|
) {
|
||||||
|
const projectId =
|
||||||
|
typeof source.projectId === "string" && source.projectId.trim().length > 0
|
||||||
|
? source.projectId.trim()
|
||||||
|
: fallbackProjectId;
|
||||||
|
if (projectId) {
|
||||||
|
metadata.projectId = projectId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWrapperLocation = (value: unknown, filePath: string) => {
|
||||||
|
if (isRecord(value)) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
file_path: filePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file_path: filePath,
|
||||||
|
};
|
||||||
|
};
|
||||||
+232
-65
@@ -10,21 +10,29 @@ import {
|
|||||||
listJsonFiles,
|
listJsonFiles,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
|
toProjectKey,
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type ResultReferenceRecord = {
|
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
||||||
resultRef: string;
|
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||||
actorKey: string;
|
|
||||||
clientSessionId: string;
|
export const RESULT_REFERENCE_KIND = {
|
||||||
createdAt: string;
|
dynamicHttpResult: "dynamic-http-result",
|
||||||
data: unknown;
|
renderJunctionsPayload: "render-junctions-payload",
|
||||||
preview: ResultPreview;
|
} as const;
|
||||||
projectId?: string;
|
|
||||||
projectKey: string;
|
export const RESULT_REFERENCE_SOURCE = {
|
||||||
sessionId: string;
|
dynamicHttp: "dynamic_http",
|
||||||
sizeBytes: number;
|
agentGenerated: "agent_generated",
|
||||||
traceId: string;
|
legacy: "legacy",
|
||||||
};
|
migration: "migration",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ResultReferenceKind =
|
||||||
|
(typeof RESULT_REFERENCE_KIND)[keyof typeof RESULT_REFERENCE_KIND];
|
||||||
|
|
||||||
|
export type ResultReferenceSource =
|
||||||
|
(typeof RESULT_REFERENCE_SOURCE)[keyof typeof RESULT_REFERENCE_SOURCE];
|
||||||
|
|
||||||
export type ResultPreview = {
|
export type ResultPreview = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -33,29 +41,51 @@ export type ResultPreview = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResultReferenceRecord = {
|
||||||
|
resultRef: string;
|
||||||
|
actorKey: string;
|
||||||
|
clientSessionId: string;
|
||||||
|
createdAt: string;
|
||||||
|
data: unknown;
|
||||||
|
kind: ResultReferenceKind;
|
||||||
|
preview: ResultPreview;
|
||||||
|
projectId?: string;
|
||||||
|
projectKey: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
sessionId: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
source: ResultReferenceSource;
|
||||||
|
traceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type StoreResultInput = {
|
export type StoreResultInput = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
kind: ResultReferenceKind;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
|
schemaVersion: number;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
source: ResultReferenceSource;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RetrievalContext = {
|
export type RetrievalContext = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
clientSessionId?: string;
|
clientSessionId?: string;
|
||||||
maxItems?: number;
|
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResultReferencePeek = {
|
export type ResultReferencePeek = {
|
||||||
resultRef: string;
|
resultRef: string;
|
||||||
|
kind: ResultReferenceKind;
|
||||||
preview: ResultPreview;
|
preview: ResultPreview;
|
||||||
storedAt: string;
|
storedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PartialRecord = Partial<ResultReferenceRecord> & { data?: unknown };
|
||||||
|
|
||||||
export class ResultReferenceStore {
|
export class ResultReferenceStore {
|
||||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
@@ -95,55 +125,59 @@ export class ResultReferenceStore {
|
|||||||
clientSessionId: input.clientSessionId,
|
clientSessionId: input.clientSessionId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
data: input.data,
|
data: input.data,
|
||||||
|
kind: input.kind,
|
||||||
preview: buildPreview(input.data),
|
preview: buildPreview(input.data),
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
projectKey: input.projectKey,
|
projectKey: input.projectKey,
|
||||||
|
schemaVersion: input.schemaVersion,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
sizeBytes: estimateBytes(input.data),
|
sizeBytes: estimateBytes(input.data),
|
||||||
|
source: input.source,
|
||||||
traceId: input.traceId,
|
traceId: input.traceId,
|
||||||
};
|
};
|
||||||
await atomicWriteJson(this.filePath(resultRef), record);
|
await atomicWriteJson(this.filePath(resultRef), record);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthorized(resultRef: string, context: RetrievalContext) {
|
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||||
const record = await this.readAuthorizedRecord(resultRef, context);
|
const normalizedResultRef = normalizeResultRef(resultRef);
|
||||||
|
if (!normalizedResultRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
|
||||||
|
const record =
|
||||||
|
normalizeResultReferenceRecord(rawRecord) ??
|
||||||
|
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = projectData(record.data, context.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS);
|
if (record.actorKey !== context.actorKey) {
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
result_ref: record.resultRef,
|
|
||||||
result_size_bytes: record.sizeBytes,
|
|
||||||
stored_at: record.createdAt,
|
|
||||||
data,
|
|
||||||
preview: record.preview,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFullAuthorized(resultRef: string, context: RetrievalContext) {
|
|
||||||
const record = await this.readAuthorizedRecord(resultRef, context);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
||||||
ok: true,
|
return null;
|
||||||
result_ref: record.resultRef,
|
}
|
||||||
result_size_bytes: record.sizeBytes,
|
if (
|
||||||
stored_at: record.createdAt,
|
context.clientSessionId &&
|
||||||
data: record.data,
|
record.clientSessionId !== context.clientSessionId
|
||||||
preview: record.preview,
|
) {
|
||||||
};
|
return null;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async peekAuthorized(resultRef: string, context: RetrievalContext): Promise<ResultReferencePeek | null> {
|
async peekAuthorized(
|
||||||
const record = await this.readAuthorizedRecord(resultRef, context);
|
resultRef: string,
|
||||||
|
context: RetrievalContext,
|
||||||
|
): Promise<ResultReferencePeek | null> {
|
||||||
|
const record = await this.getAuthorizedRecord(resultRef, context);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
resultRef: record.resultRef,
|
resultRef: record.resultRef,
|
||||||
|
kind: record.kind,
|
||||||
preview: record.preview,
|
preview: record.preview,
|
||||||
storedAt: record.createdAt,
|
storedAt: record.createdAt,
|
||||||
};
|
};
|
||||||
@@ -152,7 +186,9 @@ export class ResultReferenceStore {
|
|||||||
async listBySession(sessionId: string) {
|
async listBySession(sessionId: string) {
|
||||||
const files = await listJsonFiles(this.baseDir);
|
const files = await listJsonFiles(this.baseDir);
|
||||||
const records = await Promise.all(
|
const records = await Promise.all(
|
||||||
files.map(async (filePath) => readJsonFile<ResultReferenceRecord>(filePath)),
|
files.map(async (filePath) =>
|
||||||
|
normalizeResultReferenceRecord(await readJsonFile<unknown>(filePath)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return records
|
return records
|
||||||
.filter((record): record is ResultReferenceRecord => Boolean(record))
|
.filter((record): record is ResultReferenceRecord => Boolean(record))
|
||||||
@@ -177,27 +213,166 @@ export class ResultReferenceStore {
|
|||||||
private filePath(resultRef: string) {
|
private filePath(resultRef: string) {
|
||||||
return join(this.baseDir, `${resultRef}.json`);
|
return join(this.baseDir, `${resultRef}.json`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async readAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
export const normalizeResultReferenceRecord = (
|
||||||
const record = await readJsonFile<ResultReferenceRecord>(this.filePath(resultRef));
|
value: unknown,
|
||||||
if (!record) {
|
): ResultReferenceRecord | null => {
|
||||||
return null;
|
if (!isRecord(value)) {
|
||||||
}
|
|
||||||
if (record.actorKey !== context.actorKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const partial = value as PartialRecord;
|
||||||
if (
|
if (
|
||||||
context.clientSessionId &&
|
!isValidResultRef(partial.resultRef) ||
|
||||||
record.clientSessionId !== context.clientSessionId
|
typeof partial.actorKey !== "string" ||
|
||||||
|
typeof partial.clientSessionId !== "string" ||
|
||||||
|
typeof partial.createdAt !== "string" ||
|
||||||
|
!("data" in partial) ||
|
||||||
|
!isResultPreview(partial.preview) ||
|
||||||
|
typeof partial.projectKey !== "string" ||
|
||||||
|
typeof partial.sessionId !== "string" ||
|
||||||
|
typeof partial.sizeBytes !== "number" ||
|
||||||
|
!Number.isFinite(partial.sizeBytes) ||
|
||||||
|
typeof partial.traceId !== "string"
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return record;
|
|
||||||
|
const kind = normalizeResultReferenceKind(partial.kind);
|
||||||
|
const source = normalizeResultReferenceSource(partial.source);
|
||||||
|
const schemaVersion =
|
||||||
|
typeof partial.schemaVersion === "number" &&
|
||||||
|
Number.isInteger(partial.schemaVersion) &&
|
||||||
|
partial.schemaVersion > 0
|
||||||
|
? partial.schemaVersion
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
if (!kind || !source) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (
|
||||||
|
partial.projectId !== undefined &&
|
||||||
|
typeof partial.projectId !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resultRef: partial.resultRef,
|
||||||
|
actorKey: partial.actorKey,
|
||||||
|
clientSessionId: partial.clientSessionId,
|
||||||
|
createdAt: partial.createdAt,
|
||||||
|
data: partial.data,
|
||||||
|
kind,
|
||||||
|
preview: partial.preview,
|
||||||
|
projectId: partial.projectId,
|
||||||
|
projectKey: partial.projectKey,
|
||||||
|
schemaVersion,
|
||||||
|
sessionId: partial.sessionId,
|
||||||
|
sizeBytes: partial.sizeBytes,
|
||||||
|
source,
|
||||||
|
traceId: partial.traceId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeResultReferenceKind = (
|
||||||
|
value: unknown,
|
||||||
|
): ResultReferenceKind | null => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return RESULT_REFERENCE_KIND.dynamicHttpResult;
|
||||||
|
}
|
||||||
|
return Object.values(RESULT_REFERENCE_KIND).includes(
|
||||||
|
value as ResultReferenceKind,
|
||||||
|
)
|
||||||
|
? (value as ResultReferenceKind)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeResultReferenceSource = (
|
||||||
|
value: unknown,
|
||||||
|
): ResultReferenceSource | null => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return RESULT_REFERENCE_SOURCE.legacy;
|
||||||
|
}
|
||||||
|
return Object.values(RESULT_REFERENCE_SOURCE).includes(
|
||||||
|
value as ResultReferenceSource,
|
||||||
|
)
|
||||||
|
? (value as ResultReferenceSource)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidResultRef = (value: unknown): value is string =>
|
||||||
|
typeof value === "string" && RESULT_REF_PATTERN.test(value);
|
||||||
|
|
||||||
|
const normalizeResultRef = (value: string) => {
|
||||||
|
const match = value.trim().match(RESULT_REF_FILE_PATTERN);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeLegacyRenderReferenceRecord = (
|
||||||
|
value: unknown,
|
||||||
|
resultRef: string,
|
||||||
|
context: RetrievalContext,
|
||||||
|
): ResultReferenceRecord | null => {
|
||||||
|
const data = extractLegacyRenderPayload(value);
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = isRecord(value) ? value : {};
|
||||||
|
const metadata = isRecord(root.metadata) ? root.metadata : {};
|
||||||
|
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
|
||||||
|
const createdAt =
|
||||||
|
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
resultRef,
|
||||||
|
actorKey: context.actorKey,
|
||||||
|
clientSessionId: context.clientSessionId ?? "",
|
||||||
|
createdAt,
|
||||||
|
data,
|
||||||
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
|
preview: buildPreview(data),
|
||||||
|
projectId,
|
||||||
|
projectKey: toProjectKey(projectId),
|
||||||
|
schemaVersion: 1,
|
||||||
|
sessionId: context.clientSessionId ?? resultRef,
|
||||||
|
sizeBytes: estimateBytes(data),
|
||||||
|
source: RESULT_REFERENCE_SOURCE.legacy,
|
||||||
|
traceId: "legacy-render-ref",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLegacyRenderPayload = (value: unknown) => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidate = isRecord(value.data) ? value.data : value;
|
||||||
|
if (!isRecord(candidate.node_area_map)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstNonEmptyString = (...values: unknown[]) => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isResultPreview = (value: unknown): value is ResultPreview =>
|
||||||
|
isRecord(value) &&
|
||||||
|
typeof value.count === "number" &&
|
||||||
|
Number.isFinite(value.count) &&
|
||||||
|
Array.isArray(value.fields) &&
|
||||||
|
value.fields.every((field) => typeof field === "string") &&
|
||||||
|
typeof value.summary === "string" &&
|
||||||
|
"sample" in value;
|
||||||
|
|
||||||
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
|
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
|
||||||
|
|
||||||
@@ -219,7 +394,9 @@ const buildPreview = (data: unknown): ResultPreview => {
|
|||||||
if (isRecord(data)) {
|
if (isRecord(data)) {
|
||||||
const fields = Object.keys(data).slice(0, 30);
|
const fields = Object.keys(data).slice(0, 30);
|
||||||
const sample = Object.fromEntries(
|
const sample = Object.fromEntries(
|
||||||
fields.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS).map((field) => [field, data[field]]),
|
fields
|
||||||
|
.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS)
|
||||||
|
.map((field) => [field, data[field]]),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
count: fields.length,
|
count: fields.length,
|
||||||
@@ -237,15 +414,5 @@ const buildPreview = (data: unknown): ResultPreview => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectData = (data: unknown, maxItems: number) => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.slice(0, maxItems);
|
|
||||||
}
|
|
||||||
if (isRecord(data)) {
|
|
||||||
return Object.fromEntries(Object.entries(data).slice(0, maxItems));
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|||||||
+337
-818
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
|||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { type SessionTurnRecord } from "../history/store.js";
|
||||||
|
import { MemoryStore } from "../memory/store.js";
|
||||||
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
|
||||||
|
import { collectTextContent } from "./chatStream.js";
|
||||||
|
|
||||||
|
const TITLE_PROMPT_TIMEOUT_MS = 5000;
|
||||||
|
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
|
||||||
|
const TITLE_CONTEXT_CHAR_LIMIT = 2400;
|
||||||
|
const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
|
||||||
|
const RESTORE_TURN_LIMIT = 8;
|
||||||
|
const RESTORE_MESSAGE_CHAR_LIMIT = 480;
|
||||||
|
const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
|
||||||
|
const DEFAULT_SESSION_TITLE = "新对话";
|
||||||
|
|
||||||
|
const buildSessionTitle = (message: string) => {
|
||||||
|
const normalized = message.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return DEFAULT_SESSION_TITLE;
|
||||||
|
}
|
||||||
|
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendTitleContextMessage = (
|
||||||
|
lines: string[],
|
||||||
|
role: "用户" | "助手",
|
||||||
|
content: string | undefined,
|
||||||
|
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
|
||||||
|
) => {
|
||||||
|
const normalized = content?.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(`${role}:${normalized.slice(0, maxLength)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTitleConversationContext = async (
|
||||||
|
runtime: OpencodeRuntimeAdapter,
|
||||||
|
sessionId: string,
|
||||||
|
) => {
|
||||||
|
const messages = await runtime.messages(sessionId, TITLE_CONTEXT_MESSAGE_LIMIT);
|
||||||
|
const recentMessages = messages
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
message.info.role === "user" || message.info.role === "assistant",
|
||||||
|
)
|
||||||
|
.map((message) => ({
|
||||||
|
role: message.info.role,
|
||||||
|
content: collectTextContent(message.parts)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, TITLE_CONTEXT_MESSAGE_CHAR_LIMIT),
|
||||||
|
}))
|
||||||
|
.filter((message) => message.content.length > 0);
|
||||||
|
|
||||||
|
if (recentMessages.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedMessages = recentMessages.map(
|
||||||
|
(message) => `${message.role === "user" ? "用户" : "助手"}:${message.content}`,
|
||||||
|
);
|
||||||
|
const fullConversation = formattedMessages.join("\n");
|
||||||
|
if (fullConversation.length <= TITLE_CONTEXT_CHAR_LIMIT) {
|
||||||
|
return fullConversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headCount = Math.min(4, formattedMessages.length);
|
||||||
|
const tailCount = Math.min(8, Math.max(0, formattedMessages.length - headCount));
|
||||||
|
const middleOmitted = formattedMessages.length > headCount + tailCount;
|
||||||
|
const summary = [
|
||||||
|
...formattedMessages.slice(0, headCount),
|
||||||
|
...(middleOmitted ? ["……(中间省略若干轮对话)"] : []),
|
||||||
|
...formattedMessages.slice(-tailCount),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return summary.slice(0, TITLE_CONTEXT_CHAR_LIMIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
|
||||||
|
const normalized = rawTitle
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/^标题[::]\s*/i, "")
|
||||||
|
.replace(/["'“”‘’`]/g, "")
|
||||||
|
.replace(/[。!?!?,,、;;::]+$/g, "")
|
||||||
|
.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldGenerateSessionTitle = (options: {
|
||||||
|
recentTurnCount: number;
|
||||||
|
isTitleManuallyEdited: boolean;
|
||||||
|
}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited;
|
||||||
|
|
||||||
|
export const generateSessionTitle = async (
|
||||||
|
runtime: OpencodeRuntimeAdapter,
|
||||||
|
options: {
|
||||||
|
sessionId: string;
|
||||||
|
latestUserMessage: string;
|
||||||
|
latestAssistantMessage?: string;
|
||||||
|
fallbackTitle?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const fallbackTitle = options.fallbackTitle?.trim();
|
||||||
|
const fallback =
|
||||||
|
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
|
||||||
|
? fallbackTitle
|
||||||
|
: buildSessionTitle(options.latestUserMessage);
|
||||||
|
let titleSessionId: string | undefined;
|
||||||
|
try {
|
||||||
|
const scopedContext: string[] = [];
|
||||||
|
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
|
||||||
|
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
|
||||||
|
const conversation =
|
||||||
|
scopedContext.length > 0
|
||||||
|
? scopedContext.join("\n")
|
||||||
|
: await buildTitleConversationContext(runtime, options.sessionId);
|
||||||
|
if (!conversation) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleSession = await runtime.createSession(`title-${Date.now().toString(36)}`);
|
||||||
|
titleSessionId = titleSession.id;
|
||||||
|
const request = runtime
|
||||||
|
.prompt(
|
||||||
|
titleSession.id,
|
||||||
|
[
|
||||||
|
"你是会话标题生成器。",
|
||||||
|
"请根据下面整段多轮对话生成一个 8-16 字中文标题。",
|
||||||
|
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
|
||||||
|
"优先概括用户当前真实需求和助手最终结论。",
|
||||||
|
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
|
||||||
|
"不要直接照抄用户任一条消息原文。",
|
||||||
|
"只输出标题本身。",
|
||||||
|
"",
|
||||||
|
conversation,
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
await runtime.waitForSessionIdle(titleSession.id, TITLE_PROMPT_TIMEOUT_MS);
|
||||||
|
const messages = await runtime.messages(titleSession.id, 20);
|
||||||
|
const assistantMessage = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.info.role === "assistant");
|
||||||
|
const title = collectTextContent(assistantMessage?.parts ?? []);
|
||||||
|
return normalizeGeneratedTitle(title, fallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = new Promise<string>((resolve) => {
|
||||||
|
setTimeout(() => resolve(fallback), TITLE_PROMPT_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.race([request, timeout]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, "failed to generate session title, using fallback");
|
||||||
|
return fallback;
|
||||||
|
} finally {
|
||||||
|
if (titleSessionId) {
|
||||||
|
await runtime.abortSession(titleSessionId).catch((error) => {
|
||||||
|
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConversationTurnStats = async (
|
||||||
|
runtime: OpencodeRuntimeAdapter,
|
||||||
|
sessionId: string,
|
||||||
|
) => {
|
||||||
|
const messages = await runtime.messages(sessionId, 12);
|
||||||
|
return messages.reduce(
|
||||||
|
(stats, message) => {
|
||||||
|
if (message.info.role === "user") {
|
||||||
|
stats.userMessageCount += 1;
|
||||||
|
} else if (message.info.role === "assistant") {
|
||||||
|
stats.assistantMessageCount += 1;
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userMessageCount: 0,
|
||||||
|
assistantMessageCount: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPromptWithLearningContext = async (
|
||||||
|
memoryStore: MemoryStore,
|
||||||
|
actorKey: string,
|
||||||
|
projectKey: string,
|
||||||
|
recentTurns: SessionTurnRecord[],
|
||||||
|
message: string,
|
||||||
|
) => {
|
||||||
|
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
|
||||||
|
const restoredConversation = buildRestoredConversationContext(recentTurns);
|
||||||
|
if (!snapshot && !restoredConversation) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return [snapshot, restoredConversation, `[Current user request]\n${message}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
|
||||||
|
const formattedTurns = recentTurns
|
||||||
|
.slice(-RESTORE_TURN_LIMIT)
|
||||||
|
.flatMap((turn) => [
|
||||||
|
`用户:${compactMessage(turn.userMessage)}`,
|
||||||
|
`助手:${compactMessage(turn.assistantMessage)}`,
|
||||||
|
])
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
if (formattedTurns.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = formattedTurns.join("\n");
|
||||||
|
const trimmedConversation =
|
||||||
|
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
|
||||||
|
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
|
||||||
|
: conversation;
|
||||||
|
|
||||||
|
return [
|
||||||
|
"[Previous conversation context]",
|
||||||
|
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
|
||||||
|
trimmedConversation,
|
||||||
|
].join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactMessage = (value: string) => {
|
||||||
|
const normalized = value.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
|
||||||
|
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
|
||||||
|
: normalized;
|
||||||
|
};
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||||||
|
|
||||||
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
|
||||||
|
export const supportedModels = [
|
||||||
|
"deepseek/deepseek-v4-flash",
|
||||||
|
"deepseek/deepseek-v4-pro",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SupportedModel = (typeof supportedModels)[number];
|
||||||
|
|
||||||
|
type StreamPromptOptions = {
|
||||||
|
runtime: OpencodeRuntimeAdapter;
|
||||||
|
opencodeSessionId: string;
|
||||||
|
clientSessionId: string;
|
||||||
|
message: string;
|
||||||
|
model?: SupportedModel;
|
||||||
|
traceId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
write: (event: string, data: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressStatus = "running" | "completed" | "error";
|
||||||
|
|
||||||
|
type ProgressPayload = {
|
||||||
|
id: string;
|
||||||
|
phase: string;
|
||||||
|
status: ProgressStatus;
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
const toolLabels: Record<string, string> = {
|
||||||
|
dynamic_http_call: "后端数据查询",
|
||||||
|
fetch_result_ref: "结果引用回读",
|
||||||
|
memory_manager: "记忆写入",
|
||||||
|
session_search: "历史会话检索",
|
||||||
|
skill_manager: "流程沉淀",
|
||||||
|
locate_features: "地图定位",
|
||||||
|
view_history: "历史数据面板",
|
||||||
|
view_scada: "SCADA 面板",
|
||||||
|
show_chart: "图表渲染",
|
||||||
|
render_junctions: "节点渲染",
|
||||||
|
};
|
||||||
|
|
||||||
|
const logDevelopmentDebug = (
|
||||||
|
message: string,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
if (!isDevelopmentDebugLoggingEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info(metadata, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (error: {
|
||||||
|
name: string;
|
||||||
|
data?: { message?: string };
|
||||||
|
}) => error.data?.message ?? error.name;
|
||||||
|
|
||||||
|
const getUnknownErrorMessage = (error: unknown) => {
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"name" in error &&
|
||||||
|
typeof error.name === "string"
|
||||||
|
) {
|
||||||
|
const maybeData = "data" in error ? error.data : undefined;
|
||||||
|
return getErrorMessage({
|
||||||
|
name: error.name,
|
||||||
|
data:
|
||||||
|
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
|
||||||
|
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||||||
|
if (isObjectRecord(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
return isObjectRecord(parsed) ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractRequestReason = (params: Record<string, unknown>) => {
|
||||||
|
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
|
||||||
|
for (const key of candidates) {
|
||||||
|
const value = params[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill");
|
||||||
|
|
||||||
|
const extractSkillAuditInfo = (event: OpencodeEvent) => {
|
||||||
|
const payload = isObjectRecord(event.properties)
|
||||||
|
? (event.properties as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const candidateName =
|
||||||
|
typeof payload.skill === "string"
|
||||||
|
? payload.skill
|
||||||
|
: typeof payload.skillName === "string"
|
||||||
|
? payload.skillName
|
||||||
|
: typeof payload.name === "string"
|
||||||
|
? payload.name
|
||||||
|
: event.type;
|
||||||
|
const reason = extractRequestReason(payload);
|
||||||
|
return {
|
||||||
|
name: candidateName,
|
||||||
|
reason,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasToolParams = (params: Record<string, unknown>) =>
|
||||||
|
Object.keys(params).length > 0;
|
||||||
|
|
||||||
|
const toRuntimeModel = (model?: SupportedModel) => {
|
||||||
|
if (!model) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const [providerID, modelID] = model.split("/");
|
||||||
|
if (!providerID || !modelID) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
|
||||||
|
"properties" in event &&
|
||||||
|
typeof event.properties === "object" &&
|
||||||
|
event.properties !== null &&
|
||||||
|
"sessionID" in event.properties &&
|
||||||
|
event.properties.sessionID === sessionId;
|
||||||
|
|
||||||
|
export const collectTextContent = (parts: Part[]) =>
|
||||||
|
parts
|
||||||
|
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const emitFallbackMessage = async (
|
||||||
|
runtime: OpencodeRuntimeAdapter,
|
||||||
|
opencodeSessionId: string,
|
||||||
|
clientSessionId: string,
|
||||||
|
write: (event: string, data: Record<string, unknown>) => void,
|
||||||
|
) => {
|
||||||
|
const messages = await runtime.messages(opencodeSessionId);
|
||||||
|
const assistantMessage = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.info.role === "assistant");
|
||||||
|
const parts = assistantMessage?.parts ?? [];
|
||||||
|
const text = collectTextContent(parts);
|
||||||
|
if (text) {
|
||||||
|
write("token", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
content: text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeToolStatus = (status: string) => {
|
||||||
|
if (status === "completed") return "completed";
|
||||||
|
if (status === "error") return "error";
|
||||||
|
return "running";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatProgressValue = (value: unknown): string => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
value === null ||
|
||||||
|
value === undefined
|
||||||
|
) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeProgressText = (chunks: string[]) => chunks.join("").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
const truncateProgressText = (text: string, maxLength: number) =>
|
||||||
|
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||||||
|
|
||||||
|
const summarizeToolParams = (params: Record<string, unknown>) => {
|
||||||
|
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
|
||||||
|
const summary = Object.entries(params)
|
||||||
|
.filter(([key]) => !ignoredKeys.has(key))
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return summary || "无附加参数";
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
|
||||||
|
if (status.type === "retry") {
|
||||||
|
return status.message
|
||||||
|
? `模型请求需要重试,原因:${status.message}`
|
||||||
|
: "模型请求正在重试,等待下一次响应。";
|
||||||
|
}
|
||||||
|
if (status.type === "busy") {
|
||||||
|
return status.message
|
||||||
|
? `Agent 正在处理中:${status.message}`
|
||||||
|
: "Agent 正在执行推理、工具调用或结果整理。";
|
||||||
|
}
|
||||||
|
if (status.type === "idle") {
|
||||||
|
return status.message
|
||||||
|
? `Agent 已空闲:${status.message}`
|
||||||
|
: "当前会话暂时没有待处理任务。";
|
||||||
|
}
|
||||||
|
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildReasoningProgressDetail = (chunks: string[], ended?: string | number | Date | null) => {
|
||||||
|
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
|
||||||
|
if (ended) {
|
||||||
|
return reasoningText
|
||||||
|
? `推理过程:${reasoningText}`
|
||||||
|
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
|
||||||
|
}
|
||||||
|
return reasoningText
|
||||||
|
? `正在推理:${reasoningText}`
|
||||||
|
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildToolProgressDetail = (
|
||||||
|
tool: string,
|
||||||
|
status: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
reason: string,
|
||||||
|
error?: string,
|
||||||
|
) => {
|
||||||
|
const toolName = toolLabels[tool] ?? tool;
|
||||||
|
const reasonText = reason ? `;调用原因:${reason}` : "";
|
||||||
|
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
const errorText = error ? `;错误:${error}` : "";
|
||||||
|
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return `${toolName} 已执行完成${reasonText}${paramsText}`;
|
||||||
|
}
|
||||||
|
if (status === "pending") {
|
||||||
|
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
|
||||||
|
}
|
||||||
|
return `${toolName} 正在执行${reasonText}${paramsText}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToolProgressTitle = (tool: string, status: string) => {
|
||||||
|
const toolName = toolLabels[tool] ?? tool;
|
||||||
|
if (status === "completed") return `${toolName} 已完成`;
|
||||||
|
if (status === "error") return `${toolName} 执行失败`;
|
||||||
|
if (status === "pending") return `准备调用 ${toolName}`;
|
||||||
|
return `正在调用 ${toolName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamPromptResponse = async ({
|
||||||
|
runtime,
|
||||||
|
opencodeSessionId,
|
||||||
|
clientSessionId,
|
||||||
|
message,
|
||||||
|
model,
|
||||||
|
traceId,
|
||||||
|
projectId,
|
||||||
|
signal,
|
||||||
|
write,
|
||||||
|
}: StreamPromptOptions): Promise<{
|
||||||
|
aborted: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
toolCallCount: number;
|
||||||
|
}> => {
|
||||||
|
const eventStream = await runtime.subscribeEvents();
|
||||||
|
const iterator = eventStream[Symbol.asyncIterator]();
|
||||||
|
const requestStartedAt = Date.now();
|
||||||
|
const promptStartedAt = Date.now();
|
||||||
|
const progressStartedAtMap = new Map<string, number>();
|
||||||
|
const finalizedProgressIds = new Set<string>();
|
||||||
|
const emittedToolParts = new Set<string>();
|
||||||
|
const partTypes = new Map<string, Part["type"]>();
|
||||||
|
const pendingPartTextDeltas = new Map<string, string[]>();
|
||||||
|
const reasoningDeltas = new Map<string, string[]>();
|
||||||
|
const reasoningStatuses = new Map<string, "running" | "completed">();
|
||||||
|
const toolStatuses = new Map<string, string>();
|
||||||
|
let firstSessionEventLogged = false;
|
||||||
|
let firstNonStatusEventLogged = false;
|
||||||
|
let firstTokenLogged = false;
|
||||||
|
let firstReasoningLogged = false;
|
||||||
|
let firstToolEventLogged = false;
|
||||||
|
let lastSessionStatus: string | null = null;
|
||||||
|
let lastSessionStatusMessage: string | null = null;
|
||||||
|
let emittedText = false;
|
||||||
|
let toolCallCount = 0;
|
||||||
|
let done = false;
|
||||||
|
let promptSettled = false;
|
||||||
|
let aborted = signal?.aborted ?? false;
|
||||||
|
let failed = false;
|
||||||
|
const debugContext = {
|
||||||
|
opencodeSessionId,
|
||||||
|
clientSessionId,
|
||||||
|
traceId,
|
||||||
|
projectId,
|
||||||
|
model: model ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
logDevelopmentDebug("chat stream started", {
|
||||||
|
...debugContext,
|
||||||
|
messageChars: message.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortPromise = signal
|
||||||
|
? new Promise<{ type: "abort" }>((resolve) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
resolve({ type: "abort" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
|
||||||
|
if (status === "running" && finalizedProgressIds.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const startedAt = progressStartedAtMap.get(id) ?? now;
|
||||||
|
if (!progressStartedAtMap.has(id)) {
|
||||||
|
progressStartedAtMap.set(id, startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "running") {
|
||||||
|
write("progress", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
id,
|
||||||
|
phase,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
started_at: startedAt,
|
||||||
|
elapsed_ms: Math.max(0, now - startedAt),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Math.max(0, now - startedAt);
|
||||||
|
finalizedProgressIds.add(id);
|
||||||
|
progressStartedAtMap.delete(id);
|
||||||
|
write("progress", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
id,
|
||||||
|
phase,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
started_at: startedAt,
|
||||||
|
ended_at: now,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
emitProgress({
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "已收到请求,正在启动 Agent 分析",
|
||||||
|
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptPromise = runtime
|
||||||
|
.prompt(opencodeSessionId, message, toRuntimeModel(model))
|
||||||
|
.then(() => {
|
||||||
|
promptSettled = true;
|
||||||
|
logDevelopmentDebug("runtime.prompt resolved", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
promptSettled = true;
|
||||||
|
logDevelopmentDebug("runtime.prompt failed", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
error: getUnknownErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
logDevelopmentDebug("runtime.prompt dispatched", {
|
||||||
|
...debugContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!done) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
aborted = true;
|
||||||
|
logDevelopmentDebug("chat stream noticed abort signal", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEvent = iterator
|
||||||
|
.next()
|
||||||
|
.then((result) => ({ type: "event" as const, result }));
|
||||||
|
const nextPrompt = promptSettled
|
||||||
|
? null
|
||||||
|
: promptPromise.then(
|
||||||
|
() => ({ type: "prompt" as const }),
|
||||||
|
(error: unknown) => ({ type: "prompt-error" as const, error }),
|
||||||
|
);
|
||||||
|
const next = await Promise.race(
|
||||||
|
[
|
||||||
|
...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]),
|
||||||
|
...(abortPromise ? [abortPromise] : []),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next.type === "abort") {
|
||||||
|
aborted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.type === "prompt-error") {
|
||||||
|
throw next.error;
|
||||||
|
}
|
||||||
|
if (next.type === "prompt") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (next.result.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = next.result.value as OpencodeEvent;
|
||||||
|
if (!isSessionEvent(event, opencodeSessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstSessionEventLogged) {
|
||||||
|
firstSessionEventLogged = true;
|
||||||
|
logDevelopmentDebug("first session event received", {
|
||||||
|
...debugContext,
|
||||||
|
eventType: event.type,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.status") {
|
||||||
|
const nextStatus = event.properties.status.type;
|
||||||
|
const nextStatusMessage =
|
||||||
|
"message" in event.properties.status &&
|
||||||
|
typeof event.properties.status.message === "string"
|
||||||
|
? event.properties.status.message
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
nextStatus !== lastSessionStatus ||
|
||||||
|
nextStatusMessage !== lastSessionStatusMessage
|
||||||
|
) {
|
||||||
|
lastSessionStatus = nextStatus;
|
||||||
|
lastSessionStatusMessage = nextStatusMessage;
|
||||||
|
logDevelopmentDebug("session status updated", {
|
||||||
|
...debugContext,
|
||||||
|
status: nextStatus,
|
||||||
|
statusMessage: nextStatusMessage,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emitProgress({
|
||||||
|
id: "session-status",
|
||||||
|
phase: "session",
|
||||||
|
status: event.properties.status.type === "idle" ? "completed" : "running",
|
||||||
|
title:
|
||||||
|
event.properties.status.type === "retry"
|
||||||
|
? `模型请求重试中:${event.properties.status.message}`
|
||||||
|
: event.properties.status.type === "busy"
|
||||||
|
? "Agent 正在处理请求"
|
||||||
|
: "Agent 已空闲",
|
||||||
|
detail: buildSessionStatusDetail(event.properties.status),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstNonStatusEventLogged) {
|
||||||
|
firstNonStatusEventLogged = true;
|
||||||
|
logDevelopmentDebug("first non-status session event received", {
|
||||||
|
...debugContext,
|
||||||
|
eventType: event.type,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSkillEvent(event)) {
|
||||||
|
const { name, reason, payload } = extractSkillAuditInfo(event);
|
||||||
|
logDevelopmentDebug("skill event received", {
|
||||||
|
...debugContext,
|
||||||
|
skill: name,
|
||||||
|
reason: reason || null,
|
||||||
|
payloadKeys: Object.keys(payload).slice(0, 8),
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
void writeLlmRequestAuditLog({
|
||||||
|
kind: "skill",
|
||||||
|
sessionId: opencodeSessionId,
|
||||||
|
clientSessionId,
|
||||||
|
traceId,
|
||||||
|
projectId,
|
||||||
|
target: name,
|
||||||
|
reason,
|
||||||
|
reasonProvided: Boolean(reason),
|
||||||
|
payload,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn({ err: error }, "failed to write skill audit log");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
||||||
|
const partType = partTypes.get(event.properties.partID);
|
||||||
|
if (partType === "text") {
|
||||||
|
if (!firstTokenLogged) {
|
||||||
|
firstTokenLogged = true;
|
||||||
|
logDevelopmentDebug("first response token emitted", {
|
||||||
|
...debugContext,
|
||||||
|
partId: event.properties.partID,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emittedText = true;
|
||||||
|
write("token", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
content: event.properties.delta,
|
||||||
|
});
|
||||||
|
} else if (partType === "reasoning") {
|
||||||
|
if (!firstReasoningLogged) {
|
||||||
|
firstReasoningLogged = true;
|
||||||
|
logDevelopmentDebug("first reasoning delta received", {
|
||||||
|
...debugContext,
|
||||||
|
partId: event.properties.partID,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
|
||||||
|
pending.push(event.properties.delta);
|
||||||
|
reasoningDeltas.set(event.properties.partID, pending);
|
||||||
|
} else if (!partType) {
|
||||||
|
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
|
||||||
|
pending.push(event.properties.delta);
|
||||||
|
pendingPartTextDeltas.set(event.properties.partID, pending);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.part.updated") {
|
||||||
|
const part = event.properties.part;
|
||||||
|
partTypes.set(part.id, part.type);
|
||||||
|
if (part.type === "text") {
|
||||||
|
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
||||||
|
pendingPartTextDeltas.delete(part.id);
|
||||||
|
for (const content of pending) {
|
||||||
|
emittedText = true;
|
||||||
|
write("token", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (part.type === "reasoning") {
|
||||||
|
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
||||||
|
if (pending.length > 0) {
|
||||||
|
const existing = reasoningDeltas.get(part.id) ?? [];
|
||||||
|
reasoningDeltas.set(part.id, existing.concat(pending));
|
||||||
|
}
|
||||||
|
pendingPartTextDeltas.delete(part.id);
|
||||||
|
const reasoningStatus = part.time.end ? "completed" : "running";
|
||||||
|
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
||||||
|
reasoningStatuses.set(part.id, reasoningStatus);
|
||||||
|
logDevelopmentDebug("reasoning part status changed", {
|
||||||
|
...debugContext,
|
||||||
|
partId: part.id,
|
||||||
|
status: reasoningStatus,
|
||||||
|
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const reasoningDetail = buildReasoningProgressDetail(
|
||||||
|
reasoningDeltas.get(part.id) ?? [],
|
||||||
|
part.time.end,
|
||||||
|
);
|
||||||
|
emitProgress({
|
||||||
|
id: part.id,
|
||||||
|
phase: "planning",
|
||||||
|
status: part.time.end ? "completed" : "running",
|
||||||
|
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
|
||||||
|
detail: reasoningDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (part.type === "tool") {
|
||||||
|
if (!firstToolEventLogged) {
|
||||||
|
firstToolEventLogged = true;
|
||||||
|
logDevelopmentDebug("first tool event received", {
|
||||||
|
...debugContext,
|
||||||
|
partId: part.id,
|
||||||
|
tool: part.tool,
|
||||||
|
status: part.state.status,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const toolParams = normalizeToolParams(part.state.input);
|
||||||
|
const reason = extractRequestReason(toolParams);
|
||||||
|
const isToolFinalState =
|
||||||
|
part.state.status === "completed" || part.state.status === "error";
|
||||||
|
const nextToolStatus = String(part.state.status);
|
||||||
|
|
||||||
|
if (toolStatuses.get(part.id) !== nextToolStatus) {
|
||||||
|
toolStatuses.set(part.id, nextToolStatus);
|
||||||
|
logDevelopmentDebug("tool part status changed", {
|
||||||
|
...debugContext,
|
||||||
|
partId: part.id,
|
||||||
|
tool: part.tool,
|
||||||
|
status: nextToolStatus,
|
||||||
|
reason: reason || null,
|
||||||
|
inputKeys: Object.keys(toolParams).slice(0, 8),
|
||||||
|
error:
|
||||||
|
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProgress({
|
||||||
|
id: part.id,
|
||||||
|
phase: "tool",
|
||||||
|
status: normalizeToolStatus(part.state.status),
|
||||||
|
title: getToolProgressTitle(part.tool, part.state.status),
|
||||||
|
detail: buildToolProgressDetail(
|
||||||
|
part.tool,
|
||||||
|
part.state.status,
|
||||||
|
toolParams,
|
||||||
|
reason,
|
||||||
|
part.state.status === "error" ? part.state.error : undefined,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!emittedToolParts.has(part.id) &&
|
||||||
|
(hasToolParams(toolParams) || isToolFinalState)
|
||||||
|
) {
|
||||||
|
emittedToolParts.add(part.id);
|
||||||
|
toolCallCount += 1;
|
||||||
|
if (!reason) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
tool: part.tool,
|
||||||
|
sessionId: opencodeSessionId,
|
||||||
|
clientSessionId,
|
||||||
|
},
|
||||||
|
"llm tool request missing reason",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void writeLlmRequestAuditLog({
|
||||||
|
kind: "tool",
|
||||||
|
sessionId: opencodeSessionId,
|
||||||
|
clientSessionId,
|
||||||
|
traceId,
|
||||||
|
projectId,
|
||||||
|
target: part.tool,
|
||||||
|
reason,
|
||||||
|
reasonProvided: Boolean(reason),
|
||||||
|
payload: toolParams,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn({ err: error }, "failed to write tool audit log");
|
||||||
|
});
|
||||||
|
write("tool_call", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
tool: part.tool,
|
||||||
|
params: toolParams,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "todo.updated") {
|
||||||
|
const completed = event.properties.todos.filter(
|
||||||
|
(todo) => todo.status === "completed",
|
||||||
|
).length;
|
||||||
|
emitProgress({
|
||||||
|
id: "todo-progress",
|
||||||
|
phase: "planning",
|
||||||
|
status: completed === event.properties.todos.length ? "completed" : "running",
|
||||||
|
title: `计划进度 ${completed}/${event.properties.todos.length}`,
|
||||||
|
detail: event.properties.todos
|
||||||
|
.map((todo) => `${todo.status}: ${todo.content}`)
|
||||||
|
.join("\n"),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
logDevelopmentDebug("session error received", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
error: event.properties.error
|
||||||
|
? getErrorMessage(event.properties.error)
|
||||||
|
: "opencode session error",
|
||||||
|
});
|
||||||
|
write("error", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
message: event.properties.error
|
||||||
|
? getErrorMessage(event.properties.error)
|
||||||
|
: "opencode session error",
|
||||||
|
detail: event.properties.error?.name,
|
||||||
|
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
failed = true;
|
||||||
|
done = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
logDevelopmentDebug("session idle received", {
|
||||||
|
...debugContext,
|
||||||
|
emittedText,
|
||||||
|
toolCallCount,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
emitProgress({
|
||||||
|
id: "session-status",
|
||||||
|
phase: "session",
|
||||||
|
status: "completed",
|
||||||
|
title: "Agent 已完成处理",
|
||||||
|
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
|
||||||
|
});
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aborted) {
|
||||||
|
logDevelopmentDebug("chat stream aborting session", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
await runtime.abortSession(opencodeSessionId).catch((error) => {
|
||||||
|
logger.warn({ sessionId: opencodeSessionId, err: error }, "failed to abort opencode session");
|
||||||
|
});
|
||||||
|
await runtime.waitForSessionIdle(opencodeSessionId).catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ sessionId: opencodeSessionId, err: error },
|
||||||
|
"failed while waiting for aborted opencode session to become idle",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { aborted: true, failed: false, toolCallCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return { aborted: false, failed: true, toolCallCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
await promptPromise;
|
||||||
|
if (!emittedText) {
|
||||||
|
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
await emitFallbackMessage(runtime, opencodeSessionId, clientSessionId, write);
|
||||||
|
}
|
||||||
|
emitProgress({
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "completed",
|
||||||
|
title: "请求处理完成",
|
||||||
|
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
|
||||||
|
});
|
||||||
|
emitProgress({
|
||||||
|
id: "request-completed",
|
||||||
|
phase: "complete",
|
||||||
|
status: "completed",
|
||||||
|
title: "分析完成",
|
||||||
|
detail: emittedText
|
||||||
|
? "最终回答已生成并推送到前端。"
|
||||||
|
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
||||||
|
});
|
||||||
|
write("done", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
logDevelopmentDebug("chat stream completed", {
|
||||||
|
...debugContext,
|
||||||
|
emittedText,
|
||||||
|
toolCallCount,
|
||||||
|
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
return { aborted: false, failed: false, toolCallCount };
|
||||||
|
} finally {
|
||||||
|
await iterator.return?.(undefined);
|
||||||
|
if (!promptSettled) {
|
||||||
|
await promptPromise.catch(() => undefined);
|
||||||
|
}
|
||||||
|
logDevelopmentDebug("chat stream cleanup finished", {
|
||||||
|
...debugContext,
|
||||||
|
promptSettled,
|
||||||
|
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+54
-17
@@ -7,6 +7,18 @@ import {
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
|
||||||
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
const logDevelopmentDebug = (
|
||||||
|
message: string,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
if (!isDevelopmentDebugLoggingEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info(metadata, message);
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeHealth = {
|
export type RuntimeHealth = {
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -42,14 +54,6 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "session.create");
|
return requireData(response.data, "session.create");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSession(id: string) {
|
|
||||||
const client = await this.ensureClient();
|
|
||||||
const response = await client.session.get({
|
|
||||||
sessionID: id,
|
|
||||||
});
|
|
||||||
return requireData(response.data, "session.get");
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendPrompt(sessionId: string, text: string) {
|
async sendPrompt(sessionId: string, text: string) {
|
||||||
await this.prompt(sessionId, text);
|
await this.prompt(sessionId, text);
|
||||||
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
||||||
@@ -59,11 +63,27 @@ export class OpencodeRuntimeAdapter {
|
|||||||
|
|
||||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
logDevelopmentDebug(
|
||||||
|
"dispatching opencode session.prompt",
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
model: model ?? null,
|
||||||
|
textChars: text.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
await client.session.prompt({
|
await client.session.prompt({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
model,
|
model,
|
||||||
parts: [{ type: "text", text }],
|
parts: [{ type: "text", text }],
|
||||||
});
|
});
|
||||||
|
logDevelopmentDebug(
|
||||||
|
"opencode session.prompt returned",
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async messages(sessionId: string, limit = 20) {
|
async messages(sessionId: string, limit = 20) {
|
||||||
@@ -75,15 +95,6 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(messages.data, "session.messages");
|
return requireData(messages.data, "session.messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
async forkSession(sessionId: string, messageId?: string) {
|
|
||||||
const client = await this.ensureClient();
|
|
||||||
const response = await client.session.fork({
|
|
||||||
sessionID: sessionId,
|
|
||||||
messageID: messageId,
|
|
||||||
});
|
|
||||||
return requireData(response.data, "session.fork");
|
|
||||||
}
|
|
||||||
|
|
||||||
async abortSession(sessionId: string) {
|
async abortSession(sessionId: string) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.session.abort({
|
const response = await client.session.abort({
|
||||||
@@ -92,6 +103,26 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "session.abort");
|
return requireData(response.data, "session.abort");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
|
||||||
|
const client = await this.ensureClient();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
const response = await client.session.status({});
|
||||||
|
const statuses = requireData(response.data, "session.status");
|
||||||
|
const status = statuses[sessionId];
|
||||||
|
if (!status || status.type === "idle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
{ sessionId, timeoutMs },
|
||||||
|
"timed out waiting for opencode session to become idle",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async subscribeEvents() {
|
async subscribeEvents() {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.event.subscribe();
|
const response = await client.event.subscribe();
|
||||||
@@ -180,3 +211,9 @@ function requireData<T>(data: T | undefined, operation: string): T {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+98
-21
@@ -5,19 +5,22 @@ import express from "express";
|
|||||||
import { SessionHistoryStore } from "./history/store.js";
|
import { SessionHistoryStore } from "./history/store.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
import { ConversationStateStore } from "./conversations/stateStore.js";
|
||||||
|
import { ConversationStore } from "./conversations/store.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||||
import { MemoryStore } from "./memory/store.js";
|
import { MemoryStore } from "./memory/store.js";
|
||||||
|
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||||
import { ResultReferenceStore } from "./results/store.js";
|
import { ResultReferenceStore } from "./results/store.js";
|
||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||||
import { SessionRegistry } from "./session/registry.js";
|
|
||||||
import { ToolSessionContextStore } from "./session/toolContextStore.js";
|
import { ToolSessionContextStore } from "./session/toolContextStore.js";
|
||||||
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const registry = new SessionRegistry(config.SESSION_TTL_SECONDS);
|
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||||
const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime);
|
const conversationStore = new ConversationStore();
|
||||||
|
const conversationStateStore = new ConversationStateStore();
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
const sessionHistoryStore = new SessionHistoryStore();
|
const sessionHistoryStore = new SessionHistoryStore();
|
||||||
const toolContextStore = new ToolSessionContextStore();
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
@@ -27,10 +30,11 @@ const learningOrchestrator = new LearningOrchestrator(
|
|||||||
sessionHistoryStore,
|
sessionHistoryStore,
|
||||||
);
|
);
|
||||||
const resultReferenceStore = new ResultReferenceStore();
|
const resultReferenceStore = new ResultReferenceStore();
|
||||||
|
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||||
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
|
const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore);
|
||||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||||
|
|
||||||
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref)。
|
// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -61,12 +65,22 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
const sessionScopeKey =
|
||||||
const context = sessionBridge.getSessionContext(sessionId);
|
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||||
|
const threadContext = await toolContextStore.read(sessionScopeKey);
|
||||||
|
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
|
||||||
|
if (!threadContext && !runtimeContext) {
|
||||||
|
res.status(404).json({
|
||||||
|
message: "runtime or session context not found",
|
||||||
|
detail: sessionScopeKey,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = runtimeContext ?? threadContext;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
message: "session context not found",
|
message: "runtime or session context not found",
|
||||||
detail: sessionId,
|
detail: sessionScopeKey,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,12 +95,12 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
|||||||
arguments: req.body?.arguments,
|
arguments: req.body?.arguments,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessToken: context.accessToken,
|
accessToken: runtimeContext?.accessToken,
|
||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
clientSessionId: context.clientSessionId,
|
clientSessionId: context.clientSessionId,
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
projectKey: context.projectKey,
|
projectKey: context.projectKey,
|
||||||
sessionId,
|
sessionId: context.clientSessionId,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -106,13 +120,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
const sessionScopeKey =
|
||||||
|
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||||
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
|
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
|
||||||
const context = sessionBridge.getSessionContext(sessionId);
|
const context = await toolContextStore.read(sessionScopeKey);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
message: "session context not found",
|
message: "session context not found",
|
||||||
detail: sessionId,
|
detail: sessionScopeKey,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,12 +136,18 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await resultReferenceStore.getAuthorized(resultRef, {
|
const result = await resultReferenceResolver.getAuthorized(
|
||||||
|
resultRef,
|
||||||
|
{
|
||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
|
clientSessionId: context.clientSessionId,
|
||||||
|
projectId: context.projectId,
|
||||||
|
},
|
||||||
|
{
|
||||||
maxItems:
|
maxItems:
|
||||||
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
|
typeof req.body?.max_items === "number" ? req.body.max_items : undefined,
|
||||||
projectId: context.projectId,
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(404).json({ message: "result_ref not found" });
|
res.status(404).json({ message: "result_ref not found" });
|
||||||
@@ -136,19 +157,70 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||||
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
|
res.status(403).json({ message: "forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionScopeKey =
|
||||||
|
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||||
|
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
|
||||||
|
const context = await toolContextStore.read(sessionScopeKey);
|
||||||
|
if (!context) {
|
||||||
|
res.status(404).json({
|
||||||
|
message: "session context not found",
|
||||||
|
detail: sessionScopeKey,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!filePath) {
|
||||||
|
res.status(400).json({ message: "file_path is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: context.actorKey,
|
||||||
|
clientSessionId: context.clientSessionId,
|
||||||
|
projectId: context.projectId,
|
||||||
|
projectKey: context.projectKey,
|
||||||
|
sessionId: context.clientSessionId,
|
||||||
|
source: "migration",
|
||||||
|
traceId: context.traceId,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
render_ref: record.resultRef,
|
||||||
|
stored_at: record.createdAt,
|
||||||
|
preview: record.preview,
|
||||||
|
kind: record.kind,
|
||||||
|
schema_version: record.schemaVersion,
|
||||||
|
source: record.source,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
res.status(400).json({
|
||||||
|
message: "store render ref failed",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/internal/tools/session-search", async (req, res) => {
|
app.post("/internal/tools/session-search", async (req, res) => {
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
res.status(403).json({ message: "forbidden" });
|
res.status(403).json({ message: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
const sessionScopeKey =
|
||||||
|
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||||
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
||||||
const context = await toolContextStore.read(sessionId);
|
const context = await toolContextStore.read(sessionScopeKey);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
message: "tool session context not found",
|
message: "session context not found",
|
||||||
detail: sessionId,
|
detail: sessionScopeKey,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,14 +247,19 @@ app.use(
|
|||||||
buildChatRouter(
|
buildChatRouter(
|
||||||
sessionBridge,
|
sessionBridge,
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
|
conversationStore,
|
||||||
|
conversationStateStore,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
|
sessionHistoryStore,
|
||||||
learningOrchestrator,
|
learningOrchestrator,
|
||||||
resultReferenceStore,
|
resultReferenceResolver,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
conversationStore.initialize(),
|
||||||
|
conversationStateStore.initialize(),
|
||||||
learningOrchestrator.initialize(),
|
learningOrchestrator.initialize(),
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
resultReferenceStore.initialize(),
|
resultReferenceStore.initialize(),
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
export type SessionBinding = {
|
|
||||||
clientSessionId: string;
|
|
||||||
sessionId: string;
|
|
||||||
lastUsedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SessionContext = {
|
|
||||||
clientSessionId: string;
|
|
||||||
accessToken?: string;
|
|
||||||
projectId?: string;
|
|
||||||
userId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SessionRegistry {
|
|
||||||
private readonly ttlMs: number;
|
|
||||||
private readonly bindings = new Map<string, SessionBinding>();
|
|
||||||
|
|
||||||
constructor(ttlSeconds: number) {
|
|
||||||
this.ttlMs = ttlSeconds * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert(context: SessionContext, sessionId: string): SessionBinding {
|
|
||||||
const binding: SessionBinding = {
|
|
||||||
clientSessionId: context.clientSessionId,
|
|
||||||
sessionId,
|
|
||||||
lastUsedAt: Date.now(),
|
|
||||||
};
|
|
||||||
this.bindings.set(this.makeKey(context), binding);
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(context: SessionContext): SessionBinding | null {
|
|
||||||
const key = this.makeKey(context);
|
|
||||||
const binding = this.bindings.get(key);
|
|
||||||
if (!binding) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (Date.now() - binding.lastUsedAt > this.ttlMs) {
|
|
||||||
this.bindings.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
binding.lastUsedAt = Date.now();
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
|
|
||||||
count(): number {
|
|
||||||
this.evictExpired();
|
|
||||||
return this.bindings.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
evictExpired(): string[] {
|
|
||||||
const expired: string[] = [];
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, binding] of this.bindings.entries()) {
|
|
||||||
if (now - binding.lastUsedAt > this.ttlMs) {
|
|
||||||
expired.push(binding.sessionId);
|
|
||||||
this.bindings.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return expired;
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeKey(context: SessionContext): string {
|
|
||||||
// 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
|
|
||||||
const digest = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(
|
|
||||||
[
|
|
||||||
context.clientSessionId,
|
|
||||||
context.userId?.trim() ?? "",
|
|
||||||
context.projectId ?? "",
|
|
||||||
].join("|"),
|
|
||||||
)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
return digest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
|
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type ToolSessionContext = {
|
export type ToolSessionContext = {
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
@@ -16,6 +17,7 @@ export type ToolSessionContext = {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
sessionScopeKey: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +30,9 @@ export class ToolSessionContextStore {
|
|||||||
|
|
||||||
async write(context: ToolSessionContext) {
|
async write(context: ToolSessionContext) {
|
||||||
await atomicWriteJson(this.filePath(context.sessionId), context);
|
await atomicWriteJson(this.filePath(context.sessionId), context);
|
||||||
|
if (context.learningMode === "interactive" && context.sessionScopeKey) {
|
||||||
|
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(sessionId: string) {
|
async read(sessionId: string) {
|
||||||
@@ -42,3 +47,9 @@ export class ToolSessionContextStore {
|
|||||||
return join(this.baseDir, `${sessionId}.json`);
|
return join(this.baseDir, `${sessionId}.json`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const buildToolSessionScopeKey = (
|
||||||
|
actorKey: string,
|
||||||
|
projectKey: string,
|
||||||
|
clientSessionId: string,
|
||||||
|
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
|
||||||
import { ResultReferenceStore } from "../results/store.js";
|
import { ResultReferenceStore } from "../results/store.js";
|
||||||
|
|
||||||
export type DynamicHttpInput = {
|
export type DynamicHttpInput = {
|
||||||
@@ -146,9 +147,12 @@ const normalizeSuccessResult = async (
|
|||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
clientSessionId: context.clientSessionId,
|
clientSessionId: context.clientSessionId,
|
||||||
data,
|
data,
|
||||||
|
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
projectKey: context.projectKey,
|
projectKey: context.projectKey,
|
||||||
|
schemaVersion: 1,
|
||||||
sessionId: context.sessionId,
|
sessionId: context.sessionId,
|
||||||
|
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,12 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
|
|||||||
export const toStableId = (...parts: string[]) =>
|
export const toStableId = (...parts: string[]) =>
|
||||||
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
|
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
|
||||||
|
|
||||||
|
export const toConversationScopeKey = (
|
||||||
|
actorKey: string,
|
||||||
|
projectKey: string,
|
||||||
|
sessionId: string,
|
||||||
|
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
|
||||||
|
|
||||||
export const slugify = (value: string) =>
|
export const slugify = (value: string) =>
|
||||||
value
|
value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { ConversationStore } from "../../src/conversations/store.js";
|
||||||
|
|
||||||
|
describe("ConversationStore", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let store: ConversationStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-conversation-"));
|
||||||
|
store = new ConversationStore(tempDir);
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues backend-managed session ids when absent", async () => {
|
||||||
|
const { record, created } = await store.ensure({
|
||||||
|
actorKey: "actor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-key-1",
|
||||||
|
userId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created).toBe(true);
|
||||||
|
expect(record.sessionId).toStartWith("chat-");
|
||||||
|
expect(record.ownerUserId).toBe("user-1");
|
||||||
|
expect(record.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touches metadata and preserves scoped ownership", async () => {
|
||||||
|
const { record } = await store.ensure({
|
||||||
|
actorKey: "actor-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
projectKey: "project-key-2",
|
||||||
|
sessionId: "existing-session",
|
||||||
|
userId: "user-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const touched = await store.touch(record, {
|
||||||
|
title: "新标题",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(touched.title).toBe("新标题");
|
||||||
|
expect(touched.updatedAt >= record.updatedAt).toBe(true);
|
||||||
|
|
||||||
|
const fetched = await store.get(
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
projectKey: "project-key-2",
|
||||||
|
userId: "user-2",
|
||||||
|
},
|
||||||
|
"existing-session",
|
||||||
|
);
|
||||||
|
expect(fetched?.sessionScopeKey).toBe(record.sessionScopeKey);
|
||||||
|
expect(fetched?.title).toBe("新标题");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { SessionHistoryStore } from "../../src/history/store.js";
|
||||||
|
|
||||||
|
describe("SessionHistoryStore", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let store: SessionHistoryStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-history-"));
|
||||||
|
store = new SessionHistoryStore(tempDir);
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy runtime-session transcripts by client session id and migrates on append", async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, "actor-1__project-1__runtime-session-1.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
actorKey: "actor-1",
|
||||||
|
clientSessionId: "thread-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
id: "turn-1",
|
||||||
|
assistantMessage: "先检查泵站流量。",
|
||||||
|
timestamp: "2026-05-21T00:00:00.000Z",
|
||||||
|
toolCallCount: 1,
|
||||||
|
userMessage: "帮我看一下当前异常。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentTurns = await store.getRecentTurns(
|
||||||
|
{
|
||||||
|
actorKey: "actor-1",
|
||||||
|
clientSessionId: "thread-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "thread-1",
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recentTurns).toHaveLength(1);
|
||||||
|
expect(recentTurns[0]?.userMessage).toBe("帮我看一下当前异常。");
|
||||||
|
|
||||||
|
const transcript = await store.appendTurn(
|
||||||
|
{
|
||||||
|
actorKey: "actor-1",
|
||||||
|
clientSessionId: "thread-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "thread-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assistantMessage: "已经定位到 3 条疑似异常支路。",
|
||||||
|
toolCallCount: 2,
|
||||||
|
userMessage: "继续分析这些支路。",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(transcript.sessionId).toBe("thread-1");
|
||||||
|
expect(transcript.turns).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clones only the kept prefix when forking a thread", async () => {
|
||||||
|
await store.appendTurn(
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
clientSessionId: "thread-source",
|
||||||
|
projectKey: "project-2",
|
||||||
|
sessionId: "thread-source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assistantMessage: "第一轮回复",
|
||||||
|
toolCallCount: 0,
|
||||||
|
userMessage: "第一轮提问",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await store.appendTurn(
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
clientSessionId: "thread-source",
|
||||||
|
projectKey: "project-2",
|
||||||
|
sessionId: "thread-source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assistantMessage: "第二轮回复",
|
||||||
|
toolCallCount: 0,
|
||||||
|
userMessage: "第二轮提问",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const cloned = await store.cloneThread(
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
clientSessionId: "thread-source",
|
||||||
|
projectKey: "project-2",
|
||||||
|
sessionId: "thread-source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
clientSessionId: "thread-fork",
|
||||||
|
projectKey: "project-2",
|
||||||
|
sessionId: "thread-fork",
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cloned.turns).toHaveLength(1);
|
||||||
|
expect(cloned.turns[0]?.userMessage).toBe("第一轮提问");
|
||||||
|
|
||||||
|
const forkRecentTurns = await store.getRecentTurns(
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
clientSessionId: "thread-fork",
|
||||||
|
projectKey: "project-2",
|
||||||
|
sessionId: "thread-fork",
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(forkRecentTurns).toHaveLength(1);
|
||||||
|
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { ResultReferenceResolver } from "../../src/results/resolver.js";
|
||||||
|
import {
|
||||||
|
RESULT_REFERENCE_KIND,
|
||||||
|
RESULT_REFERENCE_SOURCE,
|
||||||
|
ResultReferenceStore,
|
||||||
|
} from "../../src/results/store.js";
|
||||||
|
|
||||||
|
describe("ResultReferenceResolver", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let store: ResultReferenceStore;
|
||||||
|
let resolver: ResultReferenceResolver;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
||||||
|
store = new ResultReferenceStore(tempDir, 60_000);
|
||||||
|
resolver = new ResultReferenceResolver(store);
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores metadata for new referenced results and resolves them", async () => {
|
||||||
|
const record = await resolver.register({
|
||||||
|
actorKey: "actor-1",
|
||||||
|
clientSessionId: "client-1",
|
||||||
|
data: [{ id: "J1" }, { id: "J2" }],
|
||||||
|
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-key-1",
|
||||||
|
schemaVersion: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.dynamicHttp,
|
||||||
|
traceId: "trace-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
||||||
|
expect(record.schemaVersion).toBe(1);
|
||||||
|
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
|
||||||
|
|
||||||
|
const result = await resolver.getAuthorized(
|
||||||
|
record.resultRef,
|
||||||
|
{
|
||||||
|
actorKey: "actor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxItems: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
||||||
|
expect(result?.schema_version).toBe(1);
|
||||||
|
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp);
|
||||||
|
expect(result?.data).toEqual([{ id: "J1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy result refs readable while defaulting metadata", async () => {
|
||||||
|
const legacyRef = "res-aaaaaaaaaaaaaaaa";
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, `${legacyRef}.json`),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
resultRef: legacyRef,
|
||||||
|
actorKey: "actor-legacy",
|
||||||
|
clientSessionId: "client-legacy",
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
data: { nodes: ["J1"] },
|
||||||
|
preview: {
|
||||||
|
count: 1,
|
||||||
|
fields: ["nodes"],
|
||||||
|
sample: { nodes: ["J1"] },
|
||||||
|
summary: "object<1 fields>",
|
||||||
|
},
|
||||||
|
projectId: "project-legacy",
|
||||||
|
projectKey: "project-key-legacy",
|
||||||
|
sessionId: "session-legacy",
|
||||||
|
sizeBytes: 16,
|
||||||
|
traceId: "trace-legacy",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = await store.getAuthorizedRecord(legacyRef, {
|
||||||
|
actorKey: "actor-legacy",
|
||||||
|
projectId: "project-legacy",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record).not.toBeNull();
|
||||||
|
expect(record?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult);
|
||||||
|
expect(record?.schemaVersion).toBe(1);
|
||||||
|
expect(record?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed refs, mismatched kinds, and auth mismatches", async () => {
|
||||||
|
const malformedRef = "res-bbbbbbbbbbbbbbbb";
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, `${malformedRef}.json`),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
resultRef: malformedRef,
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
data: { value: 1 },
|
||||||
|
preview: {
|
||||||
|
count: 1,
|
||||||
|
fields: ["value"],
|
||||||
|
sample: { value: 1 },
|
||||||
|
summary: "object<1 fields>",
|
||||||
|
},
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-key-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
sizeBytes: 10,
|
||||||
|
traceId: "trace-1",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const malformed = await store.getAuthorizedRecord(malformedRef, {
|
||||||
|
actorKey: "actor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
});
|
||||||
|
expect(malformed).toBeNull();
|
||||||
|
|
||||||
|
const renderRecord = await resolver.register({
|
||||||
|
actorKey: "actor-2",
|
||||||
|
clientSessionId: "client-2",
|
||||||
|
data: {
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
|
projectId: "project-2",
|
||||||
|
projectKey: "project-key-2",
|
||||||
|
schemaVersion: 1,
|
||||||
|
sessionId: "session-2",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
|
traceId: "trace-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrongKind = await resolver.getFullAuthorized(
|
||||||
|
renderRecord.resultRef,
|
||||||
|
{
|
||||||
|
actorKey: "actor-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedKind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(wrongKind).toBeNull();
|
||||||
|
|
||||||
|
const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
|
||||||
|
actorKey: "actor-other",
|
||||||
|
projectId: "project-2",
|
||||||
|
});
|
||||||
|
expect(wrongActor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers render refs from local wrapper files and normalizes payloads", async () => {
|
||||||
|
const filePath = join(tempDir, "render-wrapper.json");
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
projectId: "project-3",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
file_path: filePath,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
J2: 2,
|
||||||
|
},
|
||||||
|
area_ids: ["DMA-1", " DMA-2 "],
|
||||||
|
area_colors: {
|
||||||
|
"DMA-1": "#ff0000",
|
||||||
|
"DMA-2": "#00ff00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = await resolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: "actor-3",
|
||||||
|
clientSessionId: "client-3",
|
||||||
|
projectId: "project-3",
|
||||||
|
projectKey: "project-key-3",
|
||||||
|
sessionId: "session-3",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.migration,
|
||||||
|
traceId: "trace-3",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||||
|
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.migration);
|
||||||
|
|
||||||
|
const result = await resolver.getFullAuthorized(
|
||||||
|
record.resultRef,
|
||||||
|
{
|
||||||
|
actorKey: "actor-3",
|
||||||
|
projectId: "project-3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.data).toEqual({
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
J2: "2",
|
||||||
|
},
|
||||||
|
area_ids: ["DMA-1", "DMA-2"],
|
||||||
|
area_colors: {
|
||||||
|
"DMA-1": "#ff0000",
|
||||||
|
"DMA-2": "#00ff00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs wrapper files that omit metadata and location", async () => {
|
||||||
|
const filePath = join(tempDir, "render-wrapper-missing-fields.json");
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = await resolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: "actor-4",
|
||||||
|
clientSessionId: "client-4",
|
||||||
|
projectId: "project-4",
|
||||||
|
projectKey: "project-key-4",
|
||||||
|
sessionId: "session-4",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.migration,
|
||||||
|
traceId: "trace-4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||||
|
|
||||||
|
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
location?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(repaired.metadata).toEqual({
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
projectId: "project-4",
|
||||||
|
});
|
||||||
|
expect(repaired.location).toEqual({
|
||||||
|
file_path: filePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs wrapper files whose location points elsewhere", async () => {
|
||||||
|
const filePath = join(tempDir, "render-wrapper-wrong-location.json");
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
file_path: "/tmp/elsewhere.json",
|
||||||
|
source: "legacy",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: "actor-4",
|
||||||
|
clientSessionId: "client-4",
|
||||||
|
projectId: "project-4",
|
||||||
|
projectKey: "project-key-4",
|
||||||
|
sessionId: "session-4",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.migration,
|
||||||
|
traceId: "trace-4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
location?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(repaired.metadata).toEqual({
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
projectId: "project-4",
|
||||||
|
});
|
||||||
|
expect(repaired.location).toEqual({
|
||||||
|
file_path: filePath,
|
||||||
|
source: "legacy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves legacy render payload files when callers include the json suffix", async () => {
|
||||||
|
const legacyRef = "res-c2fcee33-577e";
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, `${legacyRef}.json`),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
J2: 2,
|
||||||
|
},
|
||||||
|
area_ids: ["DMA-1"],
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
projectId: "project-legacy-render",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolver.getFullAuthorized(
|
||||||
|
`${legacyRef}.json`,
|
||||||
|
{
|
||||||
|
actorKey: "actor-legacy-render",
|
||||||
|
clientSessionId: "chat-legacy-render",
|
||||||
|
projectId: "project-legacy-render",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.result_ref).toBe(legacyRef);
|
||||||
|
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||||
|
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
|
||||||
|
expect(result?.data).toEqual({
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
J2: "2",
|
||||||
|
},
|
||||||
|
area_ids: ["DMA-1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy render payload files scoped to their project", async () => {
|
||||||
|
const legacyRef = "res-dddddddddddddddd";
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, `${legacyRef}.json`),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
node_area_map: {
|
||||||
|
J1: "DMA-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
projectId: "project-allowed",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolver.getFullAuthorized(
|
||||||
|
legacyRef,
|
||||||
|
{
|
||||||
|
actorKey: "actor-legacy-render",
|
||||||
|
clientSessionId: "chat-legacy-render",
|
||||||
|
projectId: "project-denied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateSessionTitle,
|
||||||
|
shouldGenerateSessionTitle,
|
||||||
|
} from "../../src/routes/chatSession.js";
|
||||||
|
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||||
|
|
||||||
|
describe("shouldGenerateSessionTitle", () => {
|
||||||
|
it("allows auto-title generation for the first turn when the title was not edited", () => {
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 0,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks auto-title generation after the user edits the title manually", () => {
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 0,
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only allows auto-title generation during the first two turns", () => {
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 1,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 2,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateSessionTitle", () => {
|
||||||
|
it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => {
|
||||||
|
let titlePrompt = "";
|
||||||
|
const runtime = {
|
||||||
|
createSession: async () => ({ id: "title-session" }),
|
||||||
|
prompt: async (_sessionId: string, prompt: string) => {
|
||||||
|
titlePrompt = prompt;
|
||||||
|
},
|
||||||
|
waitForSessionIdle: async () => undefined,
|
||||||
|
messages: async () => [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abortSession: async () => undefined,
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
const title = await generateSessionTitle(runtime, {
|
||||||
|
sessionId: "chat-session",
|
||||||
|
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
|
||||||
|
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
|
||||||
|
fallbackTitle: "新对话",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title).toBe("泵站压力异常排查");
|
||||||
|
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
|
||||||
|
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildToolSessionScopeKey,
|
||||||
|
ToolSessionContextStore,
|
||||||
|
} from "../../src/session/toolContextStore.js";
|
||||||
|
|
||||||
|
describe("ToolSessionContextStore", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let store: ToolSessionContextStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-tool-context-"));
|
||||||
|
store = new ToolSessionContextStore(tempDir);
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes interactive aliases under scoped session keys", async () => {
|
||||||
|
const sessionScopeKey = buildToolSessionScopeKey(
|
||||||
|
"actor-1",
|
||||||
|
"project-1",
|
||||||
|
"chat-session-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.write({
|
||||||
|
actorKey: "actor-1",
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: "chat-session-1",
|
||||||
|
learningMode: "interactive",
|
||||||
|
projectId: "project-id-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
sessionScopeKey,
|
||||||
|
traceId: "trace-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeContext = await store.read("runtime-session-1");
|
||||||
|
const scopedContext = await store.read(sessionScopeKey);
|
||||||
|
|
||||||
|
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
||||||
|
expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey);
|
||||||
|
expect(scopedContext?.sessionId).toBe("runtime-session-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-3
@@ -8,9 +8,10 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"rootDir": "src",
|
"rootDir": ".",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node", "bun-types"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user