Merge WS v1 engine and web debug runtime-config integration

This commit is contained in:
Xin Wang
2026-02-09 08:19:55 +08:00
13 changed files with 986 additions and 298 deletions

View File

@@ -4,8 +4,7 @@ import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Vide
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
import { mockLLMModels, mockASRModels } from '../services/mockData';
import { Assistant, KnowledgeBase, TabValue, Voice } from '../types';
import { GoogleGenAI } from "@google/genai";
import { createAssistant, deleteAssistant, fetchAssistants, fetchKnowledgeBases, fetchVoices, updateAssistant as updateAssistantApi } from '../services/backendApi';
import { createAssistant, deleteAssistant, fetchAssistantRuntimeConfig, fetchAssistants, fetchKnowledgeBases, fetchVoices, updateAssistant as updateAssistantApi } from '../services/backendApi';
interface ToolItem {
id: string;
@@ -1022,11 +1021,26 @@ export const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assis
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle');
const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected');
const [wsError, setWsError] = useState('');
const [resolvedConfigOpen, setResolvedConfigOpen] = useState(false);
const [resolvedConfigView, setResolvedConfigView] = useState<string>('');
const [wsUrl, setWsUrl] = useState<string>(() => {
const fromStorage = localStorage.getItem('debug_ws_url');
if (fromStorage) return fromStorage;
const defaultHost = window.location.hostname || 'localhost';
return `ws://${defaultHost}:8000/ws`;
});
// Media State
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const wsReadyRef = useRef(false);
const pendingResolveRef = useRef<(() => void) | null>(null);
const pendingRejectRef = useRef<((e: Error) => void) | null>(null);
const assistantDraftIndexRef = useRef<number | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedCamera, setSelectedCamera] = useState<string>('');
@@ -1045,11 +1059,16 @@ export const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assis
} else {
setMode('text');
stopMedia();
closeWs();
setIsSwapped(false);
setCallStatus('idle');
}
}, [isOpen, assistant, mode]);
useEffect(() => {
localStorage.setItem('debug_ws_url', wsUrl);
}, [wsUrl]);
// Auto-scroll logic
useEffect(() => {
if (scrollRef.current) {
@@ -1132,29 +1151,196 @@ export const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assis
setIsLoading(true);
try {
if (process.env.API_KEY) {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const chat = ai.chats.create({
model: "gemini-3-flash-preview",
config: { systemInstruction: assistant.prompt },
history: messages.map(m => ({ role: m.role, parts: [{ text: m.text }] }))
});
const result = await chat.sendMessage({ message: userMsg });
setMessages(prev => [...prev, { role: 'model', text: result.text || '' }]);
if (mode === 'text') {
await ensureWsSession();
wsRef.current?.send(JSON.stringify({ type: 'input.text', text: userMsg }));
} else {
setTimeout(() => {
setMessages(prev => [...prev, { role: 'model', text: `[Mock Response]: Received "${userMsg}"` }]);
setIsLoading(false);
setMessages(prev => [...prev, { role: 'model', text: `[Mock Response]: Received "${userMsg}"` }]);
setIsLoading(false);
}, 1000);
}
} catch (e) {
console.error(e);
setMessages(prev => [...prev, { role: 'model', text: "Error: Failed to connect to AI service." }]);
} finally {
setIsLoading(false);
} finally {
if (mode !== 'text') setIsLoading(false);
}
};
const fetchRuntimeMetadata = async (): Promise<Record<string, any>> => {
try {
const resolved = await fetchAssistantRuntimeConfig(assistant.id);
setResolvedConfigView(
JSON.stringify(
{
assistantId: resolved.assistantId,
sources: resolved.sources,
warnings: resolved.warnings,
sessionStartMetadata: resolved.sessionStartMetadata || {},
},
null,
2,
),
);
return resolved.sessionStartMetadata || {};
} catch (error) {
console.error('Failed to load runtime config, using fallback.', error);
const fallback = {
systemPrompt: assistant.prompt || '',
greeting: assistant.opener || '',
services: {},
};
setResolvedConfigView(
JSON.stringify(
{
assistantId: assistant.id,
sources: {},
warnings: ['runtime-config endpoint failed; using local fallback'],
sessionStartMetadata: fallback,
},
null,
2,
),
);
return fallback;
}
};
const closeWs = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'session.stop', reason: 'debug_drawer_closed' }));
}
wsRef.current?.close();
wsRef.current = null;
wsReadyRef.current = false;
pendingResolveRef.current = null;
pendingRejectRef.current = null;
assistantDraftIndexRef.current = null;
if (isOpen) setWsStatus('disconnected');
};
const ensureWsSession = async () => {
if (wsRef.current && wsReadyRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return;
}
if (wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING) {
await new Promise<void>((resolve, reject) => {
pendingResolveRef.current = resolve;
pendingRejectRef.current = reject;
});
return;
}
const metadata = await fetchRuntimeMetadata();
setWsStatus('connecting');
setWsError('');
await new Promise<void>((resolve, reject) => {
pendingResolveRef.current = resolve;
pendingRejectRef.current = reject;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'hello', version: 'v1' }));
};
ws.onmessage = (event) => {
if (typeof event.data !== 'string') return;
let payload: any;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
const type = payload?.type;
if (type === 'hello.ack') {
ws.send(
JSON.stringify({
type: 'session.start',
audio: { encoding: 'pcm_s16le', sample_rate_hz: 16000, channels: 1 },
metadata,
})
);
return;
}
if (type === 'session.started') {
wsReadyRef.current = true;
setWsStatus('ready');
pendingResolveRef.current?.();
pendingResolveRef.current = null;
pendingRejectRef.current = null;
return;
}
if (type === 'assistant.response.delta') {
const delta = String(payload.text || '');
if (!delta) return;
setMessages((prev) => {
const idx = assistantDraftIndexRef.current;
if (idx === null || !prev[idx] || prev[idx].role !== 'model') {
const next = [...prev, { role: 'model' as const, text: delta }];
assistantDraftIndexRef.current = next.length - 1;
return next;
}
const next = [...prev];
next[idx] = { ...next[idx], text: next[idx].text + delta };
return next;
});
return;
}
if (type === 'assistant.response.final') {
const finalText = String(payload.text || '');
setMessages((prev) => {
const idx = assistantDraftIndexRef.current;
assistantDraftIndexRef.current = null;
if (idx !== null && prev[idx] && prev[idx].role === 'model') {
const next = [...prev];
next[idx] = { ...next[idx], text: finalText || next[idx].text };
return next;
}
return finalText ? [...prev, { role: 'model', text: finalText }] : prev;
});
setIsLoading(false);
return;
}
if (type === 'error') {
const message = String(payload.message || 'Unknown error');
setWsStatus('error');
setWsError(message);
setIsLoading(false);
const err = new Error(message);
pendingRejectRef.current?.(err);
pendingResolveRef.current = null;
pendingRejectRef.current = null;
}
};
ws.onerror = () => {
const err = new Error('WebSocket connection error');
setWsStatus('error');
setWsError(err.message);
setIsLoading(false);
pendingRejectRef.current?.(err);
pendingResolveRef.current = null;
pendingRejectRef.current = null;
};
ws.onclose = () => {
wsReadyRef.current = false;
if (wsStatus !== 'error') setWsStatus('disconnected');
};
});
};
const TranscriptionLog = () => (
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4"></div>}
@@ -1205,7 +1391,38 @@ export const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assis
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4">
{mode === 'text' ? (
<TranscriptionLog />
<div className="flex flex-col gap-2 h-full min-h-0">
<div className="shrink-0 rounded-md border border-white/10 bg-white/5 p-2 grid grid-cols-1 md:grid-cols-3 gap-2">
<Input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://localhost:8000/ws" />
<div className="md:col-span-2 flex items-center gap-2">
<Badge variant="outline" className="text-xs">
WS: {wsStatus}
</Badge>
<Button size="sm" variant="secondary" onClick={() => ensureWsSession()} disabled={wsStatus === 'connecting'}>
Connect
</Button>
<Button size="sm" variant="ghost" onClick={closeWs}>
Disconnect
</Button>
{wsError && <span className="text-xs text-red-400 truncate">{wsError}</span>}
</div>
<div className="md:col-span-3 rounded-md border border-white/10 bg-black/20">
<button
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground flex items-center justify-between"
onClick={() => setResolvedConfigOpen((v) => !v)}
>
<span>View Resolved Runtime Config (read-only)</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${resolvedConfigOpen ? 'rotate-180' : ''}`} />
</button>
{resolvedConfigOpen && (
<pre className="px-3 pb-3 text-[11px] leading-5 text-cyan-100/90 whitespace-pre-wrap break-all max-h-52 overflow-auto">
{resolvedConfigView || 'Connect to load resolved config...'}
</pre>
)}
</div>
</div>
<TranscriptionLog />
</div>
) : callStatus === 'idle' ? (
<div className="flex-1 flex flex-col items-center justify-center space-y-6 border border-white/5 rounded-xl bg-black/20 animate-in fade-in zoom-in-95">
<div className="relative">