Files
AI-VideoAssistant/web/pages/Assistants.tsx

4746 lines
223 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: any) => tool.id === normalizeToolId(call.toolName || '')) && (
<option value={normalizeToolId(call.toolName || '')}>
{`${normalizeToolId(call.toolName || '')} (未启用/不存在)`}
</option>
)}
{openerToolOptions.map((tool: any) => (
<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>
))}
</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 [agentState, setAgentState] = useState<'waiting' | 'listening' | 'thinking' | 'speaking'>('waiting');
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: any) => {
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');
setAgentState('waiting');
}
} 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');
setAgentState('waiting');
}
}, [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');
setAgentState('waiting');
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');
setAgentState('waiting');
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();
setAgentState('waiting');
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();
setAgentState('speaking');
return;
}
if (type === 'output.audio.end') {
setAgentState('waiting');
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();
setAgentState('waiting');
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');
setAgentState('waiting');
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);
setAgentState('listening');
return;
}
if (type === 'input.speech_stopped') {
setIsLoading(false);
setAgentState('thinking');
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}`} className="w-[90vw] sm:w-[85vw] max-w-none">
<div className="relative flex h-full min-h-0 overflow-hidden gap-6">
{/* Left Column: Call Interface */}
<div className="flex-1 flex flex-col 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>
<Button
variant="outline"
size="icon"
className="h-[36px] w-[36px] shrink-0 bg-white/5 border-white/10 hover:bg-white/10 hover:text-primary transition-colors"
onClick={() => setSettingsDrawerOpen(true)}
title="调试设置"
>
<Wrench className="h-4 w-4" />
</Button>
</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 mb-4`}>
{mode === 'text' ? (
textSessionStarted ? (
<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-primary" />
</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>
</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-4">
{mode === 'voice' ? (
<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 px-6 relative overflow-hidden">
<div className="absolute inset-0 bg-primary/5 rounded-full blur-3xl animate-pulse"></div>
<div className="relative z-10">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
<div className="relative h-32 w-32 rounded-full bg-card border border-white/10 flex items-center justify-center shadow-2xl">
<Mic className="h-12 w-12 text-primary" />
</div>
</div>
</div>
<div className="text-center z-10">
<h3 className="text-xl font-bold text-white mb-2 tracking-tight">
{agentState === 'listening' ? '正在倾听...' :
agentState === 'thinking' ? '思考中...' :
agentState === 'speaking' ? '正在回复...' :
'待机中'}
</h3>
<div className="flex items-center justify-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${
agentState === 'listening' ? 'bg-blue-400' :
agentState === 'thinking' ? 'bg-yellow-400' :
agentState === 'speaking' ? 'bg-green-400' :
'bg-gray-400'
}`}></span>
<span className={`relative inline-flex rounded-full h-2.5 w-2.5 ${
agentState === 'listening' ? 'bg-blue-500' :
agentState === 'thinking' ? 'bg-yellow-500' :
agentState === 'speaking' ? 'bg-green-500' :
'bg-gray-500'
}`}></span>
</span>
<p className={`text-sm font-medium ${
agentState === 'listening' ? 'text-blue-400' :
agentState === 'thinking' ? 'text-yellow-400' :
agentState === 'speaking' ? 'text-green-400' :
'text-gray-400'
}`}>
{agentState === 'listening' ? 'Listening' :
agentState === 'thinking' ? 'Thinking' :
agentState === 'speaking' ? 'Speaking' :
'Waiting'}
</p>
</div>
</div>
</div>
) : (
<div className="flex flex-col h-full min-h-0 space-y-3 animate-in fade-in">
<div className="flex gap-2 shrink-0">
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-3 py-2 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 transition-colors hover:bg-white/10 [&>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-3 py-2 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 transition-colors hover:bg-white/10 [&>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 flex items-center justify-center relative rounded-xl overflow-hidden border border-white/10 bg-black min-h-0 shadow-inner">
<div className="absolute inset-0 object-cover">{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}</div>
<div className="absolute bottom-4 right-4 w-32 h-44 z-10 rounded-lg overflow-hidden border border-white/20 shadow-2xl transition-transform hover:scale-[1.02]">{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}</div>
<button className="absolute top-4 right-4 z-20 h-10 w-10 rounded-full bg-black/50 backdrop-blur-md flex items-center justify-center text-white border border-white/10 hover:bg-primary/80 transition-all hover:scale-110 shadow-lg" onClick={() => setIsSwapped(!isSwapped)} title="切换视图"><ArrowLeftRight className="h-4 w-4" /></button>
</div>
</div>
)}
</div>
)}
</div>
{/* Hangup / Mic Select area (Left Column Bottom) */}
<div className={'shrink-0 space-y-3 mt-auto px-1 mb-1'}>
{mode === 'voice' && callStatus === 'active' && (
<div className="w-full flex items-center gap-2 pb-2">
<span className="text-xs font-medium text-muted-foreground shrink-0 uppercase tracking-wider"></span>
<select
className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 transition-colors hover:bg-white/10 [&>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 justify-center items-center">
{mode === 'text' && textSessionStarted && (
<Button
variant="destructive"
size="lg"
className="w-full font-bold shadow-lg shadow-destructive/20 hover:shadow-destructive/40 transition-all"
onClick={closeWs}
>
<PhoneOff className="h-5 w-5 mr-2" />
</Button>
)}
{mode !== 'text' && callStatus === 'active' && (
<Button
variant="destructive"
size="lg"
className="w-full font-bold shadow-lg shadow-destructive/20 hover:shadow-destructive/40 transition-all"
onClick={handleHangup}
>
<PhoneOff className="h-5 w-5 mr-2" />
</Button>
)}
</div>
</div>
</div>
</div>
{/* Right Column: Transcript */}
<div className="w-[380px] lg:w-[450px] shrink-0 border-l border-white/10 pl-6 flex flex-col min-h-0 h-full">
<div className="flex items-center gap-2 mb-4 shrink-0 px-1">
<MessageSquare className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-foreground tracking-tight">Transcript</h3>
</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col bg-card/30 rounded-xl border border-white/5 p-2 mb-4">
<div className="flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
{(messages.length === 0 && !isLoading) ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/60 space-y-3">
<MessageSquare className="h-8 w-8 opacity-20" />
<p className="text-xs"></p>
</div>
) : (
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="pb-4" />
)}
</div>
</div>
{/* Input bar */}
<div className="shrink-0 flex items-center gap-2 mt-auto p-1 bg-card/50 rounded-lg border border-white/10 shadow-sm focus-within:border-primary/40 focus-within:ring-1 focus-within:ring-primary/20 transition-all">
<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 border-0 bg-transparent focus-visible:ring-0 shadow-none px-3"
/>
<Button
size="icon"
className="h-8 w-8 shrink-0 rounded-md bg-primary/90 hover:bg-primary shadow-sm mr-1"
onClick={handleSend}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
>
<Send className="h-3.5 w-3.5" />
</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: any) => (
<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>
)}
</Drawer>
{isOpen && (
<Dialog
isOpen={settingsDrawerOpen}
onClose={() => setSettingsDrawerOpen(false)}
title="调试设置"
contentClassName="max-w-[90vw] md:max-w-[70vw] lg:max-w-2xl h-[85vh] flex flex-col"
>
<div className="flex-1 min-h-0 overflow-y-auto">
{settingsPanel}
</div>
</Dialog>
)}
</>
);
};