"use client"; import { Bot, Boxes, Brain, Check, Copy, Database, MessageSquareText, MoreHorizontal, Pencil, Plus, Rocket, Sparkles, Trash2, Workflow, ChevronLeft, ChevronUp, ChevronDown, Save, Mic, Send, HelpCircle, Waypoints, AudioLines, Terminal, Loader2, PhoneOff, Orbit, Waves, Bug, Video, Smartphone, Wrench, Settings2, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { NetworkQualityIndicator } from "@/components/network-quality-indicator"; import { TurnConfigEditor } from "@/components/turn-config-editor"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { AuraVisualizer } from "@/components/ui/aura-visualizer"; import { NebulaVisualizer } from "@/components/ui/nebula-visualizer"; import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer"; import { WaveVisualizer } from "@/components/ui/wave-visualizer"; import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline"; import { DataList } from "@/components/ui/data-list"; import { PageHeader } from "@/components/ui/page-header"; import { FilterPills } from "@/components/ui/filter-pills"; import { SearchInput } from "@/components/ui/search-input"; import { ListToolbar } from "@/components/ui/list-toolbar"; import { Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { assistantsApi, knowledgeBasesApi, modelResourcesApi, toolsApi, type Assistant, type AssistantType as ApiAssistantType, type AssistantUpsert, type KnowledgeBase, type KnowledgeRetrievalConfig, type ModelResource, type Tool, type TurnConfig, } from "@/lib/api"; import { useVoicePreview, type ChatMessage, type VoicePreview, type VoicePreviewStatus, } from "@/hooks/use-voice-preview"; import { useCameraPreview, type CameraPreview, } from "@/hooks/use-camera-preview"; import { WorkflowEditor, type WorkflowSettings, } from "@/components/workflow/WorkflowEditor"; import { defaultGraph, type WorkflowGraph, } from "@/components/workflow/specs"; type RuntimeMode = "pipeline" | "realtime"; type AssistantForm = { name: string; greeting: string; prompt: string; runtimeMode: RuntimeMode; realtimeModel: string; model: string; asr: string; voice: string; knowledgeBase: string; knowledgeRetrievalConfig: KnowledgeRetrievalConfig; enableInterrupt: boolean; turnConfig: TurnConfig; visionEnabled: boolean; visionModelResourceId: string; toolIds: string[]; }; type FastGptForm = { name: string; agent: string; asr: string; voice: string; enableInterrupt: boolean; turnConfig: TurnConfig; }; type DifyForm = { name: string; agent: string; asr: string; voice: string; enableInterrupt: boolean; turnConfig: TurnConfig; }; type OpenCodeForm = { name: string; prompt: string; agent: string; model: string; asr: string; voice: string; enableInterrupt: boolean; turnConfig: TurnConfig; visionEnabled: boolean; visionModelResourceId: string; }; type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT" | "OpenCode"; const assistantTypes: AssistantType[] = [ "提示词", "工作流", "Dify", "FastGPT", "OpenCode", ]; function defaultTurnConfig(): TurnConfig { return { bargeIn: { strategy: "vad", }, vad: { confidence: 0.7, startSecs: 0.2, stopSecs: 0.2, minVolume: 0.6, }, turnDetection: { strategy: "silence", silenceTimeoutSecs: 0.6, }, }; } function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig { return { mode: "automatic", topN: 5, scoreThreshold: 0, }; } // 后端 type(英文) ↔ 列表展示标签(中文) const typeToLabel: Record = { prompt: "提示词", workflow: "工作流", dify: "Dify", fastgpt: "FastGPT", opencode: "OpenCode", }; const typeFromLabel: Record = { 提示词: "prompt", 工作流: "workflow", Dify: "dify", FastGPT: "fastgpt", OpenCode: "opencode", }; // 后端 type → 编辑器视图(工作流暂为占位页) const typeToView = { prompt: "create", dify: "create-dify", fastgpt: "create-fastgpt", opencode: "create-opencode", workflow: "workflow", } as const; type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType]; // 路由驱动的页面模式: // /assistants → list | /assistants/new → choose(引导,确认即建库) | /assistants/[id] → edit export type AssistantPageProps = | { mode: "list" } | { mode: "choose" } | { mode: "edit"; assistantId: string }; // 各类型的空白表单模板(新建用) function blankPromptForm(name: string): AssistantForm { return { name, greeting: "", prompt: "", runtimeMode: "pipeline", realtimeModel: "", model: "", asr: "", voice: "", knowledgeBase: "", knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), enableInterrupt: true, turnConfig: defaultTurnConfig(), visionEnabled: false, visionModelResourceId: "", toolIds: [], }; } function blankFastGptForm(name: string): FastGptForm { return { name, agent: "", asr: "", voice: "", enableInterrupt: true, turnConfig: defaultTurnConfig(), }; } function blankDifyForm(name: string): DifyForm { return { name, agent: "", asr: "", voice: "", enableInterrupt: true, turnConfig: defaultTurnConfig(), }; } function blankOpenCodeForm(name: string): OpenCodeForm { return { name, prompt: "", agent: "", model: "", asr: "", voice: "", enableInterrupt: true, turnConfig: defaultTurnConfig(), visionEnabled: false, visionModelResourceId: "", }; } function formatTimestamp(iso?: string | null): string { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad( d.getHours(), )}:${pad(d.getMinutes())}`; } type AssistantTypeOption = { type: AssistantType; label: string; description: string; icon: React.ReactNode; /** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */ available: boolean; }; const assistantTypeOptions: AssistantTypeOption[] = [ { type: "提示词", label: "使用提示词构建", description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。", icon: , available: true, }, { type: "工作流", label: "使用工作流构建", description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。", icon: , available: false, }, { type: "Dify", label: "使用 Dify 构建", description: "对接 Dify 应用,复用其编排能力与知识库配置。", icon: , available: true, }, { type: "FastGPT", label: "使用 FastGPT 构建", description: "对接 FastGPT 应用,复用其知识库问答与工作流能力。", icon: , available: true, }, { type: "OpenCode", label: "使用 OpenCode 构建", description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。", icon: , available: true, }, ]; type AssistantListItem = { id: string; name: string; type: AssistantType; updatedAt: string; /** 原始 ISO 时间戳,用于按时间排序(updatedAt 为展示用整形字符串) */ updatedAtRaw: string | null | undefined; }; type TypeFilter = "全部" | AssistantType; const typeFilters: TypeFilter[] = ["全部", ...assistantTypes]; // 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前 type SortOrder = "newest" | "oldest"; export function AssistantPage(props: AssistantPageProps) { const router = useRouter(); // 编辑中的助手 id(来自路由) const editingId = props.mode === "edit" ? props.assistantId : null; const [form, setForm] = useState(() => blankPromptForm("")); const [fastGptForm, setFastGptForm] = useState(() => blankFastGptForm(""), ); const [difyForm, setDifyForm] = useState(() => blankDifyForm("")); const [openCodeForm, setOpenCodeForm] = useState(() => blankOpenCodeForm(""), ); const [assistants, setAssistants] = useState([]); const [listLoading, setListLoading] = useState(true); const [listError, setListError] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); // 编辑模式:加载指定 id 助手失败时的错误 const [loadError, setLoadError] = useState(null); // 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥") // 下拉数据源:模型资源 + 知识库 const [modelResources, setModelResources] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); const [tools, setTools] = useState([]); // 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换 const [view, setView] = useState(() => { if (props.mode === "choose") return "choose"; if (props.mode === "edit") return "loading"; return "list"; }); const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("全部"); const [sortOrder, setSortOrder] = useState("newest"); const [currentPage, setCurrentPage] = useState(1); // choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页 // (工作流占位页也用它展示名称与类型) const [draftName, setDraftName] = useState(""); const [draftType, setDraftType] = useState(null); // 引导页:创建请求进行中 / 创建失败 const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); // 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击 const [savedSnapshot, setSavedSnapshot] = useState(null); // workflow 编辑器:名称 + 图(nodes/edges)。graph 实时由画布回传。 const [workflowName, setWorkflowName] = useState(""); const [workflowGraph, setWorkflowGraph] = useState(() => defaultGraph(), ); const [workflowSettings, setWorkflowSettings] = useState({ allowInterrupt: true, turnConfig: defaultTurnConfig(), }); const [debugOpen, setDebugOpen] = useState(false); const [activeNodeId, setActiveNodeId] = useState(null); const loadAssistants = useCallback(async () => { setListLoading(true); setListError(null); try { setAssistants(await assistantsApi.list()); } catch (error) { setListError(error instanceof Error ? error.message : "加载失败"); } finally { setListLoading(false); } }, []); useEffect(() => { // 列表页挂载时拉取助手列表(与后端同步) if (props.mode !== "list") return; // eslint-disable-next-line react-hooks/set-state-in-effect void loadAssistants(); }, [props.mode, loadAssistants]); // 进入创建/编辑前加载下拉数据源(模型资源 + 知识库) const loadResources = useCallback(async () => { try { const [creds, kbs, toolRows] = await Promise.all([ modelResourcesApi.list(), knowledgeBasesApi.list(), toolsApi.list(), ]); setModelResources(creds); setKnowledgeBases(kbs); setTools(toolRows); } catch { // 拉取失败时下拉为空,不阻塞表单 } }, []); // 编辑页挂载时加载下拉数据源 useEffect(() => { if (props.mode !== "edit") return; // eslint-disable-next-line react-hooks/set-state-in-effect void loadResources(); }, [props.mode, loadResources]); // 按资源类型生成 {value:id, label:name} 选项 const credOptions = (type: ModelResource["capability"]) => modelResources .filter((c) => c.capability === type) .map((c) => ({ value: c.id, label: c.name })); const agentOptions = (interfaceType: "dify" | "fastgpt" | "opencode") => modelResources .filter( (resource) => resource.capability === "Agent" && resource.interfaceType === interfaceType, ) .map((resource) => ({ value: resource.id, label: resource.name })); const visionModelOptionsFor = (currentModelId: string) => modelResources .filter( (c) => c.capability === "LLM" && c.supportImageInput && c.id !== currentModelId, ) .map((c) => ({ value: c.id, label: c.name })); const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name })); function handlePromptVisionEnabledChange(enabled: boolean) { updateForm("visionEnabled", enabled); if (!enabled) updateForm("visionModelResourceId", ""); } function handlePromptModelChange(value: string) { updateForm("model", value); if (form.visionModelResourceId === value) { updateForm("visionModelResourceId", ""); } } function handleOpenCodeVisionEnabledChange(enabled: boolean) { updateOpenCodeForm("visionEnabled", enabled); if (!enabled) updateOpenCodeForm("visionModelResourceId", ""); } function handleOpenCodeModelChange(value: string) { updateOpenCodeForm("model", value); if (openCodeForm.visionModelResourceId === value) { updateOpenCodeForm("visionModelResourceId", ""); } } function startCreate() { router.push("/assistants/new"); } // 把后端 Assistant 回填进提示词表单(model/asr/voice 等存模型资源 id) // 返回回填后的表单,供调用方记录"已保存基线" function fillPromptForm(a: Assistant): AssistantForm { const next: AssistantForm = { name: a.name, greeting: a.greeting, prompt: a.prompt, runtimeMode: a.runtimeMode, realtimeModel: a.modelResourceIds.Realtime ?? "", model: a.modelResourceIds.LLM ?? "", asr: a.modelResourceIds.ASR ?? "", voice: a.modelResourceIds.TTS ?? "", knowledgeBase: a.knowledgeBaseId ?? "", knowledgeRetrievalConfig: a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(), enableInterrupt: a.enableInterrupt, turnConfig: a.turnConfig, visionEnabled: a.visionEnabled, visionModelResourceId: a.visionModelResourceId ?? "", toolIds: a.toolIds ?? [], }; setForm(next); return next; } // 编辑:跳转到 /assistants/[id],由编辑页按助手类型回填表单 function handleEdit(assistant: AssistantListItem) { router.push(`/assistants/${assistant.id}`); } // 引导页确认:直接创建到数据库拿到 id,再进入该助手的编辑页 async function confirmType() { if (!draftName.trim() || !draftType || creating) { return; } setCreating(true); setCreateError(null); try { const created = await assistantsApi.create( baseUpsert({ name: draftName.trim(), type: typeFromLabel[draftType], }), ); router.push(`/assistants/${created.id}`); } catch (error) { setCreateError(error instanceof Error ? error.message : "创建失败"); setCreating(false); } } // 复制助手:服务端整行复制(含真 key,密钥不经浏览器) async function handleDuplicate(assistant: AssistantListItem) { try { await assistantsApi.duplicate(assistant.id); await loadAssistants(); } catch (error) { setListError(error instanceof Error ? error.message : "复制失败"); } } // 删除助手 async function handleDelete(id: string) { try { await assistantsApi.remove(id); await loadAssistants(); } catch (error) { setListError(error instanceof Error ? error.message : "删除失败"); } } // 平铺字段的空白 upsert,各类型按需覆盖(后端会按 type 清掉无关字段) function baseUpsert(over: Partial): AssistantUpsert { return { name: "", type: "prompt", runtimeMode: "pipeline", greeting: "", enableInterrupt: true, turnConfig: defaultTurnConfig(), visionEnabled: false, visionModelResourceId: null, modelResourceIds: {}, knowledgeBaseId: null, knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), toolIds: [], prompt: "", apiUrl: "", apiKey: "", appId: "", graph: {}, ...over, }; } // 保存后停留在编辑页,并把当前表单记为已保存基线(按钮随之置灰)。 async function save(payload: AssistantUpsert) { if (!editingId) return; setSaving(true); setSaveError(null); try { await assistantsApi.update(editingId, payload); if (view === "create") { setSavedSnapshot(JSON.stringify(form)); } else if (view === "create-dify") { const next = { ...difyForm }; setDifyForm(next); setSavedSnapshot(JSON.stringify(next)); } else if (view === "create-fastgpt") { const next = { ...fastGptForm }; setFastGptForm(next); setSavedSnapshot(JSON.stringify(next)); } else if (view === "create-opencode") { const next = { ...openCodeForm }; setOpenCodeForm(next); setSavedSnapshot(JSON.stringify(next)); } else if (view === "workflow") { setSavedSnapshot( JSON.stringify({ name: workflowName, graph: workflowGraph, settings: workflowSettings, }), ); } } catch (error) { setSaveError(error instanceof Error ? error.message : "保存失败"); } finally { setSaving(false); } } function handleSavePrompt() { void save( baseUpsert({ name: form.name.trim(), type: "prompt", runtimeMode: form.runtimeMode, greeting: form.greeting, enableInterrupt: form.enableInterrupt, turnConfig: form.turnConfig, visionEnabled: form.visionEnabled, visionModelResourceId: form.visionModelResourceId || null, modelResourceIds: { ...(form.model ? { LLM: form.model } : {}), ...(form.asr ? { ASR: form.asr } : {}), ...(form.voice ? { TTS: form.voice } : {}), ...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}), }, knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null, knowledgeRetrievalConfig: form.knowledgeRetrievalConfig, toolIds: form.toolIds, prompt: form.prompt, }), ); } // ---- Dify ---- function fillDifyForm(a: Assistant): DifyForm { const next: DifyForm = { name: a.name, agent: a.modelResourceIds.Agent ?? "", asr: a.modelResourceIds.ASR ?? "", voice: a.modelResourceIds.TTS ?? "", enableInterrupt: a.enableInterrupt, turnConfig: a.turnConfig, }; setDifyForm(next); return next; } function handleSaveDify() { void save( baseUpsert({ name: difyForm.name.trim(), type: "dify", enableInterrupt: difyForm.enableInterrupt, turnConfig: difyForm.turnConfig, modelResourceIds: { ...(difyForm.agent ? { Agent: difyForm.agent } : {}), ...(difyForm.asr ? { ASR: difyForm.asr } : {}), ...(difyForm.voice ? { TTS: difyForm.voice } : {}), }, }), ); } // ---- FastGPT ---- function fillFastGptForm(a: Assistant): FastGptForm { const next: FastGptForm = { name: a.name, agent: a.modelResourceIds.Agent ?? "", asr: a.modelResourceIds.ASR ?? "", voice: a.modelResourceIds.TTS ?? "", enableInterrupt: a.enableInterrupt, turnConfig: a.turnConfig, }; setFastGptForm(next); return next; } function handleSaveFastGpt() { void save( baseUpsert({ name: fastGptForm.name.trim(), type: "fastgpt", enableInterrupt: fastGptForm.enableInterrupt, turnConfig: fastGptForm.turnConfig, modelResourceIds: { ...(fastGptForm.agent ? { Agent: fastGptForm.agent } : {}), ...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}), ...(fastGptForm.voice ? { TTS: fastGptForm.voice } : {}), }, }), ); } // ---- OpenCode ---- function fillOpenCodeForm(a: Assistant): OpenCodeForm { const next: OpenCodeForm = { name: a.name, prompt: a.prompt, agent: a.modelResourceIds.Agent ?? "", model: a.modelResourceIds.LLM ?? "", asr: a.modelResourceIds.ASR ?? "", voice: a.modelResourceIds.TTS ?? "", enableInterrupt: a.enableInterrupt, turnConfig: a.turnConfig, visionEnabled: a.visionEnabled, visionModelResourceId: a.visionModelResourceId ?? "", }; setOpenCodeForm(next); return next; } // 编辑模式:按路由中的 id 拉取助手,回填对应类型的表单后再切换视图 useEffect(() => { if (props.mode !== "edit" || !editingId) return; let cancelled = false; void (async () => { try { const assistant = await assistantsApi.get(editingId); if (cancelled) return; if (assistant.type === "prompt") { setSavedSnapshot(JSON.stringify(fillPromptForm(assistant))); } else if (assistant.type === "fastgpt") { setSavedSnapshot(JSON.stringify(fillFastGptForm(assistant))); } else if (assistant.type === "dify") { setSavedSnapshot(JSON.stringify(fillDifyForm(assistant))); } else if (assistant.type === "opencode") { setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant))); } else { // 工作流:回填名称与图(空图回落到默认 开始→智能体→结束) const graph = assistant.graph && Array.isArray((assistant.graph as WorkflowGraph).nodes) && (assistant.graph as WorkflowGraph).nodes.length > 0 ? (assistant.graph as WorkflowGraph) : defaultGraph(); const wfSettings: WorkflowSettings = { llm: assistant.modelResourceIds.LLM, asr: assistant.modelResourceIds.ASR, tts: assistant.modelResourceIds.TTS, allowInterrupt: assistant.enableInterrupt, turnConfig: assistant.turnConfig, }; setWorkflowName(assistant.name); setWorkflowGraph(graph); setWorkflowSettings(wfSettings); setSavedSnapshot( JSON.stringify({ name: assistant.name, graph, settings: wfSettings, }), ); } setView(typeToView[assistant.type]); } catch (error) { if (!cancelled) { setLoadError( error instanceof Error ? error.message : "加载助手失败", ); } } })(); return () => { cancelled = true; }; }, [props.mode, editingId]); function handleSaveOpenCode() { void save( baseUpsert({ name: openCodeForm.name.trim(), type: "opencode", enableInterrupt: openCodeForm.enableInterrupt, turnConfig: openCodeForm.turnConfig, visionEnabled: openCodeForm.visionEnabled, visionModelResourceId: openCodeForm.visionModelResourceId || null, modelResourceIds: { ...(openCodeForm.agent ? { Agent: openCodeForm.agent } : {}), ...(openCodeForm.model ? { LLM: openCodeForm.model } : {}), ...(openCodeForm.asr ? { ASR: openCodeForm.asr } : {}), ...(openCodeForm.voice ? { TTS: openCodeForm.voice } : {}), }, prompt: openCodeForm.prompt, }), ); } function handleSaveWorkflow() { void save( baseUpsert({ name: workflowName.trim(), type: "workflow", enableInterrupt: workflowSettings.allowInterrupt, turnConfig: workflowSettings.turnConfig, modelResourceIds: { ...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}), ...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}), ...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}), }, graph: workflowGraph as unknown as Record, }), ); } // 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点) const activeFormJson = view === "create" ? JSON.stringify(form) : view === "create-dify" ? JSON.stringify(difyForm) : view === "create-fastgpt" ? JSON.stringify(fastGptForm) : view === "create-opencode" ? JSON.stringify(openCodeForm) : view === "workflow" ? JSON.stringify({ name: workflowName, graph: workflowGraph, settings: workflowSettings, }) : null; const dirty = activeFormJson !== null && savedSnapshot !== null && activeFormJson !== savedSnapshot; const listItems: AssistantListItem[] = assistants.map((a) => ({ id: a.id, name: a.name, type: typeToLabel[a.type], updatedAt: formatTimestamp(a.updatedAt), updatedAtRaw: a.updatedAt, })); const filteredAssistants = listItems.filter((assistant) => { if (typeFilter !== "全部" && assistant.type !== typeFilter) { return false; } const keyword = searchQuery.trim().toLowerCase(); if (!keyword) { return true; } return [assistant.name, assistant.id, assistant.type, assistant.updatedAt] .join(" ") .toLowerCase() .includes(keyword); }); // 按更新时间排序(以原始 ISO 时间戳为准);缺失时间戳视为最早,id 作为稳定的次级排序键 const timeValue = (iso: string | null | undefined) => { const t = iso ? new Date(iso).getTime() : NaN; return Number.isNaN(t) ? 0 : t; }; const sortedAssistants = [...filteredAssistants].sort((a, b) => { const diff = timeValue(b.updatedAtRaw) - timeValue(a.updatedAtRaw); if (diff !== 0) return sortOrder === "newest" ? diff : -diff; return a.id.localeCompare(b.id); }); const pageSize = 5; const totalPages = Math.max(1, Math.ceil(sortedAssistants.length / pageSize)); const safeCurrentPage = Math.min(currentPage, totalPages); const pageStart = (safeCurrentPage - 1) * pageSize; const pageEnd = pageStart + pageSize; const paginatedAssistants = sortedAssistants.slice(pageStart, pageEnd); function handleSearchChange(value: string) { setSearchQuery(value); setCurrentPage(1); } function handleFilterChange(filter: TypeFilter) { setTypeFilter(filter); setCurrentPage(1); } function handleSortChange(order: SortOrder) { setSortOrder(order); setCurrentPage(1); } function toggleSortOrder() { handleSortChange(sortOrder === "newest" ? "oldest" : "newest"); } function updateForm( key: K, value: AssistantForm[K], ) { setForm((prev) => ({ ...prev, [key]: value, })); } function updateFastGptForm( key: K, value: FastGptForm[K], ) { setFastGptForm((prev) => ({ ...prev, [key]: value, })); } function updateDifyForm( key: K, value: DifyForm[K], ) { setDifyForm((prev) => ({ ...prev, [key]: value, })); } function updateOpenCodeForm( key: K, value: OpenCodeForm[K], ) { setOpenCodeForm((prev) => ({ ...prev, [key]: value, })); } if (view === "loading") { return (
{loadError ? (
加载助手失败
{loadError}
) : (
正在加载助手…
)}
); } if (view === "list") { return (
创建助手 } />
} search={ } /> rows={paginatedAssistants} rowKey={(assistant) => assistant.id} loading={listLoading} loadingText="正在加载助手列表…" error={listError} onRetry={() => void loadAssistants()} empty={{ title: listItems.length === 0 ? "暂无助手" : "未找到匹配的助手", description: listItems.length === 0 ? "点击右上角「创建助手」开始。" : "请调整关键词或筛选条件后再试。", }} pagination={{ page: safeCurrentPage, totalPages, onPageChange: setCurrentPage, summary: filteredAssistants.length === 0 ? "没有数据" : `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`, }} columns={[ { key: "name", header: "助手名称", width: "md:w-[360px]", cell: (assistant) => ( <>
{assistant.name}
{assistant.id}
), }, { key: "type", header: "助手类型", width: "md:w-[128px]", cell: (assistant) => ( {assistant.type} ), }, { key: "updatedAt", width: "md:w-[176px]", header: ( ), cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground", cell: (assistant) => assistant.updatedAt, }, { key: "actions", header: "操作", align: "right", cellClassName: "flex justify-end gap-2", cell: (assistant) => ( <> handleDuplicate(assistant)} > 复制 { event.preventDefault(); void handleDelete(assistant.id); }} > 删除 ), }, ]} />
); } if (view === "choose") { return (

创建助手

先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。

构建方式
{assistantTypeOptions.map((option) => { const selected = draftType === option.type; return ( ); })}
{createError && ( {createError} )}
); } if (view === "workflow") { return (
router.push("/assistants")} />
{saveError && ( {saveError} )}
{ setDebugOpen(open); if (!open) setActiveNodeId(null); }} modal={false} > e.preventDefault()} className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]" > 语音调试 与当前助手进行语音对话调试,画布会高亮正在激活的节点。
); } if (view === "create-dify") { return (
router.push("/assistants")} /> updateDifyForm("name", value)} />
{saveError && ( {saveError} )}
} title="Dify 应用配置" description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。" > updateDifyForm("agent", value)} options={agentOptions("dify")} noneLabel="请选择" /> } title="语音配置" description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。" > updateDifyForm("asr", value)} options={credOptions("ASR")} noneLabel="无" /> updateDifyForm("voice", value)} options={credOptions("TTS")} noneLabel="无" /> } title="交互策略" description="设置实时视频对话时的交互体验" > updateDifyForm("enableInterrupt", checked)} onConfigChange={(config) => updateDifyForm("turnConfig", config)} />
); } if (view === "create-fastgpt") { return (
router.push("/assistants")} /> updateFastGptForm("name", value)} />
{saveError && ( {saveError} )}
} title="FastGPT 应用配置" description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。" > updateFastGptForm("agent", value)} options={agentOptions("fastgpt")} noneLabel="请选择" /> } title="语音配置" description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。" > updateFastGptForm("asr", value)} options={credOptions("ASR")} noneLabel="无" /> updateFastGptForm("voice", value)} options={credOptions("TTS")} noneLabel="无" /> } title="交互策略" description="设置实时视频对话时的交互体验" > updateFastGptForm("enableInterrupt", checked)} onConfigChange={(config) => updateFastGptForm("turnConfig", config)} />
); } if (view === "create-opencode") { return (
router.push("/assistants")} /> updateOpenCodeForm("name", value)} />
{saveError && ( {saveError} )}
} title="OpenCode 服务配置" description="从「模型资源」中选择 OpenCode 服务资源。" > updateOpenCodeForm("agent", value)} options={agentOptions("opencode")} noneLabel="请选择" /> } title="提示词" description="描述助手的角色、能力和回答要求" > updateOpenCodeForm("prompt", value)} placeholder="请输入提示词,描述助手的角色、能力和回答要求" rows={8} /> } title="模型与语音配置" description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。" > {openCodeForm.visionEnabled && ( updateOpenCodeForm("visionModelResourceId", value) } options={visionModelOptionsFor(openCodeForm.model)} noneLabel="模型自己" /> )} updateOpenCodeForm("asr", value)} options={credOptions("ASR")} noneLabel="无" /> updateOpenCodeForm("voice", value)} options={credOptions("TTS")} noneLabel="无" /> } title="交互策略" description="设置实时视频对话时的交互体验" > updateOpenCodeForm("enableInterrupt", checked)} onConfigChange={(config) => updateOpenCodeForm("turnConfig", config)} />
); } return (
router.push("/assistants")} /> updateForm("name", value)} />
{saveError && ( {saveError} )}
updateForm("runtimeMode", "pipeline")} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); updateForm("runtimeMode", "pipeline"); } }} className={[ "cursor-pointer rounded-2xl border p-5 text-left transition-colors", form.runtimeMode === "pipeline" ? "border-primary bg-primary/5 ring-1 ring-primary" : "border-hairline bg-canvas-soft hover:border-hairline-strong", ].join(" ")} >
Pipeline 模式
{form.runtimeMode === "pipeline" && ( )}
updateForm("runtimeMode", "realtime")} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); updateForm("runtimeMode", "realtime"); } }} className={[ "cursor-pointer rounded-2xl border p-5 text-left transition-colors", form.runtimeMode === "realtime" ? "border-primary bg-primary/5 ring-1 ring-primary" : "border-hairline bg-canvas-soft hover:border-hairline-strong", ].join(" ")} >
Realtime 模式
{form.runtimeMode === "realtime" && ( )}
} title="提示词" description="描述助手的角色、能力和回答要求" > updateForm("prompt", value)} placeholder="请输入提示词,描述助手的角色、能力和回答要求" rows={8} /> {form.runtimeMode === "pipeline" ? ( } title="模型配置" description="从「模型资源」中选择大语言模型、语音识别与语音合成" > {form.visionEnabled && ( updateForm("visionModelResourceId", value) } options={visionModelOptionsFor(form.model)} noneLabel="模型自己" /> )} updateForm("asr", value)} options={credOptions("ASR")} noneLabel="无" /> updateForm("voice", value)} options={credOptions("TTS")} noneLabel="无" /> ) : ( } title="模型配置" description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成" > updateForm("realtimeModel", value)} options={credOptions("Realtime")} noneLabel="无" /> )} } title="开场白" description="助手与用户首次对话时的开场语" > updateForm("greeting", value)} placeholder="请输入助手开场白" /> {form.runtimeMode === "pipeline" && ( } title="知识库配置" description="选择助手回答时可检索的业务知识来源" >
知识库选择 updateForm("knowledgeRetrievalConfig", config) } />
updateForm("knowledgeBase", value)} options={kbOptions} noneLabel="无" />
)} } title="工具" description="配置该提示词助手可以调用的工具" > tool.status === "active")} selectedIds={form.toolIds} onChange={(toolIds) => updateForm("toolIds", toolIds)} /> } title="交互策略" description="设置实时视频对话时的交互体验" > {form.runtimeMode === "pipeline" ? ( updateForm("enableInterrupt", checked)} onConfigChange={(config) => updateForm("turnConfig", config)} /> ) : (

高级抢话与轮次检测当前仅支持 Pipeline 模式。

)}
); } type VizStyle = "aura" | "nebula" | "bars" | "wave"; // 调试面板顶部主视图:聊天记录 / 视频流 type DebugView = "chat" | "video"; type DebugInputMode = "mic" | "text"; const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] = [ { style: "aura", label: "光环", icon: }, { style: "nebula", label: "星云", icon: }, { style: "bars", label: "频谱", icon: }, { style: "wave", label: "波形", icon: }, ]; // 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为 // 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。 const SHOW_VOICE_VIZ = false; function SegmentedIconGroup({ children, label, }: { children: React.ReactNode; label: string; }) { return (
{children}
); } function SegmentedIconButton({ selected, label, onClick, children, }: { selected: boolean; label: string; onClick: () => void; children: React.ReactNode; }) { return ( ); } function DebugDrawer({ assistantId, asSheet = false, hasUnsavedChanges = false, onNodeActive, vision = false, }: { assistantId: string | null; asSheet?: boolean; hasUnsavedChanges?: boolean; onNodeActive?: (nodeId: string | null) => void; vision?: boolean; }) { const preview = useVoicePreview(assistantId, onNodeActive); const camera = useCameraPreview(); const [showTranscript, setShowTranscript] = useState(false); const [vizStyle, setVizStyle] = useState("aura"); const [view, setView] = useState("chat"); const stopCamera = camera.stop; const startCamera = camera.start; const recording = preview.status === "connecting" || preview.status === "connected"; const shouldKeepCamera = vision && recording; useEffect(() => { if (shouldKeepCamera) { void startCamera(); } else { stopCamera(); } }, [shouldKeepCamera, startCamera, stopCamera]); const selectCamera = useCallback( async (deviceId: string) => { const nextStream = await camera.selectCamera(deviceId); await preview.replaceVideoStream(nextStream); }, [camera, preview], ); const containerClass = asSheet ? "flex h-full min-w-0 flex-1 flex-col overflow-hidden" : "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex"; return ( ); } function CallPreviewLink({ assistantId }: { assistantId: string | null }) { const [callUrl, setCallUrl] = useState(""); const [copied, setCopied] = useState(false); const copyLink = useCallback(async () => { if (!callUrl) return; await navigator.clipboard.writeText(callUrl); setCopied(true); window.setTimeout(() => setCopied(false), 1600); }, [callUrl]); return ( { if (open && assistantId) { setCallUrl( `${window.location.origin}/call/${encodeURIComponent(assistantId)}`, ); setCopied(false); } }} >
手机通话预览

复制链接并在浏览器中打开,即可进入全屏视频通话界面。

event.currentTarget.select()} className="h-9 min-w-0 flex-1 text-xs" />
{copied &&

链接已复制

}
); } function DeviceSelectField({ icon, ariaLabel, placeholder, fallbackLabel, value, devices, onSelect, }: { icon: React.ReactNode; ariaLabel: string; placeholder: string; fallbackLabel: string; value: string; devices: MediaDeviceInfo[]; onSelect: (deviceId: string) => void; }) { return ( ); } function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) { return ( } ariaLabel="选择麦克风" placeholder="默认麦克风" fallbackLabel="麦克风" value={preview.selectedDeviceId} devices={preview.audioInputs} onSelect={(deviceId) => preview.selectDevice(deviceId)} /> ); } function CameraDeviceField({ camera, onSelect, }: { camera: CameraPreview; onSelect?: (deviceId: string) => void | Promise; }) { return ( } ariaLabel="选择摄像头" placeholder="默认摄像头" fallbackLabel="摄像头" value={camera.deviceId} devices={camera.devices} onSelect={(deviceId) => void (onSelect ?? camera.selectCamera)(deviceId)} /> ); } function DebugInputModeButton({ selected, label, onClick, children, }: { selected: boolean; label: string; onClick: () => void; children: React.ReactNode; }) { return ( ); } function DebugVoicePanel({ view, showTranscript, vizStyle, assistantId, preview, camera, hasUnsavedChanges, vision, }: { view: DebugView; showTranscript: boolean; vizStyle: VizStyle; assistantId: string | null; preview: VoicePreview; camera: CameraPreview; hasUnsavedChanges: boolean; vision: boolean; }) { const { status, error, micWarning, localStream, remoteStream, messages, sendText, connect, disconnect, audioRef, } = preview; const recording = status === "connecting" || status === "connected"; const [textDraft, setTextDraft] = useState(""); const [inputMode, setInputMode] = useState("mic"); const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript); const idleOrFailed = status === "idle" || status === "failed"; const showIdleHub = idleOrFailed && (view === "video" || (messages.length === 0 && inChatView)); const startBlockedMessage = !assistantId ? "请先保存助手,再开始对话。" : hasUnsavedChanges ? "请先保存当前改动,再开始对话。" : ""; const startDisabled = status === "connecting" || Boolean(startBlockedMessage); function handleSendText() { if (sendText(textDraft)) { setTextDraft(""); } } const startConversation = useCallback(async () => { if (!assistantId || hasUnsavedChanges) return; const videoStream = vision ? await camera.start() : null; await connect({ visionEnabled: vision, videoStream, }); }, [assistantId, camera, connect, hasUnsavedChanges, vision]); return (
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}