diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py index 3bc4163..15ddb65 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/core/duplex_pipeline.py @@ -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 diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index 49d132d..4c5d613 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -1901,27 +1901,68 @@ const TranscriptionLog: React.FC<{ messages: DebugTranscriptMessage[]; isLoading: boolean; className?: string; -}> = ({ scrollRef, messages, isLoading, className = '' }) => ( -
- {messages.length === 0 &&
暂无转写记录
} - {messages.map((m, i) => ( -
-
-
- {m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'} - {m.role === 'model' && typeof m.ttfbMs === 'number' && Number.isFinite(m.ttfbMs) && ( - - TTFB {Math.round(m.ttfbMs)}ms - +}> = ({ scrollRef, messages, isLoading, className = '' }) => { + const [copiedIndex, setCopiedIndex] = useState(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 ( +
+ {messages.length === 0 &&
暂无转写记录
} + {messages.map((m, i) => ( +
+
+ {m.role === 'user' && ( + )} +
+
+ {m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'} + {m.role === 'model' && typeof m.ttfbMs === 'number' && Number.isFinite(m.ttfbMs) && ( + + TTFB {Math.round(m.ttfbMs)}ms + + )} +
+ {m.text} +
- {m.text}
-
- ))} - {isLoading &&
Thinking...
} -
-); + ))} + {isLoading &&
Thinking...
} +
+ ); +}; // --- Debug Drawer Component --- export const DebugDrawer: React.FC<{ @@ -2086,6 +2127,7 @@ export const DebugDrawer: React.FC<{ const micProcessorRef = useRef(null); const micGainRef = useRef(null); const micFrameBufferRef = useRef(new Uint8Array(0)); + const textPromptTimeoutRef = useRef(null); const userDraftIndexRef = useRef(null); const lastUserFinalRef = useRef(''); const debugVolumePercentRef = useRef(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 };