diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py
index e575bab..f3f08dc 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 08d80af..12a2632 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 };
@@ -3649,7 +3730,7 @@ export const DebugDrawer: React.FC<{
title={enabled ? '点击关闭' : '点击开启'}
>