From 04ded0ceb06ffe2b41a6dc2980f09431becf5106 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 3 Jun 2026 17:14:55 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E7=AE=80=E5=8C=96=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/agents/instruction.md | 2 +- .opencode/skills/runbook.md | 4 +- .opencode/skills/tjwater-cli/SKILL.md | 22 ++-- .opencode/skills/workflow/SKILL.md | 29 +++++ .../hydraulic-bottleneck-analysis/SKILL.md | 14 +++ .opencode/tools/session_search.ts | 10 +- .opencode/tools/store_render_ref.ts | 10 +- .opencode/tools/tjwater_cli.ts | 12 +- src/chat/sessionBridge.ts | 117 ++++++++---------- src/conversations/stateStore.ts | 30 +++-- src/conversations/store.ts | 49 +++++--- src/learning/orchestrator.ts | 15 +-- src/routes/chat.ts | 77 +++++++++--- src/routes/chatSession.ts | 70 ++++++++++- src/server.ts | 68 ++++------ src/session/toolContextStore.ts | 12 +- tests/conversations/store.test.ts | 4 +- tests/routes/chatSession.test.ts | 90 ++++++++++++++ tests/session/toolContextStore.test.ts | 20 +-- 19 files changed, 420 insertions(+), 235 deletions(-) create mode 100644 .opencode/skills/workflow/SKILL.md create mode 100644 .opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index b1fd6a6..d41a9bb 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -4,7 +4,7 @@ mode: primary model: deepseek/deepseek-v4-pro temperature: 0.2 --- -您是 TJWater 供水管网分析 Agent,运用水力专业知识,使用简体中文,回复简洁准确。 +你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。 ## 工作流生命周期 diff --git a/.opencode/skills/runbook.md b/.opencode/skills/runbook.md index 2143c74..b6f55a5 100644 --- a/.opencode/skills/runbook.md +++ b/.opencode/skills/runbook.md @@ -34,12 +34,12 @@ SSE 事件: { "reason": "查询当前项目数据库健康状态", "command": "project db-health", - "timeout": 60 + "timeout": 120 } ``` - `command`:tjwater-cli 子命令(不含二进制路径和 `--auth-context`) -- `timeout`:可选超时秒数,默认 60,大结果集建议 300+ +- `timeout`:可选超时秒数,默认 120,大结果集建议 300+ - 认证上下文(token、server、project)由内部桥接自动注入 ## 4) 工具参数约定(前端工具) diff --git a/.opencode/skills/tjwater-cli/SKILL.md b/.opencode/skills/tjwater-cli/SKILL.md index ed0fe46..8274803 100644 --- a/.opencode/skills/tjwater-cli/SKILL.md +++ b/.opencode/skills/tjwater-cli/SKILL.md @@ -17,7 +17,7 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输 { "reason": "说明调用原因", "command": "project list", - "timeout": 60 + "timeout": 120 } ``` @@ -25,7 +25,7 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输 |------|------|------|------| | `reason` | string | 是 | 调用原因 | | `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context`) | -| `timeout` | number | 否 | 超时秒数,默认 60,大结果集建议 300+ | +| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ | 认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。 @@ -117,6 +117,7 @@ tjwater-cli help COMMAND → 子命令与参数详情 4. **管道串联** — workflow 脚本中用 shell pipe 串联多个 CLI 命令,减少 `subprocess.run` 次数 5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试 6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据 +7. **模拟时长控制** — 模拟(`simulation`)或方案模拟的 `--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大 ## 示例 @@ -147,18 +148,25 @@ tjwater-cli help COMMAND → 子命令与参数详情 ### 触发仿真并获取结果 -`simulation run` 仅接受 `--start-time`(RFC3339,必填)和 `--duration`(整数分钟,必填)。结果需从 `data timeseries` 获取: +通常系统会自动跑仿真,建议**先尝试获取结果**,若无数据再触发仿真: ```json -// step 1: 触发仿真 (duration 为分钟数) +// step 1: 先尝试获取仿真结果 { - "reason": "触发24小时水力仿真", - "command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 1440" + "reason": "尝试获取节点 J-001 09:00 时刻的仿真压力", + "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" } -// step 2: 按节点和时间获取仿真结果 +// step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真 +{ + "reason": "无已有仿真结果,触发1小时水力仿真", + "command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60" +} +// step 3: 仿真完成后,再次获取结果(同 step 1) { "reason": "获取仿真结果中节点 J-001 09:00 时刻的压力", "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" } ``` +`simulation run` 仅接受 `--start-time`(RFC3339,必填)和 `--duration`(整数分钟,必填)。 + diff --git a/.opencode/skills/workflow/SKILL.md b/.opencode/skills/workflow/SKILL.md new file mode 100644 index 0000000..dc44a37 --- /dev/null +++ b/.opencode/skills/workflow/SKILL.md @@ -0,0 +1,29 @@ +--- +name: workflow +description: 供水管网分析工作流目录,描述可复用的分析流程与操作序列。 +--- + +# workflow 工作流 + +## 简介 + +本 skill 为工作流目录入口,汇总可复用的多步骤分析流程。每个工作流对应一个子目录,内含该流程的完整操作步骤、所需数据来源与判定策略。 + +## 可用工作流 + +| 工作流 | 子目录 | 用途 | +|--------|--------|------| +| 水力瓶颈分析 | `hydraulic-bottleneck-analysis` | 复合评分法识别管网水力瓶颈,区分管径不足与阀门节流问题 | + +## 使用方式 + +1. 根据分析需求匹配对应工作流 +2. 按子目录 `SKILL.md` 中的步骤依次执行 +3. 严格遵循判定策略与阈值,避免凭经验跳过步骤 + +## 工作流规范 + +- 每个工作流子目录包含独立的 `SKILL.md`,描述目的、步骤、参数与判定阈值 +- 所有数据获取均通过 `tjwater-cli` 命令族(见 `tjwater-cli` skill) +- 流程中若涉及仿真,遵循"先查结果后触发"原则 +- 新工作流由 `skill_manager` 在线追加,若子目录不存在则无对应工作流 diff --git a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md new file mode 100644 index 0000000..598ef09 --- /dev/null +++ b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md @@ -0,0 +1,14 @@ +--- +name: hydraulic-bottleneck-analysis +description: 由 skill_manager 在线追加的高置信度可复用 workflow。 +version: 1.0.0 +--- + +# learned skill + +## 简介 + +记录由 `skill_manager` 在线追加的高置信度 workflow 模式。 + +## Learned Patterns +- [350873f4366ebc50601f694a] 水力瓶颈复合评分法:流速分级(>3.0m/s=极危, >2.0m/s=严重, >1.5m/s=偏高) × 水头损失绝对值(>P90=严重, >P80=中度),双侧超标为复合瓶颈;辅以节点压力(<20m低压, 20-25m偏低)验证。管道setting字段<100=阀门节流(优先调整阀门), >100=疑似水泵增压 diff --git a/.opencode/tools/session_search.ts b/.opencode/tools/session_search.ts index 9631b3c..f3b3b42 100644 --- a/.opencode/tools/session_search.ts +++ b/.opencode/tools/session_search.ts @@ -1,11 +1,8 @@ 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: @@ -25,11 +22,6 @@ export default tool({ .describe("Optional maximum number of hits to return."), }, async execute(args, context) { - await initializePromise; - const sessionContext = await toolContextStore.read(context.sessionID); - if (!sessionContext) { - throw new Error(`session context not found for ${context.sessionID}`); - } const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, { method: "POST", headers: { @@ -39,7 +31,7 @@ export default tool({ body: JSON.stringify({ max_results: args.max_results, query: args.query, - sessionScopeKey: sessionContext.sessionScopeKey, + session_id: context.sessionID, }), }); const text = await response.text(); diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts index 20725f1..ddf2767 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -1,10 +1,7 @@ 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: @@ -20,11 +17,6 @@ export default tool({ ), }, 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: { @@ -32,7 +24,7 @@ export default tool({ "x-agent-internal-token": internalToken, }, body: JSON.stringify({ - sessionScopeKey: sessionContext.sessionScopeKey, + session_id: context.sessionID, file_path: args.file_path, }), }); diff --git a/.opencode/tools/tjwater_cli.ts b/.opencode/tools/tjwater_cli.ts index 9b315d0..f0af482 100644 --- a/.opencode/tools/tjwater_cli.ts +++ b/.opencode/tools/tjwater_cli.ts @@ -1,10 +1,7 @@ import { tool } from "@opencode-ai/plugin"; -import { ToolSessionContextStore } from "../../src/session/toolContextStore.js"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; -const toolContextStore = new ToolSessionContextStore(); -const initializePromise = toolContextStore.initialize(); export default tool({ description: @@ -21,14 +18,9 @@ export default tool({ timeout: tool.schema .number() .optional() - .describe("超时秒数,默认 60。大结果集建议设 120。"), + .describe("超时秒数,默认 120。大结果集建议设 300+。"), }, 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/tjwater-cli-call`, { method: "POST", headers: { @@ -36,7 +28,7 @@ export default tool({ "x-agent-internal-token": internalToken, }, body: JSON.stringify({ - sessionScopeKey: sessionContext.sessionScopeKey, + session_id: context.sessionID, reason: args.reason, command: args.command, timeout: args.timeout, diff --git a/src/chat/sessionBridge.ts b/src/chat/sessionBridge.ts index 855789b..7e660d2 100644 --- a/src/chat/sessionBridge.ts +++ b/src/chat/sessionBridge.ts @@ -2,10 +2,7 @@ import { randomUUID } from "node:crypto"; import { logger } from "../logger.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; -import { - buildToolSessionScopeKey, - ToolSessionContextStore, -} from "../session/toolContextStore.js"; +import { ToolSessionContextStore } from "../session/toolContextStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; export type SessionBinding = { @@ -28,9 +25,6 @@ export type ChatRequestContext = SessionContext & { }; export class ChatSessionBridge { - // runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。 - private readonly activeRuntimeSessions = new Map(); - private readonly activeSensitiveContexts = new Map(); private readonly abortControllers = new Map(); private readonly toolContextStore = new ToolSessionContextStore(); @@ -38,6 +32,7 @@ export class ChatSessionBridge { async resolve(context: { clientSessionId?: string; + sessionId?: string; accessToken?: string; projectId?: string; traceId?: string; @@ -48,70 +43,63 @@ export class ChatSessionBridge { created: boolean; }> { const requestContext = this.buildRequestContext(context); - await this.abortActiveRuntime(requestContext.clientSessionId); + const existingSessionId = context.sessionId?.trim(); + await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId); - const session = await this.runtime.createSession(requestContext.clientSessionId); + let sessionId = existingSessionId; + let created = false; + if (!sessionId) { + const session = await this.runtime.createSession(requestContext.clientSessionId); + sessionId = session.id; + created = true; + } const binding: SessionBinding = { clientSessionId: requestContext.clientSessionId, - sessionId: session.id, + sessionId, 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({ + accessToken: requestContext.accessToken, actorKey: requestContext.actorKey, allowLearningWrite: true, clientSessionId: requestContext.clientSessionId, learningMode: "interactive", projectId: requestContext.projectId, projectKey: requestContext.projectKey, - sessionId: session.id, - sessionScopeKey, + sessionId, traceId: requestContext.traceId, }); - return { binding, requestContext, created: true }; + return { binding, requestContext, created }; } count(): number { - return this.activeRuntimeSessions.size; + return this.abortControllers.size; } createClientSessionId() { return `agent-${randomUUID().slice(0, 12)}`; } - getActiveSensitiveContext(sessionScopeKey: string) { - return this.activeSensitiveContexts.get(sessionScopeKey) ?? null; - } - registerAbortController(clientSessionId: string, controller: AbortController) { this.abortControllers.set(clientSessionId, controller); } - deleteAbortController(clientSessionId: string) { + finalizeRequest(clientSessionId: string) { this.abortControllers.delete(clientSessionId); } async abort(context: { clientSessionId?: string; + sessionId?: string; }): Promise { const clientSessionId = context.clientSessionId?.trim(); - if (!clientSessionId) { + const sessionId = context.sessionId?.trim(); + if (!clientSessionId || !sessionId) { return null; } - const sessionId = this.activeRuntimeSessions.get(clientSessionId); - if (!sessionId) { - return null; - } - - await this.abortActiveRuntime(clientSessionId); + await this.abortActiveRuntime(clientSessionId, sessionId); return { clientSessionId, sessionId, @@ -119,18 +107,32 @@ export class ChatSessionBridge { }; } - async releaseRuntimeSession(clientSessionId: string, sessionId: string) { - const activeSessionId = this.activeRuntimeSessions.get(clientSessionId); - if (activeSessionId === sessionId) { - this.activeRuntimeSessions.delete(clientSessionId); + async deleteConversationSession(context: { + clientSessionId: string; + sessionId: string; + }) { + const clientSessionId = context.clientSessionId.trim(); + const sessionId = context.sessionId.trim(); + const controller = this.abortControllers.get(clientSessionId); + if (controller) { + this.abortControllers.delete(clientSessionId); + controller.abort(); } - this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId)); + await this.runtime.abortSession(sessionId).catch((error) => { + logger.warn( + { clientSessionId, sessionId, err: error }, + "failed to abort conversation runtime session", + ); + }); + await this.runtime.waitForSessionIdle(sessionId).catch((error) => { + logger.warn( + { clientSessionId, sessionId, err: error }, + "failed while waiting for conversation runtime session to become idle", + ); + }); 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: { @@ -151,44 +153,27 @@ export class ChatSessionBridge { }; } - private async abortActiveRuntime(clientSessionId: string) { - const activeSessionId = this.activeRuntimeSessions.get(clientSessionId); - this.activeRuntimeSessions.delete(clientSessionId); - this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId)); - + private async abortActiveRuntime(clientSessionId: string, sessionId?: string) { const controller = this.abortControllers.get(clientSessionId); if (controller) { this.abortControllers.delete(clientSessionId); controller.abort(); } - if (!activeSessionId) { + if (!sessionId) { return; } - await this.toolContextStore.remove(activeSessionId).catch(() => undefined); - await this.runtime.abortSession(activeSessionId).catch((error) => { + await this.runtime.abortSession(sessionId).catch((error) => { logger.warn( - { clientSessionId, sessionId: activeSessionId, err: error }, - "failed to abort previous active runtime session", + { clientSessionId, sessionId, err: error }, + "failed to abort active runtime session", ); }); - await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => { + await this.runtime.waitForSessionIdle(sessionId).catch((error) => { logger.warn( - { clientSessionId, sessionId: activeSessionId, err: error }, - "failed while waiting for previous runtime session to become idle", + { clientSessionId, sessionId, err: error }, + "failed while waiting for active runtime session to become idle", ); }); } } - -const findScopeKey = ( - contexts: Map, - clientSessionId: string, -) => { - for (const [scopeKey, context] of contexts.entries()) { - if (context.clientSessionId === clientSessionId) { - return scopeKey; - } - } - return clientSessionId; -}; diff --git a/src/conversations/stateStore.ts b/src/conversations/stateStore.ts index fddaf83..03563db 100644 --- a/src/conversations/stateStore.ts +++ b/src/conversations/stateStore.ts @@ -6,6 +6,7 @@ import { ensureDirectory, readJsonFile, removeFileIfExists, + toConversationScopeKey, } from "../utils/fileStore.js"; export type ConversationStateRecord = { @@ -15,6 +16,12 @@ export type ConversationStateRecord = { branchGroups: unknown[]; }; +type ConversationStateContext = { + actorKey: string; + projectKey: string; + sessionId: string; +}; + export class ConversationStateStore { constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {} @@ -22,20 +29,27 @@ export class ConversationStateStore { await ensureDirectory(this.baseDir); } - async read(sessionScopeKey: string) { - return await readJsonFile(this.filePath(sessionScopeKey)); + async read(context: ConversationStateContext) { + return await readJsonFile(this.filePath(context)); } - async write(sessionScopeKey: string, state: ConversationStateRecord) { - await atomicWriteJson(this.filePath(sessionScopeKey), state); + async write(context: ConversationStateContext, state: ConversationStateRecord) { + await atomicWriteJson(this.filePath(context), state); return state; } - async remove(sessionScopeKey: string) { - await removeFileIfExists(this.filePath(sessionScopeKey)); + async remove(context: ConversationStateContext) { + await removeFileIfExists(this.filePath(context)); } - private filePath(sessionScopeKey: string) { - return join(this.baseDir, `${sessionScopeKey}.json`); + private filePath(context: ConversationStateContext) { + return join( + this.baseDir, + `${toConversationScopeKey( + context.actorKey, + context.projectKey, + context.sessionId, + )}.json`, + ); } } diff --git a/src/conversations/store.ts b/src/conversations/store.ts index 2e5f3c8..699b55a 100644 --- a/src/conversations/store.ts +++ b/src/conversations/store.ts @@ -15,11 +15,11 @@ export type ConversationStatus = "active" | "archived"; export type ConversationRecord = { sessionId: string; - sessionScopeKey: string; actorKey: string; ownerUserId?: string; projectId?: string; projectKey: string; + opencodeSessionId?: string; parentSessionId?: string; createdAt: string; updatedAt: string; @@ -48,12 +48,9 @@ export class ConversationStore { async ensure(input: EnsureConversationInput) { const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId(); - const sessionScopeKey = toConversationScopeKey( - input.actorKey, - input.projectKey, - sessionId, + const existing = await readJsonFile( + this.filePath(input.actorKey, input.projectKey, sessionId), ); - const existing = await readJsonFile(this.filePath(sessionScopeKey)); if (existing) { return { created: false, record: existing }; } @@ -61,7 +58,6 @@ export class ConversationStore { const now = new Date().toISOString(); const record: ConversationRecord = { sessionId, - sessionScopeKey, actorKey: input.actorKey, ownerUserId: input.userId?.trim(), projectId: input.projectId, @@ -71,7 +67,10 @@ export class ConversationStore { updatedAt: now, status: "active", }; - await atomicWriteJson(this.filePath(sessionScopeKey), record); + await atomicWriteJson( + this.filePath(record.actorKey, record.projectKey, record.sessionId), + record, + ); return { created: true, record }; } @@ -81,22 +80,23 @@ export class ConversationStore { return null; } return await readJsonFile( - this.filePath( - toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId), - ), + this.filePath(context.actorKey, context.projectKey, normalizedSessionId), ); } async touch( record: ConversationRecord, - updates: Partial> = {}, + updates: Partial> = {}, ) { const next: ConversationRecord = { ...record, ...normalizeConversationUpdates(updates), updatedAt: new Date().toISOString(), }; - await atomicWriteJson(this.filePath(record.sessionScopeKey), next); + await atomicWriteJson( + this.filePath(record.actorKey, record.projectKey, record.sessionId), + next, + ); return next; } @@ -116,11 +116,16 @@ export class ConversationStore { } async remove(record: ConversationRecord) { - await removeFileIfExists(this.filePath(record.sessionScopeKey)); + await removeFileIfExists( + this.filePath(record.actorKey, record.projectKey, record.sessionId), + ); } - private filePath(sessionScopeKey: string) { - return join(this.baseDir, `${sessionScopeKey}.json`); + private filePath(actorKey: string, projectKey: string, sessionId: string) { + return join( + this.baseDir, + `${toConversationScopeKey(actorKey, projectKey, sessionId)}.json`, + ); } } @@ -132,9 +137,11 @@ const normalizeSessionId = (value?: string) => { }; const normalizeConversationUpdates = ( - updates: Partial>, + updates: Partial>, ) => { - const normalized: Partial> = {}; + const normalized: Partial< + Pick + > = {}; if (updates.status === "active" || updates.status === "archived") { normalized.status = updates.status; } @@ -144,5 +151,11 @@ const normalizeConversationUpdates = ( normalized.title = trimmed.slice(0, 120); } } + if (typeof updates.opencodeSessionId === "string") { + const trimmed = updates.opencodeSessionId.trim(); + if (trimmed) { + normalized.opencodeSessionId = trimmed.slice(0, 256); + } + } return normalized; }; diff --git a/src/learning/orchestrator.ts b/src/learning/orchestrator.ts index 7fae655..f955b4a 100644 --- a/src/learning/orchestrator.ts +++ b/src/learning/orchestrator.ts @@ -9,10 +9,7 @@ import { LearningStateStore } from "./stateStore.js"; import { MemoryStore, type MemoryScope } from "../memory/store.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { SkillStore } from "../skills/store.js"; -import { - buildToolSessionScopeKey, - ToolSessionContextStore, -} from "../session/toolContextStore.js"; +import { ToolSessionContextStore } from "../session/toolContextStore.js"; import { sanitizePersistentDocument, sanitizePersistentLine, @@ -153,11 +150,6 @@ export class LearningOrchestrator { projectId: input.requestContext.projectId, projectKey: input.requestContext.projectKey, sessionId: gateSession.id, - sessionScopeKey: buildToolSessionScopeKey( - input.requestContext.actorKey, - input.requestContext.projectKey, - input.requestContext.clientSessionId, - ), traceId: input.requestContext.traceId, }); await this.runtime.prompt( @@ -247,11 +239,6 @@ export class LearningOrchestrator { projectId: input.requestContext.projectId, projectKey: input.requestContext.projectKey, sessionId: reviewSession.id, - sessionScopeKey: buildToolSessionScopeKey( - input.requestContext.actorKey, - input.requestContext.projectKey, - input.requestContext.clientSessionId, - ), traceId: input.requestContext.traceId, }); try { diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 9bd18ae..85efc60 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -11,6 +11,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; +import { type ConversationRecord } from "../conversations/store.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { buildPromptWithLearningContext, @@ -51,6 +52,12 @@ const conversationStateSchema = z.object({ branch_groups: z.array(z.unknown()).default([]), }); +const toConversationStateContext = (conversation: ConversationRecord) => ({ + actorKey: conversation.actorKey, + projectKey: conversation.projectKey, + sessionId: conversation.sessionId, +}); + export const buildChatRouter = ( sessionBridge: ChatSessionBridge, runtime: OpencodeRuntimeAdapter, @@ -145,7 +152,9 @@ export const buildChatRouter = ( return; } - const state = await conversationStateStore.read(conversation.sessionScopeKey); + const state = await conversationStateStore.read( + toConversationStateContext(conversation), + ); res.json({ id: conversation.sessionId, title: conversation.title ?? "新对话", @@ -190,7 +199,7 @@ export const buildChatRouter = ( const nextRecord = await conversationStore.touch(record, { ...(parsed.data.title ? { title: parsed.data.title } : {}), }); - await conversationStateStore.write(nextRecord.sessionScopeKey, { + await conversationStateStore.write(toConversationStateContext(nextRecord), { sessionId: nextRecord.sessionId, isTitleManuallyEdited: parsed.data.is_title_manually_edited, messages: parsed.data.messages, @@ -231,13 +240,18 @@ export const buildChatRouter = ( return; } const nextConversation = await conversationStore.touch(conversation, { title }); - const state = await conversationStateStore.read(nextConversation.sessionScopeKey); + const state = await conversationStateStore.read( + toConversationStateContext(nextConversation), + ); if (state) { - await conversationStateStore.write(nextConversation.sessionScopeKey, { + await conversationStateStore.write( + toConversationStateContext(nextConversation), + { ...state, isTitleManuallyEdited: isTitleManuallyEdited ?? state.isTitleManuallyEdited, - }); + }, + ); } res.json({ id: nextConversation.sessionId, @@ -264,7 +278,13 @@ export const buildChatRouter = ( res.status(204).end(); return; } - await conversationStateStore.remove(conversation.sessionScopeKey); + await conversationStateStore.remove(toConversationStateContext(conversation)); + if (conversation.opencodeSessionId) { + await sessionBridge.deleteConversationSession({ + clientSessionId: conversation.sessionId, + sessionId: conversation.opencodeSessionId, + }); + } await conversationStore.remove(conversation); res.status(204).end(); }); @@ -323,9 +343,20 @@ export const buildChatRouter = ( } try { - const binding = await sessionBridge.abort({ - clientSessionId: parsed.data.session_id, - }); + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + const conversation = await conversationStore.get( + { actorKey, projectId, projectKey, userId }, + parsed.data.session_id, + ); + const binding = conversation?.opencodeSessionId + ? await sessionBridge.abort({ + clientSessionId: conversation.sessionId, + sessionId: conversation.opencodeSessionId, + }) + : null; if (!binding) { res.status(204).end(); @@ -467,14 +498,22 @@ export const buildChatRouter = ( userId, }); const activeConversation = await conversationStore.touch(conversation); + const hadExistingRuntimeSession = Boolean(activeConversation.opencodeSessionId); const { binding, requestContext, created } = await sessionBridge.resolve({ clientSessionId: activeConversation.sessionId, + sessionId: activeConversation.opencodeSessionId, accessToken, projectId, traceId, userId, }); + const conversationWithRuntime = + created && binding.sessionId !== activeConversation.opencodeSessionId + ? await conversationStore.touch(activeConversation, { + opencodeSessionId: binding.sessionId, + }) + : activeConversation; const historyContext = { actorKey: requestContext.actorKey, clientSessionId: requestContext.clientSessionId, @@ -482,6 +521,9 @@ export const buildChatRouter = ( sessionId: requestContext.clientSessionId, }; const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8); + const initialConversationState = await conversationStateStore.read( + toConversationStateContext(conversationWithRuntime), + ); logger.info( { @@ -521,8 +563,12 @@ export const buildChatRouter = ( memoryStore, requestContext.actorKey, requestContext.projectKey, - recentTurns, - parsed.data.message, + { + recentTurns, + persistedMessages: initialConversationState?.messages, + message: parsed.data.message, + restoreConversation: !hadExistingRuntimeSession, + }, ); const streamResult = await streamPromptResponse({ runtime, @@ -550,10 +596,10 @@ export const buildChatRouter = ( const latestConversation = (await conversationStore.get( { actorKey, projectId, projectKey, userId }, - activeConversation.sessionId, - )) ?? activeConversation; + conversationWithRuntime.sessionId, + )) ?? conversationWithRuntime; const latestConversationState = await conversationStateStore.read( - latestConversation.sessionScopeKey, + toConversationStateContext(latestConversation), ); const existingSessionTitle = latestConversation.title; let sessionTitle = existingSessionTitle; @@ -606,8 +652,7 @@ export const buildChatRouter = ( } } } finally { - await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId); - sessionBridge.deleteAbortController(clientSessionId); + sessionBridge.finalizeRequest(clientSessionId); streamClosed = true; req.off("close", handleClientClose); res.off("close", handleClientClose); diff --git a/src/routes/chatSession.ts b/src/routes/chatSession.ts index 8a305a6..92073b0 100644 --- a/src/routes/chatSession.ts +++ b/src/routes/chatSession.ts @@ -192,15 +192,22 @@ export const buildPromptWithLearningContext = async ( memoryStore: MemoryStore, actorKey: string, projectKey: string, - recentTurns: SessionTurnRecord[], - message: string, + options: { + recentTurns: SessionTurnRecord[]; + persistedMessages?: unknown[]; + message: string; + restoreConversation?: boolean; + }, ) => { const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey }); - const restoredConversation = buildRestoredConversationContext(recentTurns); + const restoredConversation = options.restoreConversation === false + ? "" + : buildRestoredConversationFromMessages(options.persistedMessages) || + buildRestoredConversationContext(options.recentTurns); if (!snapshot && !restoredConversation) { - return message; + return options.message; } - return [snapshot, restoredConversation, `[Current user request]\n${message}`] + return [snapshot, restoredConversation, `[Current user request]\n${options.message}`] .filter(Boolean) .join("\n\n"); }; @@ -239,4 +246,57 @@ const compactMessage = (value: string) => { return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT ? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...` : normalized; +}; + +const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isSyntheticAssistantError = (content: string) => + /^⚠️\s*\*\*(请求已中断|错误[::]?)/.test(content); + +const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => { + if (!Array.isArray(messages) || messages.length === 0) { + return ""; + } + + const formattedMessages = messages + .slice(-(RESTORE_TURN_LIMIT * 2 + 2)) + .flatMap((message) => { + if (!isObjectRecord(message)) { + return []; + } + + const role = message.role; + const content = message.content; + if ((role !== "user" && role !== "assistant") || typeof content !== "string") { + return []; + } + + const normalizedContent = compactMessage(content); + if (!normalizedContent) { + return []; + } + + if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) { + return []; + } + + return [`${role === "user" ? "用户" : "助手"}:${normalizedContent}`]; + }); + + if (formattedMessages.length === 0) { + return ""; + } + + const conversation = formattedMessages.join("\n"); + const trimmedConversation = + conversation.length > RESTORE_CONTEXT_CHAR_LIMIT + ? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...` + : conversation; + + return [ + "[Previous conversation context]", + "以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。", + trimmedConversation, + ].join("\n"); }; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 0e4504e..955064a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -66,22 +66,13 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => { return; } - const sessionScopeKey = - typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; - const threadContext = await toolContextStore.read(sessionScopeKey); - const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey); - if (!threadContext && !runtimeContext) { - res.status(404).json({ - message: "runtime or session context not found", - detail: sessionScopeKey, - }); - return; - } - const context = runtimeContext ?? threadContext; + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const context = sessionId ? await toolContextStore.read(sessionId) : null; if (!context) { res.status(404).json({ - message: "runtime or session context not found", - detail: sessionScopeKey, + message: "session context not found", + detail: sessionId, }); return; } @@ -96,7 +87,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => { arguments: req.body?.arguments, }, { - accessToken: runtimeContext?.accessToken, + accessToken: context.accessToken, actorKey: context.actorKey, clientSessionId: context.clientSessionId, projectId: context.projectId, @@ -121,22 +112,13 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { return; } - const sessionScopeKey = - typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; - const threadContext = await toolContextStore.read(sessionScopeKey); - const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey); - if (!threadContext && !runtimeContext) { - res.status(404).json({ - message: "runtime or session context not found", - detail: sessionScopeKey, - }); - return; - } - const context = runtimeContext ?? threadContext; + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const context = sessionId ? await toolContextStore.read(sessionId) : null; if (!context) { res.status(404).json({ - message: "runtime or session context not found", - detail: sessionScopeKey, + message: "session context not found", + detail: sessionId, }); return; } @@ -148,11 +130,11 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { } const timeoutSec = - typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 60; + typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120; const authJson = JSON.stringify({ server: config.TJWATER_API_BASE_URL, - access_token: runtimeContext?.accessToken, + access_token: context.accessToken, project_id: context.projectId, network:"tjwater", }); @@ -233,14 +215,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => { return; } - const sessionScopeKey = - typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : ""; - const context = await toolContextStore.read(sessionScopeKey); + const context = sessionId ? await toolContextStore.read(sessionId) : null; if (!context) { res.status(404).json({ message: "session context not found", - detail: sessionScopeKey, + detail: sessionId, }); return; } @@ -276,14 +258,14 @@ app.post("/internal/tools/store-render-ref", async (req, res) => { return; } - const sessionScopeKey = - typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : ""; - const context = await toolContextStore.read(sessionScopeKey); + const context = sessionId ? await toolContextStore.read(sessionId) : null; if (!context) { res.status(404).json({ message: "session context not found", - detail: sessionScopeKey, + detail: sessionId, }); return; } @@ -326,14 +308,14 @@ app.post("/internal/tools/session-search", async (req, res) => { return; } - const sessionScopeKey = - typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; const query = typeof req.body?.query === "string" ? req.body.query : ""; - const context = await toolContextStore.read(sessionScopeKey); + const context = sessionId ? await toolContextStore.read(sessionId) : null; if (!context) { res.status(404).json({ message: "session context not found", - detail: sessionScopeKey, + detail: sessionId, }); return; } diff --git a/src/session/toolContextStore.ts b/src/session/toolContextStore.ts index bc8fa5f..0b03ec7 100644 --- a/src/session/toolContextStore.ts +++ b/src/session/toolContextStore.ts @@ -7,9 +7,9 @@ import { readJsonFile, removeFileIfExists, } from "../utils/fileStore.js"; -import { toConversationScopeKey } from "../utils/fileStore.js"; export type ToolSessionContext = { + accessToken?: string; actorKey: string; allowLearningWrite?: boolean; clientSessionId: string; @@ -17,7 +17,6 @@ export type ToolSessionContext = { projectId?: string; projectKey: string; sessionId: string; - sessionScopeKey: string; traceId: string; }; @@ -30,9 +29,6 @@ export class ToolSessionContextStore { async write(context: ToolSessionContext) { await atomicWriteJson(this.filePath(context.sessionId), context); - if (context.learningMode === "interactive" && context.sessionScopeKey) { - await atomicWriteJson(this.filePath(context.sessionScopeKey), context); - } } async read(sessionId: string) { @@ -47,9 +43,3 @@ export class ToolSessionContextStore { return join(this.baseDir, `${sessionId}.json`); } } - -export const buildToolSessionScopeKey = ( - actorKey: string, - projectKey: string, - clientSessionId: string, -) => toConversationScopeKey(actorKey, projectKey, clientSessionId); diff --git a/tests/conversations/store.test.ts b/tests/conversations/store.test.ts index f7dc550..61e65ee 100644 --- a/tests/conversations/store.test.ts +++ b/tests/conversations/store.test.ts @@ -44,9 +44,11 @@ describe("ConversationStore", () => { const touched = await store.touch(record, { title: "新标题", + opencodeSessionId: "opencode-session-1", }); expect(touched.title).toBe("新标题"); + expect(touched.opencodeSessionId).toBe("opencode-session-1"); expect(touched.updatedAt >= record.updatedAt).toBe(true); const fetched = await store.get( @@ -58,7 +60,7 @@ describe("ConversationStore", () => { }, "existing-session", ); - expect(fetched?.sessionScopeKey).toBe(record.sessionScopeKey); expect(fetched?.title).toBe("新标题"); + expect(fetched?.opencodeSessionId).toBe("opencode-session-1"); }); }); diff --git a/tests/routes/chatSession.test.ts b/tests/routes/chatSession.test.ts index 19e9aa1..ef90f85 100644 --- a/tests/routes/chatSession.test.ts +++ b/tests/routes/chatSession.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "bun:test"; import { + buildPromptWithLearningContext, generateSessionTitle, shouldGenerateSessionTitle, } from "../../src/routes/chatSession.js"; +import { type SessionTurnRecord } from "../../src/history/store.js"; +import { type MemoryStore } from "../../src/memory/store.js"; import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; describe("shouldGenerateSessionTitle", () => { @@ -71,3 +74,90 @@ describe("generateSessionTitle", () => { expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。"); }); }); + +describe("buildPromptWithLearningContext", () => { + const memoryStore = { + buildPromptSnapshot: async () => "", + } as unknown as MemoryStore; + + it("prefers persisted frontend messages so aborted turns remain in restored context", async () => { + const prompt = await buildPromptWithLearningContext( + memoryStore, + "actor-1", + "project-1", + { + recentTurns: [], + persistedMessages: [ + { role: "user", content: "先分析 3 号泵站夜间压力波动" }, + { + role: "assistant", + content: "已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。", + isError: true, + }, + { role: "assistant", content: "⚠️ **请求已中断**", isError: true }, + ], + message: "继续刚才的分析,并补充相邻支路影响", + }, + ); + + expect(prompt).toContain("用户:先分析 3 号泵站夜间压力波动"); + expect(prompt).toContain( + "助手:已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。", + ); + expect(prompt).not.toContain("⚠️ **请求已中断**"); + expect(prompt).toContain("[Current user request]\n继续刚才的分析,并补充相邻支路影响"); + }); + + it("falls back to history turns when frontend state is unavailable", async () => { + const recentTurns: SessionTurnRecord[] = [ + { + id: "turn-1", + userMessage: "检查 DMA-2 夜间漏损异常", + assistantMessage: "DMA-2 在 02:00-04:00 出现持续最小夜流抬升。", + timestamp: new Date().toISOString(), + toolCallCount: 1, + }, + ]; + + const prompt = await buildPromptWithLearningContext( + memoryStore, + "actor-1", + "project-1", + { + recentTurns, + message: "继续给出排查建议", + }, + ); + + expect(prompt).toContain("用户:检查 DMA-2 夜间漏损异常"); + expect(prompt).toContain("助手:DMA-2 在 02:00-04:00 出现持续最小夜流抬升。"); + }); + + it("skips restored conversation injection when reusing an existing opencode session", async () => { + const prompt = await buildPromptWithLearningContext( + memoryStore, + "actor-1", + "project-1", + { + recentTurns: [ + { + id: "turn-1", + userMessage: "上一轮问题", + assistantMessage: "上一轮回答", + timestamp: new Date().toISOString(), + toolCallCount: 0, + }, + ], + persistedMessages: [ + { role: "user", content: "旧问题" }, + { role: "assistant", content: "旧回答" }, + ], + message: "基于刚才结果继续分析", + restoreConversation: false, + }, + ); + + expect(prompt).not.toContain("[Previous conversation context]"); + expect(prompt).toBe("基于刚才结果继续分析"); + }); +}); diff --git a/tests/session/toolContextStore.test.ts b/tests/session/toolContextStore.test.ts index 7ff6b3d..fde145d 100644 --- a/tests/session/toolContextStore.test.ts +++ b/tests/session/toolContextStore.test.ts @@ -3,10 +3,7 @@ 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"; +import { ToolSessionContextStore } from "../../src/session/toolContextStore.js"; describe("ToolSessionContextStore", () => { let tempDir: string; @@ -22,14 +19,9 @@ describe("ToolSessionContextStore", () => { 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", - ); - + it("writes and reads runtime session context by opencode session id", async () => { await store.write({ + accessToken: "token-1", actorKey: "actor-1", allowLearningWrite: true, clientSessionId: "chat-session-1", @@ -37,15 +29,13 @@ describe("ToolSessionContextStore", () => { 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?.accessToken).toBe("token-1"); expect(runtimeContext?.clientSessionId).toBe("chat-session-1"); - expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey); - expect(scopedContext?.sessionId).toBe("runtime-session-1"); + expect(runtimeContext?.sessionId).toBe("runtime-session-1"); }); });