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 = { Camera: , CameraOff: , Image: , Images: , CloudSun: , Calendar: , Phone: , Volume2: , TrendingUp: , Coins: , Terminal: , Globe: , Wrench: , }; return map[icon] || ; }; const TOOL_ID_ALIASES: Record = { 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> = { 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 }>; error?: string } => { const strictJson = options?.strictJson === true; const normalized: Array<{ toolName: string; arguments: Record }> = []; 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 = {}; if (argsRaw && typeof argsRaw === 'object' && !Array.isArray(argsRaw)) { args = argsRaw as Record; } 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; } 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([]); const [voices, setVoices] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); const [llmModels, setLlmModels] = useState([]); const [asrModels, setAsrModels] = useState([]); const [tools, setTools] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [selectedId, setSelectedId] = useState(null); const [activeTab, setActiveTab] = useState(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(null); const [copySuccess, setCopySuccess] = useState(false); const [saveLoading, setSaveLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); const [templateSuggestion, setTemplateSuggestion] = useState(null); const [persistedAssistantSnapshotById, setPersistedAssistantSnapshotById] = useState>({}); const [unsavedDebugConfirmOpen, setUnsavedDebugConfirmOpen] = useState(false); const [openerAudioGenerating, setOpenerAudioGenerating] = useState(false); const [openerAudioPreviewing, setOpenerAudioPreviewing] = useState(false); const openerPreviewAudioCtxRef = useRef(null); const openerPreviewSourceRef = useRef(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>((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 = { 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) => { 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 (
{/* LEFT COLUMN: List */}

小助手列表

setSearchTerm(e.target.value)} />
{!isLoading && filteredAssistants.map(assistant => (
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' }`} >
{assistant.name} {assistant.configMode && assistant.configMode !== 'none' && (
{assistant.configMode === 'platform' ? '内置' : assistant.configMode}
)}
{assistant.callCount} 次通话
))} {!isLoading && filteredAssistants.length === 0 && (
未找到小助手
)} {isLoading && (
加载中...
)}
{/* RIGHT COLUMN: Config Panel */}
{selectedAssistant ? ( <> {/* Header Area */}
UUID: {selectedAssistant.id}
updateAssistant('name', e.target.value)} className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base" />
{!isNoneConfig && (
{selectedAssistant.configMode === 'platform' ? ( <> ) : ( <> )}
)}
{isNoneConfig ? (

请先选择配置方式以展开详细设置

) : (
{activeTab === TabValue.LINK && isExternalConfig && (

接入 {selectedAssistant.configMode === 'dify' ? 'Dify' : 'FastGPT'} 引擎

配置后,视频通话过程中的对话逻辑、知识库检索以及工作流将由外部引擎托管。

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" />
updateAssistant('apiKey', e.target.value)} placeholder="请输入应用 API 密钥..." className="bg-white/5 border-white/10 focus:border-primary/50 font-mono text-xs" />
)} {activeTab === TabValue.GLOBAL && selectedAssistant.configMode === 'platform' && (

选择用于驱动该助手对话的大语言模型。