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:
Xin Wang
2026-02-27 17:21:56 +08:00
parent 1a2d9a4632
commit ebb6f59878
2 changed files with 132 additions and 28 deletions

View File

@@ -1673,6 +1673,13 @@ class DuplexPipeline:
def _tool_wait_for_response(self, tool_name: str) -> bool:
return bool(self._runtime_tool_wait_for_response.get(tool_name, False))
def _tool_wait_timeout_seconds(self, tool_name: str) -> float:
if tool_name == "text_msg_prompt" and self._tool_wait_for_response(tool_name):
# Keep engine wait slightly longer than UI auto-close (120s)
# to avoid race where engine times out before client emits timeout result.
return 125.0
return self._TOOL_WAIT_TIMEOUT_SECONDS
def _tool_executor(self, tool_call: Dict[str, Any]) -> str:
name = self._tool_name(tool_call)
if name and name in self._runtime_tool_executor:
@@ -1792,10 +1799,17 @@ class DuplexPipeline:
self._early_tool_results[call_id] = item
self._completed_tool_call_ids.add(call_id)
async def _wait_for_single_tool_result(self, call_id: str) -> Dict[str, Any]:
async def _wait_for_single_tool_result(
self,
call_id: str,
*,
tool_name: str = "unknown_tool",
timeout_seconds: Optional[float] = None,
) -> Dict[str, Any]:
if call_id in self._completed_tool_call_ids and call_id not in self._early_tool_results:
return {
"tool_call_id": call_id,
"name": tool_name,
"status": {"code": 208, "message": "tool_call result already handled"},
"output": "",
}
@@ -1806,12 +1820,14 @@ class DuplexPipeline:
loop = asyncio.get_running_loop()
future = loop.create_future()
self._pending_tool_waiters[call_id] = future
wait_timeout = float(timeout_seconds if isinstance(timeout_seconds, (int, float)) and timeout_seconds > 0 else self._TOOL_WAIT_TIMEOUT_SECONDS)
try:
return await asyncio.wait_for(future, timeout=self._TOOL_WAIT_TIMEOUT_SECONDS)
return await asyncio.wait_for(future, timeout=wait_timeout)
except asyncio.TimeoutError:
self._completed_tool_call_ids.add(call_id)
return {
"tool_call_id": call_id,
"name": tool_name,
"status": {"code": 504, "message": "tool_call timeout"},
"output": "",
}
@@ -1900,7 +1916,9 @@ class DuplexPipeline:
tool_id = self._tool_id_for_name(tool_name)
tool_display_name = self._tool_display_name(tool_name) or tool_name
wait_for_response = self._tool_wait_for_response(tool_name)
wait_timeout_seconds = self._tool_wait_timeout_seconds(tool_name)
enriched_tool_call["wait_for_response"] = wait_for_response
enriched_tool_call["wait_timeout_ms"] = int(wait_timeout_seconds * 1000)
call_id = str(enriched_tool_call.get("id") or "").strip()
fn_payload = (
dict(enriched_tool_call.get("function"))
@@ -1935,9 +1953,10 @@ class DuplexPipeline:
tool_id=tool_id,
tool_display_name=tool_display_name,
wait_for_response=wait_for_response,
wait_timeout_ms=int(wait_timeout_seconds * 1000),
arguments=tool_arguments,
executor=executor,
timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000),
timeout_ms=int(wait_timeout_seconds * 1000),
tool_call=enriched_tool_call,
)
},
@@ -2075,7 +2094,11 @@ class DuplexPipeline:
tool_id = self._tool_id_for_name(tool_name)
logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}")
if executor == "client":
result = await self._wait_for_single_tool_result(call_id)
result = await self._wait_for_single_tool_result(
call_id,
tool_name=tool_name,
timeout_seconds=self._tool_wait_timeout_seconds(tool_name),
)
await self._emit_tool_result(result, source="client")
tool_results.append(result)
continue

View File

@@ -1901,11 +1901,50 @@ const TranscriptionLog: React.FC<{
messages: DebugTranscriptMessage[];
isLoading: boolean;
className?: string;
}> = ({ scrollRef, messages, isLoading, className = '' }) => (
}> = ({ 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>
@@ -1918,10 +1957,12 @@ const TranscriptionLog: React.FC<{
{m.text}
</div>
</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 };