From 1a2d9a46320cbe14411d22b911870b242c154345 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 27 Feb 2026 16:55:46 +0800 Subject: [PATCH 1/2] Update DebugDrawer button styling for improved alignment and visual consistency. Adjusted button's translate-x class for better positioning of the toggle indicator. --- web/pages/Assistants.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index 6cf8fe6..49d132d 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -3649,7 +3649,7 @@ export const DebugDrawer: React.FC<{ title={enabled ? '点击关闭' : '点击开启'} > From ebb6f598788d3b00ed4070f2be577ee272d4ad29 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 27 Feb 2026 17:21:56 +0800 Subject: [PATCH 2/2] 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. --- engine/core/duplex_pipeline.py | 31 +++++++- web/pages/Assistants.tsx | 129 +++++++++++++++++++++++++++------ 2 files changed, 132 insertions(+), 28 deletions(-) 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 };