1814 lines
61 KiB
TypeScript
1814 lines
61 KiB
TypeScript
"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<string> {
|
|
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: <Orbit size={14} /> },
|
|
{ style: "nebula", label: "星云", icon: <Sparkles size={14} /> },
|
|
{ style: "bars", label: "频谱", icon: <AudioLines size={14} /> },
|
|
{ style: "wave", label: "波形", icon: <Waves size={14} /> },
|
|
];
|
|
|
|
// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为
|
|
// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。
|
|
const SHOW_VOICE_VIZ = false;
|
|
|
|
function SegmentedIconGroup({
|
|
children,
|
|
label,
|
|
}: {
|
|
children: React.ReactNode;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label={label}
|
|
className="flex items-center gap-0.5 rounded-full border border-hairline bg-canvas-soft p-0.5"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SegmentedIconButton({
|
|
selected,
|
|
label,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
selected: boolean;
|
|
label: string;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
aria-pressed={selected}
|
|
title={label}
|
|
className={[
|
|
"flex h-7 w-7 items-center justify-center rounded-full transition-colors",
|
|
selected
|
|
? "bg-surface-strong text-foreground shadow-sm"
|
|
: "text-muted-soft hover:text-foreground",
|
|
].join(" ")}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<string, DynamicVariableDefinition>;
|
|
}) {
|
|
const preview = useVoicePreview(assistantId, onNodeActive);
|
|
const camera = useCameraPreview();
|
|
const autoTest = useAutoTestRunner();
|
|
const stopAutoTest = autoTest.stop;
|
|
const [debugMode, setDebugMode] = useState<DebugMode>("manual");
|
|
const [showTranscript, setShowTranscript] = useState(false);
|
|
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
|
const [view, setView] = useState<DebugView>("chat");
|
|
const [dynamicVariableValues, setDynamicVariableValues] = useState<
|
|
Record<string, string | number | boolean>
|
|
>({});
|
|
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<string, string | number | boolean> = {};
|
|
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 (
|
|
<aside
|
|
className={overlay
|
|
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-card"
|
|
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex"}
|
|
>
|
|
<div className={`flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-hairline py-3 ${overlay ? "px-4" : "px-5"}`}>
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
{overlay && onClose && (
|
|
<button
|
|
type="button"
|
|
aria-label="关闭调试预览"
|
|
title="关闭"
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
|
onClick={onClose}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
<div className="shrink-0 text-sm font-medium text-foreground">
|
|
调试与预览
|
|
</div>
|
|
{debugMode === "manual" && (
|
|
<>
|
|
<NetworkQualityIndicator
|
|
quality={preview.networkQuality}
|
|
status={preview.status}
|
|
/>
|
|
<DebugConnectionStatus
|
|
status={preview.status}
|
|
micWarning={preview.micWarning}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<ClientToolsPopover tools={preview.clientTools} />
|
|
<CallPreviewLink assistantId={assistantId} />
|
|
{dynamicVariablesEnabled && (
|
|
<DynamicVariableValuesPopover
|
|
entries={dynamicVariableEntries}
|
|
values={dynamicVariableValues}
|
|
sessionValues={preview.sessionVariables}
|
|
readOnly={recording}
|
|
onChange={setDynamicVariableValues}
|
|
/>
|
|
)}
|
|
{debugMode === "manual" && SHOW_VOICE_VIZ && view === "chat" && (
|
|
<>
|
|
{!showTranscript && (
|
|
<SegmentedIconGroup label="可视化样式">
|
|
{VIZ_OPTIONS.map((option) => (
|
|
<SegmentedIconButton
|
|
key={option.style}
|
|
selected={vizStyle === option.style}
|
|
label={`可视化样式:${option.label}`}
|
|
onClick={() => setVizStyle(option.style)}
|
|
>
|
|
{option.icon}
|
|
</SegmentedIconButton>
|
|
))}
|
|
</SegmentedIconGroup>
|
|
)}
|
|
<SegmentedIconGroup label="语音视图">
|
|
<SegmentedIconButton
|
|
selected={!showTranscript}
|
|
label="语音可视化视图"
|
|
onClick={() => setShowTranscript(false)}
|
|
>
|
|
<Mic size={14} />
|
|
</SegmentedIconButton>
|
|
<SegmentedIconButton
|
|
selected={showTranscript}
|
|
label="文字聊天记录视图"
|
|
onClick={() => setShowTranscript(true)}
|
|
>
|
|
<MessageSquareText size={14} />
|
|
</SegmentedIconButton>
|
|
</SegmentedIconGroup>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 space-y-2.5 border-b border-hairline px-5 py-2.5">
|
|
<DebugModeTabs mode={debugMode} onChange={handleModeChange} />
|
|
{debugMode === "manual" && (
|
|
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
|
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{debugMode === "auto" ? (
|
|
<AutoTestPanel autoTest={autoTest} />
|
|
) : (
|
|
<DebugVoicePanel
|
|
view={view}
|
|
onViewChange={setView}
|
|
showTranscript={showTranscript}
|
|
vizStyle={vizStyle}
|
|
assistantId={assistantId}
|
|
preview={preview}
|
|
camera={camera}
|
|
hasUnsavedChanges={hasUnsavedChanges}
|
|
vision={vision}
|
|
dynamicVariables={resolvedDynamicVariables}
|
|
dynamicVariablesError={dynamicVariablesError}
|
|
/>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function AutoTestPanel({
|
|
autoTest,
|
|
}: {
|
|
autoTest: ReturnType<typeof useAutoTestRunner>;
|
|
}) {
|
|
const {
|
|
state,
|
|
selectedCase,
|
|
selectCase,
|
|
clearCase,
|
|
start,
|
|
stop,
|
|
rerun,
|
|
} = autoTest;
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<AutoTestLivePanel testCase={selectedCase} state={state} />
|
|
</div>
|
|
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
|
<AutoTestRunBar
|
|
state={state}
|
|
onSelectCase={selectCase}
|
|
onClearCase={clearCase}
|
|
onStart={start}
|
|
onStop={stop}
|
|
onRerun={rerun}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) {
|
|
const [copiedToolName, setCopiedToolName] = useState<string | null>(null);
|
|
|
|
const copyToolName = useCallback(async (toolName: string) => {
|
|
await navigator.clipboard.writeText(toolName);
|
|
setCopiedToolName(toolName);
|
|
window.setTimeout(
|
|
() =>
|
|
setCopiedToolName((current) =>
|
|
current === toolName ? null : current,
|
|
),
|
|
1600,
|
|
);
|
|
}, []);
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label="查看调试端支持的 Client Tools"
|
|
title="调试端支持的 Client Tools"
|
|
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
>
|
|
<Wrench size={15} />
|
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
|
|
{tools.length}
|
|
</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="end"
|
|
side="bottom"
|
|
className="w-80 space-y-3 rounded-2xl p-4"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
<Wrench size={15} />
|
|
Client Tools
|
|
</div>
|
|
<p className="text-xs leading-5 text-muted-foreground">
|
|
当前调试客户端已注册、能够响应的工具。
|
|
</p>
|
|
</div>
|
|
{tools.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-4 text-center text-xs text-muted-foreground">
|
|
当前没有可用的 Client Tool
|
|
</div>
|
|
) : (
|
|
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
|
|
{tools.map((tool) => (
|
|
<div
|
|
key={tool.name}
|
|
className="rounded-xl border border-hairline bg-canvas-soft p-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-xs font-medium text-foreground">
|
|
{tool.label}
|
|
</div>
|
|
<Badge
|
|
variant="secondary"
|
|
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
|
|
>
|
|
可用
|
|
</Badge>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyToolName(tool.name)}
|
|
className="mt-1 flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-foreground transition-colors hover:bg-surface-strong"
|
|
aria-label={`复制工具名 ${tool.name}`}
|
|
title="点击复制工具名"
|
|
>
|
|
<code className="min-w-0 break-all font-mono text-[11px]">
|
|
{tool.name}
|
|
</code>
|
|
{copiedToolName === tool.name ? (
|
|
<Check size={13} className="shrink-0 text-success" />
|
|
) : (
|
|
<Copy size={13} className="shrink-0 text-muted-soft" />
|
|
)}
|
|
</button>
|
|
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
|
{tool.description}
|
|
</p>
|
|
<div className="mt-2 border-t border-hairline pt-2">
|
|
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-soft">
|
|
参数
|
|
</div>
|
|
<div className="space-y-2">
|
|
{tool.parameters.map((parameter) => (
|
|
<div key={parameter.name} className="text-[11px] leading-4">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<code className="font-mono font-medium text-foreground">
|
|
{parameter.name}
|
|
</code>
|
|
<span className="rounded-full bg-surface-strong px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
|
{parameter.type}
|
|
</span>
|
|
<span
|
|
className={
|
|
parameter.required
|
|
? "text-destructive"
|
|
: "text-muted-soft"
|
|
}
|
|
>
|
|
{parameter.required ? "必填" : "可选"}
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 text-muted-foreground">
|
|
{parameter.description}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function DynamicVariableValuesPopover({
|
|
entries,
|
|
values,
|
|
sessionValues,
|
|
readOnly,
|
|
onChange,
|
|
}: {
|
|
entries: [string, DynamicVariableDefinition][];
|
|
values: Record<string, string | number | boolean>;
|
|
sessionValues: Record<string, string | number | boolean>;
|
|
readOnly: boolean;
|
|
onChange: React.Dispatch<
|
|
React.SetStateAction<Record<string, string | number | boolean>>
|
|
>;
|
|
}) {
|
|
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 (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={readOnly ? "查看本次会话变量" : "设置本次会话变量"}
|
|
title={readOnly ? "查看实时会话变量" : "本次会话变量"}
|
|
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
>
|
|
<Braces size={15} />
|
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
|
|
{entries.length}
|
|
</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="end"
|
|
side="bottom"
|
|
className="w-80 space-y-3 rounded-2xl p-4"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
<Braces size={15} />
|
|
本次会话变量
|
|
</div>
|
|
<p className="text-xs leading-5 text-muted-foreground">
|
|
{readOnly
|
|
? "当前值会在 Action 或工具更新变量后实时刷新。"
|
|
: "这些值只用于下一次调试会话,不会修改助手配置。"}
|
|
</p>
|
|
</div>
|
|
<div className="max-h-72 space-y-3 overflow-y-auto pr-1">
|
|
{entries.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-4 text-center">
|
|
<div className="text-xs font-medium text-foreground">
|
|
当前没有会话变量
|
|
</div>
|
|
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
|
在工作流提示词、节点话术、边条件或 Action 中引用变量后,
|
|
可在这里设置调试值。
|
|
</p>
|
|
</div>
|
|
) : entries.map(([name, definition]) => {
|
|
const value = readOnly
|
|
? sessionValues[name] ?? definition.default ?? ""
|
|
: values[name] ?? definition.default ?? "";
|
|
return (
|
|
<label key={name} className="block space-y-1.5">
|
|
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
|
<code className="font-mono">{name}</code>
|
|
{definition.required && (
|
|
<span className="text-destructive">*</span>
|
|
)}
|
|
<span className="font-normal text-muted-soft">
|
|
{definition.type === "string"
|
|
? "文本"
|
|
: definition.type === "number"
|
|
? "数字"
|
|
: "布尔值"}
|
|
</span>
|
|
</span>
|
|
{definition.type === "boolean" ? (
|
|
<Select
|
|
disabled={readOnly}
|
|
value={value === "" ? "unset" : String(value)}
|
|
onValueChange={(next) =>
|
|
setValue(
|
|
name,
|
|
next === "unset" ? undefined : next === "true",
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="w-full border-hairline-strong bg-background"
|
|
aria-label={`会话变量 ${name}`}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="unset">未设置</SelectItem>
|
|
<SelectItem value="true">True</SelectItem>
|
|
<SelectItem value="false">False</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
disabled={readOnly}
|
|
type={definition.type === "number" ? "number" : "text"}
|
|
value={typeof value === "boolean" ? String(value) : value}
|
|
onChange={(event) => {
|
|
const raw = event.target.value;
|
|
setValue(
|
|
name,
|
|
raw === ""
|
|
? undefined
|
|
: definition.type === "number"
|
|
? Number(raw)
|
|
: raw,
|
|
);
|
|
}}
|
|
aria-label={`会话变量 ${name}`}
|
|
placeholder={definition.required ? "必填" : "可选"}
|
|
className="h-9 border-hairline-strong bg-background text-xs"
|
|
/>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Popover
|
|
onOpenChange={(open) => {
|
|
if (open && assistantId) {
|
|
setCallUrl(
|
|
`${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
|
|
);
|
|
setCopied(false);
|
|
}
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={!assistantId}
|
|
aria-label="打开手机通话链接"
|
|
title={assistantId ? "手机通话链接" : "请先保存助手"}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<Smartphone size={15} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" side="bottom" className="w-80 space-y-3">
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-foreground">手机通话预览</div>
|
|
<p className="text-xs leading-5 text-muted-foreground">
|
|
复制链接并在浏览器中打开,即可进入全屏视频通话界面。
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
readOnly
|
|
value={callUrl}
|
|
aria-label="手机通话链接"
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
className="h-9 min-w-0 flex-1 text-xs"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="secondary"
|
|
className="h-9 w-9"
|
|
onClick={() => void copyLink()}
|
|
aria-label="复制手机通话链接"
|
|
>
|
|
{copied ? <Check size={15} /> : <Copy size={15} />}
|
|
</Button>
|
|
</div>
|
|
{copied && <p className="text-xs text-success">链接已复制</p>}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Select
|
|
value={value || "default"}
|
|
onValueChange={(nextValue) =>
|
|
onSelect(nextValue === "default" ? "" : nextValue)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="h-10 min-w-0 flex-1 justify-between rounded-full border-transparent bg-transparent px-3 text-sm text-foreground shadow-none hover:bg-surface-strong focus-visible:ring-0 data-[size=sm]:h-10"
|
|
aria-label={ariaLabel}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{icon}
|
|
<span className="min-w-0 truncate text-left">
|
|
<SelectValue placeholder={placeholder} />
|
|
</span>
|
|
</span>
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
position="popper"
|
|
side="top"
|
|
align="start"
|
|
className="w-[280px]"
|
|
>
|
|
<SelectItem value="default">{placeholder}</SelectItem>
|
|
{devices.map((device, index) => (
|
|
<SelectItem key={device.deviceId} value={device.deviceId}>
|
|
{device.label || `${fallbackLabel} ${index + 1}`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
|
|
return (
|
|
<DeviceSelectField
|
|
icon={<Mic size={15} className="shrink-0 text-muted-soft" />}
|
|
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<void>;
|
|
}) {
|
|
return (
|
|
<DeviceSelectField
|
|
icon={<Video size={15} className="shrink-0 text-muted-soft" />}
|
|
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 (
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
aria-pressed={selected}
|
|
title={label}
|
|
onClick={onClick}
|
|
className={[
|
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
|
selected
|
|
? "bg-surface-strong text-foreground"
|
|
: "text-muted-soft hover:bg-surface-strong hover:text-foreground",
|
|
].join(" ")}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<string, string | number | boolean>;
|
|
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<DebugInputMode>("mic");
|
|
const [pendingImage, setPendingImage] =
|
|
useState<PendingDebugImage | null>(null);
|
|
const [inputError, setInputError] = useState("");
|
|
const [sendingInput, setSendingInput] = useState(false);
|
|
const [draggingImage, setDraggingImage] = useState(false);
|
|
const [clientDialogContainer, setClientDialogContainer] =
|
|
useState<HTMLDivElement | null>(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<HTMLTextAreaElement>) {
|
|
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<HTMLDivElement>) {
|
|
if (!hasDraggedImage(event.dataTransfer)) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
setDraggingImage(true);
|
|
}
|
|
|
|
function handleDragLeave(event: React.DragEvent<HTMLDivElement>) {
|
|
const nextTarget = event.relatedTarget;
|
|
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
|
return;
|
|
}
|
|
setDraggingImage(false);
|
|
}
|
|
|
|
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
|
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 (
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
|
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
|
<div
|
|
ref={setClientDialogContainer}
|
|
className="relative isolate flex min-h-0 flex-1 flex-col overflow-hidden"
|
|
>
|
|
<ClientMessageDialog
|
|
preview={preview}
|
|
container={clientDialogContainer}
|
|
contained
|
|
onOpenChange={setMessageDialogOpen}
|
|
/>
|
|
<div
|
|
className={[
|
|
"flex min-h-0 flex-1 flex-col overflow-hidden transition-[filter] duration-200",
|
|
messageDialogOpen
|
|
? "pointer-events-none blur-md brightness-[0.92]"
|
|
: "",
|
|
].join(" ")}
|
|
>
|
|
{vision && !showIdleHub ? (
|
|
<DebugVisionWorkspace
|
|
view={view}
|
|
onViewChange={onViewChange}
|
|
messages={messages}
|
|
recording={recording}
|
|
camera={camera}
|
|
videoStream={preview.videoStream}
|
|
photoCaptureVisible={photoCapture.visible}
|
|
photoCapturing={photoCapture.capturing}
|
|
photoError={photoCapture.error}
|
|
onPhotoCapture={photoCapture.capture}
|
|
/>
|
|
) : view === "video" ? (
|
|
showIdleHub ? (
|
|
<DebugIdleHub
|
|
status={status}
|
|
error={error}
|
|
message={startBlockedMessage}
|
|
connect={startConversation}
|
|
/>
|
|
) : (
|
|
<DebugVideoPanel camera={camera} streamOverride={preview.videoStream} />
|
|
)
|
|
) : !SHOW_VOICE_VIZ || showTranscript ? (
|
|
showIdleHub ? (
|
|
<DebugIdleHub
|
|
status={status}
|
|
error={error}
|
|
message={startBlockedMessage}
|
|
connect={startConversation}
|
|
/>
|
|
) : (
|
|
<DebugTranscriptPanel messages={messages} recording={recording} />
|
|
)
|
|
) : (
|
|
<div className="scrollbar-subtle relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
|
|
<div
|
|
className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl"
|
|
style={{
|
|
background:
|
|
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 42%, transparent), color-mix(in srgb, var(--gradient-lavender) 18%, transparent) 48%, transparent 74%)",
|
|
}}
|
|
/>
|
|
|
|
<Badge
|
|
variant="secondary"
|
|
className="relative gap-1.5 rounded-full border border-hairline bg-canvas-soft px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-none"
|
|
>
|
|
<span
|
|
className={[
|
|
"h-1.5 w-1.5 rounded-full",
|
|
recording ? "animate-pulse bg-success" : "bg-muted-soft",
|
|
].join(" ")}
|
|
/>
|
|
{recording ? "会话进行中" : "准备开始"}
|
|
</Badge>
|
|
|
|
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
|
|
{(() => {
|
|
const shared = {
|
|
active: Boolean(localStream),
|
|
stream: localStream,
|
|
className: "relative shrink-0",
|
|
} as const;
|
|
if (vizStyle === "aura")
|
|
return <AuraVisualizer {...shared} size={200} />;
|
|
if (vizStyle === "nebula")
|
|
return <NebulaVisualizer {...shared} size={200} />;
|
|
if (vizStyle === "bars")
|
|
return <SpectrumVisualizer {...shared} size={200} />;
|
|
return <WaveVisualizer {...shared} size={200} />;
|
|
})()}
|
|
</div>
|
|
|
|
<div className="relative max-w-xs space-y-1.5">
|
|
<div className="font-display display-sm text-foreground">
|
|
{status === "connecting"
|
|
? "连接中…"
|
|
: status === "connected"
|
|
? micWarning
|
|
? "仅收听模式"
|
|
: "我在聆听"
|
|
: "开始一次语音对话"}
|
|
</div>
|
|
<p className="mx-auto text-xs leading-5 text-muted-foreground">
|
|
{status === "failed"
|
|
? error ||
|
|
"连接失败,请确认后端已启动且助手已保存后重试。"
|
|
: !assistantId
|
|
? "请先保存助手,再开始语音预览。"
|
|
: hasUnsavedChanges
|
|
? "请先保存当前改动,再开始语音预览。"
|
|
: micWarning
|
|
? `${micWarning} 可接收助手播报,但无法发送语音。`
|
|
: recording
|
|
? "直接说话即可。助手会在您停顿后自然回应。"
|
|
: "测试语音识别、响应速度与助手的播报效果。"}
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
disabled={recording ? status === "connecting" : startDisabled}
|
|
onClick={() => {
|
|
if (recording) {
|
|
disconnect();
|
|
} else {
|
|
void startConversation();
|
|
}
|
|
}}
|
|
className={[
|
|
"relative h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm transition-transform hover:scale-[1.03]",
|
|
recording
|
|
? "bg-destructive text-white hover:bg-destructive/90"
|
|
: "",
|
|
].join(" ")}
|
|
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
|
>
|
|
{status === "connecting" ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : recording ? (
|
|
<PhoneOff size={18} />
|
|
) : (
|
|
<Mic size={18} />
|
|
)}
|
|
{recording ? "结束对话" : "开始对话"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
|
<div className="flex items-end gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={[
|
|
"relative flex min-h-10 min-w-0 items-end gap-1 overflow-hidden rounded-[1.4rem] border bg-background px-2 transition-colors",
|
|
draggingImage
|
|
? "border-foreground"
|
|
: "border-hairline-strong",
|
|
].join(" ")}
|
|
>
|
|
<div className="mb-1 flex shrink-0 items-center gap-0.5 rounded-full bg-canvas-soft p-0.5">
|
|
<DebugInputModeButton
|
|
selected={inputMode === "mic"}
|
|
label="选择麦克风设备"
|
|
onClick={() => setInputMode("mic")}
|
|
>
|
|
<Mic size={15} />
|
|
</DebugInputModeButton>
|
|
<DebugInputModeButton
|
|
selected={inputMode === "text"}
|
|
label="文字输入"
|
|
onClick={() => setInputMode("text")}
|
|
>
|
|
<MessageSquareText size={15} />
|
|
</DebugInputModeButton>
|
|
</div>
|
|
{inputMode === "mic" ? (
|
|
<div className="flex min-w-0 flex-1">
|
|
<MicrophoneDeviceField preview={preview} />
|
|
</div>
|
|
) : (
|
|
<div className="min-w-0 flex-1 py-1">
|
|
{pendingImage && (
|
|
<div className="flex items-center gap-2 px-2 pb-1 pt-0.5">
|
|
<div className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-hairline bg-canvas-soft">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={pendingImage.previewUrl}
|
|
alt="待发送图片预览"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label="移除待发送图片"
|
|
title="移除图片"
|
|
disabled={sendingInput}
|
|
onClick={removePendingImage}
|
|
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm transition-colors hover:bg-background disabled:cursor-not-allowed"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
{sendingInput && (
|
|
<span className="absolute inset-0 flex items-center justify-center bg-background/65">
|
|
<Loader2 size={18} className="animate-spin" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-xs font-medium text-foreground">
|
|
{pendingImage.file.name || "粘贴的图片"}
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] text-muted-soft">
|
|
按 Enter 上传并发送
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Textarea
|
|
rows={1}
|
|
value={textDraft}
|
|
disabled={status !== "connected" || sendingInput}
|
|
onChange={(event) => {
|
|
setTextDraft(event.target.value);
|
|
if (inputError) setInputError("");
|
|
}}
|
|
onPaste={handlePaste}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
event.key === "Enter" &&
|
|
!event.shiftKey &&
|
|
!event.nativeEvent.isComposing
|
|
) {
|
|
event.preventDefault();
|
|
void handleSendInput();
|
|
}
|
|
}}
|
|
placeholder={
|
|
status === "connected"
|
|
? vision
|
|
? "输入文字,或粘贴 / 拖入图片…"
|
|
: "输入文字发送给助手,将打断当前播报…"
|
|
: "开始对话后可输入文字…"
|
|
}
|
|
className="max-h-24 min-h-8 resize-none overflow-y-auto border-transparent bg-transparent px-2 py-1 text-sm leading-6 text-foreground shadow-none outline-none placeholder:text-muted-soft focus-visible:ring-0 disabled:opacity-100"
|
|
/>
|
|
</div>
|
|
)}
|
|
{draggingImage && (
|
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-[1.4rem] bg-background/95 text-xs font-medium text-foreground">
|
|
<ImageIcon size={16} />
|
|
松开以添加图片
|
|
</div>
|
|
)}
|
|
</div>
|
|
{inputError && (
|
|
<p className="px-2 pt-1 text-[11px] leading-4 text-destructive">
|
|
{inputError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{inputMode === "text" && (
|
|
<Button
|
|
size="icon"
|
|
className="h-10 w-10 shrink-0 rounded-full"
|
|
aria-label={sendingInput ? "正在发送图片" : "发送调试消息"}
|
|
disabled={
|
|
status !== "connected" ||
|
|
sendingInput ||
|
|
(!textDraft.trim() && !pendingImage)
|
|
}
|
|
onClick={() => void handleSendInput()}
|
|
>
|
|
{sendingInput ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<Send size={16} />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{!showIdleHub && (
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
<Button
|
|
size="sm"
|
|
disabled={recording ? status === "connecting" : startDisabled}
|
|
onClick={() => {
|
|
if (recording) {
|
|
disconnect();
|
|
} else {
|
|
void startConversation();
|
|
}
|
|
}}
|
|
className={[
|
|
"h-10 gap-1.5 rounded-full px-4",
|
|
recording
|
|
? "bg-destructive text-white hover:bg-destructive/90"
|
|
: "",
|
|
].join(" ")}
|
|
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
|
>
|
|
{status === "connecting" ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : recording ? (
|
|
<PhoneOff size={14} />
|
|
) : (
|
|
<Mic size={14} />
|
|
)}
|
|
{recording ? "结束对话" : "开始对话"}
|
|
</Button>
|
|
{!recording && startBlockedMessage && (
|
|
<span className="max-w-40 text-right text-[11px] leading-4 text-destructive">
|
|
{startBlockedMessage}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 底部双轨波形默认收起;展开状态只由用户操作控制。 */}
|
|
<WaveformTimelinePanel
|
|
defaultOpen={false}
|
|
userStream={localStream}
|
|
agentStream={remoteStream}
|
|
active={status === "connected"}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 空闲态只保留一个明确入口,避免中间区域显得拥挤。
|
|
function DebugIdleHub({
|
|
status,
|
|
error,
|
|
message,
|
|
connect,
|
|
}: {
|
|
status: VoicePreviewStatus;
|
|
error: string | null;
|
|
message: string;
|
|
connect: () => Promise<void>;
|
|
}) {
|
|
const helperText =
|
|
message ||
|
|
(status === "failed"
|
|
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
|
|
: "");
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-6 py-8 text-center">
|
|
<div className="relative inline-flex">
|
|
{!message && (
|
|
<>
|
|
<span
|
|
aria-hidden
|
|
className="mobile-call-ripple pointer-events-none absolute inset-0 rounded-full bg-primary/35"
|
|
/>
|
|
<span
|
|
aria-hidden
|
|
className="mobile-call-ripple mobile-call-ripple-delay pointer-events-none absolute inset-0 rounded-full bg-primary/25"
|
|
/>
|
|
</>
|
|
)}
|
|
<Button
|
|
disabled={Boolean(message)}
|
|
onClick={() => void connect()}
|
|
className="relative z-10 h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm"
|
|
aria-label={status === "failed" ? "重新连接" : "开始语音测试"}
|
|
>
|
|
<Mic size={18} />
|
|
{status === "failed" ? "重新连接" : "开始对话"}
|
|
</Button>
|
|
</div>
|
|
{helperText && (
|
|
<p
|
|
className={[
|
|
"max-w-xs text-xs leading-5",
|
|
message ? "text-destructive" : "text-muted-foreground",
|
|
].join(" ")}
|
|
>
|
|
{helperText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DebugVisionWorkspace({
|
|
view,
|
|
onViewChange,
|
|
messages,
|
|
recording,
|
|
camera,
|
|
videoStream,
|
|
photoCaptureVisible,
|
|
photoCapturing,
|
|
photoError,
|
|
onPhotoCapture,
|
|
}: {
|
|
view: DebugView;
|
|
onViewChange: (view: DebugView) => void;
|
|
messages: ChatMessage[];
|
|
recording: boolean;
|
|
camera: CameraPreview;
|
|
videoStream: MediaStream | null;
|
|
photoCaptureVisible: boolean;
|
|
photoCapturing: boolean;
|
|
photoError: string | null;
|
|
onPhotoCapture: () => Promise<void>;
|
|
}) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [position, setPosition] = useState({ x: 16, y: 16 });
|
|
const positionedRef = useRef(false);
|
|
const dragRef = useRef<{
|
|
pointerId: number;
|
|
startX: number;
|
|
startY: number;
|
|
originX: number;
|
|
originY: number;
|
|
moved: boolean;
|
|
} | null>(null);
|
|
const latestMessage = [...messages]
|
|
.reverse()
|
|
.find(
|
|
(message) =>
|
|
message.content.trim() || (message.attachments?.length ?? 0) > 0,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const rect = container.getBoundingClientRect();
|
|
if (!positionedRef.current) {
|
|
positionedRef.current = true;
|
|
setPosition({ x: Math.max(16, rect.width - 192), y: 16 });
|
|
return;
|
|
}
|
|
setPosition((current) => ({
|
|
x: Math.min(Math.max(16, current.x), Math.max(16, rect.width - 192)),
|
|
y: Math.min(Math.max(16, current.y), Math.max(16, rect.height - 116)),
|
|
}));
|
|
}, [view]);
|
|
|
|
const moveFloatingVideo = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
|
const drag = dragRef.current;
|
|
const container = containerRef.current;
|
|
if (!drag || !container || drag.pointerId !== event.pointerId) return;
|
|
const dx = event.clientX - drag.startX;
|
|
const dy = event.clientY - drag.startY;
|
|
if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true;
|
|
const rect = container.getBoundingClientRect();
|
|
setPosition({
|
|
x: Math.min(Math.max(16, drag.originX + dx), Math.max(16, rect.width - 192)),
|
|
y: Math.min(Math.max(16, drag.originY + dy), Math.max(16, rect.height - 116)),
|
|
});
|
|
}, []);
|
|
|
|
if (view === "video") {
|
|
return (
|
|
<div ref={containerRef} className="relative flex min-h-0 flex-1 overflow-hidden bg-black">
|
|
<DebugVideoPanel camera={camera} streamOverride={videoStream} />
|
|
<button
|
|
type="button"
|
|
onClick={() => onViewChange("chat")}
|
|
className="absolute left-4 right-4 top-4 z-10 flex h-16 items-center gap-3 overflow-hidden rounded-2xl border border-white/15 bg-[#07101a]/55 px-4 py-2 text-left text-white shadow-lg backdrop-blur-md transition-colors hover:bg-[#07101a]/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
|
aria-label="切换到聊天记录"
|
|
>
|
|
<MessageSquareText size={17} className="shrink-0 text-white/70" />
|
|
<span className="line-clamp-2 min-w-0 flex-1 text-xs leading-5 text-white/90">
|
|
<span className="mr-1.5 font-medium text-white/55">
|
|
{latestMessage?.role === "user" ? "我:" : "助手:"}
|
|
</span>
|
|
<span>
|
|
{latestMessage?.content ||
|
|
(latestMessage?.attachments?.length
|
|
? "发送了一张照片"
|
|
: "暂无消息,点击返回聊天记录")}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{photoCaptureVisible && (
|
|
<DebugPhotoCaptureButton
|
|
capturing={photoCapturing}
|
|
enabled={Boolean(videoStream)}
|
|
error={photoError}
|
|
onCapture={onPhotoCapture}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
<DebugTranscriptPanel messages={messages} recording={recording} />
|
|
<button
|
|
type="button"
|
|
style={{ left: position.x, top: position.y }}
|
|
className="absolute z-10 h-[100px] w-44 touch-none overflow-hidden rounded-2xl border border-white/20 bg-black text-left shadow-xl ring-1 ring-black/10 transition-shadow hover:shadow-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
aria-label="拖动摄像头浮窗,点击切换到视频流"
|
|
onPointerDown={(event) => {
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
dragRef.current = {
|
|
pointerId: event.pointerId,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
originX: position.x,
|
|
originY: position.y,
|
|
moved: false,
|
|
};
|
|
}}
|
|
onPointerMove={moveFloatingVideo}
|
|
onPointerUp={(event) => {
|
|
const drag = dragRef.current;
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
dragRef.current = null;
|
|
if (drag && !drag.moved) onViewChange("video");
|
|
}}
|
|
onPointerCancel={() => {
|
|
dragRef.current = null;
|
|
}}
|
|
>
|
|
<DebugVideoPanel camera={camera} streamOverride={videoStream} compact />
|
|
</button>
|
|
{photoCaptureVisible && (
|
|
<DebugPhotoCaptureButton
|
|
capturing={photoCapturing}
|
|
enabled={Boolean(videoStream)}
|
|
error={photoError}
|
|
onCapture={onPhotoCapture}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DebugPhotoCaptureButton({
|
|
capturing,
|
|
enabled,
|
|
error,
|
|
onCapture,
|
|
}: {
|
|
capturing: boolean;
|
|
enabled: boolean;
|
|
error: string | null;
|
|
onCapture: () => Promise<void>;
|
|
}) {
|
|
return (
|
|
<div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center gap-2">
|
|
{error && (
|
|
<div className="max-w-xs rounded-full border border-destructive/20 bg-background/90 px-3 py-1 text-xs text-destructive shadow-sm backdrop-blur">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
onClick={() => void onCapture()}
|
|
disabled={capturing || !enabled}
|
|
aria-label={capturing ? "正在提交照片" : "拍照并发送"}
|
|
title={capturing ? "正在提交照片" : "拍照并发送"}
|
|
className="size-12 rounded-full shadow-lg"
|
|
>
|
|
{capturing ? (
|
|
<Loader2 className="size-5 animate-spin" />
|
|
) : (
|
|
<ScanLine className="size-5" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DebugVideoPanel({
|
|
camera,
|
|
streamOverride,
|
|
compact = false,
|
|
}: {
|
|
camera: CameraPreview;
|
|
streamOverride?: MediaStream | null;
|
|
compact?: boolean;
|
|
}) {
|
|
const { stream, error, starting, active } = camera;
|
|
const effectiveStream = streamOverride ?? stream;
|
|
const effectiveActive = Boolean(effectiveStream) || active;
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (videoRef.current) videoRef.current.srcObject = effectiveStream;
|
|
}, [effectiveStream]);
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
"relative flex min-h-0 flex-1 items-center justify-center overflow-hidden",
|
|
compact ? "h-full w-full" : "",
|
|
effectiveActive ? "bg-black" : "bg-canvas-soft",
|
|
].join(" ")}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className={[
|
|
"h-full w-full -scale-x-100",
|
|
compact ? "object-cover" : "object-contain",
|
|
effectiveActive ? "" : "hidden",
|
|
].join(" ")}
|
|
/>
|
|
{!effectiveActive ? (
|
|
<div className="flex max-w-xs flex-col items-center gap-2 px-6 text-center">
|
|
<Video size={28} className="text-muted-soft" />
|
|
<div className="text-sm font-medium text-foreground">
|
|
{starting ? "正在开启摄像头…" : "摄像头不可用"}
|
|
</div>
|
|
{error && (
|
|
<p className="text-xs leading-5 text-muted-foreground">{error}</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DebugConnectionStatus({
|
|
status,
|
|
micWarning,
|
|
}: {
|
|
status: VoicePreviewStatus;
|
|
micWarning: string | null;
|
|
}) {
|
|
const recording = status === "connecting" || status === "connected";
|
|
const label =
|
|
status === "connecting"
|
|
? "连接中…"
|
|
: status === "connected"
|
|
? micWarning
|
|
? "仅收听"
|
|
: "进行中"
|
|
: status === "failed"
|
|
? "连接失败"
|
|
: "准备开始";
|
|
|
|
return (
|
|
<span className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground">
|
|
<span
|
|
className={[
|
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
recording
|
|
? "animate-pulse bg-success"
|
|
: status === "failed"
|
|
? "bg-destructive"
|
|
: "bg-muted-soft",
|
|
].join(" ")}
|
|
/>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
|
|
function formatMessageTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
function DebugTranscriptPanel({
|
|
messages,
|
|
recording = false,
|
|
}: {
|
|
messages: ChatMessage[];
|
|
recording?: boolean;
|
|
}) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, []);
|
|
|
|
// Scroll when message list changes (before paint).
|
|
useLayoutEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, scrollToBottom]);
|
|
|
|
// Images load after the message row mounts; re-scroll when content height grows.
|
|
useEffect(() => {
|
|
const content = contentRef.current;
|
|
if (!content) return;
|
|
const observer = new ResizeObserver(() => scrollToBottom());
|
|
observer.observe(content);
|
|
return () => observer.disconnect();
|
|
}, [scrollToBottom]);
|
|
|
|
if (messages.length === 0) {
|
|
return (
|
|
<div
|
|
className={[
|
|
"scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-3",
|
|
recording ? "items-center justify-center" : "pt-5",
|
|
].join(" ")}
|
|
>
|
|
<div className="text-center">
|
|
<MessageSquareText size={22} className="mx-auto text-muted-soft" />
|
|
<div className="mt-2 text-sm font-medium text-foreground">
|
|
{recording ? "暂无聊天记录" : "尚未开始对话"}
|
|
</div>
|
|
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
|
|
{recording
|
|
? "开口说话或在下方输入文字,对话内容会实时显示在这里。"
|
|
: "点击「开始对话」后,语音与文字消息会实时显示在这里。"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
|
|
>
|
|
<div ref={contentRef} className="flex flex-col gap-4">
|
|
{messages.map((message) => {
|
|
const time = formatMessageTime(message.timestamp);
|
|
return message.role === "assistant" ? (
|
|
<div
|
|
key={message.id}
|
|
className="flex max-w-[88%] flex-col gap-1 self-start"
|
|
>
|
|
<span className="px-1 text-[11px] text-muted-soft">
|
|
助手{time ? ` · ${time}` : ""}
|
|
</span>
|
|
<div className="whitespace-pre-wrap rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
|
|
{message.content || (message.streaming ? "…" : "")}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={message.id}
|
|
className="flex max-w-[88%] flex-col items-end gap-1 self-end"
|
|
>
|
|
<span className="px-1 text-[11px] text-muted-soft">
|
|
我{time ? ` · ${time}` : ""}
|
|
</span>
|
|
<div
|
|
className={[
|
|
"overflow-hidden whitespace-pre-wrap rounded-2xl rounded-tr-sm bg-primary text-sm leading-6 text-primary-foreground",
|
|
message.attachments?.length ? "p-1" : "px-4 py-2.5",
|
|
].join(" ")}
|
|
>
|
|
{message.attachments?.map((attachment) => (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
key={attachment.id}
|
|
src={attachment.url}
|
|
alt={attachment.alt}
|
|
className="max-h-72 w-full rounded-[0.8rem] object-cover"
|
|
onLoad={scrollToBottom}
|
|
/>
|
|
))}
|
|
{message.content && (
|
|
<div className={message.attachments?.length ? "px-3 py-2" : ""}>
|
|
{message.content}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|