feat(playground): add timing display for each message step
Shows duration for text generation and tool execution in chat messages. Tracks when each part starts and completes, displays timing in the UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6ebe7353ab
commit
e6d7fc6209
1 changed files with 87 additions and 0 deletions
|
|
@ -3,19 +3,94 @@
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||||
import type { UIMessage } from 'ai';
|
import type { UIMessage } from 'ai';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Streamdown } from 'streamdown';
|
import { Streamdown } from 'streamdown';
|
||||||
|
|
||||||
|
interface PartTiming {
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms}ms`;
|
||||||
|
}
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
}: MessageBubbleProps): React.ReactElement {
|
}: MessageBubbleProps): React.ReactElement {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
// Track timing for each part by index
|
||||||
|
const [partTimings, setPartTimings] = useState<Map<string, PartTiming>>(new Map());
|
||||||
|
const prevPartsRef = useRef<string>('');
|
||||||
|
|
||||||
|
// Track part appearances and completions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message.parts || isUser) return;
|
||||||
|
|
||||||
|
const currentPartsKey = JSON.stringify(
|
||||||
|
message.parts.map((p: any) => ({
|
||||||
|
type: p.type,
|
||||||
|
id: p.toolCallId || p.type,
|
||||||
|
state: p.state,
|
||||||
|
textLen: p.text?.length,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only process if parts changed
|
||||||
|
if (currentPartsKey === prevPartsRef.current) return;
|
||||||
|
prevPartsRef.current = currentPartsKey;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
setPartTimings((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
|
||||||
|
message.parts?.forEach((part: any, idx: number) => {
|
||||||
|
const partKey = part.toolCallId || `${part.type}-${idx}`;
|
||||||
|
const existing = updated.get(partKey);
|
||||||
|
|
||||||
|
// Skip step-start markers
|
||||||
|
if (part.type === 'step-start') return;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// New part - record start time
|
||||||
|
updated.set(partKey, { startTime: now });
|
||||||
|
} else if (!existing.endTime) {
|
||||||
|
// Check if part is complete
|
||||||
|
const isToolComplete = part.type.startsWith('tool-') && part.state === 'result';
|
||||||
|
const isTextComplete = part.type === 'text' && !isStreaming;
|
||||||
|
|
||||||
|
if (isToolComplete || isTextComplete) {
|
||||||
|
updated.set(partKey, {
|
||||||
|
...existing,
|
||||||
|
endTime: now,
|
||||||
|
duration: now - existing.startTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [message.parts, isStreaming, isUser]);
|
||||||
|
|
||||||
|
// Get timing for a specific part
|
||||||
|
const getPartTiming = (part: any, idx: number): PartTiming | undefined => {
|
||||||
|
const partKey = part.toolCallId || `${part.type}-${idx}`;
|
||||||
|
return partTimings.get(partKey);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||||
<Card variant={isUser ? 'elevated' : 'outline'} className="w-full max-w-2xl">
|
<Card variant={isUser ? 'elevated' : 'outline'} className="w-full max-w-2xl">
|
||||||
|
|
@ -40,11 +115,17 @@ export function MessageBubble({
|
||||||
|
|
||||||
// Render text parts with markdown support
|
// Render text parts with markdown support
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
|
const timing = getPartTiming(part, idx);
|
||||||
return (
|
return (
|
||||||
<div key={`text-${message.id}-${idx}`} className="text-sm">
|
<div key={`text-${message.id}-${idx}`} className="text-sm">
|
||||||
<Streamdown isAnimating={isStreaming && !isUser}>
|
<Streamdown isAnimating={isStreaming && !isUser}>
|
||||||
{part.text || ''}
|
{part.text || ''}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
|
{timing?.duration && (
|
||||||
|
<div className="mt-1 text-xs text-foreground-tertiary">
|
||||||
|
⏱ {formatDuration(timing.duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +135,7 @@ export function MessageBubble({
|
||||||
const toolName = part.type.replace('tool-', '');
|
const toolName = part.type.replace('tool-', '');
|
||||||
// Type assertion for tool parts
|
// Type assertion for tool parts
|
||||||
const toolPart = part as any;
|
const toolPart = part as any;
|
||||||
|
const timing = getPartTiming(part, idx);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toolPart.toolCallId || idx}
|
key={toolPart.toolCallId || idx}
|
||||||
|
|
@ -67,6 +149,11 @@ export function MessageBubble({
|
||||||
{toolPart.state}
|
{toolPart.state}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{timing?.duration && (
|
||||||
|
<span className="ml-auto text-xs text-foreground-tertiary">
|
||||||
|
⏱ {formatDuration(timing.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool Input */}
|
{/* Tool Input */}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue