forked from EduCraft/curriculum-project-hub
e21096c642
Materialize markdown image refs on agent finish: fetch/read bytes, upload im.v1.image, and render native card img elements so remote image URLs no longer trip Feishu content-security. Stream masks image URLs mid-run; card failure falls back to plain text plus standalone image messages. Docs: clarify im:resource covers outbound Agent image send.
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/**
|
|
* Streaming card controller for agent runs.
|
|
*
|
|
* Replaces PatchableTextStream for the agent-run use case. Manages a single
|
|
* Feishu interactive card through the full run lifecycle:
|
|
*
|
|
* thinking → tool calls → streaming text → complete
|
|
*
|
|
* The card is rebuilt from scratch on each flush (throttled at 400ms) using
|
|
* the trace store for tool steps + accumulated reasoning/text. This is
|
|
* simpler than CardKit 2.0 element-level streaming and works with the
|
|
* existing message.patch API.
|
|
*
|
|
* Lifecycle:
|
|
* 1. appendText(delta) — streaming answer text
|
|
* 2. appendReasoning(delta) — streaming thinking text
|
|
* 3. onToolStart(name, id) — register a tool step in the trace store
|
|
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
|
* 5. finish(finalText) — flush + transition to complete card
|
|
* 6. fail(errorText) — flush + transition to error card
|
|
*
|
|
* On finish, markdown image references (``) are downloaded /
|
|
* read, uploaded to Feishu as message images, and embedded as native card
|
|
* `img` elements so external URLs never hit Feishu content-security checks.
|
|
*/
|
|
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
|
import { sendCard, patchCard, sendText, sendLongText } from "../client.js";
|
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
|
import {
|
|
startToolUseTraceRun,
|
|
clearToolUseTraceRun,
|
|
recordToolUseStart,
|
|
recordToolUseEnd,
|
|
getToolUseTraceSteps,
|
|
} from "./trace-store.js";
|
|
import { buildAgentCard, type CardPhase } from "./builder.js";
|
|
import {
|
|
type CardContentSegment,
|
|
maskMarkdownImagesForStreaming,
|
|
materializeAnswerSegments,
|
|
sendImageMessage,
|
|
} from "../outboundImages.js";
|
|
|
|
export interface StreamingCardSink {
|
|
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
|
readonly patch: (messageId: string, card: Record<string, unknown>) => Promise<void>;
|
|
}
|
|
|
|
export interface StreamingCardOptions {
|
|
readonly runId: string;
|
|
readonly rt: FeishuRuntime;
|
|
readonly chatId: string;
|
|
readonly sendOptions?: SendMessageOptions | undefined;
|
|
readonly patchIntervalMs: number | undefined;
|
|
readonly maxMessageLength: number | undefined;
|
|
/** Project workspace root; required to resolve local image paths. */
|
|
readonly workspaceRoot?: string | undefined;
|
|
/** Project workspace directory; required to resolve local image paths. */
|
|
readonly workspaceDir?: string | undefined;
|
|
readonly maxImageBytes?: number | undefined;
|
|
}
|
|
|
|
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
|
|
|
export class StreamingAgentCard {
|
|
private currentMessageId: string | null = null;
|
|
private text = "";
|
|
private reasoningText = "";
|
|
private runStartedAt = Date.now();
|
|
private toolUseElapsedMs: number | undefined;
|
|
private flushChain: Promise<void> = Promise.resolve();
|
|
private flushScheduled = false;
|
|
private lastPatchAt = 0;
|
|
private interrupted = false;
|
|
|
|
private readonly runId: string;
|
|
private readonly rt: FeishuRuntime;
|
|
private readonly chatId: string;
|
|
private readonly sendOptions: SendMessageOptions | undefined;
|
|
private readonly patchIntervalMs: number;
|
|
private readonly maxMessageLength: number;
|
|
private readonly workspaceRoot: string | undefined;
|
|
private readonly workspaceDir: string | undefined;
|
|
private readonly maxImageBytes: number | undefined;
|
|
|
|
constructor(options: StreamingCardOptions) {
|
|
this.runId = options.runId;
|
|
this.rt = options.rt;
|
|
this.chatId = options.chatId;
|
|
this.sendOptions = options.sendOptions;
|
|
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
|
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
|
this.workspaceRoot = options.workspaceRoot;
|
|
this.workspaceDir = options.workspaceDir;
|
|
this.maxImageBytes = options.maxImageBytes;
|
|
startToolUseTraceRun(this.runId);
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
appendText(delta: string): void {
|
|
if (delta === "") return;
|
|
this.text += delta;
|
|
this.scheduleFlush();
|
|
}
|
|
|
|
appendReasoning(delta: string): void {
|
|
if (delta === "") return;
|
|
this.reasoningText += delta;
|
|
this.scheduleFlush();
|
|
}
|
|
|
|
onToolStart(toolName: string, toolUseId: string | undefined): void {
|
|
recordToolUseStart({ runId: this.runId, toolName, toolUseId, input: undefined });
|
|
this.scheduleFlush();
|
|
}
|
|
|
|
onToolEnd(params: {
|
|
toolName: string;
|
|
toolUseId: string | undefined;
|
|
input: unknown;
|
|
result: unknown;
|
|
error: string | undefined;
|
|
durationMs: number | undefined;
|
|
}): void {
|
|
recordToolUseEnd({ runId: this.runId, ...params });
|
|
this.scheduleFlush();
|
|
}
|
|
|
|
async finish(
|
|
fallbackText: string,
|
|
options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {},
|
|
): Promise<void> {
|
|
await this.flushChain;
|
|
this.interrupted = options.interrupted === true;
|
|
const footerText = options.footerText ?? "";
|
|
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
|
try {
|
|
let answerText =
|
|
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter;
|
|
this.text = answerText;
|
|
|
|
const { segments, unresolved } = await materializeAnswerSegments(answerText, {
|
|
rt: this.rt,
|
|
workspaceRoot: this.workspaceRoot,
|
|
workspaceDir: this.workspaceDir,
|
|
maxImageBytes: this.maxImageBytes,
|
|
});
|
|
if (unresolved.length > 0) {
|
|
this.rt.logger.warn(
|
|
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
|
|
"some answer images could not be uploaded to Feishu",
|
|
);
|
|
}
|
|
|
|
let updated = true;
|
|
if (answerText.length > 0 || segments.length > 0) {
|
|
updated = await this.flushCard("complete", answerText, false, segments);
|
|
} else if (this.currentMessageId !== null) {
|
|
updated = await this.flushCard("complete", "", false, []);
|
|
}
|
|
|
|
if (!updated) {
|
|
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
|
updated = await this.deliverPlainFallback(segments, answerText);
|
|
}
|
|
if (!updated && this.interrupted) {
|
|
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
|
}
|
|
} finally {
|
|
clearToolUseTraceRun(this.runId);
|
|
}
|
|
}
|
|
|
|
async fail(errorText: string): Promise<void> {
|
|
await this.flushChain;
|
|
try {
|
|
this.text = errorText;
|
|
await this.flushCard("complete", errorText, true);
|
|
} finally {
|
|
clearToolUseTraceRun(this.runId);
|
|
}
|
|
}
|
|
|
|
// --- Internal flush logic ---
|
|
|
|
private scheduleFlush(): void {
|
|
if (this.flushScheduled) return;
|
|
this.flushScheduled = true;
|
|
this.flushChain = this.flushChain.then(async () => {
|
|
this.flushScheduled = false;
|
|
await this.flush();
|
|
});
|
|
}
|
|
|
|
private async flush(): Promise<boolean> {
|
|
if (this.currentMessageId === null) {
|
|
const updated = await this.flushCard(this.currentPhase(), this.text);
|
|
this.lastPatchAt = Date.now();
|
|
return updated;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - this.lastPatchAt < this.patchIntervalMs) return true;
|
|
this.lastPatchAt = now;
|
|
return this.flushCard(this.currentPhase(), this.text);
|
|
}
|
|
|
|
private async flushCard(
|
|
phase: CardPhase,
|
|
text: string,
|
|
isError = false,
|
|
contentSegments?: readonly CardContentSegment[],
|
|
): Promise<boolean> {
|
|
// During live streaming, strip image URLs so Feishu never fetches remote
|
|
// ranks mid-run. Materialized segments are only used on the complete pass.
|
|
const displayText =
|
|
phase === "complete" && contentSegments !== undefined
|
|
? text
|
|
: maskMarkdownImagesForStreaming(text);
|
|
|
|
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
|
|
const firstChunk = chunks[0] ?? "";
|
|
// When we have segments (complete+images), keep first-card complete content
|
|
// on segments only; overflow text (rare) falls back to plain chunked cards.
|
|
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
|
const card = buildAgentCard({
|
|
phase,
|
|
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk,
|
|
contentSegments: contentSegments !== undefined && contentSegments.length > 0
|
|
? contentSegments
|
|
: undefined,
|
|
reasoningText: this.reasoningText || undefined,
|
|
toolUseSteps,
|
|
toolUseElapsedMs: this.toolUseElapsedMs,
|
|
isError,
|
|
interrupted: this.interrupted,
|
|
runId: this.runId,
|
|
});
|
|
|
|
if (this.currentMessageId === null) {
|
|
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
|
let updated = this.currentMessageId !== null;
|
|
// Send overflow chunks as new messages (rare for agent output). Segments
|
|
// already include the whole answer; only plain text overflows.
|
|
if (contentSegments === undefined || contentSegments.length === 0) {
|
|
for (const chunk of chunks.slice(1)) {
|
|
const overflowCard = buildAgentCard({
|
|
phase,
|
|
text: chunk,
|
|
reasoningText: undefined,
|
|
toolUseSteps: [],
|
|
toolUseElapsedMs: undefined,
|
|
isError,
|
|
interrupted: this.interrupted,
|
|
runId: undefined,
|
|
});
|
|
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
|
updated = updated && overflowMessageId !== null;
|
|
this.currentMessageId = overflowMessageId;
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
|
if (contentSegments === undefined || contentSegments.length === 0) {
|
|
for (const chunk of chunks.slice(1)) {
|
|
const overflowCard = buildAgentCard({
|
|
phase,
|
|
text: chunk,
|
|
reasoningText: undefined,
|
|
toolUseSteps: [],
|
|
toolUseElapsedMs: undefined,
|
|
isError,
|
|
interrupted: this.interrupted,
|
|
runId: undefined,
|
|
});
|
|
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
|
updated = updated && overflowMessageId !== null;
|
|
this.currentMessageId = overflowMessageId;
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
private async deliverPlainFallback(
|
|
segments: readonly CardContentSegment[],
|
|
answerText: string,
|
|
): Promise<boolean> {
|
|
const textParts: string[] = [];
|
|
const imageKeys: string[] = [];
|
|
if (segments.length > 0) {
|
|
for (const segment of segments) {
|
|
if (segment.type === "markdown") {
|
|
if (segment.content.trim() !== "") textParts.push(segment.content);
|
|
} else {
|
|
imageKeys.push(segment.imgKey);
|
|
}
|
|
}
|
|
} else if (answerText.trim() !== "") {
|
|
textParts.push(maskMarkdownImagesForStreaming(answerText));
|
|
}
|
|
|
|
let any = false;
|
|
if (textParts.length > 0) {
|
|
const messageId = await sendLongText(
|
|
this.rt,
|
|
this.chatId,
|
|
textParts.join("\n\n"),
|
|
this.sendOptions,
|
|
);
|
|
any = messageId !== null;
|
|
}
|
|
for (const imageKey of imageKeys) {
|
|
try {
|
|
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
|
|
any = any || messageId !== null;
|
|
} catch (error) {
|
|
this.rt.logger.warn(
|
|
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
|
|
"standalone image fallback failed",
|
|
);
|
|
}
|
|
}
|
|
return any;
|
|
}
|
|
|
|
private currentPhase(): CardPhase {
|
|
if (this.text.length > 0) return "streaming";
|
|
if (this.reasoningText.length > 0) return "streaming";
|
|
return "thinking";
|
|
}
|
|
}
|
|
|
|
function appendFooter(text: string, footerText: string): string {
|
|
if (footerText === "") return text;
|
|
if (text === "") return footerText;
|
|
return `${text}\n\n${footerText}`;
|
|
}
|