Files
vibe-kanban/frontend/src/components/tasks/ConversationViewer.tsx
2025-07-03 15:02:13 +01:00

800 lines
26 KiB
TypeScript

import { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Brain,
Wrench as Tool,
ChevronDown,
ChevronUp,
Clock,
Zap,
AlertTriangle,
FileText,
} from 'lucide-react';
interface JSONLLine {
type: string;
threadID?: string;
// Amp format
messages?: [
number,
{
role: 'user' | 'assistant';
content: Array<{
type: 'text' | 'thinking' | 'tool_use' | 'tool_result';
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: any;
toolUseID?: string;
run?: {
status: string;
result?: string;
toAllow?: string[];
};
}>;
meta?: {
sentAt: number;
};
state?: {
type: string;
stopReason?: string;
};
},
][];
toolResults?: Array<{
type: 'tool_use' | 'tool_result';
id?: string;
name?: string;
input?: any;
toolUseID?: string;
run?: {
status: string;
result?: string;
toAllow?: string[];
};
}>;
tokenUsage?: {
used: number;
maxAvailable: number;
};
state?: string;
tool?: string;
command?: string;
// Claude format
message?: {
role: 'user' | 'assistant' | 'system';
content:
| Array<{
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
id?: string;
name?: string;
input?: any;
tool_use_id?: string;
content?: any;
is_error?: boolean;
}>
| string;
};
messageKey?: number;
isStreaming?: boolean;
// Tool rejection message (string format)
rejectionMessage?: string;
usage?: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
result?: any;
duration_ms?: number;
total_cost_usd?: number;
error?: string; // For parse errors
}
interface ConversationViewerProps {
jsonlOutput: string;
}
// Validation functions
const isValidMessage = (data: any): boolean => {
return (
typeof data.role === 'string' &&
Array.isArray(data.content) &&
data.content.every(
(item: any) =>
typeof item.type === 'string' &&
(item.type !== 'text' || typeof item.text === 'string') &&
(item.type !== 'thinking' || typeof item.thinking === 'string') &&
(item.type !== 'tool_use' || typeof item.name === 'string') &&
(item.type !== 'tool_result' ||
!item.run ||
typeof item.run.status === 'string')
)
);
};
const isValidClaudeMessage = (data: any): boolean => {
return (
typeof data.role === 'string' &&
(typeof data.content === 'string' ||
(Array.isArray(data.content) &&
data.content.every(
(item: any) =>
typeof item.type === 'string' &&
(item.type !== 'text' || typeof item.text === 'string') &&
(item.type !== 'tool_use' || typeof item.name === 'string') &&
(item.type !== 'tool_result' || typeof item.content !== 'undefined')
)))
);
};
const isValidTokenUsage = (data: any): boolean => {
return (
data &&
typeof data.used === 'number' &&
typeof data.maxAvailable === 'number'
);
};
const isValidClaudeUsage = (data: any): boolean => {
return (
data &&
typeof data.input_tokens === 'number' &&
typeof data.output_tokens === 'number'
);
};
const isValidToolRejection = (data: any): boolean => {
return (
typeof data.tool === 'string' &&
typeof data.command === 'string' &&
(typeof data.message === 'string' ||
typeof data.rejectionMessage === 'string')
);
};
const isValidMessagesLine = (line: any): boolean => {
return (
Array.isArray(line.messages) &&
line.messages.every(
(msg: any) =>
Array.isArray(msg) &&
msg.length >= 2 &&
typeof msg[0] === 'number' &&
isValidMessage(msg[1])
)
);
};
export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
const [expandedMessages, setExpandedMessages] = useState<Set<string>>(
new Set()
);
const [showTokenUsage, setShowTokenUsage] = useState(false);
const parsedLines = useMemo(() => {
try {
return jsonlOutput
.split('\n')
.filter((line) => line.trim())
.map((line, index) => {
try {
const parsed = JSON.parse(line);
return {
...parsed,
_lineIndex: index,
_rawLine: line,
} as JSONLLine & { _lineIndex: number; _rawLine: string };
} catch {
return {
type: 'parse-error',
_lineIndex: index,
_rawLine: line,
error: 'Failed to parse JSON',
} as JSONLLine & {
_lineIndex: number;
_rawLine: string;
error: string;
};
}
});
} catch {
return [];
}
}, [jsonlOutput]);
const conversation = useMemo(() => {
const streamingMessageMap = new Map<number, any>();
const items: Array<{
type: 'message' | 'tool-rejection' | 'parse-error' | 'unknown';
role?: 'user' | 'assistant';
content?: Array<{
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: any;
toolUseID?: string;
run?: any;
}>;
timestamp?: number;
messageIndex?: number;
lineIndex?: number;
tool?: string;
command?: string;
message?: string;
error?: string;
rawLine?: string;
}> = [];
const tokenUsages: Array<{
used: number;
maxAvailable: number;
lineIndex: number;
}> = [];
const states: Array<{ state: string; lineIndex: number }> = [];
for (const line of parsedLines) {
try {
if (line.type === 'parse-error') {
items.push({
type: 'parse-error',
error: line.error,
rawLine: line._rawLine,
lineIndex: line._lineIndex,
});
} else if (
line.type === 'messages' &&
isValidMessagesLine(line) &&
line.messages
) {
// Amp format
for (const [messageIndex, message] of line.messages) {
const messageItem = {
type: 'message' as const,
role: message.role,
content: message.content,
timestamp: message.meta?.sentAt,
messageIndex,
lineIndex: line._lineIndex,
};
// Handle Gemini streaming via top-level messageKey and isStreaming
if (line.isStreaming && line.messageKey !== undefined) {
const existingMessage = streamingMessageMap.get(line.messageKey);
if (existingMessage) {
// Append new content to existing message
if (
existingMessage.content &&
existingMessage.content[0] &&
messageItem.content &&
messageItem.content[0]
) {
existingMessage.content[0].text =
(existingMessage.content[0].text || '') +
(messageItem.content[0].text || '');
existingMessage.timestamp = messageItem.timestamp; // Update timestamp
}
} else {
// First segment for this message
streamingMessageMap.set(line.messageKey, messageItem);
}
} else {
items.push(messageItem);
}
}
} else if (
(line.type === 'user' ||
line.type === 'assistant' ||
line.type === 'system') &&
line.message &&
isValidClaudeMessage(line.message)
) {
// Claude format
const content =
typeof line.message.content === 'string'
? [{ type: 'text', text: line.message.content }]
: line.message.content;
items.push({
type: 'message',
role:
line.message.role === 'system' ? 'assistant' : line.message.role,
content: content,
lineIndex: line._lineIndex,
});
} else if (
line.type === 'result' &&
line.usage &&
isValidClaudeUsage(line.usage)
) {
// Claude usage info
tokenUsages.push({
used: line.usage.input_tokens + line.usage.output_tokens,
maxAvailable:
line.usage.input_tokens + line.usage.output_tokens + 100000, // Approximate
lineIndex: line._lineIndex,
});
} else if (
line.type === 'token-usage' &&
line.tokenUsage &&
isValidTokenUsage(line.tokenUsage)
) {
// Amp format
tokenUsages.push({
used: line.tokenUsage.used,
maxAvailable: line.tokenUsage.maxAvailable,
lineIndex: line._lineIndex,
});
} else if (line.type === 'state' && typeof line.state === 'string') {
states.push({
state: line.state,
lineIndex: line._lineIndex,
});
} else if (
line.type === 'tool-rejected' &&
isValidToolRejection(line)
) {
items.push({
type: 'tool-rejection',
tool: line.tool,
command: line.command,
message:
typeof line.message === 'string'
? line.message
: line.rejectionMessage || 'Tool rejected',
lineIndex: line._lineIndex,
});
} else {
// Unknown line type or invalid structure - add as unknown for fallback rendering
items.push({
type: 'unknown',
rawLine: line._rawLine,
lineIndex: line._lineIndex,
});
}
} catch (error) {
// If anything goes wrong processing a line, treat it as unknown
items.push({
type: 'unknown',
rawLine: line._rawLine,
lineIndex: line._lineIndex,
});
}
}
const streamingMessages = Array.from(streamingMessageMap.values());
const finalItems = [...items, ...streamingMessages];
// Sort by messageIndex for messages, then by lineIndex for everything else
finalItems.sort((a, b) => {
if (a.type === 'message' && b.type === 'message') {
return (a.messageIndex || 0) - (b.messageIndex || 0);
}
return (a.lineIndex || 0) - (b.lineIndex || 0);
});
return {
items: finalItems,
tokenUsages,
states,
};
}, [parsedLines]);
const toggleMessage = (messageId: string) => {
const newExpanded = new Set(expandedMessages);
if (newExpanded.has(messageId)) {
newExpanded.delete(messageId);
} else {
newExpanded.add(messageId);
}
setExpandedMessages(newExpanded);
};
const formatToolInput = (input: any): string => {
try {
if (input === null || input === undefined) {
return String(input);
}
if (typeof input === 'object') {
// Try to stringify, but handle circular references and complex objects
return JSON.stringify(input);
}
return String(input);
} catch (error) {
// If anything goes wrong, return a safe fallback
return `[Unable to display input: ${String(input).substring(0, 100)}...]`;
}
};
const safeRenderString = (value: any): string => {
if (typeof value === 'string') {
return value;
}
if (value === null || value === undefined) {
return String(value);
}
if (typeof value === 'object') {
try {
// Use the same safe JSON.stringify logic as formatToolInput
return '(RAW)' + JSON.stringify(value);
} catch (error) {
return `[Object - serialization failed: ${String(value).substring(
0,
50
)}...]`;
}
}
return String(value);
};
const getToolStatusColor = (status: string) => {
switch (status) {
case 'done':
return 'bg-green-500';
case 'rejected-by-user':
case 'blocked-on-user':
return 'bg-yellow-500';
case 'error':
return 'bg-red-500';
default:
return 'bg-blue-500';
}
};
if (parsedLines.length === 0) {
return (
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">
No valid JSONL data found
</p>
</CardContent>
</Card>
);
}
const latestTokenUsage =
conversation.tokenUsages[conversation.tokenUsages.length - 1];
return (
<div className="space-y-4">
{/* Header with token usage */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain className="h-4 w-4" />
<span className="text-sm font-medium">LLM Conversation</span>
</div>
<div className="flex items-center gap-2">
{latestTokenUsage && (
<Badge variant="outline" className="text-xs">
<Zap className="h-3 w-3 mr-1" />
{latestTokenUsage.used.toLocaleString()} /{' '}
{latestTokenUsage.maxAvailable.toLocaleString()} tokens
</Badge>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setShowTokenUsage(!showTokenUsage)}
>
{showTokenUsage ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Token usage details */}
{showTokenUsage && conversation.tokenUsages.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Token Usage Timeline</CardTitle>
</CardHeader>
<CardContent className="p-3">
<div className="space-y-1">
{conversation.tokenUsages.map((usage, index) => (
<div
key={index}
className="flex items-center justify-between text-xs"
>
<span className="text-muted-foreground">
Step {index + 1}
</span>
<span>
{usage.used.toLocaleString()} /{' '}
{usage.maxAvailable.toLocaleString()}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Conversation items (messages and tool rejections) */}
<div className="space-y-3">
{conversation.items.map((item, index) => {
if (item.type === 'parse-error') {
return (
<Card
key={`error-${index}`}
className="bg-yellow-100/50 dark:bg-yellow-900/20 border"
>
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<Badge variant="secondary" className="text-xs">
Parse Error
</Badge>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">
Raw JSONL:
</p>
<pre className="text-xs bg-background p-2 rounded border overflow-x-auto whitespace-pre-wrap">
{safeRenderString(item.rawLine)}
</pre>
</div>
</CardContent>
</Card>
);
}
if (item.type === 'unknown') {
let prettyJson = item.rawLine;
try {
prettyJson = JSON.stringify(
JSON.parse(item.rawLine || '{}'),
null,
2
);
} catch {
// Keep as is if can't prettify
}
return (
<Card key={`unknown-${index}`} className="bg-muted/30 border">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<Badge variant="secondary" className="text-xs">
Unknown
</Badge>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">JSONL:</p>
<pre className="text-xs bg-background p-2 rounded border overflow-x-auto whitespace-pre-wrap">
{safeRenderString(prettyJson)}
</pre>
</div>
</CardContent>
</Card>
);
}
if (item.type === 'tool-rejection') {
return (
<Card
key={`rejection-${index}`}
className="bg-red-100/50 dark:bg-red-900/20 border"
>
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400" />
<Badge variant="secondary" className="text-xs">
Tool Rejected
</Badge>
<span className="text-sm font-medium">
{safeRenderString(item.tool)}
</span>
</div>
<div className="space-y-2">
<div>
<p className="text-xs text-muted-foreground mb-1">
Command:
</p>
<pre className="text-xs bg-background p-2 rounded border overflow-x-auto">
{safeRenderString(item.command)}
</pre>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">
Message:
</p>
<p className="text-xs bg-background p-2 rounded border">
{safeRenderString(item.message)}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
if (item.type === 'message') {
const messageId = `message-${index}`;
const isExpanded = expandedMessages.has(messageId);
const hasThinking = item.content?.some(
(c: any) => c.type === 'thinking'
);
return (
<Card
key={messageId}
className={`${
item.role === 'user'
? 'bg-blue-100/50 dark:bg-blue-900/20 border ml-12'
: 'bg-muted/50 border mr-12'
}`}
>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium capitalize">
{item.role}
</span>
{item.timestamp && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(item.timestamp).toLocaleTimeString()}
</div>
)}
{hasThinking && (
<Button
variant="ghost"
size="sm"
onClick={() => toggleMessage(messageId)}
className="h-6 px-2 text-xs"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Hide thinking
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show thinking
</>
)}
</Button>
)}
</div>
<div className="space-y-2">
{item.content?.map((content: any, contentIndex: number) => {
if (content.type === 'text') {
return (
<div
key={contentIndex}
className="prose prose-sm max-w-none"
>
<p className="text-sm whitespace-pre-wrap">
{safeRenderString(content.text)}
</p>
</div>
);
}
if (content.type === 'thinking' && isExpanded) {
return (
<div key={contentIndex} className="mt-3">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-xs">
💭 Thinking
</Badge>
</div>
<div className="text-xs text-muted-foreground italic whitespace-pre-wrap">
{safeRenderString(content.thinking)}
</div>
</div>
);
}
if (content.type === 'tool_use') {
return (
<div key={contentIndex} className="mt-3">
<div className="flex items-center gap-2 mb-2">
<Tool className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium">
{safeRenderString(content.name)}
</span>
</div>
{content.input && (
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
{formatToolInput(content.input)}
</pre>
)}
</div>
);
}
if (content.type === 'tool_result') {
return (
<div key={contentIndex} className="mt-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 flex items-center justify-center">
{content.run?.status ? (
<div
className={`w-2 h-2 rounded-full ${getToolStatusColor(
content.run.status
)}`}
/>
) : content.is_error ? (
<div className="w-2 h-2 rounded-full bg-red-500" />
) : (
<div className="w-2 h-2 rounded-full bg-green-500" />
)}
</div>
<span className="text-sm text-muted-foreground">
Result
</span>
{content.run?.status && (
<span className="text-xs text-muted-foreground">
({safeRenderString(content.run.status)})
</span>
)}
{content.is_error && (
<span className="text-xs text-red-500">
(Error)
</span>
)}
</div>
{/* Amp format result */}
{content.run?.result && (
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
{safeRenderString(content.run.result)}
</pre>
)}
{/* Claude format result */}
{content.content && !content.run && (
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
{safeRenderString(content.content)}
</pre>
)}
{content.run?.toAllow && (
<div className="mt-2">
<p className="text-xs text-muted-foreground mb-1">
Commands to allow:
</p>
<div className="flex flex-wrap gap-1">
{content.run.toAllow.map(
(cmd: string, i: number) => (
<code
key={i}
className="text-xs bg-muted px-1 rounded"
>
{safeRenderString(cmd)}
</code>
)
)}
</div>
</div>
)}
</div>
);
}
return null;
})}
</div>
</CardContent>
</Card>
);
}
return null;
})}
</div>
</div>
);
}