Compare commits
3 Commits
engine-v3
...
e976f7d833
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e976f7d833 | ||
|
|
ebb6f59878 | ||
|
|
1a2d9a4632 |
@@ -1673,6 +1673,13 @@ class DuplexPipeline:
|
|||||||
def _tool_wait_for_response(self, tool_name: str) -> bool:
|
def _tool_wait_for_response(self, tool_name: str) -> bool:
|
||||||
return bool(self._runtime_tool_wait_for_response.get(tool_name, False))
|
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:
|
def _tool_executor(self, tool_call: Dict[str, Any]) -> str:
|
||||||
name = self._tool_name(tool_call)
|
name = self._tool_name(tool_call)
|
||||||
if name and name in self._runtime_tool_executor:
|
if name and name in self._runtime_tool_executor:
|
||||||
@@ -1792,10 +1799,17 @@ class DuplexPipeline:
|
|||||||
self._early_tool_results[call_id] = item
|
self._early_tool_results[call_id] = item
|
||||||
self._completed_tool_call_ids.add(call_id)
|
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:
|
if call_id in self._completed_tool_call_ids and call_id not in self._early_tool_results:
|
||||||
return {
|
return {
|
||||||
"tool_call_id": call_id,
|
"tool_call_id": call_id,
|
||||||
|
"name": tool_name,
|
||||||
"status": {"code": 208, "message": "tool_call result already handled"},
|
"status": {"code": 208, "message": "tool_call result already handled"},
|
||||||
"output": "",
|
"output": "",
|
||||||
}
|
}
|
||||||
@@ -1806,12 +1820,14 @@ class DuplexPipeline:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
future = loop.create_future()
|
future = loop.create_future()
|
||||||
self._pending_tool_waiters[call_id] = 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:
|
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:
|
except asyncio.TimeoutError:
|
||||||
self._completed_tool_call_ids.add(call_id)
|
self._completed_tool_call_ids.add(call_id)
|
||||||
return {
|
return {
|
||||||
"tool_call_id": call_id,
|
"tool_call_id": call_id,
|
||||||
|
"name": tool_name,
|
||||||
"status": {"code": 504, "message": "tool_call timeout"},
|
"status": {"code": 504, "message": "tool_call timeout"},
|
||||||
"output": "",
|
"output": "",
|
||||||
}
|
}
|
||||||
@@ -1900,7 +1916,9 @@ class DuplexPipeline:
|
|||||||
tool_id = self._tool_id_for_name(tool_name)
|
tool_id = self._tool_id_for_name(tool_name)
|
||||||
tool_display_name = self._tool_display_name(tool_name) or 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_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_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()
|
call_id = str(enriched_tool_call.get("id") or "").strip()
|
||||||
fn_payload = (
|
fn_payload = (
|
||||||
dict(enriched_tool_call.get("function"))
|
dict(enriched_tool_call.get("function"))
|
||||||
@@ -1935,9 +1953,10 @@ class DuplexPipeline:
|
|||||||
tool_id=tool_id,
|
tool_id=tool_id,
|
||||||
tool_display_name=tool_display_name,
|
tool_display_name=tool_display_name,
|
||||||
wait_for_response=wait_for_response,
|
wait_for_response=wait_for_response,
|
||||||
|
wait_timeout_ms=int(wait_timeout_seconds * 1000),
|
||||||
arguments=tool_arguments,
|
arguments=tool_arguments,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000),
|
timeout_ms=int(wait_timeout_seconds * 1000),
|
||||||
tool_call=enriched_tool_call,
|
tool_call=enriched_tool_call,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -2075,7 +2094,11 @@ class DuplexPipeline:
|
|||||||
tool_id = self._tool_id_for_name(tool_name)
|
tool_id = self._tool_id_for_name(tool_name)
|
||||||
logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}")
|
logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}")
|
||||||
if executor == "client":
|
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")
|
await self._emit_tool_result(result, source="client")
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1901,27 +1901,68 @@ const TranscriptionLog: React.FC<{
|
|||||||
messages: DebugTranscriptMessage[];
|
messages: DebugTranscriptMessage[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ scrollRef, messages, isLoading, className = '' }) => (
|
}> = ({ 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}`}>
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4">暂无转写记录</div>}
|
|
||||||
{messages.map((m, i) => (
|
const copyText = async (text: string, index: number) => {
|
||||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
const value = String(text || '');
|
||||||
<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'}`}>
|
if (!value) return;
|
||||||
<div className="mb-0.5 flex items-center gap-1.5">
|
try {
|
||||||
<span className="text-[10px] opacity-70 uppercase tracking-wider">{m.role === 'user' ? 'Me' : m.role === 'tool' ? 'Tool' : 'AI'}</span>
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
{m.role === 'model' && typeof m.ttfbMs === 'number' && Number.isFinite(m.ttfbMs) && (
|
await navigator.clipboard.writeText(value);
|
||||||
<span className="rounded border border-cyan-300/40 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] text-cyan-200">
|
} else {
|
||||||
TTFB {Math.round(m.ttfbMs)}ms
|
const textarea = document.createElement('textarea');
|
||||||
</span>
|
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>
|
</div>
|
||||||
{m.text}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
|
||||||
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
// --- Debug Drawer Component ---
|
// --- Debug Drawer Component ---
|
||||||
export const DebugDrawer: React.FC<{
|
export const DebugDrawer: React.FC<{
|
||||||
@@ -2086,6 +2127,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
const micProcessorRef = useRef<ScriptProcessorNode | null>(null);
|
const micProcessorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
const micGainRef = useRef<GainNode | null>(null);
|
const micGainRef = useRef<GainNode | null>(null);
|
||||||
const micFrameBufferRef = useRef<Uint8Array>(new Uint8Array(0));
|
const micFrameBufferRef = useRef<Uint8Array>(new Uint8Array(0));
|
||||||
|
const textPromptTimeoutRef = useRef<number | null>(null);
|
||||||
const userDraftIndexRef = useRef<number | null>(null);
|
const userDraftIndexRef = useRef<number | null>(null);
|
||||||
const lastUserFinalRef = useRef<string>('');
|
const lastUserFinalRef = useRef<string>('');
|
||||||
const debugVolumePercentRef = useRef<number>(50);
|
const debugVolumePercentRef = useRef<number>(50);
|
||||||
@@ -2172,6 +2214,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
stopVoiceCapture();
|
stopVoiceCapture();
|
||||||
stopMedia();
|
stopMedia();
|
||||||
closeWs();
|
closeWs();
|
||||||
|
clearTextPromptTimeout();
|
||||||
setTextPromptDialog({ open: false, message: '' });
|
setTextPromptDialog({ open: false, message: '' });
|
||||||
if (audioCtxRef.current) {
|
if (audioCtxRef.current) {
|
||||||
void audioCtxRef.current.close();
|
void audioCtxRef.current.close();
|
||||||
@@ -2215,6 +2258,12 @@ export const DebugDrawer: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [clientToolEnabledMap]);
|
}, [clientToolEnabledMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTextPromptTimeout();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll logic
|
// Auto-scroll logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
@@ -2399,6 +2448,13 @@ export const DebugDrawer: React.FC<{
|
|||||||
clearPlaybackQueue();
|
clearPlaybackQueue();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearTextPromptTimeout = () => {
|
||||||
|
if (textPromptTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(textPromptTimeoutRef.current);
|
||||||
|
textPromptTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const emitClientToolResult = (resultPayload: any, toolDisplayName?: string) => {
|
const emitClientToolResult = (resultPayload: any, toolDisplayName?: string) => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
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:
|
let pending:
|
||||||
| {
|
| {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
@@ -2441,22 +2498,45 @@ export const DebugDrawer: React.FC<{
|
|||||||
return { open: false, message: '' };
|
return { open: false, message: '' };
|
||||||
});
|
});
|
||||||
if (pending?.waitForResponse) {
|
if (pending?.waitForResponse) {
|
||||||
|
const isTimeout = action === 'timeout';
|
||||||
emitClientToolResult(
|
emitClientToolResult(
|
||||||
{
|
{
|
||||||
tool_call_id: pending.toolCallId,
|
tool_call_id: pending.toolCallId,
|
||||||
name: pending.toolName,
|
name: pending.toolName,
|
||||||
output: {
|
output: {
|
||||||
message: 'text_prompt_closed',
|
message: isTimeout ? 'text_prompt_timeout' : 'text_prompt_closed',
|
||||||
action,
|
action,
|
||||||
msg: message,
|
msg: message,
|
||||||
},
|
},
|
||||||
status: { code: 200, message: 'ok' },
|
status: isTimeout ? { code: 504, message: 'tool_call timeout' } : { code: 200, message: 'ok' },
|
||||||
},
|
},
|
||||||
pending.toolDisplayName
|
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 scheduleQueuedPlayback = (ctx: AudioContext) => {
|
||||||
const queue = queuedAudioBuffersRef.current;
|
const queue = queuedAudioBuffersRef.current;
|
||||||
if (queue.length === 0) return;
|
if (queue.length === 0) return;
|
||||||
@@ -2587,6 +2667,8 @@ export const DebugDrawer: React.FC<{
|
|||||||
const handleHangup = () => {
|
const handleHangup = () => {
|
||||||
if (textPromptDialog.open) {
|
if (textPromptDialog.open) {
|
||||||
closeTextPromptDialog('dismiss');
|
closeTextPromptDialog('dismiss');
|
||||||
|
} else {
|
||||||
|
clearTextPromptTimeout();
|
||||||
}
|
}
|
||||||
stopVoiceCapture();
|
stopVoiceCapture();
|
||||||
stopMedia();
|
stopMedia();
|
||||||
@@ -2594,7 +2676,6 @@ export const DebugDrawer: React.FC<{
|
|||||||
setCallStatus('idle');
|
setCallStatus('idle');
|
||||||
clearResponseTracking();
|
clearResponseTracking();
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setTextPromptDialog({ open: false, message: '' });
|
|
||||||
lastUserFinalRef.current = '';
|
lastUserFinalRef.current = '';
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -3236,8 +3317,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
resultPayload.output = { message: "Missing required argument 'msg'" };
|
resultPayload.output = { message: "Missing required argument 'msg'" };
|
||||||
resultPayload.status = { code: 422, message: 'invalid_arguments' };
|
resultPayload.status = { code: 422, message: 'invalid_arguments' };
|
||||||
} else {
|
} else {
|
||||||
setTextPromptDialog({
|
openTextPromptDialog({
|
||||||
open: true,
|
|
||||||
message: msg,
|
message: msg,
|
||||||
pendingResult: {
|
pendingResult: {
|
||||||
toolCallId: toolCallId,
|
toolCallId: toolCallId,
|
||||||
@@ -3245,6 +3325,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
toolDisplayName,
|
toolDisplayName,
|
||||||
waitForResponse,
|
waitForResponse,
|
||||||
},
|
},
|
||||||
|
autoCloseMs: 120000,
|
||||||
});
|
});
|
||||||
if (!waitForResponse) {
|
if (!waitForResponse) {
|
||||||
resultPayload.output = { message: 'text_prompt_shown', msg };
|
resultPayload.output = { message: 'text_prompt_shown', msg };
|
||||||
@@ -3649,7 +3730,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
title={enabled ? '点击关闭' : '点击开启'}
|
title={enabled ? '点击关闭' : '点击开启'}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-5' : 'translate-x-0.5'}`}
|
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-5' : 'translate-x-0'}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user