移除对 copilot 的兼容。更新示例和文档,统一使用 session_id 代替 conversationId

This commit is contained in:
2026-04-29 15:31:39 +08:00
parent 127aca466f
commit 6f15b5d7e3
11 changed files with 66 additions and 65 deletions
+13 -13
View File
@@ -1,15 +1,15 @@
# 示例(基于 chat/stream 工具调用链) # 示例(基于 opencode Agent chat/stream 工具调用链)
## 示例 1:前端发起对话,LLM 触发工具调用 ## 示例 1:前端发起对话,opencode agent 触发工具调用
用户意图:查询设备 `170490` 在时间范围内的 `monitored_value` 用户意图:查询设备 `170490` 在时间范围内的 `monitored_value`
前端调用 `POST /api/v1/copilot/chat/stream` 前端调用 `POST /api/v1/agent/chat/stream`
```json ```json
{ {
"message": "请查询设备170490在最近24小时的monitored_value历史数据", "message": "请查询设备170490在最近24小时的monitored_value历史数据",
"conversationId": "conv-demo-001" "session_id": "agent-demo-001"
} }
``` ```
@@ -18,7 +18,7 @@
- `x-project-id: <project-id>` - `x-project-id: <project-id>`
服务端内部行为: 服务端内部行为:
- LLM 选择工具 `dynamic_http_call` - opencode agent 选择工具 `dynamic_http_call`
- 工具参数示例: - 工具参数示例:
```json ```json
{ {
@@ -33,7 +33,7 @@
} }
``` ```
## 示例 2LLM 多步规划 + 多次工具调用 ## 示例 2opencode agent 多步规划 + 多次工具调用
用户消息: 用户消息:
- “先查这个设备历史数据,再给我异常点摘要。” - “先查这个设备历史数据,再给我异常点摘要。”
@@ -41,14 +41,14 @@
典型链路: 典型链路:
- 第一步工具调用:查询历史数据接口。 - 第一步工具调用:查询历史数据接口。
- 第二步(可选)工具调用:查询补充数据接口。 - 第二步(可选)工具调用:查询补充数据接口。
- LLM 汇总工具结果,持续通过 SSE 输出 token,最终返回 `done` - opencode agent 汇总工具结果,持续通过 SSE 输出 token,最终返回 `done`
## 示例 3:前端工具 — 定位要素 ## 示例 3:前端工具 — 定位要素
用户消息: 用户消息:
- "帮我找到管道 P-001 和 P-002" - "帮我找到管道 P-001 和 P-002"
LLM 调用工具 `locate_features` opencode agent 调用工具 `locate_features`
```json ```json
{ {
"ids": ["P-001", "P-002"], "ids": ["P-001", "P-002"],
@@ -56,7 +56,7 @@ LLM 调用工具 `locate_features`
} }
``` ```
前端收到 SSE 事件后缩放地图并高亮管道。LLM 回复文字:"已在地图上定位到管道 P-001 和 P-002。" 前端收到 SSE 事件后缩放地图并高亮管道。opencode agent 回复文字:"已在地图上定位到管道 P-001 和 P-002。"
## 示例 4:前端工具 — 对话内图表 ## 示例 4:前端工具 — 对话内图表
@@ -64,10 +64,10 @@ LLM 调用工具 `locate_features`
- "展示节点 J-001 最近一天的压力变化曲线" - "展示节点 J-001 最近一天的压力变化曲线"
典型链路: 典型链路:
1. LLM 先调用 `dynamic_http_call` 查询数据 1. opencode agent 先调用 `dynamic_http_call` 查询数据
2. 拿到数据后,调用 `show_chart` 将处理好的数据传给前端渲染 2. 拿到数据后,调用 `show_chart` 将处理好的数据传给前端渲染
第一步 — LLM 调用 `dynamic_http_call` 第一步 — opencode agent 调用 `dynamic_http_call`
```json ```json
{ {
"path": "/api/v1/composite/element-simulation", "path": "/api/v1/composite/element-simulation",
@@ -80,7 +80,7 @@ LLM 调用工具 `locate_features`
} }
``` ```
第二步 — LLM 处理数据后调用 `show_chart` 第二步 — opencode agent 处理数据后调用 `show_chart`
```json ```json
{ {
"title": "节点 J-001 压力变化", "title": "节点 J-001 压力变化",
@@ -103,7 +103,7 @@ LLM 调用工具 `locate_features`
用户消息: 用户消息:
- "我想看看 J-001 的监测数据" - "我想看看 J-001 的监测数据"
LLM 调用工具 `view_scada` opencode agent 调用工具 `view_scada`
```json ```json
{ {
"device_ids": ["J-001"], "device_ids": ["J-001"],
+11 -11
View File
@@ -3,17 +3,17 @@
## 1) 总体原则 ## 1) 总体原则
- Skills 负责“告诉模型可做什么”。 - Skills 负责“告诉模型可做什么”。
- `chat/stream` 内部启动 LLM 会话,并注册工具 `dynamic_http_call` - `chat/stream` 内部启动 opencode 会话,并注册工具 `dynamic_http_call`
- LLM 通过工具调用后端能力,不直接发 HTTP。 - opencode agent 通过工具调用后端能力,不直接发 HTTP。
- Sidecar 执行器负责“代表当前用户调真实后端 API”(动态路径,无白名单)。 - TJWaterAgent 执行器负责“代表当前用户调真实后端 API”(动态路径,无白名单)。
## 2) 请求入口(前端) ## 2) 请求入口(前端)
- `POST /api/v1/copilot/chat/stream`(唯一前端入口) - `POST /api/v1/agent/chat/stream`(唯一前端入口)
不提供 `/execute` 对外调用路径,统一通过 `chat/stream` + 工具调用链执行。 不提供 `/execute` 对外调用路径,统一通过 `chat/stream` + 工具调用链执行。
## 3) 工具参数约定(LLM 调用工具时) ## 3) 工具参数约定(opencode agent 调用工具时)
```json ```json
{ {
@@ -53,18 +53,18 @@
前端工具(`locate_features`, `view_history`, `view_scada`, `show_chart`)的调用链与 `dynamic_http_call` 不同: 前端工具(`locate_features`, `view_history`, `view_scada`, `show_chart`)的调用链与 `dynamic_http_call` 不同:
``` ```
用户消息 → LLM → function calling → 调用前端工具 (如 locate_features) 用户消息 → opencode agent → tool calling → 调用前端工具 (如 locate_features)
tool handler: tool handler:
1) 推送 SSE tool_call 事件到前端 1) 推送 SSE tool_call 事件到前端
2) 返回简短确认给 LLM"已定位到管道" 2) 返回简短确认给 opencode agent"已定位到管道"
前端同时收到: 前端同时收到:
- SSE event: tool_call → 前端执行操作(定位地图/打开面板/渲染图表) - SSE event: tool_call → 前端执行操作(定位地图/打开面板/渲染图表)
- SSE event: token → 渲染 LLM 文字回复 - SSE event: token → 渲染 opencode agent 文字回复
``` ```
关键区别: 关键区别:
- `dynamic_http_call`Sidecar 代理 HTTP 请求,结果返回给 LLM 做后续分析。 - `dynamic_http_call`TJWaterAgent 代理 HTTP 请求,结果返回给 opencode agent 做后续分析。
- 前端工具:Sidecar 仅推送 SSE 事件,前端直接执行,结果不返回 LLM - 前端工具:TJWaterAgent 仅推送 SSE 事件,前端直接执行,结果不返回 opencode agent
- `show_chart`LLM 先通过 `dynamic_http_call` 查询数据,处理为 x_data + series 格式后调用 `show_chart`,前端直接渲染图表,不再请求后端。 - `show_chart`opencode agent 先通过 `dynamic_http_call` 查询数据,处理为 x_data + series 格式后调用 `show_chart`,前端直接渲染图表,不再请求后端。
+2 -2
View File
@@ -29,7 +29,7 @@ TJWaterAgent/
1. 启动 HTTP 服务。 1. 启动 HTTP 服务。
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。 2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
3. 管理 `conversationId -> opencode sessionId` 的映射。 3. 管理前端 `session_id -> opencode sessionId` 的映射。
4. 保存并传递用户 `Authorization``x-project-id``x-trace-id` 4. 保存并传递用户 `Authorization``x-project-id``x-trace-id`
5. 把 opencode 输出适配成前端需要的 SSE 事件。 5. 把 opencode 输出适配成前端需要的 SSE 事件。
6.`.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。 6.`.opencode/tools/dynamic_http_call.ts` 提供内部回调接口。
@@ -93,7 +93,7 @@ src/
platform/ platform/
``` ```
这里保存`TJWaterCopilot` 迁移过来的技能树,并保持原有树结构,符合渐进式披露设计。 这里保存 TJWater 技能树,并保持树结构,符合渐进式披露设计。
agent 需要某个领域知识时再按需加载对应 skill,不把整棵技能树作为 always-loaded prompt 一次性注入。 agent 需要某个领域知识时再按需加载对应 skill,不把整棵技能树作为 always-loaded prompt 一次性注入。
+3 -3
View File
@@ -11,7 +11,7 @@ export class ChatSessionBridge {
} }
async resolve(context) { async resolve(context) {
const requestContext = { const requestContext = {
conversationId: context.conversationId?.trim() || `conv-${randomUUID().slice(0, 12)}`, clientSessionId: context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken, accessToken: context.accessToken,
projectId: context.projectId, projectId: context.projectId,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
@@ -27,13 +27,13 @@ export class ChatSessionBridge {
} }
catch (error) { catch (error) {
logger.warn({ logger.warn({
conversationId: requestContext.conversationId, clientSessionId: requestContext.clientSessionId,
sessionId: current.sessionId, sessionId: current.sessionId,
err: error, err: error,
}, "existing opencode session lookup failed, creating a new session"); }, "existing opencode session lookup failed, creating a new session");
} }
} }
const session = await this.runtime.createSession(requestContext.conversationId); const session = await this.runtime.createSession(requestContext.clientSessionId);
const binding = this.registry.upsert(requestContext, session.id); const binding = this.registry.upsert(requestContext, session.id);
this.sessionContexts.set(binding.sessionId, requestContext); this.sessionContexts.set(binding.sessionId, requestContext);
return { binding, requestContext, created: true }; return { binding, requestContext, created: true };
+11 -11
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
const payloadSchema = z.object({ const payloadSchema = z.object({
message: z.string().min(1).max(10000), message: z.string().min(1).max(10000),
conversation_id: z.string().max(128).optional(), session_id: z.string().max(128).optional(),
}); });
export const buildChatRouter = (sessionBridge, runtime) => { export const buildChatRouter = (sessionBridge, runtime) => {
const chatRouter = Router(); const chatRouter = Router();
@@ -24,19 +24,19 @@ export const buildChatRouter = (sessionBridge, runtime) => {
const projectId = req.header("x-project-id") ?? undefined; const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
conversationId: parsed.data.conversation_id, clientSessionId: parsed.data.session_id,
accessToken, accessToken,
projectId, projectId,
traceId, traceId,
}); });
logger.info({ logger.info({
conversationId: requestContext.conversationId, clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId, sessionId: binding.sessionId,
created, created,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
}, "processing chat request"); }, "processing chat request");
// 当前先走“发送 prompt 后回读最近消息”的兼容实现。 // 当前先走“发送 prompt 后回读最近消息”的 opencode SDK 实现。
// 后续切到真正的 opencode 事件流时,只需要替换这里的取数方式。 // 后续切到真正的 opencode 事件流时,只需要替换这里的取数方式。
const messages = await runtime.sendPrompt(binding.sessionId, parsed.data.message); const messages = await runtime.sendPrompt(binding.sessionId, parsed.data.message);
const assistantMessage = messages.find((message) => message.info.role === "assistant"); const assistantMessage = messages.find((message) => message.info.role === "assistant");
@@ -45,38 +45,38 @@ export const buildChatRouter = (sessionBridge, runtime) => {
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive"); res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); res.setHeader("X-Accel-Buffering", "no");
const conversationId = requestContext.conversationId; const clientSessionId = requestContext.clientSessionId;
const parts = assistantMessage?.parts ?? []; const parts = assistantMessage?.parts ?? [];
const textContent = collectTextContent(parts); const textContent = collectTextContent(parts);
if (textContent) { if (textContent) {
res.write(toSse("token", { res.write(toSse("token", {
conversationId, session_id: clientSessionId,
content: textContent, content: textContent,
})); }));
} }
for (const toolCall of collectToolCalls(parts)) { for (const toolCall of collectToolCalls(parts)) {
res.write(toSse("tool_call", { res.write(toSse("tool_call", {
conversationId, session_id: clientSessionId,
tool: toolCall.tool, tool: toolCall.tool,
params: toolCall.params, params: toolCall.params,
})); }));
} }
if (assistantMessage?.info.role === "assistant" && assistantMessage.info.error) { if (assistantMessage?.info.role === "assistant" && assistantMessage.info.error) {
res.write(toSse("error", { res.write(toSse("error", {
conversationId, session_id: clientSessionId,
message: getErrorMessage(assistantMessage.info.error), message: getErrorMessage(assistantMessage.info.error),
detail: assistantMessage.info.error.name, detail: assistantMessage.info.error.name,
})); }));
} }
else if (!assistantMessage) { else if (!assistantMessage) {
res.write(toSse("error", { res.write(toSse("error", {
conversationId, session_id: clientSessionId,
message: "assistant response unavailable", message: "assistant response unavailable",
detail: "no assistant message found after prompt", detail: "no assistant message found after prompt",
})); }));
} }
else { else {
res.write(toSse("done", { conversationId })); res.write(toSse("done", { session_id: clientSessionId }));
} }
res.end(); res.end();
} }
@@ -92,7 +92,7 @@ export const buildChatRouter = (sessionBridge, runtime) => {
return chatRouter; return chatRouter;
}; };
const toSse = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; const toSse = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
// 先把 opencode 的 Part 结构压平成前端当前消费的 SSE 语义。 // 先把 opencode 的 Part 结构压平成 Agent API 的 SSE 语义。
const collectTextContent = (parts) => parts const collectTextContent = (parts) => parts
.filter((part) => part.type === "text") .filter((part) => part.type === "text")
.map((part) => part.text) .map((part) => part.text)
+1 -1
View File
@@ -66,7 +66,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
}); });
} }
}); });
app.use("/api/v1/copilot/chat", buildChatRouter(sessionBridge, opencodeRuntime)); app.use("/api/v1/agent/chat", buildChatRouter(sessionBridge, opencodeRuntime));
const server = app.listen(config.PORT, config.HOST, () => { const server = app.listen(config.PORT, config.HOST, () => {
logger.info({ host: config.HOST, port: config.PORT }, "TJWaterAgent listening"); logger.info({ host: config.HOST, port: config.PORT }, "TJWaterAgent listening");
}); });
+3 -3
View File
@@ -7,7 +7,7 @@ export class SessionRegistry {
} }
upsert(context, sessionId) { upsert(context, sessionId) {
const binding = { const binding = {
conversationId: context.conversationId, clientSessionId: context.clientSessionId,
sessionId, sessionId,
lastUsedAt: Date.now(), lastUsedAt: Date.now(),
}; };
@@ -43,11 +43,11 @@ export class SessionRegistry {
return expired; return expired;
} }
makeKey(context) { makeKey(context) {
// 会话隔离不能只看 conversationId;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。 // 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
const digest = crypto const digest = crypto
.createHash("sha256") .createHash("sha256")
.update([ .update([
context.conversationId, context.clientSessionId,
context.accessToken ?? "", context.accessToken ?? "",
context.projectId ?? "", context.projectId ?? "",
].join("|")) ].join("|"))
+5 -4
View File
@@ -18,7 +18,7 @@ export class ChatSessionBridge {
) {} ) {}
async resolve(context: { async resolve(context: {
conversationId?: string; clientSessionId?: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
@@ -28,7 +28,8 @@ export class ChatSessionBridge {
created: boolean; created: boolean;
}> { }> {
const requestContext: ChatRequestContext = { const requestContext: ChatRequestContext = {
conversationId: context.conversationId?.trim() || `conv-${randomUUID().slice(0, 12)}`, clientSessionId:
context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken, accessToken: context.accessToken,
projectId: context.projectId, projectId: context.projectId,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
@@ -46,7 +47,7 @@ export class ChatSessionBridge {
} catch (error) { } catch (error) {
logger.warn( logger.warn(
{ {
conversationId: requestContext.conversationId, clientSessionId: requestContext.clientSessionId,
sessionId: current.sessionId, sessionId: current.sessionId,
err: error, err: error,
}, },
@@ -55,7 +56,7 @@ export class ChatSessionBridge {
} }
} }
const session = await this.runtime.createSession(requestContext.conversationId); const session = await this.runtime.createSession(requestContext.clientSessionId);
const binding = this.registry.upsert(requestContext, session.id); const binding = this.registry.upsert(requestContext, session.id);
this.sessionContexts.set(binding.sessionId, requestContext); this.sessionContexts.set(binding.sessionId, requestContext);
return { binding, requestContext, created: true }; return { binding, requestContext, created: true };
+11 -11
View File
@@ -8,7 +8,7 @@ import { type ChatSessionBridge } from "../chat/sessionBridge.js";
const payloadSchema = z.object({ const payloadSchema = z.object({
message: z.string().min(1).max(10000), message: z.string().min(1).max(10000),
conversation_id: z.string().max(128).optional(), session_id: z.string().max(128).optional(),
}); });
export const buildChatRouter = ( export const buildChatRouter = (
@@ -36,7 +36,7 @@ export const buildChatRouter = (
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
conversationId: parsed.data.conversation_id, clientSessionId: parsed.data.session_id,
accessToken, accessToken,
projectId, projectId,
traceId, traceId,
@@ -44,7 +44,7 @@ export const buildChatRouter = (
logger.info( logger.info(
{ {
conversationId: requestContext.conversationId, clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId, sessionId: binding.sessionId,
created, created,
traceId: requestContext.traceId, traceId: requestContext.traceId,
@@ -53,7 +53,7 @@ export const buildChatRouter = (
"processing chat request", "processing chat request",
); );
// 当前先走“发送 prompt 后回读最近消息”的兼容实现。 // 当前先走“发送 prompt 后回读最近消息”的 opencode SDK 实现。
// 后续切到真正的 opencode 事件流时,只需要替换这里的取数方式。 // 后续切到真正的 opencode 事件流时,只需要替换这里的取数方式。
const messages = await runtime.sendPrompt(binding.sessionId, parsed.data.message); const messages = await runtime.sendPrompt(binding.sessionId, parsed.data.message);
const assistantMessage = messages.find( const assistantMessage = messages.find(
@@ -66,13 +66,13 @@ export const buildChatRouter = (
res.setHeader("Connection", "keep-alive"); res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); res.setHeader("X-Accel-Buffering", "no");
const conversationId = requestContext.conversationId; const clientSessionId = requestContext.clientSessionId;
const parts = assistantMessage?.parts ?? []; const parts = assistantMessage?.parts ?? [];
const textContent = collectTextContent(parts); const textContent = collectTextContent(parts);
if (textContent) { if (textContent) {
res.write( res.write(
toSse("token", { toSse("token", {
conversationId, session_id: clientSessionId,
content: textContent, content: textContent,
}), }),
); );
@@ -81,7 +81,7 @@ export const buildChatRouter = (
for (const toolCall of collectToolCalls(parts)) { for (const toolCall of collectToolCalls(parts)) {
res.write( res.write(
toSse("tool_call", { toSse("tool_call", {
conversationId, session_id: clientSessionId,
tool: toolCall.tool, tool: toolCall.tool,
params: toolCall.params, params: toolCall.params,
}), }),
@@ -91,7 +91,7 @@ export const buildChatRouter = (
if (assistantMessage?.info.role === "assistant" && assistantMessage.info.error) { if (assistantMessage?.info.role === "assistant" && assistantMessage.info.error) {
res.write( res.write(
toSse("error", { toSse("error", {
conversationId, session_id: clientSessionId,
message: getErrorMessage(assistantMessage.info.error), message: getErrorMessage(assistantMessage.info.error),
detail: assistantMessage.info.error.name, detail: assistantMessage.info.error.name,
}), }),
@@ -99,13 +99,13 @@ export const buildChatRouter = (
} else if (!assistantMessage) { } else if (!assistantMessage) {
res.write( res.write(
toSse("error", { toSse("error", {
conversationId, session_id: clientSessionId,
message: "assistant response unavailable", message: "assistant response unavailable",
detail: "no assistant message found after prompt", detail: "no assistant message found after prompt",
}), }),
); );
} else { } else {
res.write(toSse("done", { conversationId })); res.write(toSse("done", { session_id: clientSessionId }));
} }
res.end(); res.end();
@@ -125,7 +125,7 @@ export const buildChatRouter = (
const toSse = (event: string, data: Record<string, unknown>) => const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
// 先把 opencode 的 Part 结构压平成前端当前消费的 SSE 语义。 // 先把 opencode 的 Part 结构压平成 Agent API 的 SSE 语义。
const collectTextContent = (parts: Part[]) => const collectTextContent = (parts: Part[]) =>
parts parts
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text") .filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
+1 -1
View File
@@ -77,7 +77,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
} }
}); });
app.use("/api/v1/copilot/chat", buildChatRouter(sessionBridge, opencodeRuntime)); app.use("/api/v1/agent/chat", buildChatRouter(sessionBridge, opencodeRuntime));
const server = app.listen(config.PORT, config.HOST, () => { const server = app.listen(config.PORT, config.HOST, () => {
logger.info( logger.info(
+5 -5
View File
@@ -1,13 +1,13 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
export type SessionBinding = { export type SessionBinding = {
conversationId: string; clientSessionId: string;
sessionId: string; sessionId: string;
lastUsedAt: number; lastUsedAt: number;
}; };
export type SessionContext = { export type SessionContext = {
conversationId: string; clientSessionId: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
}; };
@@ -22,7 +22,7 @@ export class SessionRegistry {
upsert(context: SessionContext, sessionId: string): SessionBinding { upsert(context: SessionContext, sessionId: string): SessionBinding {
const binding: SessionBinding = { const binding: SessionBinding = {
conversationId: context.conversationId, clientSessionId: context.clientSessionId,
sessionId, sessionId,
lastUsedAt: Date.now(), lastUsedAt: Date.now(),
}; };
@@ -62,12 +62,12 @@ export class SessionRegistry {
} }
private makeKey(context: SessionContext): string { private makeKey(context: SessionContext): string {
// 会话隔离不能只看 conversationId;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。 // 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
const digest = crypto const digest = crypto
.createHash("sha256") .createHash("sha256")
.update( .update(
[ [
context.conversationId, context.clientSessionId,
context.accessToken ?? "", context.accessToken ?? "",
context.projectId ?? "", context.projectId ?? "",
].join("|"), ].join("|"),