"use client"; import type React from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { AudioLines, Braces, Check, Copy, ImageIcon, Loader2, MessageSquareText, Mic, Orbit, PhoneOff, ScanLine, Send, Smartphone, Sparkles, Video, Waves, Wrench, X, } from "lucide-react"; import { AutoTestLivePanel, AutoTestRunBar, DebugModeTabs, useAutoTestRunner, } from "@/components/assistant-editor/debug-auto-test"; import { NetworkQualityIndicator } from "@/components/network-quality-indicator"; import { ClientMessageDialog } from "@/components/client-message-dialog"; import { AuraVisualizer } from "@/components/ui/aura-visualizer"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NebulaVisualizer } from "@/components/ui/nebula-visualizer"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer"; import { Textarea } from "@/components/ui/textarea"; import { WaveVisualizer } from "@/components/ui/wave-visualizer"; import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline"; import { useCameraPreview, type CameraPreview } from "@/hooks/use-camera-preview"; import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool"; import { createUserInputId, useVoicePreview, type ChatMessage, type ClientToolDefinition, type UserInputPart, type VoicePreview, type VoicePreviewStatus, } from "@/hooks/use-voice-preview"; import { inputAssetsApi, type DynamicVariableDefinition, } from "@/lib/api"; type VizStyle = "aura" | "nebula" | "bars" | "wave"; // 调试面板顶部主视图:聊天记录 / 视频流 type DebugView = "chat" | "video"; type DebugInputMode = "mic" | "text"; type DebugMode = "manual" | "auto"; type PendingDebugImage = { file: File; previewUrl: string; }; const DEBUG_IMAGE_MAX_BYTES = 10 * 1024 * 1024; function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error("无法读取图片")); reader.readAsDataURL(file); }); } function hasDraggedImage(dataTransfer: DataTransfer): boolean { return ( Array.from(dataTransfer.items).some((item) => item.type.startsWith("image/"), ) || Array.from(dataTransfer.files).some((file) => file.type.startsWith("image/"), ) ); } 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 ( ); } export function DebugDrawer({ assistantId, overlay = false, onClose, hasUnsavedChanges = false, onNodeActive, vision = false, dynamicVariablesEnabled = false, dynamicVariableDefinitions = {}, }: { assistantId: string | null; overlay?: boolean; onClose?: () => void; hasUnsavedChanges?: boolean; onNodeActive?: (nodeId: string | null) => void; vision?: boolean; dynamicVariablesEnabled?: boolean; dynamicVariableDefinitions?: Record; }) { const preview = useVoicePreview(assistantId, onNodeActive); const camera = useCameraPreview(); const autoTest = useAutoTestRunner(); const stopAutoTest = autoTest.stop; const [debugMode, setDebugMode] = useState("manual"); const [showTranscript, setShowTranscript] = useState(false); const [vizStyle, setVizStyle] = useState("aura"); const [view, setView] = useState("chat"); const [dynamicVariableValues, setDynamicVariableValues] = useState< Record >({}); const recording = preview.status === "connecting" || preview.status === "connected"; const displayedDefinitions = { ...dynamicVariableDefinitions }; for (const [name, value] of Object.entries(preview.sessionVariables)) { displayedDefinitions[name] ??= { type: typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string", required: false, default: null, }; } const dynamicVariableEntries = Object.entries(displayedDefinitions); const resolvedDynamicVariables: Record = {}; let dynamicVariablesError = ""; for (const [name, definition] of Object.entries(dynamicVariableDefinitions)) { const value = dynamicVariableValues[name] ?? definition.default; if (value === null || value === undefined || value === "") { if (definition.required && !dynamicVariablesError) { dynamicVariablesError = `请先填写必填变量 ${name}`; } continue; } resolvedDynamicVariables[name] = value; } const selectCamera = useCallback( async (deviceId: string) => { await camera.selectCamera(deviceId); preview.selectCamera(deviceId); }, [camera, preview], ); // 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区 const handleModeChange = useCallback( (mode: DebugMode) => { setDebugMode(mode); if (mode === "auto" && recording) { preview.disconnect(); } if (mode === "manual") { stopAutoTest(); } }, [preview, recording, stopAutoTest], ); return ( ); } function AutoTestPanel({ autoTest, }: { autoTest: ReturnType; }) { const { state, selectedCase, selectCase, clearCase, start, stop, rerun, } = autoTest; return (
); } function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) { const [copiedToolName, setCopiedToolName] = useState(null); const copyToolName = useCallback(async (toolName: string) => { await navigator.clipboard.writeText(toolName); setCopiedToolName(toolName); window.setTimeout( () => setCopiedToolName((current) => current === toolName ? null : current, ), 1600, ); }, []); return (
Client Tools

当前调试客户端已注册、能够响应的工具。

{tools.length === 0 ? (
当前没有可用的 Client Tool
) : (
{tools.map((tool) => (
{tool.label}
可用

{tool.description}

参数
{tool.parameters.map((parameter) => (
{parameter.name} {parameter.type} {parameter.required ? "必填" : "可选"}

{parameter.description}

))}
))}
)}
); } function DynamicVariableValuesPopover({ entries, values, sessionValues, readOnly, onChange, }: { entries: [string, DynamicVariableDefinition][]; values: Record; sessionValues: Record; readOnly: boolean; onChange: React.Dispatch< React.SetStateAction> >; }) { function setValue(name: string, value: string | number | boolean | undefined) { onChange((current) => { const next = { ...current }; if (value === undefined) delete next[name]; else next[name] = value; return next; }); } return (
本次会话变量

{readOnly ? "当前值会在 Action 或工具更新变量后实时刷新。" : "这些值只用于下一次调试会话,不会修改助手配置。"}

{entries.length === 0 ? (
当前没有会话变量

在工作流提示词、节点话术、边条件或 Action 中引用变量后, 可在这里设置调试值。

) : entries.map(([name, definition]) => { const value = readOnly ? sessionValues[name] ?? definition.default ?? "" : values[name] ?? definition.default ?? ""; 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, onViewChange, showTranscript, vizStyle, assistantId, preview, camera, hasUnsavedChanges, vision, dynamicVariables, dynamicVariablesError, }: { view: DebugView; onViewChange: (view: DebugView) => void; showTranscript: boolean; vizStyle: VizStyle; assistantId: string | null; preview: VoicePreview; camera: CameraPreview; hasUnsavedChanges: boolean; vision: boolean; dynamicVariables: Record; dynamicVariablesError: string; }) { const photoCapture = usePhotoCaptureTool(preview, vision); const { status, error, micWarning, localStream, remoteStream, messages, sendText, sendUserInput, appendUserImage, removeUserImage, connect, disconnect, audioRef, } = preview; const recording = status === "connecting" || status === "connected"; const [textDraft, setTextDraft] = useState(""); const [inputMode, setInputMode] = useState("mic"); const [pendingImage, setPendingImage] = useState(null); const [inputError, setInputError] = useState(""); const [sendingInput, setSendingInput] = useState(false); const [draggingImage, setDraggingImage] = useState(false); const [clientDialogContainer, setClientDialogContainer] = useState(null); const [messageDialogOpen, setMessageDialogOpen] = useState(false); 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 ? "请先保存当前改动,再开始对话。" : dynamicVariablesError ? dynamicVariablesError : ""; const startDisabled = status === "connecting" || Boolean(startBlockedMessage); useEffect(() => { return () => { if (pendingImage) URL.revokeObjectURL(pendingImage.previewUrl); }; }, [pendingImage]); function stageImage(file: File) { setInputError(""); if (!vision) { setInputError("请先为助手开启视觉理解,再添加图片。"); return; } if (!file.type.startsWith("image/")) { setInputError("只能添加图片文件。"); return; } if (file.size > DEBUG_IMAGE_MAX_BYTES) { setInputError("图片不能超过 10 MB。"); return; } setPendingImage({ file, previewUrl: URL.createObjectURL(file) }); setInputMode("text"); } async function handleSendInput() { const text = textDraft.trim(); if (!pendingImage) { if (sendText(text)) { setTextDraft(""); setInputError(""); } return; } if (status !== "connected" || sendingInput) return; setSendingInput(true); setInputError(""); let assetToken = ""; let inputId = ""; try { const imageUrl = await fileToDataUrl(pendingImage.file); const asset = await inputAssetsApi.uploadImage(pendingImage.file); assetToken = asset.assetToken; const parts: UserInputPart[] = []; if (text) parts.push({ type: "input_text", text }); parts.push({ type: "input_image", source: { type: "uploaded_asset", asset_token: assetToken }, }); const timestamp = new Date().toISOString(); inputId = createUserInputId(); appendUserImage(inputId, imageUrl, timestamp, text); await sendUserInput(parts, { inputId }); setTextDraft(""); setPendingImage(null); } catch (sendError) { if (inputId) removeUserImage(inputId); if (assetToken) { void inputAssetsApi.remove(assetToken).catch(() => {}); } setInputError( sendError instanceof Error ? sendError.message : "图片发送失败,请重试。", ); } finally { setSendingInput(false); } } function handlePaste(event: React.ClipboardEvent) { const image = Array.from(event.clipboardData.items) .find((item) => item.type.startsWith("image/")) ?.getAsFile(); if (!image) return; event.preventDefault(); stageImage(image); } function handleDragOver(event: React.DragEvent) { if (!hasDraggedImage(event.dataTransfer)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDraggingImage(true); } function handleDragLeave(event: React.DragEvent) { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } setDraggingImage(false); } function handleDrop(event: React.DragEvent) { if (!hasDraggedImage(event.dataTransfer)) return; event.preventDefault(); setDraggingImage(false); const image = Array.from(event.dataTransfer.files).find((file) => file.type.startsWith("image/"), ); if (image) stageImage(image); } function removePendingImage() { if (!sendingInput) { setPendingImage(null); setInputError(""); } } const startConversation = useCallback(async () => { if (!assistantId || hasUnsavedChanges) return; if (dynamicVariablesError) return; await connect({ visionEnabled: vision, dynamicVariables, }); }, [ assistantId, connect, dynamicVariables, dynamicVariablesError, hasUnsavedChanges, vision, ]); return (
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}