lightclawbot
Advanced tools
| /** | ||
| * LightClaw — Chat(会话元信息)处理器 | ||
| * | ||
| * 收敛 EVENT_CHAT_REQUEST 各子类型的处理逻辑,与 handlers.ts 中的 | ||
| * 消息 / 历史 / Agents 等事件处理保持平级解耦,避免 handlers.ts 膨胀。 | ||
| * | ||
| * 涉及的持久化文件: | ||
| * `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json` | ||
| * └─ 以 ChatMeta[] 形式存储该用户在该 Agent 下的会话元信息列表 | ||
| * (不包含消息正文,正文以 jsonl 形式另外存储) | ||
| * | ||
| * 四个处理器(对应 data.type): | ||
| * - list: 读取 chats.json 返回 { chats };文件不存在 / 解析失败 | ||
| * 时返回空数组,不抛错,避免阻塞前端。 | ||
| * - create: 新建一条会话元数据,chatId = randomUUID(),插入列表最前。 | ||
| * 文件或目录不存在时由 writeChatsFile 递归创建。 | ||
| * - update: 根据 chatId 修改 title(并置 titleLocked=true + 刷新 updatedAt); | ||
| * 文件或条目不存在时回 error,不隐式创建。 | ||
| * - delete: 根据 chatId 从列表剔除,原子写回;文件或条目不存在时回 error。 | ||
| * | ||
| * 全部响应均通过 EVENT_CHAT_RESPONSE + ReliableEmitter 发出(带 ACK + 重试)。 | ||
| * 错误处理统一走 createChatErrorSender 生成的回调,保证日志 / 响应结构一致。 | ||
| */ | ||
| import { randomUUID } from 'node:crypto'; | ||
| import * as fs from 'node:fs'; | ||
| import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../config.js'; | ||
| import { generateMsgId } from '../dedup.js'; | ||
| import { readChatsFile, readChatsFileOrInitDefault, resolveChatsFilePath, writeChatsFile } from '../utils/common.js'; | ||
| /** | ||
| * 生成统一的错误响应发送器。 | ||
| * | ||
| * 背景:list / create / update / delete 四个 handler 的错误处理结构完全一致: | ||
| * 1. 打同一格式日志:`[CHANNEL_KEY] Chat <type> error: <msg>` | ||
| * 2. 通过 EVENT_CHAT_RESPONSE 回同一组基础字段(msgId/from/to/type/agentId/error) | ||
| * 仅差异项: | ||
| * - `type` 不同 | ||
| * - `chatId` 是否需要回显(update/delete 需要) | ||
| * - list 出错时前端期望一个兜底的空列表 `chats: []`,避免 UI 处理 undefined | ||
| * | ||
| * 因此将其封装为模块级工厂,handler 开头调用一次即可,避免重复闭包。 | ||
| * | ||
| * @param ctx 共享上下文(见 ChatErrorSenderContext) | ||
| * @param extra 额外字段(如 `chatId`、兜底 `chats: []`),会与 error 一并合并到响应体 | ||
| * @returns ChatErrorSender — 调用时传入错误信息即可 | ||
| */ | ||
| function createChatErrorSender(ctx, extra = {}) { | ||
| const { responseMsgId, botClientId, userId, agentId, type, reliableEmitter, log } = ctx; | ||
| return (error) => { | ||
| log?.error(`[${CHANNEL_KEY}] Chat ${type} error: ${error}`); | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type, | ||
| agentId, | ||
| timestamp: Date.now(), | ||
| ...extra, | ||
| error, | ||
| }, responseMsgId); | ||
| }; | ||
| } | ||
| /** | ||
| * 处理 EVENT_CHAT_REQUEST(type=list):返回该用户在指定 Agent 下的会话列表。 | ||
| * | ||
| * 行为: | ||
| * 1. 解析当前用户 + Agent 的 chats.json 绝对路径 | ||
| * 2. 调用 readChatsFileOrInitDefault 读取:文件不存在时**主动落盘一条 chatId='' | ||
| * 的默认会话兜底条目**,保证前端拉列表永远拿到至少一项;格式异常时返回 [] | ||
| * 3. 通过 EVENT_CHAT_RESPONSE 回 { chats };若意外抛异常则回 { chats: [], error } | ||
| * | ||
| * 为什么 list 路径要主动兜底? | ||
| * - 用户首次连接拉列表 → 拿到默认对话 → UI 展示一致,不会出现 | ||
| * “先空态、首条消息后又凭空多出一项”的诡异闪烁; | ||
| * - 兜底是幂等的:若 chats.json 已存在则等价于纯读取,无副作用; | ||
| * - 与 ensureSessionInHistory 的被动兜底形成双保险,覆盖“前端跳过 list | ||
| * 直接发消息”的极端场景。 | ||
| * | ||
| * 为什么 create/update/delete/history:request 仍走纯只读 readChatsFile? | ||
| * - 它们要么是用户主动操作(隐式创建语义已在 create 中处理), | ||
| * 要么是只读查询(不应有写盘副作用),由 list 路径统一负责兜底即可。 | ||
| */ | ||
| export function handleChatList(params) { | ||
| const { userId, agentId, botClientId, reliableEmitter, log } = params; | ||
| // 当前用户在当前 Agent 下的会话记录保存路径,即 chats.json 的绝对路径 | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| // 响应 msgId:同时做 ReliableEmitter 的 ack key,成功 / 失败分支共用同一枚 | ||
| const responseMsgId = generateMsgId(); | ||
| // list 出错时,用一个空数组兑底,避免 UI 处理 undefined | ||
| const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'list', reliableEmitter, log }, { chats: [] }); | ||
| try { | ||
| // 主动兜底读取:文件不存在则落盘一条 chatId='' 的默认会话;存在则等价于纯只读 | ||
| const chats = readChatsFileOrInitDefault(chatsPath); | ||
| log?.info(`[${CHANNEL_KEY}] Chat list: userId=${userId} agentId=${agentId} count=${chats.length}`); | ||
| // 正常分支:统一经 ReliableEmitter 发送,依赖其 ACK + 重试保障送达 | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'list', | ||
| agentId, | ||
| chats, | ||
| timestamp: Date.now(), | ||
| }, responseMsgId); | ||
| } | ||
| catch (err) { | ||
| // 兼容 Error / 非 Error 投掷(如字符串 throw),统一拼接为可读消息 | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 处理 EVENT_CHAT_REQUEST(type=create):新建一条会话元数据。 | ||
| * | ||
| * 行为: | ||
| * 1. 解析 `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json` | ||
| * 2. 文件不存在 → 以空列表起步(writeChatsFile 会递归建目录 + 原子落盘) | ||
| * 3. 文件存在 → 读取并在头部插入新会话(新建的默认置顶于列表最前) | ||
| * 4. 新会话默认 `title = '新会话'`,`chatId = randomUUID()`,createdAt = updatedAt = now | ||
| * 5. 通过 EVENT_CHAT_RESPONSE 返回 { chat: 新建项 } | ||
| */ | ||
| export function handleChatCreate(params) { | ||
| const { userId, agentId, botClientId, reliableEmitter, log } = params; | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| // 响应 msgId:成功 / 失败分支共用,侜助前端根据 msgId 反查原请求 | ||
| const responseMsgId = generateMsgId(); | ||
| const sendError = createChatErrorSender({ | ||
| responseMsgId, | ||
| botClientId, | ||
| userId, | ||
| agentId, | ||
| type: 'create', | ||
| reliableEmitter, | ||
| log, | ||
| }); | ||
| try { | ||
| // 读取已有列表:文件不存在则返回 [] —— create 是隐式创建语义 | ||
| const existing = readChatsFile(chatsPath); | ||
| const now = Date.now(); | ||
| const newChat = { | ||
| chatId: randomUUID(), | ||
| title: '新会话', | ||
| // 新建时 titleLocked=false:首条消息后可被自动标题生成策略改写 | ||
| titleLocked: false, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| pinned: false, | ||
| // sessionIdHistory:同一 chat 历史上使用过的 sessionId(支持会话分支 / 重置) | ||
| sessionIdHistory: [], | ||
| }; | ||
| // 新会话插入到最前,writeChatsFile 会在目录不存在时逐级创建(tmp → rename 原子落盘) | ||
| const nextChats = [newChat, ...existing]; | ||
| writeChatsFile(chatsPath, nextChats); | ||
| log?.info(`[${CHANNEL_KEY}] Chat create: userId=${userId} agentId=${agentId} chatId=${newChat.chatId}`); | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'create', | ||
| agentId, | ||
| chats: [newChat], | ||
| timestamp: Date.now(), | ||
| }, responseMsgId); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } | ||
| export function handleChatUpdate(params) { | ||
| const { userId, agentId, chatId, title, botClientId, reliableEmitter, log } = params; | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| const responseMsgId = generateMsgId(); | ||
| // update 错误响应需要回显 chatId,便于前端关联请求 | ||
| const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'update', reliableEmitter, log }, { chatId }); | ||
| // title 可能为纯空白字符,trim 后再判断,避免“空标题”落盘 | ||
| const nextTitle = title?.trim(); | ||
| if (!nextTitle) { | ||
| sendError('Missing title'); | ||
| return; | ||
| } | ||
| // 文件必须存在,update 不负责创建(语义上:无源数据则无从更新) | ||
| if (!fs.existsSync(chatsPath)) { | ||
| sendError(`Chats file not found: ${chatsPath}`); | ||
| return; | ||
| } | ||
| try { | ||
| const chats = readChatsFile(chatsPath); | ||
| // O(n) 线性查找;chats.json 一般单用户单 Agent 规模有限,无需 Map 索引 | ||
| const idx = chats.findIndex((c) => c.chatId === chatId); | ||
| if (idx < 0) { | ||
| sendError(`Chat not found: chatId=${chatId}`); | ||
| return; | ||
| } | ||
| // 更新:title + updatedAt + titleLocked | ||
| // titleLocked=true:用户主动改名后锁定,防止被自动标题生成策略覆盖 | ||
| const updated = { | ||
| ...chats[idx], | ||
| title: nextTitle, | ||
| titleLocked: true, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| // 浅拷贝后原位替换,保留其他项的顺序(列表排序给前端决定,后端不托管) | ||
| const nextChats = [...chats]; | ||
| nextChats[idx] = updated; | ||
| // 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件 | ||
| writeChatsFile(chatsPath, nextChats); | ||
| log?.info(`[${CHANNEL_KEY}] Chat update: userId=${userId} agentId=${agentId} chatId=${chatId} title="${nextTitle}"`); | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'update', | ||
| agentId, | ||
| chats: [updated], | ||
| timestamp: Date.now(), | ||
| }, responseMsgId); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } | ||
| export function handleChatDelete(params) { | ||
| const { userId, agentId, chatId, botClientId, reliableEmitter, log } = params; | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| const responseMsgId = generateMsgId(); | ||
| // delete 错误响应需要回显 chatId,便于前端关联请求 | ||
| const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'delete', reliableEmitter, log }, { chatId }); | ||
| // 文件必须存在,delete 不负责创建(无文件 == 无可删的项,返回明确错误) | ||
| if (!fs.existsSync(chatsPath)) { | ||
| sendError(`Chats file not found: ${chatsPath}`); | ||
| return; | ||
| } | ||
| try { | ||
| const chats = readChatsFile(chatsPath); | ||
| // 先确认条目存在再写盘,避免“无效删除”仍触发一次磁盘 IO | ||
| const idx = chats.findIndex((c) => c.chatId === chatId); | ||
| if (idx < 0) { | ||
| sendError(`Chat not found: chatId=${chatId}`); | ||
| return; | ||
| } | ||
| // 剔除目标项,保留其余顺序(不重排,不影响前端缓存的列表索引) | ||
| const nextChats = chats.filter((c) => c.chatId !== chatId); | ||
| // 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件 | ||
| writeChatsFile(chatsPath, nextChats); | ||
| log?.info(`[${CHANNEL_KEY}] Chat delete: userId=${userId} agentId=${agentId} chatId=${chatId} remaining=${nextChats.length}`); | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'delete', | ||
| agentId, | ||
| chats: chats.filter((c) => c.chatId === chatId), | ||
| timestamp: Date.now(), | ||
| }, responseMsgId); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
+160
-39
| /** | ||
| * LightClaw — ChannelPlugin 主定义 | ||
| * ============================================================================ | ||
| * channel.ts —— Lightclaw Channel Plugin 主入口 | ||
| * ============================================================================ | ||
| * | ||
| * 使用 SDK 标准的 createChatChannelPlugin 构建器, | ||
| * 参照官方 Discord/Slack channel 实现。 | ||
| * 本文件通过 OpenClaw 的 Plugin SDK 创建并导出一个「聊天类 Channel 插件」 | ||
| * (`lightclawPlugin`),用于把 Lightclaw(AI 助手 Server)接入到 OpenClaw | ||
| * 网关体系中,作为一个标准的消息通道(Channel)使用。 | ||
| * | ||
| * 结构: | ||
| * base: 来自 shared.ts 的 createLightclawPluginBase + 扩展字段 | ||
| * security: DM 安全策略 | ||
| * outbound: 出站消息适配器 | ||
| * 该插件主要负责以下几件事: | ||
| * 1. messaging:目标字符串(user:xxx / channel:xxx)的规范化与识别; | ||
| * 2. status :暴露插件/账户运行时状态,供 OpenClaw 状态面板展示; | ||
| * 3. gateway :账户级别的长连接生命周期管理(启动/停止/登出); | ||
| * 4. outbound :出站消息(文本 / 媒体)的投递策略与发送实现。 | ||
| * | ||
| * 架构概览: | ||
| * OpenClaw ──启动账户──▶ startAccount() ──▶ startGateway() ──▶ Socket.IO WS | ||
| * ◀──状态回写── ctx.setStatus() ◀── onReady/onEvent/onDisconnect/onError | ||
| * | ||
| * ============================================================================ | ||
| */ | ||
| import { createChatChannelPlugin } from 'openclaw/plugin-sdk/core'; | ||
| // ---- 通用工具:文本分片、默认账户 ID 解析 ------------------------------------ | ||
| import { chunkText, defaultAccountId } from './utils/index.js'; | ||
| // ---- 常量:通道 Key、默认账户 ID、文本分片上限 ------------------------------- | ||
| import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, TEXT_CHUNK_LIMIT } from './config.js'; | ||
| // ---- 出站发送:文本 / 媒体两类消息的实际发送实现 ----------------------------- | ||
| import { sendText, sendMedia } from './outbound.js'; | ||
| // ---- Gateway:账户级别的 Socket.IO 长连接控制器 ------------------------------ | ||
| import { startGateway } from './gateway.js'; | ||
| // ---- 共享 base:抽取出的通用插件基础配置(避免与其他插件重复) --------------- | ||
| import { createLightclawPluginBase } from './shared.js'; | ||
| // ---- Setup 适配器:首次配置 / 登录流程 --------------------------------------- | ||
| import { lightclawSetupAdapter } from './setup-core.js'; | ||
| // ---- 目标解析:对 target 字符串做 normalize 和 "是否像 ID" 的预判 ------------- | ||
| import { normalizeTarget, looksLikeId } from './messaging.js'; | ||
| // ============================================================ | ||
| // ChannelPlugin 定义(使用 createChatChannelPlugin) | ||
| // ============================================================ | ||
| /** | ||
| * Lightclaw 聊天通道插件。 | ||
| * | ||
| * 通过 `createChatChannelPlugin` 工厂创建;泛型 `ResolvedAssistantAccount` | ||
| * 指定该通道使用的账户类型(OpenClaw 会据此在各回调中注入强类型 account)。 | ||
| * | ||
| * 导出后由 OpenClaw 主程序在启动阶段加载并注册到 Channel 管理器中。 | ||
| */ | ||
| export const lightclawPlugin = createChatChannelPlugin({ | ||
| // ======================================================================== | ||
| // base:插件的核心能力声明(messaging / status / gateway ...) | ||
| // ======================================================================== | ||
| base: { | ||
| // ---- 来自 shared.ts 的基础定义 ---- | ||
| // 展开共享的 base:包含 setup、channelKey、日志前缀等公用配置 | ||
| ...createLightclawPluginBase({ | ||
| setup: lightclawSetupAdapter, | ||
| }), | ||
| // ---- 消息目标解析 ---- | ||
| // ---------------------------------------------------------------------- | ||
| // messaging:消息目标(target)的解析与规范化 | ||
| // ---------------------------------------------------------------------- | ||
| messaging: { | ||
| // 对原始目标字符串进行标准化处理,如去除空格、统一格式、转换编码等,返回标准化后的字符串 | ||
| /** | ||
| * 对原始目标字符串进行标准化处理。 | ||
| * | ||
| * 例如:去除前后空格、统一大小写、把 `@xxx` 转为 `user:xxx` 等, | ||
| * 返回规范化后的字符串,供后续路由匹配使用。 | ||
| */ | ||
| normalizeTarget: (target) => { | ||
| return normalizeTarget(target); | ||
| }, | ||
| /** | ||
| * 目标解析器:在消息路由阶段帮助框架判断 target 的"形态"。 | ||
| */ | ||
| targetResolver: { | ||
| // 判断一个原始字符串是否看起来像一个 ID,用于在解析前快速分流处理逻辑 | ||
| /** | ||
| * 判断一个原始字符串是否看起来像一个 ID(而不是昵称/关键词等), | ||
| * 用于在解析前快速分流处理逻辑,避免不必要的远端查询。 | ||
| * | ||
| * @param id 用户输入的原始字符串 | ||
| * @param normalized 经过 normalizeTarget 规范化后的字符串(可选) | ||
| */ | ||
| looksLikeId: (id, normalized) => { | ||
@@ -44,4 +85,10 @@ return looksLikeId(id, normalized); | ||
| }, | ||
| // ---- 状态面板 ---- | ||
| // ---------------------------------------------------------------------- | ||
| // status:运行时状态快照的构建与默认值 | ||
| // - defaultRuntime :账户 runtime 的初始值(未启动时回显) | ||
| // - buildChannelSummary:整个通道层级的概要状态(所有账户汇总之上的顶层) | ||
| // - buildAccountSnapshot:单个账户的详细快照(CLI/面板展示的主体数据) | ||
| // ---------------------------------------------------------------------- | ||
| status: { | ||
| /** 账户 runtime 的初始状态:未连接、未运行、无错误 */ | ||
| defaultRuntime: { | ||
@@ -54,2 +101,6 @@ accountId: DEFAULT_ACCOUNT_ID, | ||
| }, | ||
| /** | ||
| * 构建通道级别的汇总状态。 | ||
| * 由 OpenClaw 在查询 `status` 命令时调用。 | ||
| */ | ||
| buildChannelSummary: ({ snapshot }) => ({ | ||
@@ -62,2 +113,9 @@ configured: snapshot.configured ?? false, | ||
| }), | ||
| /** | ||
| * 构建单个账户的快照。 | ||
| * | ||
| * @param account 账户元数据(accountId / name / apiKey / enabled 等) | ||
| * @param runtime 账户运行时状态(由 gateway 回调实时更新) | ||
| * @param cfg 当前 OpenClaw 完整配置,用于在 account 缺失时回退取默认 | ||
| */ | ||
| buildAccountSnapshot: ({ account, runtime, cfg }) => ({ | ||
@@ -67,2 +125,3 @@ accountId: account?.accountId ?? defaultAccountId(cfg), | ||
| enabled: account?.enabled ?? false, | ||
| // configured 表示"是否已配置 apiKey",是前端判断"是否可启动"的依据 | ||
| configured: Boolean(account?.apiKey), | ||
@@ -75,11 +134,24 @@ running: runtime?.running ?? false, | ||
| }, | ||
| // ---- Gateway 生命周期(核心!) ---- | ||
| // ---------------------------------------------------------------------- | ||
| // gateway:账户级生命周期(启动 WS 长连接 / 登出清理凭证) | ||
| // ---------------------------------------------------------------------- | ||
| gateway: { | ||
| /** | ||
| * 启动账户:建立 WS 长连接到 AI 助手 Server | ||
| * OpenClaw 会在加载配置后为每个 enabled 的账户调用此方法 | ||
| * 启动账户:建立 WS 长连接到 AI 助手 Server。 | ||
| * | ||
| * OpenClaw 会在加载配置后,为每个 `enabled` 的账户调用此方法。 | ||
| * 方法内部通过 `startGateway` 维持一个带自动重连的 Socket.IO 实例, | ||
| * 并通过一组回调把连接状态反向写回 OpenClaw 的 runtime 状态。 | ||
| * | ||
| * @param ctx 插件上下文: | ||
| * - account 当前账户解析结果 | ||
| * - abortSignal 外部取消信号(用户执行 stop 时触发) | ||
| * - log 插件作用域的 logger | ||
| * - cfg OpenClaw 完整配置 | ||
| * - getStatus/setStatus 读写当前 runtime 快照 | ||
| */ | ||
| startAccount: async (ctx) => { | ||
| const { account, abortSignal, log, cfg } = ctx; | ||
| log?.info(`[${CHANNEL_KEY}:${account.accountId}] Starting gateway...`); | ||
| const preLogFix = `[${CHANNEL_KEY}:${account.accountId}]`; | ||
| log?.info(`${preLogFix} Starting gateway...`); | ||
| // 启动并维持一个 Socket.IO Gateway 实例 | ||
@@ -91,5 +163,9 @@ await startGateway({ | ||
| log, | ||
| /** WS 连接就绪回调 */ | ||
| /** | ||
| * WS 连接就绪回调: | ||
| * 标记 running=true / connected=true,并用当前时间戳初始化 | ||
| * lastConnectedAt 与 lastEventAt(后者是 health-monitor 的检测基准)。 | ||
| */ | ||
| onReady: () => { | ||
| log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS connected`); | ||
| log?.info(`${preLogFix} Gateway ready: WS connected`); | ||
| const now = Date.now(); | ||
@@ -105,5 +181,9 @@ ctx.setStatus({ | ||
| }, | ||
| /** WS 连接断开回调 */ | ||
| /** | ||
| * WS 连接断开回调: | ||
| * 仅把 connected 置为 false;running 保持 true,表示 gateway | ||
| * 仍在尝试重连。真正的停止由 abortSignal 触发。 | ||
| */ | ||
| onDisconnect: () => { | ||
| log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS disconnect`); | ||
| log?.info(`${preLogFix} Gateway ready: WS disconnect`); | ||
| ctx.setStatus({ | ||
@@ -114,5 +194,8 @@ ...ctx.getStatus(), | ||
| }, | ||
| /** 错误回调:记录错误信息,不中断重连逻辑(由 gateway 内部处理) */ | ||
| /** | ||
| * 错误回调:记录错误信息到 runtime.lastError。 | ||
| * 不中断重连逻辑(重连由 gateway 内部自愈机制处理)。 | ||
| */ | ||
| onError: (error) => { | ||
| log?.error(`[${CHANNEL_KEY}:${account.accountId}] Gateway error: ${error.message}`); | ||
| log?.error(`${preLogFix} Gateway error: ${error.message}`); | ||
| ctx.setStatus({ | ||
@@ -125,7 +208,8 @@ ...ctx.getStatus(), | ||
| * 入站事件回调:每次收到消息时调用。 | ||
| * 刷新 lastEventAt 和 lastInboundAt, | ||
| * 通知框架 health-monitor 该连接仍处于活跃状态。 | ||
| * | ||
| * 通过刷新 `lastEventAt` 和 `lastInboundAt`,通知框架 | ||
| * health-monitor 该连接仍处于活跃状态,避免被判定为 stale 而重连。 | ||
| */ | ||
| onEvent: () => { | ||
| log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway Ready: WS message enter`); | ||
| log?.info(`${preLogFix} Gateway Ready: WS message enter`); | ||
| ctx.setStatus({ | ||
@@ -139,17 +223,22 @@ ...ctx.getStatus(), | ||
| }, | ||
| /** 登出账户:清除凭证 */ | ||
| /** | ||
| * 登出账户:从配置中清除该账户的凭证(apiKey)。 | ||
| * 清理 `lightclawbot.accounts[accountId].apiKey` | ||
| * | ||
| * 注意:本方法只负责清除凭证字段,不停止 gateway(停止由框架调度)。 | ||
| * | ||
| * @returns { ok, cleared } cleared=true 表示确实清理了字段 | ||
| */ | ||
| logoutAccount: async ({ accountId, cfg }) => { | ||
| // 拷贝 cfg,避免直接改引用导致上游感知不到变化 | ||
| const nextCfg = { ...cfg }; | ||
| const section = nextCfg.channels?.[CHANNEL_KEY]; | ||
| const lightclawbotConfig = nextCfg?.channels?.[CHANNEL_KEY]; | ||
| let cleared = false; | ||
| if (section) { | ||
| if (accountId === DEFAULT_ACCOUNT_ID && section.apiKeys) { | ||
| delete section.apiKeys; | ||
| if (lightclawbotConfig) { | ||
| // 命名账户:凭证挂在 accounts[accountId] 下 | ||
| const accounts = lightclawbotConfig.accounts; | ||
| if (accounts?.[accountId]?.apiKey) { | ||
| delete accounts[accountId].apiKey; | ||
| cleared = true; | ||
| } | ||
| const accounts = section.accounts; | ||
| if (accounts?.[accountId]?.apiKeys) { | ||
| delete accounts[accountId].apiKeys; | ||
| cleared = true; | ||
| } | ||
| } | ||
@@ -160,9 +249,26 @@ return { ok: true, cleared }; | ||
| }, | ||
| // ---- 出站消息适配器 ---- | ||
| // ======================================================================== | ||
| // outbound:出站消息投递配置 | ||
| // - base :投递策略(模式、分片器、目标解析) | ||
| // - attachedResults :实际发送函数(sendText / sendMedia) | ||
| // ======================================================================== | ||
| outbound: { | ||
| base: { | ||
| /** 投递模式:direct = 直接发送,不走队列/合并 */ | ||
| deliveryMode: 'direct', | ||
| /** 文本分片器:按 Markdown 语义边界做智能切分 */ | ||
| chunker: chunkText, | ||
| /** 分片模式:markdown 模式会保留代码块、列表等结构完整性 */ | ||
| chunkerMode: 'markdown', | ||
| /** 单条消息文本长度上限(超过会被 chunker 切成多条发送) */ | ||
| textChunkLimit: TEXT_CHUNK_LIMIT, | ||
| /** | ||
| * 解析投递目标(resolveTarget): | ||
| * | ||
| * 决定一次 outbound 消息最终要发送给谁(to)。 | ||
| * 优先级: | ||
| * 1. 显式 `to` 参数(用户 / 上层明确指定); | ||
| * 2. implicit 模式下,从 `allowFrom` 列表中挑第一个非通配符条目; | ||
| * 3. 都没有则返回错误,提示用户用 `--to` 指定或配置 allowFrom。 | ||
| */ | ||
| resolveTarget: ({ to, allowFrom, mode }) => { | ||
@@ -186,7 +292,22 @@ // 优先使用显式 to;implicit 模式下回退到 allowFrom 第一个非通配符条目 | ||
| }, | ||
| /** | ||
| * attachedResults:绑定到"工具调用结果投递"这条链路的发送实现。 | ||
| * | ||
| * OpenClaw 在 assistant 产出工具结果需要推送给外部时,会根据 | ||
| * `channel` 字段路由到这里,调用 sendText / sendMedia 完成实际投递。 | ||
| */ | ||
| attachedResults: { | ||
| /** 通道标识,需与 CHANNEL_KEY 一致,供 OpenClaw 路由匹配 */ | ||
| channel: CHANNEL_KEY, | ||
| /** | ||
| * 发送纯文本消息。 | ||
| * 参数由框架透传:to 目标、text 内容、accountId 账户、replyToId 回复锚点、cfg 完整配置。 | ||
| */ | ||
| sendText: async ({ to, text, accountId, replyToId, cfg }) => { | ||
| return sendText({ to, text, accountId, replyToId, cfg }); | ||
| }, | ||
| /** | ||
| * 发送带媒体附件的消息(图片 / 文件等)。 | ||
| * text 为附带的文本说明,mediaUrl 为媒体资源 URL。 | ||
| */ | ||
| sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => { | ||
@@ -193,0 +314,0 @@ return sendMedia({ to, text, mediaUrl, accountId, replyToId, cfg }); |
@@ -150,2 +150,6 @@ /** | ||
| export const EVENT_SESSIONS_RESPONSE = 'sessions:response'; | ||
| export const EVENT_AGENTS_REQUEST = 'agents:request'; | ||
| export const EVENT_AGENTS_RESPONSE = 'agents:response'; | ||
| export const EVENT_CHAT_REQUEST = 'chat:request'; | ||
| export const EVENT_CHAT_RESPONSE = 'chat:response'; | ||
| // ============================================================ | ||
@@ -152,0 +156,0 @@ // 文件下载信令(kind 字段值) |
+17
-5
@@ -136,6 +136,15 @@ /** | ||
| const emit = (data) => { | ||
| // 统一保证 chatId 字段存在:全部 EVENT_MESSAGE_PRIVATE 出站消息都通过 | ||
| // `extra.chatId` 携带,调用方未传入时默认为 ''(默认会话)。 | ||
| // 同时保留调用方传入的其他 extra 字段(如 transferData)。 | ||
| const existingExtra = data.extra ?? {}; | ||
| const chatId = typeof existingExtra.chatId === 'string' ? existingExtra.chatId : ''; | ||
| const payload = { | ||
| ...data, | ||
| extra: { ...existingExtra, chatId }, | ||
| }; | ||
| // 完整发送日志由 ReliableEmitter 打印(含 idempotencyKey) | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, data, data.msgId).then((ok) => { | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, payload, payload.msgId).then((ok) => { | ||
| if (!ok) | ||
| log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${data.msgId}`); | ||
| log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${payload.msgId}`); | ||
| }); | ||
@@ -153,3 +162,3 @@ return true; | ||
| */ | ||
| const sendReply = (targetId, text, replyToMsgId, agentId) => { | ||
| const sendReply = (targetId, text, replyToMsgId, chatId, agentId) => { | ||
| log?.info(`[${CHANNEL_KEY}] sendReply: ${text} to ${targetId} (replyTo: ${replyToMsgId || 'none'})`); | ||
@@ -164,2 +173,3 @@ return emit({ | ||
| agentId, | ||
| extra: { chatId: chatId ?? '' }, | ||
| }); | ||
@@ -177,3 +187,3 @@ }; | ||
| */ | ||
| const sendFiles = (targetId, text, files, replyToMsgId, agentId) => { | ||
| const sendFiles = (targetId, text, files, replyToMsgId, chatId, agentId) => { | ||
| return emit({ | ||
@@ -188,2 +198,3 @@ msgId: generateMsgId(), | ||
| agentId, | ||
| extra: { chatId: chatId ?? '' }, | ||
| }); | ||
@@ -209,3 +220,3 @@ }; | ||
| try { | ||
| emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId); | ||
| emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId, msg.chatId); | ||
| emitter.emit({ | ||
@@ -220,2 +231,3 @@ msgId: generateMsgId(), | ||
| agentId: msg.agentId, | ||
| extra: { chatId: msg.chatId ?? '' }, | ||
| }); | ||
@@ -222,0 +234,0 @@ } |
@@ -23,2 +23,2 @@ /** | ||
| // ── Session Reader (核心 API) ── | ||
| export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, listSessions, } from "./session-reader.js"; | ||
| export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, readSessionHistoriesByIds, listSessions, } from "./session-reader.js"; |
@@ -12,6 +12,5 @@ /** | ||
| import fs from "node:fs"; | ||
| import { resolveTranscriptPath } from "./session-store.js"; | ||
| import { resolveTranscriptPath, loadSessionStore, resolveTranscriptPathBySessionId } from "./session-store.js"; | ||
| import { isSystemInjectedUserMessage, normalizeMessage } from "./message-parser.js"; | ||
| import { listCronSessions, classifySessionKey, extractCronJobId, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js"; | ||
| import { loadSessionStore } from "./session-store.js"; | ||
| // ============================================================ | ||
@@ -230,2 +229,58 @@ // 核心读取:单个 Session | ||
| // ============================================================ | ||
| // 多 SessionId 合并读取(跨 reset 历史回看) | ||
| // ============================================================ | ||
| /** | ||
| * 给定一组 sessionId(按时间序),合并读取它们各自 jsonl 中的消息。 | ||
| * | ||
| * 适用场景: | ||
| * chats.json 的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId | ||
| * (含已被 reset 归档的旧 ID 和当前在用的 ID)。本函数据此把多份 jsonl | ||
| * 合并为一份按时间正序的消息流,让前端能跨 reset 看到完整对话历史。 | ||
| * | ||
| * 实现细节: | ||
| * - 通过 resolveTranscriptPathBySessionId 直接按 sessionId 定位文件, | ||
| * 既能命中活跃文件 <sessionId>.jsonl,也能命中归档文件 | ||
| * <sessionId>.jsonl.reset.* / .deleted.*; | ||
| * - 单份 jsonl 内已按写入顺序自然有序,不同 sessionId 之间通过 timestamp | ||
| * 做最终归并排序; | ||
| * - 仅返回最后 limit 条(保持与 readSessionHistory 一致的行为)。 | ||
| * | ||
| * @param sessionIds - 该 chat 用过的所有 sessionId(顺序由调用方保证: | ||
| * 通常 = chats.json[chatId].sessionIdHistory) | ||
| * @param opts - 与 readSessionHistory 一致;agentId 用于路径解析 | ||
| * @returns 按 timestamp 升序合并的消息列表(最多 limit 条) | ||
| */ | ||
| export function readSessionHistoriesByIds(sessionIds, opts) { | ||
| const limit = opts?.limit ?? 200; | ||
| const chatOnly = opts?.chatOnly ?? false; | ||
| if (!Array.isArray(sessionIds) || sessionIds.length === 0) | ||
| return []; | ||
| const all = []; | ||
| // 去重 + 保持顺序:同一 sessionId 出现多次只读一次 | ||
| const seen = new Set(); | ||
| for (const sid of sessionIds) { | ||
| if (!sid || seen.has(sid)) | ||
| continue; | ||
| seen.add(sid); | ||
| const filePath = resolveTranscriptPathBySessionId(sid, opts?.agentId); | ||
| if (!filePath) | ||
| continue; | ||
| try { | ||
| const raw = fs.readFileSync(filePath, "utf-8"); | ||
| // 单文件先读到上限:避免单段过大撑爆内存;最终还要按 limit 截断 | ||
| const msgs = parseTranscriptLines(raw, { limit, chatOnly }); | ||
| for (const m of msgs) | ||
| all.push(m); | ||
| } | ||
| catch { | ||
| // 单段读取失败不阻断其他段 | ||
| } | ||
| } | ||
| if (all.length === 0) | ||
| return []; | ||
| // 多段合并:按 timestamp 升序;无 timestamp 的消息按相对顺序兜底 | ||
| all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); | ||
| return all.length > limit ? all.slice(-limit) : all; | ||
| } | ||
| // ============================================================ | ||
| // 内部工具函数 | ||
@@ -232,0 +287,0 @@ // ============================================================ |
@@ -32,2 +32,3 @@ /** | ||
| const id = agentId?.trim() || "main"; | ||
| // 例如:~/.openclaw/agents/main/sessions | ||
| return path.join(home, "agents", id, "sessions"); | ||
@@ -49,2 +50,3 @@ } | ||
| export function loadSessionStore(agentId) { | ||
| // ~/.openclaw/agents/main/sessions/sessions.json | ||
| const storePath = path.join(resolveSessionsDir(agentId), "sessions.json"); | ||
@@ -174,1 +176,42 @@ // const storePath = path.join(currentDir, "../../sessions/sessions.json").replace("file:", ""); | ||
| } | ||
| /** | ||
| * 直接根据 sessionId 解析 transcript 文件路径(不依赖 sessions.json 索引)。 | ||
| * | ||
| * 适用场景: | ||
| * chats.json 中的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId, | ||
| * 这些 sessionId 在 reset 之后会被框架从 sessions.json 索引中移除(旧条目失效), | ||
| * 因此无法再通过 sessionKey 反查;只能直接按 sessionId 在磁盘上定位 jsonl。 | ||
| * | ||
| * 解析顺序: | ||
| * 1. <sessionsDir>/<sessionId>.jsonl —— 当前在用的 transcript | ||
| * 2. <sessionsDir>/<sessionId>.jsonl.reset.* / .deleted.* —— 归档 transcript | ||
| * 3. storeDir 兜底(同 resolveTranscriptPath,离线分析场景) | ||
| * | ||
| * 找不到则返回 null。 | ||
| */ | ||
| export function resolveTranscriptPathBySessionId(sessionId, agentId) { | ||
| if (!sessionId || !sessionId.trim()) | ||
| return null; | ||
| const sessionsDir = resolveSessionsDir(agentId); | ||
| const jsonlFileName = `${sessionId}.jsonl`; | ||
| // 1. 标准目录下的活跃 transcript | ||
| const defaultPath = path.join(sessionsDir, jsonlFileName); | ||
| if (fs.existsSync(defaultPath)) | ||
| return defaultPath; | ||
| // 2. 同目录归档文件 | ||
| const archivedPath = _findArchivedTranscript(sessionsDir, jsonlFileName); | ||
| if (archivedPath) | ||
| return archivedPath; | ||
| // 3. storeDir 兜底(离线分析场景) | ||
| // 注意:getStoreDir() 仅在 loadSessionStore() 成功后才有值,调用方需确保前置条件 | ||
| const storeDir = getStoreDir(); | ||
| if (storeDir && storeDir !== sessionsDir) { | ||
| const siblingPath = path.join(storeDir, jsonlFileName); | ||
| if (fs.existsSync(siblingPath)) | ||
| return siblingPath; | ||
| const archivedInStore = _findArchivedTranscript(storeDir, jsonlFileName); | ||
| if (archivedInStore) | ||
| return archivedInStore; | ||
| } | ||
| return null; | ||
| } |
@@ -74,2 +74,9 @@ /** | ||
| result = result.replace(/用户发送了文件:\s*.+?\s*\([^)]+\)\s*/g, ""); | ||
| // 移除 OpenClaw 媒体消息的结构化包装块: | ||
| // [Image] / [Video] / [Audio] / [Document] / [Image 1] 等媒体类型标记行 | ||
| result = result.replace(/^\[[A-Za-z][\w ]*\]\s*$/gm, ""); | ||
| // "User text:" 单独一行的 label(其后紧跟用户真实输入,需保留输入本身) | ||
| result = result.replace(/^User text:\s*$/gm, ""); | ||
| // "Description:\n<AI 对媒体的自动描述>" — 位于消息尾部,直接吃到文本末尾 | ||
| result = result.replace(/^Description:[\s\S]*$/m, ""); | ||
| return result.trim(); | ||
@@ -76,0 +83,0 @@ } |
+34
-20
@@ -11,3 +11,3 @@ /** | ||
| import { emitSignal } from './utils/common.js'; | ||
| import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME } from './config.js'; | ||
| import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME, } from './config.js'; | ||
| import { getLightclawRuntime } from './runtime.js'; | ||
@@ -49,19 +49,25 @@ import { createChannelReplyPipeline } from 'openclaw/plugin-sdk/channel-reply-pipeline'; | ||
| // route: {"agentId":"main","channel":"lightclawbot","accountId":"default","sessionKey":"agent:main:lightclawbot:direct:100013456706","mainSessionKey":"agent:main:main","lastRoutePolicy":"session","matchedBy":"default"} | ||
| // 若前端指定了合法的 agentId,则用 buildAgentSessionKey 覆盖 sessionKey 和 agentId, | ||
| // 实现 Agent 间隔离(sessionKey 含 agentId,不同 Agent 的会话历史完全独立) | ||
| const route = resolvedAgentId | ||
| ? { | ||
| ...baseRoute, | ||
| agentId: resolvedAgentId, | ||
| sessionKey: pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| agentId: resolvedAgentId, | ||
| channel: CHANNEL_KEY, | ||
| accountId: baseRoute.accountId, | ||
| peer: { kind: 'direct', id: msg.senderId }, | ||
| dmScope: 'per-channel-peer', | ||
| }), | ||
| mainSessionKey: `agent:${resolvedAgentId}:main`, | ||
| } | ||
| : baseRoute; | ||
| log?.info(`[CHANNEL_KEY]: route= ${JSON.stringify(route)}`); | ||
| // 框架生成的基础 sessionKey 形如: | ||
| // agent:<agentId>:<channel>:direct:<userId> | ||
| // 当前端带上 chatId(多会话场景)时,需要在末尾追加 `:<chatId>`, | ||
| // 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>, | ||
| const chatIdSuffix = msg?.chatId?.trim(); | ||
| const appendChatId = (key) => (chatIdSuffix ? `${key}:${chatIdSuffix}` : key); | ||
| // 用 buildAgentSessionKey 基于 resolvedAgentId 重建 sessionKey 和 agentId, | ||
| // 实现 Agent 间隔离(sessionKey 含 agentId,不同 Agent 的会话历史完全独立)。 | ||
| // resolvedAgentId 由 DEFAULT_AGENT_ID 兜底,必定有值,故无需再判空。 | ||
| const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| agentId: resolvedAgentId, | ||
| channel: CHANNEL_KEY, | ||
| accountId: baseRoute.accountId, | ||
| peer: { kind: 'direct', id: msg.senderId }, | ||
| dmScope: 'per-channel-peer', | ||
| }); | ||
| const route = { | ||
| ...baseRoute, | ||
| agentId: resolvedAgentId, | ||
| sessionKey: appendChatId(baseSessionKey), | ||
| mainSessionKey: `agent:${resolvedAgentId}:main`, | ||
| }; | ||
| log?.info(`[CHANNEL_KEY]: route= ${JSON.stringify(route)}, chatId=${chatIdSuffix ?? '-'}`); | ||
| // ---- 步骤 3:权限检查 ---- | ||
@@ -82,4 +88,12 @@ // 根据 allowFrom 白名单和 dmPolicy 策略决定是否允许此用户发送命令 | ||
| const replyMsgId = generateMsgId(); | ||
| const signalCtx = { emitter, targetId, replyMsgId, originalMsgId: msg.messageId, agentId: resolvedAgentId }; | ||
| const signalCtx = { | ||
| emitter, | ||
| targetId, | ||
| replyMsgId, | ||
| originalMsgId: msg.messageId, | ||
| agentId: resolvedAgentId, | ||
| chatId: msg.chatId ?? '', | ||
| }; | ||
| emitSignal(signalCtx, 'typing_start'); | ||
| // 注:SignalContext.chatId 是上下文字段,由 emitSignal 内部规整到 extra.chatId 输出 | ||
| // 3. 处理文件附件(files[] → 本地存储 + 公网 URL) | ||
@@ -257,3 +271,3 @@ const localMediaPaths = []; | ||
| if (!hasEmittedContent()) { | ||
| emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId); | ||
| emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId, msg.chatId); | ||
| } | ||
@@ -260,0 +274,0 @@ } |
+271
-29
@@ -15,8 +15,10 @@ /** | ||
| */ | ||
| import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_SESSIONS_REQUEST, EVENT_SESSIONS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS } from '../config.js'; | ||
| import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_AGENTS_REQUEST, EVENT_AGENTS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS, EVENT_CHAT_REQUEST, } from '../config.js'; | ||
| import { isDuplicate, debounceHistoryRequest, generateMsgId } from '../dedup.js'; | ||
| import { getLightclawRuntime } from '../runtime.js'; | ||
| import { readSessionHistoryWithCron, listSessions } from '../history/index.js'; | ||
| import { readSessionHistoryWithCron, readSessionHistoriesByIds, loadSessionStore } from '../history/index.js'; | ||
| import { handleChatList, handleChatCreate, handleChatUpdate, handleChatDelete } from './chat.js'; | ||
| import { uploadFileToServer } from '../file-storage.js'; | ||
| import { guessMimeByExt } from '../media.js'; | ||
| import { ensureSessionInHistory, readChatsFile, resolveChatsFilePath } from '../utils/common.js'; | ||
| import * as fs from 'node:fs'; | ||
@@ -76,2 +78,3 @@ import * as path from 'node:path'; | ||
| // /stop 及自然语言 abort 不做旁路,统一交给 openclaw fast-abort。 | ||
| const chatId = extractChatId(data); | ||
| handleMessage({ | ||
@@ -84,3 +87,29 @@ senderId: data.from, | ||
| agentId: data.agentId, // 透传前端指定的 agentId | ||
| chatId, // 透传前端指定的 chatId(多会话分桶用) | ||
| }); | ||
| // ④ 异步登记当前生效的 sessionId 到 chats.json[chatId].sessionIdHistory。 | ||
| // | ||
| // 为什么用 setImmediate? | ||
| // - 首条消息到达时,框架还未创建 session,sessions.json 查不到该 sessionKey; | ||
| // - /reset 等场景下,框架会在派发消息前 rotate sessionId,本回合的当前 sessionId | ||
| // 是 rotate 之后的新 ID; | ||
| // - 把登记放到下一个事件循环 tick,给框架留出时序窗口(创建/rotate session); | ||
| // - 与主消息流解耦,登记失败不影响 handleMessage 主流程。 | ||
| // | ||
| // 即使本次未登记成功(首条消息时 sessions.json 还没条目),下一条消息进来时 | ||
| // 仍会再次触发本逻辑,幂等的 ensureSessionInHistory 会补登记,最终一致。 | ||
| setImmediate(() => { | ||
| try { | ||
| recordSessionInHistory({ | ||
| userId: data.from, | ||
| agentId: data.agentId, | ||
| chatId, | ||
| accountId: account.accountId, | ||
| log, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log?.warn(`[${CHANNEL_KEY}] recordSessionInHistory(message:private) error: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| }); | ||
| }); | ||
@@ -130,3 +159,10 @@ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // - 前端传了合法 agentId → 用它;否则降级到 baseRoute.agentId(框架路由解析出的默认值) | ||
| const sessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| // | ||
| // 框架生成的基础 sessionKey 形如: | ||
| // agent:<agentId>:<channel>:direct:<userId> | ||
| // 当前端带上 chatId(多会话场景)时,需要在基础 sessionKey 末尾追加 `:<chatId>`, | ||
| // 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>, | ||
| // 以区分同一用户下的不同会话历史文件。框架本身不感知 chatId, | ||
| // 故在此处显式拼接,保持与写入端(chat 维度的 sessionId)一致。 | ||
| const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| agentId: resolvedAgentId, | ||
@@ -138,12 +174,73 @@ channel: CHANNEL_KEY, | ||
| }); | ||
| log?.info(`[${CHANNEL_KEY}] userId=${data.from},当前的sessionKey=${sessionKey}`); | ||
| // 从本地持久化存储中读取该 session 的历史消息 | ||
| // includeCron: true 表示同时读取定时任务产生的消息(如每日推送) | ||
| const messages = readSessionHistoryWithCron(sessionKey, { | ||
| limit: data.limit ?? DEFAULT_HISTORY_LIMIT, | ||
| chatOnly: data.chatOnly ?? true, | ||
| includeCron: true, | ||
| agentId: resolvedAgentId, | ||
| }); | ||
| log?.info(`[${CHANNEL_KEY}] History request: userId=${data.from} sessionKey=${sessionKey} found=${messages.length}`); | ||
| const chatIdSuffix = extractChatId(data); | ||
| const sessionKey = chatIdSuffix ? `${baseSessionKey}:${chatIdSuffix}` : baseSessionKey; | ||
| log?.info(`[${CHANNEL_KEY}] userId=${data.from},chatId=${chatIdSuffix || '-'},当前的sessionKey=${sessionKey}`); | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // ① 兜底登记当前 sessionId 到 sessionIdHistory | ||
| // | ||
| // 为什么在拉历史时也做登记?—— 兜底覆盖一个边界场景:用户 reset 后没再 | ||
| // 发过消息,只是重新打开聊天界面拉历史。此时 message:private 的登记 | ||
| // 路径不会被触发,仅靠这里能补登记,确保历史不丢。 | ||
| // | ||
| // 必须先于读历史,因为读历史依赖 sessionIdHistory 来合并多份 jsonl, | ||
| // 当前 sessionId 也要一起读到。 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| try { | ||
| const store = loadSessionStore(resolvedAgentId); | ||
| const currentSessionId = store[sessionKey]?.sessionId; | ||
| if (currentSessionId) { | ||
| ensureSessionInHistory(resolvedAgentId, data.from, chatIdSuffix, currentSessionId); | ||
| } | ||
| } | ||
| catch (err) { | ||
| log?.warn(`[${CHANNEL_KEY}] ensureSessionInHistory(history:request) error: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // ② 读取历史消息:优先按 sessionIdHistory 合并多份 jsonl(跨 reset 历史回看) | ||
| // | ||
| // - chats.json 命中(含 chatId === '' 的默认对话兜底条目): | ||
| // 把该 chat 用过的所有 sessionId(含归档的旧 sessionId)的 jsonl | ||
| // 全部读出来按 timestamp 合并; | ||
| // - 否则:退化到原来的"按 sessionKey 读当前 jsonl + cron 合并"路径, | ||
| // 保持向后兼容(chats.json 不存在 / 解析异常等极端场景)。 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| const limit = data.limit ?? DEFAULT_HISTORY_LIMIT; | ||
| const chatOnly = data.chatOnly ?? true; | ||
| let messages = []; | ||
| let readMode = 'single-session-with-cron'; | ||
| try { | ||
| // 查询侧使用与登记侧同一个 chatId(已在 extractChatId 统一归一)。 | ||
| const targetChatId = chatIdSuffix; | ||
| const chatsPath = resolveChatsFilePath(resolvedAgentId, data.from); | ||
| // 这里使用**纯只读**的 readChatsFile: | ||
| // - 拉历史是只读接口,不应该在用户还没发任何消息前就把 | ||
| // chats.json 写到磁盘(避免凭空出现一个默认对话); | ||
| // - 如果是默认对话首次消息后拉历史,前面的 ensureSessionInHistory | ||
| // 已经走 readChatsFileOrInitDefault 打点过 chats.json,这里能读到。 | ||
| const matched = readChatsFile(chatsPath).find((c) => c.chatId === targetChatId); | ||
| const historyIds = matched?.sessionIdHistory ?? []; | ||
| if (historyIds.length > 0) { | ||
| messages = readSessionHistoriesByIds(historyIds, { | ||
| limit, | ||
| chatOnly, | ||
| agentId: resolvedAgentId, | ||
| }); | ||
| readMode = 'multi-session'; | ||
| } | ||
| } | ||
| catch (err) { | ||
| log?.warn(`[${CHANNEL_KEY}] readSessionHistoriesByIds fallback to single-session: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| if (messages.length === 0 && readMode !== 'multi-session') { | ||
| // 退路 / 兜底:仅当未走多 sessionId 合并路径时,退化到按当前 sessionKey 读(含 cron 合并)。 | ||
| // 已走 multi-session 但消息为空,意味着 chats.json 里 sessionIdHistory 全部 jsonl 都没内容, | ||
| // 这是合理的"空历史"状态,不应再退回 single-session 路径,避免重复 cron 拼接的副作用。 | ||
| messages = readSessionHistoryWithCron(sessionKey, { | ||
| limit, | ||
| chatOnly, | ||
| includeCron: true, | ||
| agentId: resolvedAgentId, | ||
| }); | ||
| } | ||
| log?.info(`[${CHANNEL_KEY}] History request: userId=${data.from} sessionKey=${sessionKey} mode=${readMode} found=${messages.length}`); | ||
| // 过滤掉内容为空的消息(既无文字也无附件),避免客户端渲染空气泡 | ||
@@ -176,27 +273,167 @@ const historyMsgId = generateMsgId(); | ||
| }); | ||
| socket.on(EVENT_SESSIONS_REQUEST, (data) => { | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 事件:Agents 列表请求(agents:request) | ||
| // 职责:读取 openclaw.json 中 agents.list 并返回给客户端(支持热更新,不缓存) | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| socket.on(EVENT_AGENTS_REQUEST, (data, ack) => { | ||
| // 立即回复 ACK,告知服务端请求已收到 | ||
| ack?.(); | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判) | ||
| onEvent?.(); | ||
| try { | ||
| const sessions = listSessions(); | ||
| const sessionsMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_SESSIONS_RESPONSE, { | ||
| requestId: data.requestId, | ||
| sessions, | ||
| msgId: sessionsMsgId, | ||
| }, sessionsMsgId); | ||
| // 获取插件运行时,读取最新配置 | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const agentsList = currentCfg.agents?.list ?? []; | ||
| log?.info(`[${CHANNEL_KEY}] Agents request: count=${agentsList.length}`); | ||
| const agentsMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, { | ||
| msgId: agentsMsgId, | ||
| to: data.from, | ||
| from: botClientId, | ||
| agents: agentsList, | ||
| timestamp: Date.now(), | ||
| }, agentsMsgId); | ||
| } | ||
| catch (err) { | ||
| log?.error(`[${CHANNEL_KEY}] Sessions list error: ${err}`); | ||
| const sessionsErrMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_SESSIONS_RESPONSE, { | ||
| requestId: data.requestId, | ||
| sessions: [], | ||
| log?.error(`[${CHANNEL_KEY}] Agents request error: ${err}`); | ||
| const agentsErrMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, { | ||
| msgId: agentsErrMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| agents: [], | ||
| error: err instanceof Error ? err.message : String(err), | ||
| msgId: sessionsErrMsgId, | ||
| }, sessionsErrMsgId); | ||
| timestamp: Date.now(), | ||
| }, agentsErrMsgId); | ||
| } | ||
| }); | ||
| socket.on(EVENT_CHAT_REQUEST, (data, ack) => { | ||
| ack?.(); | ||
| log?.info(`[${CHANNEL_KEY}] Received chat request from ${data.from}: ${data.type}`); | ||
| if (!data?.from) { | ||
| log?.warn(`[${CHANNEL_KEY}] Chat request missing userId, ignoring`); | ||
| return; | ||
| } | ||
| // 回环防御 | ||
| if (data.from === botClientId) | ||
| return; | ||
| onEvent?.(); | ||
| switch (data.type) { | ||
| case 'list': | ||
| handleChatList({ | ||
| userId: data.from, | ||
| agentId: data.agentId, | ||
| botClientId, | ||
| reliableEmitter, | ||
| log, | ||
| }); | ||
| break; | ||
| case 'create': | ||
| handleChatCreate({ | ||
| userId: data.from, | ||
| agentId: data.agentId, | ||
| botClientId, | ||
| reliableEmitter, | ||
| log, | ||
| }); | ||
| break; | ||
| case 'update': | ||
| handleChatUpdate({ | ||
| userId: data.from, | ||
| agentId: data.agentId, | ||
| chatId: data.chatId, | ||
| title: data.title, | ||
| botClientId, | ||
| reliableEmitter, | ||
| log, | ||
| }); | ||
| break; | ||
| case 'delete': | ||
| handleChatDelete({ | ||
| userId: data.from, | ||
| agentId: data.agentId, | ||
| chatId: data.chatId, | ||
| botClientId, | ||
| reliableEmitter, | ||
| log, | ||
| }); | ||
| break; | ||
| } | ||
| }); | ||
| } | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 内部工具:从 PrivateMessageData / HistoryRequestData 中统一提取 chatId | ||
| // | ||
| // 抽离原因:原代码同一字段在 message:private(||)、setImmediate(??)、 | ||
| // history:request(?.trim())三处使用了三种不同写法,虽然在协议正常时结果一致, | ||
| // 但语义有偏差且日后极易踩坑。统一为 extractChatId 后: | ||
| // - 入参可能是 undefined / null / 非字符串 / 含空白字符串 / 合法字符串; | ||
| // - 一律 trim 后归一为字符串;undefined / null / 非字符串 → ''; | ||
| // - 返回值要么是 ''(默认对话标识),要么是非空合法 chatId。 | ||
| // 这样写入侧(chats.json[chatId].sessionIdHistory)与读取侧(按 chatId 查询) | ||
| // 始终拿到同一个值,杜绝因写法差异引起的"找不到 chat 条目"问题。 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| function extractChatId(data) { | ||
| const raw = data?.extra?.chatId; | ||
| if (typeof raw !== 'string') | ||
| return ''; | ||
| return raw.trim(); | ||
| } | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 内部工具:把当前 chat 在用的 sessionId 登记到 chats.json[chatId].sessionIdHistory | ||
| // | ||
| // 设计要点: | ||
| // 1. 解析 sessionKey —— 与 history:request 分支保持一致: | ||
| // agent:<agentId>:<channel>:direct:<userId>[:<chatId>] | ||
| // 若无 chatId 直接早返(无 chatId 的会话不在 chats.json 中登记)。 | ||
| // 2. 通过 agentId 合法性校验,避免前端伪造 agentId 污染他人 chats.json。 | ||
| // 3. 从 sessions.json 索引读出当前 sessionKey 对应的 sessionId | ||
| // —— 框架在派发消息前完成 mint/rotate,本时刻读到的就是"当前生效"的 sessionId。 | ||
| // 4. 调用幂等的 ensureSessionInHistory:已存在则跳过;新出现则末尾追加并落盘。 | ||
| // | ||
| // 调用时机: | ||
| // - EVENT_MESSAGE_PRIVATE:在 setImmediate 中异步调用,确保框架已建好/rotate 好 session; | ||
| // - EVENT_HISTORY_REQUEST:在 sessionKey 解析后同步调用,作为兜底登记入口。 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| function recordSessionInHistory(params) { | ||
| const { userId, agentId, chatId, accountId, log } = params; | ||
| // 取最新配置,复用 history 分支同款 agentId 校验逻辑(防止伪造 agentId) | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const currentCfgTyped = currentCfg; | ||
| const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID]; | ||
| const resolvedAgentId = agentId && validAgentIds.includes(agentId) ? agentId : DEFAULT_AGENT_ID; | ||
| // 计算 sessionKey(必须与 history:request 一致,否则查不到 sessions.json 条目) | ||
| const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({ | ||
| cfg: currentCfg, | ||
| channel: CHANNEL_KEY, | ||
| accountId, | ||
| peer: { kind: 'direct', id: userId }, | ||
| }); | ||
| const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| agentId: resolvedAgentId, | ||
| channel: CHANNEL_KEY, | ||
| accountId: baseRoute.accountId, | ||
| peer: { kind: 'direct', id: userId }, | ||
| dmScope: 'per-channel-peer', | ||
| }); | ||
| // 与 history:request 保持完全一致的 sessionKey 拼接规则: | ||
| // - 空 chatId → 不追加后缀,直接用 baseSessionKey(默认对话场景) | ||
| // - 非空 chatId → 追加 `:<chatId>` 后缀(多会话场景) | ||
| // 这是写入侧已建立的约定,框架对无 chatId 会话写到 baseSessionKey 上。 | ||
| const sessionKey = chatId ? `${baseSessionKey}:${chatId}` : baseSessionKey; | ||
| // 查 sessions.json 拿当前生效 sessionId;首条消息时可能查不到 → 跳过,下一条消息会补登记 | ||
| const store = loadSessionStore(resolvedAgentId); | ||
| const currentSessionId = store[sessionKey]?.sessionId; | ||
| if (!currentSessionId) { | ||
| log?.info(`[${CHANNEL_KEY}] recordSessionInHistory: sessionId not found yet, skip. sessionKey=${sessionKey}`); | ||
| return; | ||
| } | ||
| // 登记 sessionId 到 chats.json,确保历史记录能被查到;已存在则跳过,新增则末尾追加并落盘 | ||
| ensureSessionInHistory(resolvedAgentId, userId, chatId, currentSessionId); | ||
| } | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 文件下载处理器(kind=file:download, status=download_req) | ||
@@ -229,3 +466,6 @@ // | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error } }, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error }, | ||
| }, | ||
| }, msgId); | ||
@@ -265,2 +505,3 @@ log?.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`); | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { | ||
@@ -294,2 +535,3 @@ transferId, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { | ||
@@ -296,0 +538,0 @@ transferId, |
@@ -113,7 +113,7 @@ /** | ||
| if (files.length > 0) { | ||
| emitter.sendFiles(targetId, enrichedText, files, originalMsgId); | ||
| emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId); | ||
| emittedUserVisible = true; | ||
| } | ||
| else if (enrichedText.trim()) { | ||
| emitter.sendReply(targetId, enrichedText, originalMsgId); | ||
| emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId); | ||
| emittedUserVisible = true; | ||
@@ -260,3 +260,3 @@ } | ||
| `toolStart=${toolStartCount} assistantMsg=${assistantMessageCount}. Cause: ${cause}`); | ||
| emitter.sendReply(targetId, "NO_REPLY", originalMsgId); | ||
| emitter.sendReply(targetId, "NO_REPLY", originalMsgId, signalCtx.chatId); | ||
| } | ||
@@ -263,0 +263,0 @@ else { |
+153
-1
@@ -0,2 +1,5 @@ | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { X_PRODUCT } from '../config.js'; | ||
| import { resolveOpenClawHome } from '../history/session-store.js'; | ||
| /** | ||
@@ -56,3 +59,3 @@ * 将长文本按指定长度拆分为多个文本块。 | ||
| export function emitSignal(ctx, kind, content = '', extra) { | ||
| const { emitter, targetId, replyMsgId, originalMsgId, agentId } = ctx; | ||
| const { emitter, targetId, replyMsgId, originalMsgId, agentId, chatId } = ctx; | ||
| return emitter.emit({ | ||
@@ -68,3 +71,152 @@ msgId: replyMsgId, | ||
| agentId, | ||
| extra: { chatId: chatId ?? '' }, | ||
| }); | ||
| } | ||
| /** | ||
| * 构建 chats.json 的绝对路径: | ||
| * `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json` | ||
| */ | ||
| export function resolveChatsFilePath(agentId, userId) { | ||
| const home = resolveOpenClawHome(); | ||
| return path.join(home, 'agents', agentId, 'chats', userId, 'chats.json'); | ||
| } | ||
| /** | ||
| * 读取 chats.json 并返回 chats 列表(**纯只读**,无任何文件落盘副作用)。 | ||
| * | ||
| * - 文件不存在 → 返回空数组 | ||
| * - JSON 解析失败或结构不合法 → 返回空数组 | ||
| * - 合法记录按 `updatedAt` 倒序排序,未提供则按 `createdAt` 兜底 | ||
| * | ||
| */ | ||
| export function readChatsFile(filePath) { | ||
| if (!fs.existsSync(filePath)) | ||
| return []; | ||
| const raw = fs.readFileSync(filePath, 'utf-8'); | ||
| if (!raw.trim()) | ||
| return []; | ||
| const parsed = JSON.parse(raw); | ||
| if (!parsed || !Array.isArray(parsed.chats)) | ||
| return []; | ||
| // 过滤出字段合法的记录 | ||
| const valid = parsed.chats.filter((c) => !!c && | ||
| typeof c.chatId === 'string' && | ||
| typeof c.title === 'string' && | ||
| typeof c.createdAt === 'number' && | ||
| typeof c.updatedAt === 'number'); | ||
| // 按 updatedAt 倒序,便于前端直接渲染 | ||
| return valid.sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt)); | ||
| } | ||
| /** | ||
| * 读取 chats.json;若文件不存在则**兜底创建一条 chatId='' 的默认对话**并落盘。 | ||
| * | ||
| * 与 {@link readChatsFile} 的差异:本函数有写盘副作用,专供"必须保证存在默认对话 | ||
| * 兜底条目"的调用方(典型场景:sessionId 登记入口 ensureSessionInHistory)。 | ||
| * | ||
| * 拆分原因:原 readChatsFile 兼任只读与兜底创建两种语义,导致 history:request 等 | ||
| * 纯查询路径在用户尚未发任何消息前就把 chats.json 写到磁盘上,违反单一职责。 | ||
| * | ||
| * 行为: | ||
| * - 文件不存在 → 落盘 [{ chatId: '', title: '默认会话', ... }] 并返回; | ||
| * - 文件存在但解析失败 / 结构不合法 → 沿用 readChatsFile 的容错(返回 [],不创建); | ||
| * - 文件存在但内容合法 → 等价于 readChatsFile。 | ||
| */ | ||
| export function readChatsFileOrInitDefault(filePath) { | ||
| if (!fs.existsSync(filePath)) { | ||
| const now = Date.now(); | ||
| const defaultChat = { | ||
| chatId: '', | ||
| title: '默认会话', | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| titleLocked: false, | ||
| pinned: false, | ||
| sessionIdHistory: [], | ||
| }; | ||
| writeChatsFile(filePath, [defaultChat]); | ||
| return [defaultChat]; | ||
| } | ||
| return readChatsFile(filePath); | ||
| } | ||
| /** | ||
| * 将 chats 列表原子写入 `chats.json`。 | ||
| * | ||
| * 写入策略: | ||
| * 1. 目录不存在时逐级创建(recursive: true) | ||
| * 2. 先写入同目录下的 `.tmp` 文件,再 rename 覆盖目标, | ||
| * 避免写入过程中进程崩溃导致 json 文件损坏 | ||
| * 3. schema 固定 `version: 1`,为未来平滑升级预留字段 | ||
| * | ||
| * @param filePath 目标 chats.json 绝对路径 | ||
| * @param chats 完整的 ChatMeta 列表(调用方自行保证顺序与合法性) | ||
| */ | ||
| export function writeChatsFile(filePath, chats) { | ||
| const dir = path.dirname(filePath); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const payload = { version: 1, chats }; | ||
| const tmpPath = `${filePath}.tmp`; | ||
| fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf-8'); | ||
| fs.renameSync(tmpPath, filePath); | ||
| } | ||
| /** | ||
| * 把当前生效的 sessionId 幂等地登记到 `chats.json` 中对应 chatId 的 `sessionIdHistory` 数组。 | ||
| * | ||
| * 语义说明: | ||
| * - sessionIdHistory 记录该 chat 见过的所有 sessionId(含当前在用),按时间序末尾追加; | ||
| * - 同一 sessionId 若已存在则跳过(幂等,不会重复 push); | ||
| * - 列表最后一项 ≈ 该 chat 当前在用的 sessionId(前提是调用方在每次消息进入时都调用本函数)。 | ||
| * | ||
| * 关于 chatId 为空('')的处理: | ||
| * - 空 chatId 是**合法**的"默认对话"会话标识——`readChatsFile` 在 chats.json | ||
| * 不存在时会主动写入一条 `chatId: ''` 的兜底记录; | ||
| * - 因此本函数对空 chatId 同样登记,目标条目就是 `chatId === ''` 那一条; | ||
| * - 若 chats.json 文件不存在 → 通过 readChatsFile 自动创建兜底记录; | ||
| * - 若 chats.json 已存在但缺失目标 chatId 条目 → 跳过登记并告警(避免污染他人数据)。 | ||
| * | ||
| * 容错策略: | ||
| * - chatId 为 undefined/null → 跳过;空字符串 '' 视为合法(默认会话); | ||
| * - sessionId 为空 → 跳过; | ||
| * - 任意 IO/解析异常 → 静默吞错 + 日志,绝不抛出影响主流程。 | ||
| * | ||
| * @param agentId - Agent ID(chats/<agentId> 目录段) | ||
| * @param userId - 用户 ID(chats/<agentId>/chats/<userId> 目录段) | ||
| * @param chatId - 会话 ID(ChatMeta.chatId);undefined/null 时跳过;空字符串视为默认会话 | ||
| * @param sessionId - 当前生效的 sessionId(来自 sessions.json 索引);为空时直接 return | ||
| * @returns 是否实际写盘(true=本次新增了一个 sessionId;false=跳过 / 已存在 / 出错) | ||
| */ | ||
| export function ensureSessionInHistory(agentId, userId, chatId, sessionId) { | ||
| // chatId 必须是 string(含空字符串 '' —— 默认会话的合法标识);undefined/null 跳过 | ||
| if (typeof chatId !== 'string') | ||
| return false; | ||
| if (!sessionId || !sessionId.trim()) | ||
| return false; | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| try { | ||
| // 登记入口需要兜底:默认对话首次消息时 chats.json 尚不存在, | ||
| // 走 readChatsFileOrInitDefault 让其落盘一条 chatId='' 的默认对话, | ||
| // 然后正常走"查找+追加"流程。 | ||
| const chats = readChatsFileOrInitDefault(chatsPath); | ||
| const idx = chats.findIndex((c) => c.chatId === chatId); | ||
| if (idx < 0) { | ||
| return false; | ||
| } | ||
| const target = chats[idx]; | ||
| const history = Array.isArray(target.sessionIdHistory) ? target.sessionIdHistory : []; | ||
| if (history.includes(sessionId)) { | ||
| // 已存在,幂等跳过,不写盘 | ||
| return false; | ||
| } | ||
| // 末尾追加,写回。 | ||
| // 把默认对话/具名对话无端"置顶",扰乱前端按 updatedAt 倒序的列表排序。 | ||
| const updated = { | ||
| ...target, | ||
| sessionIdHistory: [...history, sessionId], | ||
| }; | ||
| const nextChats = [...chats]; | ||
| nextChats[idx] = updated; | ||
| writeChatsFile(chatsPath, nextChats); | ||
| return true; | ||
| } | ||
| catch (err) { | ||
| return false; | ||
| } | ||
| } |
@@ -7,2 +7,5 @@ /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */ | ||
| const { randomFillSync } = require('crypto'); | ||
| const { | ||
| types: { isUint8Array } | ||
| } = require('util'); | ||
@@ -204,4 +207,6 @@ const PerMessageDeflate = require('./permessage-deflate'); | ||
| buf.write(data, 2); | ||
| } else if (isUint8Array(data)) { | ||
| buf.set(data, 2); | ||
| } else { | ||
| buf.set(data, 2); | ||
| throw new TypeError('Second argument must be a string or a Uint8Array'); | ||
| } | ||
@@ -208,0 +213,0 @@ } |
| { | ||
| "name": "ws", | ||
| "version": "8.20.0", | ||
| "version": "8.20.1", | ||
| "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+1
-1
| { | ||
| "name": "lightclawbot", | ||
| "version": "1.2.3", | ||
| "version": "1.2.5", | ||
| "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging", | ||
@@ -5,0 +5,0 @@ "type": "module", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
451042
12.21%62
1.64%10557
9.46%