UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

484 lines (483 loc) 21.6 kB
/** * LarkTransport — 飞书消息传输层 * * 职责: 将飞书 SDK 的原始消息格式转换为统一 AgentMessage, * 并把 Agent 的回复写回飞书。 * * 架构位置: * 飞书 WS Event → LarkTransport.receive(rawEvent) * → 解析文本/附件 → AgentMessage.fromLark(...) * → IntentClassifier.classify(text) * → 路由到 Bot Agent (服务端) 或 IDE Agent (VSCode) * → 回复通过 replyFn/sendFn 写回飞书 * * 与 remote.js 的关系: * remote.js 仍然管理飞书 WS 连接和 HTTP 端点, * LarkTransport 处理消息语义层 (NL 理解、Agent 路由、回复格式化)。 * * @module LarkTransport */ var _a; import { AgentMessage } from '#agent/AgentMessage.js'; import { ConversationStore } from '#agent/ConversationStore.js'; import { Intent, IntentClassifier } from '#agent/IntentClassifier.js'; import Logger from '#infra/logging/Logger.js'; export class LarkTransport { #agentFactory; #classifier; #logger; #replyFn; #sendFn; #sendImageFn; #getStatusFn; #enqueueIdeFn; #isUserAllowed; /** 持久化对话存储 */ #conversationStore = null; /** chatId → conversationId 映射缓存 */ #chatConversationMap = new Map(); /** >>} chatId → 最近对话 (降级用) */ #conversationHistory = new Map(); /** 对话历史最大轮数 */ static MAX_HISTORY = 20; /** messageId → timestamp, 消息去重 */ #recentMsgIds = new Map(); /** 去重 TTL (5 分钟) */ static DEDUP_TTL = 5 * 60 * 1000; constructor(config) { this.#agentFactory = config.agentFactory; this.#replyFn = config.replyFn ?? null; this.#sendFn = config.sendFn ?? null; this.#sendImageFn = config.sendImageFn || null; this.#getStatusFn = config.getStatusFn || null; this.#enqueueIdeFn = config.enqueueIdeFn || null; this.#isUserAllowed = config.isUserAllowed || (() => true); this.#logger = Logger.getInstance(); // 初始化持久化对话存储(静默降级) try { const projectRoot = config.projectRoot || process.cwd(); this.#conversationStore = new ConversationStore(projectRoot); this.#logger.info('[LarkTransport] ConversationStore initialized'); } catch (err) { this.#logger.warn(`[LarkTransport] ConversationStore init failed, falling back to in-memory: ${err.message}`); this.#conversationStore = null; } this.#classifier = new IntentClassifier(config.aiProvider ? { aiProvider: config.aiProvider } : {}); } /** * 接收原始飞书消息事件 * * 这是唯一入口 — 替代了 remote.js 中的 handleLarkMessage() * * @param data 飞书 im.message.receive_v1 事件数据 */ async receive(data) { const message = data?.message || data?.event?.message || {}; const sender = data?.sender || data?.event?.sender || {}; const messageId = message.message_id || ''; const chatId = message.chat_id || ''; const msgType = message.message_type; // ── 消息去重 (defense-in-depth, remote.js 也有外层去重) ── if (messageId && this.#recentMsgIds.has(messageId)) { this.#logger.debug(`[LarkTransport] Dedup: ${messageId}`); return; } if (messageId) { this.#recentMsgIds.set(messageId, Date.now()); // 清理过期条目 if (this.#recentMsgIds.size > 200) { const now = Date.now(); for (const [id, ts] of this.#recentMsgIds) { if (now - ts > _a.DEDUP_TTL) { this.#recentMsgIds.delete(id); } } } } // ── 鉴权 ── const senderId = sender.sender_id?.user_id || sender.sender_id?.open_id || ''; const senderName = sender.sender_id?.user_id || 'lark_user'; if (!this.#isUserAllowed(senderId)) { this.#logger.warn(`[LarkTransport] Blocked: ${senderId}`); await this.#reply(messageId, '🔒 权限不足。'); return; } // ── 非文本提示 ── if (msgType !== 'text') { await this.#reply(messageId, '💬 请发送文字消息,我理解自然语言。'); return; } // ── 解析文本 ── let text = ''; try { const content = JSON.parse(message.content || '{}'); text = (content.text || '').trim(); } catch { text = ''; } text = text.replace(/@_user_\d+/g, '').trim(); if (!text) { return; } this.#logger.info(`[LarkTransport] Received: "${text.slice(0, 80)}" from ${senderName}`); // ═══════════════════════════════════════════════════ // 前缀快捷路由(优先级高于 IntentClassifier) // $command → 终端命令,服务端 AgentRuntime 执行 // >command → 强制 IDE 编程,跳过分类直接转发 Copilot // 自然语言 → IntentClassifier 三层分类 // ═══════════════════════════════════════════════════ if (text.startsWith('$')) { const cmd = text.slice(1).trim(); if (!cmd) { await this.#reply(messageId, '❌ 请在 `$` 后输入要执行的终端命令。\n例: `$git status`'); return; } this.#logger.info(`[LarkTransport] Prefix $: remote-exec — "${cmd.slice(0, 60)}"`); await this.#handleRemoteExec(cmd, messageId, chatId, senderId, senderName); return; } if (text.startsWith('>')) { const cmd = text.slice(1).trim(); if (!cmd) { await this.#reply(messageId, '❌ 请在 `>` 后输入编程指令。\n例: `>在页面上新建一个绿色按钮`'); return; } this.#logger.info(`[LarkTransport] Prefix >: force IDE — "${cmd.slice(0, 60)}"`); await this.#handleIdeAgent(cmd, messageId, chatId, senderId, senderName); return; } // ── 无前缀:走 IntentClassifier 自然语言分类 ── const recentHistory = this.#getRecentHistoryText(chatId); const classification = await this.#classifier.classify(text, { recentHistory }); // 使用 LLM/规则提取的核心指令,去除 meta 包装 const effectiveCommand = classification.extractedCommand || text; this.#logger.info(`[LarkTransport] Intent: ${classification.intent} (${classification.confidence.toFixed(2)}) — ${classification.reasoning}` + (effectiveCommand !== text ? ` | extracted: "${effectiveCommand.slice(0, 60)}"` : '')); // ── 路由处理 ── switch (classification.intent) { case Intent.SYSTEM: await this.#handleSystem(classification.action, messageId, text); break; case Intent.IDE_AGENT: await this.#handleIdeAgent(effectiveCommand, messageId, chatId, senderId, senderName); break; default: await this.#handleBotAgent(effectiveCommand, messageId, chatId, senderId, senderName); break; } } // ═══════════════════════════════════════════════════ // 意图处理器 // ═══════════════════════════════════════════════════ /** 系统操作 — 直接处理,不走 Agent */ async #handleSystem(action, messageId, _text) { switch (action) { case 'status': if (this.#getStatusFn) { const status = await this.#getStatusFn(); await this.#reply(messageId, status); } else { await this.#reply(messageId, '📊 状态查询暂不可用'); } break; case 'screen': if (this.#sendImageFn) { await this.#reply(messageId, '📸 正在截取 IDE 画面...'); const result = await this.#sendImageFn(''); if (!result.success) { await this.#reply(messageId, `❌ 截图失败: ${result.message}`); } } else { await this.#reply(messageId, '📸 截图功能未配置'); } break; case 'help': await this.#reply(messageId, [ '🤖 AutoSnippet 智能助手', '', '直接用自然语言和我对话即可:', '', '📚 知识管理 (我来处理):', ' "搜索项目里关于认证的知识"', ' "解释一下这个项目的架构"', ' "帮我创建一个关于缓存策略的知识"', ' "翻译这段代码注释"', '', '💻 代码编程 (转发到 IDE):', ' "修改 src/auth.ts 的 JWT 验证"', ' "写一个新的 React 组件"', ' "修复这个 TypeScript 报错"', ' "运行一下测试"', '', '🔧 系统操作:', ' "查看状态" — 连接诊断', ' "截图" — 截取 IDE 画面', ' "帮助" — 显示此信息', '', '💡 我会自动判断你的意图类型。', ' 知识类任务我直接处理,编程类任务转发到 VSCode。', ].join('\n')); break; case 'queue': await this.#reply(messageId, '📋 请说"查看队列状态"获取更多信息。'); break; case 'cancel': await this.#reply(messageId, '🗑 取消操作已发送。'); break; case 'clear': await this.#reply(messageId, '🧹 清理操作已发送。'); break; case 'ping': await this.#reply(messageId, `🏓 pong! (${new Date().toLocaleTimeString('zh-CN')})`); break; default: await this.#reply(messageId, '❓ 未识别的系统操作。'); } } /** * IDE 编程任务 — 转发到 VSCode Copilot Agent Mode * * 调用来源: * - `>` 前缀快捷路由(已去除前缀) * - IntentClassifier 分类为 ide_agent */ async #handleIdeAgent(text, messageId, chatId, senderId, senderName) { if (!this.#enqueueIdeFn) { await this.#reply(messageId, '❌ IDE 桥接未配置,无法转发编程任务。'); return; } try { const _result = await this.#enqueueIdeFn(text, { chatId, messageId, senderId, senderName, }); // 记录到对话历史 this.#appendHistory(chatId, 'user', text); this.#appendHistory(chatId, 'assistant', `[IDE Agent] 已转发: ${text.slice(0, 50)}`); await this.#reply(messageId, [ '💻 编程任务已转发到 IDE', '', `> ${text.length > 80 ? `${text.slice(0, 80)}...` : text}`, '', 'Copilot Agent Mode 将自动处理。', '执行结果会回传到这里。', ].join('\n')); } catch (err) { this.#logger.error(`[LarkTransport] IDE enqueue failed: ${err.message}`); await this.#reply(messageId, `❌ 转发失败: ${err.message}`); } } /** 远程命令执行 — 使用 remote-exec preset(含 SafetyPolicy 命令白名单) */ async #handleRemoteExec(command, messageId, chatId, senderId, senderName) { if (this.#agentFactory.getAiProviderInfo().name === 'mock') { await this.#reply(messageId, '⚠️ AI 服务未配置,当前为 Mock 模式。请先配置 API Key。'); return; } await this.#reply(messageId, `⚡ 正在执行: \`${command.slice(0, 60)}\`...`); try { const history = this.#getHistory(chatId); const agentMessage = AgentMessage.fromLark({ text: command, chatId, senderId, senderName, messageId, messageType: 'text', }, null); agentMessage.session.history = history; // 使用 remote-exec preset — 包含 SafetyPolicy 命令白名单 + fileScope const runtime = this.#agentFactory.createRemoteExec({ lang: 'zh', onProgress: (event) => { if (event.type === 'tool_call') { this.#send(`🔧 执行: ${event.tool || 'unknown'}...`).catch(() => { }); } }, }); const result = await runtime.execute(agentMessage); const reply = result?.reply || result?.text || '命令执行完成,无输出。'; this.#appendHistory(chatId, 'user', `> ${command}`); this.#appendHistory(chatId, 'assistant', reply); const MAX_LEN = 3800; if (reply.length > MAX_LEN) { await this.#send(`${reply.slice(0, MAX_LEN)}\n\n... (内容过长已截断)`); } else { await this.#send(reply); } } catch (err) { this.#logger.error(`[LarkTransport] Remote exec error: ${err.message}\n${err.stack}`); await this.#reply(messageId, `❌ 执行失败: ${err.message}`); } } /** Bot Agent 知识任务 — 服务端 AgentRuntime 直接处理 */ async #handleBotAgent(text, messageId, chatId, senderId, senderName) { if (this.#agentFactory.getAiProviderInfo().name === 'mock') { await this.#reply(messageId, '⚠️ AI 服务未配置,当前为 Mock 模式。请先配置 API Key。'); return; } // 进度提示 await this.#reply(messageId, '🤔 正在思考...'); try { // 获取对话历史 const history = this.#getHistory(chatId); // 构建 AgentMessage // 注意: 不传 replyFn — AgentRuntime.execute() 会自动调用 message.reply(), // 但我们需要在外层处理截断逻辑,所以由下面的 #send 手动发送最终回复。 const agentMessage = AgentMessage.fromLark({ text, chatId, senderId, senderName, messageId, messageType: 'text', }, null // 不设 replyFn — 避免 AgentRuntime 自动回复导致重复发送 ); // 注入对话历史 agentMessage.session.history = history; // 创建 Lark Runtime 并执行 — 使用 lark preset(含 SafetyPolicy 鉴权) const runtime = this.#agentFactory.createLark({ lang: 'zh', onProgress: (event) => { // 工具调用时发送进度 if (event.type === 'tool_call') { this.#send(`🔧 调用工具: ${event.tool || 'unknown'}...`).catch(() => { }); } }, }); const result = await runtime.execute(agentMessage); // 提取回复 const reply = result?.reply || result?.text || '抱歉,没有生成有效回复。'; // 记录对话历史 this.#appendHistory(chatId, 'user', text); this.#appendHistory(chatId, 'assistant', reply); // 发送最终回复 (去掉之前的"正在思考",直接发新消息) // 飞书回复字数限制 ~4000,需截断 const MAX_LEN = 3800; if (reply.length > MAX_LEN) { const truncated = `${reply.slice(0, MAX_LEN)}\n\n... (内容过长已截断)`; await this.#send(truncated); } else { await this.#send(reply); } } catch (err) { this.#logger.error(`[LarkTransport] Bot Agent error: ${err.message}\n${err.stack}`); await this.#reply(messageId, `❌ 处理失败: ${err.message}`); } } // ═══════════════════════════════════════════════════ // 对话历史管理 (ConversationStore 持久化 + 内存降级) // ═══════════════════════════════════════════════════ /** * 获取或创建 chatId 对应的 conversationId * @returns conversationId (ConversationStore 不可用时返回 null) */ #resolveConversationId(chatId) { if (!this.#conversationStore || !chatId) { return null; } // 缓存命中 if (this.#chatConversationMap.has(chatId)) { return this.#chatConversationMap.get(chatId); } // 从索引中查找已有的 lark 对话 (通过 title 匹配 chatId) try { const existing = this.#conversationStore.list({ category: 'lark', limit: 100 }); const match = existing.find((e) => e.title === chatId); if (match) { this.#chatConversationMap.set(chatId, match.id); return match.id; } } catch { // 索引读取失败,继续创建新对话 } // 创建新对话 try { const convId = this.#conversationStore.create({ category: 'lark', title: chatId }); this.#chatConversationMap.set(chatId, convId); return convId; } catch (err) { this.#logger.warn(`[LarkTransport] Failed to create conversation for chatId ${chatId}: ${err.message}`); return null; } } /** 获取指定会话的历史 */ #getHistory(chatId) { // 优先使用 ConversationStore 持久化历史 const convId = this.#resolveConversationId(chatId); if (convId && this.#conversationStore) { try { return this.#conversationStore.load(convId); } catch (err) { this.#logger.warn(`[LarkTransport] ConversationStore load failed, falling back: ${err.message}`); } } // 降级到内存 return this.#conversationHistory.get(chatId) || []; } /** 获取最近对话的可读文本 (给 IntentClassifier 提供上下文) */ #getRecentHistoryText(chatId) { const history = this.#getHistory(chatId); if (history.length === 0) { return ''; } return history .slice(-6) .map((h) => `${h.role}: ${h.content.slice(0, 100)}`) .join('\n'); } /** 追加对话记录 (双写: ConversationStore + 内存降级) */ #appendHistory(chatId, role, content) { if (!chatId) { return; } // 写入 ConversationStore(持久化) const convId = this.#resolveConversationId(chatId); if (convId && this.#conversationStore) { try { this.#conversationStore.append(convId, { role, content }); } catch (err) { this.#logger.warn(`[LarkTransport] ConversationStore append failed: ${err.message}`); } } // 始终写入内存 (作为降级备份 + 用于 IntentClassifier 快速上下文) if (!this.#conversationHistory.has(chatId)) { this.#conversationHistory.set(chatId, []); } const history = this.#conversationHistory.get(chatId); history.push({ role, content }); // 限制内存历史长度 if (history?.length > _a.MAX_HISTORY * 2) { history?.splice(0, history?.length - _a.MAX_HISTORY * 2); } } // ═══════════════════════════════════════════════════ // 飞书消息发送 // ═══════════════════════════════════════════════════ async #reply(messageId, text) { if (this.#replyFn) { await this.#replyFn(messageId, text); } } async #send(text) { if (this.#sendFn) { await this.#sendFn(text); } } } _a = LarkTransport; export default LarkTransport;