/** * Feishu interactive card builder for agent run output. * * Produces card JSON with: * 1. A collapsible tool-use panel (tool steps with status, params, results) * 2. A collapsible reasoning panel (thinking text) * 3. The streaming/final answer text (markdown) * * Adapted from openclaw-lark's builder.ts, simplified for our * message.patch-based approach (no CardKit 2.0 streaming_mode). * Uses JSON 1.0 card format with `collapsible_panel` and `markdown` tags. */ import type { ToolUseTraceStep } from "./trace-store.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface FeishuCard { readonly config: Record; readonly elements: ReadonlyArray; } export type CardPhase = "thinking" | "streaming" | "complete"; const STEP_INDENT = "0px 0px 0px 22px"; const MAX_TEXT_LENGTH = 7500; // Feishu card content limit ~30k; leave room for panels // --------------------------------------------------------------------------- // Tool name → icon mapping // --------------------------------------------------------------------------- const TOOL_ICONS: Record = { read: "doc-filled", write: "edit-filled", bash: "terminal-filled", glob: "search-filled", grep: "search-filled", edit: "edit-filled", send_file: "send-filled", request_approval: "thumb-up-filled", feishu_read_context: "search-filled", feishu_download_resource: "download-filled", webfetch: "link-copy-filled", websearch: "search-filled", }; function toolIcon(toolName: string): string { const normalized = toolName.toLowerCase().replace(/^mcp_/, ""); return TOOL_ICONS[normalized] ?? "tool-filled"; } // --------------------------------------------------------------------------- // Card builder // --------------------------------------------------------------------------- export function buildAgentCard(params: { phase: CardPhase; text: string; reasoningText: string | undefined; toolUseSteps: ToolUseTraceStep[]; toolUseElapsedMs: number | undefined; isError: boolean | undefined; interrupted: boolean | undefined; runId: string | undefined; }): Record { const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params; const elements: unknown[] = []; // Tool-use panel (always present if there are steps) if (toolUseSteps.length > 0) { elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete")); } else if (phase === "thinking" || (phase === "streaming" && text === "")) { elements.push(buildPendingToolUsePanel()); } // Reasoning panel if (reasoningText !== undefined && reasoningText !== "") { if (phase === "streaming" && text === "") { // Still thinking: show reasoning inline elements.push({ tag: "markdown", content: `\u{1F4AD} \u601d\u8003\u4E2D...\n\n${reasoningText}`, text_size: "notation", }); } else if (phase === "complete") { // Collapsed reasoning panel in complete state elements.push(buildReasoningPanel(reasoningText)); } } // Main text content if (text !== "") { elements.push({ tag: "markdown", content: truncateText(text, MAX_TEXT_LENGTH), }); } else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) { elements.push({ tag: "markdown", content: "\u{1F4AD} \u601d\u8003\u4E2D...", text_size: "notation", }); } // Interrupt button while the run is live. The button carries the run id in // its action value; the card action handler aborts the run. Feishu's native // `confirm` dialog is the "are you sure" step — the action only fires after // the user confirms, so no second round-trip is needed. if (phase !== "complete" && params.runId !== undefined) { elements.push(buildInterruptAction(params.runId)); } // Footer for complete state if (phase === "complete") { const footerParts: string[] = []; if (interrupted === true) footerParts.push("\u{1F6D1} \u5DF2\u4E2D\u65AD"); else if (isError === true) footerParts.push("\u274C \u5931\u8D25"); else footerParts.push("\u2705 \u5B8C\u6210"); if (toolUseElapsedMs !== undefined) { footerParts.push(formatElapsed(toolUseElapsedMs)); } elements.push({ tag: "div", text: { tag: "plain_text", content: footerParts.join(" \u00B7 "), text_size: "notation", text_color: "grey", }, }); } return { config: { wide_screen_mode: true, update_multi: true }, elements, }; } /** Action row with a single danger "interrupt" button for a live run. */ function buildInterruptAction(runId: string): unknown { return { tag: "action", actions: [ { tag: "button", text: { tag: "plain_text", content: "\u{1F6D1} \u4E2D\u65AD" }, type: "danger", value: { interrupt_run: runId }, confirm: { title: { tag: "plain_text", content: "\u786E\u8BA4\u4E2D\u65AD" }, text: { tag: "plain_text", content: "\u786E\u5B9A\u8981\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u5417\uFF1F\u4E2D\u65AD\u540E\u65E0\u6CD5\u6062\u590D\uFF0C\u5DF2\u4EA7\u51FA\u7684\u5185\u5BB9\u4F1A\u4FDD\u7559\u3002", }, }, }, ], }; } // --------------------------------------------------------------------------- // Tool-use panel // --------------------------------------------------------------------------- function buildPendingToolUsePanel(): unknown { return { tag: "collapsible_panel", expanded: false, header: { title: { tag: "plain_text", content: "\u{1F6E0}\uFE0F \u7B49\u5F85\u5DE5\u5177\u6267\u884C", text_color: "grey", text_size: "notation", }, vertical_align: "center", icon: { tag: "standard_icon", token: "down-small-ccm_outlined", color: "grey", size: "16px 16px", }, icon_position: "right", icon_expanded_angle: -180, }, border: { color: "grey", corner_radius: "5px" }, vertical_spacing: "4px", padding: "8px 8px 8px 8px", elements: [], }; } function buildToolUsePanel(steps: ToolUseTraceStep[], elapsedMs?: number, expanded = false): unknown { const parts: string[] = ["\u{1F6E0}\uFE0F \u5DE5\u5177\u6267\u884C"]; if (steps.length > 0) { parts.push(`${steps.length} \u6B65`); } if (elapsedMs !== undefined && elapsedMs > 0) { parts.push(`(${formatElapsed(elapsedMs)})`); } const stepElements = steps.flatMap((step) => buildToolStepElements(step)); return { tag: "collapsible_panel", expanded, header: { title: { tag: "plain_text", content: parts.join(" \u00B7 "), text_color: "grey", text_size: "notation", }, vertical_align: "center", icon: { tag: "standard_icon", token: "down-small-ccm_outlined", color: "grey", size: "16px 16px", }, icon_position: "right", icon_expanded_angle: -180, }, border: { color: "grey", corner_radius: "5px" }, vertical_spacing: "4px", padding: "8px 8px 8px 8px", elements: stepElements, }; } function buildToolStepElements(step: ToolUseTraceStep): unknown[] { const elements: unknown[] = [buildToolStepTitle(step)]; const detailElement = buildToolStepDetail(step); if (detailElement !== undefined) { elements.push(detailElement); } const outputElement = buildToolStepOutput(step); if (outputElement !== undefined) { elements.push(outputElement); } return elements; } function buildToolStepTitle(step: ToolUseTraceStep): unknown { const status = formatStepStatus(step.status); return { tag: "div", icon: { tag: "standard_icon", token: toolIcon(step.toolName), color: "grey", }, text: { tag: "lark_md", content: `**${escapeMarkdown(step.toolName)}** \u00B7 ${status.label}`, text_size: "notation", }, }; } function buildToolStepDetail(step: ToolUseTraceStep): unknown | undefined { const detail = extractStepDetail(step); if (detail === undefined) return undefined; return { tag: "div", margin: STEP_INDENT, text: { tag: "plain_text", content: detail, text_color: "grey", text_size: "notation", }, }; } function buildToolStepOutput(step: ToolUseTraceStep): unknown | undefined { const content = buildStepOutputMarkdown(step); if (content === undefined) return undefined; return { tag: "div", margin: STEP_INDENT, text: { tag: "lark_md", content, text_size: "notation", }, }; } function buildStepOutputMarkdown(step: ToolUseTraceStep): string | undefined { const lines: string[] = []; if (step.error !== undefined) { lines.push("**\u9519\u8BEF**"); lines.push(formatCodeBlock(step.error, "text")); } else if (step.result !== undefined && step.result !== "") { const resultStr = typeof step.result === "string" ? step.result : JSON.stringify(step.result, null, 2); lines.push("**\u7ED3\u679C**"); lines.push(formatCodeBlock(resultStr, typeof step.result === "string" ? "text" : "json")); } if (lines.length === 0) return undefined; return lines.join("\n"); } function extractStepDetail(step: ToolUseTraceStep): string | undefined { if (step.input === undefined || step.input === null) return undefined; if (typeof step.input === "string") return truncateText(step.input, 200); const input = step.input as Record; // Extract the most relevant field based on tool name const toolLower = step.toolName.toLowerCase().replace(/^mcp_/, ""); if (toolLower === "bash" && typeof input.command === "string") { return truncateText(input.command, 200); } if (toolLower === "read" && typeof input.file_path === "string") { return input.file_path; } if (toolLower === "write" && typeof input.file_path === "string") { return input.file_path; } if (toolLower === "edit" && typeof input.file_path === "string") { return input.file_path; } if (toolLower === "glob" && typeof input.pattern === "string") { return input.pattern; } if (toolLower === "grep" && typeof input.pattern === "string") { return input.pattern; } // Fallback: show keys const keys = Object.keys(input); if (keys.length === 0) return undefined; return keys.slice(0, 4).join(", "); } // --------------------------------------------------------------------------- // Reasoning panel // --------------------------------------------------------------------------- function buildReasoningPanel(reasoningText: string): unknown { return { tag: "collapsible_panel", expanded: false, header: { title: { tag: "markdown", content: "\u{1F4AD} \u601D\u8003", text_color: "grey", }, vertical_align: "center", icon: { tag: "standard_icon", token: "down-small-ccm_outlined", size: "16px 16px", }, icon_position: "follow_text", icon_expanded_angle: -180, }, border: { color: "grey", corner_radius: "5px" }, vertical_spacing: "8px", padding: "8px 8px 8px 8px", elements: [ { tag: "markdown", content: truncateText(reasoningText, MAX_TEXT_LENGTH), text_size: "notation", }, ], }; } // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- function formatStepStatus(status: ToolUseTraceStep["status"]): { label: string; color: string } { switch (status) { case "running": return { label: "\u8FD0\u884C\u4E2D", color: "turquoise" }; case "error": return { label: "\u5931\u8D25", color: "red" }; case "success": default: return { label: "\u6210\u529F", color: "green" }; } } function formatCodeBlock(content: string, language: "json" | "text"): string { const normalized = content.replace(/\r\n/g, "\n").trim(); const fence = "`".repeat(Math.max(3, longestBacktickRun(normalized) + 1)); return `${fence}${language}\n${normalized}\n${fence}`; } function longestBacktickRun(value: string): number { const matches = value.match(/`+/g); if (matches === null) return 0; return matches.reduce((max, run) => Math.max(max, run.length), 0); } function escapeMarkdown(value: string): string { return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1"); } function truncateText(value: string, maxLength: number): string { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; } function formatElapsed(ms: number): string { if (ms < 1000) return `${ms} ms`; return `${(ms / 1000).toFixed(1)} s`; }