Merge WS v1 engine and web debug runtime-config integration
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -41,6 +41,10 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
|
||||
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
|
||||
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
|
||||
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
||||
llmModelId: readField(raw, ['llmModelId', 'llm_model_id'], ''),
|
||||
asrModelId: readField(raw, ['asrModelId', 'asr_model_id'], ''),
|
||||
embeddingModelId: readField(raw, ['embeddingModelId', 'embedding_model_id'], ''),
|
||||
rerankModelId: readField(raw, ['rerankModelId', 'rerank_model_id'], ''),
|
||||
});
|
||||
|
||||
const mapVoice = (raw: AnyRecord): Voice => ({
|
||||
@@ -218,6 +222,10 @@ export const updateAssistant = async (id: string, data: Partial<Assistant>): Pro
|
||||
configMode: data.configMode,
|
||||
apiUrl: data.apiUrl,
|
||||
apiKey: data.apiKey,
|
||||
llmModelId: data.llmModelId,
|
||||
asrModelId: data.asrModelId,
|
||||
embeddingModelId: data.embeddingModelId,
|
||||
rerankModelId: data.rerankModelId,
|
||||
};
|
||||
const response = await apiRequest<AnyRecord>(`/assistants/${id}`, { method: 'PUT', body: payload });
|
||||
return mapAssistant(response);
|
||||
@@ -227,6 +235,21 @@ export const deleteAssistant = async (id: string): Promise<void> => {
|
||||
await apiRequest(`/assistants/${id}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export interface AssistantRuntimeConfigResponse {
|
||||
assistantId: string;
|
||||
sessionStartMetadata: Record<string, any>;
|
||||
sources?: {
|
||||
llmModelId?: string;
|
||||
asrModelId?: string;
|
||||
voiceId?: string;
|
||||
};
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export const fetchAssistantRuntimeConfig = async (assistantId: string): Promise<AssistantRuntimeConfigResponse> => {
|
||||
return apiRequest<AssistantRuntimeConfigResponse>(`/assistants/${assistantId}/runtime-config`);
|
||||
};
|
||||
|
||||
export const fetchVoices = async (): Promise<Voice[]> => {
|
||||
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/voices');
|
||||
const list = Array.isArray(response) ? response : (response.list || []);
|
||||
|
||||
Reference in New Issue
Block a user