forked from bai/curriculum-project-hub
feat: streaming agent card with tool-use trace, reasoning panel, and tool results
Replace text-only PatchableTextStream with a unified StreamingAgentCard that renders the full agent run lifecycle in a single Feishu interactive card: - Tool-use panel: collapsible, shows each tool call with status (running/ success/error), input summary, and result/error in code blocks - Reasoning panel: collapsible, streams thinking text inline then collapses on completion - Answer text: rendered via native Feishu markdown component (no post-row round-trip) Runner now captures previously-discarded SDK events: - thinking_delta → reasoning stream - tool_use id + input on content_block_start / assistant message - tool_result from user messages (was entirely unhandled) New files: - card/trace-store.ts: per-run in-memory tool-use trace with secret redaction - card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text) - card/streaming-card.ts: StreamingAgentCard controller (400ms throttled) client.ts: add sendCard/patchCard raw JSON primitives
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
readonly elements: ReadonlyArray<unknown>;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
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;
|
||||
runId: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError } = 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",
|
||||
});
|
||||
}
|
||||
|
||||
// Footer for complete state
|
||||
if (phase === "complete") {
|
||||
const footerParts: string[] = [];
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 <font color='${status.color}'>${status.label}</font>`,
|
||||
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<string, unknown>;
|
||||
// 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user