4746 lines
223 KiB
TypeScript
4746 lines
223 KiB
TypeScript
|
||
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>
|
||
)}
|
||
</>
|
||
);
|
||
};
|