Files
ai-video-fullstack/frontend/src/components/assistant-editor/debug-preview.tsx
T

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>
);
}