Enhance DuplexPipeline and Assistants components to support dynamic tool wait timeouts. Introduce a new method for calculating wait time based on tool type, and update the tool result handling to include timeout management. Improve UI for message copying in Assistants, allowing users to easily copy messages with visual feedback.
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user