lightclawbot
Advanced tools
| /** | ||
| * @file 群聊事件常量定义 | ||
| * @description 定义群聊模块中所有的事件类型和对应的消息通道名称 | ||
| */ | ||
| /** 群组基础操作请求事件 */ | ||
| export const EVENT_GROUP_REQUEST = 'group:request'; | ||
| /** 群组基础操作响应事件 */ | ||
| export const EVENT_GROUP_RESPONSE = 'group:response'; | ||
| /** 群组成员管理请求事件 */ | ||
| export const EVENT_GROUP_MEMBER_REQUEST = 'group:member:request'; | ||
| /** 群组成员管理响应事件 */ | ||
| export const EVENT_GROUP_MEMBER_RESPONSE = 'group:member:response'; | ||
| /** 群组历史消息请求事件 */ | ||
| export const EVENT_GROUP_HISTORY_REQUEST = 'group:history:request'; | ||
| /** 群组历史消息响应事件 */ | ||
| export const EVENT_GROUP_HISTORY_RESPONSE = 'group:history:response'; | ||
| /** 群组操作中止请求事件 */ | ||
| export const EVENT_GROUP_ABORT_REQUEST = 'group:abort:request'; | ||
| /** 群组操作中止响应事件 */ | ||
| export const EVENT_GROUP_ABORT_RESPONSE = 'group:abort:response'; |
| /** | ||
| * @file 群聊消息入站处理器 | ||
| * @description | ||
| * 接收 Socket 私信消息,基于 extra.groupId 路由为群聊消息, | ||
| * 完成权限校验后写入账本并交由 Orchestrator 调度。 | ||
| * 所有依赖通过 {@link GroupInboundDeps} 注入,错误通过 {@link sendError} 回发。 | ||
| */ | ||
| import { generateMsgId } from '../../dedup.js'; | ||
| import { CHANNEL_KEY, MEDIA_MAX_BYTES, LOCALFILE_SCHEME, resolveEffectiveApiKey } from '../../config.js'; | ||
| import { normalizeMentions, guessMimeType } from '../utils/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| import { parseDataUrl, formatFileSize } from '../../media.js'; | ||
| import { downloadFileFromServer, uploadFileToServer, getFileDownloadUrl } from '../../file-storage.js'; | ||
| import { getLightclawRuntime } from '../../runtime.js'; | ||
| /** | ||
| * 工厂函数:基于注入的依赖创建一个群聊入站处理器。 | ||
| * | ||
| * @param deps 依赖注入对象,参见 {@link GroupInboundDeps} | ||
| * @returns 一个可直接挂载到 Socket 私信事件上的异步处理函数 | ||
| */ | ||
| export function createGroupInboundHandler(deps) { | ||
| const { emitter, storage, orchestrator } = deps; | ||
| const logger = getModuleLogger('group.inbound'); | ||
| /** | ||
| * 以群聊系统消息的形式向指定用户回发错误提示。 | ||
| * 内部捕获 emit 异常,确保错误回发本身不会再抛出影响主流程。 | ||
| * | ||
| * @param userId 目标用户 ID | ||
| * @param message 错误文案 | ||
| * @param extra 附加 extra 字段(通常包含 groupId) | ||
| * @param replyToMsgId 关联的原始消息 ID(用于在 UI 上展示回复关系) | ||
| */ | ||
| const sendError = (userId, message, extra, replyToMsgId) => { | ||
| // 错误回发统一带上 streamStatus: 'failed',便于前端流式状态机识别终态 | ||
| const finalExtra = { ...extra, streamStatus: 'failed' }; | ||
| try { | ||
| const ok = emitter.emit({ | ||
| msgId: generateMsgId(), | ||
| from: emitter.botClientId, | ||
| to: userId, | ||
| content: message, | ||
| timestamp: Date.now(), | ||
| replyToMsgId, | ||
| chatKind: 'group', | ||
| extra: finalExtra, | ||
| }); | ||
| if (!ok) { | ||
| logger.warn(`[${CHANNEL_KEY}] sendError 发送失败(socket 可能已断开)。to=${userId}`); | ||
| } | ||
| } | ||
| catch (err) { | ||
| logger.warn(`[${CHANNEL_KEY}] sendError 执行异常: ${err}`); | ||
| } | ||
| }; | ||
| /** | ||
| * 实际的消息处理函数。整体流程: | ||
| * 1. 从 extra 解析 groupId,校验合法性; | ||
| * 2. 读取群元数据 meta,校验群存在且用户为拥有者; | ||
| * 3. 规范化 mentions; | ||
| * 4. 将用户消息写入 Ledger; | ||
| * 5. 构造 DispatchContext 并交由 Orchestrator 异步调度。 | ||
| */ | ||
| return async function handle(data) { | ||
| const userId = data.from; | ||
| // 统一原始触发消息 ID:缺失时本地生成一个,使 conversationId / replyToMsgId / ctx.msgId 保持一致 | ||
| const triggerMsgId = data.msgId ?? generateMsgId(); | ||
| // 注意:data.extra 来源于客户端,字段类型不可信,统一以 unknown 承载,由下游做严格的运行时校验。 | ||
| const extraIn = (data.extra ?? {}); | ||
| const groupId = extraIn.groupId; | ||
| // 统一日志前缀 | ||
| const tag = `[${CHANNEL_KEY}][group=${groupId}][user=${userId}]`; | ||
| // —— 1. 入参校验:必须携带合法的 groupId —— | ||
| if (typeof groupId !== 'string' || !groupId.trim()) { | ||
| logger.warn(`[${tag}] 群消息缺少 groupId,无法处理。msgId=${triggerMsgId} from=${userId}`); | ||
| sendError(userId, '群消息缺少 groupId,无法处理', {}, triggerMsgId); | ||
| return; | ||
| } | ||
| // —— 2. 读取群元数据 —— | ||
| let meta; | ||
| try { | ||
| meta = await storage.readGroupMeta(groupId); | ||
| } | ||
| catch (err) { | ||
| logger.error(`${tag} 读取群元数据失败: ${err}`); | ||
| sendError(userId, '读取群信息失败,请稍后重试', { groupId }, triggerMsgId); | ||
| return; | ||
| } | ||
| // 群不存在/已解散 | ||
| if (!meta) { | ||
| logger.warn(`${tag} 群不存在或已解散`); | ||
| sendError(userId, '群不存在或已解散', { groupId }, triggerMsgId); | ||
| return; | ||
| } | ||
| // —— 3. 权限校验:仅群拥有者可在群内发言 —— | ||
| if (!meta.ownerIds.includes(userId)) { | ||
| logger.warn(`${tag} 你不是该群的拥有者,无法在此群发言`); | ||
| sendError(userId, '你不是该群的拥有者,无法在此群发言', { groupId }, triggerMsgId); | ||
| return; | ||
| } | ||
| // —— 4. 规范化业务数据 —— | ||
| const content = (data.content ?? '').trim(); | ||
| const memberIds = meta.members.map((m) => m.agentId); | ||
| const mentions = normalizeMentions(extraIn.mentions, memberIds); | ||
| // —— 4.5 处理文件附件(参考私聊 inbound.ts 的文件处理模式) —— | ||
| const rawFiles = data.files ?? []; | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| // 获取当前用户的 apiKey,用于从云端下载文件(需要认证) | ||
| const effectiveApiKey = resolveEffectiveApiKey({ senderId: userId }); | ||
| /** 群聊文件附件(与私聊 FileAttachment 对齐:name + mimeType + size + uri) */ | ||
| const groupFiles = []; | ||
| /** 传递给 AI 上下文的 Attachments(name + mimeType + url + localPath) */ | ||
| const ctxAttachments = []; | ||
| /** 本地媒体路径列表(供 SDK vision 模型直接读取文件内容) */ | ||
| const localMediaPaths = []; | ||
| /** 本地文件 MIME 类型列表,与 localMediaPaths 一一对应 */ | ||
| const localMediaTypes = []; | ||
| /** 附加到用户消息末尾的文件描述文本(告知 AI 用户发送了哪些文件) */ | ||
| let attachmentDescription = ''; | ||
| for (const file of rawFiles) { | ||
| // 前端发送 FileAttachment 格式:{ name, mimeType, size, uri, bytes } | ||
| const fileName = file.name || 'unknown'; | ||
| const fileMimeType = file.mimeType || guessMimeType(fileName); | ||
| const fileUri = file.uri || ''; | ||
| const fileSize = file.size ?? 0; | ||
| groupFiles.push({ name: fileName, mimeType: fileMimeType, size: fileSize, uri: fileUri || undefined }); | ||
| try { | ||
| let buffer; | ||
| let mimeType; | ||
| let uploadPublicUrl; | ||
| // 来源 1:data URL(base64 编码的文件内容) | ||
| const parsed = file.bytes ? parseDataUrl(file.bytes) : null; | ||
| if (parsed) { | ||
| buffer = parsed.buffer; | ||
| mimeType = parsed.mimeType; | ||
| } | ||
| else if (fileUri) { | ||
| // 来源 2:云端 URI(CDN 地址或文件路径) | ||
| logger.info(`${tag} 文件有 URI,从云端下载: ${fileUri}`); | ||
| const downloaded = await downloadFileFromServer(fileUri, { apiKey: effectiveApiKey }); | ||
| buffer = downloaded.buffer; | ||
| mimeType = fileMimeType; | ||
| uploadPublicUrl = fileUri.startsWith('http') ? fileUri : getFileDownloadUrl(fileUri); | ||
| } | ||
| else { | ||
| // 来源 3:既无 bytes 也无 uri,无法处理,跳过此文件 | ||
| logger.warn(`${tag} 文件无 bytes 或 uri: ${fileName},跳过`); | ||
| attachmentDescription += `\n用户发送了文件: ${fileName} (${formatFileSize(fileSize)})`; | ||
| continue; | ||
| } | ||
| // 保存到本地媒体存储(框架统一管理) | ||
| const saved = await pluginRuntime.channel.media.saveMediaBuffer(buffer, mimeType, 'inbound', MEDIA_MAX_BYTES, fileName); | ||
| localMediaPaths.push(saved.path); | ||
| localMediaTypes.push(mimeType); | ||
| const localPath = `${LOCALFILE_SCHEME}${saved.path}`; | ||
| if (uploadPublicUrl) { | ||
| // URI 来源:已有公网 URL,直接使用 | ||
| ctxAttachments.push({ name: fileName, mimeType, url: localPath, localPath: saved.path }); | ||
| } | ||
| else { | ||
| // data URL 来源:上传到 COS 获取公网 URL | ||
| try { | ||
| const uploadResult = await uploadFileToServer(saved.path, { apiKey: effectiveApiKey }); | ||
| ctxAttachments.push({ name: fileName, mimeType, url: localPath, localPath: saved.path }); | ||
| logger.info(`${tag} 文件已上传到 COS: ${saved.path} → ${uploadResult.url}`); | ||
| } | ||
| catch (uploadErr) { | ||
| logger.warn(`${tag} COS 上传失败,降级使用本地路径: ${uploadErr}`); | ||
| ctxAttachments.push({ name: fileName, mimeType, url: localPath, localPath: saved.path }); | ||
| } | ||
| } | ||
| logger.info(`${tag} 文件已保存: ${saved.path} (${mimeType}, ${formatFileSize(saved.size)})`); | ||
| } | ||
| catch (err) { | ||
| // 单个文件处理失败不影响整条消息的处理,记录错误后继续 | ||
| logger.error(`${tag} 文件处理失败 ${fileName}: ${err}`); | ||
| if (fileUri) { | ||
| // 降级:仅传 CDN URL | ||
| ctxAttachments.push({ name: fileName, mimeType: fileMimeType, url: fileUri }); | ||
| } | ||
| } | ||
| attachmentDescription += `\n用户发送了文件: ${fileName} (${formatFileSize(fileSize)})`; | ||
| logger.info(`${tag} 文件附件: ${fileName} (${fileMimeType}, ${formatFileSize(fileSize)}) uri=${fileUri ? '有' : '无'}`); | ||
| } | ||
| // 将文件描述追加到用户消息中(与私聊一致,让 AI 知道用户发送了哪些文件) | ||
| const userMessage = content + attachmentDescription; | ||
| // —— 5. 用户消息入账(Ledger)—— | ||
| try { | ||
| await orchestrator.ledger.appendUser({ | ||
| groupId, | ||
| userId, | ||
| content, | ||
| mentions, | ||
| files: groupFiles.length > 0 ? groupFiles : undefined, | ||
| msgId: triggerMsgId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| logger.error(`${tag} 用户消息入账失败: ${err}`); | ||
| sendError(userId, '消息入账失败,请稍后重试', { groupId }, triggerMsgId); | ||
| return; | ||
| } | ||
| // —— 6. 构造调度上下文并触发 dispatch —— | ||
| const conversationId = `conv:${groupId}:${triggerMsgId}`; | ||
| // 提前 ensure 以获取 abortSignal(dispatch 内部会幂等处理) | ||
| const abortSignal = orchestrator.runRegistry.ensureConversation(conversationId); | ||
| // 从 openclaw.json 配置中读取 agents.list,过滤掉不在配置中的成员 | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const configAgentIds = new Set((currentCfg.agents?.list ?? []).map((a) => a.id)); | ||
| // 仅保留在 openclaw.json agents 列表中存在的成员 | ||
| const validMembers = configAgentIds.size > 0 ? meta.members.filter((m) => configAgentIds.has(m.agentId)) : meta.members; | ||
| const ctx = { | ||
| conversationId, | ||
| groupId, | ||
| userId, | ||
| meta: { | ||
| groupId: meta.groupId, | ||
| members: validMembers, | ||
| defaultAgentId: meta.defaultAgentId, | ||
| ownerIds: meta.ownerIds, | ||
| }, | ||
| userMessage, | ||
| mentions, | ||
| abortSignal, | ||
| msgId: triggerMsgId, | ||
| files: ctxAttachments.length > 0 | ||
| ? ctxAttachments.map((a) => ({ | ||
| name: a.name, | ||
| mimeType: a.mimeType, | ||
| size: groupFiles.find((f) => f.name === a.name)?.size ?? 0, | ||
| uri: a.url, | ||
| localPath: a.localPath, | ||
| })) | ||
| : undefined, | ||
| mediaPaths: localMediaPaths.length > 0 ? localMediaPaths : undefined, | ||
| mediaTypes: localMediaTypes.length > 0 ? localMediaTypes : undefined, | ||
| effectiveApiKey, | ||
| }; | ||
| logger.info(`${tag} 开始调度 conv=${conversationId} mentions=[${mentions.join(',')}]`); | ||
| // dispatch 采用「即发即忘」模式,结果通过 .then 处理 | ||
| orchestrator.dispatch(ctx).then((result) => { | ||
| logger.info(`${tag} 调度完成 status=${result.finalStatus} route=${result.route}`); | ||
| }, (err) => { | ||
| logger.error(`${tag} 调度异常: ${err instanceof Error ? err.message : String(err)}`); | ||
| // 主动 dispose 防止注册表泄漏 | ||
| try { | ||
| orchestrator.runRegistry.disposeConversation(conversationId); | ||
| } | ||
| catch (cleanupErr) { | ||
| logger.warn(`${tag} 调度异常后清理会话失败: ${cleanupErr}`); | ||
| } | ||
| sendError(userId, '群消息处理异常,请稍后重试', { groupId }, triggerMsgId); | ||
| }); | ||
| }; | ||
| } |
| /** | ||
| * @file 群聊功能入口文件 | ||
| * @description 群聊模块的公共 API 出口,外部模块应从此文件导入所需功能 | ||
| */ | ||
| // ── 统一常量 ── | ||
| export { EVENT_GROUP_REQUEST, EVENT_GROUP_RESPONSE, EVENT_GROUP_MEMBER_REQUEST, EVENT_GROUP_MEMBER_RESPONSE, EVENT_GROUP_HISTORY_REQUEST, EVENT_GROUP_HISTORY_RESPONSE, EVENT_GROUP_ABORT_REQUEST, EVENT_GROUP_ABORT_RESPONSE, } from './constants/index.js'; | ||
| // ── 统一工具函数 ── | ||
| export { generatePreRunId, normalizeRunStatus, errorToString, } from './utils/index.js'; | ||
| // ── 存储层 ── | ||
| export { GroupStorageCore } from './storage/index.js'; | ||
| export { ConcurrencyManager } from './storage/concurrency-manager.js'; | ||
| // ── 调度器 ── | ||
| export { Orchestrator } from './orchestrator/orchestrator.js'; | ||
| // ── 群入站处理器 ── | ||
| export { createGroupInboundHandler } from './inbound/index.js'; |
| /** | ||
| * AgentRunner —— Agent 执行器(基于 dispatchReplyFromConfig 的实现) | ||
| * | ||
| * 历史背景: | ||
| * 早期实现直接调用 `runtime.subagent.run + waitForRun + events.onAgentEvent`, | ||
| * 该路径仅在「Gateway request 范围」内可用;当 channel plugin 在后台 socket | ||
| * 循环中(非 inbound 请求 scope)调用时,SDK 会抛 | ||
| * "Plugin runtime subagent methods are only available during a gateway request"。 | ||
| * | ||
| * 现行实现: | ||
| * 与私聊 inbound.ts 一致,统一走 | ||
| * `runtime.channel.reply.dispatchReplyFromConfig` + `withReplyDispatcher` | ||
| * 该路径由 SDK 自行管理 agent run 生命周期,无需手动操作 subagent。 | ||
| * | ||
| * 职责: | ||
| * 1. 拼装 sessionKey:`agent:<agentId>:<channel>:group:<groupId>` | ||
| * 2. 通过 `finalizeInboundContext` 构造群聊 InboundContext(ChatType: 'group') | ||
| * 3. 自定义 lightweight ReplyDispatcher: | ||
| * - sendBlockReply / sendFinalReply 用于聚合 finalText(也作为兜底来源) | ||
| * - sendToolResult 用于产 tool kind 的 StreamChunk | ||
| * 4. 自定义 replyOptions: | ||
| * - onPartialReply / onAssistantMessageStart / onToolStart 转 StreamChunk | ||
| * - onAgentRunStart 捕获真实 runId | ||
| * - abortSignal 透传给 SDK,原生级联中止 | ||
| * 5. 用 `withReplyDispatcher(dispatcher, run)` 包裹 dispatchReplyFromConfig, | ||
| * 自动处理 markComplete / 异常兜底 | ||
| * 6. 错误兜底:SDK 抛错 / 超时 / abort 都转换成结构化 status 返回,不再外抛 | ||
| * | ||
| * 设计要点: | ||
| * - 不写群账本:调用方拿到 finalText 后由路径层决定是否写 | ||
| * - 不感知出站协议:完全交给注入的 StreamSink | ||
| * - 不依赖 runtime.subagent / runtime.events,规避 gateway-request scope 限制 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../../config.js'; | ||
| import { getLightclawRuntime } from '../../../runtime.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| /** | ||
| * Agent 执行器 | ||
| */ | ||
| export class AgentRunner { | ||
| accountId; | ||
| log; | ||
| constructor(options) { | ||
| this.accountId = options.accountId; | ||
| this.log = getModuleLogger('group.agent-runner'); | ||
| } | ||
| /** 获取 PluginRuntime 实例(延迟获取,确保运行时已初始化) */ | ||
| get runtime() { | ||
| return getLightclawRuntime(); | ||
| } | ||
| /** | ||
| * 拼装群聊场景下的 sessionKey | ||
| * | ||
| * 与 `src/inbound.ts` 中私聊形态保持一致的命名风格: | ||
| * 私聊:agent:<agentId>:<channel>:direct:<userId> | ||
| * 群聊:agent:<agentId>:<channel>:group:<groupId> | ||
| * | ||
| * @param agentId - Agent ID | ||
| * @param groupId - 群 ID | ||
| */ | ||
| static buildGroupSessionKey(agentId, groupId) { | ||
| return `agent:${agentId}:${CHANNEL_KEY}:group:${groupId}`; | ||
| } | ||
| /** | ||
| * 执行一次 Agent run | ||
| * | ||
| * @param input - 执行入参 | ||
| * @returns run 终态聚合结果 | ||
| */ | ||
| async run(input) { | ||
| const sessionKey = AgentRunner.buildGroupSessionKey(input.agentId, input.groupId); | ||
| const tag = `[${CHANNEL_KEY}: AgentRunner ${input.role} ${input.agentId}@${input.groupId}]`; | ||
| const preRunId = input.preassignedRunId ?? ''; | ||
| // ── 1. 已 abort 直接短路 ── | ||
| if (input.abortSignal.aborted) { | ||
| this.log.info(`${tag} abort before start`); | ||
| const empty = { | ||
| runId: preRunId, | ||
| agentId: input.agentId, | ||
| status: 'aborted', | ||
| finalText: '', | ||
| }; | ||
| // 群聊场景:通过 sinkContext 发出 abort 终态帧 | ||
| if (input.sinkContext) { | ||
| input.sinkContext.dispatcher.markComplete({ aborted: true }); | ||
| } | ||
| return empty; | ||
| } | ||
| // ── 2. 装配 dispatcher 与 replyOptions ── | ||
| // 优先使用外部注入的 sinkContext(由路径层通过 createStreamReplyConfig 构造) | ||
| // 若未注入则内部构造(兼容旧调用方式,但群聊场景下应始终注入) | ||
| const sinkContext = input.sinkContext ?? this.createInternalSinkContext(input, tag); | ||
| const { dispatcher, replyOptions } = sinkContext; | ||
| // ── 3. 构造 InboundContext(群聊形态) ── | ||
| const fromAddress = `user:${input.userId}`; | ||
| // 构造媒体字段:SDK 的媒体理解管线依赖 MediaPaths/MediaUrls/MediaTypes 来处理文件 | ||
| // (与私聊 inbound.ts 保持一致,确保 vision 模型能"看到"图片等媒体文件) | ||
| const mediaFields = {}; | ||
| if (input.attachments && input.attachments.length > 0) { | ||
| const mediaUrls = input.attachments.map((a) => a.url); | ||
| const mediaTypes = input.attachments.map((a) => a.mimeType); | ||
| // 本地路径:SDK vision 模型优先通过 MediaPaths 读取本地文件内容 | ||
| const mediaPaths = input.attachments.map((a) => a.localPath).filter((p) => !!p); | ||
| if (mediaPaths.length > 0) { | ||
| mediaFields.MediaPaths = mediaPaths; | ||
| mediaFields.MediaPath = mediaPaths[0]; | ||
| } | ||
| mediaFields.MediaUrls = mediaUrls; | ||
| mediaFields.MediaUrl = mediaUrls[0]; | ||
| mediaFields.MediaTypes = mediaTypes; | ||
| mediaFields.MediaType = mediaTypes[0]; | ||
| } | ||
| const ctxPayload = this.runtime.channel.reply.finalizeInboundContext({ | ||
| Body: input.message, | ||
| BodyForAgent: input.message, | ||
| RawBody: input.message, | ||
| CommandBody: input.message, | ||
| From: fromAddress, | ||
| To: `${CHANNEL_KEY}:${this.accountId}`, | ||
| Channel: CHANNEL_KEY, | ||
| ChatType: 'group', | ||
| SessionKey: sessionKey, | ||
| AccountId: this.accountId, | ||
| SenderId: input.userId, | ||
| SenderName: input.userId, | ||
| Provider: CHANNEL_KEY, | ||
| Surface: CHANNEL_KEY, | ||
| Timestamp: Date.now(), | ||
| OriginatingChannel: CHANNEL_KEY, | ||
| OriginatingTo: fromAddress, | ||
| AgentId: input.agentId, | ||
| // 群聊上下文额外标识:便于工具层按 groupId 还原会话 | ||
| GroupId: input.groupId, | ||
| ConversationKind: 'group', | ||
| // 文件附件元数据:传递给 AI 上下文 | ||
| Attachments: input.attachments && input.attachments.length > 0 ? input.attachments : undefined, | ||
| // 媒体字段:SDK 媒体理解管线所需(MediaUrls/MediaTypes),使 AI 能真正"看到"文件内容 | ||
| ...mediaFields, | ||
| // 群协作指令 + 群成员能力卡作为 GroupSystemPrompt 注入, | ||
| // SDK 会将其作为 extraSystemPromptParts 的一部分追加到 agent 的 system prompt 中, | ||
| // 确保 LLM 将派活指令视为系统级指令而非用户消息。 | ||
| GroupSystemPrompt: input.systemPrompt || undefined, | ||
| }); | ||
| // ── 4. 加载最新配置并执行 dispatch ── | ||
| const cfg = this.runtime.config.loadConfig(); | ||
| let timedOut = false; | ||
| let timer; | ||
| // 构造 configOverride: | ||
| // - tools.deny: 禁用指定的内置工具(如 sessions_spawn) | ||
| // - tools.agentToAgent: 启用跨 Agent 通信权限(群聊派活场景必需) | ||
| // - responseFormat: 强制 JSON 输出(Planning 阶段使用) | ||
| const hasDeny = input.denyTools && input.denyTools.length > 0; | ||
| const hasA2A = input.enableAgentToAgent; | ||
| const hasJson = input.jsonMode; | ||
| const configOverride = hasDeny || hasA2A || hasJson | ||
| ? { | ||
| tools: { | ||
| ...(hasDeny ? { deny: input.denyTools } : {}), | ||
| ...(hasA2A ? { agentToAgent: { enabled: true } } : {}), | ||
| }, | ||
| ...(hasJson ? { responseFormat: { type: 'json_object' } } : {}), | ||
| } | ||
| : undefined; | ||
| try { | ||
| await this.runtime.channel.reply.withReplyDispatcher({ | ||
| dispatcher, | ||
| run: () => { | ||
| const dispatchPromise = this.runtime.channel.reply.dispatchReplyFromConfig({ | ||
| ctx: ctxPayload, | ||
| cfg, | ||
| dispatcher, | ||
| replyOptions, | ||
| configOverride, | ||
| }); | ||
| if (input.timeoutMs && input.timeoutMs > 0) { | ||
| return Promise.race([ | ||
| dispatchPromise, | ||
| new Promise((_, reject) => { | ||
| timer = setTimeout(() => { | ||
| timedOut = true; | ||
| reject(new Error(`dispatchReplyFromConfig timeout after ${input.timeoutMs}ms`)); | ||
| }, input.timeoutMs); | ||
| }), | ||
| ]); | ||
| } | ||
| return dispatchPromise; | ||
| }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| this.log.error(`${tag} dispatch failed: ${e.message}`); | ||
| // dispatch 异常时通过 markComplete 通知 sink 终态 | ||
| dispatcher.markComplete({ aborted: timedOut || input.abortSignal.aborted }); | ||
| } | ||
| finally { | ||
| if (timer) | ||
| clearTimeout(timer); | ||
| } | ||
| // ── 5. 归一化 status ── | ||
| // 从 sinkContext 获取最终文本(通过 hasEmittedContent 判断是否有输出) | ||
| const realRunId = sinkContext.getRunId() || preRunId; | ||
| const aborted = input.abortSignal.aborted; | ||
| const normalizedStatus = aborted ? 'aborted' : timedOut ? 'timeout' : 'ok'; | ||
| // 获取最终文本:从 sinkContext 获取聚合后的完整文本 | ||
| // markComplete 已经在 withReplyDispatcher 内部被 SDK 调用过了(正常路径) | ||
| const finalText = sinkContext.getFinalText(); | ||
| const result = { | ||
| runId: realRunId, | ||
| agentId: input.agentId, | ||
| status: normalizedStatus, | ||
| finalText, | ||
| error: timedOut ? new Error(`timeout after ${input.timeoutMs}ms`) : undefined, | ||
| }; | ||
| this.log.info(`${tag} finished runId=${realRunId} status=${normalizedStatus} bytes=${finalText.length}`); | ||
| return result; | ||
| } | ||
| /** | ||
| * 内部构造 SinkContext(兼容旧调用方式,不推荐) | ||
| * | ||
| * 当调用方未注入 sinkContext 时使用此方法构造一个最小化的 context。 | ||
| * 群聊场景下应始终由路径层注入外部构造的 sinkContext。 | ||
| */ | ||
| createInternalSinkContext(input, tag) { | ||
| const log = this.log; | ||
| // 内部状态 | ||
| let streamedText = ''; | ||
| let finalText = ''; | ||
| let lastFinalReplyText = ''; | ||
| let abortDetected = false; | ||
| let completed = false; | ||
| const dispatcher = { | ||
| sendToolResult: (_payload) => { | ||
| return true; | ||
| }, | ||
| sendBlockReply: (payload) => { | ||
| if (payload.text && payload.text.length > streamedText.length) { | ||
| streamedText = payload.text; | ||
| } | ||
| return true; | ||
| }, | ||
| sendFinalReply: (payload) => { | ||
| const text = payload.text ?? ''; | ||
| if (/^⚙️ Agent was aborted\./.test(text.trim())) { | ||
| abortDetected = true; | ||
| return true; | ||
| } | ||
| if (/Previous run is still shutting down/i.test(text)) { | ||
| log.warn(`${tag} SDK session lock conflict detected, suppressing: ${text.slice(0, 100)}`); | ||
| abortDetected = true; | ||
| return true; | ||
| } | ||
| if (text.length > 0) { | ||
| lastFinalReplyText = text; | ||
| } | ||
| return true; | ||
| }, | ||
| waitForIdle: async () => { | ||
| /* no-op */ | ||
| }, | ||
| getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), | ||
| getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }), | ||
| markComplete: () => { | ||
| completed = true; | ||
| }, | ||
| }; | ||
| const replyOptions = { | ||
| runId: input.preassignedRunId, | ||
| abortSignal: input.abortSignal, | ||
| onPartialReply: (payload) => { | ||
| if (!payload.text) | ||
| return; | ||
| const delta = payload.text.startsWith(streamedText) ? payload.text.slice(streamedText.length) : payload.text; | ||
| streamedText = payload.text.startsWith(streamedText) ? payload.text : streamedText + delta; | ||
| }, | ||
| onAssistantMessageStart: () => { | ||
| if (streamedText) { | ||
| finalText += (finalText ? '\n' : '') + streamedText; | ||
| streamedText = ''; | ||
| } | ||
| }, | ||
| suppressTyping: true, | ||
| disableBlockStreaming: true, | ||
| }; | ||
| return { | ||
| dispatcher, | ||
| replyOptions, | ||
| hasEmittedContent: () => streamedText.length > 0 || finalText.length > 0, | ||
| emitStart: () => { | ||
| /* no-op for internal sink */ | ||
| }, | ||
| getRunId: () => input.preassignedRunId, | ||
| getFinalText: () => { | ||
| const aggregated = finalText && streamedText ? `${finalText}\n${streamedText}` : finalText || streamedText; | ||
| return aggregated || lastFinalReplyText; | ||
| }, | ||
| }; | ||
| } | ||
| } |
| /** | ||
| * @file execution 子模块出口 | ||
| * @description 统一导出 AgentRunner / SoulResolver / PromptBuilder 及其类型 | ||
| */ | ||
| export { AgentRunner } from './agent-runner.js'; | ||
| export { SoulResolver } from './soul-resolver.js'; | ||
| export { PromptBuilder } from './prompt-builder.js'; |
| /** | ||
| * PromptBuilder —— 三段式 system prompt 构造器 | ||
| * | ||
| * 主 Agent 在群聊场景下扮演两种形态、子 Agent 一种形态,对应三个构造方法: | ||
| * | ||
| * 1. buildLeader 自答阶段:本体 SOUL(由 SDK 注入)+ 群协作扩展指令 + 群成员能力卡 | ||
| * | ||
| * 2. buildSummarizer 汇总阶段:本体 SOUL(由 SDK 注入)+ 子任务结果列表 | ||
| * | ||
| * 3. buildChild 子 Agent:本体 SOUL(由 SDK 注入)+ 任务上下文 | ||
| * | ||
| * 设计要点: | ||
| * 1. 本体 SOUL 由 OpenClaw SDK 自动加载(`~/.openclaw/agents/<agentId>/SOUL.md`), | ||
| * 这里只产出 * 这里只产出"额外注入"的部分,通过 SubagentRunParams.extraSystemPrompt 传给 SDK; | ||
| * 2. 群成员能力卡来自群账本 meta.json 中的 GroupMember 数据; | ||
| * 3. 模板与"主Agent设计.md §2.2 / §2.3"中文案保持一致; | ||
| * 4. 纯函数:不做 IO,便于单测。 | ||
| */ | ||
| /** | ||
| * PromptBuilder 主体(无状态,方法纯函数) | ||
| */ | ||
| export class PromptBuilder { | ||
| /** | ||
| * 构造主 Agent 自答阶段的额外 system prompt(提供群成员信息上下文) | ||
| */ | ||
| buildLeader(input) { | ||
| const members = this.buildMemberCards(input.memberCards, input.leaderAgentId); | ||
| return `--- | ||
| **场景设定** | ||
| 在团队协作类群聊环境中,作为群内沟通协调的核心角色,负责引导和管理群内成员的交流流程、整合多方意见并推动问题解决,适用于团队项目讨论、工作事务沟通等多成员参与的实时互动场景。 | ||
| **AI角色** | ||
| 群聊协作管理专家(领航员),具备沟通引导、信息整合、问题分析与解决方案输出的专业能力,以协助团队成员高效完成协作任务。 | ||
| **核心任务** | ||
| 1. 接收并准确理解群内成员提出的问题、需求或请求; | ||
| 2. 调用group_chat_history工具查询群内历史对话内容及其他成员的回复信息; | ||
| 3. 基于历史对话和当前需求,提供针对性协作方案、沟通技巧或决策建议; | ||
| 4. 以流式输出形式持续回应,保持对话连贯性与指导性。 | ||
| **输出要求** | ||
| - 格式:自然语言文本 | ||
| - 风格:专业、友好、指导性强,符合商务/团队协作沟通语境 | ||
| - 长度:无严格字数限制,根据问题复杂度灵活调整,确保信息完整且不过于冗长 | ||
| - 关键要素:包含问题回应、历史参考信息(如适用)、协作方案建议 | ||
| **约束条件** | ||
| ⚠️ 排除事项:不提供与群聊协作无关的内容;不泄露群成员隐私信息;不发表具有争议性或违法导向的观点;仅基于群内合理场景提供指导。敏感词过滤:不涉及政治敏感、人身攻击、负面情绪宣泄等内容。数据来源限制:仅依托群内历史对话记录和合理逻辑推导提供信息,不虚构群外未发生的事件或信息。 | ||
| **群成员信息** | ||
| ${members}`; | ||
| } | ||
| /** | ||
| * 构造 Planning 阶段的 system prompt(新方案:强制 JSON 输出决策) | ||
| * | ||
| * 该 prompt 要求 LLM 输出结构化 JSON,二选一: | ||
| * - { action: "self_answer" } → 自己直接回答 | ||
| * - { action: "dispatch", ... } → 拆分派活 | ||
| */ | ||
| buildPlanner(input) { | ||
| const members = this.buildMemberCards(input.memberCards, input.leaderAgentId); | ||
| return `--- | ||
| ## 场景设定 | ||
| 在一个多Agent协作的群聊环境中,你作为特定群组的领航员(对应 ${input.leaderAgentId} 角色),需要接收用户发送的消息并判断是否由自身直接回应或协调群内其他Agent协作完成任务,属于团队协作流程中的任务分配与决策环节。 | ||
| ## AI角色 | ||
| 团队协作流程中的群组领航型智能助手(任务决策与分配角色),具备分析任务复杂度、判断协作需求的专业能力。 | ||
| ## 核心任务 | ||
| 以群组领航员的身份,分析用户发送的消息内容,依据任务复杂度与群成员能力匹配度,从“自己直接回答”和“拆分派活”两种方案中选择其一,若选择拆分派活,需明确拆分逻辑、过渡话表述与子任务分配,确保符合协作约束规则。 | ||
| ## 输出要求 | ||
| - 格式:JSON | ||
| - 风格:专业、简洁、指令性 | ||
| - 长度:单条JSON对象,不超过200字符(含必要字段) | ||
| - 关键要素:包含action、thinking、transition、tasks等必要字段(若选拆分派活),或仅包含action字段(若选自己回答)。 | ||
| - 你必须且只能输出一个 JSON 对象,不要输出任何其他文字、markdown 标记或代码块包裹。 | ||
| ## 输出格式 | ||
| ### 决策一:自己回答 | ||
| {"action":"self_answer"} | ||
| ### 决策二:拆分派活 | ||
| { | ||
| "action": "dispatch", | ||
| "thinking": "简要说明你的拆分思路(1-2句话),需说明任务间是否有先后依赖", | ||
| "transition": "给用户看的过渡话,需要用 @agentName 提及对应的 Agent,例如:好的,我让 @设计师 负责logo,@程序员 负责页面开发。 | ||
| "tasks": [ | ||
| { | ||
| "id": "t1", | ||
| "to": "agentId_A", | ||
| "task": "第一个任务描述(无依赖,最先执行)", | ||
| "dependsOn": [] | ||
| }, | ||
| { | ||
| "id": "t2", | ||
| "to": "agentId_B", | ||
| "task": "第二个任务描述(需要t1的产出物才能完成)", | ||
| "dependsOn": ["t1"] | ||
| } | ||
| ] | ||
| } | ||
| ## 拆活约束 | ||
| - \`to\` 必须是下方「群成员」列表中的 agentId,不能是你自己(${input.leaderAgentId}) | ||
| - 最多拆 5 个子任务,能合并的合并 | ||
| - 不可循环依赖 | ||
| - \`task\` 描述要完整自洽,子 Agent 不会看到用户原始消息的全部上下文 | ||
| - \`transition\` 过渡话中必须用 @name 格式提及每个被派活的 Agent(使用群成员的 name 而非 agentId),让用户清楚谁在做什么 | ||
| ## dependsOn 依赖判断规则 | ||
| 判断任务之间是否存在依赖关系,核心原则:**如果任务 B 需要使用任务 A 的产出物(如文件、设计稿、数据、代码等)才能正确完成,则 B 必须依赖 A**。 | ||
| ### 必须设置 dependsOn 的场景: | ||
| - 设计/创作类任务产出素材 → 开发/集成类任务使用该素材(如:设计logo → 把logo写到页面) | ||
| - 数据生成任务 → 数据消费任务(如:爬取数据 → 分析数据) | ||
| - 基础架构搭建 → 基于该架构的功能开发(如:搭建项目框架 → 开发具体功能模块) | ||
| ### 可以并行(dependsOn 留空 [])的场景: | ||
| - 各任务之间相互独立,不需要对方的产出物(如:写文案 + 设计图标,两者互不依赖) | ||
| - 任务虽然属于同一项目,但各自可以独立完成(如:开发登录页 + 开发注册页) | ||
| ### 示例: | ||
| 用户说"设计一个logo并写到网页中": | ||
| - t1: 设计师设计logo → dependsOn: [] | ||
| - t2: 程序员把logo写到网页 → dependsOn: ["t1"](因为程序员需要设计师产出的logo文件) | ||
| ## 决策要点 | ||
| - 若群内无成员具备匹配该任务的能力,则执行self_answer; | ||
| - 若任务仅需单一领域知识且自身能够胜任,则执行self_answer; | ||
| - 若任务涉及多个领域或明确需要特定专业Agent的能力支持,则执行dispatch; | ||
| - 若明确要求"大家"、"所有人"、"每个人"各自完成某事(如自我介绍、各自发表意见),则执行dispatch,让每个成员独立回答; | ||
| - 若任务可拆分成更小子任务,则尝试将任务拆分为合理子任务后再分配; | ||
| ## 群成员信息 | ||
| ${members} | ||
| --- | ||
| [用户消息] | ||
| ${input.userMessage.trim()}`; | ||
| } | ||
| /** | ||
| * 构造主 Agent 汇总阶段的额外 system prompt | ||
| */ | ||
| buildSummarizer(input) { | ||
| return `--- | ||
| ## 场景设定 | ||
| 在多群体聊协作项目完成后,各群成员已独立完成子任务,现处于汇总答复阶段,需要将各成员任务成果整合为统一最终答复提供给用户的场景。 | ||
| ## AI角色 | ||
| 团队协作汇总型专业助手,具备信息整合、内容提炼与自然语言沟通能力的协作支持角色,专注于完成从分散成果到完整答复的专业转化工作。 | ||
| ## 核心任务 | ||
| 接收各群成员提交的所有子任务成果,执行信息整合与提炼操作,以自然语言生成完整最终答复;确保涵盖所有关键信息、核心结论与要点,同时处理可能存在的失败子任务情况(诚实告知并提供建议);严格遵守汇总答复要求,仅输出整合后的最终答复,不涉及任务分配或决策类表述。 | ||
| ## 输出要求 | ||
| - 格式:Markdown | ||
| - 风格:正式自然,逻辑连贯 | ||
| - 长度:约50-200字 | ||
| - 关键要素:各任务成果总结、核心结论、关键要点、后续建议(若有失败任务)、自然语言表达 | ||
| ## 约束条件 | ||
| - 严格遵守禁止项:不输出JSON格式; | ||
| - 不逐字复述群员输出,需做信息整合;确保紧扣用户原始诉求,不凭空发挥; | ||
| - 若存在失败的子任务,需诚实告知用户并提出可行后续建议。 | ||
| ## 用户原始诉求 | ||
| ${input.userMessage.trim()} | ||
| ## 群成员任务执行结果 | ||
| ${this.formatSubtaskResults(input.subtaskResults)}`; | ||
| } | ||
| /** | ||
| * 构造创建群后主Agent欢迎消息的额外 system prompt | ||
| * | ||
| * @remarks | ||
| * 创建群成功后,主Agent需要根据群目标和成员能力,生成一条欢迎消息, | ||
| * 包含:欢迎词 + 群目标总结 + 开始方向建议。 | ||
| */ | ||
| buildWelcome(input) { | ||
| const members = this.buildMemberCards(input.memberCards, input.leaderAgentId); | ||
| return `--- | ||
| ## 场景设定 | ||
| 你是一个多 Agent 协作群的领航员(主 Agent),群刚刚被创建成功。你需要发送一条欢迎消息,向创建者介绍这个群的目标和如何开始。 | ||
| ## AI角色 | ||
| 群聊协作领航员,负责引导团队开展工作、明确目标方向、提供建议。 | ||
| ## 核心任务 | ||
| 根据群的名称和目标描述,生成一条热情、专业的欢迎消息,内容包括: | ||
| 1. 欢迎来到「群名称」 | ||
| 2. 简要总结群的终极目标是什么 | ||
| 3. 询问创建者当前的想法或方向(如风格、技术栈、优先级等) | ||
| 4. 提供 2-3 个建议的开始方向,让创建者可以快速上手 | ||
| 5. 提及群规则(如可以随时 @ 成员) | ||
| ## 输出要求 | ||
| - 格式:Markdown | ||
| - 风格:热情、友好、专业,像一个经验丰富的团队协作者 | ||
| - 长度:100-200字 | ||
| - 语气:自然随和,不要过于正式 | ||
| ## 约束条件 | ||
| - 不要输出 JSON 格式 | ||
| - 不要派活或分配任务 | ||
| - 不要编造群成员的能力,仅基于下方提供的成员信息 | ||
| - **提及成员时必须使用名称(如 @程序员、@设计师),绝不要使用 agentId(如 @agent-xxx)** | ||
| ## 群信息 | ||
| - 群名称:${input.groupName} | ||
| - 群目标:${input.groupDesc} | ||
| ${members}`; | ||
| } | ||
| /** | ||
| * 构造子 Agent 任务阶段的额外 system prompt | ||
| */ | ||
| buildChild(input) { | ||
| const userContext = input.userMessage?.trim() | ||
| ? `\n## 用户原始诉求(仅供参考)\n\n${input.userMessage.trim()}\n` | ||
| : ''; | ||
| return `--- | ||
| [群聊协作 - 子任务模式] | ||
| 你正在群聊中作为群成员协作。领航员(${input.leaderAgentId})把以下子任务派给了你: | ||
| ## 你的任务 | ||
| ${input.taskDescription.trim()} | ||
| ## 必须遵守 | ||
| - ✅ 专注完成上述任务,输出可被领航员直接整合的结果。 | ||
| - ✅ 输出要简洁、结构化,避免大段寒暄。 | ||
| - ❌ **你不能再次派活给其他 Agent**(最多 1 层嵌套)。 | ||
| - ❌ 不要替领航员给用户做最终答复,那是领航员的职责。 | ||
| ${userContext} | ||
| ## 可用工具提示 | ||
| - 如果你需要了解群聊中之前的对话内容或其他成员的回复,可以调用 \`group_chat_history\` 工具查询历史记录。`; | ||
| } | ||
| /** | ||
| * 群成员能力卡片块 | ||
| */ | ||
| buildMemberCards(cards, leaderAgentId) { | ||
| const others = cards.filter((c) => c.agentId !== leaderAgentId); | ||
| if (others.length === 0) { | ||
| return `--- | ||
| [群成员(不含你自己)] | ||
| (当前群里没有其他可派活成员,请直接独立完成任务。)`; | ||
| } | ||
| const memberList = others | ||
| .map((c) => { | ||
| const summary = c.summary.replace(/\n+/g, ' ').trim(); | ||
| return `- **${c.name}** (agentId: ${c.agentId})\n ${summary}`; | ||
| }) | ||
| .join('\n\n'); | ||
| return `--- | ||
| [群成员(不含你自己)] | ||
| ${memberList} | ||
| > 注意:提及成员时请使用 @名称(如 @${others[0].name}),不要使用 @agentId`; | ||
| } | ||
| /** | ||
| * 子任务结果列表序列化 | ||
| */ | ||
| formatSubtaskResults(results) { | ||
| if (results.length === 0) { | ||
| return '(无子任务结果)'; | ||
| } | ||
| const blocks = results.map((r, idx) => { | ||
| const ok = r.status === 'done'; | ||
| const head = `### 子任务 ${idx + 1} - ${r.taskId} (${r.agentId}) - ${ok ? '✅ 成功' : `❌ ${r.status}`}`; | ||
| const body = ok ? (r.output ?? '(无输出)').trim() : `失败原因:${(r.errorMessage ?? '未知错误').trim()}`; | ||
| return [head, '', body].join('\n'); | ||
| }); | ||
| return blocks.join('\n\n'); | ||
| } | ||
| } |
| /** | ||
| * @file SoulResolver —— 群成员能力解析器 | ||
| * | ||
| * 职责: | ||
| * - 从群账本 meta.json 中的 GroupMember 数据直接构建 AgentCapabilityCard | ||
| * - 不涉及任何文件 IO,能力信息完全来源于群元数据 | ||
| * - 输出 AgentCapabilityCard,供 PromptBuilder 拼接能力卡片块 | ||
| */ | ||
| /** | ||
| * 群成员能力解析器 | ||
| * | ||
| * 将 GroupMember(群账本元数据)映射为 AgentCapabilityCard(能力卡片), | ||
| * 供 PromptBuilder 在构建 Leader 编排 prompt 时使用。 | ||
| */ | ||
| export class SoulResolver { | ||
| /** | ||
| * 解析单个群成员为能力卡片 | ||
| * | ||
| * @param member - 群成员信息(来自 meta.json 的 GroupMember) | ||
| * @returns 对应的 AgentCapabilityCard | ||
| */ | ||
| resolve(member) { | ||
| return { | ||
| agentId: member.agentId, | ||
| name: member.agentName || member.agentId, | ||
| summary: member.agentDesc || '', | ||
| }; | ||
| } | ||
| /** | ||
| * 批量解析群成员列表为能力卡片数组(保证顺序与输入一致) | ||
| * | ||
| * @param members - GroupMember 列表(来自 meta.json) | ||
| * @returns AgentCapabilityCard 数组 | ||
| */ | ||
| resolveMany(members) { | ||
| return members.map((m) => this.resolve(m)); | ||
| } | ||
| } |
| /** | ||
| * 执行层(execution)公共类型 | ||
| * | ||
| * 收敛 AgentRunner / SoulResolver / PromptBuilder | ||
| * 之间共享的接口形状,避免循环依赖。 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file Orchestrator 模块出口 | ||
| * @description 统一导出调度器相关的类型、基础设施层、执行层、路径层和门面 | ||
| */ | ||
| // ── 公共工具函数(从统一工具层转发) ── | ||
| export { generatePreRunId, normalizeRunStatus, errorToString, } from '../utils/index.js'; | ||
| // ── 基础设施层(lifecycle) ── | ||
| export { ConversationStateMachine, RunRegistry, LedgerWriter, } from './lifecycle/index.js'; | ||
| // ── 执行层(execution) ── | ||
| export { AgentRunner, SoulResolver, PromptBuilder, } from './execution/index.js'; | ||
| // ── 路径层(routes) ── | ||
| export { LeaderOrchestrationRoute, } from './routes/index.js'; | ||
| // ── 调度器门面 ── | ||
| export { Orchestrator } from './orchestrator.js'; |
| /** | ||
| * 回合状态机(ConversationStateMachine) | ||
| * | ||
| * 单次回合(一次"用户发问 → 系统回完所有内容")对应一个状态机实例。 | ||
| * 由 Orchestrator 在 dispatch() 入口创建,贯穿三种路径(A/B/C)和 abort/失败兜底。 | ||
| * | ||
| * 状态转移表(与 types.ts ConversationStatus 注释一致): | ||
| * | ||
| * PENDING → ROUTING | ||
| * ROUTING → RUNNING_LEADER | RUNNING_CHILDREN | ||
| * RUNNING_LEADER → DONE | PLANNING | ABORTED | FAILED | ||
| * PLANNING → RUNNING_CHILDREN | FAILED | ||
| * RUNNING_CHILDREN → DONE | SUMMARIZING | ABORTED | FAILED | ||
| * SUMMARIZING → DONE | ABORTED | FAILED | ||
| * DONE / ABORTED / FAILED 终态,不可再转移 | ||
| * | ||
| * 设计要点: | ||
| * 1. 非法转移立刻抛错,避免静默错乱; | ||
| * 2. 状态变更可订阅(onChange),方便接日志 / metrics; | ||
| * 3. 提供 isTerminal() / isRunning() 等便捷判断。 | ||
| */ | ||
| /** | ||
| * 合法状态转移表(key → 允许进入的状态集合) | ||
| */ | ||
| const TRANSITIONS = { | ||
| PENDING: ['ROUTING', 'ABORTED', 'FAILED'], | ||
| ROUTING: ['RUNNING_LEADER', 'RUNNING_CHILDREN', 'ABORTED', 'FAILED'], | ||
| RUNNING_LEADER: ['DONE', 'PLANNING', 'ABORTED', 'FAILED'], | ||
| PLANNING: ['RUNNING_CHILDREN', 'ABORTED', 'FAILED'], | ||
| RUNNING_CHILDREN: ['DONE', 'SUMMARIZING', 'ABORTED', 'FAILED'], | ||
| SUMMARIZING: ['DONE', 'ABORTED', 'FAILED'], | ||
| DONE: [], | ||
| ABORTED: [], | ||
| FAILED: [], | ||
| }; | ||
| /** | ||
| * 终态集合 | ||
| */ | ||
| const TERMINAL_STATES = new Set([ | ||
| 'DONE', | ||
| 'ABORTED', | ||
| 'FAILED', | ||
| ]); | ||
| /** | ||
| * 运行中状态集合(未进入终态、且已离开 PENDING/ROUTING) | ||
| */ | ||
| const RUNNING_STATES = new Set([ | ||
| 'RUNNING_LEADER', | ||
| 'PLANNING', | ||
| 'RUNNING_CHILDREN', | ||
| 'SUMMARIZING', | ||
| ]); | ||
| /** | ||
| * 回合状态机 | ||
| */ | ||
| export class ConversationStateMachine { | ||
| /** 关联的 conversationId */ | ||
| conversationId; | ||
| /** 当前状态 */ | ||
| current; | ||
| /** 状态变更监听器列表 */ | ||
| listeners = []; | ||
| /** | ||
| * 构造一个新的状态机 | ||
| * | ||
| * @param conversationId - 回合唯一 ID | ||
| * @param initial - 初始状态,默认为 PENDING | ||
| */ | ||
| constructor(conversationId, initial = 'PENDING') { | ||
| this.conversationId = conversationId; | ||
| this.current = initial; | ||
| } | ||
| /** | ||
| * 获取当前状态 | ||
| */ | ||
| getStatus() { | ||
| return this.current; | ||
| } | ||
| /** | ||
| * 是否处于终态 | ||
| */ | ||
| isTerminal() { | ||
| return TERMINAL_STATES.has(this.current); | ||
| } | ||
| /** | ||
| * 是否处于运行中状态 | ||
| */ | ||
| isRunning() { | ||
| return RUNNING_STATES.has(this.current); | ||
| } | ||
| /** | ||
| * 判断从当前状态能否转移到目标状态 | ||
| * | ||
| * @param next - 目标状态 | ||
| */ | ||
| canTransitionTo(next) { | ||
| return TRANSITIONS[this.current].includes(next); | ||
| } | ||
| /** | ||
| * 执行状态转移 | ||
| * | ||
| * @param next - 目标状态 | ||
| * @throws 当目标状态不在合法转移表中时抛错 | ||
| */ | ||
| transitionTo(next) { | ||
| if (this.current === next) { | ||
| // 同状态自循环视为 no-op,避免上层重复调用时报错 | ||
| return; | ||
| } | ||
| if (!this.canTransitionTo(next)) { | ||
| throw new Error(`[Orchestrator] 非法状态转移: ${this.current} → ${next} (conversationId=${this.conversationId})`); | ||
| } | ||
| const prev = this.current; | ||
| this.current = next; | ||
| this.notify(next, prev); | ||
| } | ||
| /** | ||
| * 强制转移到终态(ABORTED / FAILED) | ||
| * 用于级联 abort 或全局兜底,绕过普通转移表 | ||
| * | ||
| * @param next - 必须是终态 | ||
| * @throws 当 next 不是终态时抛错 | ||
| */ | ||
| forceTerminal(next) { | ||
| if (this.current === next) | ||
| return; | ||
| if (this.isTerminal()) { | ||
| // 已在终态,幂等返回 | ||
| return; | ||
| } | ||
| const prev = this.current; | ||
| this.current = next; | ||
| this.notify(next, prev); | ||
| } | ||
| /** | ||
| * 注册状态变更监听器 | ||
| * | ||
| * @param listener - 回调 | ||
| * @returns 取消订阅函数 | ||
| */ | ||
| onChange(listener) { | ||
| this.listeners.push(listener); | ||
| return () => { | ||
| this.listeners = this.listeners.filter((l) => l !== listener); | ||
| }; | ||
| } | ||
| /** | ||
| * 内部:通知所有监听器 | ||
| */ | ||
| notify(next, prev) { | ||
| for (const listener of this.listeners) { | ||
| try { | ||
| listener(next, prev); | ||
| } | ||
| catch (err) { | ||
| // 监听器异常不影响状态机本身 | ||
| // 后续接入统一日志后改为 logger.warn | ||
| console.warn('[ConversationStateMachine] listener error:', err); | ||
| } | ||
| } | ||
| } | ||
| } |
| /** | ||
| * lifecycle 子模块出口 | ||
| * 统一导出回合状态机、Run 注册表、群账本写入器 | ||
| */ | ||
| export { ConversationStateMachine } from './conversation-state.js'; | ||
| export { RunRegistry } from './run-registry.js'; | ||
| export { LedgerWriter } from './ledger-writer.js'; |
| /** | ||
| * LedgerWriter —— 群账本写入器 | ||
| * | ||
| * 调度器内 **唯一** 写群账本的入口。 | ||
| * 内部复用 `GroupStorageCore.appendTranscriptEntry`(已带分片 + 锁), | ||
| * 对外暴露三类语义化 API: | ||
| * | ||
| * - appendUser(...) 写一条 role=user 条目 | ||
| * - appendAssistant(...) 写一条 role=assistant 条目 | ||
| * - appendSystem(...) 写一条 role=system 条目 | ||
| * | ||
| * 设计要点: | ||
| * 1. 不直接调用 fs,所有 IO 通过 GroupStorageCore; | ||
| * 2. 屏蔽底层 TranscriptEntry 字段拼装细节,调用方传业务参数即可; | ||
| * 3. 写入失败抛错,由上层(Orchestrator)决定降级策略。 | ||
| */ | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| /** | ||
| * 群账本写入器 | ||
| */ | ||
| export class LedgerWriter { | ||
| storage; | ||
| /** | ||
| * @param storage - 群存储核心实例(建议外部单例传入) | ||
| */ | ||
| constructor(storage) { | ||
| this.storage = storage; | ||
| } | ||
| /** | ||
| * 追加用户消息条目 | ||
| * | ||
| * @param input - 用户消息入参 | ||
| */ | ||
| async appendUser(input) { | ||
| const { groupId, userId, content, mentions, files, msgId } = input; | ||
| await this.storage.appendTranscriptEntry(groupId, { | ||
| role: 'user', | ||
| userId, | ||
| content, | ||
| // 仅在显式提供时写入,避免给历史数据兜出 undefined 字段 | ||
| ...(msgId ? { msgId } : {}), | ||
| // 文件附件放在顶层(与前端 TranscriptEntry 对齐) | ||
| ...(files && files.length > 0 ? { files } : {}), | ||
| extra: this.buildExtra({ mentions }), | ||
| }); | ||
| } | ||
| /** | ||
| * 追加 Agent 回复条目 | ||
| * | ||
| * @param input - Agent 回复入参 | ||
| */ | ||
| async appendAssistant(input) { | ||
| const { groupId, agentId, content, runId, parentRunId = null } = input; | ||
| await this.storage.appendTranscriptEntry(groupId, { | ||
| role: 'assistant', | ||
| agentId, | ||
| content, | ||
| runId, | ||
| parentRunId, | ||
| // 统一去重锰点:assistant 条目以 runId 作为 msgId, | ||
| // 使前端在「流式收尾本地追加 vs 后端历史回放」之间可用同一 key 去重。 | ||
| msgId: runId, | ||
| }); | ||
| } | ||
| /** | ||
| * 追加系统事件条目 | ||
| * | ||
| * @param input - 系统事件入参 | ||
| */ | ||
| async appendSystem(input) { | ||
| const { groupId, event, agentId, userId, runId, content, members } = input; | ||
| await this.storage.appendTranscriptEntry(groupId, { | ||
| role: 'system', | ||
| event, | ||
| agentId, | ||
| userId, | ||
| runId, | ||
| content, | ||
| // 统一去重锚点:加 'sys:' 前缀避免与 assistant 条目(msgId=runId)冲突, | ||
| // 否则前端 mergeEntries 按 msgId 去重时会误将 system 条目当作 assistant 的重复而跳过。 | ||
| // 缺省时本地生成一个唯一 ID,保证账本中的所有条目都带 msgId。 | ||
| msgId: runId ? `sys:${runId}` : generateMsgId(), | ||
| extra: members && members.length > 0 ? { members } : undefined, | ||
| }); | ||
| } | ||
| /** | ||
| * 内部:构造 extra 字段,过滤空值 | ||
| */ | ||
| buildExtra(input) { | ||
| const extra = {}; | ||
| if (input.mentions && input.mentions.length > 0) { | ||
| extra.mentions = input.mentions; | ||
| } | ||
| return Object.keys(extra).length > 0 ? extra : undefined; | ||
| } | ||
| } |
| /** | ||
| * RunRegistry —— Run 注册表 / 级联 Abort 控制器 | ||
| * | ||
| * 调度器维护的核心数据结构之一: | ||
| * Map<conversationId, Map<runId, AbortController>> | ||
| * | ||
| * 用途: | ||
| * 1. 注册某次回合下启动的所有 run(leader / mention / child / summarizer); | ||
| * 2. 用户点停止时,按 conversationId 一次性级联 abort 整棵 run 树; | ||
| * 3. 单 run 异常时,定向 abort 单条 run; | ||
| * 4. 提供 conversation 级 AbortSignal,供调度器直接传给 dispatch 流程。 | ||
| * | ||
| * 设计要点: | ||
| * - 完全内存态,不做持久化; | ||
| * - 所有写操作做幂等处理,重复 register / abort 不抛错; | ||
| * - abortConversation 返回实际被中止的 runId 列表,方便接 group:abort:response。 | ||
| */ | ||
| /** | ||
| * Run 注册表(单例由 Orchestrator 持有) | ||
| */ | ||
| export class RunRegistry { | ||
| /** conversationId → 桶 */ | ||
| conversations = new Map(); | ||
| /** | ||
| * 创建(或复用)一个回合桶,返回其级联 abort 信号 | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| */ | ||
| ensureConversation(conversationId) { | ||
| let bucket = this.conversations.get(conversationId); | ||
| if (!bucket) { | ||
| bucket = { | ||
| rootController: new AbortController(), | ||
| runs: new Map(), | ||
| }; | ||
| this.conversations.set(conversationId, bucket); | ||
| } | ||
| return bucket.rootController.signal; | ||
| } | ||
| /** | ||
| * 注册一次具体的 Agent 执行 | ||
| * 自动监听回合级 abort,触发后立即 abort 本 run | ||
| * | ||
| * @param conversationId - 所属回合 | ||
| * @param runId - run 唯一 ID | ||
| * @param agentId - 执行 Agent | ||
| * @returns 该 run 的 AbortSignal(传给 subagent.run) | ||
| */ | ||
| registerRun(conversationId, runId, agentId) { | ||
| const bucket = this.getOrCreateBucket(conversationId); | ||
| // 幂等:重复注册同 runId 直接返回原 signal | ||
| const existed = bucket.runs.get(runId); | ||
| if (existed) { | ||
| return existed.controller.signal; | ||
| } | ||
| const controller = new AbortController(); | ||
| const registered = { | ||
| runId, | ||
| agentId, | ||
| controller, | ||
| aborted: false, | ||
| }; | ||
| bucket.runs.set(runId, registered); | ||
| // 回合级 abort 触发时,级联 abort 本 run | ||
| const onRootAbort = () => { | ||
| if (!registered.aborted) { | ||
| registered.aborted = true; | ||
| controller.abort(); | ||
| } | ||
| }; | ||
| if (bucket.rootController.signal.aborted) { | ||
| onRootAbort(); | ||
| } | ||
| else { | ||
| bucket.rootController.signal.addEventListener('abort', onRootAbort, { once: true }); | ||
| } | ||
| return controller.signal; | ||
| } | ||
| /** | ||
| * 解除注册(run 正常结束 / 失败时调用,避免泄漏) | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| * @param runId - run ID | ||
| */ | ||
| unregisterRun(conversationId, runId) { | ||
| const bucket = this.conversations.get(conversationId); | ||
| if (!bucket) | ||
| return; | ||
| bucket.runs.delete(runId); | ||
| } | ||
| /** | ||
| * 中止单条 run(不影响同回合其他 run) | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| * @param runId - run ID | ||
| * @returns 是否真的执行了 abort(false 表示不存在或已 abort) | ||
| */ | ||
| abortRun(conversationId, runId) { | ||
| const bucket = this.conversations.get(conversationId); | ||
| if (!bucket) | ||
| return false; | ||
| const run = bucket.runs.get(runId); | ||
| if (!run || run.aborted) | ||
| return false; | ||
| run.aborted = true; | ||
| run.controller.abort(); | ||
| return true; | ||
| } | ||
| /** | ||
| * 级联中止整个回合 | ||
| * 触发 rootController.abort(),所有已注册 run 自动级联 abort | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| * @returns 实际被中止的 runId 列表(已 abort 的不计入) | ||
| */ | ||
| abortConversation(conversationId) { | ||
| const bucket = this.conversations.get(conversationId); | ||
| if (!bucket) | ||
| return []; | ||
| const aborted = []; | ||
| // 先触发回合级 abort,让 onRootAbort 监听器统一处理 | ||
| if (!bucket.rootController.signal.aborted) { | ||
| bucket.rootController.abort(); | ||
| } | ||
| // 兜底遍历:确保所有 run 都被标记并收集 ID | ||
| for (const run of bucket.runs.values()) { | ||
| if (!run.aborted) { | ||
| run.aborted = true; | ||
| run.controller.abort(); | ||
| } | ||
| aborted.push(run.runId); | ||
| } | ||
| return aborted; | ||
| } | ||
| /** | ||
| * 清理已完成的回合(应在终态后调用,避免内存泄漏) | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| */ | ||
| disposeConversation(conversationId) { | ||
| this.conversations.delete(conversationId); | ||
| } | ||
| /** | ||
| * 获取某回合下所有 runId(调试 / metrics 用) | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| */ | ||
| listRunIds(conversationId) { | ||
| const bucket = this.conversations.get(conversationId); | ||
| if (!bucket) | ||
| return []; | ||
| return Array.from(bucket.runs.keys()); | ||
| } | ||
| /** | ||
| * 当前在跑的回合数(监控用) | ||
| */ | ||
| size() { | ||
| return this.conversations.size; | ||
| } | ||
| /** | ||
| * 内部:取桶;不存在时创建 | ||
| */ | ||
| getOrCreateBucket(conversationId) { | ||
| let bucket = this.conversations.get(conversationId); | ||
| if (!bucket) { | ||
| bucket = { | ||
| rootController: new AbortController(), | ||
| runs: new Map(), | ||
| }; | ||
| this.conversations.set(conversationId, bucket); | ||
| } | ||
| return bucket; | ||
| } | ||
| } |
| /** | ||
| * @file 群聊调度器(Orchestrator) | ||
| * @description | ||
| * 群聊消息处理的总入口,负责: | ||
| * 1. 根据用户消息中的 mentions 信息选择执行路径(Leader 决策 / Mention 并发) | ||
| * 2. 装配并注入各子模块依赖(AgentRunner、SoulResolver、PromptBuilder 等) | ||
| * 3. 管理会话生命周期(注册、abort、清理) | ||
| * 4. 发布调度过程中的关键事件(用于 metrics / 日志追踪) | ||
| * | ||
| * 设计原则: | ||
| * - 不写账本:账本写入由上游 inbound 层完成 | ||
| * - 不解析 mentions:mentions 规范化由 inbound 层完成 | ||
| * - 不直接调用 SDK:Agent 执行委托给 AgentRunner | ||
| * - 单例模式:每个 gateway / account 维护一份 Orchestrator 实例 | ||
| */ | ||
| import { ConversationStateMachine } from './lifecycle/conversation-state.js'; | ||
| import { RunRegistry } from './lifecycle/run-registry.js'; | ||
| import { LedgerWriter } from './lifecycle/ledger-writer.js'; | ||
| import { AgentRunner } from './execution/agent-runner.js'; | ||
| import { SoulResolver } from './execution/soul-resolver.js'; | ||
| import { PromptBuilder } from './execution/prompt-builder.js'; | ||
| import { LeaderOrchestrationRoute } from './routes/leader-orchestration-route.js'; | ||
| import { MentionConcurrentRoute } from './routes/mention-concurrent-route.js'; | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** | ||
| * 群聊调度器主体 | ||
| * | ||
| * @remarks | ||
| * 每个 gateway / account 维护一份实例。 | ||
| * 职责边界:路径选择 + 子模块装配 + 生命周期管理 + 事件发布。 | ||
| * 具体的 Agent 执行逻辑委托给 Route 实现。 | ||
| * | ||
| * 路径策略: | ||
| * - mentions 非空 → MentionConcurrentRoute(并发调用被 @ 的 Agent) | ||
| * - mentions 为空 → LeaderOrchestrationRoute(默认 Agent 自决直答或拆活派发) | ||
| */ | ||
| export class Orchestrator { | ||
| /** Run 注册表:管理所有活跃会话的 AbortController,支持级联 abort */ | ||
| runRegistry; | ||
| /** 群账本写入器:负责将 Agent 回复写入持久化账本 */ | ||
| ledger; | ||
| /** 路径共享依赖集合:所有 Route 实例共享的基础设施引用 */ | ||
| routeDeps; | ||
| /** Leader 决策路径:默认 Agent 自决直答或拆活派发给其他成员 */ | ||
| leaderRoute; | ||
| /** Mention 并发路径:用户 @ 了特定成员时,并发调用被 @ 的 Agent */ | ||
| mentionRoute; | ||
| /** 模块日志器 */ | ||
| log; | ||
| /** 事件订阅回调(可选) */ | ||
| onEvent; | ||
| /** | ||
| * 构造 Orchestrator 实例 | ||
| * | ||
| * @param options - 构造参数,包含 accountId、storage、emitter 等依赖 | ||
| * | ||
| * @remarks | ||
| * 构造过程中会初始化以下子模块: | ||
| * - RunRegistry:会话级 abort 管理 | ||
| * - LedgerWriter:账本写入 | ||
| * - AgentRunner:Agent 执行器(内部通过 getLightclawRuntime() 获取 runtime) | ||
| * - SoulResolver:成员身份/能力卡解析 | ||
| * - PromptBuilder:系统提示词构建 | ||
| * - LeaderOrchestrationRoute / MentionConcurrentRoute:两条执行路径 | ||
| */ | ||
| constructor(options) { | ||
| const { accountId, storage, emitter, onEvent } = options; | ||
| this.log = getModuleLogger('group.orchestrator'); | ||
| this.onEvent = onEvent; | ||
| // ── 基础设施层 ── | ||
| this.runRegistry = new RunRegistry(); | ||
| // 群账本写入器:负责将 Agent 回复写入持久化账本 | ||
| this.ledger = new LedgerWriter(storage); | ||
| // ── 执行层 ── | ||
| // AgentRunner 内部通过 getLightclawRuntime() 获取 PluginRuntime,无需外部传入 | ||
| const runner = new AgentRunner({ accountId }); | ||
| // SoulResolver:根据 agentId 解析成员的能力描述(soul card) | ||
| const soulResolver = new SoulResolver(); | ||
| // PromptBuilder:基于群元数据和成员能力卡构建系统提示词 | ||
| const promptBuilder = new PromptBuilder(); | ||
| // ── 路径共享 deps:所有 Route 实例共享的依赖集合 ── | ||
| this.routeDeps = { | ||
| runner, | ||
| soulResolver, | ||
| promptBuilder, | ||
| runRegistry: this.runRegistry, | ||
| ledger: this.ledger, | ||
| emitter, | ||
| }; | ||
| // Leader 决策路径:默认 Agent 自决直答或拆活派发给其他成员 | ||
| this.leaderRoute = new LeaderOrchestrationRoute(this.routeDeps); | ||
| // Mention 并发路径:用户 @ 了特定成员时,调用被 @ 的 Agent | ||
| this.mentionRoute = new MentionConcurrentRoute(this.routeDeps); | ||
| } | ||
| /** | ||
| * 调度入口:处理一次完整的群聊回合 | ||
| * | ||
| * @param ctx - 调度上下文,包含 conversationId、groupId、用户消息、mentions 等 | ||
| * @returns 路径执行结果,包含最终状态、各 run 信息和可能的错误 | ||
| * | ||
| * @remarks | ||
| * 执行流程: | ||
| * 1. 创建会话状态机,在 RunRegistry 中注册会话 | ||
| * 2. 发布 conversation_started 事件 | ||
| * 3. 根据 mentions 选择路径(Leader / Mention) | ||
| * 4. 执行选中的路径,获取结果 | ||
| * 5. 异常兜底:路径执行失败时标记 FAILED 状态 | ||
| * 6. 收尾:从 RunRegistry 中清理会话,发布终态事件 | ||
| */ | ||
| async dispatch(ctx) { | ||
| const tag = `[${CHANNEL_KEY}][conv=${ctx.conversationId}][group=${ctx.groupId}]`; | ||
| // 创建会话状态机(跟踪 IDLE → RUNNING → DONE/FAILED/ABORTED 状态流转) | ||
| const state = new ConversationStateMachine(ctx.conversationId); | ||
| // 在注册表中确保会话存在(幂等操作,返回 AbortSignal) | ||
| this.runRegistry.ensureConversation(ctx.conversationId); | ||
| this.emitEvent({ | ||
| type: 'conversation_started', | ||
| conversationId: ctx.conversationId, | ||
| ts: Date.now(), | ||
| }); | ||
| // ── 路径选择:根据 mentions 决定走哪条路径 ── | ||
| const route = this.selectRoute(ctx); | ||
| this.emitEvent({ | ||
| type: 'route_decided', | ||
| conversationId: ctx.conversationId, | ||
| ts: Date.now(), | ||
| payload: { route, mentions: ctx.mentions }, | ||
| }); | ||
| this.log.info(`${tag} route=${route} mentions=[${ctx.mentions.join(',')}]`); | ||
| // ── 推送 thinking_started 帧:通知前端 Agent 已进入思考阶段 ── | ||
| // 前端收到后可精确展示"哪个 Agent 在思考",替代纯前端的猜测逻辑 | ||
| this.emitThinkingStarted(ctx, route); | ||
| let result; | ||
| try { | ||
| switch (route) { | ||
| case 'leader_orchestration': | ||
| // Leader 路径:默认 Agent 先规划,再决定直答或派活 | ||
| result = await this.leaderRoute.run({ ctx, state }); | ||
| break; | ||
| case 'mention_concurrent': | ||
| // Mention 路径:并发调用所有被 @ 的 Agent | ||
| result = await this.mentionRoute.run({ ctx, state }); | ||
| break; | ||
| } | ||
| } | ||
| catch (err) { | ||
| // 兜底:路径内部未捕获的异常统一标记为 FAILED | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| this.log.error(`${tag} 路径执行失败: ${e.message}`); | ||
| state.forceTerminal('FAILED'); | ||
| result = { route, finalStatus: 'FAILED', runs: [], error: e }; | ||
| } | ||
| // 清理已完成的会话,释放资源 | ||
| this.runRegistry.disposeConversation(ctx.conversationId); | ||
| // 发布 conversation_finished / conversation_aborted / conversation_failed 事件 | ||
| this.emitFinishEvent(ctx, result); | ||
| return result; | ||
| } | ||
| /** | ||
| * 级联中止指定会话的所有 Agent run | ||
| * | ||
| * @param conversationId - 要中止的会话 ID | ||
| * @returns 实际被中止的 runId 列表 | ||
| */ | ||
| abort(conversationId) { | ||
| return this.runRegistry.abortConversation(conversationId); | ||
| } | ||
| /** | ||
| * 路径选择策略 | ||
| * | ||
| * @param ctx - 调度上下文 | ||
| * @returns 选中的路径标识 | ||
| * | ||
| * @remarks | ||
| * 当前策略: | ||
| * - mentions 非空 → mention_concurrent(用户明确指定了目标 Agent) | ||
| * - mentions 为空 → leader_orchestration(由默认 Agent 自主决策) | ||
| */ | ||
| selectRoute(ctx) { | ||
| return ctx.mentions.length > 0 ? 'mention_concurrent' : 'leader_orchestration'; | ||
| } | ||
| /** | ||
| * 安全发布事件 | ||
| * | ||
| * @param event - 要发布的调度事件 | ||
| * | ||
| * @remarks | ||
| * 监听器异常会被捕获并记录警告日志,不会影响主调度流程。 | ||
| */ | ||
| emitEvent(event) { | ||
| if (!this.onEvent) | ||
| return; | ||
| try { | ||
| this.onEvent(event); | ||
| } | ||
| catch (err) { | ||
| this.log.warn?.(`[${CHANNEL_KEY}][Orchestrator] 事件监听器异常: ${err}`); | ||
| } | ||
| } | ||
| /** | ||
| * 向前端推送 thinking_started 帧 | ||
| * | ||
| * @remarks | ||
| * 在路径选择完成后、实际 Agent 执行前推送,通知前端进入"思考中"阶段。 | ||
| * 前端收到后可精确展示对应 Agent 的思考动画,替代纯前端的猜测逻辑。 | ||
| * | ||
| * 帧格式遵循 message:private 协议,streamStatus='thinking', | ||
| * 前端 useGroupChat 中识别此状态后更新 thinking 状态和 agentId。 | ||
| * | ||
| * @param ctx - 调度上下文 | ||
| * @param route - 选中的路径标识 | ||
| */ | ||
| emitThinkingStarted(ctx, route) { | ||
| try { | ||
| // 确定思考中的 agentId: | ||
| // - leader_orchestration 路径:使用 defaultAgentId | ||
| // - mention_concurrent 路径:使用第一个被 @ 的 agentId | ||
| const agentId = route === 'leader_orchestration' ? ctx.meta.defaultAgentId : ctx.mentions[0] || ctx.meta.defaultAgentId; | ||
| this.routeDeps.emitter.emit({ | ||
| msgId: `thinking:${ctx.conversationId}`, | ||
| from: this.routeDeps.emitter.botClientId, | ||
| to: ctx.userId, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| chatKind: 'group', | ||
| extra: { | ||
| groupId: ctx.groupId, | ||
| agentId, | ||
| conversationId: ctx.conversationId, | ||
| streamStatus: 'thinking', | ||
| }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| // thinking 帧推送失败不影响主流程,仅记录警告 | ||
| this.log.warn?.(`[${CHANNEL_KEY}][Orchestrator] thinking_started 推送失败: ${err}`); | ||
| } | ||
| } | ||
| /** | ||
| * 根据路径执行结果发布终态事件 | ||
| * | ||
| * @param ctx - 调度上下文 | ||
| * @param result - 路径执行结果 | ||
| * | ||
| * @remarks | ||
| * 终态映射: | ||
| * - DONE → conversation_finished | ||
| * - ABORTED → conversation_aborted | ||
| * - 其他 → conversation_failed | ||
| */ | ||
| emitFinishEvent(ctx, result) { | ||
| const type = result.finalStatus === 'DONE' | ||
| ? 'conversation_finished' | ||
| : result.finalStatus === 'ABORTED' | ||
| ? 'conversation_aborted' | ||
| : 'conversation_failed'; | ||
| this.emitEvent({ | ||
| type, | ||
| conversationId: ctx.conversationId, | ||
| ts: Date.now(), | ||
| payload: { route: result.route, runCount: result.runs.length, error: result.error?.message }, | ||
| }); | ||
| } | ||
| } |
| /** | ||
| * planning 模块统一出口 | ||
| * | ||
| * 主 Agent 拆活相关: | ||
| * - PlanValidator(plan 合法性校验) | ||
| * - SubtaskExecutor(子任务 DAG 执行) | ||
| * - SummarizerRunner(汇总阶段执行) | ||
| * - PlanningParser(Planning 决策解析) | ||
| */ | ||
| export { validatePlan, } from './plan-validator.js'; | ||
| export { SubtaskExecutor, } from './subtask-executor.js'; | ||
| export { SummarizerRunner, } from './summarizer-runner.js'; | ||
| export { parsePlanningDecision, extractPlanFromDecision, } from './planning-parser.js'; |
| /** | ||
| * LightClaw — Plan 校验器 | ||
| * | ||
| * 对主 Agent 产出的拆分计划做硬约束校验: | ||
| * 1. 总数 1 ≤ N ≤ maxTasks; | ||
| * 2. 每个 task 的 id 非空且唯一; | ||
| * 3. to 必须 ∈ 群成员且不能是主 Agent 自己; | ||
| * 4. dependsOn 引用合法且无环; | ||
| * 5. task 描述非空。 | ||
| * | ||
| * 校验通过返回标准化后的 plan,失败返回错误列表。 | ||
| */ | ||
| /** | ||
| * 校验并标准化 Plan | ||
| * | ||
| * @param raw - 原始 plan 数据 | ||
| * @param vctx - 校验上下文 | ||
| */ | ||
| export function validatePlan(raw, vctx) { | ||
| const maxTasks = vctx.maxTasks ?? 5; | ||
| // ── 顶层结构校验 ── | ||
| const structErr = validateStructure(raw, maxTasks); | ||
| if (structErr) { | ||
| return { ok: false, errors: [structErr] }; | ||
| } | ||
| const tasksRaw = raw.tasks; | ||
| const memberSet = new Set(vctx.members); | ||
| const idSet = new Set(); | ||
| const errors = []; | ||
| const normalized = []; | ||
| // ── 逐项字段校验 + 标准化 ── | ||
| for (let i = 0; i < tasksRaw.length; i++) { | ||
| const result = validateTaskItem(tasksRaw[i], i, memberSet, idSet, vctx.leaderAgentId); | ||
| errors.push(...result.errors); | ||
| if (result.item) { | ||
| normalized.push(result.item); | ||
| } | ||
| } | ||
| if (errors.length > 0) { | ||
| return { ok: false, errors }; | ||
| } | ||
| // ── dependsOn 引用合法性 ── | ||
| for (const item of normalized) { | ||
| if (!item.dependsOn) | ||
| continue; | ||
| for (const dep of item.dependsOn) { | ||
| if (dep === item.id) { | ||
| errors.push(`任务 "${item.id}" 不能依赖自身`); | ||
| } | ||
| else if (!idSet.has(dep)) { | ||
| errors.push(`任务 "${item.id}" 依赖了不存在的 id "${dep}"`); | ||
| } | ||
| } | ||
| } | ||
| if (errors.length > 0) { | ||
| return { ok: false, errors }; | ||
| } | ||
| // ── 循环依赖检测 ── | ||
| const cycle = detectCycle(normalized); | ||
| if (cycle) { | ||
| return { | ||
| ok: false, | ||
| errors: [`计划中存在循环依赖: ${cycle.join(' -> ')}`], | ||
| }; | ||
| } | ||
| return { ok: true, plan: { tasks: normalized } }; | ||
| } | ||
| /** | ||
| * 顶层结构校验,返回错误信息或 null | ||
| */ | ||
| function validateStructure(raw, maxTasks) { | ||
| if (!raw || typeof raw !== 'object') { | ||
| return 'plan 必须是一个对象'; | ||
| } | ||
| const tasksRaw = raw.tasks; | ||
| if (!Array.isArray(tasksRaw)) { | ||
| return 'plan.tasks 必须是一个数组'; | ||
| } | ||
| if (tasksRaw.length === 0) { | ||
| return 'plan.tasks 不能为空'; | ||
| } | ||
| if (tasksRaw.length > maxTasks) { | ||
| return `plan.tasks 长度 ${tasksRaw.length} 超过最大限制 ${maxTasks}`; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * 校验并标准化单个 task 项 | ||
| */ | ||
| function validateTaskItem(t, index, memberSet, idSet, leaderAgentId) { | ||
| const prefix = `tasks[${index}]`; | ||
| const errors = []; | ||
| if (!t || typeof t !== 'object') { | ||
| return { errors: [`${prefix} 不是一个对象`], item: null }; | ||
| } | ||
| const item = t; | ||
| // id | ||
| const id = typeof item.id === 'string' ? item.id.trim() : ''; | ||
| if (!id) { | ||
| errors.push(`${prefix}.id 必须是非空字符串`); | ||
| } | ||
| else if (idSet.has(id)) { | ||
| errors.push(`${prefix}.id 重复: "${id}"`); | ||
| } | ||
| else { | ||
| idSet.add(id); | ||
| } | ||
| // to | ||
| const to = typeof item.to === 'string' ? item.to.trim() : ''; | ||
| if (!to) { | ||
| errors.push(`${prefix}.to 必须是非空字符串`); | ||
| } | ||
| else if (!memberSet.has(to)) { | ||
| errors.push(`${prefix}.to "${to}" 不是群成员`); | ||
| } | ||
| else if (to === leaderAgentId) { | ||
| errors.push(`${prefix}.to "${to}" 是主 Agent 自身(禁止派给自己)`); | ||
| } | ||
| // task | ||
| const task = typeof item.task === 'string' ? item.task.trim() : ''; | ||
| if (!task) { | ||
| errors.push(`${prefix}.task 必须是非空字符串`); | ||
| } | ||
| // dependsOn | ||
| let dependsOn; | ||
| if (item.dependsOn != null) { | ||
| if (!Array.isArray(item.dependsOn)) { | ||
| errors.push(`${prefix}.dependsOn 必须是字符串数组`); | ||
| } | ||
| else { | ||
| dependsOn = []; | ||
| for (let j = 0; j < item.dependsOn.length; j++) { | ||
| const d = item.dependsOn[j]; | ||
| if (typeof d !== 'string' || !d.trim()) { | ||
| errors.push(`${prefix}.dependsOn[${j}] 必须是非空字符串`); | ||
| } | ||
| else { | ||
| dependsOn.push(d.trim()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return { errors, item: { id, to, task, dependsOn } }; | ||
| } | ||
| /** | ||
| * 循环依赖检测(Kahn 拓扑排序) | ||
| * | ||
| * @returns 若存在环则返回环路径,否则 undefined | ||
| */ | ||
| function detectCycle(tasks) { | ||
| const indeg = new Map(); | ||
| const graph = new Map(); | ||
| for (const t of tasks) { | ||
| indeg.set(t.id, 0); | ||
| graph.set(t.id, []); | ||
| } | ||
| for (const t of tasks) { | ||
| for (const dep of t.dependsOn ?? []) { | ||
| graph.get(dep).push(t.id); | ||
| indeg.set(t.id, (indeg.get(t.id) ?? 0) + 1); | ||
| } | ||
| } | ||
| // Kahn(使用索引模拟队列,避免 shift() 的 O(n) 开销) | ||
| const queue = []; | ||
| for (const [id, deg] of indeg) { | ||
| if (deg === 0) | ||
| queue.push(id); | ||
| } | ||
| let head = 0; | ||
| let visited = 0; | ||
| while (head < queue.length) { | ||
| const cur = queue[head++]; | ||
| visited += 1; | ||
| for (const next of graph.get(cur)) { | ||
| const d = indeg.get(next) - 1; | ||
| indeg.set(next, d); | ||
| if (d === 0) | ||
| queue.push(next); | ||
| } | ||
| } | ||
| if (visited === tasks.length) | ||
| return undefined; | ||
| // 收集环中节点,DFS 还原路径 | ||
| const inCycle = new Set(); | ||
| for (const [id, d] of indeg) { | ||
| if (d > 0) | ||
| inCycle.add(id); | ||
| } | ||
| return reconstructCycle(graph, inCycle); | ||
| } | ||
| /** | ||
| * 在环子图上 DFS 还原一条可视化环路径 | ||
| */ | ||
| function reconstructCycle(graph, inCycle) { | ||
| const start = inCycle.values().next().value; | ||
| const stack = [start]; | ||
| const onStack = new Set([start]); | ||
| // 记录每个节点已探索到的邻居索引,避免重复访问导致死循环 | ||
| const edgeIdx = new Map(); | ||
| edgeIdx.set(start, 0); | ||
| while (stack.length > 0) { | ||
| const top = stack[stack.length - 1]; | ||
| const neighbors = graph.get(top) ?? []; | ||
| let idx = edgeIdx.get(top) ?? 0; | ||
| let found = false; | ||
| // 从上次探索位置继续寻找环内邻居 | ||
| while (idx < neighbors.length) { | ||
| const next = neighbors[idx]; | ||
| idx++; | ||
| if (!inCycle.has(next)) | ||
| continue; | ||
| edgeIdx.set(top, idx); | ||
| if (onStack.has(next)) { | ||
| // 找到环 | ||
| const cycleStart = stack.indexOf(next); | ||
| return [...stack.slice(cycleStart), next]; | ||
| } | ||
| stack.push(next); | ||
| onStack.add(next); | ||
| edgeIdx.set(next, 0); | ||
| found = true; | ||
| break; | ||
| } | ||
| if (!found) { | ||
| // 当前节点所有邻居已探索完毕,回溯 | ||
| edgeIdx.set(top, idx); | ||
| stack.pop(); | ||
| onStack.delete(top); | ||
| } | ||
| } | ||
| // 兜底:理论不可达 | ||
| return [...inCycle]; | ||
| } |
| /** | ||
| * @fileoverview PlanningParser —— Planning 阶段 JSON 决策解析器 | ||
| * | ||
| * 本模块负责解析主 Agent(Leader)在 Planning 阶段输出的 JSON 决策文本, | ||
| * 将其转换为结构化的 TypeScript 对象,供后续流程使用。 | ||
| * | ||
| * 核心职责: | ||
| * 1. 解析 LLM 输出的 JSON 文本(支持纯 JSON 和 markdown 代码块两种格式); | ||
| * 2. 区分 "self_answer"(主 Agent 自己回答)和 "dispatch"(拆分派活)两种决策; | ||
| * 3. dispatch 决策时提取 transition(过渡话)和 tasks(子任务列表); | ||
| * 4. 容错处理:JSON 解析失败时返回结构化的错误信息。 | ||
| * | ||
| * 设计要点: | ||
| * - 纯函数实现,不做任何 IO 操作,便于单元测试; | ||
| * - 与 PlanValidator 配合使用:本模块只做 JSON 解析 + 结构提取, | ||
| * 字段合法性校验(如 id 唯一性、成员归属、循环依赖等)由 PlanValidator 负责。 | ||
| * | ||
| * @module planning-parser | ||
| */ | ||
| /** | ||
| * 解析 LLM 输出的 Planning JSON 决策 | ||
| * | ||
| * 这是本模块的主入口函数,负责将 LLM 的文本输出转换为结构化决策对象。 | ||
| * | ||
| * 容错策略(按优先级依次尝试): | ||
| * 1. 直接对整段文本执行 JSON.parse; | ||
| * 2. 若失败,尝试从文本中提取 ```json ... ``` markdown 代码块后再解析; | ||
| * 3. 若仍失败,返回 ok=false 并附带错误描述。 | ||
| * | ||
| * @param text - LLM 输出的 finalText(可能是纯 JSON 或包含 markdown 代码块) | ||
| * @returns 解析结果,包含决策对象或错误信息 | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const result = parsePlanningDecision('{"action":"self_answer"}'); | ||
| * if (result.ok) { | ||
| * console.log(result.decision); // { action: 'self_answer' } | ||
| * } | ||
| * ``` | ||
| */ | ||
| export function parsePlanningDecision(text) { | ||
| const trimmed = text.trim(); | ||
| // 空内容直接返回失败 | ||
| if (!trimmed) { | ||
| return { ok: false, error: '响应内容为空', rawText: text }; | ||
| } | ||
| // 第一步:尝试解析 JSON(含 markdown 代码块容错) | ||
| const parseResult = tryParseJson(trimmed); | ||
| if (!parseResult.ok) { | ||
| return { ok: false, error: parseResult.error, rawText: text }; | ||
| } | ||
| const parsed = parseResult.data; | ||
| // 第二步:校验顶层结构必须是对象 | ||
| if (!parsed || typeof parsed !== 'object') { | ||
| return { ok: false, error: '解析结果不是一个对象', rawText: text }; | ||
| } | ||
| const obj = parsed; | ||
| const action = obj.action; | ||
| // 第三步:根据 action 字段分发处理 | ||
| if (action === 'self_answer') { | ||
| return { ok: true, decision: { action: 'self_answer' }, rawText: text }; | ||
| } | ||
| if (action === 'dispatch') { | ||
| return buildDispatchResult(obj, text); | ||
| } | ||
| // action 字段不是已知值 | ||
| return { | ||
| ok: false, | ||
| error: `未知的 action 类型: "${String(action)}"`, | ||
| rawText: text, | ||
| }; | ||
| } | ||
| /** | ||
| * 从 DispatchDecision 中提取 Plan 对象 | ||
| * | ||
| * 将解析器产出的 DispatchDecision 转换为 PlanValidator 所需的 Plan 结构。 | ||
| * 主要处理:将空的 dependsOn 数组转为 undefined,避免下游产生无意义的空依赖。 | ||
| * | ||
| * @param decision - 已解析的 dispatch 决策 | ||
| * @returns 标准化的 Plan 对象,可直接传入 PlanValidator 进行校验 | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const plan = extractPlanFromDecision(dispatchDecision); | ||
| * const validationResult = validatePlan(plan, context); | ||
| * ``` | ||
| */ | ||
| export function extractPlanFromDecision(decision) { | ||
| return { | ||
| tasks: decision.tasks.map((t) => ({ | ||
| id: t.id, | ||
| to: t.to, | ||
| task: t.task, | ||
| // 空数组视为无依赖,设为 undefined 以简化下游判断 | ||
| dependsOn: t.dependsOn?.length ? t.dependsOn : undefined, | ||
| })), | ||
| }; | ||
| } | ||
| /** | ||
| * 尝试解析 JSON 文本 | ||
| * | ||
| * 支持两种格式: | ||
| * 1. 纯 JSON 文本(直接 parse) | ||
| * 2. 包含 markdown 代码块的文本(提取 ```json ... ``` 内容后 parse) | ||
| * | ||
| * @param text - 待解析的文本(已 trim) | ||
| * @returns 解析结果,成功时包含 data,失败时包含 error 描述 | ||
| * @internal | ||
| */ | ||
| function tryParseJson(text) { | ||
| // 策略一:直接解析整段文本 | ||
| try { | ||
| return { ok: true, data: JSON.parse(text) }; | ||
| } | ||
| catch { | ||
| // 直接解析失败,继续尝试代码块提取 | ||
| } | ||
| // 策略二:尝试匹配 markdown 代码块(支持 ```json 和 ``` 两种写法) | ||
| const codeBlockMatch = /```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/.exec(text); | ||
| if (codeBlockMatch?.[1]) { | ||
| try { | ||
| return { ok: true, data: JSON.parse(codeBlockMatch[1].trim()) }; | ||
| } | ||
| catch { | ||
| // 代码块内容也无法解析 | ||
| return { | ||
| ok: false, | ||
| error: `JSON 解析失败(已尝试代码块提取): ${text.slice(0, 200)}`, | ||
| }; | ||
| } | ||
| } | ||
| // 两种策略均失败,返回错误(附带前 200 字符用于调试) | ||
| return { | ||
| ok: false, | ||
| error: `JSON 解析失败: ${text.slice(0, 200)}`, | ||
| }; | ||
| } | ||
| /** | ||
| * 构建 dispatch 类型的解析结果 | ||
| * | ||
| * 从已解析的 JSON 对象中提取 dispatch 决策所需的字段: | ||
| * - thinking: 可选的拆分思路 | ||
| * - transition: 过渡话(缺失时默认为空字符串) | ||
| * - tasks: 子任务数组(逐项标准化) | ||
| * | ||
| * @param obj - 已解析的 JSON 对象(action 已确认为 'dispatch') | ||
| * @param rawText - 原始文本(透传到结果中用于日志) | ||
| * @returns 解析结果 | ||
| * @internal | ||
| */ | ||
| function buildDispatchResult(obj, rawText) { | ||
| // 提取可选的拆分思路(仅 string 类型有效) | ||
| const thinking = typeof obj.thinking === 'string' ? obj.thinking : undefined; | ||
| // 提取过渡话(缺失或非 string 时默认为空) | ||
| const transition = typeof obj.transition === 'string' ? obj.transition : ''; | ||
| // 提取任务数组(非数组时降级为空数组) | ||
| const tasks = Array.isArray(obj.tasks) ? obj.tasks : []; | ||
| // tasks 为空时视为无效的 dispatch 决策 | ||
| if (tasks.length === 0) { | ||
| return { | ||
| ok: false, | ||
| error: 'dispatch 决策的 tasks 数组为空', | ||
| rawText, | ||
| }; | ||
| } | ||
| // 逐项标准化任务结构 | ||
| const extractedTasks = tasks.map(normalizeTaskItem); | ||
| return { | ||
| ok: true, | ||
| decision: { | ||
| action: 'dispatch', | ||
| thinking, | ||
| transition, | ||
| tasks: extractedTasks, | ||
| }, | ||
| rawText, | ||
| }; | ||
| } | ||
| /** | ||
| * 标准化单个 task 项 | ||
| * | ||
| * 仅做结构提取和类型安全转换,不做业务合法性校验。 | ||
| * 对于缺失或类型不匹配的字段,使用空字符串作为默认值, | ||
| * 后续由 PlanValidator 统一报告校验错误。 | ||
| * | ||
| * @param t - 原始 task 数据(类型未知) | ||
| * @returns 标准化后的 task 对象 | ||
| * @internal | ||
| */ | ||
| function normalizeTaskItem(t) { | ||
| // 非对象类型直接返回空壳(由 PlanValidator 报错) | ||
| if (!t || typeof t !== 'object') { | ||
| return { id: '', to: '', task: '' }; | ||
| } | ||
| const item = t; | ||
| // 过滤 dependsOn 中的非 string 元素,确保类型安全 | ||
| const dependsOn = Array.isArray(item.dependsOn) | ||
| ? item.dependsOn.filter((d) => typeof d === 'string') | ||
| : undefined; | ||
| return { | ||
| id: typeof item.id === 'string' ? item.id : '', | ||
| to: typeof item.to === 'string' ? item.to : '', | ||
| task: typeof item.task === 'string' ? item.task : '', | ||
| // 仅在有有效依赖时才设置 dependsOn 字段 | ||
| ...(dependsOn?.length ? { dependsOn } : {}), | ||
| }; | ||
| } |
| /** | ||
| * SubtaskExecutor —— 子任务 DAG 执行器 | ||
| * | ||
| * 职责(场景 C 拆活路径的"运行儿子"那一段): | ||
| * 1. 将主 Agent 产出的 Plan,按 dependsOn 做拓扑分层; | ||
| * 2. 层间串行、层内并发地驱动每个子 Agent 的 run(同层无依赖可安全并发); | ||
| * 3. 把上游产出的文本作为「## 前置任务结果」注入到下游 child 的 message 前缀; | ||
| * 4. 收集 SubtaskResult[],供后续 SummarizerRunner 汇总; | ||
| * 5. 失败容忍:单个 child 失败不阻断同层其他任务,下游依赖标记为 upstream_failed; | ||
| * 6. 级联 abort:任一 abort 立即跳过尚未启动的 child,已启动的由自身 abortSignal 终止。 | ||
| * | ||
| * 设计要点: | ||
| * - 不写状态机:状态 PLANNING / RUNNING_CHILDREN 由调用方(路由)转移; | ||
| * - 不直接发出站事件:每个 child 的 sink 由本类内部构造,路径层不感知; | ||
| * - 成功的 child 写 assistant 账本(parentRunId=leaderRunId); | ||
| * 失败 / 中止写 system 账本(run_failed / user_aborted); | ||
| * - MVP 阶段简单实现:依赖 PlanValidator 已校验过环路 / 自引用 / 成员合法性。 | ||
| */ | ||
| import { createStreamReplyConfig } from '../../../streaming/stream-reply-sink.js'; | ||
| import { errorToString, normalizeRunStatus } from '../../utils/run-helpers.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| /** | ||
| * SubtaskExecutor 主体(无状态,可作为单例长期持有) | ||
| */ | ||
| export class SubtaskExecutor { | ||
| deps; | ||
| log = getModuleLogger('group.subtask-executor'); | ||
| constructor(deps) { | ||
| this.deps = deps; | ||
| } | ||
| /** | ||
| * 执行整张 Plan,按拓扑分层:层间串行、层内并发跑子任务 | ||
| * | ||
| * @returns 与 plan.tasks 一一对应的 SubtaskResult 列表(顺序与 plan.tasks 一致) | ||
| */ | ||
| async run(input) { | ||
| const { plan, ctx } = input; | ||
| const tag = `[SubtaskExecutor conv=${ctx.conversationId}]`; | ||
| const log = this.log; | ||
| // ── 1. 拓扑分层(Kahn) ── | ||
| const layers = this.topoLayers(plan.tasks); | ||
| log.info(`${tag} plan accepted tasks=${plan.tasks.length} layers=${layers.length}`); | ||
| // ── 2. 层间串行、层内并发执行 ── | ||
| // 同一层的任务无依赖关系,可安全并发;层间有依赖,必须串行等待上一层完成 | ||
| const resultsById = new Map(); | ||
| for (let i = 0; i < layers.length; i++) { | ||
| // 已被外部 abort:剩余层全部跳过 | ||
| if (ctx.abortSignal.aborted) { | ||
| for (const remaining of layers.slice(i).flat()) { | ||
| resultsById.set(remaining.id, this.makeAbortedResult(remaining)); | ||
| } | ||
| break; | ||
| } | ||
| const layer = layers[i]; | ||
| // 并发执行同层任务(无依赖关系,前端按 agentId 分区展示) | ||
| const layerResults = await Promise.all(layer.map((task) => { | ||
| if (ctx.abortSignal.aborted) { | ||
| return Promise.resolve(this.makeAbortedResult(task)); | ||
| } | ||
| return this.runSingleTask(task, resultsById, input); | ||
| })); | ||
| for (const r of layerResults) { | ||
| resultsById.set(r.taskId, r); | ||
| } | ||
| } | ||
| // ── 3. 按 plan.tasks 原顺序输出结果 ── | ||
| return plan.tasks.map((t) => resultsById.get(t.id) ?? this.makeUpstreamFailedResult(t)); | ||
| } | ||
| // ──────────────────────────────────────────────── | ||
| // 内部:单任务执行 | ||
| // ──────────────────────────────────────────────── | ||
| /** | ||
| * 跑一个 child run;上游失败时直接产出 upstream_failed 结果不启动 SDK | ||
| */ | ||
| async runSingleTask(task, upstreamResults, input) { | ||
| const { ctx, leaderAgentId, leaderRunId, originalMsgId } = input; | ||
| const { runner, runRegistry, ledger, emitter, promptBuilder } = this.deps; | ||
| const log = this.log; | ||
| const tag = `[SubtaskExecutor conv=${ctx.conversationId} task=${task.id}]`; | ||
| // 1) 上游失败 → 直接短路 | ||
| const blocking = this.collectBlockingUpstream(task, upstreamResults); | ||
| if (blocking.length > 0) { | ||
| const reason = `上游任务未完成: ${blocking.map((b) => `${b.taskId}(${b.status})`).join(', ')}`; | ||
| log.warn(`${tag} skipped due to upstream: ${reason}`); | ||
| try { | ||
| await ledger.appendSystem({ | ||
| groupId: ctx.groupId, | ||
| event: 'run_failed', | ||
| agentId: task.to, | ||
| runId: `child:${task.id}:skipped`, | ||
| content: reason, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} appendSystem(skipped) failed: ${err}`); | ||
| } | ||
| return { | ||
| taskId: task.id, | ||
| agentId: task.to, | ||
| runId: `child:${task.id}:skipped`, | ||
| status: 'failed', | ||
| errorMessage: reason, | ||
| }; | ||
| } | ||
| // 2) 注册 abort(使用稳定 runId 便于前端聚合) | ||
| const childRunId = `child:${ctx.conversationId}:${task.id}`; | ||
| const abortSignal = runRegistry.registerRun(ctx.conversationId, childRunId, task.to); | ||
| // 3) 构造 sinkContext | ||
| const replyMsgId = generateMsgId(); | ||
| const signalCtx = { | ||
| emitter, | ||
| targetId: ctx.userId, | ||
| replyMsgId, | ||
| originalMsgId, | ||
| agentId: task.to, | ||
| }; | ||
| const sinkContext = createStreamReplyConfig({ | ||
| emitter, | ||
| targetId: ctx.userId, | ||
| replyMsgId, | ||
| originalMsgId, | ||
| effectiveApiKey: ctx.effectiveApiKey ?? '', | ||
| agentId: task.to, | ||
| groupId: ctx.groupId, | ||
| runId: childRunId, | ||
| parentRunId: leaderRunId, | ||
| conversationId: ctx.conversationId, | ||
| isSubtask: true, | ||
| }, { suppressTyping: true, runId: childRunId }, signalCtx); | ||
| sinkContext.emitStart(); | ||
| // 4) 拼 system prompt + message(注入上游结果) | ||
| const systemPrompt = promptBuilder.buildChild({ | ||
| childAgentId: task.to, | ||
| leaderAgentId, | ||
| taskDescription: task.task, | ||
| userMessage: ctx.userMessage, | ||
| }); | ||
| const childMessage = this.buildChildMessage(task, upstreamResults); | ||
| // 5) 拉起 child run | ||
| let runOutput; | ||
| try { | ||
| runOutput = await runner.run({ | ||
| groupId: ctx.groupId, | ||
| agentId: task.to, | ||
| userId: ctx.userId, | ||
| role: 'child', | ||
| parentRunId: leaderRunId, | ||
| message: childMessage, | ||
| systemPrompt, | ||
| abortSignal, | ||
| sinkContext, | ||
| preassignedRunId: childRunId, | ||
| // 子任务执行也需要跨 Agent 通信权限 | ||
| enableAgentToAgent: true, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| log.error(`${tag} runner exception: ${e.message}`); | ||
| runRegistry.unregisterRun(ctx.conversationId, childRunId); | ||
| try { | ||
| await ledger.appendSystem({ | ||
| groupId: ctx.groupId, | ||
| event: 'run_failed', | ||
| agentId: task.to, | ||
| runId: childRunId, | ||
| content: e.message, | ||
| }); | ||
| } | ||
| catch (logErr) { | ||
| log.warn(`${tag} appendSystem(exception) failed: ${logErr}`); | ||
| } | ||
| return { | ||
| taskId: task.id, | ||
| agentId: task.to, | ||
| runId: childRunId, | ||
| status: 'failed', | ||
| errorMessage: errorToString(e), | ||
| }; | ||
| } | ||
| // 6) 落账本 | ||
| const status = normalizeRunStatus(runOutput.status); | ||
| const result = { | ||
| taskId: task.id, | ||
| agentId: task.to, | ||
| runId: runOutput.runId || childRunId, | ||
| status, | ||
| }; | ||
| if (runOutput.status === 'ok') { | ||
| result.output = runOutput.finalText; | ||
| try { | ||
| await ledger.appendAssistant({ | ||
| groupId: ctx.groupId, | ||
| agentId: task.to, | ||
| content: runOutput.finalText, | ||
| runId: result.runId, | ||
| parentRunId: leaderRunId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} appendAssistant failed: ${err}`); | ||
| } | ||
| log.info(`${tag} done bytes=${runOutput.finalText.length}`); | ||
| } | ||
| else if (runOutput.status === 'aborted') { | ||
| // 被中止:如果已有部分输出内容,将其作为 assistant 条目持久化, | ||
| // 刷新页面后用户仍能看到中断前的内容。 | ||
| // 不再写 user_aborted 系统事件——由上层路由(leader-dispatch)统一写一条即可。 | ||
| result.errorMessage = 'aborted'; | ||
| if (runOutput.finalText) { | ||
| result.output = runOutput.finalText; | ||
| try { | ||
| await ledger.appendAssistant({ | ||
| groupId: ctx.groupId, | ||
| agentId: task.to, | ||
| content: runOutput.finalText, | ||
| runId: result.runId, | ||
| parentRunId: leaderRunId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} appendAssistant(aborted partial) failed: ${err}`); | ||
| } | ||
| log.info(`${tag} aborted but has partial content, persisted bytes=${runOutput.finalText.length}`); | ||
| } | ||
| } | ||
| else { | ||
| result.errorMessage = errorToString(runOutput.error); | ||
| try { | ||
| await ledger.appendSystem({ | ||
| groupId: ctx.groupId, | ||
| event: 'run_failed', | ||
| agentId: task.to, | ||
| runId: result.runId, | ||
| content: result.errorMessage, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} appendSystem failed: ${err}`); | ||
| } | ||
| log.warn(`${tag} ${runOutput.status} reason=${result.errorMessage}`); | ||
| } | ||
| runRegistry.unregisterRun(ctx.conversationId, childRunId); | ||
| return result; | ||
| } | ||
| // ──────────────────────────────────────────────── | ||
| // 内部:辅助方法 | ||
| // ──────────────────────────────────────────────── | ||
| /** | ||
| * 拓扑分层:返回 PlanItem[][],第 i 层在第 i-1 层全部完成后再执行 | ||
| */ | ||
| topoLayers(tasks) { | ||
| const indeg = new Map(); | ||
| const byId = new Map(); | ||
| for (const t of tasks) { | ||
| indeg.set(t.id, (t.dependsOn ?? []).length); | ||
| byId.set(t.id, t); | ||
| } | ||
| const layers = []; | ||
| let remaining = tasks.length; | ||
| while (remaining > 0) { | ||
| const layer = []; | ||
| for (const [id, deg] of indeg) { | ||
| if (deg === 0) | ||
| layer.push(byId.get(id)); | ||
| } | ||
| if (layer.length === 0) { | ||
| // PlanValidator 应该已经挡掉环路,这里仅作兜底;把剩余任务塞最后一层抛失败 | ||
| const stuck = Array.from(indeg.keys()).map((id) => byId.get(id)); | ||
| layers.push(stuck); | ||
| break; | ||
| } | ||
| // 从入度表中移除本层 | ||
| for (const t of layer) | ||
| indeg.delete(t.id); | ||
| // 后续任务的入度 -1 | ||
| for (const id of indeg.keys()) { | ||
| const t = byId.get(id); | ||
| const deps = (t.dependsOn ?? []).filter((d) => indeg.has(d)); | ||
| indeg.set(id, deps.length); | ||
| } | ||
| layers.push(layer); | ||
| remaining -= layer.length; | ||
| } | ||
| return layers; | ||
| } | ||
| /** | ||
| * 收集"阻塞下游执行"的上游:状态非 done 的依赖 | ||
| */ | ||
| collectBlockingUpstream(task, upstream) { | ||
| const blocking = []; | ||
| for (const dep of task.dependsOn ?? []) { | ||
| const r = upstream.get(dep); | ||
| if (!r || r.status !== 'done') | ||
| blocking.push(r ?? this.makeMissingResult(dep, task.to)); | ||
| } | ||
| return blocking; | ||
| } | ||
| /** | ||
| * 把上游 done 结果拼接进 child 的 message,让子 Agent 能拿到上下文 | ||
| */ | ||
| buildChildMessage(task, upstream) { | ||
| const deps = (task.dependsOn ?? []) | ||
| .map((d) => upstream.get(d)) | ||
| .filter((r) => !!r && r.status === 'done' && !!r.output); | ||
| if (deps.length === 0) | ||
| return task.task; | ||
| const blocks = deps.map((d) => `- [${d.taskId} by ${d.agentId}]\n${(d.output ?? '').trim()}`); | ||
| return [ | ||
| '## 前置任务结果', | ||
| blocks.join('\n\n'), | ||
| '', | ||
| '## 你的任务', | ||
| task.task, | ||
| ].join('\n'); | ||
| } | ||
| makeAbortedResult(task) { | ||
| return { | ||
| taskId: task.id, | ||
| agentId: task.to, | ||
| runId: `child:${task.id}:aborted`, | ||
| status: 'aborted', | ||
| errorMessage: '已停止', | ||
| }; | ||
| } | ||
| makeUpstreamFailedResult(task) { | ||
| return { | ||
| taskId: task.id, | ||
| agentId: task.to, | ||
| runId: `child:${task.id}:upstream_failed`, | ||
| status: 'failed', | ||
| errorMessage: 'upstream_failed', | ||
| }; | ||
| } | ||
| makeMissingResult(taskId, agentId) { | ||
| return { | ||
| taskId, | ||
| agentId, | ||
| runId: `child:${taskId}:missing`, | ||
| status: 'failed', | ||
| errorMessage: 'missing upstream result', | ||
| }; | ||
| } | ||
| } |
| /** | ||
| * SummarizerRunner —— 场景 C 拆活路径的"汇总阶段" | ||
| * | ||
| * 职责: | ||
| * 1. 在所有 child 完成(含 done / failed / aborted)后, | ||
| * 用 Leader Agent 自身再跑一次 run(role=summarizer),不挂派活工具; | ||
| * 2. 通过 PromptBuilder.buildSummarizer 产出汇总指令; | ||
| * 3. 流式输出到独立的 SocketStreamSink(runId 与 leaderRunId 不同, | ||
| * parentRunId=leaderRunId,前端按此聚合为汇总气泡); | ||
| * 4. 写 assistant 账本(parentRunId=leaderRunId),便于后续历史回放。 | ||
| * | ||
| * 失败 / 中止: | ||
| * - aborted → 部分内容仍持久化,由上层路由统一写 user_aborted 事件; | ||
| * - error/timeout → 写 system run_failed; | ||
| * - 路由层据此决定 finalStatus = ABORTED / FAILED。 | ||
| * | ||
| * 设计要点: | ||
| * - 不转移状态机:SUMMARIZING / DONE 由调用方负责; | ||
| * - 子任务全部失败时仍然跑汇总,由 prompt 决定如何"诚实告知失败"。 | ||
| */ | ||
| import { createStreamReplyConfig } from '../../../streaming/stream-reply-sink.js'; | ||
| import { generatePreRunId } from '../../utils/id-generator.js'; | ||
| import { errorToString, normalizeRunStatus } from '../../utils/run-helpers.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| /** | ||
| * 汇总阶段禁用的工具列表 | ||
| * | ||
| * 防止 LLM 在汇总时再次尝试派活或调用 session 相关工具 | ||
| */ | ||
| const SUMMARIZER_DENY_TOOLS = [ | ||
| 'sessions_spawn', 'sessions_send', 'sessions_list', | ||
| 'sessions_history', 'sessions_yield', 'session_status', | ||
| 'sessions.spawn', 'subagents', 'agents_list', | ||
| ]; | ||
| /** | ||
| * SummarizerRunner —— 汇总阶段执行器(无状态) | ||
| * | ||
| * 负责拉起 Leader Agent 的汇总 run,将子任务结果综合为最终回答。 | ||
| */ | ||
| export class SummarizerRunner { | ||
| deps; | ||
| log = getModuleLogger('group.summarizer'); | ||
| constructor(deps) { | ||
| this.deps = deps; | ||
| } | ||
| /** 执行一次汇总 run */ | ||
| async run(input) { | ||
| const { ctx, leaderAgentId, leaderRunId, originalMsgId, subtaskResults } = input; | ||
| const { runRegistry } = this.deps; | ||
| const tag = `[Summarizer conv=${ctx.conversationId} agent=${leaderAgentId}]`; | ||
| // 注册 run(获取 abort 信号) | ||
| const summarizerRunId = generatePreRunId(ctx.conversationId, 'summarizer'); | ||
| const abortSignal = runRegistry.registerRun(ctx.conversationId, summarizerRunId, leaderAgentId); | ||
| try { | ||
| // 准备流式输出 sink | ||
| const sinkContext = this.createSinkContext(input, summarizerRunId); | ||
| sinkContext.emitStart(); | ||
| // 执行 runner | ||
| const runOutput = await this.executeRun(input, summarizerRunId, abortSignal, sinkContext); | ||
| // 处理结果并落账本 | ||
| return await this.handleRunOutput(runOutput, input, summarizerRunId, tag); | ||
| } | ||
| catch (err) { | ||
| // runner 抛异常:记录错误并返回 failed | ||
| return await this.handleRunException(err, input, summarizerRunId, tag); | ||
| } | ||
| finally { | ||
| runRegistry.unregisterRun(ctx.conversationId, summarizerRunId); | ||
| } | ||
| } | ||
| /** | ||
| * 创建流式输出的 SinkContext | ||
| * | ||
| * 汇总气泡独立于 leader 气泡,通过 parentRunId 关联 | ||
| */ | ||
| createSinkContext(input, summarizerRunId) { | ||
| const { ctx, leaderAgentId, leaderRunId, originalMsgId } = input; | ||
| const { emitter } = this.deps; | ||
| const replyMsgId = generateMsgId(); | ||
| const signalCtx = { | ||
| emitter, | ||
| targetId: ctx.userId, | ||
| replyMsgId, | ||
| originalMsgId, | ||
| agentId: leaderAgentId, | ||
| }; | ||
| return createStreamReplyConfig({ | ||
| emitter, | ||
| targetId: ctx.userId, | ||
| replyMsgId, | ||
| originalMsgId, | ||
| effectiveApiKey: ctx.effectiveApiKey ?? '', | ||
| agentId: leaderAgentId, | ||
| groupId: ctx.groupId, | ||
| runId: summarizerRunId, | ||
| parentRunId: leaderRunId, | ||
| conversationId: ctx.conversationId, | ||
| }, { suppressTyping: true, runId: summarizerRunId }, signalCtx); | ||
| } | ||
| /** | ||
| * 执行 summarizer run | ||
| * | ||
| * 构造汇总 prompt 并调用 runner,禁用派活相关工具 | ||
| */ | ||
| async executeRun(input, summarizerRunId, abortSignal, sinkContext) { | ||
| const { ctx, leaderAgentId, leaderRunId, subtaskResults } = input; | ||
| const { runner, promptBuilder } = this.deps; | ||
| const systemPrompt = promptBuilder.buildSummarizer({ | ||
| leaderAgentId, | ||
| userMessage: ctx.userMessage, | ||
| subtaskResults, | ||
| }); | ||
| // 明确告知 LLM 当前是汇总阶段,避免受历史 Planning JSON 影响 | ||
| const summarizerMessage = [ | ||
| '[汇总阶段] 子任务已全部完成,请用自然语言综合回答用户。不要输出JSON。', | ||
| `用户原始问题:${ctx.userMessage}`, | ||
| ].join('\n'); | ||
| return runner.run({ | ||
| groupId: ctx.groupId, | ||
| agentId: leaderAgentId, | ||
| userId: ctx.userId, | ||
| role: 'summarizer', | ||
| parentRunId: leaderRunId, | ||
| message: summarizerMessage, | ||
| systemPrompt, | ||
| abortSignal, | ||
| sinkContext, | ||
| preassignedRunId: summarizerRunId, | ||
| denyTools: [...SUMMARIZER_DENY_TOOLS], | ||
| }); | ||
| } | ||
| /** | ||
| * 处理 runner 正常返回的结果,根据状态落账本 | ||
| */ | ||
| async handleRunOutput(runOutput, input, summarizerRunId, tag) { | ||
| const { ctx, leaderAgentId, leaderRunId } = input; | ||
| const realRunId = runOutput.runId || summarizerRunId; | ||
| const normalized = normalizeRunStatus(runOutput.status); | ||
| // normalizeRunStatus 实际只返回 done/aborted/failed,但类型签名为 RunStatus, | ||
| // 此处做 fallback 保证类型安全 | ||
| const status = normalized === 'done' || normalized === 'aborted' || normalized === 'failed' | ||
| ? normalized | ||
| : 'failed'; | ||
| const out = { | ||
| runId: realRunId, | ||
| agentId: leaderAgentId, | ||
| status, | ||
| finalText: runOutput.finalText, | ||
| }; | ||
| const assistantParams = { | ||
| groupId: ctx.groupId, | ||
| agentId: leaderAgentId, | ||
| content: runOutput.finalText, | ||
| runId: realRunId, | ||
| parentRunId: leaderRunId, | ||
| }; | ||
| switch (status) { | ||
| case 'done': | ||
| await this.persistAssistant(assistantParams, tag); | ||
| this.log.info(`${tag} done bytes=${runOutput.finalText.length}`); | ||
| break; | ||
| case 'aborted': | ||
| out.errorMessage = 'aborted'; | ||
| // 被中止但有部分内容时仍持久化,刷新页面后用户可见中断前内容 | ||
| if (runOutput.finalText) { | ||
| await this.persistAssistant(assistantParams, tag, 'aborted partial'); | ||
| this.log.info(`${tag} aborted,已持久化部分内容 bytes=${runOutput.finalText.length}`); | ||
| } | ||
| break; | ||
| case 'failed': | ||
| out.errorMessage = errorToString(runOutput.error); | ||
| await this.persistSystemError(ctx.groupId, leaderAgentId, realRunId, out.errorMessage, tag); | ||
| this.log.warn(`${tag} failed reason=${out.errorMessage}`); | ||
| break; | ||
| } | ||
| return out; | ||
| } | ||
| /** | ||
| * 处理 runner 抛出异常的情况 | ||
| */ | ||
| async handleRunException(err, input, summarizerRunId, tag) { | ||
| const { ctx, leaderAgentId } = input; | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| this.log.error(`${tag} runner 异常: ${e.message}`); | ||
| await this.persistSystemError(ctx.groupId, leaderAgentId, summarizerRunId, e.message, tag); | ||
| return { | ||
| runId: summarizerRunId, | ||
| agentId: leaderAgentId, | ||
| status: 'failed', | ||
| finalText: '', | ||
| errorMessage: errorToString(e), | ||
| }; | ||
| } | ||
| /** | ||
| * 持久化 assistant 账本条目(带容错) | ||
| */ | ||
| async persistAssistant(params, tag, label) { | ||
| try { | ||
| await this.deps.ledger.appendAssistant(params); | ||
| } | ||
| catch (err) { | ||
| const suffix = label ? `(${label})` : ''; | ||
| this.log.warn(`${tag} appendAssistant${suffix} failed: ${err}`); | ||
| } | ||
| } | ||
| /** | ||
| * 持久化 run_failed 系统事件(带容错) | ||
| */ | ||
| async persistSystemError(groupId, agentId, runId, content, tag) { | ||
| try { | ||
| await this.deps.ledger.appendSystem({ | ||
| groupId, | ||
| event: 'run_failed', | ||
| agentId, | ||
| runId, | ||
| content, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| this.log.warn(`${tag} appendSystem failed: ${err}`); | ||
| } | ||
| } | ||
| } |
| /** | ||
| * @file routes 子模块出口 | ||
| * @description 统一导出路径层的路由实现和共享类型 | ||
| */ | ||
| export { LeaderOrchestrationRoute } from './leader-orchestration-route.js'; | ||
| export { MentionConcurrentRoute } from './mention-concurrent-route.js'; | ||
| // 工具函数(从统一工具层转发) | ||
| export { generatePreRunId } from '../../utils/id-generator.js'; | ||
| export { normalizeRunStatus, errorToString } from '../../utils/run-helpers.js'; |
| /** | ||
| * @fileoverview Leader 派活路径执行器 | ||
| * | ||
| * @description | ||
| * 当 Planning 阶段决策为 dispatch(需要将任务分配给群成员执行)时走此路径。 | ||
| * | ||
| * 执行流程: | ||
| * 1. 校验 Plan 合法性(成员存在性、任务数上限等) | ||
| * 2. 推送过渡话给前端(如「正在为您处理...」) | ||
| * 3. 子任务 DAG 并行/串行执行 | ||
| * 4. 汇总器收集所有子任务结果,生成最终回复 | ||
| * 5. 根据各阶段结果确定终态 | ||
| * | ||
| * 与自答路径的区别: | ||
| * - 派活路径涉及多个 Agent 协作,产生多个 run | ||
| * - 派活路径有明确的 Plan 结构(tasks DAG) | ||
| * - 派活路径包含汇总阶段(Summarizer) | ||
| * | ||
| * @module leader-dispatch | ||
| */ | ||
| // ─── 工具函数 ──────────────────────────────────────────────────────────────── | ||
| import { generatePreRunId } from '../../utils/id-generator.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| // ─── Planning 模块 ─────────────────────────────────────────────────────────── | ||
| import { extractPlanFromDecision, validatePlan } from '../planning/index.js'; | ||
| // ─── 路由层公共工具 ────────────────────────────────────────────────────────── | ||
| import { createSinkForAgent, safeAppendAssistant } from './route-helpers.js'; | ||
| /** 模块级日志记录器 */ | ||
| const log = getModuleLogger('group.leader-route'); | ||
| /** | ||
| * 执行派活路径:校验 Plan → 推送过渡话 → 子任务 DAG → 汇总 | ||
| * | ||
| * @remarks | ||
| * 整个流程中状态机转移顺序: | ||
| * RUNNING → PLANNING → RUNNING_CHILDREN → SUMMARIZING → DONE/FAILED/ABORTED | ||
| * | ||
| * 任何阶段失败都会通过 forceTerminal 直接终结状态机, | ||
| * 确保不会卡在中间态。 | ||
| * | ||
| * @param deps - 路由依赖 | ||
| * @param input - 路由运行输入(ctx + state) | ||
| * @param agentId - Leader Agent ID | ||
| * @param decision - Planning 阶段产出的派活决策 | ||
| * @param memberCards - 群成员能力卡(当前用于日志,未来可用于动态路由) | ||
| * @param tag - 日志标签 | ||
| * @param routeName - 路由名称(透传到返回结果中) | ||
| * @param options - 执行器与降级回调配置 | ||
| */ | ||
| export async function executeDispatch(deps, input, agentId, decision, memberCards, tag, routeName, options) { | ||
| const { ctx, state } = input; | ||
| const { subtaskExecutor, summarizerRunner, fallbackSelfAnswer } = options; | ||
| // ── 1. 校验 Plan 合法性 ── | ||
| // 从决策中提取原始 Plan 结构,并校验成员存在性、任务数上限等约束 | ||
| const rawPlan = extractPlanFromDecision(decision); | ||
| const validationResult = validatePlan(rawPlan, { | ||
| leaderAgentId: agentId, | ||
| members: ctx.meta.members.map((m) => m.agentId), | ||
| maxTasks: 5, // 单次派活最多 5 个子任务,防止资源耗尽 | ||
| }); | ||
| if (!validationResult.ok) { | ||
| // Plan 不合法时降级为自答,不中断用户体验 | ||
| log.warn(`${tag} Plan 校验失败: ${validationResult.errors.join('; ')},降级为自答`); | ||
| return fallbackSelfAnswer(); | ||
| } | ||
| const plan = validationResult.plan; | ||
| const originalMsgId = ctx.msgId ?? generateMsgId(); | ||
| // ── 2. 推送过渡话给前端 ── | ||
| // 过渡话是 Leader 在子任务执行前给用户的即时反馈(如「正在为您处理...」) | ||
| const transitionRunId = generatePreRunId(ctx.conversationId, 'transition'); | ||
| if (decision.transition?.trim()) { | ||
| await emitTransition(deps, ctx, agentId, decision.transition, transitionRunId, originalMsgId, tag); | ||
| } | ||
| // Leader 自身的 run 摘要(过渡话视为 Leader 的一次 run) | ||
| const leaderSummary = { | ||
| runId: transitionRunId, | ||
| agentId, | ||
| status: 'done', | ||
| finalText: decision.transition || '', | ||
| }; | ||
| // ── 3. 子任务 DAG 执行 ── | ||
| log.info(`${tag} 进入子任务流程,共 ${plan.tasks.length} 个任务`); | ||
| state.transitionTo('PLANNING'); | ||
| state.transitionTo('RUNNING_CHILDREN'); | ||
| let subtaskResults; | ||
| try { | ||
| subtaskResults = await subtaskExecutor.run({ | ||
| ctx, | ||
| plan, | ||
| leaderAgentId: agentId, | ||
| leaderRunId: transitionRunId, | ||
| originalMsgId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| // 子任务执行器整体崩溃(非单个子任务失败),直接终结 | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| log.error(`${tag} 子任务执行器崩溃: ${e.message}`); | ||
| state.forceTerminal('FAILED'); | ||
| return { route: routeName, finalStatus: 'FAILED', runs: [leaderSummary], error: e }; | ||
| } | ||
| // 将子任务结果映射为统一的 RouteRunSummary 格式 | ||
| const childSummaries = mapSubtaskResults(subtaskResults); | ||
| // ── 4. 汇总阶段 ── | ||
| state.transitionTo('SUMMARIZING'); | ||
| let summarizerSummary; | ||
| try { | ||
| const summarizerOut = await summarizerRunner.run({ | ||
| ctx, | ||
| leaderAgentId: agentId, | ||
| leaderRunId: transitionRunId, | ||
| originalMsgId, | ||
| subtaskResults, | ||
| }); | ||
| summarizerSummary = { | ||
| runId: summarizerOut.runId, | ||
| agentId: summarizerOut.agentId, | ||
| status: summarizerOut.status, | ||
| finalText: summarizerOut.finalText, | ||
| errorMessage: summarizerOut.errorMessage, | ||
| }; | ||
| } | ||
| catch (err) { | ||
| // 汇总器崩溃兜底:子任务已完成但无法汇总,标记为失败 | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| log.error(`${tag} 汇总器执行异常: ${e.message}`); | ||
| state.forceTerminal('FAILED'); | ||
| return { | ||
| route: routeName, | ||
| finalStatus: 'FAILED', | ||
| runs: [leaderSummary, ...childSummaries], | ||
| error: e, | ||
| }; | ||
| } | ||
| // ── 5. 终态决策 ── | ||
| const allRuns = [leaderSummary, ...childSummaries, summarizerSummary]; | ||
| const finalStatus = determineFinalStatus(allRuns, summarizerSummary.status, state); | ||
| // ── 6. ABORTED 时写入 user_aborted 系统事件 ── | ||
| // 在所有 run 结束(部分内容已 appendAssistant)之后再写, | ||
| // 保证前端按 ts 排序后终止提示在 agent 部分回复内容之后。 | ||
| if (finalStatus === 'ABORTED') { | ||
| try { | ||
| await deps.ledger.appendSystem({ | ||
| groupId: ctx.groupId, | ||
| event: 'user_aborted', | ||
| userId: ctx.userId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} appendSystem(user_aborted) 失败: ${err}`); | ||
| } | ||
| } | ||
| log.info(`${tag} 派活流程完成 状态=${finalStatus} ` + | ||
| `子任务数=${childSummaries.length} ` + | ||
| `成功数=${childSummaries.filter((r) => r.status === 'done').length} ` + | ||
| `汇总器=${summarizerSummary.status}`); | ||
| return { | ||
| route: routeName, | ||
| finalStatus, | ||
| runs: allRuns, | ||
| error: finalStatus === 'FAILED' ? new Error(summarizerSummary.errorMessage ?? 'summarizer failed') : undefined, | ||
| }; | ||
| } | ||
| // ─── 私有辅助函数 ──────────────────────────────────────────────────────────── | ||
| /** | ||
| * 将子任务结果数组映射为统一的 RouteRunSummary 格式 | ||
| * | ||
| * @remarks | ||
| * SubtaskResult 使用 `output` 字段存储文本,而 RouteRunSummary 使用 `finalText`, | ||
| * 此函数负责字段名适配。 | ||
| */ | ||
| function mapSubtaskResults(results) { | ||
| return results.map((r) => ({ | ||
| runId: r.runId, | ||
| agentId: r.agentId, | ||
| status: r.status, | ||
| finalText: r.output, | ||
| errorMessage: r.errorMessage, | ||
| })); | ||
| } | ||
| /** | ||
| * 推送过渡话并写入账本 | ||
| * | ||
| * @remarks | ||
| * 过渡话是 Leader 在子任务执行前给用户的即时反馈, | ||
| * 通过 SocketStreamSink 的 onFinal 直接推送完整文本(非流式), | ||
| * 同时持久化到群账本供历史消息回放。 | ||
| */ | ||
| async function emitTransition(deps, ctx, agentId, text, runId, originalMsgId, tag) { | ||
| const sinkContext = createSinkForAgent(deps, ctx, agentId, runId, originalMsgId); | ||
| sinkContext.emitStart(); | ||
| try { | ||
| // 过渡话不走流式,直接通过 dispatcher 推送完整文本并触发终态帧 | ||
| sinkContext.dispatcher.sendFinalReply({ text }); | ||
| sinkContext.dispatcher.markComplete(); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} 过渡话推送异常: ${err}`); | ||
| } | ||
| await safeAppendAssistant(deps, ctx.groupId, agentId, text, runId, tag); | ||
| } | ||
| /** | ||
| * 根据所有 run 结果确定最终终态 | ||
| * | ||
| * @remarks | ||
| * 终态决策优先级: | ||
| * 1. 任何 run 被 abort → 整体 ABORTED | ||
| * 2. 汇总器成功 → 整体 DONE(即使部分子任务失败,汇总器仍可能产出有效回复) | ||
| * 3. 其他情况 → FAILED | ||
| * | ||
| * @param allRuns - 所有 run 的摘要(Leader + 子任务 + 汇总器) | ||
| * @param summarizerStatus - 汇总器的终态 | ||
| * @param state - 会话状态机 | ||
| */ | ||
| function determineFinalStatus(allRuns, summarizerStatus, state) { | ||
| // 优先检查是否有 abort(用户主动取消) | ||
| if (allRuns.some((r) => r.status === 'aborted')) { | ||
| state.forceTerminal('ABORTED'); | ||
| return 'ABORTED'; | ||
| } | ||
| // 汇总器成功即视为整体成功 | ||
| if (summarizerStatus === 'done') { | ||
| state.transitionTo('DONE'); | ||
| return 'DONE'; | ||
| } | ||
| // 兜底:汇总器未成功则整体失败 | ||
| state.forceTerminal('FAILED'); | ||
| return 'FAILED'; | ||
| } |
| /** | ||
| * @fileoverview 路径 C —— Leader 决策编排路由(主入口) | ||
| * | ||
| * @description | ||
| * 实现两阶段决策流程: | ||
| * 1. Planning 阶段:Leader Agent 以 JSON 模式输出决策(self_answer / dispatch) | ||
| * 2. Execution 阶段:根据决策走自答或派活(子任务 DAG + 汇总) | ||
| * | ||
| * 核心设计: | ||
| * - Planning 支持重试(JSON 解析失败时自动重试) | ||
| * - 所有阶段失败均可优雅降级为自答 | ||
| * - 子任务执行后由 Summarizer 汇总最终回复 | ||
| * | ||
| * @module leader-orchestration-route | ||
| */ | ||
| // ─── 工具函数 ──────────────────────────────────────────────────────────────── | ||
| import { generatePreRunId } from '../../utils/id-generator.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| // ─── Planning 模块 ─────────────────────────────────────────────────────────── | ||
| import { SubtaskExecutor, SummarizerRunner } from '../planning/index.js'; | ||
| // ─── 拆分后的子模块 ────────────────────────────────────────────────────────── | ||
| import { buildTag, resolveMemberCards } from './route-helpers.js'; | ||
| import { executePlanning } from './leader-planning.js'; | ||
| import { executeSelfAnswer } from './leader-self-answer.js'; | ||
| import { executeDispatch } from './leader-dispatch.js'; | ||
| /** | ||
| * 路径 C —— Leader 决策层(提示词驱动方案) | ||
| * | ||
| * @remarks | ||
| * 两阶段决策:Planning(JSON 决策)→ Execution(自答 / 派活+汇总) | ||
| */ | ||
| export class LeaderOrchestrationRoute { | ||
| deps; | ||
| /** 路由名称标识,用于日志和结果追踪 */ | ||
| name = 'leader_orchestration'; | ||
| /** 子任务 DAG 执行器,负责并行/串行调度多个子 Agent */ | ||
| subtaskExecutor; | ||
| /** 汇总阶段执行器,负责将子任务结果合并为最终回复 */ | ||
| summarizerRunner; | ||
| /** Planning 阶段 JSON 解析失败时的最大重试次数 */ | ||
| planningMaxRetries; | ||
| /** 日志记录器,命名空间 'group.leader-route' */ | ||
| log = getModuleLogger('group.leader-route'); | ||
| /** | ||
| * 构造 LeaderOrchestrationRoute 实例 | ||
| * | ||
| * @param deps - 路由公共依赖(runner、emitter、ledger 等) | ||
| * @param options - 可选配置项,支持注入自定义执行器和重试策略 | ||
| */ | ||
| constructor(deps, options = {}) { | ||
| this.deps = deps; | ||
| this.subtaskExecutor = options.subtaskExecutor ?? new SubtaskExecutor(deps); | ||
| this.summarizerRunner = options.summarizerRunner ?? new SummarizerRunner(deps); | ||
| this.planningMaxRetries = options.planningMaxRetries ?? 1; | ||
| } | ||
| /** | ||
| * 执行路径 C —— 两阶段决策主流程 | ||
| * | ||
| * @remarks | ||
| * 执行流程: | ||
| * 1. 状态机转移至 RUNNING_LEADER | ||
| * 2. 解析群成员能力卡(用于 Planning 提示词) | ||
| * 3. 调用 Planning 阶段获取 JSON 决策 | ||
| * 4. 根据决策分支:self_answer → 自答 / dispatch → 派活+汇总 | ||
| * 5. Planning 失败时优雅降级为自答 | ||
| * | ||
| * @param input - 路由运行输入,包含上下文(ctx)和状态机(state) | ||
| * @returns 路由执行结果,包含终态、所有 run 摘要及可能的错误 | ||
| */ | ||
| async run(input) { | ||
| const { ctx, state } = input; | ||
| const tag = buildTag(ctx); | ||
| const agentId = ctx.meta.defaultAgentId; | ||
| // ── 1. 状态转移 ── | ||
| if (state.getStatus() === 'PENDING') { | ||
| state.transitionTo('ROUTING'); | ||
| } | ||
| state.transitionTo('RUNNING_LEADER'); | ||
| // ── 2. 解析群成员能力卡 ── | ||
| const memberCards = resolveMemberCards(this.deps, ctx, tag); | ||
| // ── 2.5 欢迎消息模式:跳过 Planning,直接走自答路径 ── | ||
| if (ctx.welcomeMode) { | ||
| this.log.info(`${tag} 欢迎消息模式,跳过 Planning 直接自答`); | ||
| return await this.doWelcomeAnswer(input, agentId, memberCards, tag); | ||
| } | ||
| // ── 3. Planning 阶段 ── | ||
| const planningResult = await executePlanning(this.deps, ctx, agentId, memberCards, tag, this.planningMaxRetries); | ||
| // ── 4. 处理 Planning 结果 ── | ||
| if (planningResult.aborted) { | ||
| const planningRunId = generatePreRunId(ctx.conversationId, 'planning'); | ||
| state.forceTerminal('ABORTED'); | ||
| // Planning 阶段被中止:写入 user_aborted 系统事件 | ||
| try { | ||
| await this.deps.ledger.appendSystem({ | ||
| groupId: ctx.groupId, | ||
| event: 'user_aborted', | ||
| userId: ctx.userId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| this.log.warn(`${tag} appendSystem(user_aborted) 失败: ${err}`); | ||
| } | ||
| return { | ||
| route: this.name, | ||
| finalStatus: 'ABORTED', | ||
| runs: [{ runId: planningRunId, agentId, status: 'aborted' }], | ||
| }; | ||
| } | ||
| if (!planningResult.decision) { | ||
| this.log.warn(`${tag} Planning 失败,降级为自答: ${planningResult.lastError}`); | ||
| // 短暂等待 SDK 内部 session 写锁释放,避免 "Previous run is still shutting down" 冲突 | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| return await this.doSelfAnswer(input, agentId, memberCards, tag); | ||
| } | ||
| const decision = planningResult.decision; | ||
| // ── 5. 分支执行 ── | ||
| if (decision.action === 'self_answer') { | ||
| this.log.info(`${tag} 决策=自答`); | ||
| // 短暂等待 SDK 内部 session 写锁释放 | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| return await this.doSelfAnswer(input, agentId, memberCards, tag); | ||
| } | ||
| const dispatchDecision = decision; | ||
| return await this.doDispatch(input, agentId, dispatchDecision, memberCards, tag); | ||
| } | ||
| /** | ||
| * 委托自答路径 | ||
| * | ||
| * @remarks | ||
| * 将执行委托给 {@link executeSelfAnswer},Leader 直接流式回答用户。 | ||
| * 用于 Planning 决策为 self_answer 或 Planning 失败降级的场景。 | ||
| * | ||
| * @param input - 路由运行输入 | ||
| * @param agentId - Leader Agent ID | ||
| * @param memberCards - 群成员能力卡(用于构建系统提示词) | ||
| * @param tag - 日志标签(含 conversationId、groupId) | ||
| */ | ||
| doSelfAnswer(input, agentId, memberCards, tag) { | ||
| return executeSelfAnswer(this.deps, input, agentId, memberCards, tag, this.name); | ||
| } | ||
| /** | ||
| * 委托派活路径 | ||
| * | ||
| * @remarks | ||
| * 将执行委托给 {@link executeDispatch},流程为: | ||
| * 校验 Plan → 推送过渡话 → 子任务 DAG 执行 → Summarizer 汇总。 | ||
| * 若 Plan 校验失败,内部会通过 fallbackSelfAnswer 降级为自答。 | ||
| * | ||
| * @param input - 路由运行输入 | ||
| * @param agentId - Leader Agent ID | ||
| * @param decision - Planning 阶段输出的派活决策(含 tasks 列表) | ||
| * @param memberCards - 群成员能力卡 | ||
| * @param tag - 日志标签 | ||
| */ | ||
| doDispatch(input, agentId, decision, memberCards, tag) { | ||
| return executeDispatch(this.deps, input, agentId, decision, memberCards, tag, this.name, { | ||
| subtaskExecutor: this.subtaskExecutor, | ||
| summarizerRunner: this.summarizerRunner, | ||
| fallbackSelfAnswer: () => this.doSelfAnswer(input, agentId, memberCards, tag), | ||
| }); | ||
| } | ||
| /** | ||
| * 欢迎消息自答路径 | ||
| * | ||
| * @remarks | ||
| * 创建群后触发,使用 buildWelcome prompt 让主Agent生成欢迎消息。 | ||
| * 与普通自答路径的区别:使用专门的欢迎消息 prompt 而非通用 Leader prompt。 | ||
| * | ||
| * @param input - 路由运行输入 | ||
| * @param agentId - Leader Agent ID | ||
| * @param memberCards - 群成员能力卡 | ||
| * @param tag - 日志标签 | ||
| */ | ||
| doWelcomeAnswer(input, agentId, memberCards, tag) { | ||
| return executeSelfAnswer(this.deps, input, agentId, memberCards, tag, this.name, { | ||
| welcomeMode: true, | ||
| }); | ||
| } | ||
| } |
| /** | ||
| * @fileoverview Leader Planning 阶段执行器 | ||
| * | ||
| * @description | ||
| * 负责 Planning 阶段:以 JSON 模式调用 Leader Agent 输出决策, | ||
| * 支持重试(JSON 解析失败时自动重试)。 | ||
| * | ||
| * @module leader-planning | ||
| */ | ||
| import { generatePreRunId } from '../../utils/id-generator.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { parsePlanningDecision } from '../planning/index.js'; | ||
| import { DENY_TOOLS } from './route-helpers.js'; | ||
| const log = getModuleLogger('group.leader-route'); | ||
| /** | ||
| * 执行 Planning 阶段:强制 JSON 输出决策,支持重试 | ||
| * | ||
| * @param deps - 路由依赖 | ||
| * @param ctx - 路由上下文 | ||
| * @param agentId - Leader Agent ID | ||
| * @param memberCards - 群成员能力卡 | ||
| * @param tag - 日志标签 | ||
| * @param maxRetries - 最大重试次数 | ||
| */ | ||
| export async function executePlanning(deps, ctx, agentId, memberCards, tag, maxRetries) { | ||
| const { runner, promptBuilder, runRegistry } = deps; | ||
| const planningPrompt = promptBuilder.buildPlanner({ | ||
| leaderAgentId: agentId, | ||
| memberCards, | ||
| userMessage: ctx.userMessage, | ||
| }); | ||
| const planningRunId = generatePreRunId(ctx.conversationId, 'planning'); | ||
| const planningAbort = runRegistry.registerRun(ctx.conversationId, planningRunId, agentId); | ||
| // Planning 不推送给前端,不传 sinkContext,AgentRunner 内部会使用静默 sink | ||
| // Planning 阶段不传 attachments/媒体文件: | ||
| // Planning 只需要知道"用户发了文件"这个事实(已在 userMessage 的 attachmentDescription 中体现), | ||
| // 如果传入图片等媒体文件,AI 会被图片内容"分心",直接描述图片而忽略 JSON 格式要求。 | ||
| // 真正需要"看到"文件内容的是自答阶段(leader-self-answer)和子任务阶段。 | ||
| let planningOutput; | ||
| let lastError; | ||
| for (let attempt = 0; attempt <= maxRetries; attempt++) { | ||
| if (planningAbort.aborted) | ||
| break; | ||
| try { | ||
| planningOutput = await runner.run({ | ||
| // 使用独立的 sessionKey(加 :planning 后缀),避免 Planning 阶段的 JSON 决策 | ||
| // 输出被写入主 session transcript,污染后续自答/派活阶段的 LLM 上下文 | ||
| groupId: `${ctx.groupId}:planning`, | ||
| agentId, | ||
| userId: ctx.userId, | ||
| role: 'leader', | ||
| message: ctx.userMessage, | ||
| systemPrompt: planningPrompt, | ||
| abortSignal: planningAbort, | ||
| preassignedRunId: attempt === 0 ? planningRunId : `${planningRunId}:retry${attempt}`, | ||
| denyTools: DENY_TOOLS, | ||
| enableAgentToAgent: true, | ||
| jsonMode: true, | ||
| }); | ||
| if (planningOutput.status !== 'ok') | ||
| break; | ||
| // 尝试解析 JSON 决策 | ||
| const parseResult = parsePlanningDecision(planningOutput.finalText); | ||
| if (parseResult.ok) { | ||
| log.info(`${tag} Planning 决策完成: action=${parseResult.decision.action} ` + | ||
| `尝试次数=${attempt} 字节数=${planningOutput.finalText.length}`); | ||
| runRegistry.unregisterRun(ctx.conversationId, planningRunId); | ||
| return { aborted: false, decision: parseResult.decision }; | ||
| } | ||
| // JSON 解析失败,记录并决定是否重试 | ||
| lastError = parseResult.error; | ||
| log.warn(`${tag} Planning JSON 解析失败 (第 ${attempt} 次): ${parseResult.error}`); | ||
| if (attempt < maxRetries) { | ||
| planningOutput = undefined; | ||
| } | ||
| } | ||
| catch (err) { | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| log.error(`${tag} Planning 执行异常 (第 ${attempt} 次): ${e.message}`); | ||
| lastError = e.message; | ||
| if (attempt >= maxRetries) { | ||
| planningOutput = undefined; | ||
| } | ||
| } | ||
| } | ||
| runRegistry.unregisterRun(ctx.conversationId, planningRunId); | ||
| // 判断是否为 abort | ||
| if (planningOutput?.status === 'aborted' || planningAbort.aborted) { | ||
| return { aborted: true }; | ||
| } | ||
| return { aborted: false, lastError }; | ||
| } |
| /** | ||
| * @fileoverview Leader 自答路径执行器 | ||
| * | ||
| * @description | ||
| * 当 Planning 阶段决策为 self_answer(Leader 自身有能力回答)或 | ||
| * Planning 失败需要降级时,走此路径。 | ||
| * | ||
| * 核心行为: | ||
| * - 构造流式 SocketStreamSink,实时推送 token 到前端 | ||
| * - 调用 runner.run 让 Leader Agent 直接生成回复 | ||
| * - 根据 run 终态(ok / aborted / failed)写入账本并转移状态机 | ||
| * | ||
| * 与 dispatch 路径的区别: | ||
| * - 自答路径不涉及子任务拆分和 DAG 执行 | ||
| * - 自答路径只产生一个 run(Leader 自身) | ||
| * | ||
| * @module leader-self-answer | ||
| */ | ||
| // ─── 工具函数 ──────────────────────────────────────────────────────────────── | ||
| import { generatePreRunId } from '../../utils/id-generator.js'; | ||
| import { errorToString, normalizeRunStatus } from '../../utils/run-helpers.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| // ─── 路由层公共工具 ────────────────────────────────────────────────────────── | ||
| import { DENY_TOOLS, buildLeaderPrompt, createSinkForAgent, safeAppendAssistant, safeAppendSystem, } from './route-helpers.js'; | ||
| /** 模块级日志记录器,统一使用 leader-route 命名空间 */ | ||
| const log = getModuleLogger('group.leader-route'); | ||
| /** | ||
| * 执行自答路径:正常流式 run,Leader 直接回答用户 | ||
| * | ||
| * @remarks | ||
| * 执行流程: | ||
| * 1. 预分配 runId + 注册 AbortSignal | ||
| * 2. 构造 SocketStreamSink 并推送首帧 | ||
| * 3. 调用 runner.run 执行 Agent | ||
| * 4. 根据 run 结果写入账本并转移状态机 | ||
| * | ||
| * @param deps - 路由依赖 | ||
| * @param input - 路由运行输入(ctx + state) | ||
| * @param agentId - Leader Agent ID | ||
| * @param memberCards - 群成员能力卡(用于构造 system prompt) | ||
| * @param tag - 日志标签 | ||
| * @param routeName - 路由名称(透传到返回结果中) | ||
| * @param options - 可选配置(如 welcomeMode) | ||
| */ | ||
| export async function executeSelfAnswer(deps, input, agentId, memberCards, tag, routeName, options) { | ||
| const { ctx, state } = input; | ||
| const { runner, runRegistry } = deps; | ||
| // ── 1. 预分配 runId 并注册 AbortSignal ── | ||
| // preRunId 在 run 开始前生成,确保首帧推送时前端就能拿到稳定的 runId | ||
| const preRunId = generatePreRunId(ctx.conversationId, 'leader-answer'); | ||
| // 注册到 RunRegistry,支持用户中途 abort 时级联取消 | ||
| const abortSignal = runRegistry.registerRun(ctx.conversationId, preRunId, agentId); | ||
| // 兜底生成 msgId,确保前端气泡能正确关联到触发消息 | ||
| const originalMsgId = ctx.msgId ?? generateMsgId(); | ||
| // ── 2. 构造流式 sink 并发送首帧 ── | ||
| // sinkContext 负责将 runner 产出的 token 实时推送到前端 WebSocket | ||
| // emitStart() 发送首帧,前端据此创建气泡占位 | ||
| const sinkContext = createSinkForAgent(deps, ctx, agentId, preRunId, originalMsgId); | ||
| sinkContext.emitStart(); | ||
| // ── 3. 执行 Agent run ── | ||
| // 根据是否为欢迎消息模式,选择不同的 system prompt | ||
| let extraSystemPrompt; | ||
| if (options?.welcomeMode) { | ||
| // 欢迎消息模式:使用专门的 buildWelcome prompt | ||
| try { | ||
| extraSystemPrompt = deps.promptBuilder.buildWelcome({ | ||
| leaderAgentId: agentId, | ||
| groupName: ctx.meta.name || ctx.meta.groupId, | ||
| groupDesc: ctx.meta.desc || '', | ||
| memberCards, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} 构建 Welcome 提示词失败: ${err},降级为 Leader prompt`); | ||
| extraSystemPrompt = buildLeaderPrompt(deps, agentId, memberCards, tag); | ||
| } | ||
| } | ||
| else { | ||
| // 普通自答模式:使用 Leader prompt | ||
| extraSystemPrompt = buildLeaderPrompt(deps, agentId, memberCards, tag); | ||
| } | ||
| try { | ||
| const runOutput = await runner.run({ | ||
| groupId: ctx.groupId, | ||
| agentId, | ||
| userId: ctx.userId, | ||
| role: 'leader', // 标识为 leader 角色,影响模型选择和 token 限制 | ||
| message: ctx.userMessage, | ||
| systemPrompt: extraSystemPrompt, | ||
| abortSignal, // 用户 abort 时自动取消请求 | ||
| sinkContext, // 统一流式输出上下文 | ||
| preassignedRunId: preRunId, // 确保 runId 全程一致 | ||
| denyTools: DENY_TOOLS, // 禁止 Agent 调用会话管理类工具 | ||
| enableAgentToAgent: true, // 允许 Agent 间通信(如引用其他 Agent 的输出) | ||
| // 传递文件附件,使 AI 能感知用户上传的文件 | ||
| attachments: ctx.files?.filter((f) => f.uri).map((f) => ({ | ||
| name: f.name, | ||
| mimeType: f.mimeType || 'application/octet-stream', | ||
| url: f.uri, | ||
| localPath: f.localPath, | ||
| })), | ||
| }); | ||
| // ── 4. 处理 run 结果并写入账本 ── | ||
| // runner 可能在内部重新分配 runId(如重试场景),优先使用实际 runId | ||
| const realRunId = runOutput.runId || preRunId; | ||
| const summary = { | ||
| runId: realRunId, | ||
| agentId, | ||
| status: normalizeRunStatus(runOutput.status), | ||
| }; | ||
| // 根据 run 终态填充 summary 的可选字段,供上层日志/metrics 使用 | ||
| if (runOutput.status === 'ok') { | ||
| summary.finalText = runOutput.finalText; // 完整回复文本 | ||
| } | ||
| else if (runOutput.status === 'aborted') { | ||
| // 被中止时也保留已输出的部分内容,供 flushLedger 持久化 | ||
| summary.finalText = runOutput.finalText || undefined; | ||
| summary.errorMessage = 'aborted'; | ||
| } | ||
| else { | ||
| summary.errorMessage = errorToString(runOutput.error); // 错误描述 | ||
| } | ||
| // 确定终态并转移状态机(DONE / ABORTED / FAILED) | ||
| const finalStatus = resolveTerminalState(runOutput.status, summary, { state, tag }); | ||
| // 将结果持久化到群账本(assistant 消息或 system 事件) | ||
| await flushLedger(finalStatus, deps, ctx, agentId, realRunId, tag, summary); | ||
| // 释放 RunRegistry 中的注册,允许同一 conversation 发起新 run | ||
| runRegistry.unregisterRun(ctx.conversationId, preRunId); | ||
| return { route: routeName, finalStatus, runs: [summary], error: runOutput.error }; | ||
| } | ||
| catch (err) { | ||
| // runner.run 抛出未预期异常(如网络超时、SDK 内部错误)的兜底处理 | ||
| // 此分支确保即使 runner 崩溃,状态机也能正确终结,不会卡在 RUNNING 态 | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| log.error(`${tag} 自答执行异常: ${e.message}`); | ||
| state.forceTerminal('FAILED'); | ||
| runRegistry.unregisterRun(ctx.conversationId, preRunId); | ||
| return { | ||
| route: routeName, | ||
| finalStatus: 'FAILED', | ||
| runs: [{ runId: preRunId, agentId, status: 'failed', errorMessage: e.message }], | ||
| error: e, | ||
| }; | ||
| } | ||
| } | ||
| // ─── 私有辅助函数 ──────────────────────────────────────────────────────────── | ||
| /** | ||
| * 根据 run 输出状态确定终态并转移状态机 | ||
| * | ||
| * @remarks | ||
| * 状态转移规则: | ||
| * - ok → transitionTo('DONE') 正常完成 | ||
| * - aborted → forceTerminal('ABORTED') 用户主动取消 | ||
| * - 其他 → forceTerminal('FAILED') 执行失败 | ||
| * | ||
| * 注意:forceTerminal 与 transitionTo 的区别在于前者跳过中间态校验, | ||
| * 适用于异常终结场景。 | ||
| * | ||
| * @param outputStatus - runner 返回的原始状态字符串 | ||
| * @param summary - 当前 run 的摘要对象(可能被修改 errorMessage) | ||
| * @param opts.state - 会话状态机实例 | ||
| * @param opts.tag - 日志标签 | ||
| * @returns 终态值(DONE / ABORTED / FAILED) | ||
| */ | ||
| function resolveTerminalState(outputStatus, summary, opts) { | ||
| const { state, tag } = opts; | ||
| if (outputStatus === 'ok') { | ||
| state.transitionTo('DONE'); | ||
| log.info(`${tag} 自答完成,字节数=${summary.finalText?.length ?? 0}`); | ||
| return 'DONE'; | ||
| } | ||
| if (outputStatus === 'aborted') { | ||
| // aborted 时 runner 不一定填充 error,手动标记 | ||
| summary.errorMessage = 'aborted'; | ||
| state.forceTerminal('ABORTED'); | ||
| return 'ABORTED'; | ||
| } | ||
| // 其他状态(如 'error'、'timeout' 等)统一视为失败 | ||
| state.forceTerminal('FAILED'); | ||
| return 'FAILED'; | ||
| } | ||
| /** | ||
| * 根据终态将结果写入对应账本 | ||
| * | ||
| * @remarks | ||
| * 账本写入策略: | ||
| * - DONE → appendAssistant:记录 Agent 的完整回复内容 | ||
| * - ABORTED → appendSystem('user_aborted'):记录用户取消事件 | ||
| * - FAILED → appendSystem('run_failed'):记录失败事件及错误信息 | ||
| * | ||
| * 所有写入操作通过 safe* 包装,失败时仅 warn 不抛异常, | ||
| * 确保账本写入失败不影响主流程返回。 | ||
| * | ||
| * @param finalStatus - 已确定的终态 | ||
| * @param deps - 路由依赖(使用 ledger) | ||
| * @param ctx - 调度上下文 | ||
| * @param agentId - 发言 Agent ID | ||
| * @param runId - 关联的 runId | ||
| * @param tag - 日志标签 | ||
| * @param summary - run 摘要(提供 finalText / errorMessage) | ||
| */ | ||
| async function flushLedger(finalStatus, deps, ctx, agentId, runId, tag, summary) { | ||
| switch (finalStatus) { | ||
| case 'DONE': | ||
| // 将 Agent 完整回复持久化,供历史消息回放使用 | ||
| await safeAppendAssistant(deps, ctx.groupId, agentId, summary.finalText ?? '', runId, tag); | ||
| break; | ||
| case 'ABORTED': | ||
| // 被中止时:如果已有部分输出内容,将其作为 assistant 条目持久化, | ||
| // 刷新页面后用户仍能看到中断前的内容。 | ||
| if (summary.finalText) { | ||
| await safeAppendAssistant(deps, ctx.groupId, agentId, summary.finalText, runId, tag); | ||
| } | ||
| // 在 assistant 条目之后写 user_aborted,保证前端按 ts 排序后终止提示在部分内容之后 | ||
| await safeAppendSystem(deps, ctx.groupId, 'user_aborted', agentId, ctx.userId, runId, tag); | ||
| break; | ||
| case 'FAILED': | ||
| // 记录失败事件及错误详情,便于排查问题 | ||
| await safeAppendSystem(deps, ctx.groupId, 'run_failed', agentId, ctx.userId, runId, tag, summary.errorMessage); | ||
| break; | ||
| } | ||
| } |
| /** | ||
| * @file MentionConcurrentRoute —— 路径 B:@ 命中成员串行回复 | ||
| * @description | ||
| * 适用场景:用户消息 @ 了群内成员,被 @ 的成员按顺序串行回复,无需 Leader 参与决策。 | ||
| * | ||
| * 执行流程: | ||
| * 1. 状态转移:PENDING → ROUTING → RUNNING_CHILDREN | ||
| * 2. 为每个被 @ 的成员创建独立的 run(预分配 runId + AbortSignal) | ||
| * 3. 按顺序串行执行所有被 @ 成员的 Agent run | ||
| * 4. 每个成员独立流式输出回复(通过 SocketStreamSink 推流) | ||
| * 5. 所有成员完成后,根据结果汇总终态 → DONE / FAILED / ABORTED | ||
| * | ||
| * 设计要点: | ||
| * - 不涉及 Leader 决策,直接由被 @ 的成员回答 | ||
| * - 每个被 @ 的成员按顺序串行执行,前一个完成后再执行下一个 | ||
| * - 不进行结果汇总,每个成员直接回复用户 | ||
| */ | ||
| // ─── 工具函数 / 常量导入 ─────────────────────────────────────────────────────── | ||
| import { generatePreRunId } from '../../utils/id-generator.js'; | ||
| import { errorToString, normalizeRunStatus } from '../../utils/run-helpers.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| // ─── 路由层公共工具 ───────────────────────────────────────────────────────── | ||
| import { buildTag, createSinkForAgent, safeAppendAssistant, safeAppendSystem } from './route-helpers.js'; | ||
| /** | ||
| * 路径 B —— Mention 串行层 | ||
| * | ||
| * @remarks | ||
| * 实现 DispatchRoute 接口,由 Orchestrator 在 mentions 非空时选择调用。 | ||
| * 内部为每个被 @ 的 agentId 创建独立的 run,按顺序串行执行后汇总终态。 | ||
| */ | ||
| export class MentionConcurrentRoute { | ||
| deps; | ||
| /** 路径标识名,用于日志和 RouteResult 中标记来源 */ | ||
| name = 'mention_concurrent'; | ||
| /** 模块级日志实例 */ | ||
| log = getModuleLogger('group.mention-route'); | ||
| /** | ||
| * @param deps - 由 Orchestrator 注入的共享依赖(runner、ledger、emitter 等) | ||
| */ | ||
| constructor(deps) { | ||
| this.deps = deps; | ||
| } | ||
| /** | ||
| * 执行路径 B(Mention 串行层) | ||
| * | ||
| * @param input - 路径入参,包含调度上下文和状态机 | ||
| * @returns 路径执行结果,包含终态和各 run 摘要 | ||
| */ | ||
| async run(input) { | ||
| const { ctx, state } = input; | ||
| const tag = buildTag(ctx); | ||
| // ── 1. 状态转移:PENDING → ROUTING → RUNNING_CHILDREN ── | ||
| // 若当前仍为初始态 PENDING,先过渡到 ROUTING 表示正在选路 | ||
| if (state.getStatus() === 'PENDING') { | ||
| state.transitionTo('ROUTING'); | ||
| } | ||
| // 进入 RUNNING_CHILDREN 表示子 run 开始执行 | ||
| state.transitionTo('RUNNING_CHILDREN'); | ||
| // ── 2. 串行执行所有被 @ 成员的 run ── | ||
| // 按 mentions 数组顺序逐个执行,前一个完成后再启动下一个 | ||
| const runResults = []; | ||
| for (const agentId of ctx.mentions) { | ||
| const result = await this.executeMentionRun(ctx, agentId, tag); | ||
| runResults.push(result); | ||
| } | ||
| // ── 3. 汇总终态 ── | ||
| // 根据所有 run 的结果决定整体状态(DONE / FAILED / ABORTED) | ||
| const finalStatus = this.determineFinalStatus(runResults, state); | ||
| // ── 4. ABORTED 时写入 user_aborted 系统事件 ── | ||
| // 在所有 run 结束(部分内容已 appendAssistant)之后再写, | ||
| // 保证前端按 ts 排序后终止提示在 agent 部分回复内容之后。 | ||
| if (finalStatus === 'ABORTED') { | ||
| try { | ||
| await this.deps.ledger.appendSystem({ | ||
| groupId: ctx.groupId, | ||
| event: 'user_aborted', | ||
| userId: ctx.userId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| this.log.warn(`${tag} appendSystem(user_aborted) 失败: ${err}`); | ||
| } | ||
| } | ||
| this.log.info(`${tag} finished status=${finalStatus} mentionCount=${ctx.mentions.length} ` + | ||
| `successCount=${runResults.filter((r) => r.status === 'done').length}`); | ||
| return { | ||
| route: this.name, | ||
| finalStatus, | ||
| runs: runResults, | ||
| error: finalStatus === 'FAILED' ? new Error('一个或多个 Mention run 执行失败') : undefined, | ||
| }; | ||
| } | ||
| /** | ||
| * 为单个被 @ 的成员执行 Agent run | ||
| * | ||
| * @param ctx - 调度上下文 | ||
| * @param agentId - 被 @ 的成员 Agent ID | ||
| * @param tag - 日志前缀 | ||
| * @returns 该 run 的执行摘要(不会抛出异常) | ||
| */ | ||
| async executeMentionRun(ctx, agentId, tag) { | ||
| const { runner, runRegistry } = this.deps; | ||
| const individualTag = `${tag} agent=${agentId}`; | ||
| // ── 1. 预分配 runId 并注册 AbortSignal ── | ||
| // 生成唯一 runId,同时在 RunRegistry 中注册以支持级联 abort | ||
| const preRunId = generatePreRunId(ctx.conversationId, 'mention'); | ||
| const abortSignal = runRegistry.registerRun(ctx.conversationId, preRunId, agentId); | ||
| // ── 2. 构造独立的流式输出上下文(每个成员独立推流) ── | ||
| // 每个被 @ 成员拥有独立的 replyMsgId,前端据此区分不同成员的流式回复 | ||
| const originalMsgId = ctx.msgId ?? generateMsgId(); | ||
| const sinkContext = createSinkForAgent(this.deps, ctx, agentId, preRunId, originalMsgId); | ||
| // 在 runner.run 之前显式发 typing_start,通知前端该成员即将开始输出 | ||
| sinkContext.emitStart(); | ||
| // ── 3. 执行 Agent run ── | ||
| let runOutput; | ||
| try { | ||
| runOutput = await runner.run({ | ||
| groupId: ctx.groupId, | ||
| agentId, | ||
| userId: ctx.userId, | ||
| role: 'mention', // 标记为 mention 角色 | ||
| message: ctx.userMessage, | ||
| systemPrompt: '', // Mention 场景直接使用 Agent 默认配置,不注入额外 system prompt | ||
| abortSignal, // 传入 abort 信号以支持用户中止 | ||
| sinkContext, // 统一流式输出上下文 | ||
| preassignedRunId: preRunId, // 使用预分配的 runId 保证一致性 | ||
| // 传递文件附件,使 AI 能感知用户上传的文件 | ||
| attachments: ctx.files?.filter((f) => f.uri).map((f) => ({ | ||
| name: f.name, | ||
| mimeType: f.mimeType || 'application/octet-stream', | ||
| url: f.uri, | ||
| localPath: f.localPath, | ||
| })), | ||
| }); | ||
| } | ||
| catch (err) { | ||
| // runner.run 内部已兜底,此处为极端异常(如 OOM)的最终防线 | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| this.log.error(`${individualTag} runner 异常: ${e.message}`); | ||
| // 异常时也需注销 run,避免 RunRegistry 泄漏 | ||
| runRegistry.unregisterRun(ctx.conversationId, preRunId); | ||
| return { runId: preRunId, agentId, status: 'failed', errorMessage: errorToString(e) }; | ||
| } | ||
| // ── 4. 处理 run 结果 ── | ||
| // 优先使用 runner 返回的真实 runId(SDK 可能重新分配),兜底用预分配 ID | ||
| const realRunId = runOutput.runId || preRunId; | ||
| const status = normalizeRunStatus(runOutput.status); | ||
| const summary = { runId: realRunId, agentId, status }; | ||
| if (runOutput.status === 'ok') { | ||
| // 成功:记录最终文本并写入 assistant 账本,供历史消息回放 | ||
| summary.finalText = runOutput.finalText; | ||
| await safeAppendAssistant(this.deps, ctx.groupId, agentId, runOutput.finalText, realRunId, individualTag); | ||
| this.log.info(`${individualTag} 回复完成 bytes=${runOutput.finalText.length}`); | ||
| } | ||
| else if (runOutput.status === 'aborted') { | ||
| // 被中止:如果已有部分输出内容,将其作为 assistant 条目持久化, | ||
| // 刷新页面后用户仍能看到中断前的内容。 | ||
| // user_aborted 系统事件由 run 方法在所有 run 结束后统一写入,保证 ts 顺序正确。 | ||
| summary.errorMessage = 'aborted'; | ||
| if (runOutput.finalText) { | ||
| summary.finalText = runOutput.finalText; | ||
| await safeAppendAssistant(this.deps, ctx.groupId, agentId, runOutput.finalText, realRunId, individualTag); | ||
| this.log.info(`${individualTag} 中止但已有部分内容,已持久化 bytes=${runOutput.finalText.length}`); | ||
| } | ||
| } | ||
| else { | ||
| // 失败:记录错误信息并写入 system 账本,便于排查问题 | ||
| summary.errorMessage = errorToString(runOutput.error); | ||
| await this.safeAppendSystemEvent(ctx, agentId, runOutput, realRunId, individualTag); | ||
| } | ||
| // ── 5. 收尾:从注册表中注销 run,释放 abort 监听资源 ── | ||
| runRegistry.unregisterRun(ctx.conversationId, preRunId); | ||
| return summary; | ||
| } | ||
| /** | ||
| * 根据所有 run 的结果汇总终态并转移状态机 | ||
| * | ||
| * @remarks | ||
| * 优先级:ABORTED > FAILED > DONE | ||
| * 只要有一个 run 被 abort,整体标记为 ABORTED; | ||
| * 只要有一个 run 失败(且无 abort),整体标记为 FAILED。 | ||
| */ | ||
| determineFinalStatus(results, state) { | ||
| // 检查是否存在被用户主动中止的 run | ||
| const hasAborted = results.some((r) => r.status === 'aborted'); | ||
| // 检查是否存在执行失败的 run | ||
| const hasFailed = results.some((r) => r.status === 'failed'); | ||
| // 优先级:ABORTED > FAILED > DONE | ||
| if (hasAborted) { | ||
| state.forceTerminal('ABORTED'); | ||
| return 'ABORTED'; | ||
| } | ||
| if (hasFailed) { | ||
| state.forceTerminal('FAILED'); | ||
| return 'FAILED'; | ||
| } | ||
| // 全部成功,正常转移到 DONE | ||
| state.transitionTo('DONE'); | ||
| return 'DONE'; | ||
| } | ||
| /** | ||
| * 安全写入 system 事件账本 | ||
| * | ||
| * @remarks | ||
| * 将非成功状态的 run 信息记录到系统账本,便于运维排查和审计。 | ||
| * 根据 run 状态写入不同事件类型: | ||
| * - aborted → user_aborted 事件(用户主动中止) | ||
| * - 其他失败 → run_failed 事件(Agent 执行异常) | ||
| * | ||
| * 异常仅 warn 级别记录,不影响主流程。 | ||
| */ | ||
| async safeAppendSystemEvent(ctx, agentId, runOutput, runId, tag) { | ||
| try { | ||
| // 注意:aborted 分支已在上层处理(保存部分内容为 assistant 条目), | ||
| // 此方法现在仅处理非 abort 的失败场景。 | ||
| if (runOutput.status !== 'aborted') { | ||
| // Agent 执行失败:记录 run_failed 事件及错误详情 | ||
| await safeAppendSystem(this.deps, ctx.groupId, 'run_failed', agentId, ctx.userId, runId, tag, errorToString(runOutput.error)); | ||
| } | ||
| } | ||
| catch (err) { | ||
| this.log.warn(`${tag} appendSystem 失败: ${err}`); | ||
| } | ||
| } | ||
| } |
| /** | ||
| * @fileoverview 路由层公共辅助工具集 | ||
| * | ||
| * @description | ||
| * 提取自各路由(Leader / Mention)的通用辅助方法, | ||
| * 供 Planning / SelfAnswer / Dispatch / MentionConcurrent 各路径复用。 | ||
| * | ||
| * 包含以下能力: | ||
| * - 日志标签构建(buildTag) | ||
| * - 群成员能力卡解析(resolveMemberCards) | ||
| * - Leader 系统提示词构造(buildLeaderPrompt) | ||
| * - 统一流式输出上下文工厂封装(createSinkForAgent) | ||
| * - 安全账本写入(safeAppendAssistant / safeAppendSystem) | ||
| * | ||
| * @module route-helpers | ||
| */ | ||
| // ─── 工具函数 ────────────────────────────────────────────────────────────────────── | ||
| import { createStreamReplyConfig } from '../../../streaming/stream-reply-sink.js'; | ||
| import { getModuleLogger } from '../../../utils/logger.js'; | ||
| import { generateMsgId } from '../../../dedup.js'; | ||
| import { CHANNEL_KEY } from '../../../config.js'; | ||
| /** 模块级日志记录器 */ | ||
| const log = getModuleLogger('group.leader-route'); | ||
| /** | ||
| * 禁用的内置工具列表(所有阶段通用) | ||
| * | ||
| * @remarks | ||
| * 这些工具在 Leader 编排场景下不允许被 Agent 调用, | ||
| * 因为会话管理和子 Agent 调度由编排层统一控制。 | ||
| */ | ||
| export const DENY_TOOLS = [ | ||
| 'sessions_spawn', | ||
| 'sessions_send', | ||
| 'sessions_list', | ||
| 'sessions_history', | ||
| 'sessions_yield', | ||
| 'session_status', | ||
| 'sessions.spawn', | ||
| 'subagents', | ||
| 'agents_list', | ||
| ]; | ||
| /** | ||
| * 构建日志标签 | ||
| * | ||
| * @param ctx - 调度上下文 | ||
| * @returns 格式为 `[channel conv=xxx group=xxx]` 的标签字符串 | ||
| */ | ||
| export function buildTag(ctx) { | ||
| return `[${CHANNEL_KEY} conv=${ctx.conversationId} group=${ctx.groupId}]`; | ||
| } | ||
| /** | ||
| * 安全解析群成员能力卡 | ||
| * | ||
| * @remarks | ||
| * 通过 SoulResolver 将群成员元信息解析为结构化的能力卡。 | ||
| * 解析失败时不抛出异常,降级返回空数组(Planning 阶段将视为无可派活成员)。 | ||
| * | ||
| * @param deps - 路由依赖(使用 soulResolver) | ||
| * @param ctx - 调度上下文(使用 meta.members) | ||
| * @param tag - 日志标签 | ||
| * @returns 解析后的能力卡数组,失败时为空数组 | ||
| */ | ||
| export function resolveMemberCards(deps, ctx, tag) { | ||
| try { | ||
| return deps.soulResolver.resolveMany(ctx.meta.members); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} 解析群成员能力卡失败: ${err},降级为空数组`); | ||
| return []; | ||
| } | ||
| } | ||
| /** | ||
| * 构造 Leader 系统提示词(不含 Planning 指令) | ||
| * | ||
| * @remarks | ||
| * 用于自答路径,构建 Leader 的角色定义和群成员信息提示词。 | ||
| * 与 Planning 阶段的 `buildPlanner` 不同,此处不包含 JSON 输出格式约束。 | ||
| * 构建失败时返回空字符串,Agent 将以默认行为运行。 | ||
| * | ||
| * @param deps - 路由依赖(使用 promptBuilder) | ||
| * @param agentId - Leader Agent ID | ||
| * @param memberCards - 群成员能力卡 | ||
| * @param tag - 日志标签 | ||
| * @returns 系统提示词字符串,失败时为空 | ||
| */ | ||
| export function buildLeaderPrompt(deps, agentId, memberCards, tag) { | ||
| try { | ||
| return deps.promptBuilder.buildLeader({ leaderAgentId: agentId, memberCards }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} 构建 Leader 提示词失败: ${err}`); | ||
| return ''; | ||
| } | ||
| } | ||
| /** | ||
| * 构造统一流式输出上下文(公共逻辑提取) | ||
| * | ||
| * @remarks | ||
| * 封装 `createStreamReplyConfig` 的参数组装逻辑, | ||
| * 自动从 ctx 和 deps 中提取所需字段,调用方只需关注业务参数。 | ||
| * 返回值包含完整的 StreamReplySinkContext,可直接传给 AgentRunner。 | ||
| * | ||
| * @param deps - 路由依赖(使用 emitter) | ||
| * @param ctx - 调度上下文(使用 userId、groupId、conversationId) | ||
| * @param agentId - 当前发言 Agent ID | ||
| * @param runId - 预分配的 runId | ||
| * @param originalMsgId - 触发本回合的客户端原始 msgId | ||
| * @param options - 可选配置(parentRunId) | ||
| * @returns StreamReplySinkContext 实例 | ||
| */ | ||
| export function createSinkForAgent(deps, ctx, agentId, runId, originalMsgId, options) { | ||
| const replyMsgId = generateMsgId(); | ||
| // 构造 SignalContext(与私聊一致的信号上下文) | ||
| const signalCtx = { | ||
| emitter: deps.emitter, | ||
| targetId: ctx.userId, | ||
| replyMsgId, | ||
| originalMsgId, | ||
| agentId, | ||
| }; | ||
| return createStreamReplyConfig({ | ||
| emitter: deps.emitter, | ||
| targetId: ctx.userId, | ||
| replyMsgId, | ||
| originalMsgId, | ||
| effectiveApiKey: ctx.effectiveApiKey ?? '', // 群聊场景:从 DispatchContext 获取 apiKey,用于 COS 上传 | ||
| agentId, | ||
| // 群聊扩展字段 | ||
| groupId: ctx.groupId, | ||
| runId, | ||
| parentRunId: options?.parentRunId ?? null, | ||
| conversationId: ctx.conversationId, | ||
| }, { | ||
| // 群聊不让 SDK 自动 deliver 给 channel | ||
| suppressTyping: true, | ||
| // 预指定 runId | ||
| runId, | ||
| // abort 信号由 AgentRunner 层注入,此处不传 | ||
| }, signalCtx); | ||
| } | ||
| /** | ||
| * 安全写 assistant 账本 | ||
| * | ||
| * @remarks | ||
| * 将 Agent 的回复内容持久化到群账本。 | ||
| * 写入失败时仅记录 warn 日志,不影响主流程执行。 | ||
| * | ||
| * @param deps - 路由依赖(使用 ledger) | ||
| * @param groupId - 群 ID | ||
| * @param agentId - 发言 Agent ID | ||
| * @param content - 回复内容 | ||
| * @param runId - 关联的 runId | ||
| * @param tag - 日志标签 | ||
| */ | ||
| export async function safeAppendAssistant(deps, groupId, agentId, content, runId, tag) { | ||
| try { | ||
| await deps.ledger.appendAssistant({ groupId, agentId, content, runId, parentRunId: null }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} 写入 assistant 账本失败: ${err}`); | ||
| } | ||
| } | ||
| /** | ||
| * 安全写 system 账本 | ||
| * | ||
| * @remarks | ||
| * 将系统事件(如 user_aborted、run_failed)持久化到群账本。 | ||
| * 写入失败时仅记录 warn 日志,不影响主流程执行。 | ||
| * | ||
| * @param deps - 路由依赖(使用 ledger) | ||
| * @param groupId - 群 ID | ||
| * @param event - 系统事件类型 | ||
| * @param agentId - 关联 Agent ID | ||
| * @param userId - 触发用户 ID | ||
| * @param runId - 关联的 runId | ||
| * @param tag - 日志标签 | ||
| * @param content - 可选的事件附加内容(如错误信息) | ||
| */ | ||
| export async function safeAppendSystem(deps, groupId, event, agentId, userId, runId, tag, content) { | ||
| try { | ||
| await deps.ledger.appendSystem({ groupId, event, agentId, userId, runId, content }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`${tag} 写入 system 账本失败: ${err}`); | ||
| } | ||
| } |
| /** | ||
| * routes 子模块共享类型 | ||
| * | ||
| * 两种路径(Mention / LeaderOrchestration)共享的接口契约: | ||
| * - RouteDeps 路径执行依赖(由 Orchestrator 门面注入,避免重复构造) | ||
| * - RouteResult 路径执行结果(用于 Orchestrator 决定回合是否落地为 DONE / FAILED) | ||
| */ | ||
| export {}; |
| /** | ||
| * 群组清理服务 | ||
| * 专门负责群组数据的异步清理操作,包括物理文件删除和Agent session清理 | ||
| * | ||
| * 主要功能: | ||
| * - 群组目录清理:递归删除群组物理存储数据 | ||
| * - Agent session清理:删除相关Agent的session文件 | ||
| * - 错误处理:完善的异常捕获和日志记录 | ||
| * | ||
| * 设计特点: | ||
| * - 异步操作避免阻塞主线程 | ||
| * - 安全的文件删除操作 | ||
| * - 详细的日志记录 | ||
| */ | ||
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| import os from 'os'; | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| export class GroupCleanupService { | ||
| /** | ||
| * 群组存储服务实例 | ||
| */ | ||
| storage; | ||
| /** | ||
| * 构造函数 | ||
| * 初始化清理服务的依赖组件 | ||
| */ | ||
| constructor() { | ||
| this.storage = new GroupStorageCore(); | ||
| } | ||
| /** | ||
| * 异步清理群数据 | ||
| * 在后台删除群组的物理存储数据 | ||
| * | ||
| * @param groupId - 要清理的群组ID | ||
| * | ||
| * 清理内容: | ||
| * - 删除群组目录(递归删除所有文件) | ||
| * - 清理各Agent的session文件 | ||
| * - 错误处理和日志记录 | ||
| */ | ||
| async cleanupGroup(groupId) { | ||
| try { | ||
| const groupDir = path.join(this.storage.getBasePath(), groupId); | ||
| await fs.rm(groupDir, { recursive: true, force: true }); | ||
| // 清理各Agent的session文件 | ||
| await this.cleanupAgentSessions(groupId); | ||
| console.log(`群组 ${groupId} 清理完成`); | ||
| } | ||
| catch (error) { | ||
| console.error(`清理群组 ${groupId} 失败:`, error); | ||
| } | ||
| } | ||
| /** | ||
| * 异步清空群组聊天账本 | ||
| * 仅清理消息账本相关文件,保留 meta.json 等群组元数据, | ||
| * 同时清理各 Agent 与该群组关联的 session,保证下一轮对话从干净状态开始。 | ||
| * | ||
| * @param groupId - 要清空账本的群组ID | ||
| * | ||
| * 清理内容: | ||
| * - 删除分片索引文件 index.json | ||
| * - 删除所有 chat-*.jsonl 账本分片文件 | ||
| * - 清理各 Agent 的群组 session 文件 | ||
| * - 保留 meta.json 等群组元数据,不影响群成员、置顶等配置 | ||
| */ | ||
| async clearGroupTranscript(groupId) { | ||
| try { | ||
| const groupDir = path.join(this.storage.getBasePath(), groupId); | ||
| // 群组目录不存在时无需清理 | ||
| try { | ||
| await fs.access(groupDir); | ||
| } | ||
| catch { | ||
| console.warn(`群组 ${groupId} 目录不存在,跳过账本清理`); | ||
| // 仍然尝试清理 Agent session,避免有残留 | ||
| await this.cleanupAgentSessions(groupId); | ||
| return; | ||
| } | ||
| // 仅删除账本相关文件(保留 meta.json) | ||
| const entries = await fs.readdir(groupDir); | ||
| await Promise.all(entries | ||
| .filter((name) => name === 'index.json' || /^chat-.*\.jsonl$/.test(name)) | ||
| .map((name) => fs.rm(path.join(groupDir, name), { force: true }))); | ||
| // 清理各 Agent 的 session 文件,保证对话上下文同步重置 | ||
| await this.cleanupAgentSessions(groupId); | ||
| console.log(`群组 ${groupId} 账本清理完成(已保留元数据)`); | ||
| } | ||
| catch (error) { | ||
| console.error(`清空群组 ${groupId} 账本失败:`, error); | ||
| } | ||
| } | ||
| /** | ||
| * 清理群组相关Agent的session文件 | ||
| * 删除群组中所有Agent的session数据,避免残留 | ||
| * | ||
| * @param groupId - 要清理的群组ID | ||
| */ | ||
| async cleanupAgentSessions(groupId) { | ||
| try { | ||
| // 读取群组元数据获取成员信息 | ||
| const groupMeta = await this.storage.readGroupMeta(groupId); | ||
| if (!groupMeta) { | ||
| console.warn(`群组 ${groupId} 元数据不存在,跳过Agent session清理`); | ||
| return; | ||
| } | ||
| // 获取所有需要清理的Agent ID(包括默认代理) | ||
| const memberIds = groupMeta.members.map((m) => m.agentId); | ||
| const agentIds = [...new Set([...memberIds, groupMeta.defaultAgentId])]; | ||
| for (const agentId of agentIds) { | ||
| await this.cleanupAgentSessionFiles(agentId, groupId); | ||
| } | ||
| console.log(`群组 ${groupId} 相关Agent session文件清理完成`); | ||
| } | ||
| catch (error) { | ||
| console.error(`清理群组 ${groupId} 的Agent session文件失败:`, error); | ||
| } | ||
| } | ||
| /** | ||
| * 清理单个Agent的群组相关session文件 | ||
| * 删除Agent session目录中与该群组相关的文件,并更新sessions.json配置 | ||
| * | ||
| * @param agentId - Agent ID | ||
| * @param groupId - 群组ID | ||
| */ | ||
| async cleanupAgentSessionFiles(agentId, groupId) { | ||
| try { | ||
| const sessionsDir = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions'); | ||
| // 检查session目录是否存在 | ||
| try { | ||
| await fs.access(sessionsDir); | ||
| } | ||
| catch { | ||
| // 目录不存在,无需清理 | ||
| return; | ||
| } | ||
| // 读取sessions.json文件 | ||
| const sessionsJsonPath = path.join(sessionsDir, 'sessions.json'); | ||
| let sessionsData = {}; | ||
| try { | ||
| const sessionsContent = await fs.readFile(sessionsJsonPath, 'utf-8'); | ||
| sessionsData = JSON.parse(sessionsContent); | ||
| } | ||
| catch (error) { | ||
| // sessions.json文件不存在或解析失败,无需清理 | ||
| console.warn(`无法读取Agent ${agentId} 的sessions.json文件:`, error); | ||
| return; | ||
| } | ||
| // 构建sessionKey格式:agent:<agentId>:<channel>:group:<groupId> | ||
| const sessionKey = `agent:${agentId}:${CHANNEL_KEY}:group:${groupId}`; | ||
| // 在sessions.json中查找对应的session配置 | ||
| const sessionEntry = sessionsData[sessionKey]; | ||
| if (!sessionEntry || !sessionEntry.sessionId) { | ||
| // 没有找到对应的session配置,无需清理 | ||
| console.log(`Agent ${agentId} 的session.json中未找到群组 ${groupId} 的session配置`); | ||
| return; | ||
| } | ||
| const sessionId = sessionEntry.sessionId; | ||
| const jsonlFileName = `${sessionId}.jsonl`; | ||
| const jsonlFilePath = path.join(sessionsDir, jsonlFileName); | ||
| // 删除对应的jsonl文件 | ||
| try { | ||
| await fs.access(jsonlFilePath); | ||
| await fs.rm(jsonlFilePath, { force: true }); | ||
| console.log(`删除Agent ${agentId} 的群组session文件: ${jsonlFileName}`); | ||
| } | ||
| catch (error) { | ||
| console.warn(`无法删除Agent ${agentId} 的session文件 ${jsonlFileName}:`, error); | ||
| } | ||
| // 从sessions.json中删除对应的session配置(重要:避免文件膨胀) | ||
| if (sessionsData[sessionKey]) { | ||
| delete sessionsData[sessionKey]; | ||
| // 保存更新后的sessions.json文件 | ||
| await fs.writeFile(sessionsJsonPath, JSON.stringify(sessionsData, null, 2), 'utf-8'); | ||
| console.log(`从Agent ${agentId} 的sessions.json中删除群组 ${groupId} 的session配置`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error(`清理Agent ${agentId} 的session文件失败:`, error); | ||
| } | ||
| } | ||
| } |
| /** | ||
| * 群组创建服务 | ||
| * 专门负责群组的创建操作,包括参数校验、UUID生成和并发控制 | ||
| * | ||
| * 主要功能: | ||
| * - 参数校验:验证群组名称、成员列表、默认代理等参数 | ||
| * - UUID生成:为新建群组生成唯一标识符 | ||
| * - 并发控制:确保创建操作的原子性 | ||
| * - 元数据构建:创建完整的群组元数据 | ||
| * | ||
| * 设计特点: | ||
| * - 完整的参数验证链 | ||
| * - 线程安全的创建操作 | ||
| * - 与存储服务解耦 | ||
| */ | ||
| import { randomUUID } from 'node:crypto'; | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| import { ConcurrencyManager } from '../storage/concurrency-manager.js'; | ||
| export class GroupCreationService { | ||
| /** | ||
| * 群组存储服务实例 | ||
| */ | ||
| storage; | ||
| /** | ||
| * 并发控制管理器实例 | ||
| */ | ||
| concurrency; | ||
| /** | ||
| * 构造函数 | ||
| * 初始化创建服务的依赖组件 | ||
| */ | ||
| constructor() { | ||
| this.storage = new GroupStorageCore(); | ||
| this.concurrency = new ConcurrencyManager(); | ||
| } | ||
| /** | ||
| * 创建新群组 | ||
| * 执行完整的群组创建流程 | ||
| * | ||
| * @param creatorId - 创建者用户ID | ||
| * @param name - 群组名称 | ||
| * @param desc - 群组描述 | ||
| * @param members - 成员列表(每个元素包含 agentId / agentName / agentDesc) | ||
| * @param defaultAgentId - 默认代理ID | ||
| * @returns 新建群组的元数据 | ||
| * | ||
| * 创建流程: | ||
| * 1. 参数校验 | ||
| * 2. 生成群组ID | ||
| * 3. 构建元数据 | ||
| * 4. 使用锁确保原子性 | ||
| * 5. 写入元数据和创建事件 | ||
| */ | ||
| async createGroup(creatorId, name, desc = '', members, defaultAgentId) { | ||
| // 参数校验 | ||
| this.validateCreateParams(name, members, defaultAgentId); | ||
| const groupId = randomUUID(); | ||
| const now = Date.now(); | ||
| const groupMeta = { | ||
| groupId, | ||
| ownerIds: [creatorId], | ||
| name, | ||
| desc, | ||
| members, | ||
| defaultAgentId, | ||
| pinned: false, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| }; | ||
| // 使用锁确保原子性 | ||
| await this.concurrency.withLock(`group_create_${groupId}`, async () => { | ||
| await this.storage.writeGroupMeta(groupId, groupMeta); | ||
| // 写入群创建事件(账本 extra.members 仅记录 agentId 列表, | ||
| // 因为账本 system 事件用于追踪「事件涉及的参与者 ID」,不承载完整业务对象) | ||
| await this.storage.appendTranscriptEntry(groupId, { | ||
| role: 'system', | ||
| event: 'group_created', | ||
| msgId: `sys:group_created:${groupId}`, | ||
| extra: { | ||
| members: members, | ||
| }, | ||
| }); | ||
| }, 30000); // 30秒超时 | ||
| return groupMeta; | ||
| } | ||
| /** | ||
| * 验证创建参数 | ||
| * 检查群组创建参数的有效性 | ||
| * | ||
| * @param name - 群组名称 | ||
| * @param members - 成员列表 | ||
| * @param defaultAgentId - 默认代理ID | ||
| * @throws 如果参数无效则抛出错误 | ||
| */ | ||
| validateCreateParams(name, members, defaultAgentId) { | ||
| if (!name || name.length > 50) { | ||
| throw new Error('无效的群组名称:不能为空且长度不超过50个字符'); | ||
| } | ||
| if (!members || members.length === 0 || members.length > 10) { | ||
| throw new Error('无效的成员列表:必须有1-10个成员'); | ||
| } | ||
| // 单成员对象字段必填校验:避免 agentId 缺失导致后续逻辑越界 | ||
| for (let i = 0; i < members.length; i++) { | ||
| const m = members[i]; | ||
| if (!m || typeof m !== 'object') { | ||
| throw new Error(`无效的成员条目[${i}]:必须是对象`); | ||
| } | ||
| if (!m.agentId || typeof m.agentId !== 'string') { | ||
| throw new Error(`无效的成员条目[${i}]:缺少有效的 agentId`); | ||
| } | ||
| } | ||
| const memberIds = members.map((m) => m.agentId); | ||
| if (!defaultAgentId || !memberIds.includes(defaultAgentId)) { | ||
| throw new Error('无效的主 Agent:必须在成员列表中'); | ||
| } | ||
| // 去重校验 | ||
| const uniqueMembers = [...new Set(memberIds)]; | ||
| if (uniqueMembers.length !== memberIds.length) { | ||
| throw new Error('成员列表中存在重复的代理ID'); | ||
| } | ||
| } | ||
| } |
| /** | ||
| * 群组删除服务 | ||
| * 专门负责群组的解散操作,包括权限验证和软删除标记 | ||
| * | ||
| * 主要功能: | ||
| * - 权限验证:检查用户是否有权限解散群组 | ||
| * - 软删除标记:标记群组为解散状态 | ||
| * - 并发控制:确保删除操作的原子性 | ||
| * - 异步清理触发:启动后台清理任务 | ||
| * | ||
| * 设计特点: | ||
| * - 安全的软删除机制 | ||
| * - 异步清理避免阻塞 | ||
| * - 与清理服务解耦 | ||
| */ | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| import { ConcurrencyManager } from '../storage/concurrency-manager.js'; | ||
| import { GroupCleanupService } from './group-cleanup-service.js'; | ||
| export class GroupDeletionService { | ||
| /** | ||
| * 群组存储服务实例 | ||
| */ | ||
| storage; | ||
| /** | ||
| * 并发控制管理器实例 | ||
| */ | ||
| concurrency; | ||
| /** | ||
| * 群组清理服务实例 | ||
| */ | ||
| cleanupService; | ||
| /** | ||
| * 构造函数 | ||
| * 初始化删除服务的依赖组件 | ||
| */ | ||
| constructor() { | ||
| this.storage = new GroupStorageCore(); | ||
| this.concurrency = new ConcurrencyManager(); | ||
| this.cleanupService = new GroupCleanupService(); | ||
| } | ||
| /** | ||
| * 解散群组 | ||
| * 执行群组的解散操作 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param deleterId - 解散者用户ID | ||
| * @returns 解散后的群组元数据 | ||
| * | ||
| * 解散流程: | ||
| * 1. 权限验证(群组存在且用户是拥有者) | ||
| * 2. 标记群组为解散状态 | ||
| * 3. 异步启动清理任务 | ||
| */ | ||
| async deleteGroup(groupId, deleterId) { | ||
| return await this.concurrency.withLock(`group_delete_${groupId}`, async () => { | ||
| const existingMeta = await this.storage.readGroupMeta(groupId); | ||
| if (!existingMeta) { | ||
| throw new Error('群组不存在'); | ||
| } | ||
| if (!existingMeta.ownerIds.includes(deleterId)) { | ||
| throw new Error('权限不足'); | ||
| } | ||
| // 标记解散 | ||
| const dismissedMeta = { | ||
| ...existingMeta, | ||
| dismissed: true, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| await this.storage.writeGroupMeta(groupId, dismissedMeta); | ||
| // 异步清理(不阻塞响应) | ||
| setTimeout(() => this.cleanupService.cleanupGroup(groupId), 0); | ||
| return dismissedMeta; | ||
| }, 30000); // 30秒超时 | ||
| } | ||
| /** | ||
| * 清空群组聊天账本 | ||
| * 仅清理消息记录与相关 Agent session,保留群组元数据(成员、名称、置顶等不变) | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param operatorId - 操作者用户ID(要求是群拥有者) | ||
| * @returns 操作完成后的群组元数据(仅刷新 updatedAt) | ||
| * | ||
| * 清空流程: | ||
| * 1. 权限验证(群组存在、未解散、用户是拥有者) | ||
| * 2. 异步触发账本清理任务(保留 meta.json) | ||
| * 3. 刷新 updatedAt 并返回最新元数据 | ||
| */ | ||
| async clearGroup(groupId, operatorId) { | ||
| return await this.concurrency.withLock(`group_clear_${groupId}`, async () => { | ||
| const existingMeta = await this.storage.readGroupMeta(groupId); | ||
| if (!existingMeta) { | ||
| throw new Error('群组不存在'); | ||
| } | ||
| if (existingMeta.dismissed) { | ||
| throw new Error('群组已解散'); | ||
| } | ||
| if (!existingMeta.ownerIds.includes(operatorId)) { | ||
| throw new Error('权限不足'); | ||
| } | ||
| // 仅刷新更新时间,元数据其它字段保持不变 | ||
| const updatedMeta = { | ||
| ...existingMeta, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| await this.storage.writeGroupMeta(groupId, updatedMeta); | ||
| // 异步清空账本(不阻塞响应) | ||
| setTimeout(() => this.cleanupService.clearGroupTranscript(groupId), 0); | ||
| return updatedMeta; | ||
| }, 30000); // 30秒超时 | ||
| } | ||
| } |
| /** | ||
| * @file group-history-service.ts | ||
| * @description | ||
| * 群历史消息查询服务(业务层)。 | ||
| * | ||
| * 职责: | ||
| * 1. 校验目标群是否存在 + 当前用户是否为 ownerIds 成员(越权拦截); | ||
| * 2. 调用 GroupStorageCore.readTranscriptEntries 进行底层游标分页读取; | ||
| * 3. 统一返回 { messages, hasMore, nextCursor } 给 socket 层, | ||
| * 失败兜底由 socket 层捕获异常处理(与现有 list/create/update/delete | ||
| * 同款 try/catch 模式)。 | ||
| * | ||
| * 与 storage 层的分工: | ||
| * - storage 层只关心"目录里有什么 jsonl",不感知群的权限语义; | ||
| * - service 层负责"这个群能不能给这个用户看",不直接接触文件系统。 | ||
| * | ||
| * 设计参考:群聊方案 §6.5 群历史消息模块 | ||
| */ | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| /** | ||
| * 群历史消息服务。 | ||
| * | ||
| * 单例(在 socket 层 lazy new),无内部状态,复用同一个 GroupStorageCore | ||
| * 实例以共享 MessageReader 内的文件缓存。 | ||
| */ | ||
| export class GroupHistoryService { | ||
| /** 群存储核心:负责读 meta.json 鉴权 + 读 chat.jsonl 取历史 */ | ||
| storage; | ||
| constructor(storage = new GroupStorageCore()) { | ||
| this.storage = storage; | ||
| } | ||
| /** | ||
| * 清除内部消息读取器的文件缓存。 | ||
| * 在成员变更等写入操作后调用,确保下次 getHistory 读取到最新数据。 | ||
| */ | ||
| clearReaderCache() { | ||
| this.storage.clearReaderCache(); | ||
| } | ||
| /** | ||
| * 查询群历史消息。 | ||
| * | ||
| * 流程: | ||
| * 1. 读 meta.json:群不存在 → 抛 'Group not found'; | ||
| * 2. 校验 ownerIds.includes(userId):不通过 → 抛 'Permission denied'; | ||
| * 3. 调 storage.readTranscriptEntries 走分片读取 + 时间游标分页; | ||
| * 4. storage 返回 { entries, hasMore, nextCursor },按协议字段名映射。 | ||
| * | ||
| * @throws 'Group not found' | 'Permission denied' | ||
| * | 文件读取异常(由 storage 层抛出,socket 层兜底回 error) | ||
| */ | ||
| async getHistory(query) { | ||
| const { groupId, userId, limit, before, chatOnly } = query; | ||
| // ① 群存在性 + 越权校验 | ||
| const meta = await this.storage.readGroupMeta(groupId); | ||
| if (!meta) { | ||
| throw new Error('Group not found'); | ||
| } | ||
| if (!meta.ownerIds.includes(userId)) { | ||
| throw new Error('Permission denied'); | ||
| } | ||
| // ② 读取群账本(storage 内部已做 ENOENT 兜底 → 空结果) | ||
| const result = await this.storage.readTranscriptEntries(groupId, { | ||
| limit, | ||
| before, | ||
| chatOnly, | ||
| }); | ||
| return { | ||
| messages: result.entries, | ||
| hasMore: result.hasMore, | ||
| nextCursor: result.nextCursor, | ||
| }; | ||
| } | ||
| } |
| /** | ||
| * 群成员管理服务 | ||
| * | ||
| * 负责群组成员的「加入」与「移除」两类原子操作,是群聊方案 §6.4 | ||
| * (`group:member:request`) 在领域层的具体实现。 | ||
| * | ||
| * 关键约束(来自群聊方案 §2.3 A4 / §6.4.3): | ||
| * - 加成员:当前 members.length < 10,且 agentId 必须合法(在 agents.list 中) | ||
| * 且不在 members 中; | ||
| * - 踢成员:agentId !== meta.defaultAgentId(不能踢主 Agent),且 agentId | ||
| * 必须 ∈ members; | ||
| * - 操作必须由 ownerIds 中的用户发起(与 update/delete 一致,避免越权); | ||
| * - 成员变更后写入 system 事件到群账本:member_added / member_removed, | ||
| * 便于前端回放历史时还原"加群/踢出"动作(§3.4 群账本设计)。 | ||
| * | ||
| * 设计选择: | ||
| * - 与 GroupUpdateService 一样使用 ConcurrencyManager 加 `group_member_<groupId>` | ||
| * 锁,避免并发 add/remove 造成 members 列表错乱; | ||
| * - agentId 是否合法(在 agents.list 中)由 socket 层注入校验函数, | ||
| * service 层只关心"群内成员一致性"。这样保留域服务的纯粹性, | ||
| * 不直接依赖 lightclaw runtime / config,便于单元测试。 | ||
| */ | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| import { ConcurrencyManager } from '../storage/concurrency-manager.js'; | ||
| import { generateMsgId } from '../../dedup.js'; | ||
| export class GroupMemberService { | ||
| /** 群组存储服务实例(meta 读写 + 群账本追加) */ | ||
| storage; | ||
| /** 并发控制管理器实例(按 groupId 加锁) */ | ||
| concurrency; | ||
| /** | ||
| * 群成员上限(来自 §2.3 A1:Agent 数量 ≤ 10,本期 §6.4.3 校验文案 max 10) | ||
| */ | ||
| static MAX_MEMBERS = 10; | ||
| constructor() { | ||
| this.storage = new GroupStorageCore(); | ||
| this.concurrency = new ConcurrencyManager(); | ||
| } | ||
| /** | ||
| * 添加 Agent 到群组 | ||
| * | ||
| * 流程: | ||
| * 1. 锁内读取 meta:群必须存在 + 未解散 + 当前用户在 ownerIds 内; | ||
| * 2. agentId 合法性校验(可选)+ 不在 members + members 未达上限; | ||
| * 3. 末尾追加 agentId → 写回 meta.json(updatedAt 自动刷新); | ||
| * 4. 群账本追加 system: member_added 事件(agentId 落在 entry.agentId)。 | ||
| * | ||
| * @param groupId 目标群 ID | ||
| * @param updaterId 发起操作的用户 ID(必须 ∈ ownerIds) | ||
| * @param agentId 要加入的 Agent ID | ||
| * @param options 可选:注入 agentId 合法性校验 | ||
| * @returns 操作完成后的 meta 与 members | ||
| * | ||
| * @throws Error 群不存在 / 已解散 / 越权 / agentId 非法 / 已在群内 / 群已满 | ||
| */ | ||
| async addMember(groupId, updaterId, agentId, options = {}) { | ||
| if (!agentId || !agentId.trim()) { | ||
| throw new Error('无效的 agentId:不能为空'); | ||
| } | ||
| const trimmedAgentId = agentId.trim(); | ||
| return await this.concurrency.withLock(`group_member_${groupId}`, async () => { | ||
| const meta = await this.requireWritableMeta(groupId, updaterId); | ||
| if (options.isValidAgentId && !options.isValidAgentId(trimmedAgentId)) { | ||
| throw new Error(`无效的 agentId:${trimmedAgentId} 不在 agents.list 中`); | ||
| } | ||
| // 基于 agentId 做去重判断,避免对象比较带来的歧义 | ||
| if (meta.members.some((m) => m.agentId === trimmedAgentId)) { | ||
| throw new Error('Agent already in group'); | ||
| } | ||
| if (meta.members.length >= GroupMemberService.MAX_MEMBERS) { | ||
| throw new Error(`Group is full (max ${GroupMemberService.MAX_MEMBERS})`); | ||
| } | ||
| // 通过注入的解析器拿到 agentName / agentDesc,缺省时以 agentId 兜底, | ||
| // 保证 GroupMember 三个字段始终被填写 | ||
| const resolved = options.resolveAgentMeta?.(trimmedAgentId); | ||
| const newMember = { | ||
| agentId: trimmedAgentId, | ||
| agentName: resolved?.agentName ?? trimmedAgentId, | ||
| agentDesc: resolved?.agentDesc ?? '', | ||
| }; | ||
| const updatedMeta = { | ||
| ...meta, | ||
| members: [...meta.members, newMember], | ||
| updatedAt: Date.now(), | ||
| }; | ||
| await this.storage.writeGroupMeta(groupId, updatedMeta); | ||
| await this.storage.appendTranscriptEntry(groupId, { | ||
| role: 'system', | ||
| event: 'member_added', | ||
| agentId: trimmedAgentId, | ||
| msgId: generateMsgId(), | ||
| }); | ||
| return { | ||
| meta: updatedMeta, | ||
| members: [...updatedMeta.members], | ||
| }; | ||
| }, 30000); | ||
| } | ||
| /** | ||
| * 从群组踢出 Agent | ||
| * | ||
| * 流程: | ||
| * 1. 锁内读取 meta:群必须存在 + 未解散 + 当前用户在 ownerIds 内; | ||
| * 2. 不能踢主 Agent(defaultAgentId) + agentId 必须 ∈ members; | ||
| * 3. 从 members 中移除 → 写回 meta.json(updatedAt 自动刷新); | ||
| * 4. 群账本追加 system: member_removed 事件。 | ||
| * | ||
| * 注意:被踢 Agent 在该群内的历史发言保留(不删 sessions/.jsonl 与账本旧条目), | ||
| * 仅"不再被调度"——这是群聊方案 §6.4.3 B 的硬性约束。 | ||
| * | ||
| * @param groupId 目标群 ID | ||
| * @param updaterId 发起操作的用户 ID(必须 ∈ ownerIds) | ||
| * @param agentId 要踢出的 Agent ID | ||
| * @returns 操作完成后的 meta 与 members | ||
| * | ||
| * @throws Error 群不存在 / 已解散 / 越权 / 试图踢主 Agent / agentId 不在群内 | ||
| */ | ||
| async removeMember(groupId, updaterId, agentId) { | ||
| if (!agentId || !agentId.trim()) { | ||
| throw new Error('无效的 agentId:不能为空'); | ||
| } | ||
| const trimmedAgentId = agentId.trim(); | ||
| return await this.concurrency.withLock(`group_member_${groupId}`, async () => { | ||
| const meta = await this.requireWritableMeta(groupId, updaterId); | ||
| if (trimmedAgentId === meta.defaultAgentId) { | ||
| throw new Error('Cannot remove default agent'); | ||
| } | ||
| if (!meta.members.some((m) => m.agentId === trimmedAgentId)) { | ||
| throw new Error('Agent not in group'); | ||
| } | ||
| const updatedMeta = { | ||
| ...meta, | ||
| members: meta.members.filter((m) => m.agentId !== trimmedAgentId), | ||
| updatedAt: Date.now(), | ||
| }; | ||
| await this.storage.writeGroupMeta(groupId, updatedMeta); | ||
| await this.storage.appendTranscriptEntry(groupId, { | ||
| role: 'system', | ||
| event: 'member_removed', | ||
| agentId: trimmedAgentId, | ||
| msgId: generateMsgId(), | ||
| }); | ||
| return { | ||
| meta: updatedMeta, | ||
| members: [...updatedMeta.members], | ||
| }; | ||
| }, 30000); | ||
| } | ||
| /** | ||
| * 读取并校验"可写"状态的群元数据。 | ||
| * | ||
| * 把 add / remove 共用的前置校验抽到这里,保证两条链路对"群存在性 + | ||
| * 解散态 + 越权"的处理完全一致:任一不通过即抛错;通过则返回原 meta, | ||
| * 由调用方做后续业务字段变更。 | ||
| */ | ||
| async requireWritableMeta(groupId, updaterId) { | ||
| const meta = await this.storage.readGroupMeta(groupId); | ||
| if (!meta) { | ||
| throw new Error('Group not found'); | ||
| } | ||
| if (meta.dismissed === true) { | ||
| throw new Error('Group has been dismissed'); | ||
| } | ||
| if (!meta.ownerIds.includes(updaterId)) { | ||
| throw new Error('Permission denied'); | ||
| } | ||
| return meta; | ||
| } | ||
| } |
| /** | ||
| * 群组查询服务 | ||
| * 专门负责群组数据的查询操作,包括列表查询、过滤和排序 | ||
| * | ||
| * 性能优化特性: | ||
| * - 用户群组列表缓存:减少重复的文件系统操作 | ||
| * - 批量元数据读取:优化多群组查询性能 | ||
| * - 智能缓存失效:基于文件修改时间的缓存管理 | ||
| * | ||
| * 主要功能: | ||
| * - 群组列表查询:按拥有者过滤和排序 | ||
| * - 群组元数据读取:获取指定群组的详细信息 | ||
| * - 权限验证:检查用户对群组的访问权限 | ||
| * | ||
| * 设计特点: | ||
| * - 单一职责:专注于查询操作 | ||
| * - 可复用性:可被其他服务调用 | ||
| * - 错误处理:完善的异常捕获和空结果处理 | ||
| */ | ||
| import { promises as fs } from 'fs'; | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| export class GroupQueryService { | ||
| /** | ||
| * 群组存储服务实例 | ||
| */ | ||
| storage; | ||
| /** | ||
| * 用户群组列表缓存 | ||
| * key: userId | ||
| * value: { groups: GroupMeta[], timestamp: number } | ||
| */ | ||
| userGroupsCache = new Map(); | ||
| /** | ||
| * 缓存有效期(毫秒) | ||
| */ | ||
| CACHE_TTL = 60000; // 60秒 | ||
| /** | ||
| * 构造函数 | ||
| * 初始化查询服务的依赖组件 | ||
| */ | ||
| constructor() { | ||
| this.storage = new GroupStorageCore(); | ||
| } | ||
| /** | ||
| * 清空缓存 | ||
| * 可用于内存管理或测试 | ||
| */ | ||
| clearCache() { | ||
| this.userGroupsCache.clear(); | ||
| } | ||
| /** | ||
| * 查询用户拥有的群组列表 | ||
| * 获取用户拥有的所有活跃群组,支持排序和过滤 | ||
| * | ||
| * @param userId - 用户ID | ||
| * @returns 群组元数据列表,按置顶优先和更新时间倒序排序 | ||
| * | ||
| * 查询逻辑: | ||
| * 1. 检查缓存是否存在且有效 | ||
| * 2. 读取群组存储目录 | ||
| * 3. 批量读取所有群组元数据(性能优化) | ||
| * 4. 过滤:只返回当前用户拥有且未解散的群组 | ||
| * 5. 排序:置顶群组优先,按更新时间倒序 | ||
| */ | ||
| async getUserGroups(userId) { | ||
| const now = Date.now(); | ||
| // 检查缓存 | ||
| const cached = this.userGroupsCache.get(userId); | ||
| if (cached && now - cached.timestamp < this.CACHE_TTL) { | ||
| return cached.groups; | ||
| } | ||
| const groupsDir = this.storage.getBasePath(); | ||
| try { | ||
| const groupDirs = await fs.readdir(groupsDir); | ||
| const groups = []; | ||
| // 批量读取所有群组元数据(性能优化) | ||
| const metaPromises = groupDirs.map(async (groupId) => { | ||
| try { | ||
| const meta = await this.storage.readGroupMeta(groupId); | ||
| if (meta && meta.ownerIds.includes(userId) && !meta.dismissed) { | ||
| return meta; | ||
| } | ||
| } | ||
| catch (error) { | ||
| // 忽略单个群组读取失败的情况 | ||
| console.warn(`读取群组 ${groupId} 元数据失败:`, error); | ||
| } | ||
| return null; | ||
| }); | ||
| const metaResults = await Promise.all(metaPromises); | ||
| const validGroups = metaResults.filter(Boolean); | ||
| // 排序:置顶优先,按更新时间倒序 | ||
| validGroups.sort((a, b) => { | ||
| if (a.pinned !== b.pinned) { | ||
| return a.pinned ? -1 : 1; | ||
| } | ||
| return b.updatedAt - a.updatedAt; | ||
| }); | ||
| // 更新缓存 | ||
| this.userGroupsCache.set(userId, { groups: validGroups, timestamp: now }); | ||
| return validGroups; | ||
| } | ||
| catch (error) { | ||
| // 如果存储目录不存在(ENOENT),返回空列表 | ||
| if (error.code === 'ENOENT') { | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 获取群组详细信息 | ||
| * 读取指定群组的元数据信息 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @returns 群组元数据,如果不存在则返回null | ||
| */ | ||
| async getGroupDetail(groupId) { | ||
| return await this.storage.readGroupMeta(groupId); | ||
| } | ||
| /** | ||
| * 验证用户对群组的权限 | ||
| * 检查用户是否有权限访问指定群组 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param userId - 用户ID | ||
| * @returns 用户是否有权限访问该群组 | ||
| */ | ||
| async validateUserPermission(groupId, userId) { | ||
| const groupMeta = await this.getGroupDetail(groupId); | ||
| return !!(groupMeta && groupMeta.ownerIds.includes(userId)); | ||
| } | ||
| } |
| /** | ||
| * 群组更新服务 | ||
| * 专门负责群组信息的更新操作,包括权限验证和字段更新 | ||
| * | ||
| * 主要功能: | ||
| * - 权限验证:检查用户是否有权限更新群组 | ||
| * - 字段更新:合并更新字段并维护时间戳 | ||
| * - 并发控制:确保更新操作的原子性 | ||
| * | ||
| * 设计特点: | ||
| * - 严格的权限验证 | ||
| * - 安全的字段更新机制 | ||
| * - 与存储服务解耦 | ||
| */ | ||
| import { promises as fs } from 'fs'; | ||
| import { GroupStorageCore } from '../storage/index.js'; | ||
| import { ConcurrencyManager } from '../storage/concurrency-manager.js'; | ||
| export class GroupUpdateService { | ||
| /** | ||
| * 群组存储服务实例 | ||
| */ | ||
| storage; | ||
| /** | ||
| * 并发控制管理器实例 | ||
| */ | ||
| concurrency; | ||
| /** | ||
| * 构造函数 | ||
| * 初始化更新服务的依赖组件 | ||
| */ | ||
| constructor() { | ||
| this.storage = new GroupStorageCore(); | ||
| this.concurrency = new ConcurrencyManager(); | ||
| } | ||
| /** | ||
| * 更新群组信息 | ||
| * 执行群组信息的更新操作 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param updaterId - 更新者用户ID | ||
| * @param updates - 更新字段对象 | ||
| * @returns 更新后的群组元数据 | ||
| * | ||
| * 更新流程: | ||
| * 1. 权限验证(群组存在且用户是拥有者) | ||
| * 2. 验证更新字段有效性 | ||
| * 3. 合并更新字段 | ||
| * 4. 更新更新时间戳 | ||
| * 5. 写入更新后的元数据 | ||
| */ | ||
| async updateGroup(groupId, updaterId, updates) { | ||
| return await this.concurrency.withLock(`group_update_${groupId}`, async () => { | ||
| const existingMeta = await this.storage.readGroupMeta(groupId); | ||
| if (!existingMeta) { | ||
| throw new Error('群组不存在'); | ||
| } | ||
| if (!existingMeta.ownerIds.includes(updaterId)) { | ||
| throw new Error('权限不足'); | ||
| } | ||
| // 验证更新字段有效性(排除members和defaultAgentId的特殊处理) | ||
| const hasUpdates = Object.keys(updates).some((key) => key !== 'members' && key !== 'defaultAgentId'); | ||
| if (!hasUpdates) { | ||
| throw new Error('没有有效的更新字段'); | ||
| } | ||
| const updatedMeta = { | ||
| ...existingMeta, | ||
| ...updates, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| // 写入群组元数据 | ||
| await this.storage.writeGroupMeta(groupId, updatedMeta); | ||
| return updatedMeta; | ||
| }, 30000); // 30秒超时 | ||
| } | ||
| /** | ||
| * 全量同步成员元信息 | ||
| * | ||
| * 当 Agent 配置变更(agentName / agentDesc 修改)后,遍历所有群组, | ||
| * 将 members 中匹配的成员属性刷新为最新值。 | ||
| * | ||
| * 设计要点: | ||
| * - 不限制 userId,无论谁改了 agent 都跟着同步 | ||
| * - 只更新 agentName / agentDesc,不增删成员,保证成员列表一致性 | ||
| * - 每个群组独立加锁,避免与其他更新操作冲突 | ||
| * - 仅当群组中确实存在需要更新的成员且属性有变化时才写入,减少无效 IO | ||
| * | ||
| * @param agentMetaMap - Agent 元信息映射表,key 为 agentId | ||
| * @returns 同步结果,包含被更新的群组 ID 列表和扫描总数 | ||
| */ | ||
| async syncMembersMeta(agentMetaMap) { | ||
| const agentIds = Object.keys(agentMetaMap); | ||
| if (agentIds.length === 0) { | ||
| return { updatedGroupIds: [], scannedCount: 0 }; | ||
| } | ||
| // 扫描所有群组目录 | ||
| const groupsDir = this.storage.getBasePath(); | ||
| let groupDirs = []; | ||
| try { | ||
| groupDirs = await fs.readdir(groupsDir); | ||
| } | ||
| catch { | ||
| // 群组目录不存在时直接返回 | ||
| return { updatedGroupIds: [], scannedCount: 0 }; | ||
| } | ||
| const updatedGroupIds = []; | ||
| // 并行处理所有群组(每个群组独立加锁) | ||
| const tasks = groupDirs.map(async (groupId) => { | ||
| try { | ||
| await this.concurrency.withLock(`group_update_${groupId}`, async () => { | ||
| const meta = await this.storage.readGroupMeta(groupId); | ||
| if (!meta || meta.dismissed) { | ||
| return; | ||
| } | ||
| // 检查是否有成员需要更新 | ||
| let hasChange = false; | ||
| const updatedMembers = meta.members.map((member) => { | ||
| const newMeta = agentMetaMap[member.agentId]; | ||
| if (newMeta && | ||
| (member.agentName !== newMeta.agentName || member.agentDesc !== newMeta.agentDesc)) { | ||
| hasChange = true; | ||
| return { ...member, agentName: newMeta.agentName, agentDesc: newMeta.agentDesc }; | ||
| } | ||
| return member; | ||
| }); | ||
| if (!hasChange) { | ||
| return; | ||
| } | ||
| const updatedMeta = { | ||
| ...meta, | ||
| members: updatedMembers, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| await this.storage.writeGroupMeta(groupId, updatedMeta); | ||
| updatedGroupIds.push(groupId); | ||
| }, 30000); | ||
| } | ||
| catch { | ||
| // 单个群组同步失败不影响其他群组 | ||
| } | ||
| }); | ||
| await Promise.all(tasks); | ||
| return { updatedGroupIds, scannedCount: groupDirs.length }; | ||
| } | ||
| } |
| /** | ||
| * 群组服务模块索引文件 | ||
| * 统一导出所有群组相关的服务类,便于外部模块引用 | ||
| * | ||
| * 包含服务: | ||
| * - GroupQueryService: 群组查询服务 | ||
| * - GroupCreationService: 群组创建服务 | ||
| * - GroupUpdateService: 群组更新服务(含 syncMembersMeta 全量同步成员元信息) | ||
| * - GroupDeletionService: 群组删除服务 | ||
| * - GroupCleanupService: 群组清理服务 | ||
| * - GroupMemberService: 群组成员管理服务(add / remove) | ||
| * - GroupHistoryService: 群历史消息查询服务(chat.jsonl 游标分页 + 权限校验) | ||
| */ | ||
| export { GroupQueryService } from './group-query-service.js'; | ||
| export { GroupCreationService } from './group-creation-service.js'; | ||
| export { GroupUpdateService } from './group-update-service.js'; | ||
| export { GroupDeletionService } from './group-deletion-service.js'; | ||
| export { GroupCleanupService } from './group-cleanup-service.js'; | ||
| export { GroupMemberService } from './group-member-service.js'; | ||
| export { GroupHistoryService } from './group-history-service.js'; |
| /** | ||
| * 并发控制管理器 | ||
| * 用于确保群聊操作的原子性和线程安全,防止数据竞争和并发冲突 | ||
| * | ||
| * 设计原理: | ||
| * - 基于Promise的锁机制实现,确保同一时刻对同一资源的操作串行化 | ||
| * - 使用Map数据结构存储锁状态,键为资源标识符,值为锁的Promise对象 | ||
| * - 支持异步操作的等待和释放,保证操作的完整性和一致性 | ||
| * - 添加超时机制防止死锁,锁状态监控便于调试 | ||
| * | ||
| * 适用场景: | ||
| * - 群组创建、更新、删除等写操作 | ||
| * - 群组成员管理操作 | ||
| * - 任何需要保证操作原子性的并发场景 | ||
| */ | ||
| export class ConcurrencyManager { | ||
| /** | ||
| * 锁状态映射表 | ||
| * - key: 资源标识符(如groupId) | ||
| * - value: 表示锁状态的Promise对象,resolve时表示锁已释放 | ||
| */ | ||
| locks = new Map(); | ||
| /** | ||
| * 锁创建时间映射表,用于监控锁状态和超时检测 | ||
| */ | ||
| lockTimestamps = new Map(); | ||
| /** | ||
| * 默认锁超时时间(毫秒) | ||
| */ | ||
| DEFAULT_TIMEOUT = 30000; // 30秒 | ||
| /** | ||
| * 使用锁执行异步操作 | ||
| * 确保同一时刻对同一资源的操作按顺序执行,避免并发冲突 | ||
| * | ||
| * @param key - 锁的键,通常为资源标识符(如群组ID) | ||
| * @param operation - 要执行的异步操作函数,返回Promise<T> | ||
| * @param timeout - 锁超时时间(毫秒),默认30秒 | ||
| * @returns 操作结果的Promise,保持原操作的返回类型 | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const manager = new ConcurrencyManager(); | ||
| * const result = await manager.withLock('group-123', async () => { | ||
| * // 执行需要并发控制的群组操作 | ||
| * return await updateGroup(groupId, updates); | ||
| * }, 60000); // 60秒超时 | ||
| * ``` | ||
| * | ||
| * 执行流程: | ||
| * 1. 检查是否已有相同key的锁存在 | ||
| * 2. 如果存在,等待该锁释放,但设置超时限制 | ||
| * 3. 创建新的锁并添加到锁映射表,记录创建时间 | ||
| * 4. 执行操作函数,设置超时保护 | ||
| * 5. 无论操作成功或失败,最终释放锁并清理状态 | ||
| */ | ||
| async withLock(key, operation, timeout = this.DEFAULT_TIMEOUT) { | ||
| const startTime = Date.now(); | ||
| // 等待现有锁释放,但设置超时限制 | ||
| while (this.locks.has(key)) { | ||
| const lockPromise = this.locks.get(key); | ||
| if (lockPromise) { | ||
| // 检查锁是否超时 | ||
| const lockTimestamp = this.lockTimestamps.get(key); | ||
| if (lockTimestamp && Date.now() - lockTimestamp > timeout) { | ||
| // 强制释放超时的锁 | ||
| console.warn(`锁 ${key} 已超时,强制释放`); | ||
| this.locks.delete(key); | ||
| this.lockTimestamps.delete(key); | ||
| break; | ||
| } | ||
| // 等待锁释放,但设置超时 | ||
| try { | ||
| await Promise.race([ | ||
| lockPromise, | ||
| new Promise((_, reject) => setTimeout(() => reject(new Error(`等待锁 ${key} 超时`)), timeout)) | ||
| ]); | ||
| } | ||
| catch (error) { | ||
| // 等待超时,强制释放锁 | ||
| console.warn(`等待锁 ${key} 超时,强制释放`); | ||
| this.locks.delete(key); | ||
| this.lockTimestamps.delete(key); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| // 创建新的锁:使用Promise控制锁的状态 | ||
| let resolveLock; | ||
| const lock = new Promise(resolve => { | ||
| resolveLock = resolve; | ||
| }); | ||
| // 设置锁状态:将新锁添加到映射表中 | ||
| this.locks.set(key, lock); | ||
| this.lockTimestamps.set(key, Date.now()); | ||
| try { | ||
| // 执行受保护的操作:在锁的保护下执行实际业务逻辑,设置超时保护 | ||
| const result = await Promise.race([ | ||
| operation(), | ||
| new Promise((_, reject) => setTimeout(() => reject(new Error(`操作 ${key} 执行超时`)), timeout)) | ||
| ]); | ||
| return result; | ||
| } | ||
| catch (error) { | ||
| // 记录操作失败信息 | ||
| console.error(`锁保护的操作失败: ${key}`, error); | ||
| throw error; | ||
| } | ||
| finally { | ||
| // 清理锁状态:无论操作成功或失败,都要释放锁 | ||
| this.locks.delete(key); | ||
| this.lockTimestamps.delete(key); | ||
| resolveLock(); // 通知等待的请求锁已释放 | ||
| const duration = Date.now() - startTime; | ||
| if (duration > timeout / 2) { | ||
| console.warn(`锁 ${key} 持有时间较长: ${duration}ms`); | ||
| } | ||
| } | ||
| } | ||
| } |
| /** | ||
| * 群组存储核心服务 | ||
| * 作为主要的接口类,整合分片管理器、消息读取器和消息写入器 | ||
| * 提供统一的API接口,保持向后兼容性 | ||
| */ | ||
| import path from 'path'; | ||
| import { resolveOpenClawHome } from '../../history/session-store.js'; | ||
| import { SliceManager } from './slice-manager.js'; | ||
| import { MessageReader } from './message-reader.js'; | ||
| import { MessageWriter } from './message-writer.js'; | ||
| export class GroupStorageCore { | ||
| /** | ||
| * 存储配置对象 | ||
| */ | ||
| config; | ||
| /** | ||
| * 分片管理器 | ||
| */ | ||
| sliceManager; | ||
| /** | ||
| * 消息读取器 | ||
| */ | ||
| messageReader; | ||
| /** | ||
| * 消息写入器 | ||
| */ | ||
| messageWriter; | ||
| /** | ||
| * 构造函数 | ||
| * 初始化群组存储服务的配置参数和各个组件 | ||
| * | ||
| * @param config - 部分配置参数,会与默认配置合并 | ||
| */ | ||
| constructor(config = {}) { | ||
| this.config = { | ||
| basePath: path.join(resolveOpenClawHome(), 'groups'), | ||
| maxGroupSize: 10, | ||
| maxHistorySize: 5000, | ||
| sliceConfig: { | ||
| maxSliceSize: 1000, | ||
| minSliceSize: 100, | ||
| strategy: 'hybrid' | ||
| }, | ||
| ...config | ||
| }; | ||
| this.sliceManager = new SliceManager(); | ||
| this.messageReader = new MessageReader(); | ||
| this.messageWriter = new MessageWriter(); | ||
| } | ||
| /** | ||
| * 获取基础存储路径 | ||
| * 返回配置的基础存储路径,用于外部模块了解存储位置 | ||
| * | ||
| * @returns 基础存储路径字符串 | ||
| */ | ||
| getBasePath() { | ||
| return this.config.basePath; | ||
| } | ||
| /** | ||
| * 确保群组目录存在 | ||
| * 内部方法,用于创建或确认群组目录的存在 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @returns 群组目录的完整路径 | ||
| * @throws 当目录创建失败时抛出错误 | ||
| */ | ||
| async ensureGroupDir(groupId) { | ||
| const { promises: fs } = await import('fs'); | ||
| const groupDir = path.join(this.config.basePath, groupId); | ||
| await fs.mkdir(groupDir, { recursive: true }); | ||
| return groupDir; | ||
| } | ||
| /** | ||
| * 读取群组元数据 | ||
| * 从文件系统读取指定群组的元数据信息 | ||
| * | ||
| * @param groupId - 要读取的群组ID | ||
| * @returns 群组元数据对象,如果群组不存在则返回null | ||
| */ | ||
| async readGroupMeta(groupId) { | ||
| try { | ||
| const { promises: fs } = await import('fs'); | ||
| const groupDir = path.join(this.config.basePath, groupId); | ||
| const metaPath = path.join(groupDir, 'meta.json'); | ||
| const content = await fs.readFile(metaPath, 'utf-8'); | ||
| return JSON.parse(content); | ||
| } | ||
| catch (error) { | ||
| // 处理文件不存在的特殊情况,返回null而不是抛出错误 | ||
| if (error.code === 'ENOENT') { | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 写入群组元数据(原子操作) | ||
| * 使用原子操作方式写入群组元数据,避免数据损坏 | ||
| * | ||
| * @param groupId - 要写入的群组ID | ||
| * @param meta - 群组元数据对象 | ||
| * @returns Promise<void> | ||
| */ | ||
| async writeGroupMeta(groupId, meta) { | ||
| const groupDir = await this.ensureGroupDir(groupId); | ||
| await this.messageWriter.writeGroupMeta(groupDir, meta); | ||
| } | ||
| /** | ||
| * 追加群账本条目 | ||
| * 向群组的聊天记录文件中追加新的条目,支持分片存储 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param entry - 账本条目(不包含时间戳) | ||
| * @returns Promise<void> | ||
| */ | ||
| async appendTranscriptEntry(groupId, entry) { | ||
| const groupDir = await this.ensureGroupDir(groupId); | ||
| const maxSliceSize = this.config.sliceConfig?.maxSliceSize || 1000; | ||
| await this.messageWriter.appendTranscriptEntry(groupDir, entry, maxSliceSize); | ||
| } | ||
| /** | ||
| * 批量追加消息 | ||
| * 一次性追加多条消息,优化写入性能 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param entries - 消息条目数组 | ||
| * @returns Promise<void> | ||
| */ | ||
| async appendBatchTranscriptEntries(groupId, entries) { | ||
| const groupDir = await this.ensureGroupDir(groupId); | ||
| const maxSliceSize = this.config.sliceConfig?.maxSliceSize || 1000; | ||
| await this.messageWriter.appendBatchTranscriptEntries(groupDir, entries, maxSliceSize); | ||
| } | ||
| /** | ||
| * 读取群历史消息(游标分页) | ||
| * 读取指定群组的聊天历史记录,支持分页和过滤,支持多分片合并查询 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param options - 查询选项 | ||
| * @returns 包含历史条目和分页信息的结果对象 | ||
| */ | ||
| async readTranscriptEntries(groupId, options = {}) { | ||
| const groupDir = path.join(this.config.basePath, groupId); | ||
| try { | ||
| const { promises: fs } = await import('fs'); | ||
| await fs.access(groupDir); | ||
| return await this.messageReader.readTranscriptEntries(groupDir, options); | ||
| } | ||
| catch (error) { | ||
| // 处理目录不存在的特殊情况,返回空结果 | ||
| if (error.code === 'ENOENT') { | ||
| return { entries: [], hasMore: false }; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 批量读取消息 | ||
| * 读取指定时间范围内的所有消息,不分页 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @param startTime - 开始时间戳 | ||
| * @param endTime - 结束时间戳 | ||
| * @param chatOnly - 是否只返回聊天内容 | ||
| * @returns 时间范围内的所有消息 | ||
| */ | ||
| async readMessagesByTimeRange(groupId, startTime, endTime, chatOnly = false) { | ||
| const groupDir = path.join(this.config.basePath, groupId); | ||
| try { | ||
| const { promises: fs } = await import('fs'); | ||
| await fs.access(groupDir); | ||
| return await this.messageReader.readMessagesByTimeRange(groupDir, startTime, endTime, chatOnly); | ||
| } | ||
| catch (error) { | ||
| if (error.code === 'ENOENT') { | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 获取消息统计信息 | ||
| * 返回群组的消息统计信息 | ||
| * | ||
| * @param groupId - 群组ID | ||
| * @returns 消息统计信息 | ||
| */ | ||
| async getMessageStats(groupId) { | ||
| const groupDir = path.join(this.config.basePath, groupId); | ||
| try { | ||
| const { promises: fs } = await import('fs'); | ||
| await fs.access(groupDir); | ||
| return await this.messageReader.getMessageStats(groupDir); | ||
| } | ||
| catch (error) { | ||
| if (error.code === 'ENOENT') { | ||
| return { totalMessages: 0, sliceCount: 0 }; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 清除消息读取器的文件缓存。 | ||
| * 在写入操作后调用,确保后续读取能获取到最新数据。 | ||
| */ | ||
| clearReaderCache() { | ||
| this.messageReader.clearCache(); | ||
| } | ||
| /** | ||
| * 获取存储配置 | ||
| * 返回当前的存储配置信息 | ||
| * | ||
| * @returns 存储配置对象 | ||
| */ | ||
| getConfig() { | ||
| return { ...this.config }; | ||
| } | ||
| /** | ||
| * 更新存储配置 | ||
| * 动态更新存储配置参数 | ||
| * | ||
| * @param newConfig - 新的配置参数 | ||
| */ | ||
| updateConfig(newConfig) { | ||
| this.config = { ...this.config, ...newConfig }; | ||
| } | ||
| } |
| /** | ||
| * @file 群组存储模块索引文件 | ||
| * @description 统一导出所有存储相关的类和接口 | ||
| */ | ||
| // 导出核心接口类 | ||
| export { GroupStorageCore } from './group-storage-core.js'; | ||
| // 导出专门的服务类 | ||
| export { SliceManager } from './slice-manager.js'; | ||
| export { MessageReader } from './message-reader.js'; | ||
| export { MessageWriter } from './message-writer.js'; | ||
| // 导出并发管理器 | ||
| export { ConcurrencyManager } from './concurrency-manager.js'; |
| /** | ||
| * 消息读取器 | ||
| * 专门负责群组历史消息的读取和查询功能 | ||
| * 包括多分片合并查询、分页处理、消息过滤等高级功能 | ||
| * | ||
| * 性能优化特性: | ||
| * - 文件内容缓存:减少重复文件读取操作 | ||
| * - 批量读取优化:优化多分片查询性能 | ||
| * - 智能分片处理:根据查询条件智能选择分片 | ||
| */ | ||
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| import { SliceManager } from './slice-manager.js'; | ||
| export class MessageReader { | ||
| sliceManager; | ||
| /** | ||
| * 文件内容缓存 | ||
| * key: 文件路径 | ||
| * value: { content: 文件内容, timestamp: 缓存时间 } | ||
| */ | ||
| fileCache = new Map(); | ||
| /** | ||
| * 缓存有效期(毫秒) | ||
| */ | ||
| CACHE_TTL = 30000; // 30秒 | ||
| constructor() { | ||
| this.sliceManager = new SliceManager(); | ||
| } | ||
| /** | ||
| * 清空缓存 | ||
| * 可用于内存管理或测试 | ||
| */ | ||
| clearCache() { | ||
| this.fileCache.clear(); | ||
| } | ||
| /** | ||
| * 读取分片文件内容(带缓存) | ||
| * 读取指定分片文件中的消息记录,支持缓存优化 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param filename - 分片文件名 | ||
| * @param options - 查询选项 | ||
| * @returns 分片中的消息条目数组 | ||
| */ | ||
| async readSliceFile(groupDir, filename, options) { | ||
| const filePath = path.join(groupDir, filename); | ||
| const now = Date.now(); | ||
| // 检查缓存 | ||
| const cached = this.fileCache.get(filePath); | ||
| if (cached && now - cached.timestamp < this.CACHE_TTL) { | ||
| return this.processCachedContent(cached.content, options); | ||
| } | ||
| try { | ||
| const content = await fs.readFile(filePath, 'utf-8'); | ||
| // 更新缓存 | ||
| this.fileCache.set(filePath, { content, timestamp: now }); | ||
| return this.processContent(content, options); | ||
| } | ||
| catch (error) { | ||
| // 处理文件不存在的特殊情况,返回空数组 | ||
| if (error.code === 'ENOENT') { | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 处理缓存内容 | ||
| */ | ||
| processCachedContent(content, options) { | ||
| const lines = content.trim().split('\n').filter(line => line); | ||
| let entries = lines.map(line => { | ||
| try { | ||
| return JSON.parse(line); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| }).filter(Boolean); | ||
| // 应用过滤条件 | ||
| if (options.chatOnly) { | ||
| entries = entries.filter(entry => entry.role !== 'system'); | ||
| } | ||
| // 时间过滤和排序处理 | ||
| return entries | ||
| .filter(entry => entry.ts < options.before) | ||
| .sort((a, b) => b.ts - a.ts) | ||
| .slice(0, options.limit); | ||
| } | ||
| /** | ||
| * 处理新读取的内容 | ||
| */ | ||
| processContent(content, options) { | ||
| return this.processCachedContent(content, options); | ||
| } | ||
| /** | ||
| * 批量读取分片文件(性能优化) | ||
| * 一次性读取多个分片,减少IO操作 | ||
| */ | ||
| async readMultipleSlices(groupDir, filenames, options) { | ||
| const results = await Promise.all(filenames.map(filename => this.readSliceFile(groupDir, filename, options))); | ||
| return results.flat(); | ||
| } | ||
| /** | ||
| * 读取群历史消息(游标分页) | ||
| * 读取指定群组的聊天历史记录,支持分页和过滤,支持多分片合并查询 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param options - 查询选项 | ||
| * @param options.limit - 返回条数限制,默认50 | ||
| * @param options.before - 时间戳上限,默认当前时间 | ||
| * @param options.chatOnly - 是否只返回聊天内容,默认false | ||
| * @returns 包含历史条目和分页信息的结果对象 | ||
| * @throws 当文件读取或解析失败时抛出错误(ENOENT错误除外) | ||
| */ | ||
| async readTranscriptEntries(groupDir, options = {}) { | ||
| const { limit = 50, before = Date.now(), chatOnly = false } = options; | ||
| try { | ||
| // 获取相关分片列表 | ||
| const relevantSlices = await this.sliceManager.getRelevantSlices(groupDir, before); | ||
| // 如果没有分片,返回空结果 | ||
| if (relevantSlices.length === 0) { | ||
| return { entries: [], hasMore: false }; | ||
| } | ||
| // 批量读取所有相关分片(性能优化) | ||
| const allEntries = await this.readMultipleSlices(groupDir, relevantSlices.map(slice => slice.filename), { before, limit: Number.MAX_SAFE_INTEGER, chatOnly }); | ||
| // 排序和分页处理 | ||
| const sortedEntries = allEntries | ||
| .sort((a, b) => b.ts - a.ts) | ||
| .slice(0, limit); | ||
| // 判断是否还有更多数据 | ||
| const hasMore = await this.sliceManager.calculateHasMore(groupDir, before, sortedEntries); | ||
| const nextCursor = sortedEntries.length > 0 ? sortedEntries[0].ts : undefined; | ||
| return { | ||
| entries: sortedEntries.reverse(), // 按时间升序返回 | ||
| hasMore, | ||
| nextCursor | ||
| }; | ||
| } | ||
| catch (error) { | ||
| // 处理文件不存在的特殊情况,返回空结果 | ||
| if (error.code === 'ENOENT') { | ||
| return { entries: [], hasMore: false }; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 批量读取消息 | ||
| * 读取指定时间范围内的所有消息,不分页 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param startTime - 开始时间戳 | ||
| * @param endTime - 结束时间戳 | ||
| * @param chatOnly - 是否只返回聊天内容 | ||
| * @returns 时间范围内的所有消息 | ||
| */ | ||
| async readMessagesByTimeRange(groupDir, startTime, endTime, chatOnly = false) { | ||
| try { | ||
| const relevantSlices = await this.sliceManager.getRelevantSlices(groupDir, endTime); | ||
| let allEntries = []; | ||
| for (const slice of relevantSlices) { | ||
| // 如果分片的开始时间晚于结束时间,跳过 | ||
| if (slice.startTime > endTime) | ||
| continue; | ||
| const sliceEntries = await this.readSliceFile(groupDir, slice.filename, { | ||
| before: endTime, | ||
| limit: Number.MAX_SAFE_INTEGER, | ||
| chatOnly | ||
| }); | ||
| // 过滤开始时间之后的消息 | ||
| const filteredEntries = sliceEntries.filter(entry => entry.ts >= startTime); | ||
| allEntries = [...filteredEntries, ...allEntries]; | ||
| } | ||
| return allEntries.sort((a, b) => a.ts - b.ts); | ||
| } | ||
| catch (error) { | ||
| if (error.code === 'ENOENT') { | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 获取消息统计信息 | ||
| * 返回群组的消息统计信息 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @returns 消息统计信息 | ||
| */ | ||
| async getMessageStats(groupDir) { | ||
| try { | ||
| const fs = await import('fs'); | ||
| const indexPath = path.join(groupDir, 'index.json'); | ||
| if (!fs.existsSync(indexPath)) { | ||
| return { totalMessages: 0, sliceCount: 0 }; | ||
| } | ||
| const content = await fs.promises.readFile(indexPath, 'utf-8'); | ||
| const index = JSON.parse(content); | ||
| const firstSlice = index.slices[0]; | ||
| const lastSlice = index.slices[index.slices.length - 1]; | ||
| return { | ||
| totalMessages: index.totalMessages || 0, | ||
| sliceCount: index.slices.length, | ||
| firstMessageTime: firstSlice?.startTime, | ||
| lastMessageTime: lastSlice?.endTime | ||
| }; | ||
| } | ||
| catch (error) { | ||
| return { totalMessages: 0, sliceCount: 0 }; | ||
| } | ||
| } | ||
| } |
| /** | ||
| * 消息写入器 | ||
| * 专门负责群组消息的写入和存储管理 | ||
| * 包括原子操作、分片切换、消息ID分配等核心功能 | ||
| */ | ||
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| import { SliceManager } from './slice-manager.js'; | ||
| import { ConcurrencyManager } from './concurrency-manager.js'; | ||
| export class MessageWriter { | ||
| sliceManager; | ||
| concurrencyManager; | ||
| constructor() { | ||
| this.sliceManager = new SliceManager(); | ||
| this.concurrencyManager = new ConcurrencyManager(); | ||
| } | ||
| /** | ||
| * 追加群账本条目 | ||
| * 向群组的聊天记录文件中追加新的条目,支持分片存储 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param entry - 账本条目(不包含时间戳) | ||
| * @param maxSliceSize - 单个分片最大消息数 | ||
| * @returns Promise<void> | ||
| * @throws 当文件追加写入失败时抛出错误 | ||
| */ | ||
| async appendTranscriptEntry(groupDir, entry, maxSliceSize) { | ||
| // 使用锁确保消息ID分配的原子性 | ||
| await this.concurrencyManager.withLock(`message-write-${path.basename(groupDir)}`, async () => { | ||
| // 获取当前活跃分片 | ||
| const activeSlice = await this.sliceManager.getActiveSlice(groupDir, maxSliceSize); | ||
| const slicePath = path.join(groupDir, activeSlice.filename); | ||
| // 读取索引获取下一个消息ID | ||
| const index = await this.sliceManager.readSliceIndex(groupDir); | ||
| const messageId = index.nextMessageId || 1; | ||
| // 构建完整的账本条目,添加时间戳和消息ID | ||
| const fullEntry = { | ||
| ts: Date.now(), | ||
| id: messageId, | ||
| ...entry | ||
| }; | ||
| // 格式化为JSON Lines格式 | ||
| const line = JSON.stringify(fullEntry) + '\n'; | ||
| const lineSize = Buffer.byteLength(line, 'utf-8'); | ||
| // 使用追加模式写入 | ||
| await fs.appendFile(slicePath, line, 'utf-8'); | ||
| // 更新分片统计信息 | ||
| await this.sliceManager.updateSliceStats(groupDir, activeSlice.filename, messageId, fullEntry.ts, lineSize); | ||
| }, 30000); // 30秒超时 | ||
| } | ||
| /** | ||
| * 批量追加消息 | ||
| * 一次性追加多条消息,优化写入性能 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param entries - 消息条目数组 | ||
| * @param maxSliceSize - 单个分片最大消息数 | ||
| * @returns Promise<void> | ||
| */ | ||
| async appendBatchTranscriptEntries(groupDir, entries, maxSliceSize) { | ||
| if (entries.length === 0) | ||
| return; | ||
| // 使用锁确保批量消息ID分配的原子性 | ||
| await this.concurrencyManager.withLock(`batch-message-write-${path.basename(groupDir)}`, async () => { | ||
| // 获取当前活跃分片 | ||
| const activeSlice = await this.sliceManager.getActiveSlice(groupDir, maxSliceSize); | ||
| const slicePath = path.join(groupDir, activeSlice.filename); | ||
| // 读取索引获取起始消息ID | ||
| const index = await this.sliceManager.readSliceIndex(groupDir); | ||
| let messageId = index.nextMessageId || 1; | ||
| // 构建批量写入内容 | ||
| let batchContent = ''; | ||
| let totalSize = 0; | ||
| const timestamp = Date.now(); | ||
| for (const entry of entries) { | ||
| const fullEntry = { | ||
| ts: timestamp, | ||
| id: messageId, | ||
| ...entry | ||
| }; | ||
| const line = JSON.stringify(fullEntry) + '\n'; | ||
| batchContent += line; | ||
| totalSize += Buffer.byteLength(line, 'utf-8'); | ||
| messageId++; | ||
| } | ||
| // 批量写入 | ||
| await fs.appendFile(slicePath, batchContent, 'utf-8'); | ||
| // 更新分片统计信息 | ||
| await this.sliceManager.updateSliceStats(groupDir, activeSlice.filename, messageId - 1, // 最后一条消息的ID | ||
| timestamp, totalSize); | ||
| }, 30000); // 30秒超时 | ||
| } | ||
| /** | ||
| * 写入群组元数据(原子操作) | ||
| * 使用原子操作方式写入群组元数据,避免数据损坏 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param meta - 群组元数据对象 | ||
| * @returns Promise<void> | ||
| */ | ||
| async writeGroupMeta(groupDir, meta) { | ||
| const metaPath = path.join(groupDir, 'meta.json'); | ||
| const tempPath = `${metaPath}.tmp`; | ||
| // 先写入临时文件 | ||
| await fs.writeFile(tempPath, JSON.stringify(meta, null, 2)); | ||
| // 原子重命名 | ||
| await fs.rename(tempPath, metaPath); | ||
| } | ||
| /** | ||
| * 清理过期分片 | ||
| * 根据配置清理过期的分片文件,释放存储空间 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param maxHistorySize - 最大历史消息数量 | ||
| * @returns 被清理的分片数量 | ||
| */ | ||
| async cleanupExpiredSlices(groupDir, maxHistorySize) { | ||
| const index = await this.sliceManager.readSliceIndex(groupDir); | ||
| if (index.totalMessages <= maxHistorySize) { | ||
| return 0; | ||
| } | ||
| // 计算需要保留的消息数量 | ||
| const messagesToKeep = Math.min(maxHistorySize, index.totalMessages); | ||
| let messagesKept = 0; | ||
| let slicesToRemove = []; | ||
| // 从最新的分片开始计算 | ||
| const sortedSlices = [...index.slices].sort((a, b) => b.startTime - a.startTime); | ||
| for (const slice of sortedSlices) { | ||
| if (messagesKept + slice.messageCount <= messagesToKeep) { | ||
| messagesKept += slice.messageCount; | ||
| } | ||
| else { | ||
| slicesToRemove.push(slice); | ||
| } | ||
| } | ||
| // 移除过期的分片 | ||
| for (const slice of slicesToRemove) { | ||
| const slicePath = path.join(groupDir, slice.filename); | ||
| try { | ||
| await fs.unlink(slicePath); | ||
| } | ||
| catch (error) { | ||
| // 忽略文件不存在的错误 | ||
| if (error.code !== 'ENOENT') { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| // 更新索引 | ||
| if (slicesToRemove.length > 0) { | ||
| index.slices = index.slices.filter(slice => !slicesToRemove.includes(slice)); | ||
| index.totalMessages = messagesKept; | ||
| await this.sliceManager.writeSliceIndex(groupDir, index); | ||
| } | ||
| return slicesToRemove.length; | ||
| } | ||
| /** | ||
| * 压缩分片文件 | ||
| * 将多个小分片合并为一个大分片,优化存储效率 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param minSliceSize - 触发压缩的最小消息数 | ||
| * @returns 是否执行了压缩操作 | ||
| */ | ||
| async compactSlices(groupDir, minSliceSize) { | ||
| const index = await this.sliceManager.readSliceIndex(groupDir); | ||
| // 找出需要压缩的小分片 | ||
| const smallSlices = index.slices.filter(slice => slice.messageCount < minSliceSize); | ||
| if (smallSlices.length <= 1) { | ||
| return false; // 只有一个或没有小分片,不需要压缩 | ||
| } | ||
| // 按时间顺序排序 | ||
| smallSlices.sort((a, b) => a.startTime - b.startTime); | ||
| // 读取所有小分片的消息 | ||
| let allMessages = []; | ||
| for (const slice of smallSlices) { | ||
| const slicePath = path.join(groupDir, slice.filename); | ||
| try { | ||
| const content = await fs.readFile(slicePath, 'utf-8'); | ||
| const lines = content.trim().split('\n').filter(line => line); | ||
| const messages = lines.map(line => JSON.parse(line)); | ||
| allMessages = [...allMessages, ...messages]; | ||
| } | ||
| catch (error) { | ||
| // 忽略读取错误 | ||
| continue; | ||
| } | ||
| } | ||
| // 按时间排序 | ||
| allMessages.sort((a, b) => a.ts - b.ts); | ||
| // 创建新的合并分片 | ||
| const timestamp = Date.now(); | ||
| const dateStr = new Date(timestamp).toISOString().split('T')[0].replace(/-/g, ''); | ||
| const sequence = index.nextSliceSequence || 1; | ||
| const newFilename = `chat-${dateStr}-compacted-${String(sequence).padStart(3, '0')}.jsonl`; | ||
| // 写入合并后的分片 | ||
| const newSlicePath = path.join(groupDir, newFilename); | ||
| let fileContent = ''; | ||
| for (const message of allMessages) { | ||
| fileContent += JSON.stringify(message) + '\n'; | ||
| } | ||
| await fs.writeFile(newSlicePath, fileContent, 'utf-8'); | ||
| // 删除旧的小分片 | ||
| for (const slice of smallSlices) { | ||
| const slicePath = path.join(groupDir, slice.filename); | ||
| try { | ||
| await fs.unlink(slicePath); | ||
| } | ||
| catch (error) { | ||
| // 忽略删除错误 | ||
| } | ||
| } | ||
| // 更新索引 | ||
| index.slices = index.slices.filter(slice => !smallSlices.includes(slice)); | ||
| const newSliceInfo = { | ||
| filename: newFilename, | ||
| startTime: allMessages[0]?.ts || timestamp, | ||
| endTime: allMessages[allMessages.length - 1]?.ts || timestamp, | ||
| messageCount: allMessages.length, | ||
| fileSize: Buffer.byteLength(fileContent, 'utf-8'), | ||
| minId: allMessages[0]?.id, | ||
| maxId: allMessages[allMessages.length - 1]?.id | ||
| }; | ||
| index.slices.push(newSliceInfo); | ||
| index.nextSliceSequence = sequence + 1; | ||
| await this.sliceManager.writeSliceIndex(groupDir, index); | ||
| return true; | ||
| } | ||
| } |
| /** | ||
| * 分片管理器 | ||
| * 专门负责群组聊天记录的分片存储管理 | ||
| * 包括分片索引的读写、活跃分片管理、分片创建等核心功能 | ||
| */ | ||
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| export class SliceManager { | ||
| /** | ||
| * 读取分片索引 | ||
| * 从文件系统读取群组的分片索引信息 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @returns 分片索引对象,如果索引不存在则创建默认索引 | ||
| */ | ||
| async readSliceIndex(groupDir) { | ||
| const indexPath = path.join(groupDir, 'index.json'); | ||
| try { | ||
| const content = await fs.readFile(indexPath, 'utf-8'); | ||
| return JSON.parse(content); | ||
| } | ||
| catch (error) { | ||
| // 如果索引文件不存在,创建默认索引 | ||
| if (error.code === 'ENOENT') { | ||
| return { | ||
| version: '1.0', | ||
| groupId: path.basename(groupDir), | ||
| totalMessages: 0, | ||
| lastUpdated: Date.now(), | ||
| slices: [], | ||
| nextMessageId: 1, | ||
| nextSliceSequence: 1 | ||
| }; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| /** | ||
| * 写入分片索引(原子操作) | ||
| * 使用原子操作方式写入分片索引,避免数据损坏 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param index - 分片索引对象 | ||
| */ | ||
| async writeSliceIndex(groupDir, index) { | ||
| const indexPath = path.join(groupDir, 'index.json'); | ||
| const tempPath = `${indexPath}.tmp`; | ||
| // 更新最后更新时间 | ||
| index.lastUpdated = Date.now(); | ||
| // 先写入临时文件 | ||
| await fs.writeFile(tempPath, JSON.stringify(index, null, 2)); | ||
| // 原子重命名 | ||
| await fs.rename(tempPath, indexPath); | ||
| } | ||
| /** | ||
| * 获取当前活跃分片 | ||
| * 返回当前正在写入的分片信息,如果不存在则创建新分片 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param maxSliceSize - 单个分片最大消息数 | ||
| * @returns 活跃分片信息 | ||
| */ | ||
| async getActiveSlice(groupDir, maxSliceSize) { | ||
| const index = await this.readSliceIndex(groupDir); | ||
| // 如果没有分片,创建第一个分片 | ||
| if (index.slices.length === 0) { | ||
| return await this.createNewSlice(groupDir, index); | ||
| } | ||
| // 获取最后一个分片 | ||
| const lastSlice = index.slices[index.slices.length - 1]; | ||
| // 如果最后一个分片未满,返回该分片 | ||
| if (lastSlice.messageCount < maxSliceSize) { | ||
| return lastSlice; | ||
| } | ||
| // 创建新分片 | ||
| return await this.createNewSlice(groupDir, index); | ||
| } | ||
| /** | ||
| * 创建新分片 | ||
| * 创建新的分片文件并更新索引 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param index - 当前索引 | ||
| * @returns 新创建的分片信息 | ||
| */ | ||
| async createNewSlice(groupDir, index) { | ||
| const timestamp = Date.now(); | ||
| const dateStr = new Date(timestamp).toISOString().split('T')[0].replace(/-/g, ''); | ||
| const sequence = index.nextSliceSequence || 1; | ||
| const filename = `chat-${dateStr}-${String(sequence).padStart(3, '0')}.jsonl`; | ||
| const sliceInfo = { | ||
| filename, | ||
| startTime: timestamp, | ||
| endTime: timestamp, | ||
| messageCount: 0, | ||
| fileSize: 0, | ||
| minId: index.nextMessageId || 1, | ||
| maxId: 0 | ||
| }; | ||
| // 添加到索引 | ||
| index.slices.push(sliceInfo); | ||
| index.activeSlice = filename; | ||
| index.nextSliceSequence = sequence + 1; | ||
| // 写入索引 | ||
| await this.writeSliceIndex(groupDir, index); | ||
| return sliceInfo; | ||
| } | ||
| /** | ||
| * 更新分片统计信息 | ||
| * 在写入消息后更新分片的统计信息 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param sliceFilename - 分片文件名 | ||
| * @param messageId - 消息ID | ||
| * @param timestamp - 消息时间戳 | ||
| * @param lineSize - 写入行的字节大小 | ||
| */ | ||
| async updateSliceStats(groupDir, sliceFilename, messageId, timestamp, lineSize) { | ||
| const index = await this.readSliceIndex(groupDir); | ||
| const sliceIndex = index.slices.findIndex((slice) => slice.filename === sliceFilename); | ||
| if (sliceIndex !== -1) { | ||
| index.slices[sliceIndex].messageCount += 1; | ||
| index.slices[sliceIndex].endTime = timestamp; | ||
| index.slices[sliceIndex].maxId = messageId; | ||
| index.slices[sliceIndex].fileSize += lineSize; | ||
| index.totalMessages += 1; | ||
| index.nextMessageId = messageId + 1; | ||
| await this.writeSliceIndex(groupDir, index); | ||
| } | ||
| } | ||
| /** | ||
| * 获取相关分片列表 | ||
| * 根据时间范围获取需要查询的分片列表 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param before - 时间戳上限 | ||
| * @returns 按时间倒序排列的相关分片列表 | ||
| */ | ||
| async getRelevantSlices(groupDir, before) { | ||
| const index = await this.readSliceIndex(groupDir); | ||
| return index.slices | ||
| .filter((slice) => slice.startTime < before) | ||
| .sort((a, b) => b.startTime - a.startTime); | ||
| } | ||
| /** | ||
| * 计算是否还有更多数据 | ||
| * 根据索引和当前查询结果判断是否还有更早的历史记录 | ||
| * | ||
| * @param groupDir - 群组目录路径 | ||
| * @param before - 查询时间上限 | ||
| * @param currentEntries - 当前查询结果 | ||
| * @returns 是否还有更多数据 | ||
| */ | ||
| async calculateHasMore(groupDir, before, currentEntries) { | ||
| if (currentEntries.length === 0) { | ||
| return false; | ||
| } | ||
| const index = await this.readSliceIndex(groupDir); | ||
| // 找到最早的分片 | ||
| const earliestSlice = index.slices.reduce((earliest, slice) => slice.startTime < earliest.startTime ? slice : earliest); | ||
| // 如果最早的分片开始时间早于当前查询的最早时间戳,说明还有更早的数据 | ||
| const earliestEntryTs = currentEntries[0]?.ts || before; | ||
| return earliestSlice.startTime < earliestEntryTs; | ||
| } | ||
| } |
| /** | ||
| * @file 群聊公共类型定义 | ||
| * @description 群聊模块中跨子模块共享的基础类型(请求类型枚举等) | ||
| */ | ||
| export {}; |
| /** | ||
| * @file 群聊类型统一出口 | ||
| * @description 统一导出所有跨模块共享的 TS 类型定义 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file 群聊 IM 协议消息接口定义 | ||
| * @description 定义群聊消息的基础接口,用于类型安全和消息格式标准化 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file 调度器核心类型定义 | ||
| * @description 统一收敛群聊调度器的所有内部类型 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file 群聊存储相关类型定义 | ||
| * @description 定义群组模块的数据结构、配置选项和查询接口 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file ID 生成工具 | ||
| * @description 提供群聊模块中各类 ID 的生成方法 | ||
| */ | ||
| /** | ||
| * 生成临时 runId(在 SDK 拉起 run 之前给 RunRegistry 占位用) | ||
| * 形如:`pre_<conversationId>_<role>_<random>` | ||
| * | ||
| * @param conversationId - 回合 ID | ||
| * @param role - run 角色 | ||
| */ | ||
| export function generatePreRunId(conversationId, role) { | ||
| const rand = Math.random().toString(36).slice(2, 10); | ||
| return `pre_${conversationId}_${role}_${rand}`; | ||
| } |
| /** | ||
| * @file 群聊公共工具函数统一出口 | ||
| * @description 统一导出所有跨子模块共享的纯函数,供 orchestrator、inbound 等模块按需引入 | ||
| */ | ||
| /** ID 生成:预生成 runId,用于在 run 启动前即可关联上下文 */ | ||
| export { generatePreRunId } from './id-generator.js'; | ||
| /** Run 状态处理:状态规范化、错误序列化 */ | ||
| export { normalizeRunStatus, errorToString } from './run-helpers.js'; | ||
| /** 数据规范化:mention 列表清洗与校验 */ | ||
| export { normalizeMentions } from './normalize.js'; | ||
| /** MIME 类型推断:根据文件扩展名猜测 MIME 类型 */ | ||
| export { guessMimeType } from './mime.js'; |
| /** | ||
| * @file MIME 类型推断工具 | ||
| * @description 根据文件扩展名猜测对应的 MIME 类型,供群聊文件处理等模块复用 | ||
| */ | ||
| /** 扩展名到 MIME 类型的映射表 */ | ||
| const EXTENSION_MIME_MAP = { | ||
| png: 'image/png', | ||
| jpg: 'image/jpeg', | ||
| jpeg: 'image/jpeg', | ||
| gif: 'image/gif', | ||
| webp: 'image/webp', | ||
| svg: 'image/svg+xml', | ||
| pdf: 'application/pdf', | ||
| doc: 'application/msword', | ||
| docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||
| xls: 'application/vnd.ms-excel', | ||
| xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | ||
| ppt: 'application/vnd.ms-powerpoint', | ||
| pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', | ||
| txt: 'text/plain', | ||
| csv: 'text/csv', | ||
| json: 'application/json', | ||
| zip: 'application/zip', | ||
| mp3: 'audio/mpeg', | ||
| mp4: 'video/mp4', | ||
| }; | ||
| /** | ||
| * 根据文件扩展名猜测 MIME 类型 | ||
| * | ||
| * @param fileName 文件名(含扩展名) | ||
| * @returns 对应的 MIME 类型字符串,未匹配时返回 'application/octet-stream' | ||
| */ | ||
| export function guessMimeType(fileName) { | ||
| const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; | ||
| return EXTENSION_MIME_MAP[ext] || 'application/octet-stream'; | ||
| } |
| /** | ||
| * @file 群聊数据规范化工具 | ||
| * @description 提供群聊场景下的数据清洗与规范化函数 | ||
| */ | ||
| /** | ||
| * 规范化 mention 列表 | ||
| * | ||
| * - 仅保留字符串类型且非空的 ID | ||
| * - 去重(保持原始顺序) | ||
| * - 仅保留属于当前群成员的 ID | ||
| * | ||
| * @param raw - 原始 mention 数据(来自客户端 extra,类型不可信) | ||
| * @param members - 当前群的合法成员 ID 列表 | ||
| * @returns 规范化后的 mention 列表 | ||
| */ | ||
| export function normalizeMentions(raw, members) { | ||
| if (!Array.isArray(raw) || raw.length === 0) | ||
| return []; | ||
| const memberSet = new Set(members); | ||
| const seen = new Set(); | ||
| const result = []; | ||
| for (const item of raw) { | ||
| if (typeof item !== 'string') | ||
| continue; | ||
| const id = item.trim(); | ||
| if (!id || seen.has(id) || !memberSet.has(id)) | ||
| continue; | ||
| seen.add(id); | ||
| result.push(id); | ||
| } | ||
| return result; | ||
| } |
| /** | ||
| * @file Run 状态/错误处理工具 | ||
| * @description 提供 Run 状态转换和错误字符串化的纯函数 | ||
| */ | ||
| /** | ||
| * 把 AgentRunOutput.status 转成 RunStatus | ||
| */ | ||
| export function normalizeRunStatus(status) { | ||
| switch (status) { | ||
| case 'ok': | ||
| return 'done'; | ||
| case 'aborted': | ||
| return 'aborted'; | ||
| case 'timeout': | ||
| case 'error': | ||
| default: | ||
| return 'failed'; | ||
| } | ||
| } | ||
| /** | ||
| * 把任意错误转成可见字符串(去掉栈信息) | ||
| */ | ||
| export function errorToString(err, fallback = '未知错误') { | ||
| if (!err) | ||
| return fallback; | ||
| if (err instanceof Error) | ||
| return err.message || fallback; | ||
| if (typeof err === 'string') | ||
| return err; | ||
| try { | ||
| return JSON.stringify(err); | ||
| } | ||
| catch { | ||
| return fallback; | ||
| } | ||
| } |
| import { CHANNEL_KEY, DEFAULT_AGENT_ID, EVENT_AGENTS_REQUEST, EVENT_AGENTS_RESPONSE } from '../../config.js'; | ||
| import { generateMsgId } from '../../dedup.js'; | ||
| import { getLightclawRuntime } from '../../runtime.js'; | ||
| import { attachSoulIdsToAgents, readSoulIdMap, LIGHTSOUL_CONFIG_PATH } from '../utils/agent-soul.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.agents-request'); | ||
| /** | ||
| * 提取非空字符串,空白字符串视为无效值 | ||
| * @param value - 待检测的未知类型值 | ||
| * @returns 去除首尾空白后的字符串;若非字符串或为空白则返回 undefined | ||
| */ | ||
| const getNonEmptyString = (value) => { | ||
| if (typeof value !== 'string') | ||
| return undefined; | ||
| const trimmed = value.trim(); | ||
| return trimmed || undefined; | ||
| }; | ||
| /** | ||
| * 判断给定 agent 是否为默认的 main agent | ||
| * @param agent - agent 配置对象 | ||
| * @returns 当 agent 的 id 或 name 等于 DEFAULT_AGENT_ID 时返回 true | ||
| */ | ||
| const isMainAgent = (agent) => { | ||
| const id = getNonEmptyString(agent.id); | ||
| const name = getNonEmptyString(agent.name); | ||
| return id === DEFAULT_AGENT_ID || name === DEFAULT_AGENT_ID; | ||
| }; | ||
| /** | ||
| * 为缺少 workspace 的 agent 补齐默认 workspace | ||
| * @param agent - agent 配置对象 | ||
| * @param defaultWorkspace - 全局默认 workspace 路径 | ||
| * @returns 补齐 workspace 后的 agent 对象(若已有则原样返回) | ||
| */ | ||
| const withDefaultWorkspace = (agent, defaultWorkspace) => { | ||
| if (!defaultWorkspace || getNonEmptyString(agent.workspace)) | ||
| return agent; | ||
| return { ...agent, workspace: defaultWorkspace }; | ||
| }; | ||
| /** | ||
| * 统一发送 agents 响应消息 | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| * @param payload - 响应载荷,包含目标、来源、agents 列表及可选错误信息 | ||
| */ | ||
| const emitAgentsResponse = (reliableEmitter, payload) => { | ||
| const msgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, { msgId, ...payload, timestamp: Date.now() }, msgId); | ||
| }; | ||
| /** | ||
| * 标准化 agents 列表,确保始终包含一个 main agent | ||
| * | ||
| * 处理逻辑: | ||
| * 1. 从配置中读取 agents.list | ||
| * 2. 遍历列表,为已存在的 main agent 补齐默认 workspace | ||
| * 3. 若列表中不存在 main agent,则自动创建一个并置于列表首位 | ||
| * | ||
| * @param currentCfg - 当前 openclaw.json 完整配置对象 | ||
| * @returns 标准化后的 agents 列表(保证包含 main agent) | ||
| */ | ||
| const normalizeAgentsWithMainAgent = (currentCfg) => { | ||
| const agentsConfig = currentCfg.agents; | ||
| const agentsList = Array.isArray(agentsConfig?.list) ? agentsConfig.list : []; | ||
| // 从 defaults 中提取全局默认 workspace(如果存在且有效) | ||
| const defaultWorkspace = getNonEmptyString(agentsConfig?.defaults?.workspace); | ||
| let hasMainAgent = false; | ||
| const normalizedAgents = agentsList.map((agent) => { | ||
| if (!isMainAgent(agent)) | ||
| return agent; | ||
| hasMainAgent = true; | ||
| return withDefaultWorkspace(agent, defaultWorkspace); | ||
| }); | ||
| // 列表中已包含 main agent,直接返回 | ||
| if (hasMainAgent) | ||
| return normalizedAgents; | ||
| // 自动构造默认 main agent 并插入列表首位 | ||
| const mainAgent = { | ||
| id: DEFAULT_AGENT_ID, | ||
| name: DEFAULT_AGENT_ID, | ||
| displayName: DEFAULT_AGENT_ID, | ||
| isDefault: true, | ||
| ...(defaultWorkspace && { workspace: defaultWorkspace }), | ||
| }; | ||
| return [mainAgent, ...normalizedAgents]; | ||
| }; | ||
| /** | ||
| * 处理 agents:request 事件的核心逻辑 | ||
| * | ||
| * 流程:加载配置 → 标准化 agents → 附加 soulId → 发送响应 | ||
| * | ||
| * @param from - 请求来源客户端 ID | ||
| * @param botClientId - 当前 bot 客户端 ID | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| */ | ||
| const handleAgentsRequest = (from, botClientId, reliableEmitter) => { | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const agentsList = normalizeAgentsWithMainAgent(currentCfg); | ||
| // 尝试为 agents 附加 soulId,失败时降级使用无 soulId 的列表 | ||
| let agentsWithSoulIds = agentsList; | ||
| try { | ||
| agentsWithSoulIds = attachSoulIdsToAgents(agentsList, readSoulIdMap()); | ||
| } | ||
| catch (soulErr) { | ||
| const errMsg = soulErr instanceof Error ? soulErr.message : String(soulErr); | ||
| log.error(`[${CHANNEL_KEY}] 读取 soul 配置 ${LIGHTSOUL_CONFIG_PATH} 失败: ${errMsg}`); | ||
| } | ||
| log.info(`[${CHANNEL_KEY}] 收到 agents 请求,返回数量: ${agentsWithSoulIds.length}`); | ||
| emitAgentsResponse(reliableEmitter, { | ||
| to: from, | ||
| from: botClientId, | ||
| agents: agentsWithSoulIds, | ||
| }); | ||
| }; | ||
| /** | ||
| * 注册 agents:request 事件监听器 | ||
| * | ||
| * 当收到其他客户端发来的 agents 请求时,读取最新配置并返回 agents 列表。 | ||
| * 忽略自身发出的请求,避免消息回环。 | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项(botClientId、reliableEmitter、onEvent 回调) | ||
| */ | ||
| export function bindAgentsRequestHandler(socket, deps) { | ||
| const { botClientId, reliableEmitter, onEvent } = deps; | ||
| socket.on(EVENT_AGENTS_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| try { | ||
| handleAgentsRequest(data.from, botClientId, reliableEmitter); | ||
| } | ||
| catch (err) { | ||
| log.error(`[${CHANNEL_KEY}] 处理 agents 请求异常: ${err}`); | ||
| // 发生异常时返回空列表及错误信息 | ||
| emitAgentsResponse(reliableEmitter, { | ||
| to: data.from, | ||
| from: botClientId, | ||
| agents: [], | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }); | ||
| } | ||
| }); | ||
| } |
| /** | ||
| * @file chat-request.ts | ||
| * @description 会话管理请求处理器 | ||
| * | ||
| * 监听 chat:request 事件,根据 type 字段分发到对应的 service handler, | ||
| * 支持 list / create / update / delete 四种会话操作。 | ||
| */ | ||
| import { CHANNEL_KEY, EVENT_CHAT_REQUEST } from '../../config.js'; | ||
| import { handleChatList } from '../service/chat-list.js'; | ||
| import { handleChatCreate } from '../service/chat-create.js'; | ||
| import { handleChatUpdate } from '../service/chat-update.js'; | ||
| import { handleChatDelete } from '../service/chat-delete.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.chat-request'); | ||
| /** | ||
| * 根据 chat 操作类型分发到对应的 service handler | ||
| * @param data - 请求数据,包含 type、from、agentId 等字段 | ||
| * @param botClientId - Bot 客户端 ID | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| */ | ||
| function dispatchChatHandler(data, botClientId, reliableEmitter) { | ||
| const baseParams = { userId: data.from, agentId: data.agentId, botClientId, reliableEmitter }; | ||
| switch (data.type) { | ||
| case 'list': | ||
| handleChatList(baseParams); | ||
| break; | ||
| case 'create': | ||
| handleChatCreate(baseParams); | ||
| break; | ||
| case 'update': | ||
| handleChatUpdate({ ...baseParams, chatId: data.chatId, title: data.title }); | ||
| break; | ||
| case 'delete': | ||
| handleChatDelete({ ...baseParams, chatId: data.chatId }); | ||
| break; | ||
| default: | ||
| log.warn(`[${CHANNEL_KEY}] 未知的 chat 操作类型: ${data.type}`); | ||
| } | ||
| } | ||
| /** | ||
| * 注册 chat:request 事件监听器 | ||
| * | ||
| * 收到请求后依次执行:确认消息 → 校验来源 → 过滤自身消息 → 分发处理 | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项(botClientId、reliableEmitter、onEvent 回调) | ||
| */ | ||
| export function bindChatRequestHandler(socket, deps) { | ||
| const { botClientId, reliableEmitter, onEvent } = deps; | ||
| socket.on(EVENT_CHAT_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 缺少来源标识时直接忽略 | ||
| if (!data?.from) { | ||
| log.warn(`[${CHANNEL_KEY}] 收到 chat 请求但缺少 userId,已忽略`); | ||
| return; | ||
| } | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| log.info(`[${CHANNEL_KEY}] 收到 chat 请求: from=${data.from}, type=${data.type}`); | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| dispatchChatHandler(data, botClientId, reliableEmitter); | ||
| }); | ||
| } |
| /** | ||
| * 文件下载处理器(kind=file:download, status=download_req) | ||
| * | ||
| * 流程: | ||
| * 1. 从 data.extra.transferData 解析 transferId / localPath | ||
| * 2. 基础校验:必须是绝对路径,文件必须存在 | ||
| * 3. 先回告 download_ready 帧(transferId + 文件元数据),前端据此更新下载按钮状态 | ||
| * 4. 通过 uploadFileToServer 将本机文件上传到 ai-server,拿到下载 URL | ||
| * 5. 回告 download_url 帧(transferId + url),前端触发浏览器原生下载 | ||
| * 6. 任一阶段失败 → 回告 download_error 帧(transferId + error message) | ||
| */ | ||
| import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS, } from '../../config.js'; | ||
| import { generateMsgId } from '../../dedup.js'; | ||
| import { uploadFileToServer } from '../../file-storage.js'; | ||
| import { guessMimeByExt } from '../../media.js'; | ||
| import { extractChatId } from '../utils/message.js'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.file-download'); | ||
| /** | ||
| * 处理文件下载请求 | ||
| */ | ||
| export async function handleFileDownloadReq(data, botClientId, reliableEmitter, account) { | ||
| const transferData = data.extra?.transferData; | ||
| const transferId = transferData?.transferId; | ||
| const localPath = transferData?.localPath; | ||
| log.info(`[${CHANNEL_KEY}] file:download(req) received: transferId=${transferId}, localPath=${localPath}, from=${data.from}`); | ||
| const sendError = (error) => { | ||
| const msgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, { | ||
| msgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error }, | ||
| }, | ||
| }, msgId); | ||
| log.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`); | ||
| }; | ||
| if (!transferId || !localPath) { | ||
| sendError('Missing transferId or localPath in extra.transferData'); | ||
| return; | ||
| } | ||
| // 基础校验:必须是绝对路径 | ||
| if (!path.isAbsolute(localPath)) { | ||
| sendError(`localPath must be an absolute path: ${localPath}`); | ||
| return; | ||
| } | ||
| const resolvedPath = path.resolve(localPath); | ||
| if (!fs.existsSync(resolvedPath)) { | ||
| sendError(`File not found: ${resolvedPath}`); | ||
| return; | ||
| } | ||
| const stat = fs.statSync(resolvedPath); | ||
| if (!stat.isFile()) { | ||
| sendError(`Not a regular file: ${resolvedPath}`); | ||
| return; | ||
| } | ||
| const fileName = path.basename(resolvedPath); | ||
| const mimeType = guessMimeByExt(path.extname(fileName).toLowerCase()) || 'application/octet-stream'; | ||
| // 回告 download_ready(文件确认存在 + 元数据) | ||
| const readyMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, { | ||
| msgId: readyMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { | ||
| transferId, | ||
| status: FILE_DOWNLOAD_STATUS.READY, | ||
| name: fileName, | ||
| size: stat.size, | ||
| contentType: mimeType, | ||
| }, | ||
| }, | ||
| }, readyMsgId); | ||
| log.info(`[${CHANNEL_KEY}] file:download(ready) sent: transferId=${transferId}, name=${fileName}, size=${stat.size}`); | ||
| // 上传本机文件到 ai-server(含审核),成功后把 URL 回告前端 | ||
| try { | ||
| const apiKey = account.apiKey; | ||
| const uploadResult = await uploadFileToServer(resolvedPath, { apiKey }); | ||
| if (!uploadResult.isUploaded || !uploadResult.url) { | ||
| sendError('Upload to ai-server failed'); | ||
| return; | ||
| } | ||
| const urlMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, { | ||
| msgId: urlMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { | ||
| transferId, | ||
| status: FILE_DOWNLOAD_STATUS.URL, | ||
| url: uploadResult.url, | ||
| name: fileName, | ||
| size: stat.size, | ||
| contentType: mimeType, | ||
| }, | ||
| }, | ||
| }, urlMsgId); | ||
| log.info(`[${CHANNEL_KEY}] file:download(url) sent: transferId=${transferId}, url=${uploadResult.url}`); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file group-abort.ts | ||
| * @description 群消息中止请求处理器 | ||
| * | ||
| * 监听 group:abort:request 事件,对请求进行必填字段校验、 | ||
| * 自身消息过滤、去重判断后,委托给 handleGroupAbort 执行中止逻辑。 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { isDuplicate } from '../../dedup.js'; | ||
| import { handleGroupAbort } from '../service/group-abort.js'; | ||
| import { EVENT_GROUP_ABORT_REQUEST } from '../../group/constants/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| import { hasRequiredFields, getMissingFields } from '../utils/validate.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.group-abort-handler'); | ||
| /** 中止请求的必填字段列表 */ | ||
| const REQUIRED_FIELDS = ['from', 'msgId', 'groupId', 'conversationId']; | ||
| /** | ||
| * 注册 group:abort:request 事件监听器 | ||
| * | ||
| * 处理流程:确认消息 → 校验必填字段 → 过滤自身消息 → 去重 → 委托 service 处理 | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项(botClientId、reliableEmitter、orchestrator 等) | ||
| */ | ||
| export function bindGroupAbortHandler(socket, deps) { | ||
| const { botClientId, reliableEmitter, onEvent, orchestrator, groupStorage } = deps; | ||
| socket.on(EVENT_GROUP_ABORT_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 校验必填字段,明确记录缺失项 | ||
| if (!hasRequiredFields(data, REQUIRED_FIELDS)) { | ||
| const missing = getMissingFields(data, REQUIRED_FIELDS); | ||
| log.warn(`[${CHANNEL_KEY}] 中止群消息请求缺少必填字段: [${missing.join(', ')}],已忽略`); | ||
| return; | ||
| } | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 去重:同一 msgId 的请求只处理一次 | ||
| if (isDuplicate(`group:abort:${data.msgId}`)) { | ||
| log.warn(`[${CHANNEL_KEY}] 重复的群中止请求 (msgId=${data.msgId}),已忽略`); | ||
| return; | ||
| } | ||
| log.info(`[${CHANNEL_KEY}] 收到中止群消息请求: from=${data.from}, groupId=${data.groupId}, conversationId=${data.conversationId}`); | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| void handleGroupAbort({ | ||
| userId: data.from, | ||
| botClientId, | ||
| reliableEmitter, | ||
| requestMsgId: data.msgId, | ||
| groupId: data.groupId, | ||
| conversationId: data.conversationId, | ||
| orchestrator, | ||
| storage: groupStorage, | ||
| }); | ||
| }); | ||
| } |
| /** | ||
| * @file group-history.ts | ||
| * @description 群历史消息请求处理器 | ||
| * | ||
| * 监听 group:history:request 事件,对请求进行必填字段校验(并报告缺失字段)、 | ||
| * 自身消息过滤、去重判断后,委托给 handleGroupHistory 查询并返回历史消息。 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { isDuplicate } from '../../dedup.js'; | ||
| import { handleGroupHistory } from '../service/group-history.js'; | ||
| import { EVENT_GROUP_HISTORY_REQUEST } from '../../group/constants/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| import { hasRequiredFields, getMissingFields } from '../utils/validate.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.group-history-handler'); | ||
| /** 群历史消息请求的必填字段列表 */ | ||
| const REQUIRED_FIELDS = ['from', 'msgId', 'groupId']; | ||
| /** | ||
| * 注册 group:history:request 事件监听器 | ||
| * | ||
| * 处理流程:确认消息 → 校验必填字段(报告缺失详情) → 过滤自身消息 → 去重 → 委托 service 查询历史 | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项(botClientId、reliableEmitter、onEvent 回调) | ||
| */ | ||
| export function bindGroupHistoryHandler(socket, deps) { | ||
| const { botClientId, reliableEmitter, onEvent } = deps; | ||
| socket.on(EVENT_GROUP_HISTORY_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 校验必填字段,缺失时输出具体字段名称便于排查 | ||
| if (!hasRequiredFields(data, REQUIRED_FIELDS)) { | ||
| const missing = getMissingFields(data, REQUIRED_FIELDS); | ||
| log.warn(`[${CHANNEL_KEY}] 群历史消息请求缺少必填字段: [${missing.join(', ')}],已忽略`); | ||
| return; | ||
| } | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 去重:同一 msgId 的请求只处理一次 | ||
| if (isDuplicate(`group:history:${data.msgId}`)) { | ||
| log.warn(`[${CHANNEL_KEY}] 重复的群历史消息请求 (msgId=${data.msgId}),已忽略`); | ||
| return; | ||
| } | ||
| log.info(`[${CHANNEL_KEY}] 收到群历史消息请求: from=${data.from}, groupId=${data.groupId}, limit=${data.limit ?? '-'}, before=${data.before ?? '-'}, chatOnly=${data.chatOnly ?? '-'}`); | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| void handleGroupHistory({ | ||
| userId: data.from, | ||
| botClientId, | ||
| reliableEmitter, | ||
| requestMsgId: data.msgId, | ||
| groupId: data.groupId, | ||
| limit: data.limit, | ||
| before: data.before, | ||
| chatOnly: data.chatOnly, | ||
| }); | ||
| }); | ||
| } |
| /** | ||
| * @file group-member.ts | ||
| * @description 群成员管理请求处理器(group:member:request) | ||
| * | ||
| * 监听 group:member:request 事件,对请求进行必填字段校验(并报告缺失字段)、 | ||
| * 自身消息过滤、去重判断后,根据 type 分发到 add/remove handler。 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { isDuplicate } from '../../dedup.js'; | ||
| import { handleGroupMemberAdd, handleGroupMemberRemove, handleGroupMemberUpdate } from '../service/group-member.js'; | ||
| import { EVENT_GROUP_MEMBER_REQUEST } from '../../group/constants/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| import { hasRequiredFields, getMissingFields } from '../utils/validate.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.group-member-handler'); | ||
| /** 基础必填字段列表(所有操作类型共用) */ | ||
| const BASE_REQUIRED_FIELDS = ['from', 'msgId', 'type']; | ||
| /** add/remove 操作额外必填字段 */ | ||
| const MEMBER_OP_REQUIRED_FIELDS = ['from', 'msgId', 'type', 'groupId', 'agentId']; | ||
| /** | ||
| * 根据成员操作类型分发到对应的 service handler | ||
| * @param data - 请求数据 | ||
| * @param botClientId - Bot 客户端 ID | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| */ | ||
| function dispatchMemberHandler(data, botClientId, reliableEmitter) { | ||
| const baseCtx = { | ||
| userId: data.from, | ||
| botClientId, | ||
| reliableEmitter, | ||
| requestMsgId: data.msgId, | ||
| groupId: data.groupId, | ||
| agentId: data.agentId, | ||
| extra: data.extra, | ||
| }; | ||
| switch (data.type) { | ||
| case 'add': | ||
| void handleGroupMemberAdd(baseCtx); | ||
| break; | ||
| case 'remove': | ||
| void handleGroupMemberRemove(baseCtx); | ||
| break; | ||
| case 'update': | ||
| void handleGroupMemberUpdate(baseCtx); | ||
| break; | ||
| default: | ||
| log.warn(`[${CHANNEL_KEY}] 未知的群成员操作类型: ${data.type}`); | ||
| } | ||
| } | ||
| /** | ||
| * 注册 group:member:request 事件监听器 | ||
| * | ||
| * 处理流程:确认消息 → 校验必填字段(报告缺失详情) → 过滤自身消息 → 去重 → 分发到对应 handler | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项(botClientId、reliableEmitter、onEvent 回调) | ||
| */ | ||
| export function bindGroupMemberHandler(socket, deps) { | ||
| const { botClientId, reliableEmitter, onEvent } = deps; | ||
| socket.on(EVENT_GROUP_MEMBER_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 校验必填字段,update 类型不需要 groupId 和 agentId | ||
| const requiredFields = data.type === 'update' ? BASE_REQUIRED_FIELDS : MEMBER_OP_REQUIRED_FIELDS; | ||
| if (!hasRequiredFields(data, requiredFields)) { | ||
| const missing = getMissingFields(data, requiredFields); | ||
| log.warn(`[${CHANNEL_KEY}] 群成员请求缺少必填字段: [${missing.join(', ')}],已忽略`); | ||
| return; | ||
| } | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 去重:同一 msgId 的请求只处理一次 | ||
| if (isDuplicate(`group:member:${data.msgId}`)) { | ||
| log.warn(`[${CHANNEL_KEY}] 重复的群成员请求 (msgId=${data.msgId}),已忽略`); | ||
| return; | ||
| } | ||
| log.info(`[${CHANNEL_KEY}] 收到群成员请求: from=${data.from}, type=${data.type}, groupId=${data.groupId}, agentId=${data.agentId}`); | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| dispatchMemberHandler(data, botClientId, reliableEmitter); | ||
| }); | ||
| } |
| /** | ||
| * @file group-request.ts | ||
| * @description 群组管理请求处理器(group:request) | ||
| * | ||
| * 监听 group:request 事件,对请求进行必填字段校验(并报告缺失字段)、 | ||
| * 自身消息过滤、去重判断后,根据 type 分发到 list/create/update/delete/clear handler。 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { isDuplicate } from '../../dedup.js'; | ||
| import { handleGroupList, handleGroupCreate, handleGroupUpdate, handleGroupDelete, handleGroupClear, } from '../service/group.js'; | ||
| import { EVENT_GROUP_REQUEST } from '../../group/constants/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| import { hasRequiredFields, getMissingFields } from '../utils/validate.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.group-request'); | ||
| /** 必填字段列表 */ | ||
| const REQUIRED_FIELDS = ['from', 'msgId', 'type']; | ||
| /** | ||
| * 根据群组操作类型分发到对应的 service handler | ||
| * @param data - 请求数据 | ||
| * @param botClientId - Bot 客户端 ID | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| */ | ||
| function dispatchGroupHandler(data, botClientId, reliableEmitter, orchestrator) { | ||
| const baseCtx = { userId: data.from, botClientId, reliableEmitter, requestMsgId: data.msgId }; | ||
| switch (data.type) { | ||
| case 'list': | ||
| void handleGroupList(baseCtx); | ||
| break; | ||
| case 'create': | ||
| void handleGroupCreate({ | ||
| ...baseCtx, | ||
| name: data.extra?.name, | ||
| desc: data.extra?.desc, | ||
| members: data.extra?.members, | ||
| defaultAgentId: data.extra?.defaultAgentId, | ||
| orchestrator, | ||
| }); | ||
| break; | ||
| case 'update': | ||
| void handleGroupUpdate({ | ||
| ...baseCtx, | ||
| groupId: data.groupId, | ||
| name: data.extra?.name, | ||
| desc: data.extra?.desc, | ||
| pinned: data.extra?.pinned, | ||
| }); | ||
| break; | ||
| case 'delete': | ||
| void handleGroupDelete({ ...baseCtx, groupId: data.groupId }); | ||
| break; | ||
| case 'clear': | ||
| void handleGroupClear({ ...baseCtx, groupId: data.groupId }); | ||
| break; | ||
| default: | ||
| log.warn(`[${CHANNEL_KEY}] 未知的群组操作类型: ${data.type}`); | ||
| } | ||
| } | ||
| /** | ||
| * 注册 group:request 事件监听器 | ||
| * | ||
| * 处理流程:确认消息 → 校验必填字段(报告缺失详情) → 过滤自身消息 → 去重 → 分发到对应 handler | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项(botClientId、reliableEmitter、onEvent 回调) | ||
| */ | ||
| export function bindGroupRequestHandler(socket, deps) { | ||
| const { botClientId, reliableEmitter, onEvent, orchestrator } = deps; | ||
| socket.on(EVENT_GROUP_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 校验必填字段,缺失时输出具体字段名称便于排查 | ||
| if (!hasRequiredFields(data, REQUIRED_FIELDS)) { | ||
| const missing = getMissingFields(data, REQUIRED_FIELDS); | ||
| log.warn(`[${CHANNEL_KEY}] 群组请求缺少必填字段: [${missing.join(', ')}],已忽略`); | ||
| return; | ||
| } | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 去重:同一 msgId 的请求只处理一次 | ||
| if (isDuplicate(`group:${data.msgId}`)) { | ||
| log.warn(`[${CHANNEL_KEY}] 重复的群组请求 (msgId=${data.msgId}),已忽略`); | ||
| return; | ||
| } | ||
| log.info(`[${CHANNEL_KEY}] 收到群组请求: from=${data.from}, type=${data.type}, groupId=${data.groupId ?? '-'}`); | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| dispatchGroupHandler(data, botClientId, reliableEmitter, orchestrator); | ||
| }); | ||
| } |
| /** | ||
| * @file history-request.ts | ||
| * @description 私聊历史消息请求处理器(history:request) | ||
| * | ||
| * 监听 history:request 事件,对请求进行必填字段校验、 | ||
| * 自身消息过滤、去重与防抖后,委托给 queryPrivateHistory 执行查询, | ||
| * 并通过 reliableEmitter 回包(成功/失败统一格式)。 | ||
| */ | ||
| import { CHANNEL_KEY, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_AGENT_ID } from '../../config.js'; | ||
| import { isDuplicate, debounceHistoryRequest, generateMsgId } from '../../dedup.js'; | ||
| import { queryPrivateHistory } from '../service/history.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.history-request'); | ||
| /** | ||
| * 发送历史消息查询成功响应 | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| * @param botClientId - Bot 客户端 ID | ||
| * @param userId - 请求方用户 ID | ||
| * @param result - 查询结果 | ||
| */ | ||
| function emitSuccessResponse(reliableEmitter, botClientId, userId, result) { | ||
| const msgId = generateMsgId(); | ||
| const payload = { | ||
| msgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| sessionKey: result.sessionKey, | ||
| messages: result.messages, | ||
| agentId: result.agentId, | ||
| }; | ||
| reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, payload, msgId); | ||
| } | ||
| /** | ||
| * 发送历史消息查询失败响应 | ||
| * @param reliableEmitter - 可靠消息发射器 | ||
| * @param botClientId - Bot 客户端 ID | ||
| * @param userId - 请求方用户 ID | ||
| * @param agentId - 请求中的 agentId(兜底为默认值) | ||
| * @param err - 错误对象 | ||
| */ | ||
| function emitErrorResponse(reliableEmitter, botClientId, userId, agentId, err) { | ||
| const msgId = generateMsgId(); | ||
| const payload = { | ||
| msgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| sessionKey: '', | ||
| messages: [], | ||
| agentId, | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }; | ||
| reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, payload, msgId); | ||
| } | ||
| /** | ||
| * 注册 history:request 事件监听器 | ||
| * | ||
| * 处理流程:确认消息 → 校验 from 字段 → 过滤自身消息 → 去重 → 防抖 → 查询 → 回包 | ||
| * | ||
| * @param socket - 原生 Socket 实例 | ||
| * @param deps - 依赖项 | ||
| */ | ||
| export function bindHistoryRequestHandler(socket, deps) { | ||
| const { account, botClientId, reliableEmitter, onEvent } = deps; | ||
| socket.on(EVENT_HISTORY_REQUEST, (data, ack) => { | ||
| // 立即确认收到消息 | ||
| ack?.(); | ||
| // 校验必填字段 | ||
| if (!data?.from) { | ||
| log.warn(`[${CHANNEL_KEY}] 历史消息请求缺少 from 字段,已忽略`); | ||
| return; | ||
| } | ||
| // 忽略自身发出的请求,防止回环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 去重:同一 msgId 的请求只处理一次 | ||
| if (data.msgId && isDuplicate(`history:${data.msgId}`)) { | ||
| log.warn(`[${CHANNEL_KEY}] 重复的历史消息请求 (msgId=${data.msgId}),已忽略`); | ||
| return; | ||
| } | ||
| // 防抖:同一用户高频请求只处理最后一条 | ||
| debounceHistoryRequest(data.from, () => { | ||
| // 触发外部事件回调(如心跳续期) | ||
| onEvent?.(); | ||
| try { | ||
| const result = queryPrivateHistory(data, account); | ||
| emitSuccessResponse(reliableEmitter, botClientId, data.from, result); | ||
| } | ||
| catch (err) { | ||
| log.error(`[${CHANNEL_KEY}] 历史消息查询失败: ${err instanceof Error ? err.message : String(err)}`); | ||
| emitErrorResponse(reliableEmitter, botClientId, data.from, data.agentId || DEFAULT_AGENT_ID, err); | ||
| } | ||
| }); | ||
| }); | ||
| } |
| /** | ||
| * @file index.ts | ||
| * @description Socket 事件处理器子模块统一导出入口 | ||
| * | ||
| * 本模块作为 socket/events 目录的桶文件(barrel),集中导出: | ||
| * - 类型定义:各事件处理器所需的依赖接口和请求数据类型 | ||
| * - 工具函数:消息判断、会话 ID 提取、历史记录等通用工具 | ||
| * - 事件绑定器:各类 Socket 事件的监听注册函数 | ||
| */ | ||
| /** | ||
| * 工具函数导出 —— 消息处理相关的通用工具 | ||
| * | ||
| * - isGroupMessage: 判断消息是否为群消息 | ||
| * - extractChatId: 从消息中提取会话 ID | ||
| * - recordSessionInHistory: 将当前会话记录到历史存储 | ||
| */ | ||
| export { isGroupMessage, extractChatId, recordSessionInHistory } from '../utils/message.js'; | ||
| /** | ||
| * 事件绑定器导出 —— 各 Socket 事件的监听注册函数 | ||
| * | ||
| * - bindAgentsRequestHandler: 注册 agents 列表请求事件监听 | ||
| * - handleFileDownloadReq: 处理文件下载请求 | ||
| * - bindMessagePrivateHandler: 注册私聊消息事件监听 | ||
| * - bindHistoryRequestHandler: 注册私聊历史消息请求事件监听 | ||
| * - bindChatRequestHandler: 注册聊天请求事件监听 | ||
| * - bindGroupRequestHandler: 注册群聊请求事件监听 | ||
| * - bindGroupHistoryHandler: 注册群历史消息请求事件监听 | ||
| * - bindGroupMemberHandler: 注册群成员变更事件监听 | ||
| * - bindGroupAbortHandler: 注册群中止请求事件监听 | ||
| */ | ||
| export { bindAgentsRequestHandler } from './agents-request.js'; | ||
| export { handleFileDownloadReq } from './file-download.js'; | ||
| export { bindMessagePrivateHandler } from './message-private.js'; | ||
| export { bindHistoryRequestHandler } from './history-request.js'; | ||
| export { bindChatRequestHandler } from './chat-request.js'; | ||
| export { bindGroupRequestHandler } from './group-request.js'; | ||
| export { bindGroupHistoryHandler } from './group-history.js'; | ||
| export { bindGroupMemberHandler } from './group-member.js'; | ||
| export { bindGroupAbortHandler } from './group-abort.js'; |
| /** | ||
| * 私信消息处理器(message:private) | ||
| * | ||
| * 职责:对消息做多层过滤,将合法消息放入处理队列 | ||
| */ | ||
| import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS } from '../../config.js'; | ||
| import { isDuplicate } from '../../dedup.js'; | ||
| import { isGroupMessage, extractChatId, recordSessionInHistory } from '../utils/message.js'; | ||
| import { handleFileDownloadReq } from './file-download.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| const log = getModuleLogger('socket.message-private'); | ||
| /** 生成私聊消息去重 key */ | ||
| const dedupKey = (msgId) => `private:${msgId}`; | ||
| /** 判断消息是否包含有效内容(文字或附件) */ | ||
| function hasValidPayload(data) { | ||
| return !!(data.content?.trim() || (data.files && data.files.length > 0)); | ||
| } | ||
| /** 处理私聊文本消息(非群聊分支) */ | ||
| function handlePrivateTextMessage(data, deps) { | ||
| const { account, handleMessage } = deps; | ||
| const chatId = extractChatId(data); | ||
| handleMessage({ | ||
| senderId: data.from, | ||
| text: data.content || '', | ||
| messageId: data.msgId, | ||
| files: data.files ?? [], | ||
| timestamp: data.timestamp, | ||
| agentId: data.agentId, | ||
| chatId, | ||
| }); | ||
| // 异步登记 sessionId 到历史记录 | ||
| setImmediate(() => { | ||
| try { | ||
| recordSessionInHistory({ | ||
| userId: data.from, | ||
| agentId: data.agentId, | ||
| chatId, | ||
| accountId: account.accountId, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn(`[${CHANNEL_KEY}] recordSessionInHistory 失败: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| }); | ||
| } | ||
| /** 注册 message:private 事件监听 */ | ||
| export function bindMessagePrivateHandler(socket, deps) { | ||
| const { botClientId, onEvent, reliableEmitter, account, handleGroupMessage } = deps; | ||
| socket.on(EVENT_MESSAGE_PRIVATE, (data, ack) => { | ||
| ack?.(); | ||
| // ① 回环防御:过滤 bot 自身发出的消息 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // ② 文件下载信令分发(独立链路,不入 AI 处理队列) | ||
| if (data.kind === KIND_FILE_DOWNLOAD) { | ||
| const status = data.extra?.transferData?.status; | ||
| if (status === FILE_DOWNLOAD_STATUS.REQ && !isDuplicate(dedupKey(data.msgId))) { | ||
| onEvent?.(); | ||
| void handleFileDownloadReq(data, botClientId, reliableEmitter, account); | ||
| } | ||
| return; | ||
| } | ||
| // ③ 跳过非文本控制消息 | ||
| if (data.kind && data.kind !== 'text') | ||
| return; | ||
| // ④ 内容校验 + 去重 | ||
| if (!hasValidPayload(data)) | ||
| return; | ||
| if (isDuplicate(dedupKey(data.msgId))) | ||
| return; | ||
| onEvent?.(); | ||
| // ⑤ 群聊/私聊分流 | ||
| if (isGroupMessage(data)) { | ||
| handleGroupMessage(data); | ||
| log.info(`[${CHANNEL_KEY}] 处理群聊消息: groupId=${data?.extra?.groupId}, from=${data.from}, content="${(data.content || '').slice(0, 60)}", files=${data.files?.length ?? 0}`); | ||
| } | ||
| else { | ||
| log.info(`[${CHANNEL_KEY}] 处理私聊消息: agent=${data.agentId}, from=${data.from}, content="${(data.content || '').slice(0, 60)}", files=${data.files?.length ?? 0}`); | ||
| handlePrivateTextMessage(data, deps); | ||
| } | ||
| }); | ||
| } |
| /** | ||
| * Chat handler 共享工具函数 | ||
| * | ||
| * 提供 list / create / update / delete 四个 handler 共用的: | ||
| * - createChatErrorSender 工厂函数 | ||
| * - 模块级日志器 | ||
| * | ||
| */ | ||
| import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../../config.js'; | ||
| import { generateMsgId } from '../../dedup.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| export const log = getModuleLogger('socket.chat'); | ||
| /** | ||
| * 生成统一的错误响应发送器。 | ||
| * @param ctx 共享上下文 | ||
| * @param extra 额外字段(如 chatId、兜底 chats: []) | ||
| */ | ||
| export function createChatErrorSender(ctx, extra = {}) { | ||
| const { responseMsgId, botClientId, userId, agentId, type, reliableEmitter } = 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); | ||
| }; | ||
| } | ||
| /** 生成响应 msgId 的快捷方法 */ | ||
| export { generateMsgId }; |
| /** | ||
| * @file chat-create.ts | ||
| * @description Chat handler — create(新建会话) | ||
| * | ||
| * 新建一条会话元数据,chatId = randomUUID(),插入列表最前。 | ||
| * 文件或目录不存在时由 writeChatsFile 递归创建。 | ||
| */ | ||
| import { randomUUID } from 'node:crypto'; | ||
| import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../../config.js'; | ||
| import { readChatsFile, resolveChatsFilePath, writeChatsFile } from '../../utils/common.js'; | ||
| import { log, generateMsgId, createChatErrorSender } from './chat-common.js'; | ||
| /** | ||
| * 创建新的会话元数据 | ||
| * @returns 包含默认值的 ChatMeta 对象 | ||
| */ | ||
| function buildNewChatMeta() { | ||
| const now = Date.now(); | ||
| return { | ||
| chatId: randomUUID(), | ||
| title: '新会话', | ||
| titleLocked: false, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| pinned: false, | ||
| sessionIdHistory: [], | ||
| }; | ||
| } | ||
| /** | ||
| * 发送创建成功响应 | ||
| * @param params - handler 基础参数 | ||
| * @param responseMsgId - 响应消息 ID | ||
| * @param newChat - 新创建的会话元数据 | ||
| */ | ||
| function emitCreateResponse(params, responseMsgId, newChat) { | ||
| const { botClientId, userId, agentId, reliableEmitter } = params; | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'create', | ||
| agentId, | ||
| chats: [newChat], | ||
| timestamp: Date.now(), | ||
| }, responseMsgId); | ||
| } | ||
| /** | ||
| * 处理 EVENT_CHAT_REQUEST(type=create) | ||
| * | ||
| * 流程:解析存储路径 → 读取现有会话列表 → 创建新会话 → 写入文件 → 回包 | ||
| * | ||
| * @param params - Chat handler 通用入参 | ||
| */ | ||
| export function handleChatCreate(params) { | ||
| const { userId, agentId, botClientId, reliableEmitter } = params; | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| const responseMsgId = generateMsgId(); | ||
| const sendError = createChatErrorSender({ | ||
| responseMsgId, | ||
| botClientId, | ||
| userId, | ||
| agentId, | ||
| type: 'create', | ||
| reliableEmitter, | ||
| }); | ||
| try { | ||
| const existing = readChatsFile(chatsPath); | ||
| const newChat = buildNewChatMeta(); | ||
| writeChatsFile(chatsPath, [newChat, ...existing]); | ||
| log.info(`[${CHANNEL_KEY}] 会话创建成功: userId=${userId}, agentId=${agentId}, chatId=${newChat.chatId}`); | ||
| emitCreateResponse(params, responseMsgId, newChat); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file chat-delete.ts | ||
| * @description Chat handler — delete(删除会话) | ||
| * | ||
| * 该模块负责处理会话删除请求: | ||
| * 1. 根据传入的 chatId 在本地 JSON 文件中定位目标会话 | ||
| * 2. 将目标会话从列表中剔除并原子写回文件 | ||
| * 3. 通过可靠消息通道将删除结果推送给客户端 | ||
| * | ||
| * 异常处理: | ||
| * - 会话文件不存在时,返回 error 响应 | ||
| * - 目标 chatId 未找到时,返回 error 响应 | ||
| * - 文件读写异常时,捕获错误并返回 error 响应 | ||
| */ | ||
| import * as fs from 'node:fs'; | ||
| import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../../config.js'; | ||
| import { readChatsFile, resolveChatsFilePath, writeChatsFile } from '../../utils/common.js'; | ||
| import { log, generateMsgId, createChatErrorSender } from './chat-common.js'; | ||
| /** | ||
| * 发送删除成功响应 | ||
| * | ||
| * 通过 reliableEmitter 的 emitWithAck 方法发送带确认机制的响应消息, | ||
| * 确保客户端能够可靠接收到删除操作的结果。 | ||
| * | ||
| * @param params - handler 基础参数,包含用户/Agent/Bot 标识及消息发射器 | ||
| * @param responseMsgId - 本次响应的唯一消息 ID,用于客户端消息去重和确认 | ||
| * @param deletedChat - 被删除的会话元数据,回传给客户端用于 UI 同步 | ||
| */ | ||
| function emitDeleteResponse(params, responseMsgId, deletedChat) { | ||
| const { botClientId, userId, agentId, reliableEmitter } = params; | ||
| // 通过可靠消息通道发送删除成功响应,附带被删除的会话信息 | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, // 响应消息唯一标识 | ||
| from: botClientId, // 发送方:Bot 客户端 ID | ||
| to: userId, // 接收方:用户 ID | ||
| type: 'delete', // 操作类型标识 | ||
| agentId, // 关联的 Agent ID | ||
| chats: [deletedChat], // 被删除的会话列表(单条) | ||
| timestamp: Date.now(), // 响应时间戳 | ||
| }, responseMsgId); | ||
| } | ||
| /** | ||
| * 处理 EVENT_CHAT_REQUEST(type=delete) | ||
| * | ||
| * 完整处理流程: | ||
| * 1. 解构入参,获取用户/Agent/会话标识 | ||
| * 2. 解析会话存储文件路径 | ||
| * 3. 生成本次响应的唯一消息 ID | ||
| * 4. 创建统一的错误发送器(复用 chat-common 工具函数) | ||
| * 5. 校验会话文件是否存在(前置守卫) | ||
| * 6. 读取会话列表,查找目标会话 | ||
| * 7. 过滤掉目标会话,将剩余列表写回文件 | ||
| * 8. 记录操作日志,发送成功响应 | ||
| * | ||
| * @param params - Chat delete 操作入参,继承自 ChatHandlerParams 并扩展 chatId 字段 | ||
| */ | ||
| export function handleChatDelete(params) { | ||
| // 解构所需参数:用户ID、AgentID、目标会话ID、Bot客户端ID、可靠消息发射器 | ||
| const { userId, agentId, chatId, botClientId, reliableEmitter } = params; | ||
| // 根据 agentId 和 userId 解析会话列表的本地 JSON 文件路径 | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| // 生成本次响应的唯一消息 ID,用于消息追踪和 ACK 确认 | ||
| const responseMsgId = generateMsgId(); | ||
| // 创建统一的错误响应发送器,封装了错误消息的格式化和发送逻辑 | ||
| const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'delete', reliableEmitter }, { chatId }); | ||
| // 前置守卫:检查会话文件是否存在,不存在则直接返回错误 | ||
| if (!fs.existsSync(chatsPath)) { | ||
| sendError(`会话文件不存在: ${chatsPath}`); | ||
| return; | ||
| } | ||
| try { | ||
| // 读取当前会话列表(JSON 文件 → ChatMeta[] 数组) | ||
| const chats = readChatsFile(chatsPath); | ||
| // 在列表中查找目标会话对象 | ||
| const targetChat = chats.find((c) => c.chatId === chatId); | ||
| // 目标会话不存在时返回错误响应 | ||
| if (!targetChat) { | ||
| sendError(`会话不存在: chatId=${chatId}`); | ||
| return; | ||
| } | ||
| // 过滤掉目标会话,得到删除后的剩余列表 | ||
| const remainingChats = chats.filter((c) => c.chatId !== chatId); | ||
| // 将剩余会话列表原子写回文件(writeChatsFile 内部保证写入的原子性) | ||
| writeChatsFile(chatsPath, remainingChats); | ||
| // 记录删除成功日志,包含关键上下文信息便于排查 | ||
| log.info(`[${CHANNEL_KEY}] 会话删除成功: userId=${userId}, agentId=${agentId}, chatId=${chatId}, 剩余=${remainingChats.length}`); | ||
| // 向客户端发送删除成功响应 | ||
| emitDeleteResponse(params, responseMsgId, targetChat); | ||
| } | ||
| catch (err) { | ||
| // 捕获文件读写等异常,统一通过错误发送器返回给客户端 | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file chat-list.ts | ||
| * @description Chat handler — list(会话列表查询) | ||
| * | ||
| * 该模块负责处理会话列表查询请求: | ||
| * 1. 根据 agentId + userId 定位本地 JSON 存储文件 | ||
| * 2. 读取并返回该用户在指定 Agent 下的全部会话列表 | ||
| * 3. 通过可靠消息通道将查询结果推送给客户端 | ||
| * | ||
| * 兜底策略: | ||
| * - 文件不存在时由 readChatsFileOrInitDefault 主动落盘一条默认会话条目 | ||
| * - 保证前端拉列表永远拿到至少一项,避免空列表导致的 UI 异常 | ||
| */ | ||
| // ─── 依赖导入 ──────────────────────────────────────────────────────────────── | ||
| /** 通道标识常量和聊天响应事件名 */ | ||
| import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../../config.js'; | ||
| /** 文件读取工具:readChatsFileOrInitDefault 在文件缺失时自动创建默认条目 */ | ||
| import { readChatsFileOrInitDefault, resolveChatsFilePath } from '../../utils/common.js'; | ||
| /** 共享工具:模块日志器、消息 ID 生成器、错误发送器工厂 */ | ||
| import { log, generateMsgId, createChatErrorSender } from './chat-common.js'; | ||
| // ─── 内部辅助函数 ───────────────────────────────────────────────────────────── | ||
| /** | ||
| * 发送列表查询成功响应 | ||
| * | ||
| * 通过 reliableEmitter 的 emitWithAck 方法发送带确认机制的响应消息, | ||
| * 确保客户端能够可靠接收到会话列表数据。 | ||
| * | ||
| * @param params - handler 基础参数,包含用户/Agent/Bot 标识及消息发射器 | ||
| * @param responseMsgId - 本次响应的唯一消息 ID,用于客户端消息去重和 ACK 确认 | ||
| * @param chats - 完整的会话列表数据,至少包含一条兜底记录 | ||
| */ | ||
| function emitListResponse(params, responseMsgId, chats) { | ||
| const { botClientId, userId, agentId, reliableEmitter } = params; | ||
| // 通过可靠消息通道发送列表查询成功响应 | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, // 响应消息唯一标识 | ||
| from: botClientId, // 发送方:Bot 客户端 ID | ||
| to: userId, // 接收方:用户 ID | ||
| type: 'list', // 操作类型标识 | ||
| agentId, // 关联的 Agent ID | ||
| chats, // 会话列表数据(ChatMeta[]) | ||
| timestamp: Date.now(), // 响应时间戳 | ||
| }, responseMsgId); | ||
| } | ||
| // ─── 导出函数 ───────────────────────────────────────────────────────────────── | ||
| /** | ||
| * 处理 EVENT_CHAT_REQUEST(type=list) | ||
| * | ||
| * 完整处理流程: | ||
| * 1. 解构入参,获取用户和 Agent 标识 | ||
| * 2. 根据 agentId + userId 解析会话存储文件路径 | ||
| * 3. 生成本次响应的唯一消息 ID | ||
| * 4. 创建统一的错误发送器(复用 chat-common 工具函数) | ||
| * 5. 读取会话列表(文件不存在时自动初始化默认条目) | ||
| * 6. 记录操作日志,发送成功响应 | ||
| * | ||
| * @param params - Chat handler 通用入参,包含 userId、agentId、botClientId、reliableEmitter | ||
| */ | ||
| export function handleChatList(params) { | ||
| // 解构直接使用的字段 | ||
| const { userId, agentId } = params; | ||
| // 根据 agentId 和 userId 解析会话列表的本地 JSON 文件路径 | ||
| // 路径格式:~/.openclaw/agents/<agentId>/chats/<userId>/chats.json | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| // 生成本次响应的唯一消息 ID,用于消息追踪和 ACK 确认 | ||
| const responseMsgId = generateMsgId(); | ||
| // 创建统一的错误响应发送器 | ||
| // 附加 chats: [] 确保客户端即使收到错误也能正确处理列表字段 | ||
| const sendError = createChatErrorSender({ responseMsgId, botClientId: params.botClientId, userId, agentId, type: 'list', reliableEmitter: params.reliableEmitter }, { chats: [] }); | ||
| try { | ||
| // 读取会话列表;文件不存在时自动创建并写入一条默认会话,保证返回非空数组 | ||
| const chats = readChatsFileOrInitDefault(chatsPath); | ||
| // 记录查询成功日志,包含关键上下文信息便于排查 | ||
| log.info(`[${CHANNEL_KEY}] 会话列表查询成功: userId=${userId}, agentId=${agentId}, count=${chats.length}`); | ||
| // 向客户端发送列表查询成功响应 | ||
| emitListResponse(params, responseMsgId, chats); | ||
| } | ||
| catch (err) { | ||
| // 捕获文件读写等异常,统一通过错误发送器返回给客户端 | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file chat-update.ts | ||
| * @description Chat handler — update(修改会话标题) | ||
| * | ||
| * 根据 chatId 修改 title(并置 titleLocked=true + 刷新 updatedAt); | ||
| * 文件或条目不存在时返回 error 响应,不隐式创建。 | ||
| */ | ||
| import * as fs from 'node:fs'; | ||
| import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../../config.js'; | ||
| import { readChatsFile, resolveChatsFilePath, writeChatsFile } from '../../utils/common.js'; | ||
| import { log, generateMsgId, createChatErrorSender } from './chat-common.js'; | ||
| /** | ||
| * 对目标会话应用标题更新 | ||
| * @param chat - 原始会话元数据 | ||
| * @param newTitle - 新标题(已 trim) | ||
| * @returns 更新后的会话元数据副本 | ||
| */ | ||
| function applyTitleUpdate(chat, newTitle) { | ||
| return { | ||
| ...chat, | ||
| title: newTitle, | ||
| titleLocked: true, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| } | ||
| /** | ||
| * 发送更新成功响应 | ||
| * @param params - handler 基础参数 | ||
| * @param responseMsgId - 响应消息 ID | ||
| * @param updatedChat - 更新后的会话元数据 | ||
| */ | ||
| function emitUpdateResponse(params, responseMsgId, updatedChat) { | ||
| const { botClientId, userId, agentId, reliableEmitter } = params; | ||
| reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, { | ||
| msgId: responseMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'update', | ||
| agentId, | ||
| chats: [updatedChat], | ||
| timestamp: Date.now(), | ||
| }, responseMsgId); | ||
| } | ||
| /** | ||
| * 处理 EVENT_CHAT_REQUEST(type=update) | ||
| * | ||
| * 流程:校验 title → 校验文件存在 → 读取列表 → 查找目标 → 更新写回 → 回包 | ||
| * | ||
| * @param params - Chat update 操作入参(含 chatId、title) | ||
| */ | ||
| export function handleChatUpdate(params) { | ||
| const { userId, agentId, chatId, title, botClientId, reliableEmitter } = params; | ||
| const chatsPath = resolveChatsFilePath(agentId, userId); | ||
| const responseMsgId = generateMsgId(); | ||
| const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'update', reliableEmitter }, { chatId }); | ||
| // 前置校验:标题不能为空 | ||
| const nextTitle = title?.trim(); | ||
| if (!nextTitle) { | ||
| sendError('标题不能为空'); | ||
| return; | ||
| } | ||
| // 前置校验:会话文件必须存在 | ||
| if (!fs.existsSync(chatsPath)) { | ||
| sendError(`会话文件不存在: ${chatsPath}`); | ||
| return; | ||
| } | ||
| try { | ||
| const chats = readChatsFile(chatsPath); | ||
| const targetChat = chats.find((c) => c.chatId === chatId); | ||
| if (!targetChat) { | ||
| sendError(`会话不存在: chatId=${chatId}`); | ||
| return; | ||
| } | ||
| const updatedChat = applyTitleUpdate(targetChat, nextTitle); | ||
| const updatedChats = chats.map((c) => (c.chatId === chatId ? updatedChat : c)); | ||
| writeChatsFile(chatsPath, updatedChats); | ||
| log.info(`[${CHANNEL_KEY}] 会话标题更新成功: userId=${userId}, agentId=${agentId}, chatId=${chatId}, title="${nextTitle}"`); | ||
| emitUpdateResponse(params, responseMsgId, updatedChat); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file Socket 网关侧的群消息中止(Group Abort)请求处理器。 | ||
| * | ||
| * @description | ||
| * 当群主需要中止某个正在进行的群聊会话时,前端通过 Socket 发送 abort 请求, | ||
| * 本模块负责接收并处理该请求。 | ||
| * | ||
| * 职责: | ||
| * - 校验前端传入的 groupId / conversationId 是否合法; | ||
| * - 鉴权:验证请求用户是否为群 ownerIds 成员(仅群主可中止); | ||
| * - 调用 Orchestrator.abort() 级联中止整棵 run 树(包括所有子任务); | ||
| * - 通过 ReliableEmitter 以可靠投递方式回包(带 ACK 确认机制); | ||
| * - 幂等:重复 abort 同一 conversationId 不报错,返回 abortedRunIds: []。 | ||
| * | ||
| * @module socket/group-abort | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { EVENT_GROUP_ABORT_RESPONSE } from '../../group/constants/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 群消息中止模块日志器,模块标识为 'group.abort' */ | ||
| const log = getModuleLogger('group.abort'); | ||
| /** | ||
| * 统一发送群中止响应(成功/失败共用)。 | ||
| * | ||
| * @description 构造标准的 abort 响应消息体,通过可靠发射器发送给请求方。 | ||
| * 无论中止成功或失败,都通过此函数统一回包,保证响应格式一致。 | ||
| * | ||
| * @param params - 回包所需的上下文参数(从请求参数中提取) | ||
| * @param abortedRunIds - 成功中止的 run ID 列表,失败时为空数组 | ||
| * @param error - 可选的错误描述信息,存在时会附加到响应的 extra.error 字段 | ||
| */ | ||
| function emitAbortResponse(params, abortedRunIds, error) { | ||
| const { requestMsgId, botClientId, userId, groupId, conversationId, reliableEmitter } = params; | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_ABORT_RESPONSE, { | ||
| msgId: requestMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| groupId, | ||
| conversationId, | ||
| timestamp: Date.now(), | ||
| abortedRunIds, | ||
| ...(error ? { extra: { error } } : {}), | ||
| }, requestMsgId); | ||
| } | ||
| /** | ||
| * 处理「群消息中止」请求的核心函数。 | ||
| * | ||
| * @description | ||
| * 完整处理流程如下: | ||
| * 1. **参数校验** — 确保 groupId 和 conversationId 均已提供; | ||
| * 2. **鉴权** — 从存储层读取群元数据,校验请求用户是否在 ownerIds 中; | ||
| * 3. **级联中止** — 调用 orchestrator.abort() 中止目标 conversationId 下的所有 run; | ||
| * 4. **回包** — 将中止结果通过可靠发射器返回给前端。 | ||
| * | ||
| * @param params - 群消息中止请求的完整参数 | ||
| * @returns Promise<void> 无返回值,结果通过 reliableEmitter 回包 | ||
| */ | ||
| export async function handleGroupAbort(params) { | ||
| const { userId, groupId, conversationId, orchestrator, storage } = params; | ||
| const logContext = `userId=${userId} groupId=${groupId} conversationId=${conversationId}`; | ||
| log.info(`[${CHANNEL_KEY}] 收到群消息中止请求: ${logContext}`); | ||
| /** | ||
| * 错误快捷回包辅助函数。 | ||
| * 记录错误日志并向前端发送包含错误信息的空 abortedRunIds 响应。 | ||
| * | ||
| * @param error - 错误描述文本 | ||
| */ | ||
| const sendError = (error) => { | ||
| log.error(`[${CHANNEL_KEY}] 群消息中止失败: ${logContext} 错误=${error}`); | ||
| emitAbortResponse(params, [], error); | ||
| }; | ||
| // ① 参数校验 | ||
| if (!groupId) { | ||
| sendError('缺少 groupId'); | ||
| return; | ||
| } | ||
| if (!conversationId) { | ||
| sendError('缺少 conversationId'); | ||
| return; | ||
| } | ||
| // ② 鉴权:读取群元数据,校验用户是否为群拥有者 | ||
| // 只有 ownerIds 中的用户才有权限中止群会话,防止非授权用户恶意中止 | ||
| try { | ||
| const meta = await storage.readGroupMeta(groupId); | ||
| if (!meta) { | ||
| sendError('群组不存在'); | ||
| return; | ||
| } | ||
| if (!meta.ownerIds.includes(userId)) { | ||
| sendError('无操作权限'); | ||
| return; | ||
| } | ||
| } | ||
| catch (err) { | ||
| // 存储层异常时记录完整错误信息,便于排查 | ||
| sendError(`读取群元数据失败: ${err instanceof Error ? err.message : String(err)}`); | ||
| return; | ||
| } | ||
| // ③ 级联中止:中止该 conversationId 下所有正在运行的 run(包括子任务) | ||
| const abortedRunIds = orchestrator.abort(conversationId); | ||
| log.info(`[${CHANNEL_KEY}] 群消息中止成功: ${logContext} 已中止runIds=[${abortedRunIds.join(',')}]`); | ||
| // ④ 回包 | ||
| emitAbortResponse(params, abortedRunIds); | ||
| } |
| /** | ||
| * @file Socket 网关侧的群组历史消息(Group History)请求处理器。 | ||
| * | ||
| * @description | ||
| * 当前端需要拉取群聊历史消息时,通过 Socket 发送 history 请求, | ||
| * 本模块负责接收并处理该请求,支持分页游标翻页。 | ||
| * | ||
| * 职责: | ||
| * - 校验前端传入的分页参数(limit / before / chatOnly); | ||
| * - 调用 GroupHistoryService 拉取指定群组的历史消息; | ||
| * - 通过 ReliableEmitter 以可靠投递方式回包(带 ACK 确认机制); | ||
| * - 失败统一通过 sendError 输出错误响应与日志。 | ||
| * | ||
| * @module socket/group-history | ||
| */ | ||
| import { CHANNEL_KEY, DEFAULT_HISTORY_LIMIT } from '../../config.js'; | ||
| import { EVENT_GROUP_HISTORY_RESPONSE } from '../../group/constants/index.js'; | ||
| import { GroupHistoryService } from '../../group/services/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 群组历史消息模块日志器,模块标识为 'group.history' */ | ||
| const log = getModuleLogger('group.history'); | ||
| /** | ||
| * 模块级共享的群组历史服务实例。 | ||
| * | ||
| * @remarks | ||
| * - 采用单例模式,避免重复创建服务实例; | ||
| * - 同时导出供 group-member 模块引用,用于在成员变更时清除历史缓存。 | ||
| */ | ||
| export const historyService = new GroupHistoryService(); | ||
| /** | ||
| * 统一发送群组历史响应(成功/失败共用)。 | ||
| * | ||
| * @description 构造标准的 history 响应消息体,通过可靠发射器发送给请求方。 | ||
| * 无论拉取成功或失败,都通过此函数统一回包,保证响应格式一致。 | ||
| * | ||
| * @param params - 回包所需的上下文参数(从请求参数中 Pick) | ||
| * @param messages - 历史消息列表,失败时传空数组 | ||
| * @param hasMore - 是否还有更早的消息可继续翻页 | ||
| * @param options - 可选扩展参数 | ||
| * @param options.nextCursor - 下一页游标(本批次最早消息的时间戳) | ||
| * @param options.error - 错误描述信息,存在时附加到 extra.error | ||
| */ | ||
| function emitHistoryResponse(params, messages, hasMore, options) { | ||
| const { requestMsgId, botClientId, userId, groupId, reliableEmitter } = params; | ||
| // 构造响应 payload,使用展开运算符按需附加可选字段 | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_HISTORY_RESPONSE, { | ||
| msgId: requestMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| groupId, | ||
| timestamp: Date.now(), | ||
| messages, | ||
| hasMore, | ||
| // 仅在有下一页游标时附加 nextCursor 字段 | ||
| ...(options?.nextCursor != null ? { nextCursor: options.nextCursor } : {}), | ||
| // 仅在有错误时附加 extra.error 字段 | ||
| ...(options?.error ? { extra: { error: options.error } } : {}), | ||
| }, | ||
| // 使用 requestMsgId 作为 ACK 标识,实现幂等去重 | ||
| requestMsgId); | ||
| } | ||
| /** | ||
| * 规范化 limit 参数。 | ||
| * | ||
| * @description 校验规则: | ||
| * - 必须为 number 类型(排除 undefined) | ||
| * - 必须为有限数(排除 Infinity / NaN) | ||
| * - 必须为正数(排除 0 和负数) | ||
| * - 取整(排除小数,如 3.7 → 3) | ||
| * 不满足以上任一条件时,回退到 DEFAULT_HISTORY_LIMIT。 | ||
| * | ||
| * @param raw - 前端传入的原始 limit 值 | ||
| * @returns 合法的 limit 正整数 | ||
| */ | ||
| function normalizeLimit(raw) { | ||
| return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_HISTORY_LIMIT; | ||
| } | ||
| /** | ||
| * 规范化 before 游标。 | ||
| * | ||
| * @description 校验规则与 normalizeLimit 类似: | ||
| * - 必须为有限正数 | ||
| * - 不满足时返回 undefined,表示不指定游标(从最新消息开始拉取) | ||
| * | ||
| * @param raw - 前端传入的原始 before 时间戳 | ||
| * @returns 合法的游标时间戳,或 undefined 表示取最新一页 | ||
| */ | ||
| function normalizeBefore(raw) { | ||
| return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : undefined; | ||
| } | ||
| /** | ||
| * 处理「群组历史消息」请求的核心函数。 | ||
| * | ||
| * @description | ||
| * 完整处理流程如下: | ||
| * 1. **参数校验** — 确保 groupId 已提供; | ||
| * 2. **参数规范化** — 兜底 limit / before / chatOnly; | ||
| * 3. **拉取消息** — 调用 GroupHistoryService.getHistory; | ||
| * 4. **回包** — 将结果通过可靠发射器返回给前端。 | ||
| * | ||
| * @param params - 群组历史消息请求的完整参数 | ||
| * @returns Promise<void> 无返回值,结果通过 reliableEmitter 回包 | ||
| */ | ||
| export async function handleGroupHistory(params) { | ||
| const { userId, groupId } = params; | ||
| /** | ||
| * 错误快捷回包辅助闭包。 | ||
| * 记录 error 级日志并向前端发送包含错误信息的空消息列表响应。 | ||
| * | ||
| * @param error - 错误描述文本 | ||
| */ | ||
| const sendError = (error) => { | ||
| log.error(`[${CHANNEL_KEY}] 群组历史消息错误: groupId=${groupId} 错误=${error}`); | ||
| emitHistoryResponse(params, [], false, { error }); | ||
| }; | ||
| // ① 参数校验:groupId 为必填项,缺失时直接报错回包 | ||
| if (!groupId) { | ||
| sendError('缺少 groupId'); | ||
| return; | ||
| } | ||
| // ② 参数规范化:对前端传入的分页参数进行兜底处理 | ||
| // 条数上限 | ||
| const limit = normalizeLimit(params.limit); | ||
| // 翻页游标 | ||
| const before = normalizeBefore(params.before); | ||
| // 是否仅聊天消息,默认 false | ||
| const chatOnly = params.chatOnly ?? false; | ||
| // ③ 拉取历史消息:调用 GroupHistoryService 获取分页数据 | ||
| try { | ||
| const result = await historyService.getHistory({ groupId, userId, limit, before, chatOnly }); | ||
| // 记录本次拉取的关键参数和结果摘要,便于线上排查 | ||
| log.info(`[${CHANNEL_KEY}] 群组历史消息: groupId=${groupId} count=${result.messages.length}`); | ||
| // ④ 成功回包:携带消息列表、翻页状态和下一页游标 | ||
| emitHistoryResponse(params, result.messages, result.hasMore, { nextCursor: result.nextCursor }); | ||
| } | ||
| catch (err) { | ||
| // 服务层异常时提取错误信息并通过 sendError 统一回包 | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file group-member.ts | ||
| * @description 群成员加入/退出请求的 Socket 网关处理器。 | ||
| * 负责接收客户端的群成员变更请求,执行业务逻辑后通过可靠发射器回包。 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { EVENT_GROUP_MEMBER_RESPONSE } from '../../group/constants/index.js'; | ||
| import { GroupMemberService, GroupUpdateService, GroupQueryService } from '../../group/services/index.js'; | ||
| import { getLightclawRuntime } from '../../runtime.js'; | ||
| import { historyService } from './group-history.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| const log = getModuleLogger('group.member'); | ||
| /** 群成员服务实例,用于执行成员增删操作 */ | ||
| const memberService = new GroupMemberService(); | ||
| /** 群查询服务实例,用于管理查询缓存 */ | ||
| const queryService = new GroupQueryService(); | ||
| /** | ||
| * 构建群成员响应的基础载荷 | ||
| * @description 统一构造响应数据结构,供成功和错误场景复用 | ||
| * @param ctx - 请求上下文参数 | ||
| * @param type - 操作类型(add/remove) | ||
| * @param members - 当前群成员列表 | ||
| * @param extra - 附加信息(如错误详情) | ||
| * @returns 符合 EVENT_GROUP_MEMBER_RESPONSE 事件格式的载荷对象 | ||
| */ | ||
| function buildResponsePayload(ctx, type, members, extra) { | ||
| const { requestMsgId, botClientId, userId, groupId, agentId } = ctx; | ||
| return { | ||
| msgId: requestMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type, | ||
| timestamp: Date.now(), | ||
| groupId, | ||
| agentId, | ||
| members, | ||
| ...(extra && { extra }), | ||
| }; | ||
| } | ||
| /** | ||
| * 创建错误回包发送函数(柯里化) | ||
| * @description 返回一个闭包函数,调用时自动记录错误日志并向客户端发送错误响应 | ||
| * @param ctx - 包含操作类型的请求上下文 | ||
| * @returns 接收错误信息字符串的发送函数 | ||
| */ | ||
| function createGroupMemberErrorSender(ctx) { | ||
| const { type, reliableEmitter, requestMsgId } = ctx; | ||
| return (error) => { | ||
| log.error(`[${CHANNEL_KEY}] 群成员${type}错误: ${error}`); | ||
| const payload = buildResponsePayload(ctx, type, [], { error }); | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_MEMBER_RESPONSE, payload, requestMsgId); | ||
| }; | ||
| } | ||
| /** | ||
| * 发送成功响应 | ||
| * @description 将操作成功后的群成员列表通过可靠发射器回包给客户端 | ||
| * @param ctx - 请求上下文参数 | ||
| * @param type - 操作类型(add/remove) | ||
| * @param members - 操作后的最新群成员列表 | ||
| */ | ||
| function emitGroupMemberResponse(ctx, type, members) { | ||
| const { reliableEmitter, requestMsgId } = ctx; | ||
| const payload = buildResponsePayload(ctx, type, members); | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_MEMBER_RESPONSE, payload, requestMsgId); | ||
| } | ||
| /** | ||
| * 构建 agentId 校验器与元信息解析器 | ||
| * @description 从运行时配置中加载 Agent 列表,生成校验和元信息查询工具。 | ||
| * 优先使用 extra 中传入的 agentName/agentDesc,否则从配置列表中按优先级解析。 | ||
| * @param extra - 外部传入的 Agent 名称和描述(优先级高于配置) | ||
| * @returns 包含 isValidAgentId 校验函数和 resolveAgentMeta 元信息解析函数的对象 | ||
| */ | ||
| function buildAgentRuntimeHelpers(extra) { | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const list = currentCfg.agents?.list ?? []; | ||
| // 若配置列表为空,则使用默认 Agent ID 作为唯一合法值 | ||
| const validIds = list.length > 0 ? list.map((a) => a.id) : []; | ||
| const validSet = new Set(validIds); | ||
| // 构建 agentId -> 元信息 的映射表,按优先级解析名称和描述 | ||
| const metaMap = new Map(); | ||
| for (const a of list) { | ||
| metaMap.set(a.id, { | ||
| agentName: a.displayName ?? a.identity?.name ?? a.name ?? a.id, | ||
| agentDesc: a.description ?? a.identity?.description ?? a.desc ?? a.routing ?? '', | ||
| }); | ||
| } | ||
| return { | ||
| isValidAgentId: (agentId) => validSet.has(agentId), | ||
| resolveAgentMeta: (agentId) => { | ||
| // 若 extra 中提供了有效的 agentName 且不等于 agentId 本身,优先使用 extra | ||
| if (extra?.agentName && extra.agentName !== agentId) { | ||
| return { agentName: extra.agentName, agentDesc: extra.agentDesc ?? '' }; | ||
| } | ||
| return metaMap.get(agentId); | ||
| }, | ||
| }; | ||
| } | ||
| /** | ||
| * 校验必填参数 | ||
| * @description 类型守卫函数,校验 groupId 和 agentId 是否存在,不存在则发送错误响应 | ||
| * @param groupId - 待校验的群组 ID | ||
| * @param agentId - 待校验的 Agent ID | ||
| * @param sendError - 错误发送函数 | ||
| * @returns 校验通过返回 true(同时收窄 groupId 类型为 string) | ||
| */ | ||
| function validateRequiredParams(groupId, agentId, sendError) { | ||
| if (!groupId) { | ||
| sendError('缺少 groupId'); | ||
| return false; | ||
| } | ||
| if (!agentId) { | ||
| sendError('缺少 agentId'); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * 成员变更后的缓存清理 | ||
| * @description 清除群查询缓存和历史消息阅读者缓存,确保后续查询获取最新数据 | ||
| */ | ||
| function invalidateCaches() { | ||
| queryService.clearCache(); | ||
| historyService.clearReaderCache(); | ||
| } | ||
| /** | ||
| * 处理「加入群」请求 | ||
| * @description 校验参数后调用 memberService 添加成员,成功后清除缓存并回包 | ||
| * @param params - 群成员操作入参 | ||
| */ | ||
| export async function handleGroupMemberAdd(params) { | ||
| const { userId, groupId, agentId } = params; | ||
| const sendError = createGroupMemberErrorSender({ ...params, type: 'add' }); | ||
| if (!validateRequiredParams(groupId, agentId, sendError)) | ||
| return; | ||
| try { | ||
| const { isValidAgentId, resolveAgentMeta } = buildAgentRuntimeHelpers(params.extra); | ||
| const result = await memberService.addMember(groupId, userId, agentId, { | ||
| isValidAgentId, | ||
| resolveAgentMeta, | ||
| }); | ||
| invalidateCaches(); | ||
| log.info(`[${CHANNEL_KEY}] 群成员添加: userId=${userId} groupId=${groupId} agentId=${agentId} members=${result.members.length}`); | ||
| emitGroupMemberResponse(params, 'add', result.members); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 处理「退出群」请求 | ||
| * @description 校验参数后调用 memberService 移除成员,成功后清除缓存并回包 | ||
| * @param params - 群成员操作入参 | ||
| */ | ||
| export async function handleGroupMemberRemove(params) { | ||
| const { userId, groupId, agentId } = params; | ||
| const sendError = createGroupMemberErrorSender({ ...params, type: 'remove' }); | ||
| if (!validateRequiredParams(groupId, agentId, sendError)) | ||
| return; | ||
| try { | ||
| const result = await memberService.removeMember(groupId, userId, agentId); | ||
| invalidateCaches(); | ||
| log.info(`[${CHANNEL_KEY}] 群成员移除: userId=${userId} groupId=${groupId} agentId=${agentId} members=${result.members.length}`); | ||
| emitGroupMemberResponse(params, 'remove', result.members); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 处理「全量刷新成员元信息」请求 | ||
| * @description 从客户端传入的 extra.agentMetaMap 中获取所有 Agent 的最新 agentName / agentDesc, | ||
| * 调用 GroupUpdateService.syncMembersMeta 全量刷新所有群组中的成员信息。 | ||
| * 注意:此操作不需要 groupId 和 agentId,因为是全量刷新所有群组。 | ||
| * @param params - 群成员操作入参 | ||
| */ | ||
| export async function handleGroupMemberUpdate(params) { | ||
| const { userId, reliableEmitter, requestMsgId, botClientId } = params; | ||
| const sendError = createGroupMemberErrorSender({ ...params, type: 'update' }); | ||
| try { | ||
| // 从客户端传入的 extra 中获取 agentMetaMap | ||
| const agentMetaMap = params.extra?.agentMetaMap; | ||
| if (!agentMetaMap || Object.keys(agentMetaMap).length === 0) { | ||
| sendError('缺少 extra.agentMetaMap 或映射表为空'); | ||
| return; | ||
| } | ||
| // 调用 syncMembersMeta 全量刷新 | ||
| const updateService = new GroupUpdateService(); | ||
| const result = await updateService.syncMembersMeta(agentMetaMap); | ||
| invalidateCaches(); | ||
| log.info(`[${CHANNEL_KEY}] 群成员元信息同步: userId=${userId} scanned=${result.scannedCount} updated=${result.updatedGroupIds.length}`); | ||
| // 回包:返回被更新的群组 ID 列表 | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_MEMBER_RESPONSE, { | ||
| msgId: requestMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type: 'update', | ||
| timestamp: Date.now(), | ||
| members: [], | ||
| extra: { | ||
| updatedGroupIds: result.updatedGroupIds, | ||
| scannedCount: result.scannedCount, | ||
| }, | ||
| }, requestMsgId); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
| /** | ||
| * @file Socket 网关侧的群组(Group)请求处理器集合。 | ||
| * @description | ||
| * 提供对群组的「列表查询 / 创建 / 更新 / 解散 / 清空」五类 socket 请求的统一处理: | ||
| * - 参数校验 | ||
| * - 调用对应的领域服务(GroupQueryService / GroupCreationService / GroupUpdateService / GroupDeletionService) | ||
| * - 通过 ReliableEmitter 以可靠投递的方式回包给前端 | ||
| * - 失败时统一通过 `createGroupErrorSender` 输出错误响应与日志 | ||
| */ | ||
| import { CHANNEL_KEY } from '../../config.js'; | ||
| import { EVENT_GROUP_RESPONSE } from '../../group/constants/index.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| import { generateMsgId } from '../../dedup.js'; | ||
| import { resolveEffectiveApiKey } from '../../config.js'; | ||
| import { GroupQueryService, GroupCreationService, GroupUpdateService, GroupDeletionService, } from '../../group/services/index.js'; | ||
| /** 模块级共享的群组查询服务实例,便于命中其内部缓存。 */ | ||
| const queryService = new GroupQueryService(); | ||
| /** 模块级日志器,通过公共方法获取,无需参数传递。 */ | ||
| const logger = getModuleLogger('socket.group'); | ||
| /** 将未知错误统一转换为字符串消息。 */ | ||
| function toErrorMessage(err) { | ||
| return err instanceof Error ? err.message : String(err); | ||
| } | ||
| /** | ||
| * 构造统一的群组错误响应发射器。 | ||
| * 调用返回的函数会输出 error 日志并通过 ReliableEmitter 发送错误响应。 | ||
| * @param ctx - 错误响应的上下文(包含请求类型与回包必要字段) | ||
| * @param extra - 额外要合并到响应体的字段(例如 `groupId`) | ||
| */ | ||
| function createGroupErrorSender(ctx, extra = {}) { | ||
| const { requestMsgId, botClientId, userId, type, reliableEmitter } = ctx; | ||
| return (error) => { | ||
| logger.error(`[${CHANNEL_KEY}] 群组 ${type} 错误: ${error}`); | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_RESPONSE, { | ||
| msgId: requestMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type, | ||
| timestamp: Date.now(), | ||
| groups: [], | ||
| ...extra, | ||
| extra: { error }, | ||
| }, requestMsgId); | ||
| }; | ||
| } | ||
| /** | ||
| * 发送群组成功响应。 | ||
| * @param ctx - 通用回包上下文 | ||
| * @param type - 群组请求类型 | ||
| * @param groups - 返回给前端的群组列表 | ||
| */ | ||
| function emitGroupResponse(ctx, type, groups) { | ||
| const { requestMsgId, botClientId, userId, reliableEmitter } = ctx; | ||
| reliableEmitter.emitWithAck(EVENT_GROUP_RESPONSE, { | ||
| msgId: requestMsgId, | ||
| from: botClientId, | ||
| to: userId, | ||
| type, | ||
| groups, | ||
| timestamp: Date.now(), | ||
| }, requestMsgId); | ||
| } | ||
| /** 处理「群组列表」请求:查询当前用户加入的所有群组并回包。 */ | ||
| export async function handleGroupList(params) { | ||
| const { userId } = params; | ||
| const sendError = createGroupErrorSender({ ...params, type: 'list' }); | ||
| try { | ||
| const groups = await queryService.getUserGroups(userId); | ||
| logger.info(`[${CHANNEL_KEY}] 群组列表: userId=${userId} count=${groups.length}`); | ||
| emitGroupResponse(params, 'list', groups); | ||
| } | ||
| catch (err) { | ||
| sendError(toErrorMessage(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 处理「创建群组」请求。 | ||
| * 校验 name / members / defaultAgentId 后调用 GroupCreationService 创建群组, | ||
| * 成功后清理查询缓存并回包新建群组的 meta 信息。 | ||
| */ | ||
| export async function handleGroupCreate(params) { | ||
| const { userId, name, desc, members, defaultAgentId, orchestrator } = params; | ||
| const sendError = createGroupErrorSender({ ...params, type: 'create' }); | ||
| if (!name || !name.trim()) { | ||
| sendError('缺少群名称'); | ||
| return; | ||
| } | ||
| if (!Array.isArray(members) || members.length === 0) { | ||
| sendError('缺少群成员'); | ||
| return; | ||
| } | ||
| if (!defaultAgentId) { | ||
| sendError('缺少默认 Agent ID'); | ||
| return; | ||
| } | ||
| try { | ||
| const creationService = new GroupCreationService(); | ||
| const groupMeta = await creationService.createGroup(userId, name.trim(), (desc ?? '').trim(), members, defaultAgentId); | ||
| queryService.clearCache(); | ||
| logger.info(`[${CHANNEL_KEY}] 创建群组: userId=${userId} groupId=${groupMeta.groupId} name="${groupMeta.name}" members=${members.length}`); | ||
| emitGroupResponse(params, 'create', [groupMeta]); | ||
| // 创建群成功后,唤醒主Agent生成欢迎消息 | ||
| if (orchestrator) { | ||
| triggerWelcomeMessage(orchestrator, groupMeta, userId).catch((err) => { | ||
| logger.warn(`[${CHANNEL_KEY}] 唤醒主Agent生成欢迎消息失败: ${toErrorMessage(err)}`); | ||
| }); | ||
| } | ||
| } | ||
| catch (err) { | ||
| sendError(toErrorMessage(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 创建群成功后触发主Agent生成欢迎消息 | ||
| * | ||
| * @remarks | ||
| * 构造一个特殊的 DispatchContext,让主Agent根据群目标生成欢迎消息。 | ||
| * 采用「即发即忘」模式,不阻塞创建群的响应流程。 | ||
| * | ||
| * @param orchestrator - 调度器实例 | ||
| * @param groupMeta - 新创建的群元数据 | ||
| * @param userId - 创建者用户 ID | ||
| */ | ||
| async function triggerWelcomeMessage(orchestrator, groupMeta, userId) { | ||
| const triggerMsgId = generateMsgId(); | ||
| const conversationId = `conv:${groupMeta.groupId}:welcome:${triggerMsgId}`; | ||
| const abortSignal = orchestrator.runRegistry.ensureConversation(conversationId); | ||
| const effectiveApiKey = resolveEffectiveApiKey({ senderId: userId }); | ||
| // 构造引导主Agent生成欢迎消息的用户消息 | ||
| const welcomeUserMessage = [ | ||
| `群「${groupMeta.name}」已创建成功。`, | ||
| groupMeta.desc ? `群目标:${groupMeta.desc}` : '', | ||
| `请作为领航员发送一条欢迎消息,向我介绍这个群的目标和建议的开始方向。`, | ||
| ].filter(Boolean).join('\n'); | ||
| const ctx = { | ||
| conversationId, | ||
| groupId: groupMeta.groupId, | ||
| userId, | ||
| meta: { | ||
| groupId: groupMeta.groupId, | ||
| members: groupMeta.members, | ||
| defaultAgentId: groupMeta.defaultAgentId, | ||
| ownerIds: groupMeta.ownerIds, | ||
| name: groupMeta.name, | ||
| desc: groupMeta.desc, | ||
| }, | ||
| userMessage: welcomeUserMessage, | ||
| mentions: [], | ||
| abortSignal, | ||
| msgId: triggerMsgId, | ||
| effectiveApiKey, | ||
| welcomeMode: true, | ||
| }; | ||
| const result = await orchestrator.dispatch(ctx); | ||
| logger.info(`[${CHANNEL_KEY}] 欢迎消息生成完成: groupId=${groupMeta.groupId} status=${result.finalStatus}`); | ||
| } | ||
| /** | ||
| * 处理「更新群组」请求。 | ||
| * 仅把传入的有效字段合并进 updates,无可更新字段时返回错误。 | ||
| */ | ||
| export async function handleGroupUpdate(params) { | ||
| const { userId, groupId, name, desc, pinned } = params; | ||
| const sendError = createGroupErrorSender({ ...params, type: 'update' }, { groupId }); | ||
| if (!groupId) { | ||
| sendError('缺少群组 ID'); | ||
| return; | ||
| } | ||
| const updates = {}; | ||
| if (typeof name === 'string') | ||
| updates.name = name.trim(); | ||
| if (typeof desc === 'string') | ||
| updates.desc = desc.trim(); | ||
| if (typeof pinned === 'boolean') | ||
| updates.pinned = pinned; | ||
| if (Object.keys(updates).length === 0) { | ||
| sendError('没有可更新的字段'); | ||
| return; | ||
| } | ||
| try { | ||
| const updateService = new GroupUpdateService(); | ||
| const updated = await updateService.updateGroup(groupId, userId, updates); | ||
| queryService.clearCache(); | ||
| logger.info(`[${CHANNEL_KEY}] 更新群组: userId=${userId} groupId=${groupId} fields=${Object.keys(updates).join(',')}`); | ||
| emitGroupResponse(params, 'update', [updated]); | ||
| } | ||
| catch (err) { | ||
| sendError(toErrorMessage(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 处理「解散群组」请求。 | ||
| * 鉴权与删除逻辑由 GroupDeletionService 内部完成, | ||
| * 成功后清理查询缓存并回包被解散群组的 meta 信息。 | ||
| */ | ||
| export async function handleGroupDelete(params) { | ||
| const { userId, groupId } = params; | ||
| const sendError = createGroupErrorSender({ ...params, type: 'delete' }, { groupId }); | ||
| if (!groupId) { | ||
| sendError('缺少群组 ID'); | ||
| return; | ||
| } | ||
| try { | ||
| const deletionService = new GroupDeletionService(); | ||
| const dismissed = await deletionService.deleteGroup(groupId, userId); | ||
| queryService.clearCache(); | ||
| logger.info(`[${CHANNEL_KEY}] 解散群组: userId=${userId} groupId=${groupId}`); | ||
| emitGroupResponse(params, 'delete', [dismissed]); | ||
| } | ||
| catch (err) { | ||
| sendError(toErrorMessage(err)); | ||
| } | ||
| } | ||
| /** | ||
| * 处理「清空群组聊天账本」请求。 | ||
| * 仅清理消息账本与相关 Agent session,保留群组元数据(成员、置顶、名称等不变)。 | ||
| */ | ||
| export async function handleGroupClear(params) { | ||
| const { userId, groupId } = params; | ||
| const sendError = createGroupErrorSender({ ...params, type: 'clear' }, { groupId }); | ||
| if (!groupId) { | ||
| sendError('缺少群组 ID'); | ||
| return; | ||
| } | ||
| try { | ||
| const deletionService = new GroupDeletionService(); | ||
| const cleared = await deletionService.clearGroup(groupId, userId); | ||
| logger.info(`[${CHANNEL_KEY}] 清空群组: userId=${userId} groupId=${groupId}`); | ||
| emitGroupResponse(params, 'clear', [cleared]); | ||
| } | ||
| catch (err) { | ||
| sendError(toErrorMessage(err)); | ||
| } | ||
| } |
| /** | ||
| * 历史消息查询 Service | ||
| * | ||
| * 职责:封装私聊历史消息的查询逻辑,包括: | ||
| * - sessionKey 解析 | ||
| * - agentId 合法性校验 | ||
| * - 多模式读取(multi-session / single-session-with-cron) | ||
| * - sessionId 兜底登记 | ||
| * | ||
| * 由 handlers/history-request.ts 调用,保持 handler 层薄而清晰。 | ||
| */ | ||
| import { CHANNEL_KEY, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID } from '../../config.js'; | ||
| import { getLightclawRuntime } from '../../runtime.js'; | ||
| import { readSessionHistoryWithCron, readSessionHistoriesByIds, loadSessionStore, } from '../../history/index.js'; | ||
| import { ensureSessionInHistory, readChatsFile, resolveChatsFilePath } from '../../utils/common.js'; | ||
| import { extractChatId } from '../utils/message.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.history-service'); | ||
| /** | ||
| * 查询私聊历史消息 | ||
| * | ||
| * @param data 前端请求数据 | ||
| * @param account 当前账户配置 | ||
| * @returns 查询结果(消息列表 + sessionKey + agentId) | ||
| */ | ||
| export function queryPrivateHistory(data, account) { | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({ | ||
| cfg: currentCfg, | ||
| channel: CHANNEL_KEY, | ||
| accountId: account.accountId, | ||
| peer: { kind: 'direct', id: data.from }, | ||
| }); | ||
| // agentId 合法性校验 | ||
| const currentCfgTyped = currentCfg; | ||
| const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID]; | ||
| log.info(`[${CHANNEL_KEY}] 当前合法的agentId为:${validAgentIds}`); | ||
| const resolvedAgentId = data.agentId && validAgentIds.includes(data.agentId) ? data.agentId : DEFAULT_AGENT_ID; | ||
| log.info(`[${CHANNEL_KEY}] 当前agentId为:${resolvedAgentId}`); | ||
| // 构建 sessionKey | ||
| const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| agentId: resolvedAgentId, | ||
| channel: CHANNEL_KEY, | ||
| accountId: baseRoute.accountId, | ||
| peer: { kind: 'direct', id: data.from }, | ||
| dmScope: 'per-channel-peer', | ||
| }); | ||
| const chatIdSuffix = extractChatId(data); | ||
| const sessionKey = chatIdSuffix ? `${baseSessionKey}:${chatIdSuffix}` : baseSessionKey; | ||
| log.info(`[${CHANNEL_KEY}] userId=${data.from},chatId=${chatIdSuffix || '-'},当前的sessionKey=${sessionKey}`); | ||
| // ① 兜底登记当前 sessionId 到 sessionIdHistory | ||
| 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)}`); | ||
| } | ||
| // ② 读取历史消息 | ||
| const limit = data.limit ?? DEFAULT_HISTORY_LIMIT; | ||
| const chatOnly = data.chatOnly ?? true; | ||
| let messages = []; | ||
| let readMode = 'single-session-with-cron'; | ||
| try { | ||
| const targetChatId = chatIdSuffix; | ||
| const chatsPath = resolveChatsFilePath(resolvedAgentId, data.from); | ||
| 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') { | ||
| 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}`); | ||
| // 过滤掉内容为空的消息 | ||
| const filteredMessages = messages.filter((msg) => !!msg.content.trim() || (msg.files && msg.files.length > 0)); | ||
| return { | ||
| messages: filteredMessages, | ||
| sessionKey, | ||
| agentId: resolvedAgentId, | ||
| }; | ||
| } |
| /** | ||
| * Socket 业务逻辑层统一导出 | ||
| * | ||
| * 包含:会话管理、历史消息查询、群组管理、群成员管理、群历史、群中止等业务逻辑。 | ||
| */ | ||
| export { handleChatList } from './chat-list.js'; | ||
| export { handleChatCreate } from './chat-create.js'; | ||
| export { handleChatUpdate } from './chat-update.js'; | ||
| export { handleChatDelete } from './chat-delete.js'; | ||
| export { queryPrivateHistory } from './history.js'; | ||
| export { handleGroupList, handleGroupCreate, handleGroupUpdate, handleGroupDelete, handleGroupClear, } from './group.js'; | ||
| export { handleGroupMemberAdd, handleGroupMemberRemove } from './group-member.js'; | ||
| export { handleGroupHistory } from './group-history.js'; | ||
| export { handleGroupAbort } from './group-abort.js'; |
| /** | ||
| * Socket 事件处理器核心类型定义 | ||
| * | ||
| * 本文件仅包含 handler 层的依赖注入接口(SocketHandlerDeps), | ||
| * 各事件 payload 类型定义拆分至 request.ts 中。 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file request.ts | ||
| * @description Socket 事件 payload 类型定义 | ||
| * | ||
| * 各事件(history:request、chat:request、group:request 等)的请求数据结构。 | ||
| * 群组相关接口共享 BaseGroupRequestData 基础字段,减少重复声明。 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file service.ts | ||
| * @description Socket Service 层类型定义 | ||
| * | ||
| * 包含 chat / group / history 等 service 模块的入参接口与辅助类型。 | ||
| * 群组相关 handler 入参统一继承 GroupHandlerParams,减少重复字段声明。 | ||
| */ | ||
| export {}; |
| /** | ||
| * @file agent-soul.ts | ||
| * @description Agent SoulId 映射工具 | ||
| * | ||
| * 负责从 lightsoul 配置文件中读取 agent 与 soulId 的映射关系, | ||
| * 并将 soulId 附加到 agent 配置列表中。 | ||
| * | ||
| * 配置文件支持两种格式: | ||
| * - 扁平格式:`{ "agentId": "soulId" }` | ||
| * - 对象格式:`{ "agentId": { "soulId": "xxx" } }` | ||
| */ | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.agent-soul'); | ||
| /** lightsoul 配置文件默认路径 */ | ||
| export const LIGHTSOUL_CONFIG_PATH = path.join(process.env.HOME || '', '.config/lightsoul/config.json'); | ||
| /** | ||
| * 获取非空 trim 字符串,空白视为无效 | ||
| * @param value - 待检测值 | ||
| * @returns trim 后的字符串,若无效则返回空字符串 | ||
| */ | ||
| const getTrimmedString = (value) => (typeof value === 'string' ? value.trim() : ''); | ||
| /** | ||
| * 判断值是否为非数组的普通对象 | ||
| * @param value - 待检测值 | ||
| */ | ||
| const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value); | ||
| /** | ||
| * 解析原始 JSON 字符串为 SoulIdMap | ||
| * | ||
| * 支持两种配置格式: | ||
| * - 字符串值:`{ "agentId": "soulId" }` → 直接映射 | ||
| * - 对象值:`{ "agentId": { "soulId": "xxx" } }` → 提取 soulId 字段 | ||
| * | ||
| * @param raw - lightsoul 配置文件的原始 JSON 字符串 | ||
| * @returns 解析后的 agentId → soulId 映射表 | ||
| */ | ||
| export function parseSoulIdMap(raw) { | ||
| const map = {}; | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } | ||
| catch (error) { | ||
| log.error(`解析 lightsoul 配置失败: ${error instanceof Error ? error.message : String(error)}`); | ||
| return map; | ||
| } | ||
| if (!isPlainObject(parsed)) | ||
| return map; | ||
| for (const [agentId, value] of Object.entries(parsed)) { | ||
| // 扁平格式:值直接为 soulId 字符串 | ||
| const directValue = getTrimmedString(value); | ||
| if (directValue) { | ||
| map[agentId] = directValue; | ||
| continue; | ||
| } | ||
| // 对象格式:从 value.soulId 中提取 | ||
| if (isPlainObject(value)) { | ||
| const soulId = getTrimmedString(value.soulId); | ||
| if (soulId) { | ||
| map[agentId] = soulId; | ||
| } | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| /** | ||
| * 从配置文件中读取 SoulIdMap | ||
| * @param configPath - 配置文件路径,默认为 LIGHTSOUL_CONFIG_PATH | ||
| * @returns 解析后的 agentId → soulId 映射表 | ||
| * @throws 文件不存在或读取失败时抛出异常 | ||
| */ | ||
| export function readSoulIdMap(configPath = LIGHTSOUL_CONFIG_PATH) { | ||
| return parseSoulIdMap(fs.readFileSync(configPath, 'utf8')); | ||
| } | ||
| /** | ||
| * 为 agents 列表附加 soulId | ||
| * | ||
| * 匹配规则:优先按 agent.id 查找,其次按 agent.name 查找。 | ||
| * 若匹配到 soulId,则返回附加了 soulId 字段的新对象;否则原样返回。 | ||
| * | ||
| * @param agents - agent 配置列表 | ||
| * @param soulMap - agentId → soulId 映射表 | ||
| * @returns 附加 soulId 后的 agents 列表(不修改原数组) | ||
| */ | ||
| export function attachSoulIdsToAgents(agents, soulMap) { | ||
| return agents.map((agent) => { | ||
| const id = getTrimmedString(agent.id); | ||
| const name = getTrimmedString(agent.name); | ||
| const soulId = (id && soulMap[id]) || (name && soulMap[name]) || undefined; | ||
| return soulId ? { ...agent, soulId } : agent; | ||
| }); | ||
| } |
| /** | ||
| * Socket 工具函数层统一导出 | ||
| * | ||
| * 包含:消息判断/提取工具、Agent Soul ID 解析工具。 | ||
| */ | ||
| export { isGroupMessage, extractChatId, recordSessionInHistory } from './message.js'; | ||
| export { parseSoulIdMap, readSoulIdMap, attachSoulIdsToAgents, LIGHTSOUL_CONFIG_PATH, } from './agent-soul.js'; | ||
| export { hasRequiredFields, getMissingFields } from './validate.js'; |
| /** | ||
| * Socket 事件处理器内部工具函数 | ||
| * | ||
| * 包含:isGroupMessage、extractChatId、recordSessionInHistory | ||
| * 从 handlers.ts 中抽离,统一管理公共逻辑。 | ||
| */ | ||
| import { CHANNEL_KEY, DEFAULT_AGENT_ID } from '../../config.js'; | ||
| import { getLightclawRuntime } from '../../runtime.js'; | ||
| import { loadSessionStore } from '../../history/index.js'; | ||
| import { ensureSessionInHistory } from '../../utils/common.js'; | ||
| import { getModuleLogger } from '../../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.utils'); | ||
| /** | ||
| * 判断一条 PrivateMessageData 是否为群聊消息 | ||
| * | ||
| * 协议约定(docs/design.md §6.6.1):群聊复用 EVENT_MESSAGE_PRIVATE 事件, | ||
| * 通过顶层 chatKind === 'group' 区分,且 extra.groupId 必填为非空字符串。 | ||
| * 容错:缺 chatKind / groupId / 字段类型不对 → 一律视为非群聊,回退 DM 路径。 | ||
| */ | ||
| export function isGroupMessage(data) { | ||
| if (data.chatKind !== 'group') | ||
| return false; | ||
| const groupId = data.extra?.groupId; | ||
| return typeof groupId === 'string' && groupId.trim().length > 0; | ||
| } | ||
| /** | ||
| * 从 PrivateMessageData / HistoryRequestData 中统一提取 chatId | ||
| * | ||
| * 统一为 extractChatId 后: | ||
| * - 入参可能是 undefined / null / 非字符串 / 含空白字符串 / 合法字符串; | ||
| * - 一律 trim 后归一为字符串;undefined / null / 非字符串 → ''; | ||
| * - 返回值要么是 ''(默认对话标识),要么是非空合法 chatId。 | ||
| */ | ||
| export function extractChatId(data) { | ||
| const raw = data?.extra?.chatId; | ||
| if (typeof raw !== 'string') | ||
| return ''; | ||
| return raw.trim(); | ||
| } | ||
| /** | ||
| * 把当前 chat 在用的 sessionId 登记到 chats.json[chatId].sessionIdHistory | ||
| * | ||
| * 调用时机: | ||
| * - EVENT_MESSAGE_PRIVATE:在 setImmediate 中异步调用,确保框架已建好/rotate 好 session; | ||
| * - EVENT_HISTORY_REQUEST:在 sessionKey 解析后同步调用,作为兜底登记入口。 | ||
| */ | ||
| export function recordSessionInHistory(params) { | ||
| const { userId, agentId, chatId, accountId } = 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>` 后缀(多会话场景) | ||
| 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); | ||
| } |
| /** | ||
| * @file validate.ts | ||
| * @description Socket 事件请求数据的通用字段校验工具 | ||
| * | ||
| * 提供泛型的必填字段校验函数,避免各 handler 重复实现相同逻辑。 | ||
| */ | ||
| /** | ||
| * 校验请求数据是否包含所有必填字段(非空判断) | ||
| * | ||
| * @typeParam T - 请求数据类型 | ||
| * @param data - 请求数据(可能为 undefined) | ||
| * @param requiredFields - 必填字段名称数组 | ||
| * @returns 当所有必填字段均存在且非空时返回 true(同时作为类型守卫) | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const REQUIRED = ['from', 'msgId', 'type'] as const; | ||
| * if (!hasRequiredFields<GroupRequestData>(data, REQUIRED)) { ... } | ||
| * ``` | ||
| */ | ||
| export function hasRequiredFields(data, requiredFields) { | ||
| if (!data) | ||
| return false; | ||
| return requiredFields.every((key) => !!data[key]); | ||
| } | ||
| /** | ||
| * 获取请求数据中缺失的必填字段名称列表 | ||
| * | ||
| * @typeParam T - 请求数据类型 | ||
| * @param data - 请求数据(可能为 undefined 或部分填充) | ||
| * @param requiredFields - 必填字段名称数组 | ||
| * @returns 缺失字段名称数组,用于日志输出 | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const missing = getMissingFields(data, REQUIRED); | ||
| * log.warn(`缺少字段: [${missing.join(', ')}]`); | ||
| * ``` | ||
| */ | ||
| export function getMissingFields(data, requiredFields) { | ||
| return requiredFields.filter((key) => !data?.[key]); | ||
| } |
| /** | ||
| * LightClaw — 文件下载工具 | ||
| * | ||
| * 注册为 OpenClaw Agent Tool,允许 AI: | ||
| * 1. 获取之前上传文件的公网下载链接 | ||
| * 2. 将云端文件下载到本地工作目录 | ||
| * | ||
| * 工具名: lightclaw_get_file_url | ||
| */ | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer } from '../file-storage.js'; | ||
| import { formatFileSize } from '../media.js'; | ||
| import { resolveEffectiveApiKey } from '../config.js'; | ||
| // ============================================================ | ||
| // 工具参数 schema | ||
| // ============================================================ | ||
| export const DOWNLOAD_TOOL_NAME = 'lightclaw_get_file_url'; | ||
| export const downloadToolSchema = { | ||
| type: 'object', | ||
| properties: { | ||
| action: { | ||
| type: 'string', | ||
| enum: ['get_url', 'download_to_local', 'upload_and_get_url'], | ||
| description: 'Action to perform: ' + | ||
| "'get_url' — get the public download URL for a previously uploaded file (by filePath); " + | ||
| "'download_to_local' — download a cloud file to local directory; " + | ||
| "'upload_and_get_url' — upload a local file and return its public download URL.", | ||
| }, | ||
| filePath: { | ||
| type: 'string', | ||
| description: "For 'get_url' and 'download_to_local': the cloud file path (e.g. '2026-03-06/report.pdf'). " + | ||
| "For 'upload_and_get_url': the local file path to upload.", | ||
| }, | ||
| localDir: { | ||
| type: 'string', | ||
| description: "For 'download_to_local' only: the local directory to save the downloaded file. " + | ||
| 'Defaults to the current working directory if not specified.', | ||
| }, | ||
| }, | ||
| required: ['action', 'filePath'], | ||
| }; | ||
| // ============================================================ | ||
| // 工具注册(飞书工厂函数模式) | ||
| // ============================================================ | ||
| export function registerDownloadTool(api) { | ||
| // 通过闭包捕获 api.logger,tool ctx 中没有 log | ||
| const log = api.logger; | ||
| api.registerTool((ctx) => { | ||
| const defaultAccountId = ctx.agentAccountId; | ||
| const sessionKey = ctx.sessionKey; | ||
| return { | ||
| name: DOWNLOAD_TOOL_NAME, | ||
| description: 'Manage files on cloud storage. ' + | ||
| 'Actions: ' + | ||
| "(1) 'get_url' — get a public download URL for a previously uploaded file (by its filePath); " + | ||
| "(2) 'download_to_local' — download a cloud file back to the local filesystem for further processing; " + | ||
| "(3) 'upload_and_get_url' — upload a single local file and return its public download URL. " + | ||
| 'Note: For batch uploading files to share with users, prefer lightclaw_upload_file instead.', | ||
| parameters: downloadToolSchema, | ||
| async execute(_toolCallId, params) { | ||
| // 每次 execute 时动态解析 apiKey(多 key 模式下通过 sessionKey 直接获取) | ||
| const apiKey = resolveEffectiveApiKey({ sessionKey }); | ||
| // log.warn(`[lightclaw_get_file_url] resolved apiKey="${apiKey?.slice(0, 8)}..."`); | ||
| const { action, filePath, localDir } = params; | ||
| if (!filePath || typeof filePath !== 'string') { | ||
| return { | ||
| content: [{ type: 'text', text: 'Error: filePath is required.' }], | ||
| }; | ||
| } | ||
| try { | ||
| switch (action) { | ||
| case 'get_url': { | ||
| const url = getFileDownloadUrl(filePath); | ||
| const fileName = filePath.split('/').pop() || filePath; | ||
| return { | ||
| content: [{ type: 'text', text: `[${fileName}](${url})` }], | ||
| details: { action, filePath, url }, | ||
| }; | ||
| } | ||
| case 'download_to_local': { | ||
| const result = await downloadFileFromServer(filePath, { apiKey }); | ||
| const targetDir = localDir || process.cwd(); | ||
| // 确保目录存在 | ||
| if (!fs.existsSync(targetDir)) { | ||
| fs.mkdirSync(targetDir, { recursive: true }); | ||
| } | ||
| const targetPath = path.join(targetDir, result.fileName); | ||
| fs.writeFileSync(targetPath, result.buffer); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: `File downloaded to: ${targetPath} (${formatFileSize(result.buffer.length)}, ${result.contentType})`, | ||
| }, | ||
| ], | ||
| details: { | ||
| action, | ||
| filePath, | ||
| localPath: targetPath, | ||
| size: result.buffer.length, | ||
| contentType: result.contentType, | ||
| }, | ||
| }; | ||
| } | ||
| case 'upload_and_get_url': { | ||
| if (!fs.existsSync(filePath)) { | ||
| return { | ||
| content: [{ type: 'text', text: `Error: local file not found: ${filePath}` }], | ||
| }; | ||
| } | ||
| const uploadResult = await uploadFileToServer(filePath, { apiKey }); | ||
| const fileName = path.basename(filePath); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: `File uploaded.\n\n[${fileName}](${uploadResult.url})`, | ||
| }, | ||
| ], | ||
| details: { | ||
| action, | ||
| localPath: filePath, | ||
| cosFilePath: uploadResult.filePath, | ||
| url: uploadResult.url, | ||
| }, | ||
| }; | ||
| } | ||
| default: | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: `Error: unknown action '${action}'. Use 'get_url', 'download_to_local', or 'upload_and_get_url'.`, | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| } | ||
| catch (err) { | ||
| const errMsg = err instanceof Error ? err.message : String(err); | ||
| return { | ||
| content: [{ type: 'text', text: `Error: ${errMsg}` }], | ||
| }; | ||
| } | ||
| }, | ||
| }; | ||
| }, { name: DOWNLOAD_TOOL_NAME }); | ||
| } |
| /** | ||
| * @file group-history-tool.ts | ||
| * @description 群聊历史消息查询工具 | ||
| * 提供群聊场景下的历史消息检索能力,支持按时间分页和关键词过滤。 | ||
| * 注册为 OpenClaw 插件工具,仅在 lightclawbot 频道的群聊会话中生效。 | ||
| */ | ||
| import path from 'path'; | ||
| import { resolveOpenClawHome } from '../history/session-store.js'; | ||
| import { MessageReader } from '../group/storage/message-reader.js'; | ||
| import { CHANNEL_KEY } from '../config.js'; | ||
| /** 工具注册名称 */ | ||
| export const GROUP_HISTORY_TOOL_NAME = 'group_chat_history'; | ||
| /** 单条消息内容的最大展示长度,超出部分将被截断 */ | ||
| const MAX_CONTENT_LENGTH = 500; | ||
| /** 默认返回的消息条数 */ | ||
| const DEFAULT_LIMIT = 20; | ||
| /** 允许返回的最大消息条数 */ | ||
| const MAX_LIMIT = 50; | ||
| /** 关键词过滤时的读取倍率,多读取数据以保证过滤后仍有足够结果 */ | ||
| const KEYWORD_READ_MULTIPLIER = 3; | ||
| /** 从 sessionKey 中提取 groupId 的正则表达式 */ | ||
| const GROUP_ID_REGEX = /:group:(.+)$/; | ||
| /** | ||
| * 群聊历史工具的参数 JSON Schema 定义 | ||
| * 用于 Agent 调用时的参数校验和提示 | ||
| */ | ||
| export const groupHistoryToolSchema = { | ||
| type: 'object', | ||
| properties: { | ||
| limit: { | ||
| type: 'number', | ||
| description: '返回的历史消息条数,默认 20,最大 50', | ||
| }, | ||
| before: { | ||
| type: 'number', | ||
| description: '时间戳上限(毫秒),只返回此时间之前的消息。不传则返回最新消息', | ||
| }, | ||
| keyword: { | ||
| type: 'string', | ||
| description: '关键词过滤,只返回包含该关键词的消息(大小写不敏感)', | ||
| }, | ||
| }, | ||
| required: [], | ||
| }; | ||
| /** | ||
| * 从 sessionKey 中解析出 groupId | ||
| * @param sessionKey - 会话标识,格式如 `xxx:group:groupId` | ||
| * @returns 解析出的 groupId,无法解析时返回 undefined | ||
| */ | ||
| function parseGroupIdFromSessionKey(sessionKey) { | ||
| if (!sessionKey) | ||
| return undefined; | ||
| return sessionKey.match(GROUP_ID_REGEX)?.[1]; | ||
| } | ||
| /** | ||
| * 根据 groupId 解析群聊数据存储目录的绝对路径 | ||
| * @param groupId - 群组唯一标识 | ||
| * @returns 群聊数据目录路径 | ||
| */ | ||
| function resolveGroupDir(groupId) { | ||
| return path.join(resolveOpenClawHome(), 'groups', groupId); | ||
| } | ||
| /** | ||
| * 规范化 limit 参数,确保在 [1, MAX_LIMIT] 范围内 | ||
| * @param raw - 原始 limit 值 | ||
| * @returns 规范化后的 limit 值 | ||
| */ | ||
| function normalizeLimit(raw) { | ||
| return Math.min(Math.max(raw ?? DEFAULT_LIMIT, 1), MAX_LIMIT); | ||
| } | ||
| /** | ||
| * 将单条消息记录格式化为可读字符串 | ||
| * 格式:[时间] 角色(名称): 内容 | ||
| * @param entry - 消息记录条目 | ||
| * @returns 格式化后的字符串 | ||
| */ | ||
| function formatEntry(entry) { | ||
| const time = new Date(entry.ts).toLocaleString('zh-CN', { | ||
| month: '2-digit', | ||
| day: '2-digit', | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| }); | ||
| const role = entry.role === 'user' ? `用户(${entry.userId ?? '未知'})` : `Agent(${entry.agentId ?? '未知'})`; | ||
| let content = (entry.content ?? '').trim(); | ||
| if (content.length > MAX_CONTENT_LENGTH) { | ||
| content = content.slice(0, MAX_CONTENT_LENGTH) + '…(已截断)'; | ||
| } | ||
| return `[${time}] ${role}: ${content}`; | ||
| } | ||
| /** | ||
| * 批量格式化消息记录列表 | ||
| * @param entries - 消息记录数组 | ||
| * @returns 以换行符连接的格式化字符串 | ||
| */ | ||
| function formatEntries(entries) { | ||
| return entries.map(formatEntry).join('\n'); | ||
| } | ||
| /** | ||
| * 构建查询成功时的返回结果 | ||
| * @param entries - 匹配到的消息记录 | ||
| * @param hasMore - 是否还有更早的记录可供翻页 | ||
| * @returns 标准工具返回结果 | ||
| */ | ||
| function buildSuccessResult(entries, hasMore) { | ||
| const formatted = formatEntries(entries); | ||
| const hasMoreHint = hasMore ? '(还有更早的记录,可通过 before 参数翻页)' : ''; | ||
| const summary = `共找到 ${entries.length} 条消息${hasMoreHint}:\n\n${formatted}`; | ||
| return { content: [{ type: 'text', text: summary }] }; | ||
| } | ||
| /** | ||
| * 构建纯文本的工具返回结果 | ||
| * @param text - 要返回的文本内容 | ||
| * @returns 标准工具返回结果 | ||
| */ | ||
| function buildTextResult(text) { | ||
| return { content: [{ type: 'text', text }] }; | ||
| } | ||
| /** | ||
| * 注册群聊历史消息查询工具到 OpenClaw 插件系统 | ||
| * 该工具仅在 lightclawbot 频道的群聊会话中可用, | ||
| * 支持按时间分页查询和关键词过滤。 | ||
| * @param api - OpenClaw 插件 API 实例 | ||
| */ | ||
| export function registerGroupHistoryTool(api) { | ||
| const log = api.logger; | ||
| const reader = new MessageReader(); | ||
| api.registerTool((ctx) => { | ||
| // 仅在 lightclawbot 频道生效 | ||
| if (ctx.messageChannel !== 'lightclawbot') | ||
| return null; | ||
| // 从 sessionKey 中提取 groupId,非群聊会话则不注册此工具 | ||
| const groupId = parseGroupIdFromSessionKey(ctx.sessionKey); | ||
| if (!groupId) | ||
| return null; | ||
| return { | ||
| name: GROUP_HISTORY_TOOL_NAME, | ||
| description: `查询当前群聊的历史消息记录。当你需要了解之前的对话内容、其他 Agent 的回复、用户的历史诉求时,调用此工具。返回按时间排序的对话记录,包含发言者角色、名称和内容。`, | ||
| parameters: groupHistoryToolSchema, | ||
| async execute(_toolCallId, params) { | ||
| const limit = normalizeLimit(params.limit); | ||
| const before = params.before ?? Date.now(); | ||
| const keyword = params.keyword?.trim().toLowerCase(); | ||
| log?.info?.(`[$${CHANNEL_KEY}:${GROUP_HISTORY_TOOL_NAME}] 开始查询群${groupId}的历史记录`); | ||
| try { | ||
| const groupDir = resolveGroupDir(groupId); | ||
| // 关键词过滤时多读取数据,确保过滤后仍有足够结果 | ||
| const readLimit = keyword ? limit * KEYWORD_READ_MULTIPLIER : limit; | ||
| const result = await reader.readTranscriptEntries(groupDir, { | ||
| limit: readLimit, | ||
| before, | ||
| chatOnly: true, | ||
| }); | ||
| let entries = result.entries; | ||
| // 按关键词过滤并截取指定条数 | ||
| if (keyword) { | ||
| entries = entries.filter((e) => e.content?.toLowerCase().includes(keyword)).slice(0, limit); | ||
| } | ||
| if (entries.length === 0) { | ||
| return buildTextResult('未找到匹配的历史消息。'); | ||
| } | ||
| return buildSuccessResult(entries, result.hasMore); | ||
| } | ||
| catch (err) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| log?.error?.(`[$${CHANNEL_KEY}: ${GROUP_HISTORY_TOOL_NAME}] 查询失败: ${msg}`); | ||
| return buildTextResult(`查询群聊历史失败: ${msg}`); | ||
| } | ||
| }, | ||
| }; | ||
| }, { name: GROUP_HISTORY_TOOL_NAME }); | ||
| } |
| /** | ||
| * LightClaw — 文件注册工具 | ||
| * | ||
| * 注册为 OpenClaw Agent Tool。AI 生成本机文件后调用此工具, | ||
| * 返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。AI 将链接 | ||
| * 原样写入回复,前端识别 `localfile://` 前缀后通过 WS file:download 信令 | ||
| * (status=download_req)让 lightclaw 按需上传到 ai-server 再推回浏览器下载。 | ||
| * | ||
| * 工具名: lightclaw_upload_file | ||
| * | ||
| * 备注:名字保留 "upload" 以兼容现有 skill 表述,实际不再主动上传 COS, | ||
| * 只在本机做存在性校验并生成标准格式的 Markdown 链接。 | ||
| */ | ||
| import * as fsp from "node:fs/promises"; | ||
| import * as path from "node:path"; | ||
| import { formatFileSize } from "../media.js"; | ||
| import { LOCALFILE_SCHEME } from "../config.js"; | ||
| // ============================================================ | ||
| // 工具参数 schema(JSON Schema 格式) | ||
| // ============================================================ | ||
| export const UPLOAD_TOOL_NAME = "lightclaw_upload_file"; | ||
| export const uploadToolSchema = { | ||
| type: "object", | ||
| properties: { | ||
| paths: { | ||
| type: "array", | ||
| items: { type: "string" }, | ||
| minItems: 1, | ||
| maxItems: 5, | ||
| description: "需要注册的本地文件绝对路径数组,文件必须已写入磁盘,最多 5 个文件", | ||
| }, | ||
| }, | ||
| required: ["paths"], | ||
| }; | ||
| // ============================================================ | ||
| // 工具注册(工厂函数模式) | ||
| // ============================================================ | ||
| export function registerUploadTool(api) { | ||
| // 通过闭包捕获 api.logger,tool ctx 中没有 log | ||
| const log = api.logger; | ||
| api.registerTool((ctx) => { | ||
| // 硬隔离:非 lightclawbot 通道时直接返回 null,框架不会暴露此工具 | ||
| if (ctx.messageChannel !== "lightclawbot") { | ||
| return null; | ||
| } | ||
| return { | ||
| name: UPLOAD_TOOL_NAME, | ||
| description: "将 AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" + | ||
| "当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" + | ||
| "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" + | ||
| "重要:工具返回的 Markdown 链接已是最终格式,请原样使用,不要改写为 https:// 或本机路径字符串。", | ||
| parameters: uploadToolSchema, | ||
| async execute(_toolCallId, params) { | ||
| const { paths } = params; | ||
| // 参数校验 | ||
| if (!Array.isArray(paths) || paths.length === 0) { | ||
| return { | ||
| content: [{ type: "text", text: "Error: paths must be a non-empty array of file paths." }], | ||
| }; | ||
| } | ||
| if (paths.length > 5) { | ||
| return { | ||
| content: [{ type: "text", text: "Error: maximum 5 files per upload." }], | ||
| }; | ||
| } | ||
| // 并发校验所有路径并顺便拿到 stat | ||
| // - 路径合法性(绝对路径、非空字符串):同步判定 | ||
| // - 文件存在 + 必须是 regular file:靠 fsp.stat 抛错 / isFile() 判定 | ||
| const results = await Promise.all(paths.map(async (p) => { | ||
| if (typeof p !== "string" || !p.trim()) { | ||
| return { path: p, error: `Invalid path: empty or non-string` }; | ||
| } | ||
| if (!p.startsWith("/") && !/^[A-Za-z]:\\/.test(p)) { | ||
| return { path: p, error: `Path must be absolute: ${p}` }; | ||
| } | ||
| try { | ||
| const stat = await fsp.stat(p); | ||
| if (!stat.isFile()) { | ||
| return { path: p, error: `Not a regular file: ${p}` }; | ||
| } | ||
| return { path: p, stat }; | ||
| } | ||
| catch (err) { | ||
| const code = err?.code; | ||
| if (code === 'ENOENT') { | ||
| return { path: p, error: `File not found: ${p}` }; | ||
| } | ||
| return { path: p, error: `Stat failed: ${p} (${err.message})` }; | ||
| } | ||
| })); | ||
| const validationErrors = results | ||
| .filter((r) => 'error' in r) | ||
| .map((r) => r.error); | ||
| if (validationErrors.length > 0) { | ||
| return { | ||
| content: [{ type: "text", text: `Validation errors:\n${validationErrors.join("\n")}` }], | ||
| }; | ||
| } | ||
| // 基于本机路径生成 localfile:// 链接(不上传 COS),复用上一步已拿到的 stat | ||
| const successResults = results.map(({ path: filePath, stat }) => ({ | ||
| name: path.basename(filePath), | ||
| path: filePath, | ||
| url: `${LOCALFILE_SCHEME}${filePath}`, | ||
| size: formatFileSize(stat.size), | ||
| })); | ||
| log?.info?.(`[${UPLOAD_TOOL_NAME}] registered ${successResults.length} file(s)`); | ||
| // 构建结果文本(Markdown 链接,引导模型原样输出给用户) | ||
| const lines = successResults.map((r) => `✅ [${r.name}](${r.url}) (${r.size})`); | ||
| const summary = successResults.length === 1 | ||
| ? `File registered successfully.\n\n[${successResults[0].name}](${successResults[0].url})` | ||
| : `Registered ${successResults.length} files.\n${lines.join("\n")}`; | ||
| return { | ||
| content: [{ type: "text", text: summary }], | ||
| details: { uploads: successResults }, | ||
| }; | ||
| }, | ||
| }; | ||
| }, { name: UPLOAD_TOOL_NAME }); | ||
| } |
| import { getLightclawRuntime } from '../runtime.js'; | ||
| /** | ||
| * 获取模块专属日志器(惰性代理)。 | ||
| * | ||
| * 返回一个 Proxy 对象,仅在实际调用日志方法(info/warn/error/debug 等)时 | ||
| * 才通过 getLightclawRuntime() 获取真实 logger 实例并转发调用。 | ||
| * 这样即使在模块顶层 `const log = getModuleLogger('xxx')` 也不会触发 | ||
| * runtime 未初始化的异常。 | ||
| * | ||
| * @param module 模块名称(如 'group.inbound'、'outbound') | ||
| * @returns 带模块标签的惰性日志器代理 | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // 可安全地在模块顶层调用 | ||
| * const log = getModuleLogger('group.orchestrator'); | ||
| * // 实际使用时才会初始化 runtime | ||
| * log.info('调度开始'); | ||
| * ``` | ||
| */ | ||
| export function getModuleLogger(module) { | ||
| let cached = null; | ||
| /** 获取或缓存真实 logger 实例 */ | ||
| const resolve = () => { | ||
| if (!cached) { | ||
| cached = getLightclawRuntime().logging.getChildLogger({ module }); | ||
| } | ||
| return cached; | ||
| }; | ||
| return new Proxy({}, { | ||
| get(_target, prop, receiver) { | ||
| const real = resolve(); | ||
| const value = Reflect.get(real, prop, receiver); | ||
| // 如果是函数则绑定到真实 logger 上下文 | ||
| return typeof value === 'function' ? value.bind(real) : value; | ||
| }, | ||
| }); | ||
| } |
+50
-6
@@ -22,2 +22,3 @@ /** | ||
| import { buildAuthHeaders } from './utils/index.js'; | ||
| import { GroupStorageCore, Orchestrator, createGroupInboundHandler, } from './group/index.js'; | ||
| // ============================================================ | ||
@@ -129,3 +130,3 @@ // Gateway 实例追踪(防止同一 accountId 创建多个连接) | ||
| // ReliableEmitter 负责:断线时缓冲消息、重连后 flush、ACK 超时重试 | ||
| const reliableEmitter = new ReliableEmitter(() => currentSocket, log); | ||
| const reliableEmitter = new ReliableEmitter(() => currentSocket); | ||
| // ---- WebSocket 发送抽象 ---- | ||
@@ -201,6 +202,20 @@ /** | ||
| const emitter = { emit, sendReply, sendFiles, botClientId }; | ||
| // ---- 群聊调度器装配(路径 A 全链路服务于 chatKind === 'group' 的消息) ---- | ||
| // 多 Gateway 进程共享同一份 ~/.openclaw/groups 目录,但每 account 一份 Orchestrator | ||
| // (RunRegistry 是进程内存态,跨 account 互不干扰)。 | ||
| const groupStorage = new GroupStorageCore(); | ||
| const orchestrator = new Orchestrator({ | ||
| accountId: account.accountId, | ||
| storage: groupStorage, | ||
| emitter, | ||
| }); | ||
| const groupInboundHandler = createGroupInboundHandler({ | ||
| emitter, | ||
| storage: groupStorage, | ||
| orchestrator, | ||
| }); | ||
| // ---- 入站消息处理器 ---- | ||
| // createInboundHandler 返回一个异步函数,负责处理单条用户消息(文件处理、路由、AI 分发、回复) | ||
| const handleInboundMessage = createInboundHandler(account, emitter, log); | ||
| // ---- 消息处理(fire-and-forget) ---- | ||
| // ---- 私聊消息处理(fire-and-forget) ---- | ||
| // 不做串行队列:并发/abort 由 openclaw 的 session 写锁 + followup queue 负责。 | ||
@@ -237,2 +252,31 @@ // 这里仅兜底 inbound 自身未捕获的异常。 | ||
| }; | ||
| // ---- 群聊消息处理 ---- | ||
| const handleGroupMessage = (data) => { | ||
| if (isAborted) | ||
| return; | ||
| void (async () => { | ||
| try { | ||
| await groupInboundHandler(data); | ||
| } | ||
| catch (err) { | ||
| log?.error(`[${CHANNEL_KEY}] Group handler error: ${err}`); | ||
| try { | ||
| const groupId = data.extra?.groupId; | ||
| emitter.emit({ | ||
| msgId: generateMsgId(), | ||
| from: emitter.botClientId, | ||
| to: data.from, | ||
| content: '群消息处理异常,请稍后重试。', | ||
| timestamp: Date.now(), | ||
| replyToMsgId: data.msgId, | ||
| chatKind: 'group', | ||
| extra: { groupId: typeof groupId === 'string' ? groupId : undefined }, | ||
| }); | ||
| } | ||
| catch (notifyErr) { | ||
| log?.error(`[${CHANNEL_KEY}] Failed to notify user about group handler error: ${notifyErr}`); | ||
| } | ||
| } | ||
| })(); | ||
| }; | ||
| // ---- 生命周期 ---- | ||
@@ -275,4 +319,2 @@ /** | ||
| path: `${SOCKET_PATH}${ticketQuery}&enableMultiLogin=false`, | ||
| // 注入日志对象,便于 NativeSocketClient 内部打印连接/重连/错误等诊断信息 | ||
| log, | ||
| logPrefix: `[${CHANNEL_KEY}:${account.accountId}:NativeSocket]`, | ||
@@ -299,3 +341,3 @@ }); | ||
| // 重连后 flush 断线期间缓冲的 outbound 消息(主动发送场景) | ||
| const { sent, failed } = flushPendingMessages(account.accountId, log); | ||
| const { sent, failed } = flushPendingMessages(account.accountId); | ||
| if (sent > 0 || failed > 0) { | ||
@@ -320,6 +362,8 @@ log?.info(`[${CHANNEL_KEY}] Flushed buffered outbound messages: sent=${sent}, failed=${failed}`); | ||
| botClientId, | ||
| log, | ||
| handleMessage, | ||
| onEvent, | ||
| reliableEmitter, | ||
| handleGroupMessage, | ||
| orchestrator, | ||
| groupStorage, | ||
| }); | ||
@@ -326,0 +370,0 @@ /** |
+12
-19
@@ -14,13 +14,8 @@ /** | ||
| import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, EVENT_MESSAGE_PRIVATE } from './config.js'; | ||
| import { getLightclawRuntime } from './runtime.js'; | ||
| import { getSocket, bufferMessage, hasEntry, getBotClientId, getReliableEmitter } from './socket/index.js'; | ||
| import { resolveAccount } from './utils/index.js'; | ||
| import { getModuleLogger } from './utils/logger.js'; | ||
| /** 出站模块专属日志器(惰性代理,模块顶层安全调用) */ | ||
| const logger = getModuleLogger('outbound'); | ||
| /** | ||
| * 获取出站模块专属日志器。 | ||
| * 通过 getChildLogger 注入 module 标签,方便在日志系统中过滤出站相关日志。 | ||
| */ | ||
| function getLogger() { | ||
| return getLightclawRuntime().logging.getChildLogger({ module: 'outbound' }); | ||
| } | ||
| /** | ||
| * 出站消息 ID 计数器,用于生成全局唯一的消息序号。 | ||
@@ -111,6 +106,6 @@ * 模块级变量,进程生命周期内单调递增,不会重置。 | ||
| if (!ok) { | ||
| getLogger().error(`[${CHANNEL_KEY}] outbound delivery failed after retries: msgId=${msgId}`); | ||
| logger.error(`[${CHANNEL_KEY}] outbound delivery failed after retries: msgId=${msgId}`); | ||
| } | ||
| }); | ||
| getLogger().info(`[${CHANNEL_KEY}] outbound sent via reliable WS: to=${target} msgId=${msgId}`); | ||
| logger.info(`[${CHANNEL_KEY}] outbound sent via reliable WS: to=${target} msgId=${msgId}`); | ||
| return msgId; | ||
@@ -121,3 +116,3 @@ } | ||
| entry.socket.emit(EVENT_MESSAGE_PRIVATE, message); | ||
| getLogger().info(`[${CHANNEL_KEY}] outbound sent via WS (fallback): to=${target} msgId=${msgId}`); | ||
| logger.info(`[${CHANNEL_KEY}] outbound sent via WS (fallback): to=${target} msgId=${msgId}`); | ||
| return msgId; | ||
@@ -127,3 +122,3 @@ } | ||
| // emit 本身抛异常(如 socket 已关闭),降级到 REST API | ||
| getLogger().warn(`[${CHANNEL_KEY}] outbound WS emit failed: ${err}`); | ||
| logger.warn(`[${CHANNEL_KEY}] outbound WS emit failed: ${err}`); | ||
| return null; | ||
@@ -137,3 +132,3 @@ } | ||
| if (buffered) { | ||
| getLogger().info(`[${CHANNEL_KEY}] outbound buffered (WS reconnecting): to=${target} msgId=${msgId}`); | ||
| logger.info(`[${CHANNEL_KEY}] outbound buffered (WS reconnecting): to=${target} msgId=${msgId}`); | ||
| return msgId; | ||
@@ -161,3 +156,2 @@ } | ||
| export async function sendText(ctx) { | ||
| const log = getLogger(); | ||
| try { | ||
@@ -178,3 +172,3 @@ // 解析账户配置(含 apiKey、apiBaseUrl、accountId 等) | ||
| } | ||
| log.info(`[${CHANNEL_KEY}] outbound fallback to REST API: to=${target}`); | ||
| logger.info(`[${CHANNEL_KEY}] outbound fallback to REST API: to=${target}`); | ||
| const resp = await fetch(`${account.apiBaseUrl}/send`, { | ||
@@ -203,3 +197,3 @@ method: 'POST', | ||
| catch (err) { | ||
| log.error(`[${CHANNEL_KEY}] outbound.sendText error: ${err}`); | ||
| logger.error(`[${CHANNEL_KEY}] outbound.sendText error: ${err}`); | ||
| throw err; | ||
@@ -227,3 +221,2 @@ } | ||
| export async function sendMedia(ctx) { | ||
| const log = getLogger(); | ||
| try { | ||
@@ -252,3 +245,3 @@ // 解析账户配置 | ||
| // 通过 REST API 发送完整媒体消息(含 mediaUrl) | ||
| log.info(`[${CHANNEL_KEY}] outbound media via REST API: to=${target}`); | ||
| logger.info(`[${CHANNEL_KEY}] outbound media via REST API: to=${target}`); | ||
| const resp = await fetch(`${account.apiBaseUrl}/send-media`, { | ||
@@ -277,5 +270,5 @@ method: 'POST', | ||
| catch (err) { | ||
| log.error(`[${CHANNEL_KEY}] outbound.sendMedia error: ${err}`); | ||
| logger.error(`[${CHANNEL_KEY}] outbound.sendMedia error: ${err}`); | ||
| throw err; | ||
| } | ||
| } |
@@ -127,5 +127,6 @@ /** | ||
| }, | ||
| streaming: { | ||
| blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, | ||
| }, | ||
| // 注意:原 streaming.blockStreamingCoalesceDefaults 已移除—— | ||
| // 当前请求侧固定走 disableBlockStreaming=true(partial 通道),框架不会调用 | ||
| // BlockReplyCoalescer,配置不生效;二次聚合改在 sink 内部由 | ||
| // partialCoalesce 完成(详见 ADR-017 / streaming/stream-reply-sink.ts)。 | ||
| reload: { configPrefixes: [`channels.${CHANNEL_KEY}`] }, | ||
@@ -132,0 +133,0 @@ configSchema: { |
+53
-568
| /** | ||
| * LightClaw — Socket.IO 事件处理器 | ||
| * LightClaw — Socket.IO 事件处理器(主入口) | ||
| * | ||
| * 将 socket 事件监听(message:private、history:request、sessions:request) | ||
| * 从 gateway 主逻辑中解耦,使 gateway.ts 只负责连接管理,本模块专注事件处理。 | ||
| * 将 socket 事件监听从 gateway 主逻辑中解耦,本模块只负责事件注册与分发。 | ||
| * 各事件的具体处理逻辑已拆分到 events/ 子目录下的独立模块中。 | ||
| * | ||
@@ -10,62 +10,12 @@ * 事件处理流程: | ||
| * 2. history:request — 接收历史消息拉取请求,查询本地存储后返回 | ||
| * 3. sessions:request — 接收会话列表请求,返回所有已存在的会话 | ||
| * | ||
| * 所有出站 socket.emit 通过 ReliableEmitter 实现 ACK 确认 + 自动重试, | ||
| * 保证消息在网络抖动时不丢失。 | ||
| * 3. agents:request — 接收 Agent 列表请求,返回配置中的 agents | ||
| * 4. chat:request — 接收会话管理请求(list/create/update/delete) | ||
| * 5. group:request — 接收群组管理请求(list/create/update/delete/clear) | ||
| * 6. group:member:request — 接收群成员管理请求(add/remove) | ||
| * 7. group:history:request — 接收群历史消息请求 | ||
| * 8. group:abort:request — 接收群消息中止请求 | ||
| */ | ||
| 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, 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 { attachSoulIdsToAgents, readSoulIdMap, LIGHTSOUL_CONFIG_PATH } from './agent-soul.js'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| const getNonEmptyString = (value) => { | ||
| if (typeof value !== 'string') | ||
| return undefined; | ||
| const trimmed = value.trim(); | ||
| return trimmed || undefined; | ||
| }; | ||
| const isMainAgent = (agent) => { | ||
| const id = getNonEmptyString(agent.id); | ||
| const name = getNonEmptyString(agent.name); | ||
| return id === DEFAULT_AGENT_ID || name === DEFAULT_AGENT_ID; | ||
| }; | ||
| const withDefaultWorkspace = (agent, defaultWorkspace) => { | ||
| if (!defaultWorkspace || getNonEmptyString(agent.workspace)) | ||
| return agent; | ||
| return { ...agent, workspace: defaultWorkspace }; | ||
| }; | ||
| // 从子模块导入各事件处理器绑定函数 | ||
| import { bindMessagePrivateHandler, bindHistoryRequestHandler, bindAgentsRequestHandler, bindChatRequestHandler, bindGroupRequestHandler, bindGroupHistoryHandler, bindGroupMemberHandler, bindGroupAbortHandler, } from './events/index.js'; | ||
| /** | ||
| * 组装 agents:request 响应列表,确保单 agent 场景也返回默认 main agent。 | ||
| */ | ||
| export const normalizeAgentsWithMainAgent = (currentCfg) => { | ||
| const agentsConfig = currentCfg.agents; | ||
| const agentsList = Array.isArray(agentsConfig?.list) ? agentsConfig.list : []; | ||
| const defaultWorkspace = getNonEmptyString(agentsConfig?.defaults?.workspace); | ||
| let hasMainAgent = false; | ||
| const normalizedAgents = agentsList.map((agent) => { | ||
| if (!isMainAgent(agent)) | ||
| return agent; | ||
| hasMainAgent = true; | ||
| return withDefaultWorkspace(agent, defaultWorkspace); | ||
| }); | ||
| if (hasMainAgent) | ||
| return normalizedAgents; | ||
| const mainAgent = { | ||
| id: DEFAULT_AGENT_ID, | ||
| name: DEFAULT_AGENT_ID, | ||
| displayName: DEFAULT_AGENT_ID, | ||
| isDefault: true, | ||
| }; | ||
| if (defaultWorkspace) { | ||
| mainAgent.workspace = defaultWorkspace; | ||
| } | ||
| return [mainAgent, ...normalizedAgents]; | ||
| }; | ||
| /** | ||
| * 绑定所有 Socket.IO 事件监听器到指定 socket 实例。 | ||
@@ -80,517 +30,52 @@ * | ||
| export function bindSocketHandlers(socket, deps) { | ||
| const { account, botClientId, log, handleMessage, onEvent, reliableEmitter } = deps; | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| const { botClientId, onEvent, reliableEmitter, orchestrator, groupStorage } = deps; | ||
| // 事件:接收用户私信(message:private) | ||
| // 职责:对消息做多层过滤,将合法消息放入处理队列 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| socket.on(EVENT_MESSAGE_PRIVATE, (data, ack) => { | ||
| // 立即回复 ACK,告知服务端消息已收到,避免服务端重发 | ||
| ack?.(); | ||
| // {"msgId":"91647ae9-c123-48e4-9d80-26f2e9a91ac9","from":"100013456706","to":"lhins-jem9a0br","content":"今天天气怎么样","files":[],"timestamp":1776077511044} | ||
| log?.info(`[${CHANNEL_KEY}] Received private message: ${JSON.stringify(data)},botClientId:${botClientId}`); | ||
| // ① 回环防御:过滤 bot 自身发出的消息,防止自问自答死循环 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // ② 分发文件下载信令(kind=file:download, status=download_req):独立链路,不入 AI 处理队列 | ||
| if (data.kind === KIND_FILE_DOWNLOAD) { | ||
| const reqTransferData = data.extra?.transferData; | ||
| if (reqTransferData?.status === FILE_DOWNLOAD_STATUS.REQ) { | ||
| // 去重:同一 transferId 的重复请求直接跳过 | ||
| if (isDuplicate(data.msgId)) | ||
| return; | ||
| onEvent?.(); | ||
| void handleFileDownloadReq(data, botClientId, reliableEmitter, account, log); | ||
| } | ||
| // 其他 status(ready/url/error)是本端自己发出的下行消息,理论上不会从前端回流; | ||
| // 即便误触发,此处直接 return 不做处理,避免进入 AI 处理队列 | ||
| return; | ||
| } | ||
| // ③ 跳过其他控制消息(如 typing 状态、stream 信号等),只处理 kind=text 的真实用户消息 | ||
| if (data.kind && data.kind !== 'text') | ||
| return; | ||
| // ③ 内容校验:消息既无文字内容也无附件文件时,直接丢弃 | ||
| const hasContent = data.content?.trim(); | ||
| const hasFiles = data.files && data.files.length > 0; | ||
| if (!hasContent && !hasFiles) | ||
| return; | ||
| // 去重 | ||
| if (isDuplicate(data.msgId)) | ||
| return; | ||
| // 通知框架收到入站事件,更新 lastEventAt 时间戳,防止 stale-socket 检测误判断线 | ||
| onEvent?.(); | ||
| log?.info(`[${CHANNEL_KEY}] Message from Agent: ${data.agentId}, ${data.from}: "${(data.content || '').slice(0, 60)}" files=${data.files?.length ?? 0}`); | ||
| // /stop 及自然语言 abort 不做旁路,统一交给 openclaw fast-abort。 | ||
| const chatId = extractChatId(data); | ||
| handleMessage({ | ||
| senderId: data.from, | ||
| text: data.content || '', | ||
| messageId: data.msgId, | ||
| files: data.files ?? [], | ||
| timestamp: data.timestamp, | ||
| 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)}`); | ||
| } | ||
| }); | ||
| }); | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| bindMessagePrivateHandler(socket, deps); | ||
| // 事件:历史消息请求(history:request) | ||
| // 职责:根据用户 ID 解析 sessionKey,从本地存储读取历史消息后返回给客户端 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| socket.on(EVENT_HISTORY_REQUEST, (data, ack) => { | ||
| // 立即回复 ACK,告知服务端请求已收到 | ||
| ack?.(); | ||
| if (!data?.from) { | ||
| log?.warn(`[${CHANNEL_KEY}] History request missing userId, ignoring`); | ||
| return; | ||
| } | ||
| // 回环防御:忽略 bot 自身发出的请求 | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 去重:如果有 msgId,走消息级去重 | ||
| if (data.msgId && isDuplicate(data.msgId)) { | ||
| log?.warn(`[${CHANNEL_KEY}] Duplicate history request (msgId), ignoring`); | ||
| return; | ||
| } | ||
| // 防抖:同一用户高频请求只处理最后一条 | ||
| debounceHistoryRequest(data.from, () => { | ||
| // 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判) | ||
| onEvent?.(); | ||
| try { | ||
| // 获取插件运行时,读取最新配置(支持热更新,不缓存旧配置) | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| // 通过路由解析器获取该用户对应的 Agent 路由信息 | ||
| // resolveAgentRoute 会综合账户配置、用户 ID、渠道等信息计算出 sessionKey | ||
| const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({ | ||
| cfg: currentCfg, | ||
| channel: CHANNEL_KEY, | ||
| accountId: account.accountId, | ||
| peer: { kind: 'direct', id: data.from }, | ||
| }); | ||
| // agentId 合法性校验:防止前端传入任意 agentId 访问其他 Agent 的数据 | ||
| const currentCfgTyped = currentCfg; | ||
| const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID]; | ||
| log?.info(`[${CHANNEL_KEY}], 当前合法的agentId为:${validAgentIds}`); | ||
| const resolvedAgentId = data.agentId && validAgentIds.includes(data.agentId) ? data.agentId : DEFAULT_AGENT_ID; | ||
| log?.info(`[${CHANNEL_KEY}], 当前agentId为:${resolvedAgentId}`); | ||
| // 始终用 effectiveAgentId 调用 buildAgentSessionKey 生成 sessionKey: | ||
| // - 前端传了合法 agentId → 用它;否则降级到 baseRoute.agentId(框架路由解析出的默认值) | ||
| // | ||
| // 框架生成的基础 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, | ||
| channel: CHANNEL_KEY, | ||
| accountId: baseRoute.accountId, | ||
| peer: { kind: 'direct', id: data.from }, | ||
| dmScope: 'per-channel-peer', | ||
| }); | ||
| 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}`); | ||
| // 过滤掉内容为空的消息(既无文字也无附件),避免客户端渲染空气泡 | ||
| const historyMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, { | ||
| msgId: historyMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| sessionKey, | ||
| messages: messages.filter((msg) => !!msg.content.trim() || (msg.files && msg.files.length > 0)), | ||
| agentId: resolvedAgentId, | ||
| }, historyMsgId); | ||
| } | ||
| catch (err) { | ||
| // 查询失败时返回空列表 + 错误信息,客户端可据此展示错误提示 | ||
| log?.error(`[${CHANNEL_KEY}] History request error: ${err}`); | ||
| const errorMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, { | ||
| msgId: errorMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| sessionKey: '', | ||
| messages: [], | ||
| error: err instanceof Error ? err.message : String(err), | ||
| agentId: data.agentId || DEFAULT_AGENT_ID, | ||
| }, errorMsgId); | ||
| } | ||
| }); | ||
| bindHistoryRequestHandler(socket, { | ||
| account: deps.account, | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| }); | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 事件:Agents 列表请求(agents:request) | ||
| // 职责:读取 openclaw.json 中 agents.list,并在单 agent 场景补齐默认 main(支持热更新,不缓存) | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| socket.on(EVENT_AGENTS_REQUEST, (data, ack) => { | ||
| // 立即回复 ACK,告知服务端请求已收到 | ||
| ack?.(); | ||
| if (data.from === botClientId) | ||
| return; | ||
| // 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判) | ||
| onEvent?.(); | ||
| try { | ||
| // 获取插件运行时,读取最新配置 | ||
| const pluginRuntime = getLightclawRuntime(); | ||
| const currentCfg = pluginRuntime.config.loadConfig(); | ||
| const agentsList = normalizeAgentsWithMainAgent(currentCfg); | ||
| let agentsWithSoulIds = agentsList; | ||
| try { | ||
| agentsWithSoulIds = attachSoulIdsToAgents(agentsList, readSoulIdMap()); | ||
| } | ||
| catch (soulErr) { | ||
| log?.warn(`[${CHANNEL_KEY}] Failed to read soul config ${LIGHTSOUL_CONFIG_PATH}: ${soulErr instanceof Error ? soulErr.message : String(soulErr)}`); | ||
| } | ||
| log?.info(`[${CHANNEL_KEY}] Agents request: count=${agentsWithSoulIds.length}`); | ||
| const agentsMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, { | ||
| msgId: agentsMsgId, | ||
| to: data.from, | ||
| from: botClientId, | ||
| agents: agentsWithSoulIds, | ||
| timestamp: Date.now(), | ||
| }, agentsMsgId); | ||
| } | ||
| catch (err) { | ||
| 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), | ||
| timestamp: Date.now(), | ||
| }, agentsErrMsgId); | ||
| } | ||
| bindAgentsRequestHandler(socket, { | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| }); | ||
| 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; | ||
| } | ||
| // 事件:会话管理请求(chat:request) | ||
| bindChatRequestHandler(socket, { | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| }); | ||
| } | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 内部工具:从 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 }, | ||
| // 事件:群组管理请求(group:request) | ||
| // 传入 orchestrator 用于创建群后唤醒主Agent生成欢迎消息 | ||
| bindGroupRequestHandler(socket, { | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| orchestrator, | ||
| }); | ||
| const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({ | ||
| agentId: resolvedAgentId, | ||
| channel: CHANNEL_KEY, | ||
| accountId: baseRoute.accountId, | ||
| peer: { kind: 'direct', id: userId }, | ||
| dmScope: 'per-channel-peer', | ||
| // 事件:群历史消息请求(group:history:request) | ||
| bindGroupHistoryHandler(socket, { | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| }); | ||
| // 与 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); | ||
| // 事件:群成员管理请求(group:member:request) | ||
| bindGroupMemberHandler(socket, { | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| }); | ||
| // 事件:群消息中止请求(group:abort:request) | ||
| bindGroupAbortHandler(socket, { | ||
| botClientId, | ||
| reliableEmitter, | ||
| onEvent, | ||
| orchestrator, | ||
| groupStorage, | ||
| }); | ||
| } | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| // 文件下载处理器(kind=file:download, status=download_req) | ||
| // | ||
| // 流程: | ||
| // 1. 从 data.extra.transferData 解析 transferId / localPath | ||
| // 2. 基础校验:必须是绝对路径,文件必须存在 | ||
| // 3. 先回告 download_ready 帧(transferId + 文件元数据),前端据此更新下载按钮状态 | ||
| // 4. 通过 uploadFileToServer 将本机文件上传到 ai-server,拿到下载 URL | ||
| // 5. 回告 download_url 帧(transferId + url),前端触发浏览器原生下载 | ||
| // 6. 任一阶段失败 → 回告 download_error 帧(transferId + error message) | ||
| // | ||
| // 备注:不走目录白名单校验,因为 localPath 来自 AI 工具返回的 localfile:// 链接, | ||
| // AI 只会返回自己刚写盘的文件;上传到 ai-server 后会经过一轮内容审核兜底。 | ||
| // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||
| async function handleFileDownloadReq(data, botClientId, reliableEmitter, account, log) { | ||
| const transferData = data.extra?.transferData; | ||
| const transferId = transferData?.transferId; | ||
| const localPath = transferData?.localPath; | ||
| log?.info(`[${CHANNEL_KEY}] file:download(req) received: transferId=${transferId}, localPath=${localPath}, from=${data.from}`); | ||
| const sendError = (error) => { | ||
| const msgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, { | ||
| msgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error }, | ||
| }, | ||
| }, msgId); | ||
| log?.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`); | ||
| }; | ||
| if (!transferId || !localPath) { | ||
| sendError('Missing transferId or localPath in extra.transferData'); | ||
| return; | ||
| } | ||
| // 基础校验:必须是绝对路径 | ||
| if (!path.isAbsolute(localPath)) { | ||
| sendError(`localPath must be an absolute path: ${localPath}`); | ||
| return; | ||
| } | ||
| const resolvedPath = path.resolve(localPath); | ||
| if (!fs.existsSync(resolvedPath)) { | ||
| sendError(`File not found: ${resolvedPath}`); | ||
| return; | ||
| } | ||
| const stat = fs.statSync(resolvedPath); | ||
| if (!stat.isFile()) { | ||
| sendError(`Not a regular file: ${resolvedPath}`); | ||
| return; | ||
| } | ||
| const fileName = path.basename(resolvedPath); | ||
| const mimeType = guessMimeByExt(path.extname(fileName).toLowerCase()) || 'application/octet-stream'; | ||
| // 回告 download_ready(文件确认存在 + 元数据) | ||
| const readyMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, { | ||
| msgId: readyMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { | ||
| transferId, | ||
| status: FILE_DOWNLOAD_STATUS.READY, | ||
| name: fileName, | ||
| size: stat.size, | ||
| contentType: mimeType, | ||
| }, | ||
| }, | ||
| }, readyMsgId); | ||
| log?.info(`[${CHANNEL_KEY}] file:download(ready) sent: transferId=${transferId}, name=${fileName}, size=${stat.size}`); | ||
| // 上传本机文件到 ai-server(含审核),成功后把 URL 回告前端 | ||
| try { | ||
| // 通过 senderId 解析真实 apiKey,用于 /drive/save 鉴权 | ||
| const apiKey = account.apiKey; | ||
| const uploadResult = await uploadFileToServer(resolvedPath, { apiKey }); | ||
| if (!uploadResult.isUploaded || !uploadResult.url) { | ||
| sendError('Upload to ai-server failed'); | ||
| return; | ||
| } | ||
| const urlMsgId = generateMsgId(); | ||
| reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, { | ||
| msgId: urlMsgId, | ||
| from: botClientId, | ||
| to: data.from, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: KIND_FILE_DOWNLOAD, | ||
| extra: { | ||
| chatId: extractChatId(data), | ||
| transferData: { | ||
| transferId, | ||
| status: FILE_DOWNLOAD_STATUS.URL, | ||
| url: uploadResult.url, | ||
| name: fileName, | ||
| size: stat.size, | ||
| contentType: mimeType, | ||
| }, | ||
| }, | ||
| }, urlMsgId); | ||
| log?.info(`[${CHANNEL_KEY}] file:download(url) sent: transferId=${transferId}, url=${uploadResult.url}`); | ||
| } | ||
| catch (err) { | ||
| sendError(err instanceof Error ? err.message : String(err)); | ||
| } | ||
| } |
@@ -17,2 +17,5 @@ /** | ||
| import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS } from '../config.js'; | ||
| import { getModuleLogger } from '../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const moduleLog = getModuleLogger('socket.native-socket'); | ||
| // ============================================================ | ||
@@ -63,4 +66,4 @@ // NativeSocketClient 实现 | ||
| // ---------------------------------------------------------- | ||
| /** 日志对象(可选)。调用方通常传入 GatewayContext.log,用于打印连接/重连/错误等诊断信息。 */ | ||
| log; | ||
| /** 日志对象,使用公共方法获取。 */ | ||
| log = moduleLog; | ||
| /** 日志前缀,便于在多实例场景下区分日志来源。 */ | ||
@@ -72,3 +75,2 @@ logPrefix; | ||
| * - path: 连接路径(如 /claw-socket),会附加到 URL | ||
| * - log: 日志对象(可选),用于打印连接生命周期与异常信息 | ||
| * - logPrefix: 日志前缀(可选),默认 `[NativeSocket]` | ||
@@ -79,3 +81,2 @@ */ | ||
| this.options = options; | ||
| this.log = options.log; | ||
| this.logPrefix = options.logPrefix ?? '[NativeSocket]'; | ||
@@ -176,3 +177,3 @@ this._connect(); | ||
| disconnect() { | ||
| this.log?.info(`${this.logPrefix} disconnect() called (wasConnected=${this._connected})`); | ||
| this.log.info(`${this.logPrefix} disconnect() called (wasConnected=${this._connected})`); | ||
| this._manualDisconnect = true; | ||
@@ -192,6 +193,6 @@ this._clearReconnectTimer(); | ||
| if (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN)) { | ||
| this.log?.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws.readyState === WebSocket.OPEN ? 'OPEN' : 'CONNECTING'}`); | ||
| this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws.readyState === WebSocket.OPEN ? 'OPEN' : 'CONNECTING'}`); | ||
| return this; | ||
| } | ||
| this.log?.info(`${this.logPrefix} connect() called, manually reconnecting`); | ||
| this.log.info(`${this.logPrefix} connect() called, manually reconnecting`); | ||
| this._manualDisconnect = false; | ||
@@ -209,3 +210,3 @@ this._reconnectAttempts = 0; | ||
| const fullUrl = this._buildUrl(); | ||
| this.log?.info(`${this.logPrefix} Connecting to ${fullUrl}`); | ||
| this.log.info(`${this.logPrefix} Connecting to ${fullUrl}`); | ||
| try { | ||
@@ -217,3 +218,3 @@ const ws = new WebSocket(fullUrl); | ||
| this._connected = true; | ||
| this.log?.info(`${this.logPrefix} WebSocket opened`); | ||
| this.log.info(`${this.logPrefix} WebSocket opened`); | ||
| this._dispatch('connect'); | ||
@@ -229,3 +230,3 @@ }; | ||
| this._ws = null; | ||
| this.log?.info(`${this.logPrefix} WebSocket closed (code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, wasConnected=${wasConnected})`); | ||
| this.log.info(`${this.logPrefix} WebSocket closed (code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, wasConnected=${wasConnected})`); | ||
| // 立即 flush 所有挂起的 ACK,以断开错误立即回调,而非等待超时 | ||
@@ -243,3 +244,3 @@ this._flushPendingAcks(new Error('disconnect')); | ||
| const detail = event.error?.stack || event.message || 'unknown'; | ||
| this.log?.error(`${this.logPrefix} WebSocket error: ${detail}`); | ||
| this.log.error(`${this.logPrefix} WebSocket error: ${detail}`); | ||
| // onerror 后必然触发 onclose,此处只派发 connect_error 事件 | ||
@@ -251,3 +252,3 @@ this._dispatch('connect_error', new Error('WebSocket error')); | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| this.log?.error(`${this.logPrefix} Failed to construct WebSocket: ${message}`); | ||
| this.log.error(`${this.logPrefix} Failed to construct WebSocket: ${message}`); | ||
| this._dispatch('connect_error', err instanceof Error ? err : new Error(String(err))); | ||
@@ -268,7 +269,7 @@ if (!this._manualDisconnect) { | ||
| catch { | ||
| this.log?.warn(`${this.logPrefix} Received non-JSON message, ignored`); | ||
| this.log.warn(`${this.logPrefix} Received non-JSON message, ignored`); | ||
| return; // 忽略非 JSON 消息 | ||
| } | ||
| if (!msg.event) { | ||
| this.log?.warn(`${this.logPrefix} Received message without event field, ignored`); | ||
| this.log.warn(`${this.logPrefix} Received message without event field, ignored`); | ||
| return; | ||
@@ -278,3 +279,3 @@ } | ||
| if (msg.event === 'message:ack') { | ||
| this.log?.info(`${this.logPrefix} Received message ACK ${JSON.stringify(msg)}`); | ||
| // this.log.info(`${this.logPrefix} Received message ACK ${JSON.stringify(msg)}`); | ||
| // 服务端通过 relatedMsgId 回传上行消息的 msgId,用于匹配挂起的 ACK 记录 | ||
@@ -292,3 +293,3 @@ // pendingKey = data.msgId(注册时),relatedMsgId = msgId(服务端回传),两者一致 | ||
| else { | ||
| this.log?.debug?.(`${this.logPrefix} Received ACK for unknown relatedMsgId=${relatedMsgId}`); | ||
| this.log.debug?.(`${this.logPrefix} Received ACK for unknown relatedMsgId=${relatedMsgId}`); | ||
| } | ||
@@ -303,3 +304,3 @@ } | ||
| this._id = handshakeData.id; | ||
| this.log?.info(`${this.logPrefix} Handshake received, socket id=${this._id}`); | ||
| this.log.info(`${this.logPrefix} Handshake received, socket id=${this._id}`); | ||
| } | ||
@@ -309,3 +310,3 @@ return; | ||
| // 分发业务事件 | ||
| this.log?.debug?.(`${this.logPrefix} Dispatch event: ${msg.event}`); | ||
| this.log.debug?.(`${this.logPrefix} Dispatch event: ${msg.event}`); | ||
| this._dispatch(msg.event, msg.data); | ||
@@ -337,3 +338,3 @@ } | ||
| if (SOCKET_RECONNECTION_ATTEMPTS !== Infinity && this._reconnectAttempts >= SOCKET_RECONNECTION_ATTEMPTS) { | ||
| this.log?.error(`${this.logPrefix} Max reconnection attempts (${SOCKET_RECONNECTION_ATTEMPTS}) reached, giving up`); | ||
| this.log.error(`${this.logPrefix} Max reconnection attempts (${SOCKET_RECONNECTION_ATTEMPTS}) reached, giving up`); | ||
| this._dispatch('connect_error', new Error(`Max reconnection attempts (${SOCKET_RECONNECTION_ATTEMPTS}) reached`)); | ||
@@ -345,3 +346,3 @@ return; | ||
| this._reconnectAttempts++; | ||
| this.log?.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms`); | ||
| this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms`); | ||
| this._reconnectTimer = setTimeout(() => { | ||
@@ -348,0 +349,0 @@ this._reconnectTimer = null; |
@@ -14,2 +14,5 @@ /** | ||
| import { MAX_PENDING_MESSAGES, EVENT_MESSAGE_PRIVATE } from "../config.js"; | ||
| import { getModuleLogger } from '../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const log = getModuleLogger('socket.registry'); | ||
| /** accountId → SocketEntry */ | ||
@@ -85,3 +88,3 @@ const registry = new Map(); | ||
| */ | ||
| export function flushPendingMessages(accountId, log) { | ||
| export function flushPendingMessages(accountId) { | ||
| const entry = registry.get(accountId); | ||
@@ -114,3 +117,3 @@ if (!entry) | ||
| failed++; | ||
| log?.warn(`[socket-registry] Failed to flush buffered message: msgId=${msg.msgId}`); | ||
| log.warn(`[socket-registry] Failed to flush buffered message: msgId=${msg.msgId}`); | ||
| } | ||
@@ -120,3 +123,3 @@ } | ||
| if (sent > 0 || failed > 0) { | ||
| log?.info(`[socket-registry] Flushed pending messages: sent=${sent}, failed=${failed}`); | ||
| log.info(`[socket-registry] Flushed pending messages: sent=${sent}, failed=${failed}`); | ||
| } | ||
@@ -123,0 +126,0 @@ return { sent, failed }; |
@@ -26,2 +26,5 @@ /** | ||
| import { EMIT_ACK_TIMEOUT, EMIT_MAX_RETRIES, EMIT_RETRY_BASE_DELAY, EMIT_RETRY_MAX_DELAY, EMIT_PENDING_MAX, } from '../config.js'; | ||
| import { getModuleLogger } from '../utils/logger.js'; | ||
| /** 模块级日志器 */ | ||
| const moduleLog = getModuleLogger('socket.reliable-emitter'); | ||
| // ============================================================ | ||
@@ -32,3 +35,2 @@ // ReliableEmitter 主类 | ||
| getSocket; | ||
| log; | ||
| /** | ||
@@ -62,5 +64,6 @@ * 待确认消息队列。 | ||
| logPrefix; | ||
| constructor(getSocket, log, logPrefix = "[ReliableEmitter]") { | ||
| /** 模块级日志器引用 */ | ||
| log = moduleLog; | ||
| constructor(getSocket, logPrefix = "[ReliableEmitter]") { | ||
| this.getSocket = getSocket; | ||
| this.log = log; | ||
| this.logPrefix = logPrefix; | ||
@@ -102,3 +105,3 @@ } | ||
| // 统一的发送日志(含最终 payload),业务层无需再打 | ||
| this.log?.info(`${this.logPrefix} emit: ${JSON.stringify(enrichedData)}`); | ||
| this.log.info(`${this.logPrefix} emit: ${JSON.stringify(enrichedData)}`); | ||
| return new Promise((resolve) => { | ||
@@ -119,3 +122,3 @@ const entry = { | ||
| // 断线中,等 resume 统一重发 | ||
| this.log?.info(`${this.logPrefix} Queued while paused: emitId=${emitId}, msgId=${msgId}`); | ||
| this.log.info(`${this.logPrefix} Queued while paused: emitId=${emitId}, msgId=${msgId}`); | ||
| return; | ||
@@ -148,3 +151,3 @@ } | ||
| } | ||
| this.log?.info(`${this.logPrefix} Paused, ${this.pending.size} message(s) pending`); | ||
| this.log.info(`${this.logPrefix} Paused, ${this.pending.size} message(s) pending`); | ||
| } | ||
@@ -165,3 +168,3 @@ /** | ||
| this.paused = false; | ||
| this.log?.info(`${this.logPrefix} Resumed, re-emitting ${this.pending.size} pending message(s)`); | ||
| this.log.info(`${this.logPrefix} Resumed, re-emitting ${this.pending.size} pending message(s)`); | ||
| for (const entry of this.pending.values()) { | ||
@@ -186,3 +189,3 @@ this.doEmit(entry); | ||
| this.pending.clear(); | ||
| this.log?.info(`${this.logPrefix} Destroyed`); | ||
| this.log.info(`${this.logPrefix} Destroyed`); | ||
| } | ||
@@ -239,3 +242,3 @@ /** | ||
| // ACK 超时或服务端返回错误,安排下一次重试 | ||
| this.log?.warn(`[ReliableEmitter] ACK error: emitId=${entry.id}, msgId=${entry.msgId}, err=${err.message}, retryCount=${entry.retryCount}`); | ||
| this.log.warn(`[ReliableEmitter] ACK error: emitId=${entry.id}, msgId=${entry.msgId}, err=${err.message}, retryCount=${entry.retryCount}`); | ||
| this.scheduleRetry(entry); | ||
@@ -245,3 +248,3 @@ } | ||
| // 服务端已确认收到,从 pending 队列移除 | ||
| // this.log?.info( | ||
| // this.log.info( | ||
| // `[ReliableEmitter] ACK success: emitId=${entry.id}, msgId=${entry.msgId}, retryCount=${entry.retryCount}`, | ||
@@ -287,3 +290,3 @@ // ); | ||
| this.stats.totalFailed++; | ||
| this.log?.error(`${this.logPrefix} Gave up after ${entry.retryCount} retries: emitId=${entry.id}, msgId=${entry.msgId}, ` + | ||
| this.log.error(`${this.logPrefix} Gave up after ${entry.retryCount} retries: emitId=${entry.id}, msgId=${entry.msgId}, ` + | ||
| `elapsed=${Date.now() - entry.createdAt}ms`); | ||
@@ -298,3 +301,3 @@ // 通知调用方:消息最终未被确认 | ||
| const delay = this.getRetryDelay(entry.retryCount); | ||
| this.log?.info(`${this.logPrefix} Retry #${entry.retryCount} in ${delay}ms: emitId=${entry.id}, msgId=${entry.msgId}`); | ||
| this.log.info(`${this.logPrefix} Retry #${entry.retryCount} in ${delay}ms: emitId=${entry.id}, msgId=${entry.msgId}`); | ||
| entry.retryTimer = setTimeout(() => { | ||
@@ -348,3 +351,3 @@ entry.retryTimer = null; | ||
| this.stats.totalFailed++; | ||
| this.log?.warn(`${this.logPrefix} Evicted oldest pending: emitId=${oldest.id}, msgId=${oldest.msgId}`); | ||
| this.log.warn(`${this.logPrefix} Evicted oldest pending: emitId=${oldest.id}, msgId=${oldest.msgId}`); | ||
| oldest.resolve(false); | ||
@@ -351,0 +354,0 @@ } |
@@ -9,2 +9,3 @@ /** | ||
| */ | ||
| export { DEFAULT_PARTIAL_COALESCE } from "./types.js"; | ||
| // 增量计算器 | ||
@@ -11,0 +12,0 @@ export { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js"; |
@@ -20,2 +20,3 @@ /** | ||
| import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js"; | ||
| import { DEFAULT_PARTIAL_COALESCE } from "./types.js"; | ||
| import { emitSignal } from "../utils/common.js"; | ||
@@ -37,2 +38,35 @@ import { readSessionHistoryTail } from "../history/index.js"; | ||
| const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts; | ||
| // ── 群聊扩展字段 ── | ||
| const { groupId, runId: optRunId, parentRunId, conversationId, isSubtask, parentMsgId } = opts; | ||
| /** 是否为群聊模式 */ | ||
| const isGroupMode = !!groupId; | ||
| /** 群聊场景下可变的 runId(可能由 SDK 回填) */ | ||
| let knownRunId = optRunId; | ||
| /** | ||
| * 构建群聊 extra 数据载荷(仅群聊模式使用) | ||
| * 协议字段与前端 useGroupChat.ts 对齐 | ||
| */ | ||
| const buildGroupDataExtra = (streamStatus) => { | ||
| if (!isGroupMode) | ||
| return undefined; | ||
| const extra = { groupId, agentId, streamStatus }; | ||
| if (knownRunId) | ||
| extra.runId = knownRunId; | ||
| if (parentRunId !== undefined && parentRunId !== null) | ||
| extra.parentRunId = parentRunId; | ||
| if (conversationId) | ||
| extra.conversationId = conversationId; | ||
| if (isSubtask) | ||
| extra.isSubtask = true; | ||
| if (parentMsgId) | ||
| extra.parentMsgId = parentMsgId; | ||
| return extra; | ||
| }; | ||
| /** 群聊场景下所有信号帧的顶层 extra(注入 chatKind: 'group') */ | ||
| const groupSignalExtra = isGroupMode ? { chatKind: 'group' } : undefined; | ||
| // ── partial 通道二次聚合参数(详见 ADR-017) ── | ||
| const coalesceCfg = { | ||
| ...DEFAULT_PARTIAL_COALESCE, | ||
| ...(opts.partialCoalesce ?? {}), | ||
| }; | ||
| // ── 增量追踪 & 已推送文本 ── | ||
@@ -61,2 +95,85 @@ let partialReplyState = createDeltaTrackerState(); | ||
| let abortDetected = false; | ||
| // ── Coalescer 状态:缓冲区 + idle 定时器 + 统计 ── | ||
| let coalesceBuf = ""; | ||
| let idleTimer = null; | ||
| let firstFrameEmitted = false; | ||
| /** 自然 flush(size/idle)的最近一次时间戳,用于统计 maxIdleGap */ | ||
| let lastFlushAt = 0; | ||
| /** 首字(首帧)emit 时刻,用于 firstByteMs 统计 */ | ||
| let firstByteAt = 0; | ||
| /** 本次会话的 sink 启动时间(首次 onPartialReply 进入时戳一次) */ | ||
| let sinkStartAt = 0; | ||
| const coalesceStats = { | ||
| frames: 0, | ||
| totalChars: 0, | ||
| triggerStats: { first: 0, size: 0, idle: 0, tool: 0, final: 0, round: 0, block: 0, complete: 0 }, | ||
| maxIdleGap: 0, | ||
| /** 模型 onPartialReply 累计的 token 增量条数(用于评估 "模型给了多少条" → 合并比) */ | ||
| totalTokenDeltas: 0, | ||
| }; | ||
| /** | ||
| * 当前合并周期内累计的模型 token 增量条数。 | ||
| * | ||
| * - onPartialReply 每收到一个非空 delta 就 +1 | ||
| * - emitChunk 时打入日志后清零 | ||
| * - finalDelta 路径不计入(不是模型流式 token) | ||
| */ | ||
| let pendingTokenCount = 0; | ||
| /** 清掉 idle 定时器(不 flush) */ | ||
| const clearIdleTimer = () => { | ||
| if (idleTimer !== null) { | ||
| clearTimeout(idleTimer); | ||
| idleTimer = null; | ||
| } | ||
| }; | ||
| /** 重置 idle 定时器:到期触发 idle flush */ | ||
| const armIdleTimer = () => { | ||
| clearIdleTimer(); | ||
| if (coalesceCfg.idleMs > 0) { | ||
| idleTimer = setTimeout(() => { | ||
| idleTimer = null; | ||
| flushCoalesceBuffer("idle"); | ||
| }, coalesceCfg.idleMs); | ||
| } | ||
| }; | ||
| /** | ||
| * 真正向 socket 发出 stream_chunk 帧 + 维护 coalesceStats。 | ||
| * trigger 仅用于日志归因,行为完全一致。 | ||
| */ | ||
| const emitChunk = (text, trigger) => { | ||
| if (!text) | ||
| return; | ||
| emitSignal(signalCtx, "stream_chunk", text, groupSignalExtra, buildGroupDataExtra('delta')); | ||
| emittedUserVisible = true; | ||
| coalesceStats.frames++; | ||
| coalesceStats.totalChars += text.length; | ||
| coalesceStats.triggerStats[trigger]++; | ||
| const now = Date.now(); | ||
| if (firstByteAt === 0) | ||
| firstByteAt = now; | ||
| if (lastFlushAt > 0) { | ||
| const gap = now - lastFlushAt; | ||
| if (gap > coalesceStats.maxIdleGap) | ||
| coalesceStats.maxIdleGap = gap; | ||
| } | ||
| lastFlushAt = now; | ||
| // 当前周期内累计的模型 token 增量条数,emit 后清零;final 路径未累加,会显示为 0 | ||
| const tokensInCycle = pendingTokenCount; | ||
| coalesceStats.totalTokenDeltas += tokensInCycle; | ||
| pendingTokenCount = 0; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] [coalesce] flush: trigger=${trigger} ` + | ||
| `bufLen=${coalesceBuf.length} deltaLen=${text.length} tokens=${tokensInCycle}`); | ||
| }; | ||
| /** | ||
| * 把 buffer 中堆积的字符整体 emit 出去并复位 buffer/timer。 | ||
| * 任何强制 flush 路径必须经过这里,保证统计与日志一致。 | ||
| */ | ||
| const flushCoalesceBuffer = (trigger) => { | ||
| clearIdleTimer(); | ||
| if (!coalesceBuf) | ||
| return; | ||
| const payload = coalesceBuf; | ||
| coalesceBuf = ""; | ||
| emitChunk(payload, trigger); | ||
| }; | ||
| // ── Token 用量 ── | ||
@@ -77,13 +194,40 @@ // 把每一轮的 usage 写入 transcript jsonl(与 history 模块同源), | ||
| typingStartSent = true; | ||
| emitSignal(signalCtx, "typing_start"); | ||
| emitSignal(signalCtx, "typing_start", "", groupSignalExtra, buildGroupDataExtra('delta')); | ||
| }; | ||
| const sendTypingStop = () => { | ||
| const sendTypingStop = (streamStatus, finalContent) => { | ||
| if (typingStopSent) | ||
| return; | ||
| typingStopSent = true; | ||
| emitSignal(signalCtx, "typing_stop"); | ||
| emitSignal(signalCtx, "typing_stop", finalContent ?? "", groupSignalExtra, buildGroupDataExtra(streamStatus ?? 'done')); | ||
| }; | ||
| /** | ||
| * 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 → emit sendReply/sendFiles。 | ||
| * 群聊场景:发送最终完整文本帧 | ||
| * 通过 emitter.emit 直接推送完整消息(非信号),前端据此渲染最终气泡内容。 | ||
| */ | ||
| const emitFinalText = (text, streamStatus) => { | ||
| if (!isGroupMode) | ||
| return; | ||
| try { | ||
| emitter.emit({ | ||
| msgId: signalCtx.replyMsgId, | ||
| from: emitter.botClientId, | ||
| to: targetId, | ||
| content: text, | ||
| timestamp: Date.now(), | ||
| replyToMsgId: originalMsgId, | ||
| agentId, | ||
| chatKind: 'group', | ||
| extra: buildGroupDataExtra(streamStatus), | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log?.warn(`[${CHANNEL_KEY}] [stream] emitFinalText failed: ${err}`); | ||
| } | ||
| }; | ||
| /** | ||
| * 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 → 推送给用户。 | ||
| * 仅在 mediaList 非空时调用。 | ||
| * | ||
| * 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。 | ||
| * 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。 | ||
| */ | ||
@@ -112,2 +256,3 @@ const handleMediaFinal = async (replyText, mediaList) => { | ||
| if (publicUrls.length > 0) { | ||
| // 判断是否为图片类型(根据 URL 中的文件扩展名) | ||
| const urlSection = publicUrls | ||
@@ -118,3 +263,6 @@ .map((url, i) => { | ||
| const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`; | ||
| return `📎 [${fileName}](${url})`; | ||
| const ext = fileName.split('.').pop()?.toLowerCase() || ''; | ||
| const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext); | ||
| // 图片使用 Markdown 图片语法,非图片使用链接语法 | ||
| return isImage ? `` : `📎 [${fileName}](${url})`; | ||
| }) | ||
@@ -124,2 +272,14 @@ .join("\n"); | ||
| } | ||
| // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ── | ||
| if (isGroupMode) { | ||
| if (enrichedText.trim()) { | ||
| const delta = `\n\n${enrichedText}`; | ||
| streamedText += delta; | ||
| emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta')); | ||
| emittedUserVisible = true; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`); | ||
| } | ||
| return; | ||
| } | ||
| // ── 私聊模式:通过 sendFiles / sendReply 推送 ── | ||
| if (files.length > 0) { | ||
@@ -148,5 +308,7 @@ emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId); | ||
| counts.tool++; | ||
| // 强制 flush:tool_end 之后 LLM 通常会续写新文本,必须把上一段 buffer 先发出去,避免错位。 | ||
| flushCoalesceBuffer("tool"); | ||
| const isError = !!payload.isError; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] sendToolResult: textLen=${payload.text?.length ?? 0} isError=${isError}`); | ||
| emitSignal(signalCtx, "tool_end", "", { toolIsError: isError }); | ||
| emitSignal(signalCtx, "tool_end", "", { toolIsError: isError, ...groupSignalExtra }, buildGroupDataExtra('delta')); | ||
| return true; | ||
@@ -164,2 +326,4 @@ }, | ||
| log?.info(`[${CHANNEL_KEY}] [stream] sendBlockReply: processing ${mediaList.length} media files`); | ||
| // 强制 flush:媒体附件要走在文本"已渲染"之后,否则前端会先看到媒体卡再补文字,渲染错乱。 | ||
| flushCoalesceBuffer("block"); | ||
| enqueue(() => handleMediaFinal(payload.text ?? "", mediaList).catch((err) => { | ||
@@ -194,2 +358,4 @@ log?.error(`[${CHANNEL_KEY}] [stream] sendBlockReply media error: ${err}`); | ||
| log?.info(`[${CHANNEL_KEY}] [stream] sendFinalReply: textLen=${replyText.length} mediaCount=${mediaList.length} streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`); | ||
| // 强制 flush:尾部增量必须在 buffer 已堆积内容"之后"发,不能让 finalDelta 插队。 | ||
| flushCoalesceBuffer("final"); | ||
| if (mediaList.length > 0) { | ||
@@ -229,4 +395,4 @@ enqueue(() => handleMediaFinal(replyText, mediaList).catch((err) => { | ||
| if (delta) { | ||
| emitSignal(signalCtx, "stream_chunk", delta); | ||
| emittedUserVisible = true; | ||
| // 直接走 emitChunk 而非进 buffer:finalDelta 是"本轮兜底尾巴",必须立即可见。 | ||
| emitChunk(delta, "final"); | ||
| log?.debug?.(`[${CHANNEL_KEY}] [stream] Final delta: ${delta.length} chars`); | ||
@@ -258,2 +424,4 @@ } | ||
| completed = true; | ||
| // 强制 flush:buffer 残留必须在 typing_stop 之前发出去;同时也清掉 idle timer 防泄漏。 | ||
| flushCoalesceBuffer("complete"); | ||
| const hadStreamedText = streamedText.length > 0 || streamedHistory.length > 0; | ||
@@ -263,2 +431,26 @@ const hadDispatch = counts.tool > 0 || counts.block > 0 || counts.final > 0; | ||
| const isAborted = markOpts?.aborted === true || abortDetected; | ||
| // ── 群聊模式:简化终态处理(不发 usage / NO_REPLY,完整文本合并到 typing_stop 的 content 中) ── | ||
| if (isGroupMode) { | ||
| const aggregatedText = streamedHistory.length > 0 | ||
| ? [...streamedHistory, streamedText].filter(Boolean).join('\n') | ||
| : streamedText; | ||
| if (isAborted) { | ||
| log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): aborted`); | ||
| // 将完整文本合并到 typing_stop 的 content 中,前端据此渲染最终气泡 | ||
| sendTypingStop('aborted', aggregatedText || '已停止'); | ||
| } | ||
| else if (hadStreamedText || hadDispatch) { | ||
| log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): done ` + | ||
| `streamedLen=${streamedText.length} historyRounds=${streamedHistory.length}`); | ||
| // 将完整文本合并到 typing_stop 的 content 中,避免额外发送独立消息导致前端重复渲染 | ||
| sendTypingStop('done', aggregatedText); | ||
| } | ||
| else { | ||
| // 群聊无内容输出时也发 done(不发 NO_REPLY) | ||
| log?.info(`[${CHANNEL_KEY}] [stream] markComplete(group): done (no content)`); | ||
| sendTypingStop('failed', '调用 Agent 失败,请稍后重试'); | ||
| } | ||
| return; | ||
| } | ||
| // ── 私聊模式:原有逻辑 ── | ||
| if (isAborted) { | ||
@@ -290,2 +482,19 @@ log?.info(`[${CHANNEL_KEY}] [stream] markComplete: aborted`); | ||
| } | ||
| // 会话级聚合统计 summary —— 调参依据(详见 ADR-017) | ||
| if (coalesceStats.frames > 0) { | ||
| const durationMs = sinkStartAt > 0 ? Date.now() - sinkStartAt : 0; | ||
| const firstByteMs = sinkStartAt > 0 && firstByteAt > 0 ? firstByteAt - sinkStartAt : 0; | ||
| const avgFrameLen = (coalesceStats.totalChars / coalesceStats.frames).toFixed(1); | ||
| // 平均合并比:每个 emit 帧承载了多少个模型 token 增量 | ||
| const avgTokensPerFrame = coalesceStats.frames > 0 | ||
| ? (coalesceStats.totalTokenDeltas / coalesceStats.frames).toFixed(1) | ||
| : "0"; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] [coalesce-summary] ` + | ||
| `frames=${coalesceStats.frames} totalChars=${coalesceStats.totalChars} ` + | ||
| `totalTokens=${coalesceStats.totalTokenDeltas} avgTokensPerFrame=${avgTokensPerFrame} ` + | ||
| `triggerStats=${JSON.stringify(coalesceStats.triggerStats)} ` + | ||
| `avgFrameLen=${avgFrameLen} maxIdleGap=${coalesceStats.maxIdleGap}ms ` + | ||
| `firstByteMs=${firstByteMs} durationMs=${durationMs} ` + | ||
| `cfg={minChars:${coalesceCfg.minChars},idleMs:${coalesceCfg.idleMs}}`); | ||
| } | ||
| // 在 typing_stop 之前 emit 一帧 usage(如有)。 | ||
@@ -340,3 +549,8 @@ // 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。 | ||
| ...prefixOptions, | ||
| /** AI 每产 token 回调,计算增量后 emit stream_chunk。 */ | ||
| /** | ||
| * AI 每产 token 回调,计算增量后进 Coalescer: | ||
| * - 首帧(flushFirstFrame=true)立即 emit,保证首字延迟 < 100ms | ||
| * - 后续追加到 buffer,达到 minChars 即 flush;否则 idle 超过 idleMs 兜底 flush | ||
| * - 详见 ADR-017 | ||
| */ | ||
| onPartialReply: (payload) => { | ||
@@ -346,8 +560,30 @@ if (completed || !payload.text) | ||
| ensureTypingStart(); | ||
| if (sinkStartAt === 0) | ||
| sinkStartAt = Date.now(); | ||
| const delta = toStreamDeltaText(partialReplyState, payload.text); | ||
| if (delta) { | ||
| streamedText += delta; | ||
| emitSignal(signalCtx, "stream_chunk", delta); | ||
| emittedUserVisible = true; | ||
| if (!delta) | ||
| return; | ||
| // 当前合并周期内的模型 token 增量计数 +1(emit 时清零并打入日志) | ||
| pendingTokenCount++; | ||
| streamedText += delta; | ||
| // 不聚合模式(兜底回滚开关):minChars/idleMs 任一为 0 时退化为每帧立即 emit | ||
| if (coalesceCfg.minChars <= 0 || coalesceCfg.idleMs <= 0) { | ||
| emitChunk(delta, firstFrameEmitted ? "size" : "first"); | ||
| firstFrameEmitted = true; | ||
| return; | ||
| } | ||
| // 首帧立即 flush(含 buffer 中可能已有的内容,但通常为空) | ||
| if (!firstFrameEmitted && coalesceCfg.flushFirstFrame !== false) { | ||
| firstFrameEmitted = true; | ||
| coalesceBuf += delta; | ||
| flushCoalesceBuffer("first"); | ||
| return; | ||
| } | ||
| coalesceBuf += delta; | ||
| if (coalesceBuf.length >= coalesceCfg.minChars) { | ||
| flushCoalesceBuffer("size"); | ||
| } | ||
| else { | ||
| armIdleTimer(); | ||
| } | ||
| }, | ||
@@ -363,2 +599,4 @@ /** | ||
| toolStartCount++; | ||
| // 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。 | ||
| flushCoalesceBuffer("tool"); | ||
| const toolName = payload.name ?? "unknown"; | ||
@@ -369,3 +607,3 @@ const toolPhase = payload.phase ?? ""; | ||
| (isSubagentSpawn ? " (subagent-spawn)" : "")); | ||
| emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase }); | ||
| emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta')); | ||
| }, | ||
@@ -380,2 +618,6 @@ onReplyStart: () => { | ||
| return; | ||
| // 群聊场景:若外部未预设 runId,用 SDK 返回的 runId 回填 | ||
| if (isGroupMode && !optRunId && runId) { | ||
| knownRunId = runId; | ||
| } | ||
| log?.info(`[${CHANNEL_KEY}] [stream] Agent run started: runId=${runId}`); | ||
@@ -418,2 +660,6 @@ }, | ||
| assistantMessageCount++; | ||
| // 强制 flush:切轮前若不发出 buffer 残留,旋转 streamedText 时会丢失这段未发的字符。 | ||
| flushCoalesceBuffer("round"); | ||
| // 切轮意味着"上一段语义已结束",重置首帧标记,让下一段开头的首字也能立即可见。 | ||
| firstFrameEmitted = false; | ||
| if (streamedText) | ||
@@ -458,3 +704,13 @@ streamedHistory.push(streamedText); | ||
| hasEmittedContent: () => emittedUserVisible, | ||
| // 群聊场景下暴露 emitStart / getRunId,私聊场景为 no-op | ||
| emitStart: ensureTypingStart, | ||
| getRunId: () => knownRunId, | ||
| // 获取本次 run 的最终完整文本(聚合所有轮次),供 AgentRunner 返回给上层写入账本 | ||
| getFinalText: () => { | ||
| if (streamedHistory.length > 0) { | ||
| return [...streamedHistory, streamedText].filter(Boolean).join('\n'); | ||
| } | ||
| return streamedText; | ||
| }, | ||
| }; | ||
| } |
| /** | ||
| * LightClaw — 流式输出类型定义 | ||
| */ | ||
| export {}; | ||
| /** | ||
| * 默认聚合参数(详见 ADR-017 数据评估)。 | ||
| * | ||
| * 调优记录: | ||
| * - v1(minChars=24, idleMs=120):合并率 ~95%(500 字 200+ 帧 → 约 8-9 帧), | ||
| * 但视觉上"打字一卡一卡",每 1.2-2 秒才更新一次,且尾部有 ~350ms 停顿感。 | ||
| * - v2(当前,minChars=24, idleMs=120):兼顾流畅度与节流效率 | ||
| * · minChars=24 ≈ 1 句中文/半行英文,承载 ~10 个模型 token / 帧 | ||
| * · idleMs=120 远低于人眼感知卡顿阈值(约 400-500ms),尾部无停顿感 | ||
| * · 预估合并率仍 >80%(200+ token → 约 25-30 帧),更新频率 ~1.3-2 帧/秒, | ||
| * 与主流 Chat 产品(ChatGPT / Claude)网页端体感一致 | ||
| * | ||
| * 实际合并比可通过 `[coalesce-summary]` 日志中的 `avgTokensPerFrame` 字段观测, | ||
| * 据此进一步调参。 | ||
| */ | ||
| export const DEFAULT_PARTIAL_COALESCE = { | ||
| minChars: 24, | ||
| idleMs: 120, | ||
| flushFirstFrame: true, | ||
| }; |
@@ -7,8 +7,9 @@ /** | ||
| */ | ||
| import { registerUploadTool } from './upload-tool.js'; | ||
| // import { registerDownloadTool } from "./download-tool.js"; | ||
| import { registerUploadTool } from './tools/upload-tool.js'; | ||
| import { registerGroupHistoryTool } from './tools/group-history-tool.js'; | ||
| export function registerLightclawTools(api) { | ||
| // 注册文件存储工具(飞书工厂函数模式:execute 时动态从 api.config 解析 apiKey) | ||
| registerUploadTool(api); | ||
| // registerDownloadTool(api); | ||
| // 注册群聊历史查询工具(群聊场景下 Agent 可主动查询历史记录) | ||
| registerGroupHistoryTool(api); | ||
| } |
| export * from './common.js'; | ||
| export * from './account.js'; | ||
| export * from './logger.js'; |
@@ -81,5 +81,6 @@ { | ||
| "tools": [ | ||
| "lightclaw_upload_file" | ||
| "lightclaw_upload_file", | ||
| "group_chat_history" | ||
| ] | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "lightclawbot", | ||
| "version": "1.2.6", | ||
| "version": "1.2.7", | ||
| "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging", | ||
@@ -5,0 +5,0 @@ "type": "module", |
| /** | ||
| * LightClaw — 文件下载工具 | ||
| * | ||
| * 注册为 OpenClaw Agent Tool,允许 AI: | ||
| * 1. 获取之前上传文件的公网下载链接 | ||
| * 2. 将云端文件下载到本地工作目录 | ||
| * | ||
| * 工具名: lightclaw_get_file_url | ||
| */ | ||
| import * as fs from "node:fs"; | ||
| import * as path from "node:path"; | ||
| import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer, } from "./file-storage.js"; | ||
| import { formatFileSize } from "./media.js"; | ||
| import { resolveEffectiveApiKey } from "./config.js"; | ||
| // ============================================================ | ||
| // 工具参数 schema | ||
| // ============================================================ | ||
| export const DOWNLOAD_TOOL_NAME = "lightclaw_get_file_url"; | ||
| export const downloadToolSchema = { | ||
| type: "object", | ||
| properties: { | ||
| action: { | ||
| type: "string", | ||
| enum: ["get_url", "download_to_local", "upload_and_get_url"], | ||
| description: "Action to perform: " + | ||
| "'get_url' — get the public download URL for a previously uploaded file (by filePath); " + | ||
| "'download_to_local' — download a cloud file to local directory; " + | ||
| "'upload_and_get_url' — upload a local file and return its public download URL.", | ||
| }, | ||
| filePath: { | ||
| type: "string", | ||
| description: "For 'get_url' and 'download_to_local': the cloud file path (e.g. '2026-03-06/report.pdf'). " + | ||
| "For 'upload_and_get_url': the local file path to upload.", | ||
| }, | ||
| localDir: { | ||
| type: "string", | ||
| description: "For 'download_to_local' only: the local directory to save the downloaded file. " + | ||
| "Defaults to the current working directory if not specified.", | ||
| }, | ||
| }, | ||
| required: ["action", "filePath"], | ||
| }; | ||
| // ============================================================ | ||
| // 工具注册(飞书工厂函数模式) | ||
| // ============================================================ | ||
| export function registerDownloadTool(api) { | ||
| // 通过闭包捕获 api.logger,tool ctx 中没有 log | ||
| const log = api.logger; | ||
| api.registerTool((ctx) => { | ||
| const defaultAccountId = ctx.agentAccountId; | ||
| const sessionKey = ctx.sessionKey; | ||
| return { | ||
| name: DOWNLOAD_TOOL_NAME, | ||
| description: "Manage files on cloud storage. " + | ||
| "Actions: " + | ||
| "(1) 'get_url' — get a public download URL for a previously uploaded file (by its filePath); " + | ||
| "(2) 'download_to_local' — download a cloud file back to the local filesystem for further processing; " + | ||
| "(3) 'upload_and_get_url' — upload a single local file and return its public download URL. " + | ||
| "Note: For batch uploading files to share with users, prefer lightclaw_upload_file instead.", | ||
| parameters: downloadToolSchema, | ||
| async execute(_toolCallId, params) { | ||
| // 每次 execute 时动态解析 apiKey(多 key 模式下通过 sessionKey 直接获取) | ||
| const apiKey = resolveEffectiveApiKey({ sessionKey }); | ||
| // log.warn(`[lightclaw_get_file_url] resolved apiKey="${apiKey?.slice(0, 8)}..."`); | ||
| const { action, filePath, localDir } = params; | ||
| if (!filePath || typeof filePath !== "string") { | ||
| return { | ||
| content: [{ type: "text", text: "Error: filePath is required." }], | ||
| }; | ||
| } | ||
| try { | ||
| switch (action) { | ||
| case "get_url": { | ||
| const url = getFileDownloadUrl(filePath); | ||
| const fileName = filePath.split("/").pop() || filePath; | ||
| return { | ||
| content: [{ type: "text", text: `[${fileName}](${url})` }], | ||
| details: { action, filePath, url }, | ||
| }; | ||
| } | ||
| case "download_to_local": { | ||
| const result = await downloadFileFromServer(filePath, { apiKey }); | ||
| const targetDir = localDir || process.cwd(); | ||
| // 确保目录存在 | ||
| if (!fs.existsSync(targetDir)) { | ||
| fs.mkdirSync(targetDir, { recursive: true }); | ||
| } | ||
| const targetPath = path.join(targetDir, result.fileName); | ||
| fs.writeFileSync(targetPath, result.buffer); | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: `File downloaded to: ${targetPath} (${formatFileSize(result.buffer.length)}, ${result.contentType})`, | ||
| }], | ||
| details: { | ||
| action, | ||
| filePath, | ||
| localPath: targetPath, | ||
| size: result.buffer.length, | ||
| contentType: result.contentType, | ||
| }, | ||
| }; | ||
| } | ||
| case "upload_and_get_url": { | ||
| if (!fs.existsSync(filePath)) { | ||
| return { | ||
| content: [{ type: "text", text: `Error: local file not found: ${filePath}` }], | ||
| }; | ||
| } | ||
| const uploadResult = await uploadFileToServer(filePath, { apiKey }); | ||
| const fileName = path.basename(filePath); | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: `File uploaded.\n\n[${fileName}](${uploadResult.url})`, | ||
| }], | ||
| details: { | ||
| action, | ||
| localPath: filePath, | ||
| cosFilePath: uploadResult.filePath, | ||
| url: uploadResult.url, | ||
| }, | ||
| }; | ||
| } | ||
| default: | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: `Error: unknown action '${action}'. Use 'get_url', 'download_to_local', or 'upload_and_get_url'.`, | ||
| }], | ||
| }; | ||
| } | ||
| } | ||
| catch (err) { | ||
| const errMsg = err instanceof Error ? err.message : String(err); | ||
| return { | ||
| content: [{ type: "text", text: `Error: ${errMsg}` }], | ||
| }; | ||
| } | ||
| }, | ||
| }; | ||
| }, { name: DOWNLOAD_TOOL_NAME }); | ||
| } |
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| export const LIGHTSOUL_CONFIG_PATH = path.join(process.env.HOME || '', '.config/lightsoul/config.json'); | ||
| export function parseSoulIdMap(raw) { | ||
| const map = {}; | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } | ||
| catch (error) { | ||
| console.error('Failed to parse lightsoul config:', error); | ||
| return map; | ||
| } | ||
| if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { | ||
| return map; | ||
| } | ||
| for (const [agentId, value] of Object.entries(parsed)) { | ||
| if (typeof value === 'string' && value.trim()) { | ||
| map[agentId] = value.trim(); | ||
| continue; | ||
| } | ||
| if (value && typeof value === 'object' && !Array.isArray(value)) { | ||
| const soulId = value.soulId; | ||
| if (typeof soulId === 'string' && soulId.trim()) { | ||
| map[agentId] = soulId.trim(); | ||
| } | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| export function readSoulIdMap(configPath = LIGHTSOUL_CONFIG_PATH) { | ||
| return parseSoulIdMap(fs.readFileSync(configPath, 'utf8')); | ||
| } | ||
| export function attachSoulIdsToAgents(agents, soulMap) { | ||
| return agents.map((agent) => { | ||
| const id = typeof agent.id === 'string' ? agent.id.trim() : ''; | ||
| const name = typeof agent.name === 'string' ? agent.name.trim() : ''; | ||
| const soulId = (id && soulMap[id]) || (name && soulMap[name]) || undefined; | ||
| return soulId ? { ...agent, soulId } : agent; | ||
| }); | ||
| } |
| /** | ||
| * 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)); | ||
| } | ||
| } |
| /** | ||
| * LightClaw — 文件注册工具 | ||
| * | ||
| * 注册为 OpenClaw Agent Tool。AI 生成本机文件后调用此工具, | ||
| * 返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。AI 将链接 | ||
| * 原样写入回复,前端识别 `localfile://` 前缀后通过 WS file:download 信令 | ||
| * (status=download_req)让 lightclaw 按需上传到 ai-server 再推回浏览器下载。 | ||
| * | ||
| * 工具名: lightclaw_upload_file | ||
| * | ||
| * 备注:名字保留 "upload" 以兼容现有 skill 表述,实际不再主动上传 COS, | ||
| * 只在本机做存在性校验并生成标准格式的 Markdown 链接。 | ||
| */ | ||
| import * as fsp from "node:fs/promises"; | ||
| import * as path from "node:path"; | ||
| import { formatFileSize } from "./media.js"; | ||
| import { LOCALFILE_SCHEME } from "./config.js"; | ||
| // ============================================================ | ||
| // 工具参数 schema(JSON Schema 格式) | ||
| // ============================================================ | ||
| export const UPLOAD_TOOL_NAME = "lightclaw_upload_file"; | ||
| export const uploadToolSchema = { | ||
| type: "object", | ||
| properties: { | ||
| paths: { | ||
| type: "array", | ||
| items: { type: "string" }, | ||
| minItems: 1, | ||
| maxItems: 5, | ||
| description: "需要注册的本地文件绝对路径数组,文件必须已写入磁盘,最多 5 个文件", | ||
| }, | ||
| }, | ||
| required: ["paths"], | ||
| }; | ||
| // ============================================================ | ||
| // 工具注册(工厂函数模式) | ||
| // ============================================================ | ||
| export function registerUploadTool(api) { | ||
| // 通过闭包捕获 api.logger,tool ctx 中没有 log | ||
| const log = api.logger; | ||
| api.registerTool((ctx) => { | ||
| // 硬隔离:非 lightclawbot 通道时直接返回 null,框架不会暴露此工具 | ||
| if (ctx.messageChannel !== "lightclawbot") { | ||
| return null; | ||
| } | ||
| return { | ||
| name: UPLOAD_TOOL_NAME, | ||
| description: "将 AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" + | ||
| "当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" + | ||
| "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" + | ||
| "重要:工具返回的 Markdown 链接已是最终格式,请原样使用,不要改写为 https:// 或本机路径字符串。", | ||
| parameters: uploadToolSchema, | ||
| async execute(_toolCallId, params) { | ||
| const { paths } = params; | ||
| // 参数校验 | ||
| if (!Array.isArray(paths) || paths.length === 0) { | ||
| return { | ||
| content: [{ type: "text", text: "Error: paths must be a non-empty array of file paths." }], | ||
| }; | ||
| } | ||
| if (paths.length > 5) { | ||
| return { | ||
| content: [{ type: "text", text: "Error: maximum 5 files per upload." }], | ||
| }; | ||
| } | ||
| // 并发校验所有路径并顺便拿到 stat | ||
| // - 路径合法性(绝对路径、非空字符串):同步判定 | ||
| // - 文件存在 + 必须是 regular file:靠 fsp.stat 抛错 / isFile() 判定 | ||
| const results = await Promise.all(paths.map(async (p) => { | ||
| if (typeof p !== "string" || !p.trim()) { | ||
| return { path: p, error: `Invalid path: empty or non-string` }; | ||
| } | ||
| if (!p.startsWith("/") && !/^[A-Za-z]:\\/.test(p)) { | ||
| return { path: p, error: `Path must be absolute: ${p}` }; | ||
| } | ||
| try { | ||
| const stat = await fsp.stat(p); | ||
| if (!stat.isFile()) { | ||
| return { path: p, error: `Not a regular file: ${p}` }; | ||
| } | ||
| return { path: p, stat }; | ||
| } | ||
| catch (err) { | ||
| const code = err?.code; | ||
| if (code === 'ENOENT') { | ||
| return { path: p, error: `File not found: ${p}` }; | ||
| } | ||
| return { path: p, error: `Stat failed: ${p} (${err.message})` }; | ||
| } | ||
| })); | ||
| const validationErrors = results | ||
| .filter((r) => 'error' in r) | ||
| .map((r) => r.error); | ||
| if (validationErrors.length > 0) { | ||
| return { | ||
| content: [{ type: "text", text: `Validation errors:\n${validationErrors.join("\n")}` }], | ||
| }; | ||
| } | ||
| // 基于本机路径生成 localfile:// 链接(不上传 COS),复用上一步已拿到的 stat | ||
| const successResults = results.map(({ path: filePath, stat }) => ({ | ||
| name: path.basename(filePath), | ||
| path: filePath, | ||
| url: `${LOCALFILE_SCHEME}${filePath}`, | ||
| size: formatFileSize(stat.size), | ||
| })); | ||
| log?.info?.(`[${UPLOAD_TOOL_NAME}] registered ${successResults.length} file(s)`); | ||
| // 构建结果文本(Markdown 链接,引导模型原样输出给用户) | ||
| const lines = successResults.map((r) => `✅ [${r.name}](${r.url}) (${r.size})`); | ||
| const summary = successResults.length === 1 | ||
| ? `File registered successfully.\n\n[${successResults[0].name}](${successResults[0].url})` | ||
| : `Registered ${successResults.length} files.\n${lines.join("\n")}`; | ||
| return { | ||
| content: [{ type: "text", text: summary }], | ||
| details: { uploads: successResults }, | ||
| }; | ||
| }, | ||
| }; | ||
| }, { name: UPLOAD_TOOL_NAME }); | ||
| } |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
High entropy strings
Supply chain riskContains high entropy strings. This could be a sign of encrypted data, leaked secrets or obfuscated code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
782909
67.51%146
117.91%18766
71.88%14
100%