1745 lines
59 KiB
TypeScript
1745 lines
59 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 { 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 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 [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],
|
||
);
|
||
|
||
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>
|
||
<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}
|
||
/>
|
||
)}
|
||
{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 border-b border-hairline px-5 py-2.5">
|
||
<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>
|
||
<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 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>
|
||
);
|
||
}
|