This commit is contained in:
Xin Wang
2026-02-27 17:38:48 +08:00
2 changed files with 133 additions and 29 deletions

View File

@@ -1901,27 +1901,68 @@ const TranscriptionLog: React.FC<{
messages: DebugTranscriptMessage[];
isLoading: boolean;
className?: string;
}> = ({ scrollRef, messages, isLoading, className = '' }) => (
<div ref={scrollRef} className={`overflow-y-auto overflow-x-hidden space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0 custom-scrollbar ${className}`}>
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4"></div>}
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${m.role === 'user' ? 'bg-primary text-primary-foreground' : m.role === 'tool' ? 'bg-amber-500/10 border border-amber-400/30 text-amber-100' : 'bg-card border border-white/10 shadow-sm text-foreground'}`}>
<div className="mb-0.5 flex items-center gap-1.5">
<span className="text-[10px] opacity-70 uppercase tracking-wider">{m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'}</span>
{m.role === 'model' && typeof m.ttfbMs === 'number' && Number.isFinite(m.ttfbMs) && (
<span className="rounded border border-cyan-300/40 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] text-cyan-200">
TTFB {Math.round(m.ttfbMs)}ms
</span>
}> = ({ scrollRef, messages, isLoading, className = '' }) => {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const copyText = async (text: string, index: number) => {
const value = String(text || '');
if (!value) return;
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopiedIndex(index);
window.setTimeout(() => {
setCopiedIndex((prev) => (prev === index ? null : prev));
}, 1200);
} catch {
// no-op
}
};
return (
<div ref={scrollRef} className={`overflow-y-auto overflow-x-hidden space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0 custom-scrollbar ${className}`}>
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4"></div>}
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className="flex items-center gap-2">
{m.role === 'user' && (
<button
type="button"
onClick={() => void copyText(m.text, i)}
className="h-6 w-6 shrink-0 rounded border border-white/20 bg-black/20 text-primary-foreground/80 hover:bg-black/35 transition-colors flex items-center justify-center"
title="复制此消息"
>
{copiedIndex === i ? <ClipboardCheck className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
)}
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${m.role === 'user' ? 'bg-primary text-primary-foreground' : m.role === 'tool' ? 'bg-amber-500/10 border border-amber-400/30 text-amber-100' : 'bg-card border border-white/10 shadow-sm text-foreground'}`}>
<div className="mb-0.5 flex items-center gap-1.5">
<span className="text-[10px] opacity-70 uppercase tracking-wider">{m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'}</span>
{m.role === 'model' && typeof m.ttfbMs === 'number' && Number.isFinite(m.ttfbMs) && (
<span className="rounded border border-cyan-300/40 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] text-cyan-200">
TTFB {Math.round(m.ttfbMs)}ms
</span>
)}
</div>
{m.text}
</div>
</div>
{m.text}
</div>
</div>
))}
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
</div>
);
))}
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
</div>
);
};
// --- Debug Drawer Component ---
export const DebugDrawer: React.FC<{
@@ -2086,6 +2127,7 @@ export const DebugDrawer: React.FC<{
const micProcessorRef = useRef<ScriptProcessorNode | null>(null);
const micGainRef = useRef<GainNode | null>(null);
const micFrameBufferRef = useRef<Uint8Array>(new Uint8Array(0));
const textPromptTimeoutRef = useRef<number | null>(null);
const userDraftIndexRef = useRef<number | null>(null);
const lastUserFinalRef = useRef<string>('');
const debugVolumePercentRef = useRef<number>(50);
@@ -2172,6 +2214,7 @@ export const DebugDrawer: React.FC<{
stopVoiceCapture();
stopMedia();
closeWs();
clearTextPromptTimeout();
setTextPromptDialog({ open: false, message: '' });
if (audioCtxRef.current) {
void audioCtxRef.current.close();
@@ -2215,6 +2258,12 @@ export const DebugDrawer: React.FC<{
}
}, [clientToolEnabledMap]);
useEffect(() => {
return () => {
clearTextPromptTimeout();
};
}, []);
// Auto-scroll logic
useEffect(() => {
if (scrollRef.current) {
@@ -2399,6 +2448,13 @@ export const DebugDrawer: React.FC<{
clearPlaybackQueue();
};
const clearTextPromptTimeout = () => {
if (textPromptTimeoutRef.current !== null) {
window.clearTimeout(textPromptTimeoutRef.current);
textPromptTimeoutRef.current = null;
}
};
const emitClientToolResult = (resultPayload: any, toolDisplayName?: string) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -2425,7 +2481,8 @@ export const DebugDrawer: React.FC<{
]);
};
const closeTextPromptDialog = (action: 'confirm' | 'dismiss') => {
const closeTextPromptDialog = (action: 'confirm' | 'dismiss' | 'timeout') => {
clearTextPromptTimeout();
let pending:
| {
toolCallId: string;
@@ -2441,22 +2498,45 @@ export const DebugDrawer: React.FC<{
return { open: false, message: '' };
});
if (pending?.waitForResponse) {
const isTimeout = action === 'timeout';
emitClientToolResult(
{
tool_call_id: pending.toolCallId,
name: pending.toolName,
output: {
message: 'text_prompt_closed',
message: isTimeout ? 'text_prompt_timeout' : 'text_prompt_closed',
action,
msg: message,
},
status: { code: 200, message: 'ok' },
status: isTimeout ? { code: 504, message: 'tool_call timeout' } : { code: 200, message: 'ok' },
},
pending.toolDisplayName
);
}
};
const openTextPromptDialog = (args: {
message: string;
pendingResult?: {
toolCallId: string;
toolName: string;
toolDisplayName: string;
waitForResponse: boolean;
};
autoCloseMs?: number;
}) => {
clearTextPromptTimeout();
setTextPromptDialog({
open: true,
message: args.message,
pendingResult: args.pendingResult,
});
const timeoutMs = Math.max(1000, Number(args.autoCloseMs || 120000));
textPromptTimeoutRef.current = window.setTimeout(() => {
closeTextPromptDialog('timeout');
}, timeoutMs);
};
const scheduleQueuedPlayback = (ctx: AudioContext) => {
const queue = queuedAudioBuffersRef.current;
if (queue.length === 0) return;
@@ -2587,6 +2667,8 @@ export const DebugDrawer: React.FC<{
const handleHangup = () => {
if (textPromptDialog.open) {
closeTextPromptDialog('dismiss');
} else {
clearTextPromptTimeout();
}
stopVoiceCapture();
stopMedia();
@@ -2594,7 +2676,6 @@ export const DebugDrawer: React.FC<{
setCallStatus('idle');
clearResponseTracking();
setMessages([]);
setTextPromptDialog({ open: false, message: '' });
lastUserFinalRef.current = '';
setIsLoading(false);
};
@@ -3236,8 +3317,7 @@ export const DebugDrawer: React.FC<{
resultPayload.output = { message: "Missing required argument 'msg'" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else {
setTextPromptDialog({
open: true,
openTextPromptDialog({
message: msg,
pendingResult: {
toolCallId: toolCallId,
@@ -3245,6 +3325,7 @@ export const DebugDrawer: React.FC<{
toolDisplayName,
waitForResponse,
},
autoCloseMs: 120000,
});
if (!waitForResponse) {
resultPayload.output = { message: 'text_prompt_shown', msg };
@@ -3649,7 +3730,7 @@ export const DebugDrawer: React.FC<{
title={enabled ? '点击关闭' : '点击开启'}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-5' : 'translate-x-0.5'}`}
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-5' : 'translate-x-0'}`}
/>
</button>
</div>