diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts index 40eb3a8..c280d36 100644 --- a/src/feishu/__tests__/event-handler.test.ts +++ b/src/feishu/__tests__/event-handler.test.ts @@ -861,6 +861,44 @@ describe('formatConversationTrace', () => { }); }); +// ============================================================ +// formatRestartImageHints 测试 — 工作区切换重启时图片落盘路径注入 +// ============================================================ + +const { formatRestartImageHints } = await import('../event-handler.js'); + +describe('formatRestartImageHints', () => { + it('should return empty string for empty paths', () => { + expect(formatRestartImageHints([])).toBe(''); + }); + + it('should emit hint with single path', () => { + const result = formatRestartImageHints(['/tmp/feishu-downloads/abc-image.png']); + expect(result).toContain('[历史聊天图片]'); + expect(result).toContain('- /tmp/feishu-downloads/abc-image.png'); + expect(result).toContain('Read 工具'); + }); + + it('should dedupe duplicate paths', () => { + const result = formatRestartImageHints([ + '/tmp/feishu-downloads/a.png', + '/tmp/feishu-downloads/a.png', + '/tmp/feishu-downloads/b.png', + ]); + const matches = result.match(/\/tmp\/feishu-downloads\/a\.png/g); + expect(matches?.length).toBe(1); + expect(result).toContain('/tmp/feishu-downloads/b.png'); + }); + + it('should list multiple distinct paths', () => { + const result = formatRestartImageHints([ + '/tmp/feishu-downloads/x.jpg', + '/tmp/feishu-downloads/y.png', + ]); + expect(result.split('\n').filter(l => l.startsWith('- ')).length).toBe(2); + }); +}); + // ============================================================ // parseMessage empty @mention 测试 // diff --git a/src/feishu/__tests__/inject-quoted-message.test.ts b/src/feishu/__tests__/inject-quoted-message.test.ts index 98343d1..ab0c2a5 100644 --- a/src/feishu/__tests__/inject-quoted-message.test.ts +++ b/src/feishu/__tests__/inject-quoted-message.test.ts @@ -40,6 +40,15 @@ vi.mock('../../utils/image-compress.js', () => ({ })), })); +const mockSaveMessageFileToCache = vi.fn(); +vi.mock('../file-cache.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + saveMessageFileToCache: (...args: unknown[]) => mockSaveMessageFileToCache(...args), + }; +}); + // ============================================================ // Tests // ============================================================ @@ -191,6 +200,44 @@ describe('injectQuotedMessage', () => { expect(result.images).toBeUndefined(); }); + it('persists quoted image to cache and returns savedImagePath', async () => { + mockGetMessageById.mockResolvedValue([ + { + message_id: 'imgsave', + msg_type: 'image', + body: { content: '{"image_key":"img_key_save"}' }, + }, + ]); + const fakePng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]); + mockDownloadMessageImage.mockResolvedValue(fakePng); + mockSaveMessageFileToCache.mockResolvedValue('/tmp/feishu-downloads/imgsave-img_key_save.png'); + + const result = await injectQuotedMessage('prompt', 'imgsave', 'msg1', 'chat1'); + expect(mockSaveMessageFileToCache).toHaveBeenCalledWith('imgsave', 'img_key_save', fakePng, 'image.png'); + expect(result.savedImagePath).toBe('/tmp/feishu-downloads/imgsave-img_key_save.png'); + // 多模态 images 仍正常返回,不受落盘影响 + expect(result.images).toHaveLength(1); + }); + + it('returns no savedImagePath when cache save throws (non-fatal)', async () => { + mockGetMessageById.mockResolvedValue([ + { + message_id: 'imgfail', + msg_type: 'image', + body: { content: '{"image_key":"img_key_fail"}' }, + }, + ]); + const fakePng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]); + mockDownloadMessageImage.mockResolvedValue(fakePng); + mockSaveMessageFileToCache.mockRejectedValue(new Error('disk full')); + + const result = await injectQuotedMessage('prompt', 'imgfail', 'msg1', 'chat1'); + // 落盘失败不影响主流程:images 与 prompt 仍正常返回 + expect(result.images).toHaveLength(1); + expect(result.prompt).toContain('引用了一张图片'); + expect(result.savedImagePath).toBeUndefined(); + }); + it('merges quoted image with existing images', async () => { mockGetMessageById.mockResolvedValue([ { diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index e6f29ad..412690f 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -90,6 +90,22 @@ export function formatConversationTrace(trace?: ConversationTurn[]): string { return joined.length > MAX_TOTAL ? joined.slice(-MAX_TOTAL) : joined; } +/** + * 工作区切换 restart 时:把已落盘的图片路径拼成文本提示,让 agent 用 Read 工具按需查看。 + * 由于 restart query 不重传多模态 images 参数(避免重复消耗 token),需以文本路径作为 fallback。 + * 返回空字符串表示没有可附加的图片提示。 + */ +export function formatRestartImageHints(paths: string[]): string { + if (!paths.length) return ''; + // 去重后保持原顺序 + const unique = Array.from(new Set(paths)); + const lines = unique.map(p => `- ${p}`); + return [ + '[历史聊天图片] 工作区切换前已加载的图片已落盘到本地,如需查看请使用 Read 工具读取:', + ...lines, + ].join('\n'); +} + // ============================================================ // AskUserQuestion 待回答存储 // 当 Claude 调用 AskUserQuestion 时,canUseTool 拦截并发送飞书卡片, @@ -641,7 +657,7 @@ function processQueue(queueKey: string, agentId: AgentId = 'dev'): void { const executeFn = useDirectMode ? executeDirectTask(task.message, task.chatId, task.userId, task.messageId, task.images, task.documents, agentId, task.threadId, task.rootId, task.createTime, { forceThread: task.forceThread }, task.messageType) - : executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId, task.threadId, task.images, task.documents, agentId, task.createTime, task.messageType); + : executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId, task.threadId, task.images, task.documents, agentId, task.createTime, task.messageType, task.currentImagePaths); claudeExecutor.registerTask(executeFn); @@ -779,6 +795,8 @@ interface ParsedMessage { senderType?: string; /** 消息创建时间(毫秒级时间戳字符串,来自飞书 message.create_time) */ createTime?: string; + /** 当前消息内图片的落盘路径(workspace 切换后 restart 不重传多模态 images,用此作为文本 fallback) */ + currentImagePaths?: string[]; } /** @@ -828,7 +846,7 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd const parsed = await parseMessage(data); if (!parsed) return; - const { text, messageId, userId, chatId, chatType, mentionedBot, rootId, threadId, images, documents, messageType, mentions, createTime } = parsed; + const { text, messageId, userId, chatId, chatType, mentionedBot, rootId, threadId, images, documents, messageType, mentions, createTime, currentImagePaths } = parsed; logger.info({ userId, chatId, chatType, rootId, threadId, accountId, text: text.slice(0, 100), hasImages: !!images?.length }, 'Received message'); @@ -981,7 +999,7 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd const queueKey = perMessageParallel ? makeQueueKey(chatId, undefined, agentId, messageId) : makeQueueKey(chatId, effectiveThreadId, agentId, isDirectMode ? userId : undefined); - taskQueue.enqueue(queueKey, chatId, userId, effectiveText, messageId, rootId, effectiveThreadId, images, documents, createTime, forceThread, messageType, accountId).catch(() => {}); + taskQueue.enqueue(queueKey, chatId, userId, effectiveText, messageId, rootId, effectiveThreadId, images, documents, createTime, forceThread, messageType, accountId, currentImagePaths).catch(() => {}); processQueue(queueKey, agentId); } @@ -1368,7 +1386,7 @@ const MAX_HISTORY_IMAGES = 5; async function downloadHistoryImages( messages: Array<{ messageId: string; imageRefs?: Array<{ imageKey: string }> }>, parentMsgCount = 0, -): Promise<{ images: ImageAttachment[]; lazyHints: string[] }> { +): Promise<{ images: ImageAttachment[]; lazyHints: string[]; savedImagePaths: string[] }> { // 收集所有图片引用,标记是否来自父群 const refs: Array<{ messageId: string; imageKey: string; fromParent: boolean }> = []; for (let i = 0; i < messages.length; i++) { @@ -1380,7 +1398,7 @@ async function downloadHistoryImages( } } } - if (refs.length === 0) return { images: [], lazyHints: [] }; + if (refs.length === 0) return { images: [], lazyHints: [], savedImagePaths: [] }; // 父群图片:lazy loading(注入元数据,不下载) const lazyHints: string[] = []; @@ -1398,7 +1416,7 @@ async function downloadHistoryImages( logger.info({ parentImageCount: lazyHints.length }, 'Parent chat images skipped (lazy loading), metadata injected'); } - if (downloadable.length === 0) return { images: [], lazyHints }; + if (downloadable.length === 0) return { images: [], lazyHints, savedImagePaths: [] }; const toDownload = downloadable.slice(-MAX_HISTORY_IMAGES); @@ -1411,22 +1429,51 @@ async function downloadHistoryImages( } const mediaType = detectImageMediaType(buf); const compressed = await compressImageForHistory(buf, mediaType); + // 同时落盘原图,方便 workspace 切换后 agent 通过 Read 工具按需重新加载 + // (restart query 不重传多模态 images 参数,避免重复消耗 token;落盘路径作为文本兜底) + let savedPath: string | undefined; + try { + savedPath = await saveMessageFileToCache( + ref.messageId, + ref.imageKey, + buf, + `image${mediaTypeToExt(mediaType)}`, + ); + } catch (saveErr) { + logger.debug({ err: saveErr, messageId: ref.messageId, imageKey: ref.imageKey }, 'Failed to persist history image to cache (non-fatal)'); + } return { - data: compressed.data.toString('base64'), - mediaType: compressed.mediaType, - } as ImageAttachment; + image: { + data: compressed.data.toString('base64'), + mediaType: compressed.mediaType, + } as ImageAttachment, + savedPath, + }; } catch (err) { logger.warn({ err, messageId: ref.messageId, imageKey: ref.imageKey }, 'Failed to download history image, skipping'); return null; } })); - const images = results.filter((img): img is ImageAttachment => img !== null); + const successful = results.filter((r): r is { image: ImageAttachment; savedPath: string | undefined } => r !== null); + const images = successful.map(r => r.image); + const savedImagePaths = successful.map(r => r.savedPath).filter((p): p is string => !!p); if (images.length > 0) { - logger.info({ count: images.length, totalRefs: refs.length, parentSkipped: lazyHints.length }, 'Downloaded history images'); + logger.info({ count: images.length, totalRefs: refs.length, parentSkipped: lazyHints.length, savedCount: savedImagePaths.length }, 'Downloaded history images'); } - return { images, lazyHints }; + return { images, lazyHints, savedImagePaths }; +} + +/** mediaType → 文件扩展名(与 detectImageMediaType 配对) */ +function mediaTypeToExt(mediaType: ImageAttachment['mediaType']): string { + switch (mediaType) { + case 'image/jpeg': return '.jpg'; + case 'image/png': return '.png'; + case 'image/gif': return '.gif'; + case 'image/webp': return '.webp'; + default: return '.png'; + } } /** 支持下载并嵌入 prompt 的文本类文件扩展名 */ @@ -1682,6 +1729,7 @@ async function buildHistoryContext( ...(imagesResult.images.length > 0 ? { images: imagesResult.images } : {}), ...(historyFiles.documents.length > 0 ? { documents: historyFiles.documents } : {}), ...(fileTexts.length > 0 ? { fileTexts } : {}), + ...(imagesResult.savedImagePaths.length > 0 ? { savedImagePaths: imagesResult.savedImagePaths } : {}), }; } catch (err) { logger.error({ err, chatId, threadId }, errorLabel); @@ -1934,7 +1982,7 @@ async function injectQuotedMessage( messageId: string, chatId: string, existingImages?: ImageAttachment[], -): Promise<{ prompt: string; images?: ImageAttachment[] }> { +): Promise<{ prompt: string; images?: ImageAttachment[]; savedImagePath?: string }> { if (!rootId || rootId === messageId) return { prompt: effectivePrompt, images: existingImages }; try { @@ -1947,6 +1995,7 @@ async function injectQuotedMessage( const rootMsgType = rootMsg.msg_type || 'text'; let rootContent = ''; let quotedImage: ImageAttachment | undefined; + let quotedSavedPath: string | undefined; if (rootMsgType === 'image') { // 图片消息:下载图片并追加到 images @@ -1961,6 +2010,17 @@ async function injectQuotedMessage( quotedImage = { data: compressed.data.toString('base64'), mediaType: compressed.mediaType }; rootContent = '[用户引用了一张图片]'; logger.info({ rootId, imageSize: buf.length, compressedSize: compressed.data.length }, 'Downloaded quoted image'); + // 同时落盘原图,工作区切换 restart 时通过 Read 工具兜底加载 + try { + quotedSavedPath = await saveMessageFileToCache( + rootId, + imageKey, + buf, + `image${mediaTypeToExt(mediaType)}`, + ); + } catch (saveErr) { + logger.debug({ err: saveErr, rootId, imageKey }, 'Failed to persist quoted image to cache (non-fatal)'); + } } else { rootContent = '[用户引用了一张图片,但图片过大无法加载]'; } @@ -2006,7 +2066,7 @@ async function injectQuotedMessage( const mergedImages = quotedImage ? [...(existingImages || []), quotedImage] : existingImages; - return { prompt: newPrompt, images: mergedImages }; + return { prompt: newPrompt, images: mergedImages, savedImagePath: quotedSavedPath }; } } catch (err) { logger.warn({ err, rootId }, 'Failed to fetch rootId message for injection'); @@ -2119,6 +2179,7 @@ export async function executeClaudeTask( agentId: AgentId = 'dev', createTime?: string, messageType?: string, + currentImagePaths?: string[], ): Promise { // 1. 解析话题上下文(thread + workingDir + greeting) const resolved = await resolveThreadContext({ @@ -2203,6 +2264,9 @@ export async function executeClaudeTask( // resume 时通过 afterMsgId 去重,只注入上次交互后新增的消息 // 确保 dev-bot 能看到中间 @其他bot 的对话等未直接参与的消息 let effectivePrompt = promptWithTime; + // workspace 切换 restart 时多模态 images 不会重传,需用落盘路径作为文本 fallback + // 包含当前消息内图片 + 历史消息内图片,restart 时拼成提示注入新 prompt + const restartImagePaths: string[] = [...(currentImagePaths ?? [])]; if (!historySummaries) { // 收集所有自己管理的 bot open_id,用于历史消息差异化截断 const selfBotOpenIds = accountManager.getAllBotOpenIds(); @@ -2210,6 +2274,9 @@ export async function executeClaudeTask( const afterMsgId = activeConversationId ? _historyDedup.get(sessionKey) : undefined; const history = await buildChatHistoryContext(chatId, threadId, messageId, afterMsgId, selfBotOpenIds); + if (history.savedImagePaths?.length) { + restartImagePaths.push(...history.savedImagePaths); + } if (history.text) { effectivePrompt = history.text + '\n\n---\n\n' + promptWithTime; } @@ -2256,6 +2323,10 @@ export async function executeClaudeTask( const quoted = await injectQuotedMessage(effectivePrompt, rootId, messageId, chatId, images); effectivePrompt = quoted.prompt; images = quoted.images; + // 引用图片同样纳入工作区切换后落盘路径,避免 restart 丢失 + if (quoted.savedImagePath) { + restartImagePaths.push(quoted.savedImagePath); + } } // 构造逐条 turn 回调 @@ -2461,9 +2532,14 @@ export async function executeClaudeTask( // - 不传 onWorkspaceChanged(不触发二次 restart) // - disableWorkspaceTool: 完全移除 setup_workspace MCP tool,防止无限循环 // - 使用 effectivePrompt(含聊天历史)而非裸 prompt,避免 restart 后丢失对话上下文 + // - restart 不重传多模态 images:将 S1 落盘的图片路径作为文本附加,让 agent 用 Read 工具按需查看 + const restartImageHints = formatRestartImageHints(restartImagePaths); + const restartPromptWithImageHints = restartImageHints + ? `${restartImageHints}\n\n---\n\n${effectivePrompt}` + : effectivePrompt; const restartResult = await claudeExecutor.execute({ sessionKey, - prompt: effectivePrompt, + prompt: restartPromptWithImageHints, workingDir: result.newWorkingDir, readOnly, model: agentCfg?.model, @@ -2955,6 +3031,8 @@ interface HistoryResult { documents?: DocumentAttachment[]; /** 历史消息中提取的文本文件内容(已格式化,可拼入 prompt) */ fileTexts?: string[]; + /** 历史图片原图落盘路径,用于 workspace 切换后通过 Read 工具按需重新加载 */ + savedImagePaths?: string[]; } /** @@ -3204,6 +3282,8 @@ async function parseMessage(data: MessageEventData): Promise 0 ? { currentImagePaths } : {}), }; } diff --git a/src/feishu/tools/__tests__/index.test.ts b/src/feishu/tools/__tests__/index.test.ts index 297bef6..74643b4 100644 --- a/src/feishu/tools/__tests__/index.test.ts +++ b/src/feishu/tools/__tests__/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // vi.hoisted runs before vi.mock factories — safe to reference in factory -const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, mockChatTool, mockContactTool, mockTaskTool, mockCalendarTool, mockMainChatTool, mockMessageFileTool, mockCreateSdkMcpServer } = vi.hoisted(() => { +const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, mockChatTool, mockContactTool, mockTaskTool, mockCalendarTool, mockMainChatTool, mockMessageFileTool, mockMessageImageTool, mockCreateSdkMcpServer } = vi.hoisted(() => { const mockConfig = { feishu: { tools: { @@ -30,6 +30,7 @@ const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, m mockCalendarTool: vi.fn(() => ({ name: 'feishu_calendar' })), mockMainChatTool: vi.fn(() => ({ name: 'feishu_send_to_chat' })), mockMessageFileTool: vi.fn(() => ({ name: 'feishu_download_message_file' })), + mockMessageImageTool: vi.fn(() => ({ name: 'feishu_download_message_image' })), mockCreateSdkMcpServer: vi.fn((opts: unknown) => ({ ...(opts as object), type: 'mcp-server' })), }; }); @@ -46,6 +47,7 @@ vi.mock('../task.js', () => ({ feishuTaskTool: () => mockTaskTool() })); vi.mock('../calendar.js', () => ({ feishuCalendarTool: () => mockCalendarTool() })); vi.mock('../main-chat.js', () => ({ feishuMainChatTool: () => mockMainChatTool() })); vi.mock('../message.js', () => ({ feishuMessageFileTool: () => mockMessageFileTool() })); +vi.mock('../image.js', () => ({ feishuMessageImageTool: () => mockMessageImageTool() })); vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ createSdkMcpServer: (opts: unknown) => mockCreateSdkMcpServer(opts), })); @@ -72,8 +74,8 @@ describe('createFeishuToolsMcpServer', () => { expect(mockCreateSdkMcpServer).toHaveBeenCalledTimes(1); const call = mockCreateSdkMcpServer.mock.calls[0][0]; expect(call.name).toBe('feishu-tools'); - // 8 config-gated tools + 1 always-on message file tool = 9 - expect(call.tools).toHaveLength(9); + // 8 config-gated tools + 1 message file + 1 message image = 10 + expect(call.tools).toHaveLength(10); }); it('should include only enabled tools', () => { @@ -83,8 +85,8 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer(); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - // 6 config-gated + 1 always-on message file = 7 - expect(call.tools).toHaveLength(7); + // 6 config-gated + 1 message file + 1 message image = 8 + expect(call.tools).toHaveLength(8); expect(mockDocTool).toHaveBeenCalledTimes(1); expect(mockDriveTool).toHaveBeenCalledTimes(1); expect(mockChatTool).toHaveBeenCalledTimes(1); @@ -99,8 +101,8 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer(); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - // 7 config-gated + 1 always-on message file = 8 - expect(call.tools).toHaveLength(8); + // 7 config-gated + 1 message file + 1 message image = 9 + expect(call.tools).toHaveLength(9); expect(mockDocTool).toHaveBeenCalledTimes(1); expect(mockWikiTool).toHaveBeenCalledTimes(1); expect(mockDriveTool).toHaveBeenCalledTimes(1); @@ -114,8 +116,8 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer(undefined); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - // 8 config-gated + 1 message file = 9 (no main-chat without chatId) - expect(call.tools).toHaveLength(9); + // 8 config-gated + 1 message file + 1 message image = 10 (no main-chat without chatId) + expect(call.tools).toHaveLength(10); expect(mockChatTool).toHaveBeenCalledTimes(1); expect(mockMainChatTool).not.toHaveBeenCalled(); }); @@ -124,8 +126,8 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer('chat_123'); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - // 8 config-gated + 1 main-chat + 1 message file = 10 - expect(call.tools).toHaveLength(10); + // 8 config-gated + 1 main-chat + 1 message file + 1 message image = 11 + expect(call.tools).toHaveLength(11); expect(mockMainChatTool).toHaveBeenCalledTimes(1); }); @@ -135,7 +137,7 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer(); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - expect(call.tools).toHaveLength(8); + expect(call.tools).toHaveLength(9); expect(mockTaskTool).not.toHaveBeenCalled(); }); @@ -145,7 +147,7 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer(); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - expect(call.tools).toHaveLength(8); + expect(call.tools).toHaveLength(9); expect(mockContactTool).not.toHaveBeenCalled(); }); @@ -160,11 +162,12 @@ describe('createFeishuToolsMcpServer', () => { mockConfig.feishu.tools.calendar = false; const result = createFeishuToolsMcpServer(); - // message file tool is always present + // message file + message image tools are always present expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - expect(call.tools).toHaveLength(1); + expect(call.tools).toHaveLength(2); expect(mockMessageFileTool).toHaveBeenCalledTimes(1); + expect(mockMessageImageTool).toHaveBeenCalledTimes(1); }); it('should return server with config-gated tool + message file tool when only one sub-switch enabled', () => { @@ -180,8 +183,8 @@ describe('createFeishuToolsMcpServer', () => { const result = createFeishuToolsMcpServer(); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - // 1 config-gated + 1 always-on message file = 2 - expect(call.tools).toHaveLength(2); + // 1 config-gated + 1 message file + 1 message image = 3 + expect(call.tools).toHaveLength(3); expect(mockBitableTool).toHaveBeenCalledTimes(1); }); }); diff --git a/src/feishu/tools/image.ts b/src/feishu/tools/image.ts new file mode 100644 index 0000000..44b8e08 --- /dev/null +++ b/src/feishu/tools/image.ts @@ -0,0 +1,88 @@ +import { tool } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { feishuClient } from '../client.js'; +import { saveMessageFileToCache } from '../file-cache.js'; +import { logger } from '../../utils/logger.js'; + +const MAX_IMAGE_SIZE = 30 * 1024 * 1024; + +function detectImageExt(buf: Buffer): string { + if (buf[0] === 0xFF && buf[1] === 0xD8) return '.jpg'; + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png'; + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif'; + if ( + buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 + && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 + ) return '.webp'; + return '.png'; +} + +/** + * 飞书消息图片下载 MCP 工具 + * + * 配合父群图片 lazy loading 与工作区切换后的图片落盘策略, + * 让 agent 在历史上下文中看到图片提示时按需获取原图。 + */ +export function feishuMessageImageTool() { + return tool( + 'feishu_download_message_image', + [ + '下载飞书消息中的图片到本地,返回图片路径。', + '', + '当聊天历史上下文中出现 [群聊历史图片] 或 [历史聊天图片] 等提示时,', + '可以使用此工具按需下载该图片。下载后用 Read 工具查看图片内容。', + '', + '参数:', + '- message_id: 消息 ID(从历史上下文元数据中获取)', + '- image_key: 图片 Key(从历史上下文元数据中获取)', + ].join('\n'), + { + message_id: z.string().describe('飞书消息 ID(如 om_xxx)'), + image_key: z.string().describe('图片 Key(如 img_v3_xxx)'), + }, + async (args) => { + try { + const buf = await feishuClient.downloadMessageImage(args.message_id, args.image_key); + + if (buf.length > MAX_IMAGE_SIZE) { + return { + content: [{ + type: 'text' as const, + text: `图片过大 (${(buf.length / 1024 / 1024).toFixed(1)}MB),超过 ${MAX_IMAGE_SIZE / 1024 / 1024}MB 限制`, + }], + isError: true, + }; + } + + const ext = detectImageExt(buf); + const filePath = await saveMessageFileToCache(args.message_id, args.image_key, buf, `image${ext}`); + + logger.info( + { messageId: args.message_id, imageKey: args.image_key, sizeBytes: buf.length, filePath }, + 'Message image downloaded on-demand via MCP tool', + ); + + return { + content: [{ + type: 'text' as const, + text: `图片已下载到: ${filePath}\n大小: ${(buf.length / 1024).toFixed(1)}KB\n\n请使用 Read 工具查看该图片。`, + }], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ err: msg, messageId: args.message_id, imageKey: args.image_key }, 'feishu_download_message_image failed'); + return { + content: [{ type: 'text' as const, text: `下载图片失败: ${msg}` }], + isError: true, + }; + } + }, + { + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + }, + ); +} diff --git a/src/feishu/tools/index.ts b/src/feishu/tools/index.ts index 4501add..a717e95 100644 --- a/src/feishu/tools/index.ts +++ b/src/feishu/tools/index.ts @@ -10,6 +10,7 @@ import { feishuContactTool } from './contact.js'; import { feishuCalendarTool } from './calendar.js'; import { feishuMainChatTool } from './main-chat.js'; import { feishuMessageFileTool } from './message.js'; +import { feishuMessageImageTool } from './image.js'; import { getValidUserToken } from '../oauth.js'; /** @@ -39,6 +40,8 @@ export function createFeishuToolsMcpServer(chatId?: string, userId?: string) { // 消息文件按需下载工具:配合 lazy loading,agent 可按需获取历史消息中的文件 tools.push(feishuMessageFileTool()); + // 消息图片按需下载工具:父群历史图片 / 工作区切换后落盘图片皆通过此工具按需读取 + tools.push(feishuMessageImageTool()); return createSdkMcpServer({ name: 'feishu-tools', diff --git a/src/session/queue.ts b/src/session/queue.ts index bc01991..49e1014 100644 --- a/src/session/queue.ts +++ b/src/session/queue.ts @@ -30,6 +30,7 @@ export class TaskQueue { forceThread?: boolean, messageType?: string, accountId?: string, + currentImagePaths?: string[], ): Promise { return new Promise((resolve, reject) => { const task: QueueTask = { @@ -46,6 +47,7 @@ export class TaskQueue { createTime, forceThread, accountId, + currentImagePaths, resolve, reject, createdAt: new Date(), diff --git a/src/session/types.ts b/src/session/types.ts index 373c1d3..29d4e1c 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -104,6 +104,8 @@ export interface QueueTask { createTime?: string; /** 强制使用话题模式(/t 命令触发) */ forceThread?: boolean; + /** 当前消息中图片已落盘的本地路径,用于工作区切换重启时透传给 agent */ + currentImagePaths?: string[]; resolve: (result: string) => void; reject: (error: Error) => void; createdAt: Date;