UNPKG

@kaifronsdal/transcript-viewer

Version:

A web-based viewer for AI conversation transcripts with rollback support

325 lines (281 loc) 9.29 kB
import type { Message, Events, ToolDefinition, ToolCall } from '$lib/shared/types'; import type { ConversationColumn } from '$lib/shared/transcript-parser'; export interface CopyResult { success: boolean; message: string; isError: boolean; } /** * Copy text to clipboard with fallback for older browsers */ export async function copyToClipboard(text: string): Promise<boolean> { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } // Fallback for older browsers or non-secure contexts const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const result = document.execCommand('copy'); document.body.removeChild(textArea); return result; } catch (err) { console.error('Failed to copy to clipboard:', err); return false; } } /** * Remove null values from objects recursively */ function removeNullValues(obj: any): any { if (obj === null || obj === undefined) { return undefined; } if (Array.isArray(obj)) { return obj.map(removeNullValues).filter(item => item !== undefined); } if (typeof obj === 'object') { const cleaned: any = {}; for (const [key, value] of Object.entries(obj)) { const cleanedValue = removeNullValues(value); if (cleanedValue !== undefined) { cleaned[key] = cleanedValue; } } return cleaned; } return obj; } /** * Clean message data for copying (remove UI-specific properties) */ function cleanMessageForCopy(message: Message): Omit<Message, 'isShared' | 'viewSource' | 'eventId'> { const { isShared, viewSource, eventId, ...cleanMessage } = message; // Clean tool_calls if they exist (only on AssistantMessage) if (cleanMessage.type === 'assistant' && cleanMessage.tool_calls && Array.isArray(cleanMessage.tool_calls)) { cleanMessage.tool_calls = cleanMessage.tool_calls.map((toolCall: ToolCall) => { const { render, ...cleanToolCall } = toolCall; return cleanToolCall; }); } return cleanMessage; } /** * Copy message history up to a specific message (inclusive) */ export async function copyMessageHistory( conversationColumns: ConversationColumn[], columnIndex: number, messageIndex: number ): Promise<CopyResult> { if (!conversationColumns[columnIndex]) { return { success: false, message: 'Invalid column index', isError: true }; } const column = conversationColumns[columnIndex]; const messagesUpToPoint = column.messages.slice(0, messageIndex + 1); const cleanMessages = messagesUpToPoint.map(cleanMessageForCopy); const cleanedData = removeNullValues(cleanMessages); const jsonString = JSON.stringify(cleanedData, null, 2); const success = await copyToClipboard(jsonString); return { success, message: success ? `Copied ${cleanMessages.length} messages to clipboard` : 'Failed to copy to clipboard', isError: !success }; } /** * Copy a single message */ export async function copySingleMessage(message: Message): Promise<CopyResult> { const cleanMessage = cleanMessageForCopy(message); const cleanedData = removeNullValues([cleanMessage]); const jsonString = JSON.stringify(cleanedData, null, 2); const success = await copyToClipboard(jsonString); return { success, message: success ? 'Copied message to clipboard' : 'Failed to copy to clipboard', isError: !success }; } /** * Copy events up to a specific message (inclusive) */ export async function copyEventsUpToMessage( conversationColumns: ConversationColumn[], transcriptEvents: Events[], columnIndex: number, messageIndex: number ): Promise<CopyResult> { if (!conversationColumns[columnIndex] || !transcriptEvents) { return { success: false, message: 'Invalid data or column index', isError: true }; } const column = conversationColumns[columnIndex]; const targetMessage = column.messages[messageIndex]; if (!targetMessage.eventId) { return { success: false, message: 'Cannot copy events: message has no event ID', isError: true }; } // Find the target event by ID and get all events up to and including it const targetEventIndex = transcriptEvents.findIndex(event => event.type === 'transcript_event' && event.id === targetMessage.eventId ); if (targetEventIndex === -1) { return { success: false, message: 'Cannot find event for message', isError: true }; } const eventsUpToPoint = transcriptEvents.slice(0, targetEventIndex + 1); const cleanedData = removeNullValues(eventsUpToPoint); const jsonString = JSON.stringify(cleanedData, null, 2); const success = await copyToClipboard(jsonString); return { success, message: success ? `Copied ${eventsUpToPoint.length} events to clipboard` : 'Failed to copy events to clipboard', isError: !success }; } /** * Get tools created up to a specific message point */ function getToolsUpToMessage( conversationColumns: ConversationColumn[], transcriptEvents: Events[], columnIndex: number, messageIndex: number ): (ToolDefinition | string)[] { if (!conversationColumns[columnIndex] || !transcriptEvents) return []; const column = conversationColumns[columnIndex]; const targetMessage = column.messages[messageIndex]; if (!targetMessage.eventId) { return []; } // Find the target event by ID and get all events up to and including it const targetEventIndex = transcriptEvents.findIndex(event => event.type === 'transcript_event' && event.id === targetMessage.eventId ); if (targetEventIndex === -1) { return []; } const eventsUpToPoint = transcriptEvents.slice(0, targetEventIndex + 1); const tools: (ToolDefinition | string)[] = []; // Extract tool definitions and function code strings from events for (const event of eventsUpToPoint) { if (event.type === 'tool_creation_event') { tools.push(event.tool); } // Also check for function code strings in info events if (event.type === 'info_event' && typeof event.info === 'string') { // Check if this looks like function code if (event.info.includes('def ') || event.info.includes('function ') || event.info.includes('=>')) { tools.push(event.info); } } } return tools; } /** * Copy tools created up to a specific message point */ export async function copyToolsUpToMessage( conversationColumns: ConversationColumn[], transcriptEvents: Events[], columnIndex: number, messageIndex: number ): Promise<CopyResult> { const tools = getToolsUpToMessage(conversationColumns, transcriptEvents, columnIndex, messageIndex); if (tools.length === 0) { return { success: true, message: 'No tools created up to this point', isError: false }; } const cleanedTools = removeNullValues(tools); const jsonString = JSON.stringify(cleanedTools, null, 2); const success = await copyToClipboard(jsonString); if (success) { // Check if we have tool definitions or function code strings const isToolDefinitions = tools.length > 0 && typeof tools[0] === 'object' && 'name' in tools[0]; const toolType = isToolDefinitions ? 'tool definitions' : 'function code strings'; return { success: true, message: `Copied ${tools.length} ${toolType} to clipboard`, isError: false }; } return { success: false, message: 'Failed to copy tools to clipboard', isError: true }; } /** * Unified copy action handler */ export type CopyAction = | { type: 'history'; columnIndex: number; messageIndex: number } | { type: 'events'; columnIndex: number; messageIndex: number } | { type: 'single'; message: Message } | { type: 'tools'; columnIndex: number; messageIndex: number }; export async function handleCopyAction( action: CopyAction, conversationColumns: ConversationColumn[], transcriptEvents?: Events[] ): Promise<CopyResult> { switch (action.type) { case 'history': return copyMessageHistory(conversationColumns, action.columnIndex, action.messageIndex); case 'single': return copySingleMessage(action.message); case 'events': if (!transcriptEvents) { return { success: false, message: 'No transcript events available', isError: true }; } return copyEventsUpToMessage(conversationColumns, transcriptEvents, action.columnIndex, action.messageIndex); case 'tools': if (!transcriptEvents) { return { success: false, message: 'No transcript events available', isError: true }; } return copyToolsUpToMessage(conversationColumns, transcriptEvents, action.columnIndex, action.messageIndex); default: return { success: false, message: 'Unknown copy action type', isError: true }; } }