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