@kaifronsdal/transcript-viewer
Version:
A web-based viewer for AI conversation transcripts with rollback support
435 lines (374 loc) • 15.4 kB
text/typescript
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';
}
}