diff --git a/proxy/internal/service/model_router.go b/proxy/internal/service/model_router.go index afc5276..3e7ac60 100644 --- a/proxy/internal/service/model_router.go +++ b/proxy/internal/service/model_router.go @@ -25,7 +25,6 @@ type ModelRouter struct { providers map[string]provider.Provider subagentMappings map[string]string // agentName -> targetModel customAgentPrompts map[string]SubagentDefinition // promptHash -> definition - modelProviderMap map[string]string // model -> provider mapping logger *log.Logger } @@ -42,7 +41,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider, providers: providers, subagentMappings: cfg.Subagents.Mappings, customAgentPrompts: make(map[string]SubagentDefinition), - modelProviderMap: initializeModelProviderMap(), logger: logger, } @@ -58,63 +56,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider, return router } -// initializeModelProviderMap creates a mapping of model names to their providers -func initializeModelProviderMap() map[string]string { - modelMap := make(map[string]string) - - // OpenAI models - openaiModels := []string{ - // GPT-5 family - "gpt-5", "gpt-5-mini", "gpt-5-nano", - - // GPT-4.1 family - "gpt-4.1", "gpt-4.1-2025-04-14", - "gpt-4.1-mini", "gpt-4.1-mini-2025-04-14", - "gpt-4.1-nano", "gpt-4.1-nano-2025-04-14", - - // GPT-4.5 - "gpt-4.5-preview", "gpt-4.5-preview-2025-02-27", - - // GPT-4o variants - "gpt-4o", "gpt-4o-2024-08-06", - "gpt-4o-mini", "gpt-4o-mini-2024-07-18", - - // GPT-3.5 variants - "gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-instruct", - - // O1 series - "o1", "o1-2024-12-17", - "o1-pro", "o1-pro-2025-03-19", - "o1-mini", "o1-mini-2024-09-12", - - // O3 series - "o3-pro", "o3-pro-2025-06-10", - "o3", "o3-2025-04-16", - "o3-mini", "o3-mini-2025-01-31", - } - - for _, model := range openaiModels { - modelMap[model] = "openai" - } - - // Anthropic models - anthropicModels := []string{ - "claude-opus-4-1-20250805", - "claude-opus-4-20250514", - "claude-sonnet-4-20250514", - "claude-sonnet-4-5-20250929", - "claude-opus-4-5-20251101", - "claude-3-7-sonnet-20250219", - "claude-3-5-haiku-20241022", - } - - for _, model := range anthropicModels { - modelMap[model] = "anthropic" - } - - return modelMap -} - // extractStaticPrompt extracts the portion before "Notes:" if it exists func (r *ModelRouter) extractStaticPrompt(systemPrompt string) string { // Find the "Notes:" section @@ -265,11 +206,21 @@ func (r *ModelRouter) hashString(s string) string { } func (r *ModelRouter) getProviderNameForModel(model string) string { - if provider, exists := r.modelProviderMap[model]; exists { - return provider + modelLower := strings.ToLower(model) + + // Anthropic models: claude-* + if strings.HasPrefix(modelLower, "claude") { + return "anthropic" + } + + // OpenAI models: gpt-*, o1*, o3* + if strings.HasPrefix(modelLower, "gpt") || + strings.HasPrefix(modelLower, "o1") || + strings.HasPrefix(modelLower, "o3") { + return "openai" } - // Default to anthropic + // Default to anthropic for unknown models r.logger.Printf("⚠️ Model '%s' doesn't match any known patterns, defaulting to anthropic", model) return "anthropic" } diff --git a/web/app/components/CodeViewer.test.tsx b/web/app/components/CodeViewer.test.tsx new file mode 100644 index 0000000..be2568e --- /dev/null +++ b/web/app/components/CodeViewer.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; + +// Test the escapeHtml and string regex patterns used in CodeViewer +// We test the logic directly since the component uses internal functions + +describe('CodeViewer escapeHtml', () => { + // Replicate the escapeHtml function from CodeViewer + const escapeHtml = (str: string) => str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + it('escapes double quotes for attribute safety', () => { + expect(escapeHtml('class="foo"')).toBe('class="foo"'); + }); + + it('escapes single quotes for attribute safety', () => { + expect(escapeHtml("class='foo'")).toBe("class='foo'"); + }); + + it('escapes HTML tags', () => { + expect(escapeHtml('
')).toBe('<div>'); + }); + + it('escapes ampersands', () => { + expect(escapeHtml('a && b')).toBe('a && b'); + }); +}); + +describe('CodeViewer string regex patterns', () => { + // Test the improved string patterns + const doubleQuotePattern = /"(?:[^"\\]|\\.)*"/; + const singleQuotePattern = /'(?:[^'\\]|\\.)*'/; + const backtickPattern = /`(?:[^`\\]|\\.)*`/; + + describe('double-quoted strings', () => { + it('matches simple double-quoted strings', () => { + expect('"hello"'.match(doubleQuotePattern)?.[0]).toBe('"hello"'); + }); + + it('matches strings with escaped quotes', () => { + expect('"He said \\"hello\\""'.match(doubleQuotePattern)?.[0]).toBe('"He said \\"hello\\""'); + }); + + it('matches strings with escaped backslashes', () => { + expect('"path\\\\to\\\\file"'.match(doubleQuotePattern)?.[0]).toBe('"path\\\\to\\\\file"'); + }); + + it('matches empty strings', () => { + expect('""'.match(doubleQuotePattern)?.[0]).toBe('""'); + }); + }); + + describe('single-quoted strings', () => { + it('matches simple single-quoted strings', () => { + expect("'hello'".match(singleQuotePattern)?.[0]).toBe("'hello'"); + }); + + it('matches strings with escaped quotes', () => { + expect("'It\\'s fine'".match(singleQuotePattern)?.[0]).toBe("'It\\'s fine'"); + }); + + it('matches empty strings', () => { + expect("''".match(singleQuotePattern)?.[0]).toBe("''"); + }); + }); + + describe('backtick strings', () => { + it('matches simple backtick strings', () => { + expect('`hello`'.match(backtickPattern)?.[0]).toBe('`hello`'); + }); + + it('matches strings with escaped backticks', () => { + expect('`use \\`code\\``'.match(backtickPattern)?.[0]).toBe('`use \\`code\\``'); + }); + + it('matches empty strings', () => { + expect('``'.match(backtickPattern)?.[0]).toBe('``'); + }); + }); +}); diff --git a/web/app/components/CodeViewer.tsx b/web/app/components/CodeViewer.tsx index f9f4aae..5f9cc4a 100644 --- a/web/app/components/CodeViewer.tsx +++ b/web/app/components/CodeViewer.tsx @@ -82,39 +82,68 @@ export function CodeViewer({ code, fileName, language }: CodeViewerProps) { const detectedLanguage = language || getLanguageFromFileName(fileName); - // Basic syntax highlighting for common tokens + // Single-pass syntax highlighting to avoid corrupting HTML class attributes const highlightCode = (code: string): string => { - // Escape HTML - let highlighted = code + // Escape HTML helper + const escapeHtml = (str: string) => str .replace(/&/g, '&') .replace(//g, '>'); - - // Common patterns for many languages - const patterns = [ - // Strings - { regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, class: 'text-green-400' }, - // Comments - { regex: /(\/\/.*$)/gm, class: 'text-gray-500 italic' }, - { regex: /(\/\*[\s\S]*?\*\/)/g, class: 'text-gray-500 italic' }, - { regex: /(#.*$)/gm, class: 'text-gray-500 italic' }, - // Numbers - { regex: /\b(\d+\.?\d*)\b/g, class: 'text-purple-400' }, - // Keywords (common across many languages) - { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/g, class: 'text-blue-400' }, - // Boolean and null values - { regex: /\b(true|false|null|undefined|nil|None|True|False)\b/g, class: 'text-orange-400' }, - // Function calls (basic) - { regex: /(\w+)(?=\s*\()/g, class: 'text-yellow-400' }, - // Types/Classes (PascalCase) - { regex: /\b([A-Z][a-zA-Z0-9]*)\b/g, class: 'text-cyan-400' }, + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Define token patterns with priorities (first match wins) + // Order matters: strings and comments first to avoid highlighting inside them + const tokenPatterns = [ + { regex: /"(?:[^"\\]|\\.)*"/, className: 'text-green-400' }, // double-quoted strings + { regex: /'(?:[^'\\]|\\.)*'/, className: 'text-green-400' }, // single-quoted strings + { regex: /`(?:[^`\\]|\\.)*`/, className: 'text-green-400' }, // backtick strings + { regex: /\/\/.*$/, className: 'text-gray-500 italic' }, // single-line comments + { regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' }, // multi-line comments + { regex: /#.*$/, className: 'text-gray-500 italic' }, // hash comments + { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default|fn|pub|mod|use|mut|match|loop|impl|trait|where|type|readonly|override)\b/, className: 'text-blue-400' }, // keywords + { regex: /\b(true|false|null|undefined|nil|None|True|False|NULL)\b/, className: 'text-orange-400' }, // literals + { regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' }, // numbers + { regex: /\b[A-Z][a-zA-Z0-9]*\b/, className: 'text-cyan-400' }, // PascalCase (types/classes) ]; - patterns.forEach(({ regex, class: className }) => { - highlighted = highlighted.replace(regex, `$&`); - }); + // Build a combined regex that matches any token + const combinedPattern = new RegExp( + tokenPatterns.map(p => `(${p.regex.source})`).join('|'), + 'gm' + ); + + let result = ''; + let lastIndex = 0; + + // Single pass through the string + for (const match of code.matchAll(combinedPattern)) { + // Add non-matched text before this match (escaped) + if (match.index! > lastIndex) { + result += escapeHtml(code.slice(lastIndex, match.index)); + } + + // Find which pattern matched (first non-undefined capture group) + const matchedText = match[0]; + let className = ''; + for (let i = 0; i < tokenPatterns.length; i++) { + if (match[i + 1] !== undefined) { + className = tokenPatterns[i].className; + break; + } + } + + // Add the highlighted token (escape the matched text too) + result += `${escapeHtml(matchedText)}`; + lastIndex = match.index! + matchedText.length; + } + + // Add remaining text after last match + if (lastIndex < code.length) { + result += escapeHtml(code.slice(lastIndex)); + } - return highlighted; + return result; }; const handleCopy = async () => { diff --git a/web/app/components/RequestCompareModal.tsx b/web/app/components/RequestCompareModal.tsx new file mode 100644 index 0000000..2d5ad0e --- /dev/null +++ b/web/app/components/RequestCompareModal.tsx @@ -0,0 +1,1152 @@ +import { useState, useMemo } from 'react'; +import { + X, + GitCompare, + Plus, + Minus, + Equal, + ChevronDown, + ChevronRight, + MessageCircle, + User, + Bot, + Settings, + Clock, + Cpu, + Brain, + ArrowRight, + List, + FileText, + Download +} from 'lucide-react'; +import { MessageContent } from './MessageContent'; + +interface Message { + role: string; + content: any; +} + +interface Request { + id: number; + timestamp: string; + method: string; + endpoint: string; + headers: Record; + originalModel?: string; + routedModel?: string; + body?: { + model?: string; + messages?: Message[]; + system?: Array<{ + text: string; + type: string; + cache_control?: { type: string }; + }>; + tools?: Array<{ + name: string; + description: string; + input_schema?: any; + }>; + max_tokens?: number; + temperature?: number; + stream?: boolean; + }; + response?: { + statusCode: number; + headers: Record; + body?: any; + bodyText?: string; + responseTime: number; + streamingChunks?: string[]; + isStreaming: boolean; + completedAt: string; + }; +} + +interface RequestCompareModalProps { + request1: Request; + request2: Request; + onClose: () => void; +} + +type DiffType = 'added' | 'removed' | 'unchanged' | 'modified'; + +interface MessageDiff { + type: DiffType; + index1?: number; + index2?: number; + message1?: Message; + message2?: Message; +} + +// Extract text content from a message for comparison +function getMessageText(content: any): string { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content + .map(block => { + if (typeof block === 'string') return block; + if (block.type === 'text') return block.text || ''; + if (block.type === 'tool_use') return `[Tool: ${block.name}]`; + if (block.type === 'tool_result') return `[Tool Result: ${block.tool_use_id}]`; + return JSON.stringify(block); + }) + .join('\n'); + } + return JSON.stringify(content); +} + +// Compare two messages to see if they're similar +function messagesAreSimilar(msg1: Message, msg2: Message): boolean { + if (msg1.role !== msg2.role) return false; + const text1 = getMessageText(msg1.content); + const text2 = getMessageText(msg2.content); + // Consider messages similar if they share >80% of content + const shorter = Math.min(text1.length, text2.length); + const longer = Math.max(text1.length, text2.length); + if (longer === 0) return true; + if (shorter / longer < 0.5) return false; + // Simple check: if one is a prefix of the other or they're equal + return text1 === text2 || text1.startsWith(text2.slice(0, 100)) || text2.startsWith(text1.slice(0, 100)); +} + +// Compute diff between two message arrays +function computeMessageDiff(messages1: Message[], messages2: Message[]): MessageDiff[] { + const diffs: MessageDiff[] = []; + let i = 0; + let j = 0; + + while (i < messages1.length || j < messages2.length) { + if (i >= messages1.length) { + // All remaining messages in request2 are additions + diffs.push({ + type: 'added', + index2: j, + message2: messages2[j] + }); + j++; + } else if (j >= messages2.length) { + // All remaining messages in request1 are removals + diffs.push({ + type: 'removed', + index1: i, + message1: messages1[i] + }); + i++; + } else if (messagesAreSimilar(messages1[i], messages2[j])) { + // Messages match + const text1 = getMessageText(messages1[i].content); + const text2 = getMessageText(messages2[j].content); + diffs.push({ + type: text1 === text2 ? 'unchanged' : 'modified', + index1: i, + index2: j, + message1: messages1[i], + message2: messages2[j] + }); + i++; + j++; + } else { + // Look ahead to find a match + let foundMatch = false; + + // Check if messages1[i] matches something ahead in messages2 + for (let k = j + 1; k < Math.min(j + 5, messages2.length); k++) { + if (messagesAreSimilar(messages1[i], messages2[k])) { + // messages2[j..k-1] are additions + for (let l = j; l < k; l++) { + diffs.push({ + type: 'added', + index2: l, + message2: messages2[l] + }); + } + j = k; + foundMatch = true; + break; + } + } + + if (!foundMatch) { + // Check if messages2[j] matches something ahead in messages1 + for (let k = i + 1; k < Math.min(i + 5, messages1.length); k++) { + if (messagesAreSimilar(messages1[k], messages2[j])) { + // messages1[i..k-1] are removals + for (let l = i; l < k; l++) { + diffs.push({ + type: 'removed', + index1: l, + message1: messages1[l] + }); + } + i = k; + foundMatch = true; + break; + } + } + } + + if (!foundMatch) { + // No match found, treat as removal then addition + diffs.push({ + type: 'removed', + index1: i, + message1: messages1[i] + }); + i++; + } + } + } + + return diffs; +} + +export function RequestCompareModal({ request1, request2, onClose }: RequestCompareModalProps) { + const [viewMode, setViewMode] = useState<'structured' | 'diff'>('structured'); + const [expandedSections, setExpandedSections] = useState>({ + summary: true, + messages: true, + system: false, + tools: false + }); + + const toggleSection = (section: string) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + const messages1 = request1.body?.messages || []; + const messages2 = request2.body?.messages || []; + + const messageDiffs = useMemo(() => computeMessageDiff(messages1, messages2), [messages1, messages2]); + + const diffStats = useMemo(() => { + const stats = { + added: 0, + removed: 0, + modified: 0, + unchanged: 0 + }; + messageDiffs.forEach(diff => { + stats[diff.type]++; + }); + return stats; + }, [messageDiffs]); + + const getModelDisplay = (request: Request) => { + const model = request.routedModel || request.body?.model || 'Unknown'; + if (model.includes('opus')) return { name: 'Opus', color: 'text-purple-600' }; + if (model.includes('sonnet')) return { name: 'Sonnet', color: 'text-indigo-600' }; + if (model.includes('haiku')) return { name: 'Haiku', color: 'text-teal-600' }; + return { name: model, color: 'text-gray-600' }; + }; + + const model1 = getModelDisplay(request1); + const model2 = getModelDisplay(request2); + + return ( +
+
+ {/* Header */} +
+
+
+ +

Compare Requests

+
+ {model1.name} + + {model2.name} +
+
+
+ {/* View mode toggle */} +
+ + +
+ +
+
+
+ + {/* Content */} +
+ {viewMode === 'diff' ? ( + + ) : ( + <> + {/* Summary Section */} +
+
toggleSection('summary')} + > +
+

+ + Comparison Summary +

+ {expandedSections.summary ? ( + + ) : ( + + )} +
+
+ {expandedSections.summary && ( +
+ {/* Stats */} +
+
+
+ + {diffStats.added} +
+
Added
+
+
+
+ + {diffStats.removed} +
+
Removed
+
+
+
+ + {diffStats.modified} +
+
Modified
+
+
+
+ + {diffStats.unchanged} +
+
Unchanged
+
+
+ + {/* Request comparison */} +
+ + +
+
+ )} +
+ + {/* Messages Diff Section */} +
+
toggleSection('messages')} + > +
+

+ + Message Differences + + {messages1.length} vs {messages2.length} messages + +

+ {expandedSections.messages ? ( + + ) : ( + + )} +
+
+ {expandedSections.messages && ( +
+ {messageDiffs.length === 0 ? ( +
+ +

No messages to compare

+
+ ) : ( + messageDiffs.map((diff, index) => ( + + )) + )} +
+ )} +
+ + {/* System Prompts Comparison */} + {(request1.body?.system || request2.body?.system) && ( +
+
toggleSection('system')} + > +
+

+ + System Prompts + + {request1.body?.system?.length || 0} vs {request2.body?.system?.length || 0} + +

+ {expandedSections.system ? ( + + ) : ( + + )} +
+
+ {expandedSections.system && ( +
+
+
+
Request #1
+ {request1.body?.system?.map((sys, i) => ( +
+
+                            {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''}
+                          
+
+ )) ||
No system prompt
} +
+
+
Request #2
+ {request2.body?.system?.map((sys, i) => ( +
+
+                            {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''}
+                          
+
+ )) ||
No system prompt
} +
+
+
+ )} +
+ )} + + {/* Tools Comparison */} + {(request1.body?.tools || request2.body?.tools) && ( +
+
toggleSection('tools')} + > +
+

+ + Available Tools + + {request1.body?.tools?.length || 0} vs {request2.body?.tools?.length || 0} + +

+ {expandedSections.tools ? ( + + ) : ( + + )} +
+
+ {expandedSections.tools && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+ ); +} + +// Convert full request to plain text for diff +function requestToText(request: Request): string[] { + const lines: string[] = []; + + // System prompt + if (request.body?.system && request.body.system.length > 0) { + lines.push('=== SYSTEM PROMPT ==='); + request.body.system.forEach((sys, idx) => { + lines.push(`--- System Block [${idx + 1}] (${(new Blob([sys.text]).size / 1024).toFixed(1)} KB) ---`); + sys.text.split('\n').forEach(line => lines.push(line)); + lines.push(''); + }); + lines.push(''); + } + + // Tools (just names and sizes, not full definitions) + if (request.body?.tools && request.body.tools.length > 0) { + lines.push('=== TOOLS ==='); + const toolsSize = new Blob([JSON.stringify(request.body.tools)]).size; + lines.push(`Total: ${request.body.tools.length} tools (${(toolsSize / 1024).toFixed(1)} KB)`); + request.body.tools.forEach(tool => { + const toolSize = new Blob([JSON.stringify(tool)]).size; + lines.push(` - ${tool.name} (${(toolSize / 1024).toFixed(1)} KB)`); + }); + lines.push(''); + } + + // Messages + lines.push('=== MESSAGES ==='); + const messages = request.body?.messages || []; + messages.forEach((msg, idx) => { + const roleLabel = msg.role.toUpperCase(); + const msgSize = new Blob([getMessageText(msg.content)]).size; + lines.push(`--- ${roleLabel} [${idx + 1}] (${(msgSize / 1024).toFixed(1)} KB) ---`); + const text = getMessageText(msg.content); + text.split('\n').forEach(line => lines.push(line)); + lines.push(''); + }); + + return lines; +} + +// Simple line-based diff algorithm +function computeLineDiff(lines1: string[], lines2: string[]): Array<{ type: 'same' | 'added' | 'removed'; line: string; lineNum1?: number; lineNum2?: number }> { + const result: Array<{ type: 'same' | 'added' | 'removed'; line: string; lineNum1?: number; lineNum2?: number }> = []; + + // Use longest common subsequence approach + const m = lines1.length; + const n = lines2.length; + + // Build LCS table + const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (lines1[i - 1] === lines2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to find diff + let i = m, j = n; + const diffItems: Array<{ type: 'same' | 'added' | 'removed'; line: string; idx1?: number; idx2?: number }> = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && lines1[i - 1] === lines2[j - 1]) { + diffItems.unshift({ type: 'same', line: lines1[i - 1], idx1: i, idx2: j }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + diffItems.unshift({ type: 'added', line: lines2[j - 1], idx2: j }); + j--; + } else { + diffItems.unshift({ type: 'removed', line: lines1[i - 1], idx1: i }); + i--; + } + } + + // Convert to result with line numbers + let lineNum1 = 1, lineNum2 = 1; + for (const item of diffItems) { + if (item.type === 'same') { + result.push({ type: 'same', line: item.line, lineNum1: lineNum1++, lineNum2: lineNum2++ }); + } else if (item.type === 'removed') { + result.push({ type: 'removed', line: item.line, lineNum1: lineNum1++ }); + } else { + result.push({ type: 'added', line: item.line, lineNum2: lineNum2++ }); + } + } + + return result; +} + +// Text diff view component +function TextDiffView({ request1, request2 }: { request1: Request; request2: Request }) { + const lines1 = useMemo(() => requestToText(request1), [request1]); + const lines2 = useMemo(() => requestToText(request2), [request2]); + const diff = useMemo(() => computeLineDiff(lines1, lines2), [lines1, lines2]); + + const stats = useMemo(() => { + let added = 0, removed = 0, same = 0; + diff.forEach(d => { + if (d.type === 'added') added++; + else if (d.type === 'removed') removed++; + else same++; + }); + return { added, removed, same }; + }, [diff]); + + // Generate unified diff format + const generateUnifiedDiff = () => { + const lines: string[] = []; + lines.push('--- Request #1'); + lines.push('+++ Request #2'); + lines.push(''); + + diff.forEach(item => { + const prefix = item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' '; + lines.push(`${prefix}${item.line}`); + }); + + return lines.join('\n'); + }; + + // Generate markdown format + const generateMarkdown = () => { + const lines: string[] = []; + lines.push('# Request Comparison'); + lines.push(''); + lines.push(`**Added:** ${stats.added} lines | **Removed:** ${stats.removed} lines | **Unchanged:** ${stats.same} lines`); + lines.push(''); + lines.push('```diff'); + diff.forEach(item => { + const prefix = item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' '; + lines.push(`${prefix}${item.line}`); + }); + lines.push('```'); + return lines.join('\n'); + }; + + // Generate JSON format + const generateJSON = () => { + return JSON.stringify({ + stats, + request1: { + lines: lines1, + timestamp: request1.timestamp, + model: request1.routedModel || request1.body?.model + }, + request2: { + lines: lines2, + timestamp: request2.timestamp, + model: request2.routedModel || request2.body?.model + }, + diff: diff.map(d => ({ + type: d.type, + line: d.line, + lineNum1: d.lineNum1, + lineNum2: d.lineNum2 + })) + }, null, 2); + }; + + const handleDownload = (format: 'diff' | 'md' | 'json' | 'vscode') => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + // VS Code: download both files separately + if (format === 'vscode') { + const file1Content = lines1.join('\n'); + const file2Content = lines2.join('\n'); + + // Download first file + const blob1 = new Blob([file1Content], { type: 'text/plain' }); + const url1 = URL.createObjectURL(blob1); + const a1 = document.createElement('a'); + a1.href = url1; + a1.download = `request1-${timestamp}.txt`; + document.body.appendChild(a1); + a1.click(); + document.body.removeChild(a1); + URL.revokeObjectURL(url1); + + // Small delay then download second file + setTimeout(() => { + const blob2 = new Blob([file2Content], { type: 'text/plain' }); + const url2 = URL.createObjectURL(blob2); + const a2 = document.createElement('a'); + a2.href = url2; + a2.download = `request2-${timestamp}.txt`; + document.body.appendChild(a2); + a2.click(); + document.body.removeChild(a2); + URL.revokeObjectURL(url2); + + // Show instruction + alert(`Files downloaded!\n\nCompare with your preferred diff tool:\n diff ~/Downloads/request1-${timestamp}.txt ~/Downloads/request2-${timestamp}.txt\n\nOr in VS Code:\n code --diff ~/Downloads/request1-${timestamp}.txt ~/Downloads/request2-${timestamp}.txt`); + }, 100); + + return; + } + + let content: string; + let filename: string; + let type: string; + + switch (format) { + case 'md': + content = generateMarkdown(); + filename = `diff-${timestamp}.md`; + type = 'text/markdown'; + break; + case 'json': + content = generateJSON(); + filename = `diff-${timestamp}.json`; + type = 'application/json'; + break; + default: + content = generateUnifiedDiff(); + filename = `diff-${timestamp}.diff`; + type = 'text/plain'; + } + + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+
+
+

+ + Text Diff +

+
+ +{stats.added} added + -{stats.removed} removed + {stats.same} unchanged +
+ + + +
+
+
+
+
+ + + {diff.map((item, idx) => ( + + + + + + + ))} + +
+ {item.lineNum1 || ''} + + {item.lineNum2 || ''} + + {item.type === 'added' && +} + {item.type === 'removed' && -} + + {item.line || '\u00A0'} +
+
+
+ ); +} + +// Calculate size of content in KB +function getContentSize(content: any): number { + if (!content) return 0; + const text = typeof content === 'string' ? content : JSON.stringify(content); + return new Blob([text]).size; +} + +// Download helper +function downloadFile(content: string, filename: string, type: string = 'application/json') { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// Request summary card +function RequestSummaryCard({ request, label }: { request: Request; label: string }) { + const model = request.routedModel || request.body?.model || 'Unknown'; + const tokens = request.response?.body?.usage; + const inputTokens = (tokens?.input_tokens || 0) + (tokens?.cache_read_input_tokens || 0); + const outputTokens = tokens?.output_tokens || 0; + const cacheRead = tokens?.cache_read_input_tokens || 0; + const cacheCreation = tokens?.cache_creation_input_tokens || 0; + + // Calculate sizes + const systemSize = request.body?.system?.reduce((acc, s) => acc + getContentSize(s.text), 0) || 0; + const toolsSize = getContentSize(request.body?.tools); + const messagesSize = request.body?.messages?.reduce((acc, m) => acc + getContentSize(m.content), 0) || 0; + const totalSize = systemSize + toolsSize + messagesSize; + + const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; + }; + + const handleDownloadJSON = () => { + const timestamp = new Date(request.timestamp).toISOString().replace(/[:.]/g, '-'); + const filename = `request-${timestamp}.json`; + downloadFile(JSON.stringify(request, null, 2), filename); + }; + + const handleDownloadMarkdown = () => { + const timestamp = new Date(request.timestamp).toISOString().replace(/[:.]/g, '-'); + const model = request.routedModel || request.body?.model || 'Unknown'; + + let md = `# Request ${timestamp}\n\n`; + md += `**Model:** ${model}\n`; + md += `**Input Tokens:** ${inputTokens.toLocaleString()}\n`; + md += `**Output Tokens:** ${outputTokens.toLocaleString()}\n\n`; + + if (request.body?.system) { + md += `## System Prompt\n\n`; + request.body.system.forEach((sys, i) => { + md += `### Block ${i + 1}\n\n\`\`\`\n${sys.text}\n\`\`\`\n\n`; + }); + } + + if (request.body?.messages) { + md += `## Messages\n\n`; + request.body.messages.forEach((msg, i) => { + md += `### ${msg.role.toUpperCase()} [${i + 1}]\n\n`; + const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2); + md += `\`\`\`\n${text}\n\`\`\`\n\n`; + }); + } + + downloadFile(md, `request-${timestamp}.md`, 'text/markdown'); + }; + + return ( +
+
+
{label}
+
+ + +
+
+
+
+ Model: + {model.split('-').slice(-1)[0] || model} +
+
+ Input Tokens: + {inputTokens.toLocaleString()} +
+
+ Output Tokens: + {outputTokens.toLocaleString()} +
+ {cacheRead > 0 && ( +
+ Cache Read: + {cacheRead.toLocaleString()} +
+ )} + {cacheCreation > 0 && ( +
+ Cache Creation: + {cacheCreation.toLocaleString()} +
+ )} +
+
Size Breakdown
+
+ System Prompt: + {formatSize(systemSize)} +
+
+ Tools ({request.body?.tools?.length || 0}): + {formatSize(toolsSize)} +
+
+ Messages ({request.body?.messages?.length || 0}): + {formatSize(messagesSize)} +
+
+ Total: + {formatSize(totalSize)} +
+
+
+ Response Time: + {((request.response?.responseTime || 0) / 1000).toFixed(2)}s +
+
+ Timestamp: + {new Date(request.timestamp).toLocaleString()} +
+
+
+ ); +} + +// Get message size in KB +function getMessageSize(message: Message | undefined): string { + if (!message) return '0 KB'; + const text = getMessageText(message.content); + const bytes = new Blob([text]).size; + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +// Message diff row component +function MessageDiffRow({ diff }: { diff: MessageDiff }) { + const [expanded, setExpanded] = useState(diff.type !== 'unchanged'); + + const roleIcons = { + 'user': User, + 'assistant': Bot, + 'system': Settings + }; + + const getDiffStyles = () => { + switch (diff.type) { + case 'added': + return { + bg: 'bg-green-50', + border: 'border-green-200', + icon: , + label: 'Added', + labelBg: 'bg-green-100 text-green-700' + }; + case 'removed': + return { + bg: 'bg-red-50', + border: 'border-red-200', + icon: , + label: 'Removed', + labelBg: 'bg-red-100 text-red-700' + }; + case 'modified': + return { + bg: 'bg-yellow-50', + border: 'border-yellow-200', + icon: , + label: 'Modified', + labelBg: 'bg-yellow-100 text-yellow-700' + }; + default: + return { + bg: 'bg-gray-50', + border: 'border-gray-200', + icon: , + label: 'Unchanged', + labelBg: 'bg-gray-100 text-gray-600' + }; + } + }; + + const styles = getDiffStyles(); + const message = diff.message1 || diff.message2; + const role = message?.role || 'unknown'; + const Icon = roleIcons[role as keyof typeof roleIcons] || User; + + return ( +
+
setExpanded(!expanded)} + > +
+ {styles.icon} +
+ +
+ {role} + + {styles.label} + + {diff.index1 !== undefined && ( + #{diff.index1 + 1} + )} + {diff.index2 !== undefined && diff.index1 !== diff.index2 && ( + + {diff.index1 !== undefined ? ` → #${diff.index2 + 1}` : `#${diff.index2 + 1}`} + + )} + + {getMessageSize(diff.message1 || diff.message2)} + +
+ {expanded ? ( + + ) : ( + + )} +
+ {expanded && ( +
+ {diff.type === 'modified' ? ( +
+
+
Before
+
+ +
+
+
+
After
+
+ +
+
+
+ ) : ( +
+
+ +
+
+ )} +
+ )} +
+ ); +} + +// Tools comparison component +function ToolsComparison({ tools1, tools2 }: { tools1: any[]; tools2: any[] }) { + const toolNames1 = new Set(tools1.map(t => t.name)); + const toolNames2 = new Set(tools2.map(t => t.name)); + + const added = tools2.filter(t => !toolNames1.has(t.name)); + const removed = tools1.filter(t => !toolNames2.has(t.name)); + const common = tools1.filter(t => toolNames2.has(t.name)); + + return ( +
+ {added.length > 0 && ( +
+
+ + Added Tools ({added.length}) +
+
+ {added.map((tool, i) => ( + + {tool.name} + + ))} +
+
+ )} + {removed.length > 0 && ( +
+
+ + Removed Tools ({removed.length}) +
+
+ {removed.map((tool, i) => ( + + {tool.name} + + ))} +
+
+ )} + {common.length > 0 && ( +
+
+ + Common Tools ({common.length}) +
+
+ {common.map((tool, i) => ( + + {tool.name} + + ))} +
+
+ )} + {tools1.length === 0 && tools2.length === 0 && ( +
+ +

No tools defined in either request

+
+ )} +
+ ); +} diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 9908607..351de70 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -1,9 +1,9 @@ import type { MetaFunction } from "@remix-run/node"; import { useState, useEffect, useTransition } from "react"; -import { - Activity, - RefreshCw, - Trash2, +import { + Activity, + RefreshCw, + Trash2, List, FileText, X, @@ -29,11 +29,15 @@ import { Check, Lightbulb, Loader2, - ArrowLeftRight + ArrowLeftRight, + GitCompare, + Square, + CheckSquare } from "lucide-react"; import RequestDetailContent from "../components/RequestDetailContent"; import { ConversationThread } from "../components/ConversationThread"; +import { RequestCompareModal } from "../components/RequestCompareModal"; import { getChatCompletionsEndpoint } from "../utils/models"; export const meta: MetaFunction = () => { @@ -156,6 +160,11 @@ export default function Index() { const [hasMoreConversations, setHasMoreConversations] = useState(true); const itemsPerPage = 50; + // Compare mode state + const [compareMode, setCompareMode] = useState(false); + const [selectedForCompare, setSelectedForCompare] = useState([]); + const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); + const loadRequests = async (filter?: string, loadMore = false) => { setIsFetching(true); const pageToFetch = loadMore ? requestsCurrentPage + 1 : 1; @@ -355,6 +364,38 @@ export default function Index() { setSelectedRequest(null); }; + // Compare mode functions + const toggleCompareMode = () => { + setCompareMode(!compareMode); + setSelectedForCompare([]); + }; + + const toggleRequestSelection = (request: Request) => { + setSelectedForCompare(prev => { + const isSelected = prev.some(r => r.id === request.id); + if (isSelected) { + return prev.filter(r => r.id !== request.id); + } else if (prev.length < 2) { + return [...prev, request]; + } + return prev; + }); + }; + + const isRequestSelected = (request: Request) => { + return selectedForCompare.some(r => r.id === request.id); + }; + + const openCompareModal = () => { + if (selectedForCompare.length === 2) { + setIsCompareModalOpen(true); + } + }; + + const closeCompareModal = () => { + setIsCompareModalOpen(false); + }; + const getToolStats = () => { let toolDefinitions = 0; let toolCalls = 0; @@ -488,21 +529,25 @@ export default function Index() { useEffect(() => { const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { - if (isModalOpen) { + if (isCompareModalOpen) { + closeCompareModal(); + } else if (isModalOpen) { closeModal(); } else if (isConversationModalOpen) { setIsConversationModalOpen(false); setSelectedConversation(null); + } else if (compareMode) { + toggleCompareMode(); } } }; window.addEventListener('keydown', handleEscapeKey); - + return () => { window.removeEventListener('keydown', handleEscapeKey); }; - }, [isModalOpen, isConversationModalOpen]); + }, [isModalOpen, isConversationModalOpen, isCompareModalOpen, compareMode]); const filteredRequests = filterRequests(filter); @@ -516,6 +561,19 @@ export default function Index() {

Claude Code Monitor

+ {viewMode === "requests" && ( + + )}
+ {/* Compare mode banner - sticky below header */} + {compareMode && viewMode === "requests" && ( +
+
+
+
+ +
+ + Compare Mode + + + Select 2 requests to compare ({selectedForCompare.length}/2 selected) + +
+
+
+ {selectedForCompare.length === 2 && ( + + )} + +
+
+
+
+ )} + {/* Filter buttons - only show for requests view */} {viewMode === "requests" && (
@@ -653,8 +748,38 @@ export default function Index() { ) : ( <> {filteredRequests.map(request => ( -
showRequestDetails(request.id)}> +
{ + if (compareMode) { + toggleRequestSelection(request); + } else { + showRequestDetails(request.id); + } + }} + >
+ {/* Compare mode checkbox */} + {compareMode && ( +
+ +
+ )}
{/* Model and Status */}
@@ -680,8 +805,8 @@ export default function Index() { )} {request.response?.statusCode && ( = 200 && request.response.statusCode < 300 - ? 'bg-green-100 text-green-700' + request.response.statusCode >= 200 && request.response.statusCode < 300 + ? 'bg-green-100 text-green-700' : request.response.statusCode >= 300 && request.response.statusCode < 400 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700' @@ -694,28 +819,38 @@ export default function Index() { Turn {request.turnNumber} )} + {/* Selection order indicator in compare mode */} + {compareMode && isRequestSelected(request) && ( + + #{selectedForCompare.findIndex(r => r.id === request.id) + 1} + + )}
- + {/* Endpoint */}
{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}
- + {/* Metrics Row */}
{request.response?.body?.usage && ( <> - {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()} tokens + {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0)).toLocaleString()} in - {request.response.body.usage.cache_read_input_tokens && ( + + {(request.response.body.usage.output_tokens || 0).toLocaleString()} out + + {request.response.body.usage.cache_read_input_tokens && + ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0)) > 0 && ( - {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached + {Math.round((request.response.body.usage.cache_read_input_tokens / ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0))) * 100)}% cached )} )} - + {request.response?.responseTime && ( {(request.response.responseTime / 1000).toFixed(2)}s @@ -909,6 +1044,15 @@ export default function Index() {
)} + + {/* Request Compare Modal */} + {isCompareModalOpen && selectedForCompare.length === 2 && ( + + )}
); } diff --git a/web/app/utils/formatters.test.ts b/web/app/utils/formatters.test.ts new file mode 100644 index 0000000..74a468e --- /dev/null +++ b/web/app/utils/formatters.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { escapeHtml, formatLargeText } from './formatters'; + +describe('escapeHtml', () => { + it('escapes ampersands', () => { + expect(escapeHtml('a & b')).toBe('a & b'); + }); + + it('escapes less than', () => { + expect(escapeHtml('a < b')).toBe('a < b'); + }); + + it('escapes greater than', () => { + expect(escapeHtml('a > b')).toBe('a > b'); + }); + + it('escapes double quotes', () => { + expect(escapeHtml('He said "hello"')).toBe('He said "hello"'); + }); + + it('escapes single quotes', () => { + expect(escapeHtml("It's fine")).toBe("It's fine"); + }); + + it('escapes all special characters together', () => { + expect(escapeHtml('')).toBe( + '<script>"alert('xss')&"</script>' + ); + }); +}); + +describe('formatLargeText', () => { + it('returns empty string for empty input', () => { + expect(formatLargeText('')).toBe(''); + }); + + it('wraps simple text in paragraph tags', () => { + expect(formatLargeText('Hello world')).toBe('

Hello world

'); + }); + + it('converts single newlines to br tags', () => { + expect(formatLargeText('Line1\nLine2')).toBe('

Line1
Line2

'); + }); + + it('converts double newlines to paragraph breaks with proper nesting', () => { + const result = formatLargeText('Line1\n\nLine2'); + expect(result).toBe('

Line1

Line2

'); + }); + + it('handles multiple paragraph breaks correctly', () => { + const result = formatLargeText('Para1\n\nPara2\n\nPara3'); + expect(result).toBe('

Para1

Para2

Para3

'); + }); + + it('escapes HTML in the input', () => { + const result = formatLargeText(''); + expect(result).toContain('<script>'); + expect(result).not.toContain('