forked from bai/curriculum-project-hub
97f7972cc5
Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency. Roles that still list markdown_to_pdf must be cleaned before startup. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
/**
|
|
* 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",
|
|
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<string, unknown> {
|
|
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 <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`;
|
|
}
|