SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3201ca6 verified
Raw
History Blame Contribute Delete
21.4 kB
import type {
ActionEvent,
OpenHandsEvent,
ObservationEvent,
} from "#/types/agent-server/core";
import type { TaskAction } from "#/types/agent-server/core/base/action";
import type { TaskObservation } from "#/types/agent-server/core/base/observation";
import {
isACPToolCallEvent,
isActionEvent,
isAgentErrorEvent,
isConversationStateUpdateEvent,
isGoalConversationStateUpdateEvent,
isHookExecutionEvent,
isMessageEvent,
isObservationEvent,
isStreamingDeltaEvent,
isSwitchLLMObservationEvent,
} from "#/types/agent-server/type-guards";
import { handleEventForUI } from "#/utils/handle-event-for-ui";
import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event";
import { parseMessageFromEvent } from "#/components/conversation-events/chat/event-content-helpers/parse-message-from-event";
import { getActionContent } from "#/components/conversation-events/chat/event-content-helpers/get-action-content";
import { getObservationContent } from "#/components/conversation-events/chat/event-content-helpers/get-observation-content";
import {
getACPToolCallContent,
stripRedundantTitlePrefix,
} from "#/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content";
import { groupEvents } from "#/components/conversation-events/chat/group-events";
import {
getActionThoughtText,
getReasoningContent,
splitInlineThink,
} from "#/components/conversation-events/chat/event-thought-helpers";
import i18n from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
export type TranscriptExportFormat = "markdown" | "html";
export interface TranscriptExportOptions {
includeToolDetails: boolean;
includeTimestamps: boolean;
title?: string | null;
model?: string | null;
}
type TranscriptEntry =
| {
kind: "message";
author: "user" | "assistant";
content: string;
timestamp: string;
}
| {
kind: "tool";
summary: string;
details: string;
timestamp: string;
}
| {
kind: "error";
content: string;
timestamp: string;
}
| {
kind: "note";
summary: string;
content: string;
timestamp: string;
};
const SAFE_ACTION_DETAIL_KINDS = new Set([
"BrowserClickAction",
"BrowserCloseTabAction",
"BrowserGetContentAction",
"BrowserGetStateAction",
"BrowserGoBackAction",
"BrowserListTabsAction",
"BrowserNavigateAction",
"BrowserScrollAction",
"BrowserSwitchTabAction",
"BrowserTypeAction",
"ExecuteBashAction",
"FileEditorAction",
"GlobAction",
"GrepAction",
"InvokeSkillAction",
"MCPToolAction",
"StrReplaceEditorAction",
"TaskAction",
"TaskTrackerAction",
"TerminalAction",
"ThinkAction",
]);
const SAFE_OBSERVATION_DETAIL_KINDS = new Set([
"BrowserObservation",
"CanvasUIObservation",
"ExecuteBashObservation",
"FileEditorObservation",
"GlobObservation",
"GrepObservation",
"InvokeSkillObservation",
"MCPToolObservation",
"StrReplaceEditorObservation",
"SwitchLLMObservation",
"TaskTrackerObservation",
"TaskObservation",
"TerminalObservation",
]);
const cleanInlineText = (value: string): string =>
value
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
const truncate = (value: string, maxLength = 100): string =>
value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? timestamp : date.toISOString();
};
const escapeHtml = (value: string): string =>
value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
/**
* GitHub Markdown permits raw HTML, so dynamic conversation text needs the
* same inert representation as the chat's sanitized Markdown renderer.
*/
const sanitizeMarkdownText = (value: string): string =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replace(
/(!?)\[([^\]]*)\]\(\s*((?:javascript|data|vbscript):[^)]*)\)/gi,
"$1\\[$2\\]($3)",
)
.replace(
/^(\s{0,3})\[([^\]]+)\]:(\s*<?(?:javascript|data|vbscript):)/gim,
"$1\\[$2\\]:$3",
);
const safeTitle = (title?: string | null): string =>
cleanInlineText(title || i18n.t(I18nKey.TRANSCRIPT_EXPORT$DEFAULT_TITLE)) ||
i18n.t(I18nKey.TRANSCRIPT_EXPORT$DEFAULT_TITLE);
const translatePlain = (
key: I18nKey,
values?: Record<string, unknown>,
): string =>
cleanInlineText(i18n.t(key, values).replace(/<\/?(?:cmd|path)>/g, ""));
const isServerFallbackSummary = (summary: string): boolean =>
/^[a-z][a-z0-9_]*\s*:\s*[[{]/i.test(summary);
const humanizeEventKind = (kind: string): string =>
kind
.replace(/(?:Action|Observation)$/, "")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.trim();
const getActionSummary = (event: ActionEvent): string => {
const summary = cleanInlineText(event.summary || "");
if (summary && !isServerFallbackSummary(summary)) return summary;
return (
cleanInlineText(event.tool_name) || humanizeEventKind(event.action.kind)
);
};
const getObservationSummary = (
event: ObservationEvent,
correspondingAction?: ActionEvent,
): string => {
if (correspondingAction) return getActionSummary(correspondingAction);
return (
cleanInlineText(event.tool_name) ||
humanizeEventKind(event.observation.kind)
);
};
const getTextContent = (
content: Array<{ type: string; text?: string }>,
): string =>
content
.filter(
(item): item is { type: "text"; text: string } =>
item.type === "text" && typeof item.text === "string",
)
.map((item) => item.text)
.join("\n");
const getTaskActionDetails = (action: TaskAction): string =>
[
`${i18n.t(I18nKey.TASK$SUBAGENT)}: ${action.subagent_type}`,
`${i18n.t(I18nKey.TASK$QUERY)}:\n${action.prompt}`,
].join("\n\n");
const getTaskObservationDetails = (
observation: TaskObservation,
correspondingAction?: ActionEvent,
): string => {
const taskAction =
correspondingAction?.action.kind === "TaskAction"
? correspondingAction.action
: undefined;
return [
`${i18n.t(I18nKey.TASK$SUBAGENT)}: ${observation.subagent}`,
`${i18n.t(I18nKey.TASK$TASK_ID)}: ${observation.task_id}`,
taskAction ? `${i18n.t(I18nKey.TASK$QUERY)}:\n${taskAction.prompt}` : "",
`${i18n.t(I18nKey.TASK$RESULT)}:\n${getTextContent(observation.content)}`,
]
.filter(Boolean)
.join("\n\n");
};
const getSafeActionDetails = (event: ActionEvent): string => {
if (event.action.kind === "TaskAction") {
return getTaskActionDetails(event.action);
}
return SAFE_ACTION_DETAIL_KINDS.has(event.action.kind)
? getActionContent(event)
: "";
};
const getSafeObservationDetails = (
event: ObservationEvent,
correspondingAction?: ActionEvent,
): string => {
if (event.observation.kind === "TaskObservation") {
return getTaskObservationDetails(event.observation, correspondingAction);
}
return SAFE_OBSERVATION_DETAIL_KINDS.has(event.observation.kind)
? getObservationContent(event)
: "";
};
const getHookDetails = (event: OpenHandsEvent): string => {
if (!isHookExecutionEvent(event)) return "";
return [event.reason, event.error, event.stdout, event.stderr]
.filter((value): value is string => Boolean(value?.trim()))
.join("\n\n");
};
const getActionNarration = (event: ActionEvent): string =>
[getReasoningContent(event), getActionThoughtText(event)]
.map((content) => content.trim())
.filter(Boolean)
.join("\n\n");
const canRenderActionNarration = (event: ActionEvent): boolean =>
!["FinishAction", "SwitchLLMAction", "ThinkAction"].includes(
event.action.kind,
);
const buildTranscriptEntries = (
events: OpenHandsEvent[],
includeToolDetails: boolean,
): TranscriptEntry[] => {
const uiEvents = events.reduce<OpenHandsEvent[]>((current, event) => {
try {
return handleEventForUI(event, current);
} catch {
return current;
}
}, []);
const actionsById = new Map(
events.filter(isActionEvent).map((event) => [event.id, event]),
);
const entries: TranscriptEntry[] = [];
const emittedNarrationActionIds = new Set<string>();
const renderableEvents = uiEvents.filter(
(event) =>
(isSwitchLLMObservationEvent(event) && !event.observation.is_error) ||
shouldRenderEvent(event),
);
const renderedItems = groupEvents(
renderableEvents,
Number.MAX_SAFE_INTEGER,
events,
);
const addActionNarration = (action: ActionEvent) => {
if (
emittedNarrationActionIds.has(action.id) ||
!canRenderActionNarration(action)
) {
return;
}
emittedNarrationActionIds.add(action.id);
const content = getActionNarration(action);
if (content) {
entries.push({
kind: "message",
author: "assistant",
content,
timestamp: action.timestamp,
});
}
};
for (const item of renderedItems) {
if (item.kind === "group") continue;
if (item.kind === "thought") {
addActionNarration(item.action);
continue;
}
const { event } = item;
try {
const narrationAction = isActionEvent(event)
? event
: isObservationEvent(event)
? actionsById.get(event.action_id)
: undefined;
if (narrationAction) addActionNarration(narrationAction);
if (isSwitchLLMObservationEvent(event) && !event.observation.is_error) {
entries.push({
kind: "note",
summary: translatePlain(I18nKey.MODEL$SWITCHED_TO_PROFILE, {
name: event.observation.profile_name,
}),
content: [
event.observation.active_model
? `${i18n.t(I18nKey.TRANSCRIPT_EXPORT$MODEL)}: ${event.observation.active_model}`
: "",
event.observation.reason
? `${i18n.t(I18nKey.TRANSCRIPT_EXPORT$REASON)}: ${event.observation.reason}`
: "",
]
.filter(Boolean)
.join("\n"),
timestamp: event.timestamp ?? "",
});
continue;
}
if (isMessageEvent(event)) {
const parsed = parseMessageFromEvent(event).trim();
if (event.source === "agent") {
const { reasoning, message } = splitInlineThink(parsed);
[reasoning, message].filter(Boolean).forEach((content) => {
entries.push({
kind: "message",
author: "assistant",
content,
timestamp: event.timestamp ?? "",
});
});
} else if (parsed) {
entries.push({
kind: "message",
author: "user",
content: parsed,
timestamp: event.timestamp ?? "",
});
}
continue;
}
if (isStreamingDeltaEvent(event)) {
const { reasoning: inlineThink, message } = splitInlineThink(
event.content ?? "",
{ streaming: true },
);
const reasoningContent = [
event.reasoning_content?.trim() || "",
inlineThink,
]
.filter(Boolean)
.join("\n\n");
if (reasoningContent) {
entries.push({
kind: "message",
author: "assistant",
content: reasoningContent,
timestamp: event.timestamp ?? "",
});
}
if (message.trim()) {
entries.push({
kind: "message",
author: "assistant",
content: message.trim(),
timestamp: event.timestamp ?? "",
});
}
continue;
}
if (isAgentErrorEvent(event)) {
entries.push({
kind: "error",
content: event.error,
timestamp: event.timestamp ?? "",
});
continue;
}
if (isActionEvent(event)) {
if (event.action.kind === "FinishAction") {
const content = event.action.message.trim();
if (content) {
entries.push({
kind: "message",
author: "assistant",
content,
timestamp: event.timestamp ?? "",
});
}
} else {
entries.push({
kind: "tool",
summary: getActionSummary(event),
details: includeToolDetails ? getSafeActionDetails(event) : "",
timestamp: event.timestamp ?? "",
});
}
continue;
}
if (isObservationEvent(event)) {
const correspondingAction = actionsById.get(event.action_id);
entries.push({
kind: "tool",
summary: getObservationSummary(event, correspondingAction),
details: includeToolDetails
? getSafeObservationDetails(event, correspondingAction)
: "",
timestamp: event.timestamp ?? "",
});
continue;
}
if (isACPToolCallEvent(event)) {
entries.push({
kind: "tool",
summary:
stripRedundantTitlePrefix(event) ||
i18n.t(I18nKey.ACTION_MESSAGE$ACP_TOOL),
details: includeToolDetails ? getACPToolCallContent(event) : "",
timestamp: event.timestamp ?? "",
});
continue;
}
if (isHookExecutionEvent(event)) {
entries.push({
kind: "tool",
summary: i18n.t(I18nKey.TRANSCRIPT_EXPORT$HOOK, {
command: truncate(cleanInlineText(event.hook_command), 100),
}),
details: includeToolDetails ? getHookDetails(event) : "",
timestamp: event.timestamp ?? "",
});
continue;
}
if (
isConversationStateUpdateEvent(event) &&
isGoalConversationStateUpdateEvent(event)
) {
entries.push({
kind: "note",
summary: `${i18n.t(I18nKey.GOAL$PREFIX)} ${i18n.t(
{
running: I18nKey.GOAL$STATUS_RUNNING,
complete: I18nKey.GOAL$STATUS_COMPLETE,
capped: I18nKey.GOAL$STATUS_CAPPED,
interrupted: I18nKey.GOAL$STATUS_INTERRUPTED,
}[event.value.status],
)}`,
content: [event.value.objective, event.value.verdict?.missing || ""]
.filter(Boolean)
.join("\n\n"),
timestamp: event.timestamp ?? "",
});
}
} catch {
// Runtime event payloads may be older than the local TypeScript schema.
// Skip malformed entries instead of aborting an otherwise valid export.
}
}
return entries;
};
const markdownTimestamp = (
entry: TranscriptEntry,
options: TranscriptExportOptions,
): string =>
options.includeTimestamps
? `<sub>${escapeHtml(formatTimestamp(entry.timestamp))}</sub>\n\n`
: "";
const markdownFence = (content: string): string => {
const longestRun = Math.max(
0,
...Array.from(content.matchAll(/`+/g), (match) => match[0].length),
);
const fence = "`".repeat(Math.max(3, longestRun + 1));
return `${fence}text\n${content}\n${fence}`;
};
export const eventsToMarkdown = (
events: OpenHandsEvent[],
options: TranscriptExportOptions,
): string => {
const lines = [`# ${sanitizeMarkdownText(safeTitle(options.title))}`, ""];
if (options.model) {
lines.push(
`**${i18n.t(I18nKey.TRANSCRIPT_EXPORT$MODEL)}:** ${sanitizeMarkdownText(cleanInlineText(options.model))}`,
"",
);
}
for (const entry of buildTranscriptEntries(
events,
options.includeToolDetails,
)) {
const timestamp = markdownTimestamp(entry, options);
if (entry.kind === "message") {
const author =
entry.author === "user"
? i18n.t(I18nKey.TRANSCRIPT_EXPORT$USER)
: i18n.t(I18nKey.CHAT_INTERFACE$ASSISTANT);
lines.push(
`## ${author}`,
"",
timestamp + sanitizeMarkdownText(entry.content),
"",
);
} else if (entry.kind === "error") {
const quoted = entry.content
.split("\n")
.map((line) => `> ${sanitizeMarkdownText(line)}`)
.join("\n");
lines.push(
`## ${i18n.t(I18nKey.COMMON$ERROR)}`,
"",
timestamp + quoted,
"",
);
} else if (entry.kind === "note") {
lines.push(
`> **${escapeHtml(entry.summary)}**`,
"",
timestamp + sanitizeMarkdownText(entry.content),
"",
);
} else if (options.includeToolDetails && entry.details) {
lines.push(
"<details>",
`<summary><strong>${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}:</strong> ${escapeHtml(entry.summary)}</summary>`,
"",
timestamp + markdownFence(entry.details),
"",
"</details>",
"",
);
} else {
const timestampMarkup = timestamp ? `<br>${timestamp.trimEnd()}` : "";
lines.push(
`<p><strong>${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}:</strong> ${escapeHtml(entry.summary)}${timestampMarkup}</p>`,
"",
);
}
}
return `${lines.join("\n").trim()}\n`;
};
const htmlTimestamp = (
entry: TranscriptEntry,
options: TranscriptExportOptions,
): string => {
if (!options.includeTimestamps) return "";
const timestamp = formatTimestamp(entry.timestamp);
return `<time datetime="${escapeHtml(timestamp)}">${escapeHtml(timestamp)}</time>`;
};
export const eventsToHtml = (
events: OpenHandsEvent[],
options: TranscriptExportOptions,
): string => {
const title = safeTitle(options.title);
const body = buildTranscriptEntries(events, options.includeToolDetails)
.map((entry) => {
const timestamp = htmlTimestamp(entry, options);
if (entry.kind === "message") {
const author =
entry.author === "user"
? i18n.t(I18nKey.TRANSCRIPT_EXPORT$USER)
: i18n.t(I18nKey.CHAT_INTERFACE$ASSISTANT);
return `<section class="message ${entry.author.toLowerCase()}">
<header><h2>${escapeHtml(author)}</h2>${timestamp}</header>
<div class="content">${escapeHtml(entry.content)}</div>
</section>`;
}
if (entry.kind === "error") {
return `<section class="message error">
<header><h2>${escapeHtml(i18n.t(I18nKey.COMMON$ERROR))}</h2>${timestamp}</header>
<div class="content">${escapeHtml(entry.content)}</div>
</section>`;
}
if (entry.kind === "note") {
return `<aside class="note">
<header><strong>${escapeHtml(entry.summary)}</strong>${timestamp}</header>
${entry.content ? `<div class="content">${escapeHtml(entry.content)}</div>` : ""}
</aside>`;
}
const details =
options.includeToolDetails && entry.details
? `<details>
<summary><strong>${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}:</strong> ${escapeHtml(entry.summary)}${timestamp}</summary>
<pre>${escapeHtml(entry.details)}</pre>
</details>`
: `<div class="tool-summary"><strong>${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}:</strong> ${escapeHtml(entry.summary)}${timestamp}</div>`;
return details;
})
.join("\n");
const model = options.model
? `<p class="model"><strong>${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$MODEL))}:</strong> ${escapeHtml(cleanInlineText(options.model))}</p>`
: "";
return `<!doctype html>
<html lang="${escapeHtml(i18n.resolvedLanguage || i18n.language || "en")}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
<title>${escapeHtml(title)}</title>
<style>
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; background: #111827; color: #e5e7eb; line-height: 1.55; }
main { box-sizing: border-box; width: min(860px, 100%); margin: 0 auto; padding: 48px 24px 80px; }
h1 { margin: 0; font-size: 2rem; }
h2 { margin: 0; font-size: 1rem; }
.model { margin: 8px 0 32px; color: #9ca3af; }
.message, details, .tool-summary, .note { margin: 16px 0; border: 1px solid #374151; border-radius: 10px; padding: 16px; background: #1f2937; }
.user { border-left: 4px solid #60a5fa; }
.assistant { border-left: 4px solid #34d399; }
.error { border-left: 4px solid #f87171; }
.note { border-left: 4px solid #a78bfa; }
header, summary, .tool-summary { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
summary { cursor: pointer; }
time { flex: none; color: #9ca3af; font-size: .75rem; font-weight: 400; }
.content { margin-top: 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
pre { margin: 14px 0 0; padding: 14px; overflow-x: auto; border-radius: 7px; background: #111827; color: #d1d5db; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (prefers-color-scheme: light) {
body { background: #f9fafb; color: #111827; }
.message, details, .tool-summary, .note { border-color: #d1d5db; background: #fff; }
pre { background: #f3f4f6; color: #1f2937; }
.model, time { color: #6b7280; }
}
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
${model}
${body}
</main>
</body>
</html>
`;
};