agent-ecosystem/src/renderer/utils/sessionExporter.ts

427 lines
13 KiB
TypeScript

/**
* Session export utilities for Agent Teams AI.
*
* Provides formatters to export session data as plain text, Markdown, or JSON,
* and a download trigger for browser-based file saving.
*/
import type { Chunk, SessionDetail } from '@renderer/types/data';
import type { ContentBlock } from '@shared/types';
// =============================================================================
// Types
// =============================================================================
export type ExportFormat = 'markdown' | 'json' | 'plaintext';
interface ExtractOptions {
includeThinking?: boolean;
}
// =============================================================================
// Helpers (not exported)
// =============================================================================
function formatNumber(n: number): string {
return n.toLocaleString('en-US');
}
function formatCost(cost?: number): string {
if (cost == null) return 'N/A';
return `$${cost.toFixed(2)}`;
}
function formatTimestamp(date: Date): string {
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, ' UTC');
}
function formatDurationForExport(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
const mins = Math.floor(secs / 60);
const remainSecs = secs % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${remainSecs}s`;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + '...';
}
// =============================================================================
// extractTextFromContent
// =============================================================================
/**
* Extract readable text from message content (string or ContentBlock[]).
*
* @param content - String content or array of ContentBlocks
* @param options - Options controlling extraction behavior
* @returns Extracted text with newlines between blocks
*/
export function extractTextFromContent(
content: string | ContentBlock[],
options?: ExtractOptions
): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content) || content.length === 0) {
return '';
}
const parts: string[] = [];
for (const block of content) {
switch (block.type) {
case 'text':
parts.push(block.text);
break;
case 'thinking':
if (options?.includeThinking) {
parts.push(block.thinking);
}
break;
case 'tool_use':
parts.push(`Tool: ${block.name}\nInput: ${JSON.stringify(block.input, null, 2)}`);
break;
case 'tool_result': {
const resultContent = block.content;
if (typeof resultContent === 'string') {
parts.push(resultContent);
} else if (Array.isArray(resultContent)) {
// Recursively extract from nested content blocks
const nested = extractTextFromContent(resultContent);
if (nested) parts.push(nested);
}
break;
}
case 'image':
parts.push('[Image]');
break;
}
}
return parts.join('\n');
}
// =============================================================================
// Plain Text Chunk Formatters
// =============================================================================
function formatToolExecutionPlainText(exec: {
toolCall: { name: string; input: Record<string, unknown> };
result?: { content: string | unknown[]; isError: boolean };
}): string[] {
const lines: string[] = [];
lines.push(` TOOL: ${exec.toolCall.name}`);
lines.push(` Input: ${JSON.stringify(exec.toolCall.input)}`);
if (exec.result) {
const prefix = exec.result.isError ? ' [ERROR] Result: ' : ' Result: ';
const resultText =
typeof exec.result.content === 'string'
? exec.result.content
: JSON.stringify(exec.result.content);
lines.push(`${prefix}${truncate(resultText, 500)}`);
} else {
lines.push(' [No result]');
}
return lines;
}
function formatChunkPlainText(chunk: Chunk): string[] {
const lines: string[] = [];
switch (chunk.chunkType) {
case 'user': {
lines.push(`USER: ${extractTextFromContent(chunk.userMessage.content)}`);
break;
}
case 'ai': {
// Render thinking blocks first, then text
for (const response of chunk.responses) {
if (Array.isArray(response.content)) {
// Check for thinking blocks
for (const block of response.content) {
if (block.type === 'thinking') {
lines.push(`THINKING: ${block.thinking}`);
}
}
// Then text
const text = extractTextFromContent(response.content);
if (text) {
lines.push(`ASSISTANT: ${text}`);
}
} else if (typeof response.content === 'string') {
lines.push(`ASSISTANT: ${response.content}`);
}
}
// Tool executions
for (const exec of chunk.toolExecutions) {
lines.push(...formatToolExecutionPlainText(exec));
}
break;
}
case 'system': {
lines.push(`SYSTEM: ${chunk.commandOutput}`);
break;
}
case 'compact': {
lines.push('[Context compacted]');
break;
}
}
return lines;
}
// =============================================================================
// Markdown Chunk Formatters
// =============================================================================
function formatToolExecutionMarkdown(exec: {
toolCall: { name: string; input: Record<string, unknown> };
result?: { content: string | unknown[]; isError: boolean };
}): string[] {
const lines: string[] = [];
lines.push(`**Tool:** \`${exec.toolCall.name}\``);
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(exec.toolCall.input, null, 2));
lines.push('```');
lines.push('');
if (exec.result) {
if (exec.result.isError) {
lines.push('**Error:**');
} else {
lines.push('**Result:**');
}
lines.push('');
const resultText =
typeof exec.result.content === 'string'
? exec.result.content
: JSON.stringify(exec.result.content, null, 2);
lines.push('```');
lines.push(truncate(resultText, 2000));
lines.push('```');
}
return lines;
}
function formatChunkMarkdown(chunk: Chunk, turnNum: number): string[] {
const lines: string[] = [];
switch (chunk.chunkType) {
case 'user': {
lines.push(`### User (Turn ${turnNum})`);
lines.push('');
lines.push(extractTextFromContent(chunk.userMessage.content));
lines.push('');
break;
}
case 'ai': {
lines.push(`### Assistant (Turn ${turnNum})`);
lines.push('');
for (const response of chunk.responses) {
if (Array.isArray(response.content)) {
// Thinking blocks as blockquotes
for (const block of response.content) {
if (block.type === 'thinking') {
lines.push('> *Thinking:*');
for (const thinkLine of block.thinking.split('\n')) {
lines.push(`> ${thinkLine}`);
}
lines.push('');
}
}
// Text content
const text = extractTextFromContent(response.content);
if (text) {
lines.push(text);
lines.push('');
}
} else if (typeof response.content === 'string') {
lines.push(response.content);
lines.push('');
}
}
// Tool executions
for (const exec of chunk.toolExecutions) {
lines.push(...formatToolExecutionMarkdown(exec));
lines.push('');
}
break;
}
case 'system': {
lines.push(`### System (Turn ${turnNum})`);
lines.push('');
lines.push(chunk.commandOutput);
lines.push('');
break;
}
case 'compact': {
lines.push('---');
lines.push('');
lines.push('*Context compacted*');
lines.push('');
break;
}
}
return lines;
}
// =============================================================================
// Export Functions
// =============================================================================
/**
* Export session as plain text transcript.
*
* Produces a flat text format with clear labels (USER:, ASSISTANT:, TOOL:, etc.)
* and separator lines between sections.
*/
export function exportAsPlainText(detail: SessionDetail): string {
const { session, metrics, chunks } = detail;
const lines: string[] = [];
// Header
lines.push('═'.repeat(60));
lines.push('SESSION EXPORT');
lines.push('═'.repeat(60));
lines.push(`Session: ${session.id}`);
lines.push(`Project: ${session.projectPath}`);
if (session.gitBranch) {
lines.push(`Branch: ${session.gitBranch}`);
}
lines.push(`Date: ${formatTimestamp(new Date(session.createdAt))}`);
lines.push('');
// Metrics
lines.push('─'.repeat(40));
lines.push('METRICS');
lines.push('─'.repeat(40));
lines.push(`Duration: ${formatDurationForExport(metrics.durationMs)}`);
lines.push(`Total Tokens: ${formatNumber(metrics.totalTokens)}`);
lines.push(`Input Tokens: ${formatNumber(metrics.inputTokens)}`);
lines.push(`Output Tokens: ${formatNumber(metrics.outputTokens)}`);
lines.push(`Cache Read: ${formatNumber(metrics.cacheReadTokens)}`);
lines.push(`Cache Created: ${formatNumber(metrics.cacheCreationTokens)}`);
lines.push(`Messages: ${formatNumber(metrics.messageCount)}`);
lines.push(`Cost: ${formatCost(metrics.costUsd)}`);
lines.push('');
// Conversation
lines.push('═'.repeat(60));
lines.push('CONVERSATION');
lines.push('═'.repeat(60));
lines.push('');
for (const chunk of chunks) {
lines.push('─'.repeat(40));
lines.push(...formatChunkPlainText(chunk));
lines.push('');
}
return lines.join('\n');
}
/**
* Export session as structured Markdown.
*
* Produces Markdown with headings, tables, code blocks, and blockquotes
* suitable for viewing in any Markdown renderer.
*/
export function exportAsMarkdown(detail: SessionDetail): string {
const { session, metrics, chunks } = detail;
const lines: string[] = [];
// Title
lines.push('# Session Export');
lines.push('');
// Property table
lines.push('| Property | Value |');
lines.push('|----------|-------|');
lines.push(`| Session | \`${session.id}\` |`);
lines.push(`| Project | \`${session.projectPath}\` |`);
if (session.gitBranch) {
lines.push(`| Branch | \`${session.gitBranch}\` |`);
}
lines.push(`| Date | ${formatTimestamp(new Date(session.createdAt))} |`);
lines.push('');
// Metrics table
lines.push('## Metrics');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Duration | ${formatDurationForExport(metrics.durationMs)} |`);
lines.push(`| Total Tokens | ${formatNumber(metrics.totalTokens)} |`);
lines.push(`| Input Tokens | ${formatNumber(metrics.inputTokens)} |`);
lines.push(`| Output Tokens | ${formatNumber(metrics.outputTokens)} |`);
lines.push(`| Cache Read | ${formatNumber(metrics.cacheReadTokens)} |`);
lines.push(`| Cache Created | ${formatNumber(metrics.cacheCreationTokens)} |`);
lines.push(`| Messages | ${formatNumber(metrics.messageCount)} |`);
lines.push(`| Cost | ${formatCost(metrics.costUsd)} |`);
lines.push('');
// Conversation
lines.push('## Conversation');
lines.push('');
let turnNum = 0;
for (const chunk of chunks) {
turnNum++;
lines.push(...formatChunkMarkdown(chunk, turnNum));
}
return lines.join('\n');
}
/**
* Export session as pretty-printed JSON.
*/
export function exportAsJson(detail: SessionDetail): string {
return JSON.stringify(detail, null, 2);
}
/**
* Trigger a browser file download for the given session in the specified format.
*
* Creates a Blob, generates an object URL, and simulates an anchor click
* to initiate the download.
*/
export function triggerDownload(detail: SessionDetail, format: ExportFormat): void {
const formatters: Record<
ExportFormat,
{ fn: (d: SessionDetail) => string; ext: string; mime: string }
> = {
markdown: { fn: exportAsMarkdown, ext: 'md', mime: 'text/markdown;charset=utf-8' },
json: { fn: exportAsJson, ext: 'json', mime: 'application/json;charset=utf-8' },
plaintext: { fn: exportAsPlainText, ext: 'txt', mime: 'text/plain;charset=utf-8' },
};
const { fn, ext, mime } = formatters[format];
const content = fn(detail);
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `session-${detail.session.id}.${ext}`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}