Files
AI-VideoAssistant/web/pages/Assistants.tsx
Xin Wang da38157638 Add ASR interim results support in Assistant model and API
- Introduced `asr_interim_enabled` field in the Assistant model to control interim ASR results.
- Updated AssistantBase and AssistantUpdate schemas to include the new field.
- Modified the database schema to add the `asr_interim_enabled` column.
- Enhanced runtime metadata to reflect interim ASR settings.
- Updated API endpoints and tests to validate the new functionality.
- Adjusted documentation to include details about interim ASR results configuration.
2026-03-06 12:58:54 +08:00

4653 lines
218 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Plus, Search, Play, Square, Copy, Trash2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, Rocket, AlertTriangle, PhoneCall, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Wrench, Globe, Terminal, X, ClipboardCheck, Sparkles, Volume2, Timer, ChevronDown, Database, Server, Zap, ExternalLink, Key, BrainCircuit, Ear, Book, Filter } from 'lucide-react';
import { Button, Input, Badge, Drawer, Dialog, Switch } from '../components/UI';
import { ASRModel, Assistant, AssistantOpenerToolCall, KnowledgeBase, LLMModel, TabValue, Tool, Voice } from '../types';
import { createAssistant, deleteAssistant, fetchASRModels, fetchAssistantOpenerAudioPcmBuffer, fetchAssistants, fetchKnowledgeBases, fetchLLMModels, fetchTools, fetchVoices, generateAssistantOpenerAudio, previewVoice, updateAssistant as updateAssistantApi } from '../services/backendApi';
import { useDebugPrefsStore } from '../stores/debugPrefsStore';
const isOpenAICompatibleVendor = (vendor?: string) => {
const normalized = String(vendor || '').trim().toLowerCase();
return (
normalized === 'siliconflow' ||
normalized === '硅基流动' ||
normalized === 'openai compatible' ||
normalized === 'openai-compatible'
);
};
const OPENAI_COMPATIBLE_DEFAULT_MODEL = 'FunAudioLLM/CosyVoice2-0.5B';
const OPENAI_COMPATIBLE_KNOWN_VOICES = new Set([
'alex',
'anna',
'bella',
'benjamin',
'charles',
'claire',
'david',
'diana',
]);
const normalizeOpenAICompatibleVoiceKey = (voiceValue: string, model?: string) => {
const raw = String(voiceValue || '').trim();
const modelName = String(model || '').trim() || OPENAI_COMPATIBLE_DEFAULT_MODEL;
if (!raw) return `${modelName}:anna`;
if (raw.includes(':')) {
const [prefix, ...rest] = raw.split(':');
const voiceIdRaw = rest.join(':').trim();
const voiceIdLower = voiceIdRaw.toLowerCase();
const normalizedVoiceId = OPENAI_COMPATIBLE_KNOWN_VOICES.has(voiceIdLower) ? voiceIdLower : voiceIdRaw;
return `${(prefix || modelName).trim()}:${normalizedVoiceId}`;
}
const rawLower = raw.toLowerCase();
const normalizedVoiceId = OPENAI_COMPATIBLE_KNOWN_VOICES.has(rawLower) ? rawLower : raw;
return `${modelName}:${normalizedVoiceId}`;
};
const buildOpenAICompatibleVoiceKey = (voiceId: string, model?: string) => {
return normalizeOpenAICompatibleVoiceKey(voiceId, model);
};
const resolveRuntimeTtsVoice = (selectedVoiceId: string, voice: Voice) => {
const explicitKey = String(voice.voiceKey || '').trim();
if (!isOpenAICompatibleVendor(voice.vendor)) {
return explicitKey || selectedVoiceId;
}
const resolved = normalizeOpenAICompatibleVoiceKey(explicitKey || selectedVoiceId, voice.model);
if (voice.isSystem) {
const canonical = normalizeOpenAICompatibleVoiceKey(selectedVoiceId, voice.model);
if (!explicitKey) return canonical;
const explicitSuffix = explicitKey.includes(':') ? explicitKey.split(':').pop() : explicitKey;
if (explicitSuffix && explicitSuffix !== selectedVoiceId) return canonical;
}
return resolved;
};
const renderToolIcon = (icon: string) => {
const className = 'w-4 h-4';
const map: Record<string, React.ReactNode> = {
Camera: <Camera className={className} />,
CameraOff: <CameraOff className={className} />,
Image: <Image className={className} />,
Images: <Images className={className} />,
CloudSun: <CloudSun className={className} />,
Calendar: <Calendar className={className} />,
Phone: <Phone className={className} />,
Volume2: <Volume2 className={className} />,
TrendingUp: <TrendingUp className={className} />,
Coins: <Coins className={className} />,
Terminal: <Terminal className={className} />,
Globe: <Globe className={className} />,
Wrench: <Wrench className={className} />,
};
return map[icon] || <Wrench className={className} />;
};
const TOOL_ID_ALIASES: Record<string, string> = {
voice_message_prompt: 'voice_msg_prompt',
};
const normalizeToolId = (raw: unknown): string => {
const toolId = String(raw || '').trim();
if (!toolId) return '';
return TOOL_ID_ALIASES[toolId] || toolId;
};
const OPENER_TOOL_ARGUMENT_TEMPLATES: Record<string, Record<string, any>> = {
text_msg_prompt: {
msg: '您好,请先描述您要咨询的问题。',
},
voice_msg_prompt: {
msg: '您好,请先描述您要咨询的问题。',
},
text_choice_prompt: {
question: '请选择需要办理的业务',
options: [
{ id: 'billing', label: '账单咨询', value: 'billing' },
{ id: 'repair', label: '故障报修', value: 'repair' },
{ id: 'manual', label: '人工客服', value: 'manual' },
],
},
voice_choice_prompt: {
question: '请选择需要办理的业务',
options: [
{ id: 'billing', label: '账单咨询', value: 'billing' },
{ id: 'repair', label: '故障报修', value: 'repair' },
{ id: 'manual', label: '人工客服', value: 'manual' },
],
voice_text: '请从以下选项中选择:账单咨询、故障报修或人工客服。',
},
};
const normalizeManualOpenerToolCallsForRuntime = (
calls: AssistantOpenerToolCall[] | undefined,
options?: { strictJson?: boolean }
): { calls: Array<{ toolName: string; arguments: Record<string, any> }>; error?: string } => {
const strictJson = options?.strictJson === true;
const normalized: Array<{ toolName: string; arguments: Record<string, any> }> = [];
if (!Array.isArray(calls)) return { calls: normalized };
for (let i = 0; i < calls.length; i += 1) {
const item = calls[i];
if (!item || typeof item !== 'object') continue;
const toolName = normalizeToolId(item.toolName || '');
if (!toolName) continue;
const argsRaw = item.arguments;
let args: Record<string, any> = {};
if (argsRaw && typeof argsRaw === 'object' && !Array.isArray(argsRaw)) {
args = argsRaw as Record<string, any>;
} else if (typeof argsRaw === 'string' && argsRaw.trim()) {
try {
const parsed = JSON.parse(argsRaw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
args = parsed as Record<string, any>;
} else if (strictJson) {
return { calls: normalized, error: `Opener tool call #${i + 1} arguments must be a JSON object.` };
}
} catch {
if (strictJson) {
return { calls: normalized, error: `Opener tool call #${i + 1} has invalid JSON arguments.` };
}
}
}
normalized.push({ toolName, arguments: args });
}
return { calls: normalized.slice(0, 8) };
};
export const AssistantsPage: React.FC = () => {
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [voices, setVoices] = useState<Voice[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [asrModels, setAsrModels] = useState<ASRModel[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabValue>(TabValue.GLOBAL);
const [debugOpen, setDebugOpen] = useState(false);
const [hotwordInput, setHotwordInput] = useState('');
const [toolPickerOpen, setToolPickerOpen] = useState(false);
// Publish Modal State
const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
const [publishTab, setPublishTab] = useState<'web' | 'api'>('web');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [templateSuggestion, setTemplateSuggestion] = useState<TemplateSuggestionState | null>(null);
const [persistedAssistantSnapshotById, setPersistedAssistantSnapshotById] = useState<Record<string, string>>({});
const [unsavedDebugConfirmOpen, setUnsavedDebugConfirmOpen] = useState(false);
const [openerAudioGenerating, setOpenerAudioGenerating] = useState(false);
const [openerAudioPreviewing, setOpenerAudioPreviewing] = useState(false);
const openerPreviewAudioCtxRef = useRef<AudioContext | null>(null);
const openerPreviewSourceRef = useRef<AudioBufferSourceNode | null>(null);
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
const serializeAssistant = (assistant: Assistant) => JSON.stringify(assistant);
const selectedAssistantHasUnsavedChanges = Boolean(
selectedAssistant
&& persistedAssistantSnapshotById[selectedAssistant.id] !== serializeAssistant(selectedAssistant)
);
const filteredAssistants = assistants.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
setTemplateSuggestion(null);
}, [selectedId, activeTab]);
useEffect(() => {
const loadInitialData = async () => {
setIsLoading(true);
try {
const [assistantList, voiceList, kbList, llmList, asrList, toolList] = await Promise.all([
fetchAssistants(),
fetchVoices(),
fetchKnowledgeBases(),
fetchLLMModels(),
fetchASRModels(),
fetchTools(),
]);
setAssistants(assistantList);
setVoices(voiceList);
setKnowledgeBases(kbList);
setLlmModels(llmList);
setAsrModels(asrList);
setTools(toolList);
setPersistedAssistantSnapshotById(
assistantList.reduce<Record<string, string>>((acc, item) => {
acc[item.id] = serializeAssistant(item);
return acc;
}, {})
);
if (assistantList.length > 0) {
setSelectedId(assistantList[0].id);
}
} catch (error) {
console.error(error);
alert('加载助手数据失败,请检查后端服务是否启动。');
} finally {
setIsLoading(false);
}
};
loadInitialData();
}, []);
const handleCreate = async () => {
const newAssistantPayload: Partial<Assistant> = {
name: 'New Assistant',
firstTurnMode: 'bot_first',
opener: '',
manualOpenerToolCalls: [],
generatedOpenerEnabled: false,
openerAudioEnabled: false,
prompt: '',
knowledgeBaseId: '',
language: 'zh',
voiceOutputEnabled: true,
voice: voices[0]?.id || '',
speed: 1,
hotwords: [],
tools: [],
asrInterimEnabled: false,
botCannotBeInterrupted: false,
interruptionSensitivity: 180,
configMode: 'platform',
};
try {
const created = await createAssistant(newAssistantPayload);
setAssistants((prev) => [created, ...prev]);
setPersistedAssistantSnapshotById((prev) => ({ ...prev, [created.id]: serializeAssistant(created) }));
setSelectedId(created.id);
setActiveTab(TabValue.GLOBAL);
} catch (error) {
console.error(error);
alert('创建助手失败。');
}
};
const handleSave = async () => {
if (!selectedAssistant) return;
const normalizedManualCalls = normalizeManualOpenerToolCallsForRuntime(selectedAssistant.manualOpenerToolCalls, { strictJson: true });
if (normalizedManualCalls.error) {
alert(normalizedManualCalls.error);
return;
}
setSaveLoading(true);
try {
const updated = await updateAssistantApi(selectedAssistant.id, {
...selectedAssistant,
manualOpenerToolCalls: normalizedManualCalls.calls,
});
setAssistants((prev) => prev.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setPersistedAssistantSnapshotById((prev) => ({ ...prev, [updated.id]: serializeAssistant(updated) }));
} catch (error) {
console.error(error);
alert('保存失败,请稍后重试。');
} finally {
setSaveLoading(false);
}
};
const handleCopyId = (id: string, text?: string) => {
navigator.clipboard.writeText(text || id);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
};
const handleCopy = async (e: React.MouseEvent, assistant: Assistant) => {
e.stopPropagation();
try {
const copied = await createAssistant({
...assistant,
name: `${assistant.name} (Copy)`,
});
setAssistants((prev) => [copied, ...prev]);
} catch (error) {
console.error(error);
alert('复制助手失败。');
}
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeleteId(id);
};
const confirmDelete = async () => {
if (deleteId) {
try {
await deleteAssistant(deleteId);
setAssistants(prev => prev.filter(a => a.id !== deleteId));
setPersistedAssistantSnapshotById((prev) => {
const next = { ...prev };
delete next[deleteId];
return next;
});
if (selectedId === deleteId) setSelectedId(null);
setDeleteId(null);
} catch (error) {
console.error(error);
alert('删除失败,请稍后重试。');
}
}
};
const updateAssistant = (field: keyof Assistant, value: any) => {
if (!selectedId) return;
setAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
if (field === 'configMode') {
if (value === 'platform') {
setActiveTab(TabValue.GLOBAL);
} else if (value === 'dify' || value === 'fastgpt') {
setActiveTab(TabValue.LINK);
}
}
};
const updateTemplateSuggestionState = (
field: 'prompt' | 'opener',
value: string,
caret: number | null,
control?: HTMLTextAreaElement | HTMLInputElement | null
) => {
if (caret === null || caret < 0) {
setTemplateSuggestion(null);
return;
}
const probe = value.slice(0, caret);
const start = probe.lastIndexOf('{{');
if (start < 0) {
setTemplateSuggestion(null);
return;
}
const token = value.slice(start + 2, caret);
if (token.includes('}')) {
setTemplateSuggestion(null);
return;
}
if (!/^[a-zA-Z0-9_]*$/.test(token)) {
setTemplateSuggestion(null);
return;
}
if (!control) {
setTemplateSuggestion(null);
return;
}
const anchor = getTemplateSuggestionAnchor(control, caret);
setTemplateSuggestion({
field,
start,
end: caret,
query: token,
anchorLeft: anchor.left,
anchorTop: anchor.top,
});
};
const applySystemTemplateVariable = (field: 'prompt' | 'opener', key: string) => {
if (!selectedAssistant || !templateSuggestion || templateSuggestion.field !== field) {
return;
}
const current = String(selectedAssistant[field] || '');
const before = current.slice(0, templateSuggestion.start);
const after = current.slice(templateSuggestion.end);
const nextValue = `${before}{{${key}}}${after}`;
updateAssistant(field, nextValue);
setTemplateSuggestion(null);
};
const filteredSystemTemplateVariables = useMemo(() => {
if (!templateSuggestion) return [];
const query = templateSuggestion.query.trim().toLowerCase();
if (!query) return SYSTEM_DYNAMIC_VARIABLE_OPTIONS;
return SYSTEM_DYNAMIC_VARIABLE_OPTIONS.filter((item) => item.key.toLowerCase().includes(query));
}, [templateSuggestion]);
const handleOpenDebug = () => {
if (!selectedAssistant) return;
if (selectedAssistantHasUnsavedChanges) {
setUnsavedDebugConfirmOpen(true);
return;
}
setDebugOpen(true);
};
const handleGenerateOpenerAudio = async () => {
if (!selectedAssistant) return;
setOpenerAudioGenerating(true);
try {
const status = await generateAssistantOpenerAudio(selectedAssistant.id, {
text: selectedAssistant.opener || '',
});
setAssistants((prev) => prev.map((item) => {
if (item.id !== selectedAssistant.id) return item;
return {
...item,
openerAudioEnabled: status.enabled,
openerAudioReady: status.ready,
openerAudioDurationMs: status.duration_ms,
openerAudioUpdatedAt: status.updated_at || '',
};
}));
} catch (error) {
console.error(error);
alert((error as Error)?.message || '生成开场白预加载音频失败');
} finally {
setOpenerAudioGenerating(false);
}
};
const stopOpenerAudioPreview = () => {
if (openerPreviewSourceRef.current) {
try {
openerPreviewSourceRef.current.stop();
} catch {
// no-op
}
try {
openerPreviewSourceRef.current.disconnect();
} catch {
// no-op
}
openerPreviewSourceRef.current = null;
}
setOpenerAudioPreviewing(false);
};
const handlePreviewOpenerAudio = async () => {
if (!selectedAssistant?.id || !selectedAssistant.openerAudioReady) return;
try {
stopOpenerAudioPreview();
const pcmBuffer = await fetchAssistantOpenerAudioPcmBuffer(selectedAssistant.id);
const int16 = new Int16Array(pcmBuffer);
if (int16.length === 0) return;
let ctx = openerPreviewAudioCtxRef.current;
if (!ctx) {
ctx = new AudioContext();
openerPreviewAudioCtxRef.current = ctx;
}
if (ctx.state === 'suspended') {
await ctx.resume();
}
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i += 1) {
float32[i] = int16[i] / 32768;
}
const audioBuffer = ctx.createBuffer(1, float32.length, 16000);
audioBuffer.copyToChannel(float32, 0);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
source.onended = () => {
if (openerPreviewSourceRef.current === source) {
openerPreviewSourceRef.current = null;
setOpenerAudioPreviewing(false);
}
try {
source.disconnect();
} catch {
// no-op
}
};
openerPreviewSourceRef.current = source;
setOpenerAudioPreviewing(true);
source.start();
} catch (error) {
console.error(error);
setOpenerAudioPreviewing(false);
alert((error as Error)?.message || '预览预加载开场音频失败');
}
};
const handleConfirmOpenDebug = () => {
setUnsavedDebugConfirmOpen(false);
setDebugOpen(true);
};
const toggleTool = (toolId: string) => {
if (!selectedAssistant) return;
const canonicalToolId = normalizeToolId(toolId);
const currentTools = (selectedAssistant.tools || []).map((id) => normalizeToolId(id));
const newTools = currentTools.includes(canonicalToolId)
? currentTools.filter(id => id !== canonicalToolId)
: [...currentTools, canonicalToolId];
updateAssistant('tools', newTools);
};
const removeImportedTool = (e: React.MouseEvent, tool: Tool) => {
e.stopPropagation();
if (!selectedAssistant) return;
const canonicalToolId = normalizeToolId(tool.id);
updateAssistant('tools', (selectedAssistant.tools || []).filter((id) => normalizeToolId(id) !== canonicalToolId));
};
const addHotword = () => {
if (hotwordInput.trim() && selectedAssistant) {
updateAssistant('hotwords', [...selectedAssistant.hotwords, hotwordInput.trim()]);
setHotwordInput('');
}
};
const removeHotword = (word: string) => {
if (selectedAssistant) {
updateAssistant('hotwords', selectedAssistant.hotwords.filter(w => w !== word));
}
};
const addManualOpenerToolCall = () => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
if (current.length >= 8) return;
const fallbackTool = normalizeToolId(
(selectedAssistant.tools || []).find((id) =>
tools.some((tool) => normalizeToolId(tool.id) === normalizeToolId(id) && tool.enabled !== false)
) || ''
);
updateAssistant('manualOpenerToolCalls', [
...current,
{
toolName: fallbackTool,
arguments: '{}',
},
]);
};
const updateManualOpenerToolCall = (index: number, patch: Partial<AssistantOpenerToolCall>) => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
if (index < 0 || index >= current.length) return;
const next = [...current];
const normalizedPatch = { ...patch };
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'toolName')) {
normalizedPatch.toolName = normalizeToolId(normalizedPatch.toolName || '');
}
next[index] = { ...next[index], ...normalizedPatch };
updateAssistant('manualOpenerToolCalls', next);
};
const removeManualOpenerToolCall = (index: number) => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
updateAssistant('manualOpenerToolCalls', current.filter((_, idx) => idx !== index));
};
const applyManualOpenerToolTemplate = (index: number) => {
if (!selectedAssistant) return;
const current = selectedAssistant.manualOpenerToolCalls || [];
if (index < 0 || index >= current.length) return;
const toolName = normalizeToolId(current[index]?.toolName || '');
const template = OPENER_TOOL_ARGUMENT_TEMPLATES[toolName];
if (!template) return;
updateManualOpenerToolCall(index, {
arguments: JSON.stringify(template, null, 2),
});
};
const systemTools = tools.filter((t) => t.enabled !== false && t.category === 'system');
const queryTools = tools.filter((t) => t.enabled !== false && t.category === 'query');
const selectedToolIds = (selectedAssistant?.tools || []).map((id) => normalizeToolId(id));
const activeSystemTools = systemTools.filter((tool) => selectedToolIds.includes(normalizeToolId(tool.id)));
const activeQueryTools = queryTools.filter((tool) => selectedToolIds.includes(normalizeToolId(tool.id)));
const availableSystemTools = systemTools.filter((tool) => !selectedToolIds.includes(normalizeToolId(tool.id)));
const availableQueryTools = queryTools.filter((tool) => !selectedToolIds.includes(normalizeToolId(tool.id)));
const openerToolOptions = Array.from(
new Map(
tools
.filter(
(tool) =>
tool.enabled !== false &&
selectedToolIds.some((selectedId) => normalizeToolId(selectedId) === normalizeToolId(tool.id))
)
.map((tool) => {
const toolId = normalizeToolId(tool.id);
return [toolId, { id: toolId, label: `${tool.name} (${toolId})` }];
})
).values()
);
const isExternalConfig = selectedAssistant?.configMode === 'dify' || selectedAssistant?.configMode === 'fastgpt';
const isNoneConfig = selectedAssistant?.configMode === 'none' || !selectedAssistant?.configMode;
const canAdjustInterruptionSensitivity = selectedAssistant?.botCannotBeInterrupted !== true;
const isBotFirstTurn = selectedAssistant?.firstTurnMode !== 'user_first';
useEffect(() => {
return () => {
if (openerPreviewSourceRef.current) {
try {
openerPreviewSourceRef.current.stop();
} catch {
// no-op
}
openerPreviewSourceRef.current = null;
}
if (openerPreviewAudioCtxRef.current) {
void openerPreviewAudioCtxRef.current.close();
openerPreviewAudioCtxRef.current = null;
}
};
}, []);
return (
<div className="flex h-full min-h-0 gap-6 animate-in fade-in">
{/* LEFT COLUMN: List */}
<div className="w-80 flex flex-col gap-4 shrink-0">
<div className="flex items-center justify-between px-1 text-white">
<h2 className="text-xl font-bold tracking-tight text-white"></h2>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索..."
className="pl-9 bg-card/50 border-white/5"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button size="icon" onClick={handleCreate} title="新建小助手">
<Plus className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
{!isLoading && filteredAssistants.map(assistant => (
<div
key={assistant.id}
onClick={() => setSelectedId(assistant.id)}
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
selectedId === assistant.id
? 'bg-primary/10 border-primary/40 shadow-[0_0_15px_rgba(6,182,212,0.15)]'
: 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'
}`}
>
<div className="flex flex-col gap-1.5 mb-2 pr-16 overflow-hidden">
<span className={`font-semibold truncate ${selectedId === assistant.id ? 'text-primary' : 'text-foreground'}`}>
{assistant.name}
</span>
{assistant.configMode && assistant.configMode !== 'none' && (
<div className="flex">
<Badge
variant="outline"
className={`text-[9px] uppercase tracking-tighter shrink-0 opacity-70 border-white/10 ${
assistant.configMode === 'platform' ? 'text-cyan-400 bg-cyan-400/5' :
assistant.configMode === 'dify' ? 'text-blue-400 bg-blue-400/5' :
'text-purple-400 bg-purple-400/5'
}`}
>
{assistant.configMode === 'platform' ? '内置' : assistant.configMode}
</Badge>
</div>
)}
</div>
<div className="flex items-center text-xs text-muted-foreground">
<Phone className="h-3 w-3 mr-1.5 opacity-70" />
<span>{assistant.callCount} </span>
</div>
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5 shadow-lg border border-white/5">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, assistant)} title="复制">
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, assistant.id)} title="删除">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
{!isLoading && filteredAssistants.length === 0 && (
<div className="text-center py-10 text-muted-foreground text-sm">
</div>
)}
{isLoading && (
<div className="text-center py-10 text-muted-foreground text-sm">
...
</div>
)}
</div>
</div>
{/* RIGHT COLUMN: Config Panel */}
<div className="flex-1 bg-card/20 backdrop-blur-sm border border-white/5 rounded-2xl overflow-hidden flex flex-col relative shadow-xl">
{selectedAssistant ? (
<>
{/* Header Area */}
<div className="p-6 border-b border-white/5 bg-white/[0.02] space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1"></label>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-white/5 border border-white/10 group/id transition-all hover:bg-white/10">
<span className="text-[10px] font-mono text-muted-foreground/60 select-all tracking-tight">UUID: {selectedAssistant.id}</span>
<button
onClick={() => handleCopyId(selectedAssistant.id)}
className="text-muted-foreground hover:text-primary transition-colors flex items-center"
title="复制ID"
>
{copySuccess ? <ClipboardCheck className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</div>
<Input
value={selectedAssistant.name}
onChange={(e) => updateAssistant('name', e.target.value)}
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
/>
</div>
<div className="flex items-center space-x-2 pt-6">
<Button
variant="secondary"
onClick={handleOpenDebug}
className="border border-primary/20 hover:border-primary/50 text-primary hover:text-primary hover:bg-primary/10 shadow-[0_0_15px_rgba(6,182,212,0.1)]"
>
<Play className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={saveLoading}
className="border border-white/10 hover:border-primary/50 text-foreground"
>
<Save className={`mr-2 h-4 w-4 ${saveLoading ? 'animate-pulse' : ''}`} /> {saveLoading ? '正在保存...' : '保存'}
</Button>
<Button
onClick={() => setIsPublishModalOpen(true)}
className="shadow-[0_0_20px_rgba(6,182,212,0.3)]"
>
<Rocket className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1"></label>
<div className="relative group w-full">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
value={selectedAssistant.configMode || 'none'}
onChange={(e) => updateAssistant('configMode', e.target.value as any)}
>
<option value="none"></option>
<option value="platform"></option>
<option value="dify">Dify </option>
<option value="fastgpt">FastGPT </option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
</div>
{!isNoneConfig && (
<div className="flex bg-white/5 p-1 rounded-lg w-fit border border-white/5 animate-in fade-in slide-in-from-top-1">
{selectedAssistant.configMode === 'platform' ? (
<>
<button
onClick={() => setActiveTab(TabValue.GLOBAL)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.GLOBAL ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.VOICE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.TOOLS)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.TOOLS ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.KNOWLEDGE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.KNOWLEDGE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
</>
) : (
<>
<button
onClick={() => setActiveTab(TabValue.LINK)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.LINK ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.VOICE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
</>
)}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
{isNoneConfig ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-40 animate-in fade-in">
<AlertTriangle className="w-12 h-12 mb-4" />
<p className="text-sm font-medium"></p>
</div>
) : (
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
{activeTab === TabValue.LINK && isExternalConfig && (
<div className="space-y-6 animate-in fade-in">
<div className="p-4 rounded-xl bg-primary/5 border border-primary/20 flex items-start gap-3 mb-4">
<Database className="w-5 h-5 text-primary shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-bold text-white mb-1">
{selectedAssistant.configMode === 'dify' ? 'Dify' : 'FastGPT'}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
</p>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Globe className="w-4 h-4 mr-2 text-primary"/> (API URL)
</label>
<Input
value={selectedAssistant.apiUrl || ''}
onChange={(e) => updateAssistant('apiUrl', e.target.value)}
placeholder={selectedAssistant.configMode === 'dify' ? "https://api.dify.ai/v1" : "https://api.fastgpt.in/api/v1"}
className="bg-white/5 border-white/10 focus:border-primary/50 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Terminal className="w-4 h-4 mr-2 text-primary"/> (API KEY)
</label>
<Input
type="password"
value={selectedAssistant.apiKey || ''}
onChange={(e) => updateAssistant('apiKey', e.target.value)}
placeholder="请输入应用 API 密钥..."
className="bg-white/5 border-white/10 focus:border-primary/50 font-mono text-xs"
/>
</div>
</div>
)}
{activeTab === TabValue.GLOBAL && selectedAssistant.configMode === 'platform' && (
<div className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<BrainCircuit className="w-4 h-4 mr-2 text-primary"/> (LLM Model)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.llmModelId || ''}
onChange={(e) => updateAssistant('llmModelId', e.target.value)}
>
<option value="">使用系统默认模型</option>
{llmModels.filter(m => m.type === 'text').map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.vendor})</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground">选择用于驱动该助手对话的大语言模型。</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<BotIcon className="w-4 h-4 mr-2 text-primary"/> 提示词 (Prompt)
</label>
<div className="relative">
<textarea
className="flex min-h-[200px] w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 resize-y text-white"
value={selectedAssistant.prompt}
onChange={(e) => {
const next = e.target.value;
updateAssistant('prompt', next);
updateTemplateSuggestionState('prompt', next, e.currentTarget.selectionStart, e.currentTarget);
}}
onKeyUp={(e) => {
updateTemplateSuggestionState('prompt', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
}}
onClick={(e) => {
updateTemplateSuggestionState('prompt', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
}}
onFocus={(e) => {
updateTemplateSuggestionState('prompt', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
}}
onBlur={() => {
window.setTimeout(() => {
setTemplateSuggestion((prev) => (prev?.field === 'prompt' ? null : prev));
}, 120);
}}
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
/>
{templateSuggestion?.field === 'prompt' &&
filteredSystemTemplateVariables.length > 0 &&
typeof document !== 'undefined' &&
createPortal(
<div
className="fixed z-[100] w-[320px] max-w-[calc(100vw-1rem)] rounded-md border border-white/15 bg-black/95 shadow-xl backdrop-blur-md max-h-48 overflow-auto"
style={{
left: templateSuggestion.anchorLeft,
top: templateSuggestion.anchorTop,
}}
>
{filteredSystemTemplateVariables.map((item) => (
<button
key={item.key}
type="button"
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors"
onMouseDown={(e) => {
e.preventDefault();
applySystemTemplateVariable('prompt', item.key);
}}
>
<div className="text-xs text-cyan-100">{`{{${item.key}}}`}</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{item.description}</div>
</button>
))}
</div>,
document.body
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-white flex items-center">
<PhoneCall className="w-4 h-4 mr-2 text-primary"/> 首轮发言方
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('firstTurnMode', 'bot_first')}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
isBotFirstTurn
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
机器人先说
</button>
<button
type="button"
onClick={() => updateAssistant('firstTurnMode', 'user_first')}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
isBotFirstTurn
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
用户先说
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{isBotFirstTurn ? '' : ''}
</p>
</div>
{isBotFirstTurn && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium flex items-center text-white">
<MessageSquare className="w-4 h-4 mr-2 text-primary"/> 开场白
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('generatedOpenerEnabled', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.generatedOpenerEnabled === true
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
手动输入
</button>
<button
type="button"
onClick={() => updateAssistant('generatedOpenerEnabled', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.generatedOpenerEnabled === true
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
自动生成
</button>
</div>
</div>
{selectedAssistant.generatedOpenerEnabled === true ? (
<div className="rounded-md border border-dashed border-white/15 bg-white/5 px-3 py-2 text-xs text-muted-foreground">
自动生成模式下不使用固定开场白文本,仅依据系统提示词生成首句。
</div>
) : (
<div className="relative">
<Input
value={selectedAssistant.opener}
onChange={(e) => {
const next = e.target.value;
updateAssistant('opener', next);
updateTemplateSuggestionState('opener', next, e.currentTarget.selectionStart, e.currentTarget);
}}
onKeyUp={(e) => {
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
}}
onClick={(e) => {
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
}}
onFocus={(e) => {
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
}}
onBlur={() => {
window.setTimeout(() => {
setTemplateSuggestion((prev) => (prev?.field === 'opener' ? null : prev));
}, 120);
}}
placeholder="例如您好我是您的专属AI助手..."
className="bg-white/5 border-white/10 focus:border-primary/50"
/>
{templateSuggestion?.field === 'opener' &&
filteredSystemTemplateVariables.length > 0 &&
typeof document !== 'undefined' &&
createPortal(
<div
className="fixed z-[100] w-[320px] max-w-[calc(100vw-1rem)] rounded-md border border-white/15 bg-black/95 shadow-xl backdrop-blur-md max-h-48 overflow-auto"
style={{
left: templateSuggestion.anchorLeft,
top: templateSuggestion.anchorTop,
}}
>
{filteredSystemTemplateVariables.map((item) => (
<button
key={item.key}
type="button"
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors"
onMouseDown={(e) => {
e.preventDefault();
applySystemTemplateVariable('opener', item.key);
}}
>
<div className="text-xs text-cyan-100">{`{{${item.key}}}`}</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{item.description}</div>
</button>
))}
</div>,
document.body
)}
</div>
)}
{selectedAssistant.generatedOpenerEnabled !== true && (
<div className="mt-3 p-3 rounded-lg border border-white/10 bg-white/[0.03] space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-semibold text-white flex items-center">
<Wrench className="w-4 h-4 mr-2 text-primary" />
开场工具调用(手动模式)
</label>
<Button
type="button"
variant="secondary"
size="sm"
onClick={addManualOpenerToolCall}
disabled={(selectedAssistant.manualOpenerToolCalls || []).length >= 8}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
添加
</Button>
</div>
{(selectedAssistant.manualOpenerToolCalls || []).length === 0 ? (
<div className="text-[11px] text-muted-foreground border border-dashed border-white/10 rounded-md px-2 py-2">
未配置。可添加 text_msg_prompt / voice_msg_prompt 等工具作为开场动作。
</div>
) : (
<div className="space-y-2">
{(selectedAssistant.manualOpenerToolCalls || []).map((call, idx) => (
<div key={`manual-opener-tool-${idx}`} className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
<select
className="h-9 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-foreground"
value={normalizeToolId(call.toolName || '')}
onChange={(e) => updateManualOpenerToolCall(idx, { toolName: e.target.value })}
>
<option value="">选择已启用工具</option>
{normalizeToolId(call.toolName || '') && !openerToolOptions.some((tool) => tool.id === normalizeToolId(call.toolName || '')) && (
<option value={normalizeToolId(call.toolName || '')}>
{`${normalizeToolId(call.toolName || '')} (未启用/不存在)`}
</option>
)}
{openerToolOptions.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.label}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-red-300"
onClick={() => removeManualOpenerToolCall(idx)}
title="删除该工具调用"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-muted-foreground">参数 JSON</p>
{OPENER_TOOL_ARGUMENT_TEMPLATES[normalizeToolId(call.toolName || '')] && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => applyManualOpenerToolTemplate(idx)}
>
填充模板
</Button>
)}
</div>
<textarea
value={typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments || {}, null, 2)}
onChange={(e) => updateManualOpenerToolCall(idx, { arguments: e.target.value })}
rows={3}
className="w-full rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-foreground"
placeholder=' JSON {"msg":"您好,请先选择业务类型"}'
/>
</div>
))}
</div>
)}
{openerToolOptions.length === 0 && (
<p className="text-[11px] text-amber-300/90">
当前助手未启用任何工具,请先在“工具配置”里添加后再选择。
</p>
)}
<p className="text-[11px] text-muted-foreground">
按列表顺序执行。参数必须是 JSON 对象保存时会校验格式。text_msg_prompt / voice_msg_prompt / text_choice_prompt / voice_choice_prompt 支持一键填充模板。
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
{selectedAssistant.generatedOpenerEnabled === true
? ''
: ''}
</p>
<div className="mt-3 p-3 rounded-lg border border-white/10 bg-white/[0.03] space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-semibold text-white flex items-center">
<Volume2 className="w-4 h-4 mr-2 text-primary" />
使用预加载开场音频
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('openerAudioEnabled', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.openerAudioEnabled === true
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
关闭
</button>
<button
type="button"
onClick={() => updateAssistant('openerAudioEnabled', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.openerAudioEnabled === true
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
开启
</button>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
状态:
{selectedAssistant.openerAudioReady
? `已生成 (${Math.round((selectedAssistant.openerAudioDurationMs || 0) / 1000)}s)`
: ''}
</p>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={handlePreviewOpenerAudio}
disabled={!selectedAssistant.openerAudioReady || openerAudioGenerating}
>
<Play className="w-3.5 h-3.5 mr-1.5" />
{openerAudioPreviewing ? '...' : ''}
</Button>
{openerAudioPreviewing && (
<Button variant="ghost" size="sm" onClick={stopOpenerAudioPreview}>
<Square className="w-3.5 h-3.5 mr-1.5" />
停止
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={handleGenerateOpenerAudio}
disabled={openerAudioGenerating || selectedAssistant.generatedOpenerEnabled === true}
>
{openerAudioGenerating ? '...' : ''}
</Button>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
使用当前 TTS 配置生成并保存到后端;引擎可直接播放以降低首包延迟。
</p>
</div>
</div>
)}
</div>
)}
{activeTab === TabValue.KNOWLEDGE && selectedAssistant.configMode === 'platform' && (
<div className="space-y-8 animate-in fade-in">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<BrainCircuit className="w-4 h-4 mr-2 text-primary"/> 嵌入模型 (Embedding Model)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.embeddingModelId || ''}
onChange={(e) => updateAssistant('embeddingModelId', e.target.value)}
>
<option value="">使用系统默认</option>
{llmModels.filter(m => m.type === 'embedding').map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.vendor})</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground">用于将文本转换为向量的嵌入模型。</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Filter className="w-4 h-4 mr-2 text-primary"/> 重排模型 (Rerank Model)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.rerankModelId || ''}
onChange={(e) => updateAssistant('rerankModelId', e.target.value)}
>
<option value="">不使用重排</option>
{llmModels.filter(m => m.type === 'rerank').map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.vendor})</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground">可选配置。重排模型可优化检索结果的相关性排序。</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Book className="w-4 h-4 mr-2 text-primary"/> 知识库挂载 (Knowledge Base)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.knowledgeBaseId}
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
>
<option value="">如果不选择,则使用通用大模型知识</option>
{knowledgeBases.map(kb => (
<option key={kb.id} value={kb.id}>{kb.name}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground">选择助手回答问题时参考的私有知识库。</p>
</div>
</div>
)}
{activeTab === TabValue.VOICE && !isNoneConfig && (
<div className="space-y-8">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Ear className="w-4 h-4 mr-2 text-primary"/> 语音识别 (ASR Model)
</label>
<div className="relative group">
<select
className="flex h-12 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-1 text-sm shadow-sm transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.asrModelId || ''}
onChange={(e) => updateAssistant('asrModelId', e.target.value)}
>
<option value="">使用系统默认模型</option>
{asrModels.map(model => (
<option key={model.id} value={model.id}>
{model.name} ({model.vendor})
</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
选择用于识别用户语音输入的模型。
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-white flex items-center">
<Mic className="w-4 h-4 mr-2 text-primary"/> 离线 ASR 中间结果
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('asrInterimEnabled', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.asrInterimEnabled === true
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
关闭
</button>
<button
type="button"
onClick={() => updateAssistant('asrInterimEnabled', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.asrInterimEnabled === true
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
开启
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
仅影响离线 ASR 模式OpenAI Compatible / buffered。默认关闭。
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-white flex items-center">
<Volume2 className="w-4 h-4 mr-2 text-primary"/> 语音输出
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('voiceOutputEnabled', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.voiceOutputEnabled !== false
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
关闭
</button>
<button
type="button"
onClick={() => updateAssistant('voiceOutputEnabled', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.voiceOutputEnabled !== false
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
开启
</button>
</div>
</div>
<div className="relative group">
<select
className="flex h-12 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-1 text-sm shadow-sm transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
value={selectedAssistant.voice}
onChange={(e) => updateAssistant('voice', e.target.value)}
disabled={selectedAssistant.voiceOutputEnabled === false}
>
<option value="" disabled>请选择声音库中的声音...</option>
{voices.map(voice => (
<option key={voice.id} value={voice.id}>
{voice.name} ({voice.vendor} - {voice.gender === 'Male' ? '' : ''})
</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
<Sparkles className="w-3 h-3 mr-1 text-primary opacity-70" />
{selectedAssistant.voiceOutputEnabled === false
? ''
: ''}
</p>
</div>
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-white flex items-center">
<ArrowLeftRight className="w-4 h-4 mr-2 text-primary" /> 打断控制
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('botCannotBeInterrupted', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.botCannotBeInterrupted === true
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
可打断
</button>
<button
type="button"
onClick={() => updateAssistant('botCannotBeInterrupted', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.botCannotBeInterrupted === true
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
不可打断
</button>
</div>
</div>
{canAdjustInterruptionSensitivity && (
<>
<div className="flex justify-between items-center mb-1">
<label className="text-sm font-medium flex items-center text-white">
<Timer className="w-4 h-4 mr-2 text-primary"/> 打断灵敏度
</label>
<div className="flex items-center gap-2">
<div className="relative">
<Input
type="number"
value={selectedAssistant.interruptionSensitivity || 180}
onChange={(e) => updateAssistant('interruptionSensitivity', parseInt(e.target.value) || 0)}
className="w-20 h-8 text-right pr-7 text-xs font-mono bg-black/40 border-white/5"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground font-mono">ms</span>
</div>
</div>
</div>
<div className="flex items-center gap-6">
<input
type="range"
min="0"
max="2000"
step="50"
value={selectedAssistant.interruptionSensitivity || 180}
onChange={(e) => updateAssistant('interruptionSensitivity', parseInt(e.target.value))}
className="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div className="flex justify-between text-[10px] font-mono uppercase tracking-widest px-0.5 text-muted-foreground opacity-50">
<span>0ms极易打断</span>
<span>1000ms</span>
<span>2000ms较难打断</span>
</div>
<p className="text-xs pt-1 italic text-muted-foreground opacity-60">
用户持续说话达到该时长后AI 将停止当前发言并响应。数值越小越敏感,也更容易被噪音触发。
</p>
</>
)}
</div>
<div className="space-y-3">
<label className="text-sm font-medium text-white flex items-center">
<Mic className="w-4 h-4 mr-2 text-primary"/> ASR 热词优化 (Hotwords)
</label>
<div className="flex space-x-2">
<Input
value={hotwordInput}
onChange={(e) => setHotwordInput(e.target.value)}
placeholder="输入专有名词或高频词汇..."
onKeyDown={(e) => e.key === 'Enter' && addHotword()}
className="bg-white/5 border-white/10"
/>
<Button variant="secondary" onClick={addHotword} className="px-10 whitespace-nowrap">添加</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-lg border border-dashed border-white/10">
{selectedAssistant.hotwords.length === 0 && (
<span className="text-xs text-muted-foreground py-1 px-1">暂无热词</span>
)}
{selectedAssistant.hotwords.map((word, idx) => (
<Badge key={idx} variant="outline" className="py-1">
{word}
<button onClick={() => removeHotword(word)} className="ml-2 hover:text-destructive transition-colors text-lg leading-none">×</button>
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">添加热词可以提高语音识别特定词汇的准确率。</p>
</div>
</div>
)}
{activeTab === TabValue.TOOLS && selectedAssistant.configMode === 'platform' && (
<div className="space-y-8 animate-in fade-in">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black flex items-center text-white uppercase tracking-[0.2em]">
<Wrench className="w-3.5 h-3.5 mr-2 text-primary" /> 已启用工具
</h3>
<button
type="button"
onClick={() => setToolPickerOpen(true)}
className="h-7 w-7 rounded-full border border-white/15 bg-white/5 text-muted-foreground hover:text-white hover:border-primary/50 hover:bg-primary/10 flex items-center justify-center transition-colors"
title="添加工具"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black flex items-center text-primary uppercase tracking-[0.2em]">
<Wrench className="w-3.5 h-3.5 mr-2" /> 系统指令
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{activeSystemTools.map(tool => (
<div
key={tool.id}
className="p-4 rounded-xl border border-primary/40 bg-primary/10 shadow-[0_0_15px_rgba(6,182,212,0.1)] relative flex items-start space-x-3"
>
<button
type="button"
onClick={(e) => removeImportedTool(e, tool)}
className="absolute -top-1 -right-1 p-0.5 rounded-full bg-destructive text-white hover:scale-110 transition-transform shadow-lg z-10"
title="移除工具"
>
<X className="w-3 h-3" />
</button>
<div className="p-2 rounded-lg shrink-0 bg-primary text-primary-foreground">
{renderToolIcon(tool.icon)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white">{tool.name}</div>
<p className="text-[10px] text-muted-foreground line-clamp-1 opacity-70">{tool.description}</p>
</div>
</div>
))}
{activeSystemTools.length === 0 && (
<div className="col-span-full p-4 rounded-xl border border-dashed border-white/10 text-xs text-muted-foreground">
暂未启用系统指令工具
</div>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black flex items-center text-blue-400 uppercase tracking-[0.2em]">
<TrendingUp className="w-3.5 h-3.5 mr-2" /> 信息查询
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{activeQueryTools.map(tool => (
<div
key={tool.id}
className="p-4 rounded-xl border border-blue-500/40 bg-blue-500/10 shadow-[0_0_15px_rgba(59,130,246,0.1)] relative flex items-start space-x-3"
>
<button
type="button"
onClick={(e) => removeImportedTool(e, tool)}
className="absolute -top-1 -right-1 p-0.5 rounded-full bg-destructive text-white hover:scale-110 transition-transform shadow-lg z-10"
title="移除工具"
>
<X className="w-3 h-3" />
</button>
<div className="p-2 rounded-lg shrink-0 bg-blue-500 text-white">
{renderToolIcon(tool.icon)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white">{tool.name}</div>
<p className="text-[10px] text-muted-foreground line-clamp-1 opacity-70">{tool.description}</p>
</div>
</div>
))}
{activeQueryTools.length === 0 && (
<div className="col-span-full p-4 rounded-xl border border-dashed border-white/10 text-xs text-muted-foreground">
暂未启用信息查询工具
</div>
)}
</div>
</div>
<div className="p-4 bg-white/5 border border-white/5 rounded-xl text-[10px] text-muted-foreground flex items-center gap-3">
<Rocket className="w-4 h-4 text-primary shrink-0" />
<span>提示:此处仅导入工具库中的已有工具。移除仅对当前小助手生效,不会删除工具库。</span>
</div>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<BotIcon className="h-8 w-8 opacity-50" />
</div>
<p className="text-lg font-medium">请选择一个小助手</p>
<p className="text-sm opacity-60">从左侧列表选择或创建一个新的小助手以开始配置</p>
</div>
)}
</div>
{/* Publish Modal */}
<Dialog
isOpen={isPublishModalOpen}
onClose={() => setIsPublishModalOpen(false)}
title="发布小助手"
footer={
<Button onClick={() => setIsPublishModalOpen(false)}>确认</Button>
}
>
<div className="space-y-6">
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
<button
onClick={() => setPublishTab('web')}
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${publishTab === 'web' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
<ExternalLink className="w-3.5 h-3.5 mr-2" /> 网页分享
</button>
<button
onClick={() => setPublishTab('api')}
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${publishTab === 'api' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
<Server className="w-3.5 h-3.5 mr-2" /> API 接入
</button>
</div>
{publishTab === 'web' ? (
<div className="space-y-4 animate-in fade-in slide-in-from-top-1">
<div className="p-4 rounded-xl bg-primary/5 border border-primary/20 space-y-3">
<div className="flex items-center gap-2 text-primary">
<Zap className="w-4 h-4" />
<h4 className="text-sm font-bold">交互体验站</h4>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
该链接允许用户通过独立的浏览器页面与您的智能体进行交互。支持:文本对话、实时音频通话以及双向视频通话。
</p>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase">公开访问链接</label>
<div className="flex gap-2">
<Input
readOnly
value={`https://ai-video.com/share/${selectedAssistant?.id}`}
className="bg-white/5 border-white/10 font-mono text-[11px] text-primary"
/>
<Button variant="secondary" size="icon" onClick={() => handleCopyId('', `https://ai-video.com/share/${selectedAssistant?.id}`)}>
{copySuccess ? <ClipboardCheck className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
) : (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1">
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase">API Endpoint (v1)</label>
<div className="flex gap-2">
<Input
readOnly
value={`https://api.ai-video.com/v1/call/${selectedAssistant?.id}`}
className="bg-white/5 border-white/10 font-mono text-[11px]"
/>
<Button variant="ghost" size="icon" onClick={() => handleCopyId('', `https://api.ai-video.com/v1/call/${selectedAssistant?.id}`)}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase">Secret API KEY</label>
<Badge variant="outline" className="text-[8px] opacity-50">PRIVATE</Badge>
</div>
<div className="flex gap-2">
<Input
readOnly
type="password"
value="sk-ai-video-78x29jKkL1M90vX..."
className="bg-white/5 border-white/10 font-mono text-[11px]"
/>
<Button variant="ghost" size="icon" onClick={() => handleCopyId('', "sk-ai-video-78x29jKkL1M90vX...")}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground italic flex items-center gap-1.5 px-1 mt-2">
<Key className="w-3 h-3" /> API Key 仅用于身份鉴权,请务必妥善保存。
</p>
</div>
</div>
)}
</div>
</Dialog>
{/* Tool Picker Modal */}
<Dialog
isOpen={toolPickerOpen}
onClose={() => setToolPickerOpen(false)}
title="添加工具"
>
<div className="space-y-6">
<div className="space-y-3">
<h5 className="text-[10px] font-black flex items-center text-primary uppercase tracking-[0.2em]">
<Wrench className="w-3.5 h-3.5 mr-2" /> 系统指令可选
</h5>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{availableSystemTools.map((tool) => (
<button
key={tool.id}
type="button"
onClick={() => toggleTool(tool.id)}
className="text-left p-3 rounded-lg border border-white/10 bg-white/5 hover:bg-primary/10 hover:border-primary/40 transition-colors"
>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-white/10 text-primary">{renderToolIcon(tool.icon)}</div>
<div className="text-sm font-bold text-white">{tool.name}</div>
</div>
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-1">{tool.description}</p>
</button>
))}
{availableSystemTools.length === 0 && (
<div className="col-span-full p-3 rounded-lg border border-dashed border-white/10 text-xs text-muted-foreground">
没有可添加的系统指令工具
</div>
)}
</div>
</div>
<div className="space-y-3">
<h5 className="text-[10px] font-black flex items-center text-blue-400 uppercase tracking-[0.2em]">
<TrendingUp className="w-3.5 h-3.5 mr-2" /> 信息查询可选
</h5>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{availableQueryTools.map((tool) => (
<button
key={tool.id}
type="button"
onClick={() => toggleTool(tool.id)}
className="text-left p-3 rounded-lg border border-white/10 bg-white/5 hover:bg-blue-500/10 hover:border-blue-500/40 transition-colors"
>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-white/10 text-blue-300">{renderToolIcon(tool.icon)}</div>
<div className="text-sm font-bold text-white">{tool.name}</div>
</div>
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-1">{tool.description}</p>
</button>
))}
{availableQueryTools.length === 0 && (
<div className="col-span-full p-3 rounded-lg border border-dashed border-white/10 text-xs text-muted-foreground">
没有可添加的信息查询工具
</div>
)}
</div>
</div>
</div>
</Dialog>
{selectedAssistant && (
<DebugDrawer
isOpen={debugOpen}
onClose={() => setDebugOpen(false)}
assistant={selectedAssistant}
voices={voices}
llmModels={llmModels}
asrModels={asrModels}
tools={tools}
textTtsEnabled={selectedAssistant.voiceOutputEnabled !== false}
/>
)}
<Dialog
isOpen={unsavedDebugConfirmOpen}
onClose={() => setUnsavedDebugConfirmOpen(false)}
title="未保存配置提醒"
footer={
<>
<Button variant="ghost" onClick={() => setUnsavedDebugConfirmOpen(false)}>
取消
</Button>
<Button onClick={handleConfirmOpenDebug}>
继续调试
</Button>
</>
}
>
<div className="flex items-center space-x-4">
<div className="p-3 bg-amber-500/10 rounded-full">
<AlertTriangle className="h-6 w-6 text-amber-400" />
</div>
<div>
<p className="text-sm text-foreground text-white">
当前助手配置尚未保存,仍要打开调试窗口吗?
</p>
<p className="text-xs text-muted-foreground mt-1">
建议先保存配置,以确保调试使用最新参数。
</p>
</div>
</div>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
title="确认删除"
footer={
<>
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
<Button variant="destructive" onClick={confirmDelete}>确认删除</Button>
</>
}
>
<div className="flex items-center space-x-4">
<div className="p-3 bg-destructive/10 rounded-full">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<div>
<p className="text-sm text-foreground text-white">
您确定要删除此小助手吗?此操作无法撤销。
</p>
{deleteId && (
<p className="text-xs text-muted-foreground mt-1">
将要删除: {assistants.find(a => a.id === deleteId)?.name}
</p>
)}
</div>
</div>
</Dialog>
</div>
);
};
// Icon helper
const BotIcon = ({className}: {className?: string}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
);
const TOOL_PARAMETER_HINTS: Record<string, any> = {
search: {
type: 'object',
properties: { query: { type: 'string', description: 'Search query' } },
required: ['query'],
},
calculator: {
type: 'object',
properties: { expression: { type: 'string', description: 'Math expression' } },
required: ['expression'],
},
weather: {
type: 'object',
properties: { city: { type: 'string', description: 'City name' } },
required: ['city'],
},
translate: {
type: 'object',
properties: {
text: { type: 'string', description: 'Text to translate' },
target_lang: { type: 'string', description: 'Target language' },
},
required: ['text', 'target_lang'],
},
knowledge: {
type: 'object',
properties: {
query: { type: 'string', description: 'Knowledge query' },
kb_id: { type: 'string', description: 'Knowledge base id' },
},
required: ['query'],
},
current_time: {
type: 'object',
properties: {},
required: [],
},
take_phone: {
type: 'object',
properties: {},
required: [],
},
increase_volume: {
type: 'object',
properties: {
step: { type: 'integer', description: 'Volume step, default 1' },
},
required: [],
},
decrease_volume: {
type: 'object',
properties: {
step: { type: 'integer', description: 'Volume step, default 1' },
},
required: [],
},
voice_msg_prompt: {
type: 'object',
properties: {
msg: { type: 'string', description: 'Message text to speak' },
},
required: ['msg'],
},
text_msg_prompt: {
type: 'object',
properties: {
msg: { type: 'string', description: 'Message text to display in debug drawer modal' },
},
required: ['msg'],
},
voice_choice_prompt: {
type: 'object',
properties: {
question: { type: 'string', description: 'Question text to ask the user' },
options: {
type: 'array',
description: 'Selectable options (string or object with id/label/value)',
minItems: 2,
items: {
anyOf: [
{ type: 'string' },
{
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string' },
value: { type: 'string' },
},
required: ['label'],
},
],
},
},
voice_text: { type: 'string', description: 'Optional custom voice text, defaults to question' },
},
required: ['question', 'options'],
},
text_choice_prompt: {
type: 'object',
properties: {
question: { type: 'string', description: 'Question text to ask the user' },
options: {
type: 'array',
description: 'Selectable options (string or object with id/label/value)',
minItems: 2,
items: {
anyOf: [
{ type: 'string' },
{
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string' },
value: { type: 'string' },
},
required: ['label'],
},
],
},
},
},
required: ['question', 'options'],
},
code_interpreter: {
type: 'object',
properties: {
code: { type: 'string', description: 'Python code' },
},
required: ['code'],
},
};
const getDefaultToolParameters = (toolId: string) =>
TOOL_PARAMETER_HINTS[toolId] || { type: 'object', properties: {} };
const DEBUG_CLIENT_TOOLS = [
{ id: 'turn_on_camera', name: 'turn_on_camera', description: '' },
{ id: 'turn_off_camera', name: 'turn_off_camera', description: '' },
{ id: 'increase_volume', name: 'increase_volume', description: '' },
{ id: 'decrease_volume', name: 'decrease_volume', description: '' },
{ id: 'voice_msg_prompt', name: 'voice_msg_prompt', description: '' },
{ id: 'text_msg_prompt', name: 'text_msg_prompt', description: '' },
{ id: 'voice_choice_prompt', name: 'voice_choice_prompt', description: '' },
{ id: 'text_choice_prompt', name: 'text_choice_prompt', description: '' },
] as const;
const DEBUG_CLIENT_TOOL_ID_SET = new Set<string>(DEBUG_CLIENT_TOOLS.map((item) => item.id));
const DEBUG_CLIENT_TOOL_WAIT_DEFAULTS: Record<string, boolean> = {
text_msg_prompt: true,
voice_choice_prompt: true,
text_choice_prompt: true,
};
type DynamicVariableEntry = {
id: string;
key: string;
value: string;
};
type TemplateSuggestionState = {
field: 'prompt' | 'opener';
start: number;
end: number;
query: string;
anchorLeft: number;
anchorTop: number;
};
const DYNAMIC_VARIABLE_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
const DYNAMIC_VARIABLE_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
const DYNAMIC_VARIABLE_MAX_ITEMS = 30;
const DYNAMIC_VARIABLE_MAX_VALUE_LENGTH = 1000;
const TEMPLATE_SUGGESTION_PANEL_WIDTH = 320;
const TEMPLATE_SUGGESTION_PANEL_MAX_HEIGHT = 240;
const TEMPLATE_SUGGESTION_VIEWPORT_PADDING = 8;
const SYSTEM_DYNAMIC_VARIABLE_OPTIONS = [
{
key: 'system__time',
description: 'Session local time (YYYY-MM-DD HH:mm:ss)',
},
{
key: 'system_utc',
description: 'Session UTC time (YYYY-MM-DD HH:mm:ss)',
},
{
key: 'system_timezone',
description: 'Session local timezone',
},
];
const SYSTEM_DYNAMIC_VARIABLE_KEY_SET = new Set(SYSTEM_DYNAMIC_VARIABLE_OPTIONS.map((item) => item.key));
const clampTemplateSuggestionPosition = (left: number, top: number) => {
if (typeof window === 'undefined') return { left, top };
const maxLeft = Math.max(
TEMPLATE_SUGGESTION_VIEWPORT_PADDING,
window.innerWidth - TEMPLATE_SUGGESTION_PANEL_WIDTH - TEMPLATE_SUGGESTION_VIEWPORT_PADDING
);
const maxTop = Math.max(
TEMPLATE_SUGGESTION_VIEWPORT_PADDING,
window.innerHeight - TEMPLATE_SUGGESTION_PANEL_MAX_HEIGHT - TEMPLATE_SUGGESTION_VIEWPORT_PADDING
);
return {
left: Math.min(Math.max(left, TEMPLATE_SUGGESTION_VIEWPORT_PADDING), maxLeft),
top: Math.min(Math.max(top, TEMPLATE_SUGGESTION_VIEWPORT_PADDING), maxTop),
};
};
const getTemplateSuggestionAnchor = (
control: HTMLTextAreaElement | HTMLInputElement,
caret: number | null
) => {
const rect = control.getBoundingClientRect();
const fallback = clampTemplateSuggestionPosition(rect.left + 12, rect.bottom + 8);
if (
caret === null ||
caret < 0 ||
typeof window === 'undefined' ||
typeof document === 'undefined'
) {
return fallback;
}
const computedStyle = window.getComputedStyle(control);
const isInput = control instanceof HTMLInputElement;
const mirror = document.createElement('div');
const marker = document.createElement('span');
const propertiesToCopy = [
'direction',
'boxSizing',
'width',
'overflowX',
'overflowY',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderTopStyle',
'borderRightStyle',
'borderBottomStyle',
'borderLeftStyle',
'fontFamily',
'fontSize',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'letterSpacing',
'lineHeight',
'textAlign',
'textIndent',
'textTransform',
'textDecoration',
'wordSpacing',
'tabSize',
'overflowWrap',
] as const;
mirror.style.position = 'absolute';
mirror.style.left = '0px';
mirror.style.top = '0px';
mirror.style.visibility = 'hidden';
mirror.style.pointerEvents = 'none';
mirror.style.whiteSpace = isInput ? 'pre' : 'pre-wrap';
mirror.style.wordBreak = 'break-word';
mirror.style.wordWrap = 'break-word';
mirror.style.overflow = 'hidden';
if (isInput) {
mirror.style.height = computedStyle.height;
mirror.style.lineHeight = computedStyle.height;
}
for (const property of propertiesToCopy) {
(mirror.style as any)[property] = (computedStyle as any)[property];
}
const value = control.value || '';
const safeCaret = Math.min(caret, value.length);
mirror.textContent = value.slice(0, safeCaret);
marker.textContent = value.slice(safeCaret) || '.';
marker.style.display = 'inline-block';
marker.style.width = '1px';
mirror.appendChild(marker);
document.body.appendChild(mirror);
const markerLeft = marker.offsetLeft;
const markerTop = marker.offsetTop;
document.body.removeChild(mirror);
if (!Number.isFinite(markerLeft) || !Number.isFinite(markerTop)) {
return fallback;
}
const lineHeightRaw = Number.parseFloat(computedStyle.lineHeight);
const fontSizeRaw = Number.parseFloat(computedStyle.fontSize);
const lineHeight = Number.isFinite(lineHeightRaw)
? lineHeightRaw
: Number.isFinite(fontSizeRaw)
? fontSizeRaw * 1.2
: 16;
const caretLeft = rect.left + markerLeft - control.scrollLeft;
const caretTop = rect.top + markerTop - control.scrollTop;
return clampTemplateSuggestionPosition(caretLeft, caretTop + lineHeight + 6);
};
const extractDynamicTemplateKeys = (text: string): string[] => {
if (!text) return [];
const keys = new Set<string>();
const pattern = new RegExp(DYNAMIC_VARIABLE_PLACEHOLDER_RE);
for (const match of text.matchAll(pattern)) {
if (match[1]) {
keys.add(match[1]);
}
}
return Array.from(keys);
};
type DebugTranscriptMessage = {
role: 'user' | 'model' | 'tool';
text: string;
responseId?: string;
ttfbMs?: number;
};
type DebugPromptPendingResult = {
toolCallId: string;
toolName: string;
toolDisplayName: string;
waitForResponse: boolean;
};
type DebugChoicePromptOption = {
id: string;
label: string;
value: string;
};
type DebugTextPromptDialogState = {
open: boolean;
message: string;
pendingResult?: DebugPromptPendingResult;
promptType?: 'text' | 'voice';
voiceText?: string;
};
type DebugChoicePromptDialogState = {
open: boolean;
question: string;
options: DebugChoicePromptOption[];
pendingResult?: DebugPromptPendingResult;
requireSelection?: boolean;
voiceText?: string;
};
type DebugPromptQueueItem =
| { kind: 'text'; payload: Omit<DebugTextPromptDialogState, 'open'> }
| { kind: 'choice'; payload: Omit<DebugChoicePromptDialogState, 'open'> };
const normalizeChoicePromptOptions = (rawOptions: unknown[]): DebugChoicePromptOption[] => {
const usedIds = new Set<string>();
const resolved: DebugChoicePromptOption[] = [];
rawOptions.forEach((rawOption, index) => {
let id = `opt_${index + 1}`;
let label = '';
let value = '';
if (typeof rawOption === 'string' || typeof rawOption === 'number' || typeof rawOption === 'boolean') {
label = String(rawOption).trim();
value = label;
} else if (rawOption && typeof rawOption === 'object') {
const row = rawOption as Record<string, unknown>;
const labelCandidate = row.label ?? row.text ?? row.name;
label = String(labelCandidate ?? '').trim();
id = String(row.id ?? id).trim() || id;
const valueCandidate = row.value;
value = valueCandidate === undefined || valueCandidate === null ? label : String(valueCandidate);
}
if (!label) return;
if (usedIds.has(id)) {
let suffix = 2;
while (usedIds.has(`${id}_${suffix}`)) suffix += 1;
id = `${id}_${suffix}`;
}
usedIds.add(id);
resolved.push({ id, label, value });
});
return resolved;
};
// Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping)
const TranscriptionLog: React.FC<{
scrollRef: React.RefObject<HTMLDivElement | null>;
messages: DebugTranscriptMessage[];
isLoading: boolean;
className?: string;
}> = ({ 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}`}>
{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={`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>
))}
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
</div>
);
// --- Debug Drawer Component ---
export const DebugDrawer: React.FC<{
isOpen: boolean;
onClose: () => void;
assistant: Assistant;
voices?: Voice[];
llmModels?: LLMModel[];
asrModels?: ASRModel[];
tools?: Tool[];
textTtsEnabled: boolean;
sessionMetadataExtras?: Record<string, any>;
onProtocolEvent?: (event: Record<string, any>) => void;
}> = ({
isOpen,
onClose,
assistant,
voices = [],
llmModels = [],
asrModels = [],
tools = [],
textTtsEnabled,
sessionMetadataExtras,
onProtocolEvent,
}) => {
const TARGET_SAMPLE_RATE = 16000;
const PCM_FRAME_BYTES = 640; // WS v1 fixed 20ms frame for 16k mono pcm_s16le
const downsampleTo16k = (input: Float32Array, inputSampleRate: number): Float32Array => {
if (inputSampleRate === TARGET_SAMPLE_RATE) return input;
if (inputSampleRate < TARGET_SAMPLE_RATE) return input;
const ratio = inputSampleRate / TARGET_SAMPLE_RATE;
const outputLength = Math.max(1, Math.round(input.length / ratio));
const output = new Float32Array(outputLength);
let offsetInput = 0;
for (let i = 0; i < outputLength; i += 1) {
const nextOffsetInput = Math.min(input.length, Math.round((i + 1) * ratio));
let accum = 0;
let count = 0;
for (let j = offsetInput; j < nextOffsetInput; j += 1) {
accum += input[j];
count += 1;
}
output[i] = count > 0 ? accum / count : input[Math.min(offsetInput, input.length - 1)] || 0;
offsetInput = nextOffsetInput;
}
return output;
};
const float32ToPcm16 = (input: Float32Array): Int16Array => {
const output = new Int16Array(input.length);
for (let i = 0; i < input.length; i += 1) {
const s = Math.max(-1, Math.min(1, input[i]));
output[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
}
return output;
};
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
const [messages, setMessages] = useState<DebugTranscriptMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle');
const [textPromptDialog, setTextPromptDialog] = useState<DebugTextPromptDialogState>({
open: false,
message: '',
promptType: 'text',
});
const [choicePromptDialog, setChoicePromptDialog] = useState<DebugChoicePromptDialogState>({ open: false, question: '', options: [] });
const textPromptDialogRef = useRef(textPromptDialog);
const choicePromptDialogRef = useRef(choicePromptDialog);
const promptDialogQueueRef = useRef<DebugPromptQueueItem[]>([]);
const promptAudioRef = useRef<HTMLAudioElement | null>(null);
const [textSessionStarted, setTextSessionStarted] = useState(false);
const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected');
const [wsError, setWsError] = useState('');
const wsStatusRef = useRef<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected');
const [resolvedConfigOpen, setResolvedConfigOpen] = useState(false);
const [resolvedConfigView, setResolvedConfigView] = useState<string>('');
const [captureConfigOpen, setCaptureConfigOpen] = useState(false);
const [captureConfigView, setCaptureConfigView] = useState<string>('');
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
const [dynamicVariables, setDynamicVariables] = useState<DynamicVariableEntry[]>([]);
const [dynamicVariablesError, setDynamicVariablesError] = useState('');
const dynamicVariableSeqRef = useRef(0);
const wsUrl = useDebugPrefsStore((state) => state.wsUrl);
const setWsUrl = useDebugPrefsStore((state) => state.setWsUrl);
const aecEnabled = useDebugPrefsStore((state) => state.aecEnabled);
const setAecEnabled = useDebugPrefsStore((state) => state.setAecEnabled);
const nsEnabled = useDebugPrefsStore((state) => state.nsEnabled);
const setNsEnabled = useDebugPrefsStore((state) => state.setNsEnabled);
const agcEnabled = useDebugPrefsStore((state) => state.agcEnabled);
const setAgcEnabled = useDebugPrefsStore((state) => state.setAgcEnabled);
const clientToolEnabledMap = useDebugPrefsStore((state) => state.clientToolEnabledMap);
const setClientToolEnabled = useDebugPrefsStore((state) => state.setClientToolEnabled);
const hydrateClientToolDefaults = useDebugPrefsStore((state) => state.hydrateClientToolDefaults);
const nextDynamicVariableId = () => {
dynamicVariableSeqRef.current += 1;
return `var_${dynamicVariableSeqRef.current}`;
};
const isDynamicVariablesLocked =
wsStatus === 'connecting'
|| wsStatus === 'ready'
|| callStatus === 'calling'
|| callStatus === 'active'
|| textSessionStarted;
const requiredTemplateVariableKeys = useMemo(() => {
const keys = new Set<string>();
const includeOpenerTemplate = assistant.generatedOpenerEnabled !== true;
extractDynamicTemplateKeys(String(assistant.prompt || '')).forEach((key) => keys.add(key));
if (includeOpenerTemplate) {
extractDynamicTemplateKeys(String(assistant.opener || '')).forEach((key) => keys.add(key));
}
return Array.from(keys).sort();
}, [assistant.generatedOpenerEnabled, assistant.opener, assistant.prompt]);
const missingRequiredDynamicVariableKeys = useMemo(() => {
const valuesByKey = new Map<string, string>();
for (const row of dynamicVariables) {
const key = row.key.trim();
if (!key || valuesByKey.has(key)) continue;
valuesByKey.set(key, row.value);
}
return requiredTemplateVariableKeys.filter((key) => {
if (SYSTEM_DYNAMIC_VARIABLE_KEY_SET.has(key)) return false;
if (!valuesByKey.has(key)) return true;
return valuesByKey.get(key) === '';
});
}, [dynamicVariables, requiredTemplateVariableKeys]);
// 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 submittedMetadataRef = useRef<Record<string, any> | null>(null);
const assistantDraftIndexRef = useRef<number | null>(null);
const assistantResponseIndexByIdRef = useRef<Map<string, number>>(new Map());
const pendingTtfbByResponseIdRef = useRef<Map<string, number>>(new Map());
const interruptedResponseIdsRef = useRef<Set<string>>(new Set());
const interruptedDropNoticeKeysRef = useRef<Set<string>>(new Set());
const audioCtxRef = useRef<AudioContext | null>(null);
const playbackTimeRef = useRef<number>(0);
const activeAudioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const queuedAudioBuffersRef = useRef<AudioBuffer[]>([]);
const queuedAudioDurationRef = useRef<number>(0);
const PLAYBACK_INITIAL_BUFFER_SECONDS = 0.25;
const PLAYBACK_MAX_AHEAD_SECONDS = 0.8;
const PLAYBACK_SCHEDULE_LEAD_SECONDS = 0.04;
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedCamera, setSelectedCamera] = useState<string>('');
const [selectedMic, setSelectedMic] = useState<string>('');
const [isSwapped, setIsSwapped] = useState(false);
const micAudioCtxRef = useRef<AudioContext | null>(null);
const micSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const micProcessorRef = useRef<ScriptProcessorNode | null>(null);
const micGainRef = useRef<GainNode | null>(null);
const micFrameBufferRef = useRef<Uint8Array>(new Uint8Array(0));
const userDraftIndexRef = useRef<number | null>(null);
const lastUserFinalRef = useRef<string>('');
const debugVolumePercentRef = useRef<number>(50);
const clientToolEnabledMapRef = useRef<Record<string, boolean>>(clientToolEnabledMap);
const isClientToolEnabled = (toolId: string) => clientToolEnabledMap[normalizeToolId(toolId)] !== false;
const isClientToolEnabledLive = (toolId: string) => clientToolEnabledMapRef.current[normalizeToolId(toolId)] !== false;
const selectedToolSchemas = useMemo(() => {
const ids = Array.from(new Set((assistant.tools || []).map((id) => normalizeToolId(id))));
const byId = new Map(tools.map((t) => [normalizeToolId(t.id), { ...t, id: normalizeToolId(t.id) }]));
return ids.map((id) => {
const item = byId.get(id);
const toolId = item?.id || id;
const debugClientTool = DEBUG_CLIENT_TOOLS.find((tool) => tool.id === toolId);
if (debugClientTool && !isClientToolEnabled(toolId)) {
return null;
}
const isClientTool = debugClientTool ? true : (item?.category || 'query') === 'system';
const waitForResponse = isClientTool
? (item?.waitForResponse ?? DEBUG_CLIENT_TOOL_WAIT_DEFAULTS[toolId] ?? false)
: false;
const parameterSchema = (item?.parameterSchema && typeof item.parameterSchema === 'object')
? item.parameterSchema
: getDefaultToolParameters(toolId);
const parameterDefaults = (item?.parameterDefaults && typeof item.parameterDefaults === 'object')
? item.parameterDefaults
: undefined;
return {
type: 'function',
executor: isClientTool ? 'client' : 'server',
waitForResponse,
...(parameterDefaults && Object.keys(parameterDefaults).length > 0 ? { defaultArgs: parameterDefaults } : {}),
function: {
name: toolId,
description: item?.description || item?.name || debugClientTool?.description || id,
parameters: parameterSchema,
},
};
}).filter(Boolean) as Array<Record<string, any>>;
}, [assistant.tools, tools, clientToolEnabledMap]);
const clearResponseTracking = () => {
assistantDraftIndexRef.current = null;
assistantResponseIndexByIdRef.current.clear();
pendingTtfbByResponseIdRef.current.clear();
interruptedResponseIdsRef.current.clear();
interruptedDropNoticeKeysRef.current.clear();
};
const extractResponseId = (payload: any): string | undefined => {
const responseIdRaw = payload?.data?.response_id ?? payload?.response_id ?? payload?.responseId;
const responseId = String(responseIdRaw || '').trim();
return responseId || undefined;
};
const noteInterruptedDrop = (responseId: string, kind: 'ttfb' | 'delta' | 'final') => {
const key = `${responseId}:${kind}`;
if (interruptedDropNoticeKeysRef.current.has(key)) return;
interruptedDropNoticeKeysRef.current.add(key);
if (interruptedDropNoticeKeysRef.current.size > 256) {
const oldest = interruptedDropNoticeKeysRef.current.values().next().value as string | undefined;
if (oldest) interruptedDropNoticeKeysRef.current.delete(oldest);
}
setMessages((prev) => [
...prev,
{
role: 'tool',
text: `drop stale ${kind} from interrupted response ${responseId}`,
},
]);
};
// Initialize
useEffect(() => {
if (isOpen) {
if (mode === 'text') {
clearResponseTracking();
setMessages([]);
setTextSessionStarted(false);
} else {
clearResponseTracking();
setMessages([]);
setCallStatus('idle');
}
} else {
setMode('text');
if (textPromptDialogRef.current.open) {
closeTextPromptDialog('dismiss', { force: true, skipQueueAdvance: true });
}
if (choicePromptDialogRef.current.open) {
closeChoicePromptDialog('dismiss', undefined, { force: true, skipQueueAdvance: true });
}
stopVoiceCapture();
stopMedia();
closeWs();
stopPromptVoicePlayback();
promptDialogQueueRef.current = [];
setTextPromptDialog({ open: false, message: '', promptType: 'text' });
setChoicePromptDialog({ open: false, question: '', options: [] });
if (audioCtxRef.current) {
void audioCtxRef.current.close();
audioCtxRef.current = null;
}
setSettingsDrawerOpen(false);
setIsSwapped(false);
setCallStatus('idle');
}
}, [isOpen, assistant, mode]);
useEffect(() => {
hydrateClientToolDefaults(DEBUG_CLIENT_TOOLS.map((tool) => tool.id));
}, [hydrateClientToolDefaults]);
useEffect(() => {
wsStatusRef.current = wsStatus;
}, [wsStatus]);
useEffect(() => {
textPromptDialogRef.current = textPromptDialog;
}, [textPromptDialog]);
useEffect(() => {
choicePromptDialogRef.current = choicePromptDialog;
}, [choicePromptDialog]);
useEffect(() => {
dynamicVariableSeqRef.current = 0;
setDynamicVariables([]);
setDynamicVariablesError('');
}, [assistant.id, isOpen]);
useEffect(() => {
clientToolEnabledMapRef.current = clientToolEnabledMap;
}, [clientToolEnabledMap]);
// Auto-scroll logic
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, mode]);
// Fetch Devices
useEffect(() => {
if (isOpen && (mode === 'video' || mode === 'voice')) {
const getDevices = async () => {
let permissionStream: MediaStream | null = null;
try {
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: mode === 'video' });
const dev = await navigator.mediaDevices.enumerateDevices();
setDevices(dev);
const cams = dev.filter(d => d.kind === 'videoinput');
const mics = dev.filter(d => d.kind === 'audioinput');
if (cams.length > 0 && !selectedCamera) setSelectedCamera(cams[0].deviceId);
if (mics.length > 0 && !selectedMic) setSelectedMic(mics[0].deviceId);
} catch (e) {
console.error("Error enumerating devices", e);
} finally {
permissionStream?.getTracks().forEach((track) => track.stop());
}
};
getDevices();
}
}, [isOpen, mode]);
const stopMedia = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
const stopVoiceCapture = () => {
if (micProcessorRef.current) {
micProcessorRef.current.onaudioprocess = null;
try {
micProcessorRef.current.disconnect();
} catch {
// no-op
}
micProcessorRef.current = null;
}
if (micSourceRef.current) {
try {
micSourceRef.current.disconnect();
} catch {
// no-op
}
micSourceRef.current = null;
}
if (micGainRef.current) {
try {
micGainRef.current.disconnect();
} catch {
// no-op
}
micGainRef.current = null;
}
if (micAudioCtxRef.current) {
void micAudioCtxRef.current.close();
micAudioCtxRef.current = null;
}
micFrameBufferRef.current = new Uint8Array(0);
setCaptureConfigView('');
stopMedia();
};
const sendFramedMicAudio = (pcm16: Int16Array) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN || !wsReadyRef.current) return;
if (pcm16.byteLength <= 0) return;
const chunkBytes = new Uint8Array(pcm16.buffer, pcm16.byteOffset, pcm16.byteLength);
const pending = micFrameBufferRef.current;
const merged = new Uint8Array(pending.length + chunkBytes.length);
merged.set(pending, 0);
merged.set(chunkBytes, pending.length);
let offset = 0;
while (merged.length - offset >= PCM_FRAME_BYTES) {
ws.send(merged.subarray(offset, offset + PCM_FRAME_BYTES));
offset += PCM_FRAME_BYTES;
}
micFrameBufferRef.current = offset >= merged.length ? new Uint8Array(0) : merged.slice(offset);
};
const buildMicConstraints = (): MediaTrackConstraints => ({
deviceId: selectedMic ? { exact: selectedMic } : undefined,
echoCancellation: aecEnabled,
noiseSuppression: nsEnabled,
autoGainControl: agcEnabled,
channelCount: 1,
sampleRate: TARGET_SAMPLE_RATE,
});
const startVoiceCapture = async () => {
stopVoiceCapture();
const requestedConstraints = buildMicConstraints();
const stream = await navigator.mediaDevices.getUserMedia({
audio: requestedConstraints,
video: false,
});
streamRef.current = stream;
const track = stream.getAudioTracks()[0];
if (track) {
console.log('Voice capture settings', track.getSettings());
setCaptureConfigView(
JSON.stringify(
{
requested: requestedConstraints,
applied: track.getSettings(),
capabilities: typeof track.getCapabilities === 'function' ? track.getCapabilities() : undefined,
},
null,
2
)
);
}
const ctx = new AudioContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
micAudioCtxRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(4096, 1, 1);
const silentGain = ctx.createGain();
silentGain.gain.value = 0;
source.connect(processor);
processor.connect(silentGain);
silentGain.connect(ctx.destination);
processor.onaudioprocess = (event) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !wsReadyRef.current) return;
const inChannel = event.inputBuffer.getChannelData(0);
const downsampled = downsampleTo16k(inChannel, event.inputBuffer.sampleRate);
const pcm16 = float32ToPcm16(downsampled);
sendFramedMicAudio(pcm16);
};
micSourceRef.current = source;
micProcessorRef.current = processor;
micGainRef.current = silentGain;
};
const ensureAudioContext = async () => {
if (!audioCtxRef.current) {
audioCtxRef.current = new AudioContext();
playbackTimeRef.current = audioCtxRef.current.currentTime;
}
if (audioCtxRef.current.state === 'suspended') {
await audioCtxRef.current.resume();
}
return audioCtxRef.current;
};
const clearPlaybackQueue = () => {
const ctx = audioCtxRef.current;
const now = ctx ? ctx.currentTime : 0;
playbackTimeRef.current = now;
queuedAudioBuffersRef.current = [];
queuedAudioDurationRef.current = 0;
};
const stopPlaybackImmediately = () => {
activeAudioSourcesRef.current.forEach((source) => {
try {
source.stop();
} catch {
// no-op
}
source.disconnect();
});
activeAudioSourcesRef.current.clear();
clearPlaybackQueue();
};
const emitClientToolResult = (resultPayload: any, toolDisplayName?: string) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'tool_call.results',
results: [resultPayload],
})
);
}
const statusCode = Number(resultPayload?.status?.code || 500);
const statusMessage = String(resultPayload?.status?.message || 'error');
const displayName = toolDisplayName || String(resultPayload?.name || 'unknown_tool');
const resultText =
statusCode === 200 && typeof resultPayload?.output?.result === 'number'
? `result ${displayName} = ${resultPayload.output.result}`
: `result ${displayName} status=${statusCode} ${statusMessage}`;
setMessages((prev) => [
...prev,
{
role: 'tool',
text: resultText,
},
]);
};
const stopPromptVoicePlayback = () => {
if (promptAudioRef.current) {
try {
promptAudioRef.current.pause();
} catch {
// no-op
}
promptAudioRef.current = null;
}
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
};
const playPromptVoice = async (text: string) => {
const phrase = String(text || '').trim();
if (!phrase) return;
stopPromptVoicePlayback();
const canUseAssistantTts = assistant.voiceOutputEnabled !== false && Boolean(assistant.voice);
if (canUseAssistantTts) {
const selectedVoice = voices.find((item) => item.id === assistant.voice);
if (selectedVoice) {
try {
const audioUrl = await previewVoice(selectedVoice.id, phrase, assistant.speed);
const audio = new Audio(audioUrl);
promptAudioRef.current = audio;
audio.onended = () => {
if (promptAudioRef.current === audio) {
promptAudioRef.current = null;
}
};
audio.onerror = () => {
if (promptAudioRef.current === audio) {
promptAudioRef.current = null;
}
};
await audio.play();
return;
} catch (err) {
console.warn('Assistant TTS preview failed, falling back to speechSynthesis', err);
}
}
}
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(phrase);
utterance.lang = assistant.language === 'en' ? 'en-US' : 'zh-CN';
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}
};
const hasActivePromptDialog = () => textPromptDialogRef.current.open || choicePromptDialogRef.current.open;
const activatePromptDialog = (item: DebugPromptQueueItem) => {
if (item.kind === 'text') {
const nextVoiceText = String(item.payload.voiceText || '').trim();
setTextPromptDialog({
open: true,
message: item.payload.message,
pendingResult: item.payload.pendingResult,
promptType: item.payload.promptType || 'text',
voiceText: nextVoiceText || undefined,
});
if (nextVoiceText) {
void playPromptVoice(nextVoiceText);
}
return;
}
const nextVoiceText = String(item.payload.voiceText || '').trim();
setChoicePromptDialog({
open: true,
question: item.payload.question,
options: item.payload.options,
pendingResult: item.payload.pendingResult,
requireSelection: item.payload.requireSelection === true,
voiceText: nextVoiceText || undefined,
});
if (nextVoiceText) {
void playPromptVoice(nextVoiceText);
}
};
const enqueuePromptDialog = (item: DebugPromptQueueItem) => {
if (hasActivePromptDialog()) {
promptDialogQueueRef.current.push(item);
return;
}
activatePromptDialog(item);
};
const openNextPromptDialog = (force = false) => {
if (!force && hasActivePromptDialog()) return;
const next = promptDialogQueueRef.current.shift();
if (!next) return;
activatePromptDialog(next);
};
const closeTextPromptDialog = (action: 'confirm' | 'dismiss', opts?: { force?: boolean; skipQueueAdvance?: boolean }) => {
const snapshot = textPromptDialogRef.current;
if (!snapshot.open && !opts?.force) return;
const pending = snapshot?.pendingResult;
const message = snapshot?.message || '';
const promptType = snapshot?.promptType === 'voice' ? 'voice' : 'text';
stopPromptVoicePlayback();
setTextPromptDialog({ open: false, message: '', promptType: 'text' });
if (pending?.waitForResponse) {
emitClientToolResult(
{
tool_call_id: pending.toolCallId,
name: pending.toolName,
output: {
message: promptType === 'voice' ? 'voice_prompt_closed' : 'text_prompt_closed',
action,
msg: message,
prompt_type: promptType,
},
status: { code: 200, message: 'ok' },
},
pending.toolDisplayName
);
}
if (!opts?.skipQueueAdvance) {
openNextPromptDialog(true);
}
};
const closeChoicePromptDialog = (
action: 'select' | 'dismiss',
selectedOption?: DebugChoicePromptOption,
opts?: { force?: boolean; skipQueueAdvance?: boolean }
) => {
const snapshot = choicePromptDialogRef.current;
if (!snapshot.open && !opts?.force) return;
if (snapshot.requireSelection && action !== 'select' && !opts?.force) {
return;
}
const pending = snapshot?.pendingResult;
const question = snapshot?.question || '';
const options = snapshot?.options || [];
stopPromptVoicePlayback();
setChoicePromptDialog({ open: false, question: '', options: [] });
if (pending?.waitForResponse) {
emitClientToolResult(
{
tool_call_id: pending.toolCallId,
name: pending.toolName,
output: {
message: action === 'select' ? 'choice_selected' : 'choice_dismissed',
action,
question,
option: selectedOption
? {
id: selectedOption.id,
label: selectedOption.label,
value: selectedOption.value,
}
: null,
options,
},
status: { code: 200, message: 'ok' },
},
pending.toolDisplayName
);
}
if (!opts?.skipQueueAdvance) {
openNextPromptDialog(true);
}
};
const scheduleQueuedPlayback = (ctx: AudioContext) => {
const queue = queuedAudioBuffersRef.current;
if (queue.length === 0) return;
const now = ctx.currentTime;
if (playbackTimeRef.current < now) {
playbackTimeRef.current = now;
}
const hasActivePlayback = activeAudioSourcesRef.current.size > 0;
const minBufferSeconds = hasActivePlayback
? 0
: PLAYBACK_INITIAL_BUFFER_SECONDS;
if (queuedAudioDurationRef.current < minBufferSeconds) {
return;
}
while (queue.length > 0 && (playbackTimeRef.current - now) < PLAYBACK_MAX_AHEAD_SECONDS) {
const audioBuffer = queue.shift();
if (!audioBuffer) break;
queuedAudioDurationRef.current = Math.max(0, queuedAudioDurationRef.current - audioBuffer.duration);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
activeAudioSourcesRef.current.add(source);
source.onended = () => {
activeAudioSourcesRef.current.delete(source);
try {
source.disconnect();
} catch {
// no-op
}
scheduleQueuedPlayback(ctx);
};
const startAt = Math.max(ctx.currentTime + PLAYBACK_SCHEDULE_LEAD_SECONDS, playbackTimeRef.current);
source.start(startAt);
playbackTimeRef.current = startAt + audioBuffer.duration;
}
};
const playPcm16Chunk = async (pcmBuffer: ArrayBuffer) => {
if (!textTtsEnabled) return;
const ctx = await ensureAudioContext();
const int16 = new Int16Array(pcmBuffer);
if (int16.length === 0) return;
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i += 1) {
float32[i] = int16[i] / 32768;
}
const sampleRate = 16000;
const audioBuffer = ctx.createBuffer(1, float32.length, sampleRate);
audioBuffer.copyToChannel(float32, 0);
queuedAudioBuffersRef.current.push(audioBuffer);
queuedAudioDurationRef.current += audioBuffer.duration;
scheduleQueuedPlayback(ctx);
};
useEffect(() => {
const handleStream = async () => {
if (isOpen && mode === 'video' && callStatus === 'active') {
try {
stopMedia();
const constraints = {
video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true,
audio: selectedMic ? { deviceId: { exact: selectedMic } } : true
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("Failed to access camera/mic:", err);
}
} else if (callStatus !== 'active') {
stopMedia();
}
};
handleStream();
return () => stopMedia();
}, [mode, isOpen, selectedCamera, selectedMic, callStatus]);
const handleCall = () => {
if (mode !== 'voice') {
setCallStatus('calling');
setTimeout(() => {
setCallStatus('active');
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
}, 1500);
return;
}
const launchVoice = async () => {
try {
setCallStatus('calling');
clearResponseTracking();
setMessages([]);
lastUserFinalRef.current = '';
setWsError('');
setDynamicVariablesError('');
closeWs();
if (textTtsEnabled) await ensureAudioContext();
// Start mic capture before session.start so barge-in works from opener start.
await startVoiceCapture();
await ensureWsSession();
setCallStatus('active');
} catch (e) {
console.error(e);
stopVoiceCapture();
setCallStatus('idle');
const err = e as Error & { __dynamicVariables?: boolean };
if (err.__dynamicVariables) {
setWsStatus('disconnected');
return;
}
setWsStatus('error');
setWsError(err?.message || 'Failed to start voice call');
}
};
void launchVoice();
};
const handleHangup = () => {
if (textPromptDialog.open) {
closeTextPromptDialog('dismiss', { force: true, skipQueueAdvance: true });
}
if (choicePromptDialog.open) {
closeChoicePromptDialog('dismiss', undefined, { force: true, skipQueueAdvance: true });
}
stopPromptVoicePlayback();
promptDialogQueueRef.current = [];
stopVoiceCapture();
stopMedia();
closeWs();
setCallStatus('idle');
clearResponseTracking();
setMessages([]);
setTextPromptDialog({ open: false, message: '', promptType: 'text' });
setChoicePromptDialog({ open: false, question: '', options: [] });
lastUserFinalRef.current = '';
setIsLoading(false);
};
const handleSend = async () => {
if (!inputText.trim()) return;
const userMsg = inputText;
assistantDraftIndexRef.current = null;
setMessages(prev => [...prev, { role: 'user', text: userMsg }]);
setInputText('');
setIsLoading(true);
try {
setDynamicVariablesError('');
if (mode === 'text') {
if (textTtsEnabled) await ensureAudioContext();
await ensureWsSession();
// Interrupt any in-flight response/audio before sending new user utterance.
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'response.cancel', graceful: false }));
}
stopPlaybackImmediately();
wsRef.current?.send(JSON.stringify({ type: 'input.text', text: userMsg }));
} else if (mode === 'voice') {
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);
}, 1000);
}
} catch (e) {
console.error(e);
const err = e as Error & { __dynamicVariables?: boolean };
if (err.__dynamicVariables) {
setIsLoading(false);
return;
}
const errMessage = err?.message || 'Failed to connect to AI service.';
setMessages(prev => [...prev, { role: 'model', text: `Error: ${errMessage}` }]);
setWsError(errMessage);
setIsLoading(false);
} finally {
if (mode !== 'text') setIsLoading(false);
}
};
const handleTextLaunch = async () => {
try {
setWsError('');
setDynamicVariablesError('');
// Start every text debug run as a fresh session transcript.
clearResponseTracking();
setMessages([]);
lastUserFinalRef.current = '';
// Force a fresh WS session so updated assistant runtime config
// (voice/model/provider/speed) is applied on session.start.
closeWs();
if (textTtsEnabled) await ensureAudioContext();
await ensureWsSession();
setTextSessionStarted(true);
} catch (e) {
console.error(e);
const err = e as Error & { __dynamicVariables?: boolean };
if (err.__dynamicVariables) {
setWsStatus('disconnected');
setTextSessionStarted(false);
return;
}
setWsStatus('error');
setWsError(err?.message || 'Failed to connect');
setTextSessionStarted(false);
}
};
const addDynamicVariableRow = () => {
if (isDynamicVariablesLocked) return;
setDynamicVariablesError('');
setDynamicVariables((prev) => {
if (prev.length >= DYNAMIC_VARIABLE_MAX_ITEMS) return prev;
return [...prev, { id: nextDynamicVariableId(), key: '', value: '' }];
});
};
const updateDynamicVariableRow = (rowId: string, field: 'key' | 'value', value: string) => {
if (isDynamicVariablesLocked) return;
setDynamicVariablesError('');
setDynamicVariables((prev) => prev.map((item) => (item.id === rowId ? { ...item, [field]: value } : item)));
};
const removeDynamicVariableRow = (rowId: string) => {
if (isDynamicVariablesLocked) return;
setDynamicVariablesError('');
setDynamicVariables((prev) => prev.filter((item) => item.id !== rowId));
};
const importDynamicVariablesFromPlaceholders = () => {
if (isDynamicVariablesLocked) return;
setDynamicVariablesError('');
setDynamicVariables((prev) => {
if (!requiredTemplateVariableKeys.length || prev.length >= DYNAMIC_VARIABLE_MAX_ITEMS) {
return prev;
}
const existingKeys = new Set(
prev
.map((item) => item.key.trim())
.filter((key) => key !== '')
);
const next = [...prev];
for (const key of requiredTemplateVariableKeys) {
if (next.length >= DYNAMIC_VARIABLE_MAX_ITEMS) break;
if (existingKeys.has(key)) continue;
next.push({
id: nextDynamicVariableId(),
key,
value: '',
});
existingKeys.add(key);
}
return next;
});
};
const createDynamicVariablesError = (message: string): Error & { __dynamicVariables?: boolean } => {
const error = new Error(message) as Error & { __dynamicVariables?: boolean };
error.__dynamicVariables = true;
return error;
};
const METADATA_OVERRIDE_WHITELIST = new Set([
'firstTurnMode',
'greeting',
'generatedOpenerEnabled',
'manualOpenerToolCalls',
'systemPrompt',
'output',
'bargeIn',
'knowledge',
'knowledgeBaseId',
'openerAudio',
'tools',
]);
const METADATA_FORBIDDEN_SECRET_TOKENS = ['apikey', 'token', 'secret', 'password', 'authorization'];
const isPlainObject = (value: unknown): value is Record<string, any> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const isForbiddenSecretKey = (key: string): boolean => {
const normalized = key.toLowerCase().replace(/[_-]/g, '');
return METADATA_FORBIDDEN_SECRET_TOKENS.some((token) => normalized.includes(token));
};
const stripForbiddenSecretKeysDeep = (value: any): any => {
if (Array.isArray(value)) return value.map(stripForbiddenSecretKeysDeep);
if (!isPlainObject(value)) return value;
return Object.entries(value).reduce<Record<string, any>>((acc, [key, nested]) => {
if (isForbiddenSecretKey(key)) return acc;
acc[key] = stripForbiddenSecretKeysDeep(nested);
return acc;
}, {});
};
const sanitizeMetadataForWs = (raw: unknown): Record<string, any> => {
if (!isPlainObject(raw)) return { overrides: {} };
const sanitized: Record<string, any> = { overrides: {} };
if (typeof raw.channel === 'string' && raw.channel.trim()) {
sanitized.channel = raw.channel.trim();
}
if (typeof raw.source === 'string' && raw.source.trim()) {
sanitized.source = raw.source.trim();
}
if (isPlainObject(raw.history) && raw.history.userId !== undefined) {
sanitized.history = { userId: raw.history.userId };
}
if (isPlainObject(raw.dynamicVariables)) {
sanitized.dynamicVariables = raw.dynamicVariables;
}
if (isPlainObject(raw.overrides)) {
const overrides = Object.entries(raw.overrides).reduce<Record<string, any>>((acc, [key, value]) => {
if (!METADATA_OVERRIDE_WHITELIST.has(key)) return acc;
if (isForbiddenSecretKey(key)) return acc;
acc[key] = stripForbiddenSecretKeysDeep(value);
return acc;
}, {});
sanitized.overrides = overrides;
}
return sanitized;
};
const formatRuntimeConfigDebugView = (args: {
submittedSessionStartMetadata?: Record<string, any> | null;
wsResolvedConfig?: Record<string, any> | null;
}): string => {
const hasWsResolved = Boolean(args.wsResolvedConfig && Object.keys(args.wsResolvedConfig).length > 0);
const payload: Record<string, any> = {
protocol: 'ws_v1',
wsConfigResolvedEvent: hasWsResolved ? 'received' : 'not_emitted',
note: hasWsResolved
? 'Optional ws config.resolved event received.'
: 'ws config.resolved is optional and disabled by default. Showing local session.start metadata preview.',
};
if (args.submittedSessionStartMetadata) {
payload.submittedSessionStartMetadata = args.submittedSessionStartMetadata;
}
if (hasWsResolved) {
payload.wsResolvedConfig = args.wsResolvedConfig;
}
return JSON.stringify(payload, null, 2);
};
const buildDynamicVariablesPayload = (): { variables: Record<string, string>; error?: string } => {
const variables: Record<string, string> = {};
const nonEmptyRows = dynamicVariables
.map((row, index) => ({ ...row, index, key: row.key.trim() }))
.filter((row) => row.key !== '' || row.value !== '');
if (nonEmptyRows.length > DYNAMIC_VARIABLE_MAX_ITEMS) {
return {
variables,
error: `Dynamic variable count cannot exceed ${DYNAMIC_VARIABLE_MAX_ITEMS}.`,
};
}
for (const row of nonEmptyRows) {
if (!row.key) {
return {
variables,
error: `Dynamic variable row ${row.index + 1} is missing key.`,
};
}
if (!DYNAMIC_VARIABLE_KEY_RE.test(row.key)) {
return {
variables,
error: `Invalid dynamic variable key "${row.key}". Use letters, digits, underscore and start with a letter/underscore.`,
};
}
if (SYSTEM_DYNAMIC_VARIABLE_KEY_SET.has(row.key)) {
continue;
}
if (row.value === '') {
return {
variables,
error: `Dynamic variable "${row.key}" is missing value.`,
};
}
if (row.value.length > DYNAMIC_VARIABLE_MAX_VALUE_LENGTH) {
return {
variables,
error: `Dynamic variable "${row.key}" exceeds ${DYNAMIC_VARIABLE_MAX_VALUE_LENGTH} characters.`,
};
}
if (Object.prototype.hasOwnProperty.call(variables, row.key)) {
return {
variables,
error: `Duplicate dynamic variable key "${row.key}".`,
};
}
variables[row.key] = row.value;
}
const missingTemplateKeys = requiredTemplateVariableKeys.filter((key) => {
if (SYSTEM_DYNAMIC_VARIABLE_KEY_SET.has(key)) return false;
return !Object.prototype.hasOwnProperty.call(variables, key);
});
if (missingTemplateKeys.length > 0) {
return {
variables,
error: `Missing required dynamic variables: ${missingTemplateKeys.join(', ')}`,
};
}
return { variables };
};
const buildLocalResolvedRuntime = () => {
const warnings: string[] = [];
const ttsEnabled = Boolean(textTtsEnabled);
const generatedOpenerEnabled = assistant.generatedOpenerEnabled === true;
const normalizedManualCalls = normalizeManualOpenerToolCallsForRuntime(assistant.manualOpenerToolCalls, { strictJson: true });
if (normalizedManualCalls.error) {
setDynamicVariablesError(normalizedManualCalls.error);
throw createDynamicVariablesError(normalizedManualCalls.error);
}
const knowledgeBaseId = String(assistant.knowledgeBaseId || '').trim();
const knowledge = knowledgeBaseId
? { enabled: true, kbId: knowledgeBaseId, nResults: 5 }
: { enabled: false };
const localResolved = {
warnings,
sessionStartMetadata: {
overrides: {
output: {
mode: ttsEnabled ? 'audio' : 'text',
},
systemPrompt: assistant.prompt || '',
firstTurnMode: assistant.firstTurnMode || 'bot_first',
greeting: generatedOpenerEnabled ? '' : (assistant.opener || ''),
generatedOpenerEnabled,
manualOpenerToolCalls: generatedOpenerEnabled ? [] : normalizedManualCalls.calls,
bargeIn: {
enabled: assistant.botCannotBeInterrupted !== true,
minDurationMs: Math.max(0, Number(assistant.interruptionSensitivity ?? 180)),
},
knowledgeBaseId,
knowledge,
tools: selectedToolSchemas,
},
history: {
userId: 1,
},
source: 'web_debug',
},
};
return localResolved;
};
const fetchRuntimeMetadata = async (): Promise<Record<string, any>> => {
const dynamicVariablesResult = buildDynamicVariablesPayload();
if (dynamicVariablesResult.error) {
setDynamicVariablesError(dynamicVariablesResult.error);
throw createDynamicVariablesError(dynamicVariablesResult.error);
}
setDynamicVariablesError('');
const localResolved = buildLocalResolvedRuntime();
const mergedMetadata: Record<string, any> = sanitizeMetadataForWs({
...localResolved.sessionStartMetadata,
...(sessionMetadataExtras || {}),
});
if (Object.keys(dynamicVariablesResult.variables).length > 0) {
mergedMetadata.dynamicVariables = dynamicVariablesResult.variables;
}
if (!mergedMetadata.channel) {
mergedMetadata.channel = 'web_debug';
}
submittedMetadataRef.current = mergedMetadata;
setResolvedConfigView(
formatRuntimeConfigDebugView({
submittedSessionStartMetadata: mergedMetadata,
})
);
return mergedMetadata;
};
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;
clearResponseTracking();
userDraftIndexRef.current = null;
lastUserFinalRef.current = '';
micFrameBufferRef.current = new Uint8Array(0);
stopPromptVoicePlayback();
promptDialogQueueRef.current = [];
setTextPromptDialog({ open: false, message: '', promptType: 'text' });
setChoicePromptDialog({ open: false, question: '', options: [] });
setTextSessionStarted(false);
stopPlaybackImmediately();
if (isOpen) setWsStatus('disconnected');
};
const buildSessionWsUrl = () => {
const base = wsUrl.trim();
if (!base) return '';
try {
const parsed = new URL(base);
parsed.searchParams.set('assistant_id', assistant.id);
return parsed.toString();
} catch {
try {
const parsed = new URL(base, window.location.href);
parsed.searchParams.set('assistant_id', assistant.id);
return parsed.toString();
} catch {
return base;
}
}
};
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();
const sessionWsUrl = buildSessionWsUrl();
setWsStatus('connecting');
setWsError('');
await new Promise<void>((resolve, reject) => {
pendingResolveRef.current = resolve;
pendingRejectRef.current = reject;
const ws = new WebSocket(sessionWsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
ws.send(
JSON.stringify({
type: 'session.start',
audio: { encoding: 'pcm_s16le', sample_rate_hz: 16000, channels: 1 },
metadata,
})
);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
void playPcm16Chunk(event.data);
return;
}
if (event.data instanceof Blob) {
void event.data.arrayBuffer().then((buf) => playPcm16Chunk(buf));
return;
}
if (typeof event.data !== 'string') return;
let payload: any;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
const type = payload?.type;
if (onProtocolEvent) {
onProtocolEvent(payload);
}
if (type === 'output.audio.start') {
// New utterance audio starts: cancel old queued/playing audio to avoid overlap.
stopPlaybackImmediately();
return;
}
if (type === 'response.interrupted') {
const interruptedResponseId = extractResponseId(payload);
if (interruptedResponseId) {
interruptedResponseIdsRef.current.add(interruptedResponseId);
if (interruptedResponseIdsRef.current.size > 64) {
const oldest = interruptedResponseIdsRef.current.values().next().value as string | undefined;
if (oldest) interruptedResponseIdsRef.current.delete(oldest);
}
}
assistantDraftIndexRef.current = null;
setIsLoading(false);
stopPlaybackImmediately();
return;
}
if (type === 'metrics.ttfb') {
const maybeTtfb = Number(payload?.latencyMs ?? payload?.data?.latencyMs);
if (!Number.isFinite(maybeTtfb) || maybeTtfb < 0) return;
const ttfbMs = Math.round(maybeTtfb);
const responseId = extractResponseId(payload);
if (responseId && interruptedResponseIdsRef.current.has(responseId)) {
noteInterruptedDrop(responseId, 'ttfb');
return;
}
if (responseId) {
const indexed = assistantResponseIndexByIdRef.current.get(responseId);
if (typeof indexed === 'number') {
setMessages((prev) => {
if (!prev[indexed] || prev[indexed].role !== 'model') return prev;
const next = [...prev];
next[indexed] = { ...next[indexed], ttfbMs };
return next;
});
} else {
pendingTtfbByResponseIdRef.current.set(responseId, ttfbMs);
}
return;
}
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i -= 1) {
if (prev[i]?.role === 'model') {
const next = [...prev];
next[i] = { ...next[i], ttfbMs };
return next;
}
}
return prev;
});
return;
}
if (type === 'assistant.tool_call') {
const toolCall = payload?.tool_call || {};
const toolCallId = String(payload?.tool_call_id || toolCall?.id || '').trim();
const toolName = normalizeToolId(toolCall?.function?.name || toolCall?.name || 'unknown_tool');
const toolDisplayName = String(payload?.tool_display_name || toolCall?.displayName || toolName);
const executor = String(toolCall?.executor || 'server').toLowerCase();
const rawArgs = String(toolCall?.function?.arguments || '');
const argText = rawArgs.length > 160 ? `${rawArgs.slice(0, 160)}...` : rawArgs;
setMessages((prev) => [
...prev,
{
role: 'tool',
text: `call ${toolDisplayName} executor=${executor}${argText ? ` args=${argText}` : ''}`,
},
]);
if (executor === 'client' && toolCallId && ws.readyState === WebSocket.OPEN) {
let parsedArgs: Record<string, any> = {};
if (rawArgs) {
try {
const candidate = JSON.parse(rawArgs);
parsedArgs = candidate && typeof candidate === 'object' ? candidate : {};
} catch {
parsedArgs = {};
}
}
const waitForResponseRaw = Boolean(
payload?.wait_for_response ?? toolCall?.wait_for_response ?? toolCall?.waitForResponse ?? false
);
const waitForResponse =
toolName === 'voice_choice_prompt' || toolName === 'text_choice_prompt' ? true : waitForResponseRaw;
const resultPayload: any = {
tool_call_id: toolCallId,
name: toolName,
output: { message: `Unhandled client tool '${toolName}'` },
status: { code: 501, message: 'not_implemented' },
};
if (DEBUG_CLIENT_TOOL_ID_SET.has(toolName) && !isClientToolEnabledLive(toolName)) {
resultPayload.output = { message: `Client tool '${toolName}' is disabled in debug settings` };
resultPayload.status = { code: 503, message: 'tool_disabled' };
}
try {
if (resultPayload.status.code === 503) {
// Keep disabled result as-is.
} else if (toolName === 'turn_on_camera') {
if (!waitForResponse) {
emitClientToolResult(
{
tool_call_id: toolCallId,
name: toolName,
output: { message: 'camera_on_dispatched' },
status: { code: 200, message: 'ok' },
},
toolDisplayName
);
}
navigator.mediaDevices
.getUserMedia({
video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true,
audio: false,
})
.then((stream) => {
if (videoRef.current) videoRef.current.srcObject = stream;
streamRef.current = stream;
if (waitForResponse) {
emitClientToolResult(
{
tool_call_id: toolCallId,
name: toolName,
output: {
message: 'camera_on',
tracks: stream.getVideoTracks().length,
},
status: { code: 200, message: 'ok' },
},
toolDisplayName
);
}
})
.catch((err) => {
if (waitForResponse) {
emitClientToolResult(
{
tool_call_id: toolCallId,
name: toolName,
output: {
message: `Client tool '${toolName}' failed`,
error: err instanceof Error ? err.message : String(err),
},
status: { code: 500, message: 'client_tool_failed' },
},
toolDisplayName
);
}
});
return;
} else if (toolName === 'turn_off_camera') {
stopMedia();
if (videoRef.current) videoRef.current.srcObject = null;
resultPayload.output = { message: 'camera_off' };
resultPayload.status = { code: 200, message: 'ok' };
} else if (toolName === 'increase_volume') {
const rawStep = Number(parsedArgs?.step);
const step = Number.isFinite(rawStep) ? Math.max(1, Math.floor(rawStep)) : 1;
debugVolumePercentRef.current = Math.min(100, debugVolumePercentRef.current + step);
resultPayload.output = {
message: 'volume_increased',
level: debugVolumePercentRef.current,
};
resultPayload.status = { code: 200, message: 'ok' };
} else if (toolName === 'decrease_volume') {
const rawStep = Number(parsedArgs?.step);
const step = Number.isFinite(rawStep) ? Math.max(1, Math.floor(rawStep)) : 1;
debugVolumePercentRef.current = Math.max(0, debugVolumePercentRef.current - step);
resultPayload.output = {
message: 'volume_decreased',
level: debugVolumePercentRef.current,
};
resultPayload.status = { code: 200, message: 'ok' };
} else if (toolName === 'voice_msg_prompt' || toolName === 'voice_message_prompt') {
const msg = String(parsedArgs?.msg || '').trim();
if (!msg) {
resultPayload.output = { message: "Missing required argument 'msg'" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else {
enqueuePromptDialog({
kind: 'text',
payload: {
message: msg,
promptType: 'voice',
voiceText: msg,
pendingResult: {
toolCallId: toolCallId,
toolName,
toolDisplayName,
waitForResponse,
},
},
});
if (!waitForResponse) {
resultPayload.output = { message: 'voice_prompt_shown', msg };
resultPayload.status = { code: 200, message: 'ok' };
} else {
return;
}
}
} else if (toolName === 'text_msg_prompt') {
const msg = String(parsedArgs?.msg || '').trim();
if (!msg) {
resultPayload.output = { message: "Missing required argument 'msg'" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else {
enqueuePromptDialog({
kind: 'text',
payload: {
message: msg,
pendingResult: {
toolCallId: toolCallId,
toolName,
toolDisplayName,
waitForResponse,
},
},
});
if (!waitForResponse) {
resultPayload.output = { message: 'text_prompt_shown', msg };
resultPayload.status = { code: 200, message: 'ok' };
} else {
return;
}
}
} else if (toolName === 'text_choice_prompt' || toolName === 'voice_choice_prompt') {
const question = String(parsedArgs?.question || '').trim();
const rawOptions = Array.isArray(parsedArgs?.options) ? parsedArgs.options : [];
const options = normalizeChoicePromptOptions(rawOptions);
const isVoiceChoicePrompt = toolName === 'voice_choice_prompt';
const voiceText = isVoiceChoicePrompt
? String(parsedArgs?.voice_text || parsedArgs?.voiceText || parsedArgs?.msg || question || '').trim()
: '';
const requireSelection = toolName === 'voice_choice_prompt' || toolName === 'text_choice_prompt';
if (!question) {
resultPayload.output = { message: "Missing required argument 'question'" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else if (options.length < 2) {
resultPayload.output = { message: "Argument 'options' requires at least 2 valid entries" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else {
enqueuePromptDialog({
kind: 'choice',
payload: {
question,
options,
pendingResult: {
toolCallId: toolCallId,
toolName,
toolDisplayName,
waitForResponse,
},
requireSelection,
voiceText,
},
});
if (!waitForResponse && !requireSelection) {
resultPayload.output = {
message: `${toolName}_shown`,
question,
options,
};
resultPayload.status = { code: 200, message: 'ok' };
} else {
return;
}
}
}
} catch (err) {
resultPayload.output = {
message: `Client tool '${toolName}' failed`,
error: err instanceof Error ? err.message : String(err),
};
resultPayload.status = { code: 500, message: 'client_tool_failed' };
}
emitClientToolResult(resultPayload, toolDisplayName);
}
return;
}
if (type === 'assistant.tool_result') {
const result = payload?.result || {};
const toolName = normalizeToolId(result?.name || 'unknown_tool');
const toolDisplayName = String(payload?.tool_display_name || toolName);
const statusCode = Number(result?.status?.code || 500);
const statusMessage = String(result?.status?.message || 'error');
const source = String(payload?.source || 'server');
const output = result?.output;
const resultText =
statusCode === 200
? `result ${toolDisplayName} source=${source} ${JSON.stringify(output)}`
: `result ${toolDisplayName} source=${source} status=${statusCode} ${statusMessage}`;
setMessages((prev) => [...prev, { role: 'tool', text: resultText }]);
return;
}
if (type === 'session.started') {
wsReadyRef.current = true;
setWsStatus('ready');
pendingResolveRef.current?.();
pendingResolveRef.current = null;
pendingRejectRef.current = null;
return;
}
if (type === 'config.resolved') {
const resolved = payload?.config || payload?.data?.config;
if (resolved) {
setResolvedConfigView(
formatRuntimeConfigDebugView({
submittedSessionStartMetadata: submittedMetadataRef.current,
wsResolvedConfig: resolved,
})
);
}
return;
}
if (type === 'input.speech_started') {
setIsLoading(true);
return;
}
if (type === 'input.speech_stopped') {
setIsLoading(false);
return;
}
if (type === 'transcript.delta') {
const delta = String(payload.text || '');
if (!delta) return;
setMessages((prev) => {
const idx = userDraftIndexRef.current;
if (idx === null || !prev[idx] || prev[idx].role !== 'user') {
const next = [...prev, { role: 'user' as const, text: delta }];
userDraftIndexRef.current = next.length - 1;
return next;
}
const next = [...prev];
// ASR interim is typically the latest partial text, not a true text delta.
next[idx] = { ...next[idx], text: delta };
return next;
});
return;
}
if (type === 'transcript.final') {
const finalText = String(payload.text || '');
if (!finalText) {
userDraftIndexRef.current = null;
return;
}
if (lastUserFinalRef.current === finalText) {
userDraftIndexRef.current = null;
return;
}
setMessages((prev) => {
const idx = userDraftIndexRef.current;
userDraftIndexRef.current = null;
if (idx !== null && prev[idx] && prev[idx].role === 'user') {
const next = [...prev];
next[idx] = { ...next[idx], text: finalText || next[idx].text };
lastUserFinalRef.current = finalText;
return next;
}
const last = prev[prev.length - 1];
if (last?.role === 'user') {
const next = [...prev];
next[next.length - 1] = { ...last, text: finalText };
lastUserFinalRef.current = finalText;
return next;
}
lastUserFinalRef.current = finalText;
return [...prev, { role: 'user', text: finalText }];
});
return;
}
if (type === 'assistant.response.delta') {
const delta = String(payload.text || '');
if (!delta) return;
const responseId = extractResponseId(payload);
if (responseId && interruptedResponseIdsRef.current.has(responseId)) {
noteInterruptedDrop(responseId, 'delta');
return;
}
setMessages((prev) => {
let idx = assistantDraftIndexRef.current;
if (idx === null || !prev[idx] || prev[idx].role !== 'model') {
// Tool records can be appended between assistant chunks; recover the
// latest model row instead of creating a duplicate assistant row.
for (let i = prev.length - 1; i >= 0; i -= 1) {
if (prev[i]?.role === 'model') {
if (
responseId
&& prev[i].responseId
&& prev[i].responseId !== responseId
) {
break;
}
idx = i;
assistantDraftIndexRef.current = i;
break;
}
if (prev[i]?.role === 'user') break;
}
}
if (idx === null || !prev[idx] || prev[idx].role !== 'model') {
const last = prev[prev.length - 1];
if (last?.role === 'model' && last.text === delta) {
return prev;
}
const nextMessage: DebugTranscriptMessage = { role: 'model' as const, text: delta };
if (responseId) {
nextMessage.responseId = responseId;
if (pendingTtfbByResponseIdRef.current.has(responseId)) {
nextMessage.ttfbMs = pendingTtfbByResponseIdRef.current.get(responseId);
pendingTtfbByResponseIdRef.current.delete(responseId);
}
}
const next = [...prev, nextMessage];
assistantDraftIndexRef.current = next.length - 1;
if (responseId) {
assistantResponseIndexByIdRef.current.set(responseId, next.length - 1);
}
return next;
}
const next = [...prev];
const nextMessage = { ...next[idx], text: next[idx].text + delta };
if (responseId && !nextMessage.responseId) {
nextMessage.responseId = responseId;
}
if (
responseId
&& typeof nextMessage.ttfbMs !== 'number'
&& pendingTtfbByResponseIdRef.current.has(responseId)
) {
nextMessage.ttfbMs = pendingTtfbByResponseIdRef.current.get(responseId);
pendingTtfbByResponseIdRef.current.delete(responseId);
}
next[idx] = nextMessage;
if (responseId) {
assistantResponseIndexByIdRef.current.set(responseId, idx);
}
return next;
});
return;
}
if (type === 'assistant.response.final') {
const finalText = String(payload.text || '');
const responseId = extractResponseId(payload);
if (responseId && interruptedResponseIdsRef.current.has(responseId)) {
noteInterruptedDrop(responseId, 'final');
return;
}
setMessages((prev) => {
let idx = assistantDraftIndexRef.current;
assistantDraftIndexRef.current = null;
if (idx === null || !prev[idx] || prev[idx].role !== 'model') {
for (let i = prev.length - 1; i >= 0; i -= 1) {
if (prev[i]?.role === 'model') {
if (
responseId
&& prev[i].responseId
&& prev[i].responseId !== responseId
) {
break;
}
idx = i;
break;
}
if (prev[i]?.role === 'user') break;
}
}
if (idx !== null && prev[idx] && prev[idx].role === 'model') {
const next = [...prev];
const nextMessage = { ...next[idx], text: finalText || next[idx].text };
if (responseId && !nextMessage.responseId) {
nextMessage.responseId = responseId;
}
if (
responseId
&& typeof nextMessage.ttfbMs !== 'number'
&& pendingTtfbByResponseIdRef.current.has(responseId)
) {
nextMessage.ttfbMs = pendingTtfbByResponseIdRef.current.get(responseId);
pendingTtfbByResponseIdRef.current.delete(responseId);
}
next[idx] = nextMessage;
if (responseId) {
assistantResponseIndexByIdRef.current.set(responseId, idx);
}
return next;
}
if (!finalText) return prev;
const last = prev[prev.length - 1];
if (last?.role === 'model') {
if (last.text === finalText) return prev;
if (finalText.startsWith(last.text) || last.text.startsWith(finalText)) {
const next = [...prev];
next[next.length - 1] = { ...last, text: finalText };
return next;
}
}
const nextMessage: DebugTranscriptMessage = { role: 'model', text: finalText };
if (responseId) {
nextMessage.responseId = responseId;
if (pendingTtfbByResponseIdRef.current.has(responseId)) {
nextMessage.ttfbMs = pendingTtfbByResponseIdRef.current.get(responseId);
pendingTtfbByResponseIdRef.current.delete(responseId);
}
}
const next = [...prev, nextMessage];
if (responseId) {
assistantResponseIndexByIdRef.current.set(responseId, next.length - 1);
}
return next;
});
setIsLoading(false);
return;
}
if (type === 'error') {
const message = String(payload?.message || payload?.data?.error?.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;
setTextSessionStarted(false);
userDraftIndexRef.current = null;
stopPlaybackImmediately();
if (wsStatusRef.current !== 'error') setWsStatus('disconnected');
};
});
};
useEffect(() => {
if (!isOpen) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
// If core TTS-related settings changed while drawer stays open,
// reset the active WS session so the next launch uses new metadata.
closeWs();
}, [isOpen, assistant.id, assistant.voice, assistant.speed]);
useEffect(() => {
if (!textTtsEnabled) {
stopPlaybackImmediately();
}
}, [textTtsEnabled]);
useEffect(() => {
if (!isOpen || mode !== 'voice' || callStatus !== 'active') return;
const restartCapture = async () => {
try {
await startVoiceCapture();
} catch (e) {
console.error('Failed to restart voice capture with new 3A settings', e);
}
};
void restartCapture();
}, [aecEnabled, nsEnabled, agcEnabled, selectedMic, mode, callStatus, isOpen]);
useEffect(() => {
if (!isOpen) return;
setResolvedConfigView(
formatRuntimeConfigDebugView({
submittedSessionStartMetadata: submittedMetadataRef.current,
})
);
}, [isOpen, assistant, voices, llmModels, asrModels, tools]);
const renderLocalVideo = (isSmall: boolean) => (
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover transform scale-x-[-1]" />
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">Me</div>
</div>
);
const renderRemoteVideo = (isSmall: boolean) => (
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
<div className="relative flex items-center justify-center">
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
</div>
</div>
{!isSmall && <div className="mt-4 font-mono text-primary animate-pulse text-sm">{assistant.name}</div>}
</div>
);
const settingsPanel = (
<div className="h-full border-r border-white/10 bg-background/95 backdrop-blur-md shadow-2xl p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-black tracking-[0.15em] uppercase text-muted-foreground">调试设置</h3>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setSettingsDrawerOpen(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase block">WebSocket Endpoint</label>
<Input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://localhost:8000/ws" />
</div>
<div className="flex items-center justify-between gap-2">
<Badge variant="outline" className="text-xs">WS: {wsStatus}</Badge>
<Badge variant={textTtsEnabled ? 'outline' : 'secondary'} className="text-xs">
TTS: {textTtsEnabled ? 'ON' : 'OFF'}
</Badge>
</div>
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Audio 3A</p>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={aecEnabled} onChange={(e) => setAecEnabled(e.target.checked)} className="accent-primary" />
Echo Cancellation (AEC)
</label>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={nsEnabled} onChange={(e) => setNsEnabled(e.target.checked)} className="accent-primary" />
Noise Suppression (NS)
</label>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={agcEnabled} onChange={(e) => setAgcEnabled(e.target.checked)} className="accent-primary" />
Auto Gain Control (AGC)
</label>
</div>
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Client Tools</p>
<p className="text-[11px] text-muted-foreground">滑块控制调试会话内客户端工具是否启用。</p>
<div className="space-y-2">
{DEBUG_CLIENT_TOOLS.map((tool) => {
const enabled = isClientToolEnabled(tool.id);
return (
<div key={tool.id} className="flex items-center justify-between gap-3 rounded-md border border-white/10 bg-black/20 px-2 py-1.5">
<div className="min-w-0">
<div className="text-[11px] font-mono text-foreground truncate">{tool.name}</div>
<div className="text-[10px] text-muted-foreground truncate">{tool.description}</div>
</div>
<Switch
checked={enabled}
onCheckedChange={(next) => setClientToolEnabled(tool.id, next)}
title={enabled ? '' : ''}
aria-label={`${tool.name} ${enabled ? '' : ''}`}
/>
</div>
);
})}
</div>
</div>
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Dynamic Variables</p>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
onClick={importDynamicVariablesFromPlaceholders}
disabled={isDynamicVariablesLocked || requiredTemplateVariableKeys.length === 0 || dynamicVariables.length >= DYNAMIC_VARIABLE_MAX_ITEMS}
title={`Import keys from {{placeholder}} in prompt${assistant.generatedOpenerEnabled === true ? '' : '/opener'}`}
>
Import
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={addDynamicVariableRow}
disabled={isDynamicVariablesLocked || dynamicVariables.length >= DYNAMIC_VARIABLE_MAX_ITEMS}
title={isDynamicVariablesLocked ? 'Disable editing while session is active' : 'Add variable'}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
Use placeholders like {'{{customer_name}}'} in prompt{assistant.generatedOpenerEnabled === true ? '' : '/opener'}.
</p>
<p className="text-[11px] text-muted-foreground">
Built-in system vars: {'{{system__time}}'}, {'{{system_utc}}'}, {'{{system_timezone}}'}.
</p>
{requiredTemplateVariableKeys.length > 0 && (
<p className="text-[11px] text-amber-300/90">
Required: {requiredTemplateVariableKeys.join(', ')}
</p>
)}
{missingRequiredDynamicVariableKeys.length > 0 && (
<p className="text-[11px] text-red-300/90">
Missing required dynamic variable: {missingRequiredDynamicVariableKeys.join(', ')}
</p>
)}
{dynamicVariablesError && (
<p className="text-[11px] text-red-300/90">
{dynamicVariablesError}
</p>
)}
{dynamicVariables.length === 0 ? (
<div className="text-[11px] text-muted-foreground/80 border border-dashed border-white/10 rounded-md px-2 py-2">
No variables added.
</div>
) : (
<div className="space-y-2 max-h-52 overflow-auto pr-1">
{dynamicVariables.map((row, index) => (
<div key={row.id} className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
<Input
value={row.key}
onChange={(e) => updateDynamicVariableRow(row.id, 'key', e.target.value)}
placeholder={`key_${index + 1}`}
disabled={isDynamicVariablesLocked}
className="h-8 text-xs"
/>
<Input
value={row.value}
onChange={(e) => updateDynamicVariableRow(row.id, 'value', e.target.value)}
placeholder="value"
disabled={isDynamicVariablesLocked}
className="h-8 text-xs"
/>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-red-300"
onClick={() => removeDynamicVariableRow(row.id)}
disabled={isDynamicVariablesLocked}
title="Remove variable"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{isDynamicVariablesLocked && (
<p className="text-[11px] text-muted-foreground">
Editing is locked while conversation is starting/active.
</p>
)}
</div>
<div className="rounded-md border border-white/10 bg-black/30">
<button
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground flex items-center justify-between"
onClick={() => setCaptureConfigOpen((v) => !v)}
>
<span>Capture Config Echo</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${captureConfigOpen ? 'rotate-180' : ''}`} />
</button>
{captureConfigOpen && (
<pre className="px-3 pb-3 text-[11px] leading-5 text-cyan-100/90 whitespace-pre-wrap break-all max-h-64 overflow-auto">
{captureConfigView || 'Voice call not started yet.'}
</pre>
)}
</div>
{wsError && <p className="text-xs text-red-400">{wsError}</p>}
<div className="rounded-md border border-white/10 bg-black/30">
<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>Session Config Debug</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-64 overflow-auto">
{resolvedConfigView || 'Preparing session.start metadata preview...'}
</pre>
)}
</div>
</div>
</div>
);
return (
<>
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`}>
<div className="relative flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex items-center gap-2 mb-4 shrink-0">
<div className="flex-1 flex justify-center bg-white/5 p-1 rounded-lg">
{(['text', 'voice', 'video'] as const).map(m => (
<button key={m} className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === m ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`} onClick={() => setMode(m)}>
{m === 'text' && <MessageSquare className="inline w-4 h-4 mr-1" />}
{m === 'voice' && <Mic className="inline w-4 h-4 mr-1" />}
{m === 'video' && <Video className="inline w-4 h-4 mr-1" />}
{m === 'text' ? '' : m === 'voice' ? '' : ''}
</button>
))}
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${mode === 'text' && textSessionStarted ? 'mb-0' : 'mb-4'}`}>
{mode === 'text' ? (
textSessionStarted ? (
<div className="flex-1 min-h-0 overflow-hidden animate-in fade-in flex flex-col">
<div className="h-[68vh] min-h-[420px] max-h-[68vh] w-full flex flex-col min-h-0 overflow-hidden">
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 h-full" />
</div>
</div>
) : wsStatus === 'connecting' ? (
<div className="flex-1 flex flex-col items-center justify-center space-y-6">
<div className="h-24 w-24 rounded-full bg-primary/20 flex items-center justify-center animate-bounce">
<MessageSquare className="h-10 w-10 text-primary" />
</div>
<div className="text-center">
<p className="text-primary font-mono text-sm tracking-widest animate-pulse">CONNECTING...</p>
<p className="text-xs text-muted-foreground mt-2">正在连接文本调试服务</p>
</div>
<Button onClick={closeWs} variant="destructive" className="rounded-full h-10 px-8">取消</Button>
</div>
) : (
<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 px-6">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
<div className="relative h-24 w-24 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
<MessageSquare className="h-10 w-10 text-muted-foreground" />
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-bold text-white mb-1">准备就绪</h3>
<p className="text-xs text-muted-foreground">点击下方按钮开启人机交互测试</p>
</div>
<Button
onClick={handleTextLaunch}
disabled={wsStatus === 'connecting'}
className="w-48 h-12 rounded-full bg-green-500 hover:bg-green-600 shadow-[0_0_20px_rgba(34,197,94,0.4)] text-base font-bold"
>
<PhoneCall className="mr-2 h-5 w-5" /> 发起呼叫
</Button>
</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">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
<div className="relative h-24 w-24 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
{mode === 'voice' ? <Mic className="h-10 w-10 text-muted-foreground" /> : <Video className="h-10 w-10 text-muted-foreground" />}
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-bold text-white mb-1">准备就绪</h3>
<p className="text-xs text-muted-foreground">点击下方按钮开启人机交互测试</p>
</div>
<Button onClick={handleCall} className="w-48 h-12 rounded-full bg-green-500 hover:bg-green-600 shadow-[0_0_20px_rgba(34,197,94,0.4)] text-base font-bold">
<PhoneCall className="mr-2 h-5 w-5" /> 发起呼叫
</Button>
</div>
) : callStatus === 'calling' ? (
mode === 'voice' ? (
<div className="flex-1 flex flex-col items-center justify-center space-y-6">
<div className="h-24 w-24 rounded-full bg-primary/20 flex items-center justify-center animate-bounce">
<Mic className="h-10 w-10 text-primary" />
</div>
<div className="text-center">
<p className="text-primary font-mono text-sm tracking-widest animate-pulse">CONNECTING...</p>
<p className="text-xs text-muted-foreground mt-2">正在连接语音调试服务</p>
</div>
<Button onClick={handleHangup} variant="destructive" className="rounded-full h-10 px-8">取消</Button>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center space-y-6">
<div className="h-24 w-24 rounded-full bg-primary/20 flex items-center justify-center animate-bounce">
<PhoneCall className="h-10 w-10 text-primary" />
</div>
<div className="text-center">
<p className="text-primary font-mono text-sm tracking-widest animate-pulse">CALLING...</p>
<p className="text-xs text-muted-foreground mt-2">正在连接 AI 服务</p>
</div>
<Button onClick={handleHangup} variant="destructive" className="rounded-full h-10 px-8">取消</Button>
</div>
)
) : (
<div className="flex-1 flex flex-col min-h-0 space-y-2">
{mode === 'voice' ? (
<div className="h-[68vh] min-h-[420px] max-h-[68vh] w-full flex flex-col min-h-0 overflow-hidden animate-in fade-in">
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 h-full" />
</div>
) : (
<div className="flex flex-col h-full min-h-0 space-y-2 animate-in fade-in">
<div className="h-3/5 shrink-0 flex flex-col gap-2">
<div className="flex gap-2 shrink-0">
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-2 py-1 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card [&>option]:text-foreground" value={selectedCamera} onChange={e => setSelectedCamera(e.target.value)}>
{devices.filter(d => d.kind === 'videoinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Camera'}</option>)}
</select>
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-2 py-1 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card [&>option]:text-foreground" value={selectedMic} onChange={e => setSelectedMic(e.target.value)}>
{devices.filter(d => d.kind === 'audioinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Mic'}</option>)}
</select>
</div>
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
<div className="absolute inset-0">{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}</div>
<div className="absolute bottom-2 right-2 w-24 h-36 z-10">{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}</div>
<button className="absolute top-2 right-2 z-20 h-8 w-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center text-white border border-white/10 hover:bg-primary/80" onClick={() => setIsSwapped(!isSwapped)}><ArrowLeftRight className="h-3.5 w-3.5" /></button>
</div>
</div>
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 overflow-y-auto" />
</div>
)}
</div>
)}
</div>
<div className={mode === 'text' && textSessionStarted ? 'shrink-0 mt-3 px-1 mb-3' : mode === 'voice' ? 'shrink-0 space-y-3 mt-3 px-1 mb-3' : 'shrink-0 space-y-2 mt-2 px-1 mb-3'}>
{mode === 'voice' && (
<div className="w-full flex items-center gap-2 pb-1">
<span className="text-xs text-muted-foreground shrink-0">麦克风</span>
<select
className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-2 py-1 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card [&>option]:text-foreground"
value={selectedMic}
onChange={(e) => setSelectedMic(e.target.value)}
>
{devices.filter(d => d.kind === 'audioinput').map(d => (
<option key={d.deviceId} value={d.deviceId}>{d.label || 'Mic'}</option>
))}
</select>
</div>
)}
<div className="w-full flex items-center gap-2 min-w-0">
{mode === 'text' && textSessionStarted && (
<Button
variant="destructive"
size="sm"
className="h-9 px-3 font-bold shrink-0 whitespace-nowrap"
onClick={closeWs}
>
<PhoneOff className="h-4 w-4" />
<span className="ml-1.5">结束测试</span>
</Button>
)}
{mode === 'voice' && callStatus === 'active' && (
<Button
variant="destructive"
size="sm"
className="h-9 px-3 font-bold shrink-0 whitespace-nowrap"
onClick={handleHangup}
>
<PhoneOff className="h-4 w-4" />
<span className="ml-1.5">结束通话</span>
</Button>
)}
<Input
value={inputText}
onChange={e => setInputText(e.target.value)}
placeholder={mode === 'text' && !textSessionStarted ? "请先发起呼叫后输入消息..." : (mode === 'text' ? "输入消息..." : "输入文本模拟交互...")}
onKeyDown={e => e.key === 'Enter' && handleSend()}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
className="flex-1 min-w-0"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleSend}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{textPromptDialog.open && (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/55 backdrop-blur-[1px]">
<div className="relative w-[92%] max-w-md rounded-xl border border-white/15 bg-card/95 p-4 shadow-2xl animate-in zoom-in-95 duration-200">
<button
type="button"
onClick={() => closeTextPromptDialog('dismiss')}
className="absolute right-3 top-3 rounded-sm opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
title="关闭"
>
<X className="h-4 w-4" />
</button>
<div className="mb-3 pr-6">
<div className="text-[10px] font-black tracking-[0.14em] uppercase text-amber-300">
{textPromptDialog.promptType === 'voice' ? '' : ''}
</div>
<p className="mt-2 text-sm leading-6 text-foreground whitespace-pre-wrap break-words">{textPromptDialog.message}</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => closeTextPromptDialog('confirm')}>
确认
</Button>
</div>
</div>
</div>
)}
{choicePromptDialog.open && (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/55 backdrop-blur-[1px]">
<div className="relative w-[92%] max-w-md rounded-xl border border-white/15 bg-card/95 p-4 shadow-2xl animate-in zoom-in-95 duration-200">
{!choicePromptDialog.requireSelection && (
<button
type="button"
onClick={() => closeChoicePromptDialog('dismiss')}
className="absolute right-3 top-3 rounded-sm opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
title="关闭"
>
<X className="h-4 w-4" />
</button>
)}
<div className="mb-3 pr-6">
<div className="text-[10px] font-black tracking-[0.14em] uppercase text-cyan-300">
{choicePromptDialog.requireSelection
? (choicePromptDialog.voiceText ? '' : '')
: ''}
</div>
<p className="mt-2 text-sm leading-6 text-foreground whitespace-pre-wrap break-words">{choicePromptDialog.question}</p>
{choicePromptDialog.requireSelection && (
<p className="mt-1 text-[11px] text-cyan-200/80">请点击一个选项继续。</p>
)}
</div>
<div className="space-y-2">
{choicePromptDialog.options.map((option) => (
<Button
key={option.id}
variant="outline"
className="w-full justify-start text-left h-auto py-2.5 px-3"
onClick={() => closeChoicePromptDialog('select', option)}
>
{option.label}
</Button>
))}
</div>
{!choicePromptDialog.requireSelection && (
<div className="mt-3 flex justify-end">
<Button size="sm" variant="ghost" onClick={() => closeChoicePromptDialog('dismiss')}>
跳过
</Button>
</div>
)}
</div>
</div>
)}
</div>
</Drawer>
{isOpen && (
<div className="fixed inset-y-0 z-[51] right-[min(100vw,32rem)]">
<button
className={`absolute inset-y-0 w-10 border-r border-white/10 bg-background/90 backdrop-blur-md text-muted-foreground hover:text-foreground hover:bg-background/95 transition-[right,color,background-color] duration-300 flex flex-col items-center justify-center gap-2 ${settingsDrawerOpen ? 'right-[min(78vw,28rem)]' : 'right-0'}`}
onClick={() => setSettingsDrawerOpen((v) => !v)}
title={settingsDrawerOpen ? '' : ''}
>
<span className={`text-[10px] font-mono tracking-tight ${settingsDrawerOpen ? 'text-primary animate-pulse' : 'opacity-90 animate-pulse'}`}>
{settingsDrawerOpen ? '>>' : '<<'}
</span>
<Wrench className="h-4 w-4" />
<span className="text-[10px] tracking-widest [writing-mode:vertical-rl]">调试设置</span>
</button>
<div className="absolute inset-y-0 right-0 w-[78vw] max-w-md overflow-hidden pointer-events-none">
<div className={`h-full transition-transform duration-300 ease-out will-change-transform ${settingsDrawerOpen ? 'translate-x-0 pointer-events-auto' : 'translate-x-full pointer-events-none'}`}>
{settingsPanel}
</div>
</div>
</div>
)}
</>
);
};