ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,6 @@
import type { AppProps } from 'next/app'
import '../styles/globals.css'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
@@ -0,0 +1,751 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Mic, MicOff, Trash2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
interface LogEntry {
timestamp: number;
message: string;
type: 'info' | 'error' | 'latency' | 'llm';
}
interface ChatMessage {
role: 'user' | 'assistant' | 'transcript';
content: string;
isFinal?: boolean;
}
interface TabButtonProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
const CodeBlock = ({ node, inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const lang = match ? match[1] : '';
if (!inline && lang) {
return (
<SyntaxHighlighter
language={lang}
style={oneDark}
customStyle={{
margin: '0.5em 0',
borderRadius: '0.375rem',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
}
return <code className={className} {...props}>{children}</code>;
};
const TabButton: React.FC<TabButtonProps> = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`flex-1 py-2 text-sm font-medium border-b-2 ${
active
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{children}
</button>
);
export default function Home() {
const [isRecording, setIsRecording] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const websocketRef = useRef<WebSocket | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const echoNodeRef = useRef<AudioWorkletNode | null>(null);
const audioFormatRef = useRef<any>(null);
const speechEndTimeRef = useRef<number | null>(null);
const llmStartTimeRef = useRef<number | null>(null);
const vadEndTimeRef = useRef<number | null>(null);
const hasPlaybackLatencyRef = useRef<boolean>(false);
const vadStartTimeRef = useRef<number | null>(null);
const audioQueueRef = useRef<Float32Array[]>([]);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const playbackStartTimeRef = useRef<number | null>(null);
const [finalTranscripts, setFinalTranscripts] = useState<string>('');
// Add this ref to track the current valid message ID
const currentValidMessageIdRef = useRef<string | null>(null);
// Add this ref to track if audio is currently playing
const isPlayingRef = useRef<boolean>(false);
// Add these refs after other refs
const chatHistoryRef = useRef<HTMLDivElement>(null);
const logsRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollChatRef = useRef(true);
const shouldAutoScrollLogsRef = useRef(true);
const [activeTab, setActiveTab] = useState<'chat' | 'logs'>('chat');
const addLog = (message: string, type: LogEntry['type'] = 'info') => {
setLogs(prev => [...prev, {
timestamp: Date.now(),
message,
type
}]);
};
const setupWebSocket = () => {
if (websocketRef.current?.readyState === WebSocket.OPEN) return;
// Determine if we're running on localhost
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let wsUrl;
if (isLocalhost) {
// For localhost development
wsUrl = `ws://localhost:${process.env.WEBSOCKET_PORT}`;
} else {
// For production deployment
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws`;
}
console.log('Connecting to WebSocket:', wsUrl);
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.binaryType = 'arraybuffer';
websocketRef.current.onopen = () => {
// Start sending pings when connection opens
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
websocketRef.current.send(JSON.stringify({
type: 'ping',
timestamp: Date.now()
}));
}
}, 1000);
};
websocketRef.current.onmessage = async (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const now = Date.now();
if (audioFormatRef.current) {
const int16Array = new Int16Array(event.data);
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
float32Array[i] = int16Array[i] / 32768.0;
}
if (echoNodeRef.current) {
// Set playback start time when first audio chunk is received
if (!playbackStartTimeRef.current) {
playbackStartTimeRef.current = Date.now();
}
echoNodeRef.current.port.postMessage({ type: 'unmute' });
if (vadEndTimeRef.current && !hasPlaybackLatencyRef.current) {
const latency = now - vadEndTimeRef.current;
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
addLog(`First audio playback latency - Browser VAD: ${latency}ms`, 'latency');
addLog(`First audio playback latency - Server VAD: ${serverVadLatency}ms`, 'latency');
hasPlaybackLatencyRef.current = true;
}
echoNodeRef.current.port.postMessage(float32Array);
}
}
} else {
const jsonData = JSON.parse(event.data);
const now = Date.now();
// Add ping handling
if (jsonData.type === 'pong') {
const latency = now - jsonData.timestamp;
addLog(`WebSocket latency: ${latency}ms`, 'latency');
return;
}
switch (jsonData.type) {
case 'speech_end':
speechEndTimeRef.current = now;
if (vadEndTimeRef.current) {
const vadToServerLatency = now - vadEndTimeRef.current;
addLog(`[Server] Speech end detected (${vadToServerLatency}ms after frontend VAD)`, 'latency');
} else {
// Use server's speech end as VAD end time if frontend didn't detect it
vadEndTimeRef.current = now;
hasPlaybackLatencyRef.current = false;
addLog('[Server] Speech end detected (using as VAD endpoint)', 'latency');
}
break;
case 'speech_start':
// Handle interrupt
handleInterrupt();
// Clear audio queue and stop playback
if (echoNodeRef.current) {
echoNodeRef.current.port.postMessage({ type: 'clear' });
}
audioQueueRef.current = [];
if (vadStartTimeRef.current) {
const vadToServerLatency = now - vadStartTimeRef.current;
addLog(`[Server] Speech start detected (${vadToServerLatency}ms after frontend VAD)`, 'latency');
} else {
vadStartTimeRef.current = now;
addLog('[Server] Speech start detected (using as VAD start point)', 'latency');
}
break;
case 'llm_start':
llmStartTimeRef.current = now;
if (vadEndTimeRef.current) {
const browserVadLatency = now - vadEndTimeRef.current;
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
addLog(`Transcribe latency - Browser VAD: ${browserVadLatency}ms`, 'latency');
addLog(`Transcribe latency - Server VAD: ${serverVadLatency}ms`, 'latency');
}
break;
case 'llm_first_token':
if (llmStartTimeRef.current) {
const latency = now - llmStartTimeRef.current;
addLog(`LLM time to first token: ${latency}ms`, 'latency');
}
break;
case 'llm_first_sentence':
if (vadEndTimeRef.current) {
const llmLatency = now - llmStartTimeRef.current;
const browserVadLatency = now - vadEndTimeRef.current;
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
addLog(`LLM first sentence latency - LLM: ${llmLatency}ms`, 'latency');
addLog(`LLM first sentence latency - Browser VAD: ${browserVadLatency}ms`, 'latency');
addLog(`LLM first sentence latency - Server VAD: ${serverVadLatency}ms`, 'latency');
}
break;
case 'tts_complete':
if (vadEndTimeRef.current) {
addLog(`TTS synthesis time: ${jsonData.synthesisTime}ms`, 'latency');
}
break;
case 'audio_start':
// Reset playback start time when new audio stream starts
playbackStartTimeRef.current = null;
if (echoNodeRef.current) {
echoNodeRef.current.port.postMessage({ type: 'unmute' });
}
addLog(`Audio format: ${JSON.stringify(jsonData.format)}`);
audioFormatRef.current = jsonData.format;
break;
case 'audio_end':
addLog('Audio streaming completed');
break;
case 'transcript':
if (jsonData.messageId) {
// Update the current valid message ID when receiving a transcript
currentValidMessageIdRef.current = jsonData.messageId;
}
if (jsonData.isFinal) {
addLog(`[ASR] Final transcript: "${jsonData.text}"`);
} else {
addLog(`[ASR] Interim transcript: "${jsonData.text}"`);
}
break;
case 'error':
addLog(`Error: ${jsonData.message}`, 'error');
break;
case 'llm_sentence':
if (jsonData.messageId === currentValidMessageIdRef.current) {
addLog(`LLM: "${jsonData.text}"`, 'llm');
} else {
addLog(`Ignored LLM response for recalled message: "${jsonData.text}"`, 'info');
}
break;
case 'websocket_latency':
addLog(`WebSocket RTT: ${jsonData.roundTripTime}ms`, 'latency');
break;
case 'chat_history_delta':
setChatHistory(prev => {
// Remove messages from startIndex onwards and insert new messages
return [
...prev.slice(0, jsonData.startIndex),
...jsonData.messages
];
});
break;
case 'debug_info':
addLog(jsonData.message, 'info');
break;
case 'vad_status':
// Silently ignore VAD status messages to reduce log noise
break;
default:
addLog(`Received message: ${JSON.stringify(jsonData)}`);
}
}
} catch (error) {
addLog(`Error processing message: ${error}`, 'error');
}
};
websocketRef.current.onclose = () => {
addLog('WebSocket closed');
websocketRef.current = null;
// A server restart or network blip fires onclose mid-session. Without
// tearing down, the button stays "Stop Recording", the mic keeps
// capturing, the ping interval keeps firing, and the audio worklet
// silently discards every captured buffer (it checks
// websocketRef.current?.readyState, now null) -- a "zombie recording".
// websocketRef is already null here, so stopRecording won't re-close the
// socket or re-enter onclose.
stopRecording();
};
websocketRef.current.onerror = (error) => {
addLog(`WebSocket error: ${error}`, 'error');
};
};
const startRecording = async () => {
try {
// Setup WebSocket first
setupWebSocket();
// Check if AudioContext and AudioWorklet are supported
if (!window.AudioContext && !(window as any).webkitAudioContext) {
throw new Error('AudioContext is not supported in this browser');
}
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
// Create AudioContext with specific sample rate
audioContextRef.current = new AudioContextClass({
sampleRate: 16000
});
// Check if audioWorklet is supported
if (!audioContextRef.current.audioWorklet) {
throw new Error('AudioWorklet is not supported in this browser. Please use a modern browser like Chrome or Firefox.');
}
// Resume the audio context first (needed for some browsers)
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
console.log('Audio context created with sample rate:', audioContextRef.current.sampleRate);
try {
// Add the audio worklet module with the full URL path
const workletUrl = new URL('/audioWorklet.js', window.location.origin).href;
console.log('Loading audio worklet from:', workletUrl);
// Add a timeout to the worklet loading
const workletLoadPromise = audioContextRef.current.audioWorklet.addModule(workletUrl);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Audio worklet load timeout')), 5000);
});
await Promise.race([workletLoadPromise, timeoutPromise]);
console.log('Audio worklet loaded successfully');
} catch (workletError) {
console.error('Error loading audio worklet:', workletError);
throw new Error(`Failed to load audio worklet module: ${workletError.message}`);
}
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('getUserMedia is not supported in this browser');
}
// Get user media after worklet is loaded
try {
streamRef.current = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
} catch (mediaError) {
console.error('Error accessing microphone:', mediaError);
throw new Error(`Microphone access failed: ${mediaError.message}`);
}
try {
sourceNodeRef.current = audioContextRef.current.createMediaStreamSource(streamRef.current);
workletNodeRef.current = new AudioWorkletNode(audioContextRef.current, 'audio-processor');
echoNodeRef.current = new AudioWorkletNode(audioContextRef.current, 'echo-processor');
sourceNodeRef.current.connect(workletNodeRef.current);
echoNodeRef.current.connect(audioContextRef.current.destination);
console.log('Audio nodes connected successfully');
} catch (nodeError) {
console.error('Error setting up audio nodes:', nodeError);
throw new Error(`Audio node setup failed: ${nodeError.message}`);
}
workletNodeRef.current.port.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
websocketRef.current.send(event.data);
}
} else if (event.data.type === 'vad') {
if (event.data.status === 'speech_end') {
vadEndTimeRef.current = Date.now();
hasPlaybackLatencyRef.current = false;
addLog('[Frontend VAD] End of speech detected');
} else if (event.data.status === 'speech_start') {
vadStartTimeRef.current = Date.now();
addLog('[Frontend VAD] Start of speech detected');
}
}
};
// Add message handler for the echo node
echoNodeRef.current.port.onmessage = (event) => {
if (event.data.type === 'queue_empty' && isPlayingRef.current) {
isPlayingRef.current = false;
// Reset playback state
playbackStartTimeRef.current = null;
hasPlaybackLatencyRef.current = false;
addLog('Audio playback completed');
}
};
setIsRecording(true);
} catch (error) {
console.error('Error in startRecording:', error);
addLog(`Error: ${error.message}`, 'error');
// Clean up any partially initialized resources
stopRecording();
}
};
const stopRecording = () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
hasPlaybackLatencyRef.current = false;
if (workletNodeRef.current) {
workletNodeRef.current.disconnect();
}
if (echoNodeRef.current) {
echoNodeRef.current.disconnect();
}
if (sourceNodeRef.current) {
sourceNodeRef.current.disconnect();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
if (audioContextRef.current) {
// Guard against closing an already-closed context (stopRecording is now
// idempotent because onclose also calls it).
if (audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
}
audioContextRef.current = null;
}
if (websocketRef.current) {
websocketRef.current.close();
websocketRef.current = null;
}
setIsRecording(false);
};
const clearLogs = () => {
setLogs([]);
};
// Add this helper function before the return statement
const formatLatencyLog = (message: string) => {
// Check if it's a latency message with ":" or "-"
const splitChar = message.includes(':') ? ':' : message.includes('-') ? '-' : null;
if (!splitChar) return message;
const [label, values] = message.split(splitChar);
return (
<div className="grid grid-cols-[1fr,auto] gap-2">
<span>{label}{splitChar}</span>
<span className="font-mono">{values}</span>
</div>
);
};
// Update the handleInterrupt function
const handleInterrupt = () => {
// Stop current audio playback and clear queue
if (echoNodeRef.current) {
echoNodeRef.current.port.postMessage({ type: 'clear' });
echoNodeRef.current.port.postMessage({ type: 'mute' });
echoNodeRef.current.disconnect();
echoNodeRef.current.connect(audioContextRef.current!.destination);
}
audioQueueRef.current = [];
// Reset playback state
playbackStartTimeRef.current = null;
hasPlaybackLatencyRef.current = false;
isPlayingRef.current = false;
};
// Add scroll event handlers
const handleChatScroll = () => {
if (!chatHistoryRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current;
// Consider "at bottom" if within 100 pixels of the bottom
shouldAutoScrollChatRef.current = scrollHeight - (scrollTop + clientHeight) < 100;
};
const handleLogsScroll = () => {
if (!logsRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsRef.current;
// Consider "at bottom" if within 100 pixels of the bottom
shouldAutoScrollLogsRef.current = scrollHeight - (scrollTop + clientHeight) < 100;
};
// Add scroll to bottom functions
const scrollChatToBottom = () => {
if (chatHistoryRef.current && shouldAutoScrollChatRef.current) {
chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight;
}
};
const scrollLogsToBottom = () => {
if (logsRef.current && shouldAutoScrollLogsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight;
}
};
// Update useEffect to scroll when chat history or logs change
useEffect(() => {
scrollChatToBottom();
}, [chatHistory]);
useEffect(() => {
scrollLogsToBottom();
}, [logs]);
// Tear down the mic stream, AudioContext, WebSocket and ping interval if the
// component unmounts mid-recording (client-side nav / fast refresh), instead
// of leaving the microphone active and the ping interval firing.
useEffect(() => {
return () => {
stopRecording();
};
}, []);
return (
<div className="min-h-screen flex flex-col bg-gray-100 p-2 sm:p-4">
{/* Top Controls */}
<div className="mb-4 flex flex-col sm:flex-row justify-center gap-2 sm:gap-4">
<Button
onClick={isRecording ? stopRecording : startRecording}
variant={isRecording ? "destructive" : "default"}
className={`px-4 sm:px-8 py-4 sm:py-6 text-base sm:text-lg font-semibold flex items-center justify-center gap-2 ${
!isRecording ? 'bg-green-600 hover:bg-green-700' : ''
}`}
>
{isRecording ? (
<>
<MicOff className="w-5 h-5 sm:w-6 sm:h-6" />
Stop Recording
</>
) : (
<>
<Mic className="w-5 h-5 sm:w-6 sm:h-6" />
Start Recording
</>
)}
</Button>
<Button
onClick={clearLogs}
variant="outline"
className="px-4 sm:px-8 py-4 sm:py-6 text-base sm:text-lg font-semibold flex items-center justify-center gap-2"
>
<Trash2 className="w-5 h-5 sm:w-6 sm:h-6" />
Clear Logs
</Button>
</div>
{/* Mobile Tabs - Only show on small screens */}
<div className="lg:hidden mb-2">
<div className="flex border-b border-gray-200">
<TabButton
active={activeTab === 'chat'}
onClick={() => setActiveTab('chat')}
>
Chat History
</TabButton>
<TabButton
active={activeTab === 'logs'}
onClick={() => setActiveTab('logs')}
>
Logs
</TabButton>
</div>
</div>
{/* Main Content */}
<div className="flex flex-col lg:flex-row flex-1 gap-2 sm:gap-4">
{/* Chat History Panel */}
<div className={`w-full lg:w-1/2 h-[calc(100vh-12rem)] lg:h-[calc(90vh-5rem)] ${
activeTab === 'chat' ? 'block' : 'hidden lg:block'
}`}>
<Card className="h-full">
<CardHeader className="pb-2 hidden lg:block">
<CardTitle className="text-center text-base sm:text-lg">Chat History</CardTitle>
</CardHeader>
<CardContent
ref={chatHistoryRef}
onScroll={handleChatScroll}
className="h-full lg:h-[calc(100%-3rem)] overflow-y-auto pt-4 lg:pt-0"
>
<div className="flex flex-col space-y-4">
{chatHistory.map((message, index) => (
<div
key={index}
className={`flex ${
message.role === 'assistant' ? 'bg-gray-50' :
message.role === 'transcript' ? 'bg-blue-50' :
'bg-white'
} p-3 sm:p-4 rounded-lg animate-slide-in`}
>
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full flex-shrink-0 mr-3 sm:mr-4">
{message.role === 'assistant' ? (
<div className="w-full h-full bg-green-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
AI
</div>
) : message.role === 'transcript' ? (
<div className="w-full h-full bg-blue-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
T
</div>
) : (
<div className="w-full h-full bg-gray-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
U
</div>
)}
</div>
<div className="flex-1 prose prose-sm dark:prose-invert prose-p:my-3 prose-headings:mb-3 prose-headings:mt-6 prose-li:my-2 prose-pre:bg-gray-800 prose-pre:text-gray-100 max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: CodeBlock,
}}
>
{message.content}
</ReactMarkdown>
{message.role === 'transcript' && !message.isFinal && (
<span className="text-xs text-gray-500 ml-2 animate-fade-in">(typing...)</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Logs Panel */}
<div className={`w-full lg:w-1/2 h-[calc(100vh-12rem)] lg:h-[calc(90vh-5rem)] ${
activeTab === 'logs' ? 'block' : 'hidden lg:block'
}`}>
<Card className="h-full overflow-hidden">
<CardHeader className="pb-2 hidden lg:block">
<CardTitle className="text-center text-base sm:text-lg">Logs</CardTitle>
</CardHeader>
<CardContent
ref={logsRef}
onScroll={handleLogsScroll}
className="h-full lg:h-[calc(100%-3rem)] bg-gray-900 p-2 lg:p-3 overflow-y-auto"
>
<div className="font-mono text-xs sm:text-sm">
{logs.map((log, index) => {
const time = new Date(log.timestamp).toISOString().split('T')[1].slice(0, -1);
const baseClasses = "mb-1 font-mono";
const typeClasses = {
error: "text-red-400",
latency: "text-cyan-400",
llm: "text-green-400",
info: "text-gray-300"
};
// Special styling for different event types
const prefixColor = {
vad: "text-purple-400",
asr: "text-yellow-400",
server: "text-blue-400"
};
let content = log.message;
let prefix = null;
// Extract prefix if message starts with [Something]
const prefixMatch = log.message.match(/^\[(.*?)\]/);
if (prefixMatch) {
prefix = prefixMatch[0];
content = log.message.slice(prefix.length);
}
return (
<div key={index} className={`${baseClasses} ${typeClasses[log.type]}`}>
<span className="text-gray-500">[{time}]</span>{' '}
{prefix && (
<span className={
Object.entries(prefixColor).find(([key]) =>
prefix?.toLowerCase().includes(key))?.[1] || typeClasses[log.type]
}>
{prefix}
</span>
)}
{log.type === 'latency' ? (
formatLatencyLog(content)
) : (
<span>{content}</span>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}