From cdb4155771a2775d08e4db0d734bf0ea350c0577 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Sat, 14 Mar 2026 11:30:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Feishu=20chat=20bridge=20=E2=80=94=20st?= =?UTF-8?q?rip=20MCP=20residuals,=20enhance=20streaming=20cards,=20add=20t?= =?UTF-8?q?ool=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Strip Feishu MCP residuals - Remove 'feishu' server entry from .mcp.json - Remove @codepilot/feishu-mcp and packages/feishu-mcp from package-lock.json - General MCP infrastructure (McpManager, /api/plugins/mcp) preserved Phase 2: Streaming card improvements - card-controller.ts: Add thinking state (πŸ’­ Thinking...), tool call progress display (πŸ”„/βœ…/❌), better finalize with status footer (βœ…/⚠️/❌ + elapsed time), markdown optimization on final card, proper sequence numbering for setStreamingMode - conversation-engine.ts: Add OnToolEvent callback type, fire tool_use/tool_result events during stream consumption - bridge-manager.ts: Wire tool event tracking to card controller, pass proper status (completed/interrupted/error) to finalize, render errors in card when streaming Phase 3: Typing indicator & UX - Change reaction emoji from 'OnIt' to 'Typing' (OpenClaw convention) - Improve /help and /start command output with grouped sections - Export optimizeMarkdown() for reuse in card-controller types.ts: - Add ToolCallInfo interface for card tool progress - Add updateToolCalls?() and setThinking?() to CardStreamController Tests: 374 pass, 0 fail TypeScript: clean (npx tsc --noEmit) Co-Authored-By: Claude Opus 4.6 --- .mcp.json | 10 -- package-lock.json | 159 ------------------ src/lib/bridge/bridge-manager.ts | 81 +++++---- src/lib/bridge/conversation-engine.ts | 16 +- src/lib/channels/feishu/card-controller.ts | 181 +++++++++++++++------ src/lib/channels/feishu/index.ts | 2 +- src/lib/channels/feishu/outbound.ts | 2 +- src/lib/channels/types.ts | 13 ++ 8 files changed, 211 insertions(+), 253 deletions(-) diff --git a/.mcp.json b/.mcp.json index 93bc19c..ec1ba23 100644 --- a/.mcp.json +++ b/.mcp.json @@ -8,16 +8,6 @@ "chrome-devtools-mcp@latest" ], "env": {} - }, - "feishu": { - "command": "node", - "args": ["packages/feishu-mcp/dist/index.js"], - "env": { - "FEISHU_APP_ID": "${bridge_feishu_app_id}", - "FEISHU_APP_SECRET": "${bridge_feishu_app_secret}", - "FEISHU_DOMAIN": "${bridge_feishu_domain}", - "FEISHU_USER_ACCESS_TOKEN": "${bridge_feishu_user_token}" - } } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 16e3220..6110782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2132,10 +2132,6 @@ "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", "license": "Apache-2.0" }, - "node_modules/@codepilot/feishu-mcp": { - "resolved": "packages/feishu-mcp", - "link": true - }, "node_modules/@codepilot/site": { "resolved": "apps/site", "link": true @@ -27215,161 +27211,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "packages/feishu-mcp": { - "name": "@codepilot/feishu-mcp", - "version": "0.1.0", - "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" - }, - "bin": { - "feishu-mcp": "dist/index.js" - }, - "devDependencies": { - "tsup": "^8.0.0", - "tsx": "^4.0.0" - } - }, - "packages/feishu-mcp/node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "packages/feishu-mcp/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "packages/feishu-mcp/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "packages/feishu-mcp/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "packages/feishu-mcp/node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "packages/feishu-mcp/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index 5d632c2..6398b80 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -540,6 +540,8 @@ async function handleMessage( let cardCreating = false; let cardBufferedText = ''; let cardFinalized = false; + /** Track tool calls for card progress display */ + const cardToolCalls: import('../channels/types').ToolCallInfo[] = []; if (!previewState && adapter.getCardStreamController) { cardController = adapter.getCardStreamController(); @@ -619,6 +621,23 @@ async function handleMessage( }; } + // Build onToolEvent callback for card tool progress + let onToolEvent: ((event: any) => void) | undefined; + if (cardController) { + onToolEvent = (event: any) => { + if (event.type === 'tool_use') { + cardToolCalls.push({ id: event.id, name: event.name, status: 'running' }); + } else if (event.type === 'tool_result') { + const tc = cardToolCalls.find((t) => t.id === event.tool_use_id); + if (tc) tc.status = event.is_error ? 'error' : 'complete'; + } + // Update card display if we have a message ID + if (cardMessageId && cardController?.updateToolCalls) { + cardController.updateToolCalls(cardMessageId, cardToolCalls); + } + }; + } + try { // Pass permission callback so requests are forwarded to IM immediately // during streaming (the stream blocks until permission is resolved). @@ -636,25 +655,31 @@ async function handleMessage( perm.suggestions, msg.messageId, ); - }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText); + }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText, onToolEvent); // Send response text β€” render via channel-appropriate format if (result.responseText) { if (cardController && cardMessageId) { // Finalize streaming card with final content - await cardController.finalize(cardMessageId, result.responseText); + const finalStatus = result.hasError ? 'error' : 'completed'; + await cardController.finalize(cardMessageId, result.responseText, finalStatus); cardFinalized = true; } else { await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId); } } else if (result.hasError) { - const errorResponse: OutboundMessage = { - address: msg.address, - text: `Error: ${escapeHtml(result.errorMessage)}`, - parseMode: 'HTML', - replyToMessageId: msg.messageId, - }; - await deliver(adapter, errorResponse); + if (cardController && cardMessageId) { + await cardController.finalize(cardMessageId, `❌ Error: ${result.errorMessage}`, 'error'); + cardFinalized = true; + } else { + const errorResponse: OutboundMessage = { + address: msg.address, + text: `Error: ${escapeHtml(result.errorMessage)}`, + parseMode: 'HTML', + replyToMessageId: msg.messageId, + }; + await deliver(adapter, errorResponse); + } } // Persist the actual SDK session ID for future resume. @@ -682,7 +707,7 @@ async function handleMessage( // Clean up card streaming state if (cardController && cardMessageId && !cardFinalized) { - cardController.finalize(cardMessageId, '⚠️ Response interrupted.').catch(() => {}); + cardController.finalize(cardMessageId, '⚠️ Response interrupted.', 'interrupted').catch(() => {}); } state.activeTasks.delete(binding.codepilotSessionId); @@ -735,20 +760,7 @@ async function handleCommand( 'CodePilot Bridge', '', 'Send any message to interact with Claude.', - '', - 'Commands:', - '/new [path] - Start new session', - '/bind <session_id> - Bind to existing session', - '/cwd /path - Change working directory', - '/mode plan|code|ask - Change mode', - '/status - Show current status', - '/sessions - List recent sessions', - '/stop - Stop current session', - '/perm allow|allow_session|deny <id> - Respond to permission', - '/history [count] - Show recent messages', - '/search <keyword> - Search messages', - '/feishu <subcommand> - Feishu bridge management', - '/help - Show this help', + 'Type /help for available commands.', ].join('\n'); break; @@ -1109,18 +1121,23 @@ async function handleCommand( response = [ 'CodePilot Bridge Commands', '', - '/new [path] - Start new session', + 'Session:', + '/new [path] - Create new session (optional: specify CWD)', + '/cwd /path - Change CWD, reset context', '/bind <session_id> - Bind to existing session', - '/cwd /path - Change working directory', '/mode plan|code|ask - Change mode', - '/status - Show current status', + '/status - Show session / CWD / mode / model', '/sessions - List recent sessions', - '/stop - Stop current session', - '/perm allow|allow_session|deny <id> - Respond to permission request', + '/stop - Stop current task', + '', + 'Messages:', '/history [count] - Show recent messages', - '/search <keyword> - Search messages', - '/feishu <subcommand> - Feishu bridge management', - '/help - Show this help', + '/search <keyword> - Search in current chat', + '/perm allow|deny <id> - Permission response', + '', + 'Feishu:', + '/feishu doctor - Run diagnostics', + '/feishu auth - View auth status', ].join('\n'); break; diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index 689a06c..005b31e 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -83,6 +83,12 @@ export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise */ export type OnPartialText = (fullText: string) => void; +/** + * Callback invoked on tool_use / tool_result SSE events. + * Used by card streaming to show tool progress indicators. + */ +export type OnToolEvent = (event: { type: 'tool_use'; id: string; name: string } | { type: 'tool_result'; tool_use_id: string; is_error: boolean }) => void; + export interface ConversationResult { responseText: string; tokenUsage: TokenUsage | null; @@ -105,6 +111,7 @@ export async function processMessage( abortSignal?: AbortSignal, files?: FileAttachment[], onPartialText?: OnPartialText, + onToolEvent?: OnToolEvent, ): Promise { const sessionId = binding.codepilotSessionId; @@ -232,7 +239,7 @@ export async function processMessage( // Consume the stream server-side (replicate collectStreamResponse pattern). // Permission requests are forwarded immediately via the callback during streaming // because the stream blocks until permission is resolved β€” we can't wait until after. - return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText); + return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText, onToolEvent); } finally { clearInterval(renewalInterval); releaseSessionLock(sessionId, lockId); @@ -249,6 +256,7 @@ async function consumeStream( sessionId: string, onPermissionRequest?: OnPermissionRequest, onPartialText?: OnPartialText, + onToolEvent?: OnToolEvent, ): Promise { const reader = stream.getReader(); const contentBlocks: MessageContentBlock[] = []; @@ -300,6 +308,9 @@ async function consumeStream( name: toolData.name, input: toolData.input, }); + if (onToolEvent) { + try { onToolEvent({ type: 'tool_use', id: toolData.id, name: toolData.name }); } catch { /* non-critical */ } + } } catch { /* skip */ } break; } @@ -322,6 +333,9 @@ async function consumeStream( seenToolResultIds.add(resultData.tool_use_id); contentBlocks.push(newBlock); } + if (onToolEvent) { + try { onToolEvent({ type: 'tool_result', tool_use_id: resultData.tool_use_id, is_error: resultData.is_error || false }); } catch { /* non-critical */ } + } } catch { /* skip */ } break; } diff --git a/src/lib/channels/feishu/card-controller.ts b/src/lib/channels/feishu/card-controller.ts index 9cedb8d..46a6107 100644 --- a/src/lib/channels/feishu/card-controller.ts +++ b/src/lib/channels/feishu/card-controller.ts @@ -1,22 +1,21 @@ /** - * CodePilot Simplified Card Streaming + * Feishu Card Streaming Controller * * Manages streaming card lifecycle via CardKit v2 API. - * State machine: idle β†’ creating β†’ streaming β†’ completed | error + * State machine: idle β†’ creating β†’ streaming β†’ completed | interrupted | error * - * Differences from OpenClaw's StreamingCardController: - * - No reasoning phase streaming (thinking indicator) - * - No FlushController / UnavailableGuard - * - No IM patch fallback (if CardKit fails, falls back to static text) - * - No reply boundary detection - * - Footer is simplified (just elapsed time and status text, no animated status) - * - * These are intentional simplifications for CodePilot's use case. + * Features: + * - Thinking state display (πŸ’­ Thinking...) + * - Streaming text with throttled updates + * - Tool call progress indicators (πŸ”„/βœ…/❌) + * - Final card with status footer and elapsed time + * - Markdown optimization for Feishu rendering */ import type * as lark from '@larksuiteoapi/node-sdk'; -import type { CardStreamController } from '../types'; +import type { CardStreamController, ToolCallInfo } from '../types'; import type { CardStreamConfig } from './types'; +import { optimizeMarkdown } from './outbound'; const LOG_TAG = '[card-controller]'; @@ -28,6 +27,30 @@ interface CardState { startTime: number; throttleTimer: ReturnType | null; pendingText: string | null; + /** Current tool calls being tracked */ + toolCalls: ToolCallInfo[]; + /** Whether we're in thinking state (before text starts flowing) */ + thinking: boolean; +} + +/** Format elapsed time for footer display */ +function formatElapsed(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const sec = ms / 1000; + if (sec < 60) return `${sec.toFixed(1)}s`; + const min = Math.floor(sec / 60); + const remSec = Math.floor(sec % 60); + return `${min}m ${remSec}s`; +} + +/** Build tool progress lines for card display */ +function buildToolProgressMarkdown(tools: ToolCallInfo[]): string { + if (tools.length === 0) return ''; + const lines = tools.map((tc) => { + const icon = tc.status === 'running' ? 'πŸ”„' : tc.status === 'complete' ? 'βœ…' : '❌'; + return `${icon} \`${tc.name}\``; + }); + return lines.join('\n'); } class FeishuCardStreamController implements CardStreamController { @@ -45,11 +68,17 @@ class FeishuCardStreamController implements CardStreamController { // 1. Create streaming card via CardKit v2 const cardBody = { schema: '2.0', - config: { streaming_mode: true, wide_screen_mode: true }, + config: { + streaming_mode: true, + wide_screen_mode: true, + summary: { content: '思考中...' }, + }, body: { elements: [{ tag: 'markdown', - content: initialText || '...', + content: initialText || 'πŸ’­ Thinking...', + text_align: 'left', + text_size: 'normal', element_id: 'streaming_content', }], }, @@ -63,7 +92,6 @@ class FeishuCardStreamController implements CardStreamController { console.error(LOG_TAG, 'Card create returned no card_id'); return ''; } - console.log(LOG_TAG, 'Created card:', cardId); // 2. Send card as IM message const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } }); @@ -80,7 +108,6 @@ class FeishuCardStreamController implements CardStreamController { }); } const messageId = msgResp?.data?.message_id || ''; - console.log(LOG_TAG, 'Sent card message:', messageId); this.cards.set(messageId, { cardId, @@ -90,6 +117,8 @@ class FeishuCardStreamController implements CardStreamController { startTime: Date.now(), throttleTimer: null, pendingText: null, + toolCalls: [], + thinking: !initialText, }); return messageId; @@ -103,6 +132,11 @@ class FeishuCardStreamController implements CardStreamController { const state = this.cards.get(messageId); if (!state) return 'fail'; + // Clear thinking state once text starts flowing + if (state.thinking && text.trim()) { + state.thinking = false; + } + state.pendingText = text; const elapsed = Date.now() - state.lastUpdateAt; @@ -123,15 +157,21 @@ class FeishuCardStreamController implements CardStreamController { } private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> { - if (!state.pendingText) return 'ok'; - const text = state.pendingText; + if (!state.pendingText && state.toolCalls.length === 0) return 'ok'; + + // Build content: main text + tool progress + let content = state.pendingText || ''; + const toolMd = buildToolProgressMarkdown(state.toolCalls); + if (toolMd) { + content = content ? `${content}\n\n${toolMd}` : toolMd; + } state.pendingText = null; try { state.sequence++; await (this.client as any).cardkit.v2.card.streamContent({ path: { card_id: state.cardId }, - data: { content: text, sequence: state.sequence }, + data: { content, sequence: state.sequence }, }); state.lastUpdateAt = Date.now(); return 'ok'; @@ -141,6 +181,31 @@ class FeishuCardStreamController implements CardStreamController { } } + /** Update tool call progress β€” triggers a card update */ + updateToolCalls(messageId: string, tools: ToolCallInfo[]): void { + const state = this.cards.get(messageId); + if (!state) return; + state.toolCalls = tools; + + // Force a flush with current text + updated tool progress + const elapsed = Date.now() - state.lastUpdateAt; + if (elapsed >= this.config.throttleMs) { + this.flushUpdate(state).catch(() => {}); + } else if (!state.throttleTimer) { + state.throttleTimer = setTimeout(() => { + state.throttleTimer = null; + this.flushUpdate(state).catch(() => {}); + }, this.config.throttleMs - elapsed); + } + } + + /** Set thinking state β€” shows πŸ’­ Thinking... in card */ + setThinking(messageId: string): void { + const state = this.cards.get(messageId); + if (!state) return; + state.thinking = true; + } + async finalize( messageId: string, finalText: string, @@ -157,62 +222,80 @@ class FeishuCardStreamController implements CardStreamController { try { // Close streaming mode + state.sequence++; await (this.client as any).cardkit.v2.card.setStreamingMode({ path: { card_id: state.cardId }, - params: { streaming_mode: false }, + data: { streaming_mode: false, sequence: state.sequence }, }); - // Build footer elements if configured - const footerElements: Array<{ tag: string; content: string; element_id: string }> = []; - const footerCfg = this.config.footer; + // Build final card elements + const elements: any[] = []; - if (footerCfg?.elapsed) { - const elapsedMs = Date.now() - state.startTime; - const elapsedSec = (elapsedMs / 1000).toFixed(1); - footerElements.push({ - tag: 'markdown', - content: `*${elapsedSec}s*`, - element_id: 'footer_elapsed', - }); + // Main content (optimize markdown for Feishu rendering) + elements.push({ + tag: 'markdown', + content: optimizeMarkdown(finalText), + text_size: 'normal', + element_id: 'streaming_content', + }); + + // Tool call summary (if any tools were used) + if (state.toolCalls.length > 0) { + const toolMd = buildToolProgressMarkdown(state.toolCalls); + if (toolMd) { + elements.push({ + tag: 'markdown', + content: toolMd, + text_size: 'notation', + element_id: 'tool_summary', + }); + } } + // Footer + const footerCfg = this.config.footer; + const footerParts: string[] = []; + if (footerCfg?.status) { const statusLabels: Record = { - completed: 'Completed', - interrupted: 'Interrupted', - error: 'Error', + completed: 'βœ… Completed', + interrupted: '⚠️ Interrupted', + error: '❌ Error', }; - footerElements.push({ + footerParts.push(statusLabels[status] || status); + } + + if (footerCfg?.elapsed) { + const elapsedMs = Date.now() - state.startTime; + footerParts.push(formatElapsed(elapsedMs)); + } + + if (footerParts.length > 0) { + elements.push({ tag: 'hr' }); + elements.push({ tag: 'markdown', - content: `**${statusLabels[status] || status}**`, - element_id: 'footer_status', + content: footerParts.join(' Β· '), + text_size: 'notation', + element_id: 'footer', }); } - // Update final card content + // Update final card const finalCard = { schema: '2.0', config: { wide_screen_mode: true }, - body: { - elements: [ - { - tag: 'markdown', - content: finalText, - element_id: 'streaming_content', - }, - ...footerElements, - ], - }, + body: { elements }, }; + + state.sequence++; await (this.client as any).cardkit.v2.card.update({ path: { card_id: state.cardId }, - data: { type: 'card_json', data: JSON.stringify(finalCard) }, + data: { type: 'card_json', data: JSON.stringify(finalCard), sequence: state.sequence }, }); } catch (err: any) { console.error(LOG_TAG, 'Finalize failed:', err?.message || err); } - console.log(LOG_TAG, `Finalized card ${state.cardId} as ${status}`); this.cards.delete(messageId); } } diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts index 9964baa..4449750 100644 --- a/src/lib/channels/feishu/index.ts +++ b/src/lib/channels/feishu/index.ts @@ -206,7 +206,7 @@ export class FeishuChannelPlugin implements ChannelPlugin { const messageId = this.lastMessageIdByChat.get(chatId); if (!client || !messageId) return; // Fire-and-forget β€” don't block message processing - addReaction(client, messageId, 'OnIt').then((reactionId) => { + addReaction(client, messageId, 'Typing').then((reactionId) => { if (reactionId) { this.activeReactions.set(chatId, { messageId, reactionId }); } diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts index fb2520b..1ba0724 100644 --- a/src/lib/channels/feishu/outbound.ts +++ b/src/lib/channels/feishu/outbound.ts @@ -25,7 +25,7 @@ const LOG_TAG = '[feishu/outbound]'; * - Strip invalid image keys (prevent CardKit error 200570) * - Compress excessive newlines (3+ β†’ 2) */ -function optimizeMarkdown(text: string): string { +export function optimizeMarkdown(text: string): string { try { return _optimizeMarkdown(text); } catch { diff --git a/src/lib/channels/types.ts b/src/lib/channels/types.ts index 5473f48..0a8b459 100644 --- a/src/lib/channels/types.ts +++ b/src/lib/channels/types.ts @@ -10,6 +10,13 @@ import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '. // ── Card Stream Controller ────────────────────────────────────── /** Controls a streaming card lifecycle (create β†’ update β†’ finalize). */ +/** Tool call info for card display */ +export interface ToolCallInfo { + id: string; + name: string; + status: 'running' | 'complete' | 'error'; +} + export interface CardStreamController { /** * Create a streaming card in the given chat. @@ -31,6 +38,12 @@ export interface CardStreamController { * @param status 'completed' | 'interrupted' | 'error' */ finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise; + + /** Optional: update tool call progress display */ + updateToolCalls?(messageId: string, tools: ToolCallInfo[]): void; + + /** Optional: set thinking state before text starts flowing */ + setThinking?(messageId: string): void; } // ── Channel Capabilities ────────────────────────────────────────