claude-code-proxy/web/app/components/RequestDetailContent.tsx
2025-08-04 23:05:28 -04:00

1024 lines
No EOL
44 KiB
TypeScript

import { useState } from 'react';
import {
ChevronDown,
Info,
Settings,
Cpu,
MessageCircle,
Brain,
User,
Bot,
Target,
Copy,
Check,
ArrowLeftRight,
Activity,
Clock,
Wifi,
Calendar,
List,
FileText,
Wrench
} from 'lucide-react';
import { MessageContent } from './MessageContent';
import { formatJSON } from '../utils/formatters';
import { getChatCompletionsEndpoint, getProviderName } from '../utils/models';
interface Request {
id: number;
timestamp: string;
method: string;
endpoint: string;
headers: Record<string, string[]>;
originalModel?: string;
routedModel?: string;
body?: {
model?: string;
messages?: Array<{
role: string;
content: any;
}>;
system?: Array<{
text: string;
type: string;
cache_control?: { type: string };
}>;
tools?: Array<{
name: string;
description: string;
input_schema?: {
type: string;
properties?: Record<string, any>;
required?: string[];
};
}>;
max_tokens?: number;
temperature?: number;
stream?: boolean;
};
response?: {
statusCode: number;
headers: Record<string, string[]>;
body?: any;
bodyText?: string;
responseTime: number;
streamingChunks?: string[];
isStreaming: boolean;
completedAt: string;
};
promptGrade?: {
score: number;
criteria: Record<string, { score: number; feedback: string }>;
feedback: string;
improvedPrompt: string;
gradingTimestamp: string;
};
}
interface RequestDetailContentProps {
request: Request;
onGrade: () => void;
}
export default function RequestDetailContent({ request, onGrade }: RequestDetailContentProps) {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
overview: true,
// conversation: true
});
const [copied, setCopied] = useState<Record<string, boolean>>({});
const toggleSection = (section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleCopy = async (content: string, key: string) => {
try {
await navigator.clipboard.writeText(content);
setCopied(prev => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopied(prev => ({ ...prev, [key]: false }));
}, 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const getMethodColor = (method: string) => {
const colors = {
'GET': 'bg-green-50 text-green-700 border border-green-200',
'POST': 'bg-blue-50 text-blue-700 border border-blue-200',
'PUT': 'bg-yellow-50 text-yellow-700 border border-yellow-200',
'DELETE': 'bg-red-50 text-red-700 border border-red-200'
};
return colors[method as keyof typeof colors] || 'bg-gray-50 text-gray-700 border border-gray-200';
};
const canGradeRequest = (request: Request) => {
return request.body &&
request.body.messages &&
request.body.messages.some(msg => msg.role === 'user') &&
request.endpoint.includes('/messages');
};
return (
<div className="space-y-6">
{/* Request Overview */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Info className="w-5 h-5 text-blue-600" />
<span>Request Overview</span>
</h4>
{/* {!request.promptGrade && canGradeRequest(request) && (
<button
onClick={onGrade}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<Target className="w-4 h-4" />
<span>Grade This Prompt</span>
</button>
)} */}
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">Method:</span>
<span className={`px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wide ${getMethodColor(request.method)}`}>
{request.method}
</span>
</div>
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">Endpoint:</span>
<code className="text-blue-600 bg-blue-50 px-2 py-1 rounded font-mono text-xs border border-blue-200">
{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}
</code>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">Timestamp:</span>
<span className="text-gray-900">{new Date(request.timestamp).toLocaleString()}</span>
</div>
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">User Agent:</span>
<span className="text-gray-600 text-xs">{request.headers['User-Agent']?.[0] || 'N/A'}</span>
</div>
</div>
</div>
</div>
{/* Headers */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('headers')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Settings className="w-5 h-5 text-blue-600" />
<span>Request Headers</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.headers ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.headers && (
<div className="p-6">
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Headers</span>
<button
onClick={() => handleCopy(formatJSON(request.headers), 'headers')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy headers"
>
{copied.headers ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<pre className="text-sm text-gray-700 overflow-x-auto">
{formatJSON(request.headers)}
</pre>
</div>
</div>
)}
</div>
{request.body && (
<>
{/* System Messages */}
{request.body.system && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('system')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Cpu className="w-5 h-5 text-yellow-600" />
<span>System Instructions</span>
<span className="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded-full border border-yellow-200">
{request.body.system.length} items
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.system ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.system && (
<div className="p-6 space-y-4">
{request.body.system.map((sys, index) => (
<div key={index} className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-yellow-700 font-medium text-sm">System Message #{index + 1}</span>
{sys.cache_control && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">
Cache: {sys.cache_control.type}
</span>
)}
</div>
<div className="bg-white rounded p-3 border border-gray-200">
<MessageContent content={{ type: 'text', text: sys.text }} />
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Tools */}
{request.body.tools && request.body.tools.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('tools')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Wrench className="w-5 h-5 text-indigo-600" />
<span>Available Tools</span>
<span className="text-xs bg-indigo-50 text-indigo-700 px-2 py-1 rounded-full border border-indigo-200">
{request.body.tools.length} tools
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.tools ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.tools && (
<div className="p-6 space-y-4">
{request.body.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} index={index} />
))}
</div>
)}
</div>
)}
{/* Conversation */}
{request.body.messages && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('conversation')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<MessageCircle className="w-5 h-5 text-blue-600" />
<span>Conversation</span>
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{request.body.messages.length} messages
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.conversation ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.conversation && (
<div className="p-6 space-y-4 max-h-[600px] overflow-y-auto">
{request.body.messages.map((message, index) => (
<MessageBubble key={index} message={message} index={index} />
))}
</div>
)}
</div>
)}
{/* Model Configuration */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('model')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Brain className="w-5 h-5 text-purple-600" />
<span>Model Configuration</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.model ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.model && (
<div className="p-6 space-y-4">
{/* Model Routing Information */}
{request.routedModel && request.routedModel !== request.originalModel && (
<div className="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<span className="text-sm font-semibold text-purple-700">Requested Model</span>
<code className="text-xs bg-white px-2 py-1 rounded font-mono border border-purple-200">
{request.originalModel || request.body.model}
</code>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<ArrowLeftRight className="w-4 h-4 text-purple-600" />
<span className="text-xs text-purple-600 font-medium">Routed to</span>
</div>
<code className="text-sm bg-white px-3 py-1.5 rounded font-mono font-semibold border border-blue-200 text-blue-700">
{request.routedModel}
</code>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{getProviderName(request.routedModel)}
</span>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500 mb-1">Target Endpoint</div>
<code className="text-xs bg-white px-2 py-1 rounded font-mono border border-gray-200">
{getChatCompletionsEndpoint(request.routedModel)}
</code>
</div>
</div>
</div>
)}
{/* Model Parameters */}
<div className="grid grid-cols-2 gap-4">
{!request.routedModel || request.routedModel === request.originalModel ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Model</div>
<div className="text-sm font-medium text-gray-900">{request.originalModel || request.body.model || 'N/A'}</div>
</div>
) : null}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Max Tokens</div>
<div className="text-sm font-medium text-gray-900">
{request.body.max_tokens?.toLocaleString() || 'N/A'}
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Temperature</div>
<div className="text-sm font-medium text-gray-900">{request.body.temperature ?? 'N/A'}</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Stream</div>
<div className="text-sm font-medium text-gray-900">
{request.body.stream ? '✅ Yes' : '❌ No'}
</div>
</div>
</div>
</div>
)}
</div>
</>
)}
{/* API Response */}
{request.response && (
<ResponseDetails response={request.response} />
)}
{/* Prompt Grading Results */}
{request.promptGrade && (
<PromptGradingResults promptGrade={request.promptGrade} />
)}
</div>
);
}
// Message bubble component
function MessageBubble({ message, index }: { message: any; index: number }) {
const roleColors = {
'user': 'bg-blue-50 border border-blue-200',
'assistant': 'bg-gray-50 border border-gray-200',
'system': 'bg-yellow-50 border border-yellow-200'
};
const roleIcons = {
'user': User,
'assistant': Bot,
'system': Settings
};
const roleIconColors = {
'user': 'text-blue-600',
'assistant': 'text-gray-600',
'system': 'text-yellow-600'
};
const Icon = roleIcons[message.role as keyof typeof roleIcons] || User;
return (
<div className={`rounded-lg p-4 ${roleColors[message.role as keyof typeof roleColors] || 'bg-gray-50 border border-gray-200'}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center border border-gray-200">
<Icon className={`w-4 h-4 ${roleIconColors[message.role as keyof typeof roleIconColors] || 'text-gray-600'}`} />
</div>
<span className="font-medium capitalize text-gray-900">{message.role}</span>
<span className="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">
#{index + 1}
</span>
</div>
</div>
<div>
<MessageContent content={message.content} />
</div>
</div>
);
}
// Placeholder for prompt grading results - you can expand this
function PromptGradingResults({ promptGrade }: { promptGrade: any }) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Prompt Quality Analysis</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-gray-700">Overall Score:</span>
<span className="text-2xl font-bold text-blue-600">{promptGrade.score}/5</span>
</div>
<div className="text-sm text-gray-600">
<p>{promptGrade.feedback}</p>
</div>
</div>
</div>
);
}
// Response Details Component
function ResponseDetails({ response }: { response: NonNullable<Request['response']> }) {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
overview: true
});
const [copied, setCopied] = useState<Record<string, boolean>>({});
const toggleSection = (section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleCopy = async (content: string, key: string) => {
try {
await navigator.clipboard.writeText(content);
setCopied(prev => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopied(prev => ({ ...prev, [key]: false }));
}, 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const getStatusColor = (statusCode: number) => {
if (statusCode >= 200 && statusCode < 300) {
return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: 'text-green-600' };
}
if (statusCode >= 400 && statusCode < 500) {
return { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: 'text-yellow-600' };
}
if (statusCode >= 500) {
return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: 'text-red-600' };
}
return { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-600' };
};
// Parse streaming chunks to extract the final assembled text
const parseStreamingResponse = (chunks: string[]) => {
let assembledText = '';
let rawData = chunks.join('');
try {
// Split by lines and process each SSE event
const lines = rawData.split('\n').filter(line => line.trim());
for (const line of lines) {
// Look for data lines in SSE format
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6).trim();
// Skip non-JSON lines (like "data: [DONE]")
if (!jsonStr.startsWith('{')) continue;
try {
const eventData = JSON.parse(jsonStr);
// Extract text from content_block_delta events
if (eventData.type === 'content_block_delta' &&
eventData.delta &&
eventData.delta.type === 'text_delta' &&
typeof eventData.delta.text === 'string') {
assembledText += eventData.delta.text;
}
} catch (parseError) {
// Skip malformed JSON
continue;
}
}
}
// If we successfully extracted text, return it
if (assembledText.trim().length > 0) {
return {
finalText: assembledText,
isFormatted: true,
rawData: rawData
};
}
// Fallback: try to find any text content in the raw data
const textMatches = rawData.match(/"text":"([^"]+)"/g);
if (textMatches) {
let fallbackText = '';
for (const match of textMatches) {
const text = match.match(/"text":"([^"]+)"/)?.[1];
if (text) {
// Unescape common JSON escape sequences
fallbackText += text.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
}
if (fallbackText.trim()) {
return {
finalText: fallbackText,
isFormatted: true,
rawData: rawData
};
}
}
} catch (error) {
console.warn('Error parsing streaming response:', error);
}
// Ultimate fallback to raw concatenation
return {
finalText: rawData,
isFormatted: false,
rawData: rawData
};
};
const statusColors = getStatusColor(response.statusCode);
const completedAt = response.completedAt ? new Date(response.completedAt).toLocaleString() : 'Unknown';
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm border-l-4 border-l-blue-500">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('overview')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<ArrowLeftRight className="w-5 h-5 text-blue-600" />
<span>API Response</span>
<span className={`text-xs px-2 py-1 rounded-full border ${statusColors.bg} ${statusColors.text} ${statusColors.border}`}>
{response.statusCode}
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.overview ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.overview && (
<div className="p-6 space-y-6">
{/* Response Overview */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className={`${statusColors.bg} border ${statusColors.border} rounded-lg p-4`}>
<div className="flex items-center space-x-2 mb-2">
<Activity className={`w-4 h-4 ${statusColors.icon}`} />
<span className={`text-xs font-medium ${statusColors.text}`}>Status</span>
</div>
<div className={`text-lg font-bold ${statusColors.text}`}>{response.statusCode}</div>
<div className={`text-xs ${statusColors.text} opacity-75`}>
{response.statusCode >= 200 && response.statusCode < 300 ? 'Success' :
response.statusCode >= 400 && response.statusCode < 500 ? 'Client Error' :
response.statusCode >= 500 ? 'Server Error' : 'Unknown'}
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Clock className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-blue-700">Response Time</span>
</div>
<div className="text-lg font-bold text-blue-700">{response.responseTime}ms</div>
<div className="text-xs text-blue-700 opacity-75">
{response.responseTime < 1000 ? 'Fast' : response.responseTime < 3000 ? 'Normal' : 'Slow'}
</div>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Wifi className="w-4 h-4 text-purple-600" />
<span className="text-xs font-medium text-purple-700">Type</span>
</div>
<div className="text-lg font-bold text-purple-700">
{response.isStreaming ? 'Stream' : 'Single'}
</div>
<div className="text-xs text-purple-700 opacity-75">
{response.isStreaming ? 'Streaming' : 'Complete'}
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Calendar className="w-4 h-4 text-gray-600" />
<span className="text-xs font-medium text-gray-700">Completed</span>
</div>
<div className="text-sm font-bold text-gray-700">{completedAt.split(' ')[1] || 'N/A'}</div>
<div className="text-xs text-gray-700 opacity-75">{completedAt.split(' ')[0] || ''}</div>
</div>
</div>
{/* Token Usage */}
{response.body?.usage && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Brain className="w-4 h-4 text-indigo-600" />
<span className="text-xs font-medium text-indigo-700">Input Tokens</span>
</div>
<div className="text-lg font-bold text-indigo-700">
{response.body.usage.input_tokens?.toLocaleString() || '0'}
</div>
<div className="text-xs text-indigo-700 opacity-75">Prompt</div>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<MessageCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-medium text-emerald-700">Output Tokens</span>
</div>
<div className="text-lg font-bold text-emerald-700">
{response.body.usage.output_tokens?.toLocaleString() || '0'}
</div>
<div className="text-xs text-emerald-700 opacity-75">Response</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Cpu className="w-4 h-4 text-amber-600" />
<span className="text-xs font-medium text-amber-700">Total Tokens</span>
</div>
<div className="text-lg font-bold text-amber-700">
{((response.body.usage.input_tokens || 0) + (response.body.usage.output_tokens || 0)).toLocaleString()}
</div>
<div className="text-xs text-amber-700 opacity-75">Combined</div>
</div>
{response.body.usage.cache_read_input_tokens && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Bot className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-green-700">Cached Tokens</span>
</div>
<div className="text-lg font-bold text-green-700">
{response.body.usage.cache_read_input_tokens.toLocaleString()}
</div>
<div className="text-xs text-green-700 opacity-75">From Cache</div>
</div>
)}
</div>
)}
{/* Response Headers */}
{response.headers && (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div
className="px-4 py-3 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('responseHeaders')}
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<List className="w-4 h-4 text-gray-600" />
<span>Response Headers</span>
<span className="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">
{Object.keys(response.headers).length}
</span>
</h5>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.responseHeaders ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.responseHeaders && (
<div className="px-4 pb-4">
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Headers</span>
<button
onClick={() => handleCopy(formatJSON(response.headers), 'responseHeaders')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy response headers"
>
{copied.responseHeaders ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<pre className="text-xs text-gray-700 overflow-x-auto">
{formatJSON(response.headers)}
</pre>
</div>
</div>
)}
</div>
)}
{/* Response Body */}
{(response.body || response.bodyText) && (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div
className="px-4 py-3 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('responseBody')}
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<FileText className="w-4 h-4 text-gray-600" />
<span>Response Body</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{response.body ? 'JSON' : 'Text'}
</span>
</h5>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.responseBody ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.responseBody && (
<div className="px-4 pb-4">
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Response</span>
<button
onClick={() => handleCopy(
response.body ? formatJSON(response.body) : (response.bodyText || ''),
'responseBody'
)}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy response body"
>
{copied.responseBody ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<pre className="text-xs text-gray-700 overflow-x-auto max-h-96 overflow-y-auto">
{response.body ? formatJSON(response.body) : response.bodyText}
</pre>
</div>
</div>
)}
</div>
)}
{/* Streaming Response */}
{response.isStreaming && response.streamingChunks && response.streamingChunks.length > 0 && (() => {
const parsed = parseStreamingResponse(response.streamingChunks);
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div
className="px-4 py-3 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('streamingResponse')}
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<Wifi className="w-4 h-4 text-gray-600" />
<span>Streaming Response</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{response.streamingChunks.length} chunks
</span>
{parsed.isFormatted && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">
Parsed
</span>
)}
</h5>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.streamingResponse ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.streamingResponse && (
<div className="px-4 pb-4 space-y-3">
{/* Clean Parsed Response */}
{parsed.isFormatted && (
<div className="bg-white rounded-lg p-4 border border-green-200">
<div className="flex items-center justify-between mb-3">
<h6 className="text-sm font-semibold text-green-900 flex items-center space-x-2">
<Check className="w-4 h-4" />
<span>Final Response (Clean)</span>
</h6>
<button
onClick={() => handleCopy(parsed.finalText, 'streamingClean')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy clean response"
>
{copied.streamingClean ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<div className="bg-gray-50 rounded p-3 border border-gray-200">
<pre className="text-sm text-gray-900 whitespace-pre-wrap leading-relaxed">
{parsed.finalText}
</pre>
</div>
<div className="mt-2 text-xs text-green-600">
Extracted clean text from streaming chunks
</div>
</div>
)}
{/* Raw Data (Collapsible) */}
<div className="bg-gray-50 rounded-lg border border-gray-200">
<div
className="px-3 py-2 cursor-pointer flex items-center justify-between"
onClick={() => toggleSection('rawStreamingData')}
>
<span className="text-sm font-medium text-gray-700 flex items-center space-x-2">
<FileText className="w-4 h-4" />
<span>Raw Streaming Data</span>
</span>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.rawStreamingData ? 'rotate-180' : ''
}`} />
</div>
{expandedSections.rawStreamingData && (
<div className="px-3 pb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-600">SSE Events & Metadata</span>
<button
onClick={() => handleCopy(parsed.rawData, 'streamingRaw')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy raw data"
>
{copied.streamingRaw ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</div>
<pre className="text-xs text-gray-600 overflow-x-auto max-h-64 overflow-y-auto bg-gray-100 rounded p-2 font-mono">
{parsed.rawData}
</pre>
</div>
)}
</div>
<div className="text-xs text-gray-500">
{parsed.isFormatted
? `Successfully parsed ${response.streamingChunks.length} streaming chunks`
: `Raw display of ${response.streamingChunks.length} streaming chunks (parsing failed)`
}
</div>
</div>
)}
</div>
);
})()}
</div>
)}
</div>
);
}
// Tool Card Component
function ToolCard({ tool, index }: { tool: any; index: number }) {
const [expanded, setExpanded] = useState(false);
const [copiedSchema, setCopiedSchema] = useState(false);
const handleCopySchema = async () => {
try {
await navigator.clipboard.writeText(formatJSON(tool.input_schema));
setCopiedSchema(true);
setTimeout(() => setCopiedSchema(false), 2000);
} catch (error) {
console.error('Failed to copy schema:', error);
}
};
// Parse description to identify code blocks and format them
const formatDescription = (description: string) => {
// Split by code blocks (text between backticks)
const parts = description.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
// Code inline
const code = part.slice(1, -1);
return (
<code key={i} className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">
{code}
</code>
);
}
// Return non-code parts as plain text
return <span key={i}>{part}</span>;
});
};
const isLongDescription = tool.description.length > 300;
const displayDescription = expanded ? tool.description : tool.description.slice(0, 300);
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-gray-200 shadow-sm">
<Wrench className="w-5 h-5 text-gray-600" />
</div>
<div>
<h5 className="text-lg font-bold text-gray-900">{tool.name}</h5>
<span className="text-xs text-gray-500">Tool #{index + 1}</span>
</div>
</div>
</div>
<div className="prose prose-sm max-w-none">
<div className="text-sm text-gray-700 leading-relaxed space-y-2">
<div className="whitespace-pre-wrap">
{formatDescription(displayDescription)}
{isLongDescription && !expanded && '...'}
</div>
{isLongDescription && (
<button
onClick={() => setExpanded(!expanded)}
className="text-blue-600 hover:text-blue-700 text-xs font-medium mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
</div>
{tool.input_schema && (
<div className="mt-4">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-700 flex items-center space-x-2">
<Settings className="w-3.5 h-3.5" />
<span>Input Schema</span>
</span>
<button
onClick={handleCopySchema}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy schema"
>
{copiedSchema ? (
<Check className="w-3.5 h-3.5 text-green-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
<div className="p-3">
<pre className="text-xs text-gray-700 overflow-x-auto font-mono">
{formatJSON(tool.input_schema)}
</pre>
</div>
</div>
</div>
)}
</div>
</div>
);
}