From 3c7d7465e4d43743f2e67c9fe8d521b79ec77f67 Mon Sep 17 00:00:00 2001 From: unclee Date: Tue, 10 Mar 2026 12:37:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20feishu=5Fdoc=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E6=88=AA=E6=96=AD=20+=20read=5Fblocks=20=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read action: 超过 2000 行自动截断,附带文档目录和 read_blocks 提示 (阈值与 Claude Code 的 Read 工具一致) - 新增 read_blocks action: 按 block_id 读取指定 block 及其子块内容, 支持逗号分隔多个 block_id - list_blocks 优化: 返回精简 tab 分隔目录 (block_id/type/preview), 替代原始 JSON dump,大幅降低 token 消耗 - 工具描述注入高效读取工作流指引,引导 agent 按需读取 Co-Authored-By: Claude Opus 4.6 --- src/feishu/tools/__tests__/doc.test.ts | 185 +++++++++++++++++++++++- src/feishu/tools/doc.ts | 193 +++++++++++++++++++++++-- 2 files changed, 365 insertions(+), 13 deletions(-) diff --git a/src/feishu/tools/__tests__/doc.test.ts b/src/feishu/tools/__tests__/doc.test.ts index 9a422ff7..f7eba582 100644 --- a/src/feishu/tools/__tests__/doc.test.ts +++ b/src/feishu/tools/__tests__/doc.test.ts @@ -59,7 +59,7 @@ beforeEach(() => { describe('feishu_doc tool', () => { describe('read action', () => { - it('should return document content', async () => { + it('should return document content when within line limit', async () => { mockDocxDocumentRawContent.mockResolvedValue({ code: 0, data: { content: '文档内容' }, @@ -72,6 +72,75 @@ describe('feishu_doc tool', () => { }); }); + it('should return empty doc marker for empty content', async () => { + mockDocxDocumentRawContent.mockResolvedValue({ + code: 0, + data: { content: '' }, + }); + const result = await capturedHandler({ action: 'read', doc_token: 'ABC123' }); + expect(result.content[0].text).toBe('(空文档)'); + }); + + it('should auto-truncate documents exceeding 2000 lines', async () => { + // Generate 2500 lines of content + const lines = Array.from({ length: 2500 }, (_, i) => `line ${i + 1}`); + const fullContent = lines.join('\n'); + mockDocxDocumentRawContent.mockResolvedValue({ + code: 0, + data: { content: fullContent }, + }); + // Mock list_blocks for structure summary (no headings) + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { items: [{ block_id: 'page_1', block_type: 1 }] }, + }); + + const result = await capturedHandler({ action: 'read', doc_token: 'ABC123' }); + const text = result.content[0].text; + // Should contain first 2000 lines + expect(text).toContain('line 1'); + expect(text).toContain('line 2000'); + // Should NOT contain line 2001+ in the main content + expect(text).toContain('文档已截断'); + expect(text).toContain('2500 行'); + expect(text).toContain('read_blocks'); + }); + + it('should include heading structure in truncated output', async () => { + const lines = Array.from({ length: 2500 }, (_, i) => `line ${i + 1}`); + mockDocxDocumentRawContent.mockResolvedValue({ + code: 0, + data: { content: lines.join('\n') }, + }); + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { + items: [ + { block_id: 'page_1', block_type: 1 }, + { block_id: 'h1_1', block_type: 3, heading1: { elements: [{ text_run: { content: '第一章' } }] } }, + { block_id: 'h2_1', block_type: 4, heading2: { elements: [{ text_run: { content: '第二节' } }] } }, + ], + }, + }); + + const result = await capturedHandler({ action: 'read', doc_token: 'ABC123' }); + const text = result.content[0].text; + expect(text).toContain('目录'); + expect(text).toContain('第一章'); + expect(text).toContain('第二节'); + expect(text).toContain('h1_1'); + }); + + it('should not truncate documents with exactly 2000 lines', async () => { + const lines = Array.from({ length: 2000 }, (_, i) => `line ${i + 1}`); + mockDocxDocumentRawContent.mockResolvedValue({ + code: 0, + data: { content: lines.join('\n') }, + }); + const result = await capturedHandler({ action: 'read', doc_token: 'ABC123' }); + expect(result.content[0].text).not.toContain('文档已截断'); + }); + it('should return error when doc_token is missing', async () => { const result = await capturedHandler({ action: 'read' }); expect(result.isError).toBe(true); @@ -141,13 +210,121 @@ describe('feishu_doc tool', () => { }); describe('list_blocks action', () => { - it('should return block list', async () => { + it('should return concise block structure (tab-separated)', async () => { mockDocxDocumentBlockList.mockResolvedValue({ code: 0, - data: { items: [{ block_id: 'b1', block_type: 1 }] }, + data: { + items: [ + { block_id: 'page_1', block_type: 1 }, + { block_id: 'h1_1', block_type: 3, heading1: { elements: [{ text_run: { content: '标题内容' } }] } }, + { block_id: 'txt_1', block_type: 2, text: { elements: [{ text_run: { content: '正文段落' } }] } }, + ], + }, }); const result = await capturedHandler({ action: 'list_blocks', doc_token: 'ABC123' }); - expect(result.content[0].text).toContain('b1'); + const text = result.content[0].text; + // Should NOT contain raw JSON + expect(text).not.toContain('{'); + // Should contain header + expect(text).toContain('block_id\ttype\tpreview'); + expect(text).toContain('共 2 blocks'); // page block is skipped + // Should contain block info + expect(text).toContain('h1_1\theading1\t标题内容'); + expect(text).toContain('txt_1\ttext\t正文段落'); + }); + + it('should truncate long text in preview', async () => { + const longText = 'A'.repeat(100); + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { + items: [ + { block_id: 'page_1', block_type: 1 }, + { block_id: 'txt_1', block_type: 2, text: { elements: [{ text_run: { content: longText } }] } }, + ], + }, + }); + const result = await capturedHandler({ action: 'list_blocks', doc_token: 'ABC123' }); + const text = result.content[0].text; + // Should truncate to 80 chars + ellipsis + expect(text).toContain('A'.repeat(80) + '…'); + expect(text).not.toContain('A'.repeat(100)); + }); + }); + + describe('read_blocks action', () => { + it('should read a single block by id', async () => { + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { + items: [ + { block_id: 'page_1', block_type: 1, children: ['h1_1', 'txt_1'] }, + { block_id: 'h1_1', block_type: 3, heading1: { elements: [{ text_run: { content: '标题' } }] } }, + { block_id: 'txt_1', block_type: 2, text: { elements: [{ text_run: { content: '正文' } }] } }, + ], + }, + }); + const result = await capturedHandler({ action: 'read_blocks', doc_token: 'ABC123', block_id: 'h1_1' }); + const text = result.content[0].text; + expect(text).toContain('h1_1'); + expect(text).toContain('标题'); + }); + + it('should read multiple blocks by comma-separated ids', async () => { + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { + items: [ + { block_id: 'page_1', block_type: 1, children: ['h1_1', 'txt_1'] }, + { block_id: 'h1_1', block_type: 3, heading1: { elements: [{ text_run: { content: '标题A' } }] } }, + { block_id: 'txt_1', block_type: 2, text: { elements: [{ text_run: { content: '段落B' } }] } }, + ], + }, + }); + const result = await capturedHandler({ action: 'read_blocks', doc_token: 'ABC123', block_id: 'h1_1,txt_1' }); + const text = result.content[0].text; + expect(text).toContain('标题A'); + expect(text).toContain('段落B'); + }); + + it('should include children blocks recursively', async () => { + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { + items: [ + { block_id: 'page_1', block_type: 1, children: ['container_1'] }, + { block_id: 'container_1', block_type: 34, children: ['child_1', 'child_2'] }, + { block_id: 'child_1', block_type: 2, text: { elements: [{ text_run: { content: '子内容1' } }] } }, + { block_id: 'child_2', block_type: 2, text: { elements: [{ text_run: { content: '子内容2' } }] } }, + ], + }, + }); + const result = await capturedHandler({ action: 'read_blocks', doc_token: 'ABC123', block_id: 'container_1' }); + const text = result.content[0].text; + expect(text).toContain('3 blocks'); // container + 2 children + expect(text).toContain('子内容1'); + expect(text).toContain('子内容2'); + }); + + it('should report not found for invalid block id', async () => { + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { items: [{ block_id: 'page_1', block_type: 1 }] }, + }); + const result = await capturedHandler({ action: 'read_blocks', doc_token: 'ABC123', block_id: 'nonexistent' }); + expect(result.content[0].text).toContain('未找到'); + }); + + it('should return error when block_id is missing', async () => { + const result = await capturedHandler({ action: 'read_blocks', doc_token: 'ABC123' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('block_id'); + }); + + it('should return error when doc_token is missing', async () => { + const result = await capturedHandler({ action: 'read_blocks', block_id: 'b1' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('doc_token'); }); }); diff --git a/src/feishu/tools/doc.ts b/src/feishu/tools/doc.ts index 5c84479c..93e0d9ad 100644 --- a/src/feishu/tools/doc.ts +++ b/src/feishu/tools/doc.ts @@ -6,10 +6,62 @@ import { validateToken } from './validation.js'; import { grantOwnerPermission, grantChatMembersPermission } from './permissions.js'; import { markdownToBlocks, batchBlocks, parseInlineMarkdown } from './markdown-to-blocks.js'; +/** Max lines returned by read before truncation (matches Claude Code's Read tool default) */ +const READ_LINE_LIMIT = 2000; + +/** Block type ID → human-readable name */ +const BLOCK_TYPE_NAMES: Record = { + 1: 'page', 2: 'text', 3: 'heading1', 4: 'heading2', 5: 'heading3', + 6: 'heading4', 7: 'heading5', 8: 'heading6', 9: 'heading7', + 10: 'heading8', 11: 'heading9', 12: 'bullet', 13: 'ordered', + 14: 'code', 15: 'quote', 16: 'todo', 17: 'bitable', 18: 'callout', + 19: 'chat_card', 20: 'diagram', 21: 'divider', 22: 'file', + 23: 'grid', 24: 'grid_column', 25: 'iframe', 26: 'image', + 27: 'isv', 28: 'mindnote', 29: 'sheet', 30: 'table', + 31: 'table_cell', 32: 'view', 33: 'undefined', 999: 'virtual_merge', + 34: 'quote_container', 40: 'task', 41: 'okr', + 42: 'okr_objective', 43: 'okr_key_result', 44: 'okr_progress', + 46: 'add_ons', 48: 'jira_issue', 49: 'wiki_catalog', + 51: 'board', 52: 'agenda', 53: 'agenda_item', + 54: 'agenda_item_content', +}; + +/** + * Extract plain text from a single block's rich text elements. + */ +function extractBlockText(block: Record): string { + // Different block types store text in different properties + const textContainers = ['text', 'heading1', 'heading2', 'heading3', 'heading4', + 'heading5', 'heading6', 'heading7', 'heading8', 'heading9', + 'bullet', 'ordered', 'quote', 'todo', 'callout']; + for (const key of textContainers) { + const container = block[key] as Record | undefined; + if (container?.elements && Array.isArray(container.elements)) { + return (container.elements as Array>) + .map((el) => { + const run = el.text_run as Record | undefined; + return run?.content as string ?? ''; + }) + .join(''); + } + } + // Code block + const code = block.code as Record | undefined; + if (code?.elements && Array.isArray(code.elements)) { + return (code.elements as Array>) + .map((el) => { + const run = el.text_run as Record | undefined; + return run?.content as string ?? ''; + }) + .join(''); + } + return ''; +} + /** * 飞书文档 MCP 工具 * - * 支持操作: read / write / append / create / list_blocks / update_block / insert_blocks / delete_blocks + * 支持操作: read / write / append / create / list_blocks / read_blocks / update_block / insert_blocks / delete_blocks */ export function feishuDocTool(chatId?: string) { return tool( @@ -18,28 +70,30 @@ export function feishuDocTool(chatId?: string) { '读写飞书文档 (Docx)。', '', 'Actions:', - '- read: 读取文档纯文本内容', + '- read: 读取文档纯文本内容 (超过 2000 行自动截断,返回前 2000 行 + 文档结构摘要)', '- write: 覆盖写入文档 (清空后写入 Markdown,自动转换为飞书富文本格式)', '- append: 在文档末尾追加内容 (支持 Markdown 格式)', '- create: 创建新文档 (可同时传 content 写入内容,避免创建空文档)', - '- list_blocks: 列出文档的 block 结构', + '- list_blocks: 列出文档的 block 结构 (返回精简目录: block_id、类型、文本摘要)', + '- read_blocks: 读取指定 block 的完整文本内容 (传入 block_id,逗号分隔可读多个)', '- update_block: 更新指定 block 的文本内容 (仅支持行内 Markdown: 加粗/斜体/删除线/行内代码/链接,不支持标题/列表等块级语法。需要 block_id,通过 list_blocks 获取)', '- insert_blocks: 在指定位置插入新 block (需要 block_id 作为父 block,index 指定位置)', '- delete_blocks: 删除指定 block (需要 block_id)', '', 'write/append/insert_blocks 支持的 Markdown 语法: 标题(#)、加粗(**)、斜体(*)、删除线(~~)、行内代码(`)、链接、无序列表(-)、有序列表(1.)、代码块(```)、待办(- [ ])、分隔线(---)。', '', + '读取文档的推荐流程: read (自动截断大文档) → 如需查看被截断部分,用 list_blocks 定位 → read_blocks 按需读取', '编辑他人文档的推荐流程: list_blocks → 找到目标 block_id → update_block/insert_blocks/delete_blocks', '', 'URL Token 提取: /docx/ABC123 → doc_token: ABC123', ].join('\n'), { - action: z.enum(['read', 'write', 'append', 'create', 'list_blocks', 'update_block', 'insert_blocks', 'delete_blocks']).describe('操作类型'), - doc_token: z.string().optional().describe('文档 token (read/write/append/list_blocks/update_block/insert_blocks/delete_blocks 时必填)'), + action: z.enum(['read', 'write', 'append', 'create', 'list_blocks', 'read_blocks', 'update_block', 'insert_blocks', 'delete_blocks']).describe('操作类型'), + doc_token: z.string().optional().describe('文档 token (read/write/append/list_blocks/read_blocks/update_block/insert_blocks/delete_blocks 时必填)'), content: z.string().optional().describe('Markdown 内容 (write/append/update_block/insert_blocks 时必填;create 时可选,传入则创建后自动写入,避免空文档)'), title: z.string().optional().describe('新文档标题 (create 时必填)'), folder_token: z.string().optional().describe('目标文件夹 token (create 时可选)'), - block_id: z.string().optional().describe('目标 block ID (update_block/insert_blocks/delete_blocks 时必填,通过 list_blocks 获取)'), + block_id: z.string().optional().describe('目标 block ID (read_blocks 时支持逗号分隔多个; update_block/insert_blocks/delete_blocks 时必填)'), index: z.number().int().min(0).optional().describe('插入位置索引 (insert_blocks 时可选,0-based,不指定则追加到父 block 末尾)'), }, async (args) => { @@ -47,7 +101,13 @@ export function feishuDocTool(chatId?: string) { try { if (args.doc_token) validateToken(args.doc_token, 'doc_token'); if (args.folder_token) validateToken(args.folder_token, 'folder_token'); - if (args.block_id) validateToken(args.block_id, 'block_id'); + if (args.block_id) { + // read_blocks supports comma-separated block IDs — validate each individually + const ids = args.action === 'read_blocks' + ? args.block_id.split(',').map((id) => id.trim()).filter(Boolean) + : [args.block_id]; + for (const id of ids) validateToken(id, 'block_id'); + } switch (args.action) { case 'read': { @@ -57,7 +117,52 @@ export function feishuDocTool(chatId?: string) { params: { lang: 0 }, }); if (resp.code !== 0) throw new Error(`API 错误 (${resp.code}): ${resp.msg}`); - return { content: [{ type: 'text' as const, text: resp.data?.content ?? '(空文档)' }] }; + const fullText = resp.data?.content ?? ''; + if (!fullText) return { content: [{ type: 'text' as const, text: '(空文档)' }] }; + + const lines = fullText.split('\n'); + if (lines.length <= READ_LINE_LIMIT) { + return { content: [{ type: 'text' as const, text: fullText }] }; + } + + // Document exceeds line limit — truncate and append structure summary + const truncated = lines.slice(0, READ_LINE_LIMIT).join('\n'); + const totalChars = fullText.length; + const returnedChars = truncated.length; + + // Fetch block structure for the summary + let structureSummary = ''; + try { + const blocksResp = await client.docx.documentBlock.list({ + path: { document_id: args.doc_token }, + params: { page_size: 500 }, + }); + if (blocksResp.code === 0 && blocksResp.data?.items) { + const headings = (blocksResp.data.items as Array>) + .filter((b) => { + const t = b.block_type as number; + return t >= 3 && t <= 11; // heading1-9 + }) + .map((b) => { + const level = (b.block_type as number) - 2; + const text = extractBlockText(b); + return `${' '.repeat(level - 1)}- ${text || '(无标题)'} [${b.block_id}]`; + }); + if (headings.length > 0) { + structureSummary = '\n\n目录:\n' + headings.join('\n'); + } + } + } catch { + // Structure summary is best-effort, don't fail the read + } + + const hint = [ + `\n\n--- 文档已截断 ---`, + `已返回前 ${READ_LINE_LIMIT} 行 (${returnedChars} 字符),共 ${lines.length} 行 (${totalChars} 字符)。`, + `如需查看完整内容,使用 list_blocks 查看结构,再用 read_blocks 按需读取指定段落。`, + ].join('\n'); + + return { content: [{ type: 'text' as const, text: truncated + hint + structureSummary }] }; } case 'write': { @@ -171,14 +276,84 @@ export function feishuDocTool(chatId?: string) { params: { page_size: 500 }, }); if (blocksResp.code !== 0) throw new Error(`API 错误 (${blocksResp.code}): ${blocksResp.msg}`); + const items = (blocksResp.data?.items ?? []) as Array>; + + // Build concise structure: block_id | type | text preview + const lines = items + .filter((b) => (b.block_type as number) !== 1) // skip page block + .map((b) => { + const typeId = b.block_type as number; + const typeName = BLOCK_TYPE_NAMES[typeId] ?? `type_${typeId}`; + const text = extractBlockText(b); + const preview = text.length > 80 ? text.slice(0, 80) + '…' : text; + return `${b.block_id}\t${typeName}\t${preview}`; + }); + + const header = `block_id\ttype\tpreview (共 ${lines.length} blocks)`; return { content: [{ type: 'text' as const, - text: JSON.stringify(blocksResp.data?.items ?? [], null, 2), + text: [header, ...lines].join('\n'), }], }; } + case 'read_blocks': { + if (!args.doc_token) throw new Error('read_blocks 操作需要 doc_token'); + if (!args.block_id) throw new Error('read_blocks 操作需要 block_id (逗号分隔可读多个)'); + const targetIds = args.block_id.split(',').map((id) => id.trim()).filter(Boolean); + if (targetIds.length === 0) throw new Error('block_id 不能为空'); + + // Fetch all blocks and filter + const allBlocks = await client.docx.documentBlock.list({ + path: { document_id: args.doc_token }, + params: { page_size: 500 }, + }); + if (allBlocks.code !== 0) throw new Error(`API 错误 (${allBlocks.code}): ${allBlocks.msg}`); + + const blockMap = new Map>(); + for (const b of (allBlocks.data?.items ?? []) as Array>) { + if (b.block_id) blockMap.set(b.block_id as string, b); + } + + // Collect target blocks and their descendants + const collectDescendants = (blockId: string): string[] => { + const block = blockMap.get(blockId); + if (!block) return []; + const result = [blockId]; + const children = block.children as string[] | undefined; + if (children) { + for (const childId of children) { + result.push(...collectDescendants(childId)); + } + } + return result; + }; + + const results: string[] = []; + for (const targetId of targetIds) { + const allIds = collectDescendants(targetId); + if (allIds.length === 0) { + results.push(`[${targetId}] (未找到)`); + continue; + } + const blockTexts: string[] = []; + for (const id of allIds) { + const block = blockMap.get(id); + if (!block) continue; + const typeId = block.block_type as number; + const typeName = BLOCK_TYPE_NAMES[typeId] ?? `type_${typeId}`; + const text = extractBlockText(block); + if (text || typeId === 21 /* divider */) { + blockTexts.push(`[${typeName}] ${text}`); + } + } + results.push(`--- ${targetId} (${allIds.length} blocks) ---\n${blockTexts.join('\n')}`); + } + + return { content: [{ type: 'text' as const, text: results.join('\n\n') }] }; + } + case 'update_block': { if (!args.doc_token) throw new Error('update_block 操作需要 doc_token'); if (!args.block_id) throw new Error('update_block 操作需要 block_id');