UNPKG

@kaifronsdal/transcript-viewer

Version:

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

435 lines (374 loc) 15.4 kB
import type { Message, TranscriptEvent, Edit, Events, InfoEvent, InfoMessage, RawChatMessage, JSONPatchEdit, AddMessage, Reset } from './types'; import type { ToolCall } from './types'; import { isJsonPatchOperation, isAddOperation, isResetOperation } from './types'; import fastJsonPatch from 'fast-json-patch'; const { applyPatch } = fastJsonPatch as unknown as { applyPatch: typeof import('fast-json-patch').applyPatch }; // Helper function to generate branch titles based on edit names function getBranchTitle(branchIndex: number, edit?: Edit): string { if (branchIndex === 0) { return 'Original'; } // If this is a JSON patch edit with a name, use it if (edit && isJsonPatchOperation(edit) && edit.name) { return edit.name; } // Fallback to numbered branch return `Branch ${branchIndex}`; } // Helper to add a column to the list if it has content function addColumnIfNotEmpty(columns: ConversationColumn[], column: ConversationColumn): void { if (column.messages.length > 0) { columns.push({ ...column }); } } // Helper to create a new column function createColumn(columnIndex: number, edit?: Edit, editNumber: number = 0): ConversationColumn { return { id: `column-${columnIndex}`, title: getBranchTitle(columnIndex, edit), messages: [], editNumber: editNumber }; } // Helper to extract message from AddMessage edit function getMessageFromAddEdit(edit: Edit): RawChatMessage | null { if (isAddOperation(edit)) { return edit.message; } return null; } // Helper to extract new messages from Reset edit function getNewMessagesFromResetEdit(edit: Edit): RawChatMessage[] { if (isResetOperation(edit)) { return edit.new_messages || []; } return []; } // Helper to extract patch operations from JSONPatchEdit function getPatchFromJsonEdit(edit: Edit): any[] { if (isJsonPatchOperation(edit)) { return edit.patch; } return []; } export interface ConversationColumn { id: string; title: string; messages: Message[]; editNumber: number; } export interface SharedMessage { messageIndex: number; editNumber: number; isShared: boolean; } // Extract all unique views from transcript events export function extractAvailableViews(events: Events[]): string[] { console.log('🔍 [DEBUG] extractAvailableViews called with', events.length, 'events'); const viewSet = new Set<string>(); // Filter for TranscriptEvent types (ignore other schema events) const transcriptEvents = events.filter((event): event is TranscriptEvent => event.type === 'transcript_event'); console.log('📋 [DEBUG] Found', transcriptEvents.length, 'transcript events'); for (const event of transcriptEvents) { if (Array.isArray(event.view)) { event.view.forEach(view => viewSet.add(view)); } else { viewSet.add(event.view); } } const result = Array.from(viewSet).sort(); console.log('✅ [DEBUG] extractAvailableViews returning:', result); return result; } // Check if an event applies to a specific view function eventAppliesToView(event: TranscriptEvent, targetView: string): boolean { if (Array.isArray(event.view)) { return event.view.includes(targetView); } return event.view === targetView; } // Map a view name to the underlying message list in the transcript structure function listKeyForView(view: string): 'messages' | 'target_messages' { const v = (view || '').toLowerCase(); return v === 'target' ? 'target_messages' : 'messages'; } // Normalize a raw schema v3 chat message into the internal Message shape function normalizeRawMessage(raw: RawChatMessage, viewSource: string | string[] | undefined, eventId: string | undefined): Message { const baseProps = { id: raw.id ?? undefined, metadata: raw.metadata ?? undefined, isShared: false, viewSource: viewSource, eventId }; switch (raw.role) { case 'system': return { type: 'system', content: raw.content, ...baseProps } as Message; case 'user': return { type: 'user', content: raw.content, ...baseProps } as Message; case 'assistant': return { type: 'assistant', content: raw.content, tool_calls: raw.tool_calls ?? undefined, ...baseProps } as Message; case 'tool': { return { type: 'tool', content: raw.content, tool_call_id: raw.tool_call_id ?? undefined, function: raw.function ?? undefined, error: raw.error ?? undefined, ...baseProps } as Message; } default: // Fallback to system if unknown role return { type: 'system', content: (raw as any).content, ...baseProps } as Message; } } // Parse events from the new transcript format with careful rollback handling export function parseTranscriptEvents(events: Events[], view: string, showApiFailures: boolean = false): ConversationColumn[] { console.log('🔄 [DEBUG] parseTranscriptEvents called:', { eventsLength: events?.length || 0, view, showApiFailures }); // Check if events is a valid array if (!events || !Array.isArray(events)) { console.error('🚨 [ERROR] parseTranscriptEvents: events is not a valid array:', events); return []; } const columns: ConversationColumn[] = []; let currentMessages: Message[] = []; let editNumber = 0; // Track the current column we're building let currentColumn: ConversationColumn = { id: 'column-0', title: 'Original', messages: [], editNumber: 0 }; // Filter for TranscriptEvent types and InfoEvent types const transcriptEvents = events.filter((event): event is TranscriptEvent => event.type === 'transcript_event'); const infoEvents = events.filter((event): event is InfoEvent => event.type === 'info_event'); console.log('📋 [DEBUG] Filtered transcript events:', transcriptEvents.length, 'info events:', infoEvents.length); // Combine and sort events by timestamp if available, otherwise maintain original order const allRelevantEvents = [ ...transcriptEvents.filter(event => eventAppliesToView(event, view)), ...infoEvents // Info events don't have view filtering - they appear in all views ].sort((a, b) => { // Sort by timestamp if both have it, otherwise maintain original order if (a.timestamp && b.timestamp) { return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); } return 0; }); console.log('🎯 [DEBUG] All relevant events for view', view + ':', allRelevantEvents.length); for (let i = 0; i < allRelevantEvents.length; i++) { const event = allRelevantEvents[i]; editNumber++; if (event.type === 'info_event') { // Handle InfoEvent by converting it to an InfoMessage const infoMessage: InfoMessage = { type: 'info', id: event.id, info: event.info, timestamp: event.timestamp, isShared: false, viewSource: 'info', // Mark as coming from info events eventId: event.id }; currentMessages.push(infoMessage as Message); currentColumn.messages.push(infoMessage as Message); } else if (event.type === 'transcript_event' && isAddOperation(event.edit)) { const raw = getMessageFromAddEdit(event.edit); if (!raw) continue; const message = normalizeRawMessage(raw, event.view, event.id); // Skip API failures if not showing them if (message.type === 'api_failure' && !showApiFailures) { continue; } // Mark message as shared/not shared and add view information currentMessages.push(message); currentColumn.messages.push(message); } else if (event.type === 'transcript_event' && event.edit.operation === 'rollback') { const rollbackEdit = event.edit; // Finalize current column and start new one for this rollback addColumnIfNotEmpty(columns, currentColumn); currentColumn = createColumn(columns.length, event.edit, editNumber); // Determine rollback target let rollbackIndex = currentMessages.length; if (rollbackEdit.to_id) { // Find the index of the message with the specified ID const targetIndex = currentMessages.findIndex(msg => msg.id === rollbackEdit.to_id); if (targetIndex !== -1) { // Rollback to just after this message (keep the message with to_id) rollbackIndex = targetIndex + 1; } } else if (rollbackEdit.count) { // Fallback to count-based rollback const rollbackCount = Math.min(rollbackEdit.count, currentMessages.length); rollbackIndex = currentMessages.length - rollbackCount; } // Rollback by keeping only messages up to the rollback index currentMessages = currentMessages.slice(0, rollbackIndex); // Set the new column's messages to the remaining messages, marked as shared currentColumn.messages = currentMessages.map(msg => ({ ...msg, isShared: true })); } else if (event.type === 'transcript_event' && isResetOperation(event.edit)) { // Finalize current column and start new one for this reset addColumnIfNotEmpty(columns, currentColumn); currentColumn = createColumn(columns.length, event.edit, editNumber); // Reset to new messages const newMessages = getNewMessagesFromResetEdit(event.edit); const messagesWithFlags: Message[] = newMessages.map((raw) => normalizeRawMessage(raw, event.view, event.id) ); currentMessages = messagesWithFlags; currentColumn.messages = [...currentMessages]; } else if (event.type === 'transcript_event' && isJsonPatchOperation(event.edit)) { // Apply JSON Patch to the correct list (array root) using fast-json-patch and handle branching try { const ops = getPatchFromJsonEdit(event.edit); // Apply patch directly to the messages array (paths like "/0", "/1" index into the array) const result = applyPatch(currentMessages, ops, /*validate*/ false, /*mutateDocument*/ false); const nextMessagesRaw = result.newDocument; if (!Array.isArray(nextMessagesRaw)) { continue; } const nextMessages = nextMessagesRaw.map((m) => { // If it has a 'role' property, it's a RawChatMessage that needs normalization if (m && typeof m === 'object' && 'role' in m) { return normalizeRawMessage(m as RawChatMessage, event.view, event.id); } // Otherwise it's already a Message return m as Message; }); // Longest common prefix between old and new messages (ignoring UI-only fields) const normalize = (msg: any) => { const { isShared, viewSource, eventId, ...rest } = msg || {}; return rest; }; const areEqual = (a: any, b: any) => JSON.stringify(normalize(a)) === JSON.stringify(normalize(b)); let prefixLen = 0; const maxPrefix = Math.min(currentMessages.length, nextMessages.length); while (prefixLen < maxPrefix && areEqual(currentMessages[prefixLen], nextMessages[prefixLen])) { prefixLen++; } const oldIsPrefix = prefixLen === currentMessages.length && currentMessages.length <= nextMessages.length; if (!oldIsPrefix) { // This edit creates a new branch - finalize current column and start new one addColumnIfNotEmpty(columns, currentColumn); currentColumn = createColumn(columns.length, event.edit, editNumber); // Set messages up to prefix as shared (branch point) const messagesWithFlags: Message[] = nextMessages.map((msg, index) => ({ ...msg, isShared: index < prefixLen, viewSource: msg.viewSource ?? event.view, eventId: msg.eventId ?? event.id })); currentMessages = messagesWithFlags; currentColumn.messages = [...currentMessages]; } else { // Old list is a prefix: continue in the same column const messagesWithFlags: Message[] = nextMessages.map((msg) => ({ ...msg, isShared: msg.isShared ?? false, viewSource: msg.viewSource ?? event.view, eventId: msg.eventId ?? event.id })); currentMessages = messagesWithFlags; currentColumn.messages = [...currentMessages]; // If this is the first edit affecting the original column, update its title if (currentColumn.title === 'Original' && isJsonPatchOperation(event.edit) && event.edit.name) { currentColumn.title = event.edit.name; } } } catch (e) { console.error('Failed to apply JSON patch edit', e); } } } // Add the final column console.log('➕ [DEBUG] Adding final column with', currentColumn.messages.length, 'messages'); currentColumn.editNumber = editNumber; addColumnIfNotEmpty(columns, currentColumn); console.log('✅ [DEBUG] parseTranscriptEvents completed, returning', columns.length, 'columns'); return columns; } export function getMessageTypeColor(type: string): string { switch (type.toLowerCase()) { case 'system': return 'bg-blue-50 border-blue-200 text-gray-900 dark:text-gray-100'; case 'user': return 'bg-white border-gray-200 text-gray-900 dark:text-gray-100'; case 'assistant': return 'bg-green-50 border-green-200 text-gray-900 dark:text-gray-100'; case 'tool': return 'bg-yellow-50 border-yellow-200 text-gray-900 dark:text-gray-100'; case 'api_failure': return 'bg-red-50 border-red-200 text-gray-900 dark:text-gray-100'; case 'info': return 'bg-blue-50 border-blue-200 text-gray-900 dark:text-gray-100'; default: return 'bg-gray-50 border-gray-200 text-gray-900 dark:text-gray-100'; } } export function getMessageBackgroundColor(type: string): string { switch (type.toLowerCase()) { case 'system': return 'bg-blue-50 dark:bg-blue-950/30'; case 'user': return 'bg-purple-50 dark:bg-purple-950/30'; case 'assistant': return 'bg-green-50 dark:bg-green-950/30'; case 'tool': return 'bg-orange-50 dark:bg-orange-950/30'; case 'api_failure': return 'bg-red-50 dark:bg-red-950/30'; case 'info': return 'bg-cyan-50 dark:bg-cyan-950/30'; default: return 'bg-gray-50 dark:bg-gray-800'; } } export function getMessageBorderColor(type: string): string { switch (type.toLowerCase()) { case 'system': return 'border-blue-200 dark:border-blue-700 border-l-blue-500 dark:border-l-blue-400'; case 'user': return 'border-purple-200 dark:border-purple-700 border-l-purple-500 dark:border-l-purple-400'; case 'assistant': return 'border-green-200 dark:border-green-700 border-l-green-500 dark:border-l-green-400'; case 'tool': return 'border-orange-200 dark:border-orange-700 border-l-orange-500 dark:border-l-orange-400'; case 'api_failure': return 'border-red-200 dark:border-red-700 border-l-red-500 dark:border-l-red-400'; case 'info': return 'border-cyan-200 dark:border-cyan-700 border-l-cyan-500 dark:border-l-cyan-400'; default: return 'border-gray-200 dark:border-gray-700 border-l-gray-500 dark:border-l-gray-400'; } } export function getMessageTypeBadgeColor(type: string): string { switch (type.toLowerCase()) { case 'system': return 'badge-info'; case 'user': return 'badge-primary'; case 'assistant': return 'badge-secondary'; case 'tool': return 'badge-accent'; case 'api_failure': return 'badge-error'; case 'info': return 'badge-info'; default: return 'badge-neutral'; } }