427 lines
13 KiB
TypeScript
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);
|
|
}
|