Files
ai-video-fullstack/frontend/src/components/assistant-editor/debug-preview.tsx
2026-08-10 13:49:24 +08:00

1814 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}