Enhance AssistantPage with subtle scrollbar styling and improve voice preview handling
- Introduce a new utility class `.scrollbar-subtle` in `globals.css` for a refined scrollbar appearance. - Update `AssistantPage` to apply the subtle scrollbar styling to overflow containers, enhancing the user interface. - Reset message state and sequence in `useVoicePreview` hook upon resource release, ensuring clean state management.
This commit is contained in:
@@ -232,3 +232,43 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-subtle {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--muted-soft) 48%,
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-subtle::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-subtle::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-subtle::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--muted-soft) 42%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--muted-soft) 64%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
Waves,
|
Waves,
|
||||||
Bug,
|
Bug,
|
||||||
Video,
|
Video,
|
||||||
SlidersHorizontal,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -1321,7 +1320,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Boxes size={18} />}
|
icon={<Boxes size={18} />}
|
||||||
title="Dify 应用配置"
|
title="Dify 应用配置"
|
||||||
@@ -1420,7 +1419,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Database size={18} />}
|
icon={<Database size={18} />}
|
||||||
title="FastGPT 应用配置"
|
title="FastGPT 应用配置"
|
||||||
@@ -1534,7 +1533,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Terminal size={18} />}
|
icon={<Terminal size={18} />}
|
||||||
title="OpenCode 服务配置"
|
title="OpenCode 服务配置"
|
||||||
@@ -1652,7 +1651,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
@@ -1727,8 +1726,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 border-t border-hairline pt-4">
|
<div className="mt-4 border-t border-hairline pt-4">
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
|
icon={<Eye size={16} />}
|
||||||
title="视觉理解"
|
title="视觉理解"
|
||||||
description="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头"
|
hint="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头。"
|
||||||
checked={visionEnabled}
|
checked={visionEnabled}
|
||||||
onChange={setVisionEnabled}
|
onChange={setVisionEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -1841,6 +1841,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
|||||||
|
|
||||||
// 调试面板顶部主视图:聊天记录 / 视频流(仅视觉理解开启时可选)
|
// 调试面板顶部主视图:聊天记录 / 视频流(仅视觉理解开启时可选)
|
||||||
type DebugView = "chat" | "video";
|
type DebugView = "chat" | "video";
|
||||||
|
type DebugInputMode = "mic" | "text";
|
||||||
|
|
||||||
const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
|
const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
|
||||||
[
|
[
|
||||||
@@ -1917,38 +1918,29 @@ function DebugDrawer({
|
|||||||
const camera = useCameraPreview();
|
const camera = useCameraPreview();
|
||||||
const [showTranscript, setShowTranscript] = useState(false);
|
const [showTranscript, setShowTranscript] = useState(false);
|
||||||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
||||||
const [view, setView] = useState<DebugView>(vision ? "video" : "chat");
|
const [view, setView] = useState<DebugView>("chat");
|
||||||
const effectiveView: DebugView = vision ? view : "chat";
|
const effectiveView: DebugView = vision ? view : "chat";
|
||||||
const stopCamera = camera.stop;
|
const stopCamera = camera.stop;
|
||||||
|
const startCamera = camera.start;
|
||||||
|
const cameraShouldRun =
|
||||||
|
vision &&
|
||||||
|
(preview.status === "connecting" || preview.status === "connected");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
if (!vision) {
|
||||||
setView(vision ? "video" : "chat");
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
if (!vision) stopCamera();
|
setView("chat");
|
||||||
|
stopCamera();
|
||||||
|
}
|
||||||
}, [vision, stopCamera]);
|
}, [vision, stopCamera]);
|
||||||
|
|
||||||
const { start: startCamera, active: cameraActive, starting: cameraStarting } =
|
|
||||||
camera;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (cameraShouldRun) {
|
||||||
vision &&
|
|
||||||
effectiveView === "video" &&
|
|
||||||
!cameraActive &&
|
|
||||||
!cameraStarting
|
|
||||||
) {
|
|
||||||
void startCamera();
|
void startCamera();
|
||||||
|
} else {
|
||||||
|
stopCamera();
|
||||||
}
|
}
|
||||||
}, [
|
}, [cameraShouldRun, camera.deviceId, startCamera, stopCamera]);
|
||||||
vision,
|
|
||||||
effectiveView,
|
|
||||||
cameraActive,
|
|
||||||
cameraStarting,
|
|
||||||
startCamera,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (effectiveView !== "video") stopCamera();
|
|
||||||
}, [effectiveView, stopCamera]);
|
|
||||||
|
|
||||||
const containerClass = asSheet
|
const containerClass = asSheet
|
||||||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
@@ -2001,7 +1993,6 @@ function DebugDrawer({
|
|||||||
</SegmentedIconGroup>
|
</SegmentedIconGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DeviceMenu preview={preview} camera={camera} vision={vision} />
|
|
||||||
<SegmentedIconGroup label="预览视图">
|
<SegmentedIconGroup label="预览视图">
|
||||||
<SegmentedIconButton
|
<SegmentedIconButton
|
||||||
selected={effectiveView === "chat"}
|
selected={effectiveView === "chat"}
|
||||||
@@ -2022,7 +2013,13 @@ function DebugDrawer({
|
|||||||
</SegmentedIconGroup>
|
</SegmentedIconGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{vision && (
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DebugVoicePanel
|
<DebugVoicePanel
|
||||||
view={effectiveView}
|
view={effectiveView}
|
||||||
showTranscript={showTranscript}
|
showTranscript={showTranscript}
|
||||||
@@ -2030,75 +2027,15 @@ function DebugDrawer({
|
|||||||
assistantId={assistantId}
|
assistantId={assistantId}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
camera={camera}
|
camera={camera}
|
||||||
|
vision={vision}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceMenu({
|
function DeviceSelectField({
|
||||||
preview,
|
|
||||||
camera,
|
|
||||||
vision,
|
|
||||||
}: {
|
|
||||||
preview: VoicePreview;
|
|
||||||
camera: CameraPreview;
|
|
||||||
vision: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="设备设置"
|
|
||||||
title="设备设置"
|
|
||||||
className="flex h-8 items-center gap-1.5 rounded-full border border-hairline bg-canvas-soft px-2.5 text-xs text-muted-soft transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={14} />
|
|
||||||
<span className="hidden sm:inline">设备</span>
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-72 p-0">
|
|
||||||
<div className="border-b border-hairline px-3 py-2.5">
|
|
||||||
<div className="text-xs font-medium text-foreground">输入设备</div>
|
|
||||||
<p className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
|
||||||
{vision
|
|
||||||
? "选择麦克风与摄像头,分别用于语音与视觉预览。"
|
|
||||||
: "选择麦克风用于语音对话。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 p-3">
|
|
||||||
<DeviceSelectRow
|
|
||||||
icon={<Mic size={13} className="shrink-0 text-muted-soft" />}
|
|
||||||
label="麦克风"
|
|
||||||
placeholder="默认麦克风"
|
|
||||||
fallbackLabel="麦克风"
|
|
||||||
value={preview.selectedDeviceId}
|
|
||||||
devices={preview.audioInputs}
|
|
||||||
onSelect={(id) => preview.selectDevice(id)}
|
|
||||||
/>
|
|
||||||
{vision && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-hairline" />
|
|
||||||
<DeviceSelectRow
|
|
||||||
icon={<Video size={13} className="shrink-0 text-muted-soft" />}
|
|
||||||
label="摄像头"
|
|
||||||
placeholder="默认摄像头"
|
|
||||||
fallbackLabel="摄像头"
|
|
||||||
value={camera.deviceId}
|
|
||||||
devices={camera.devices}
|
|
||||||
onSelect={(id) => void camera.selectCamera(id)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceSelectRow({
|
|
||||||
icon,
|
icon,
|
||||||
label,
|
ariaLabel,
|
||||||
placeholder,
|
placeholder,
|
||||||
fallbackLabel,
|
fallbackLabel,
|
||||||
value,
|
value,
|
||||||
@@ -2106,7 +2043,7 @@ function DeviceSelectRow({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
ariaLabel: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
fallbackLabel: string;
|
fallbackLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -2114,30 +2051,96 @@ function DeviceSelectRow({
|
|||||||
onSelect: (deviceId: string) => void;
|
onSelect: (deviceId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<Select
|
||||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
value={value || "default"}
|
||||||
<Select
|
onValueChange={(nextValue) =>
|
||||||
value={value || "default"}
|
onSelect(nextValue === "default" ? "" : nextValue)
|
||||||
onValueChange={(v) => onSelect(v === "default" ? "" : v)}
|
}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
size="sm"
|
|
||||||
className="w-full gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
|
|
||||||
aria-label={`选择${label}`}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
<SelectValue placeholder={placeholder} />
|
<span className="min-w-0 truncate text-left">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder={placeholder} />
|
||||||
<SelectContent>
|
</span>
|
||||||
<SelectItem value="default">{placeholder}</SelectItem>
|
</span>
|
||||||
{devices.map((device, index) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
<SelectContent
|
||||||
{device.label || `${fallbackLabel} ${index + 1}`}
|
position="popper"
|
||||||
</SelectItem>
|
side="top"
|
||||||
))}
|
align="start"
|
||||||
</SelectContent>
|
className="w-[280px]"
|
||||||
</Select>
|
>
|
||||||
</div>
|
<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 }: { camera: CameraPreview }) {
|
||||||
|
return (
|
||||||
|
<DeviceSelectField
|
||||||
|
icon={<Video size={15} className="shrink-0 text-muted-soft" />}
|
||||||
|
ariaLabel="选择摄像头"
|
||||||
|
placeholder="默认摄像头"
|
||||||
|
fallbackLabel="摄像头"
|
||||||
|
value={camera.deviceId}
|
||||||
|
devices={camera.devices}
|
||||||
|
onSelect={(deviceId) => void 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2148,6 +2151,7 @@ function DebugVoicePanel({
|
|||||||
assistantId,
|
assistantId,
|
||||||
preview,
|
preview,
|
||||||
camera,
|
camera,
|
||||||
|
vision,
|
||||||
}: {
|
}: {
|
||||||
view: DebugView;
|
view: DebugView;
|
||||||
showTranscript: boolean;
|
showTranscript: boolean;
|
||||||
@@ -2155,6 +2159,7 @@ function DebugVoicePanel({
|
|||||||
assistantId: string | null;
|
assistantId: string | null;
|
||||||
preview: VoicePreview;
|
preview: VoicePreview;
|
||||||
camera: CameraPreview;
|
camera: CameraPreview;
|
||||||
|
vision: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
@@ -2170,11 +2175,13 @@ function DebugVoicePanel({
|
|||||||
} = preview;
|
} = preview;
|
||||||
const recording = status === "connecting" || status === "connected";
|
const recording = status === "connecting" || status === "connected";
|
||||||
const [textDraft, setTextDraft] = useState("");
|
const [textDraft, setTextDraft] = useState("");
|
||||||
|
const [inputMode, setInputMode] = useState<DebugInputMode>("mic");
|
||||||
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
|
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
|
||||||
|
const idleOrFailed = status === "idle" || status === "failed";
|
||||||
const showIdleHub =
|
const showIdleHub =
|
||||||
inChatView &&
|
|
||||||
messages.length === 0 &&
|
messages.length === 0 &&
|
||||||
(status === "idle" || status === "failed");
|
idleOrFailed &&
|
||||||
|
(inChatView || (vision && view === "video"));
|
||||||
|
|
||||||
function handleSendText() {
|
function handleSendText() {
|
||||||
if (sendText(textDraft)) {
|
if (sendText(textDraft)) {
|
||||||
@@ -2186,7 +2193,14 @@ function DebugVoicePanel({
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
||||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||||
{view === "video" ? (
|
{view === "video" && showIdleHub ? (
|
||||||
|
<DebugIdleHub
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
assistantId={assistantId}
|
||||||
|
connect={connect}
|
||||||
|
/>
|
||||||
|
) : view === "video" ? (
|
||||||
<DebugVideoPanel camera={camera} />
|
<DebugVideoPanel camera={camera} />
|
||||||
) : !SHOW_VOICE_VIZ || showTranscript ? (
|
) : !SHOW_VOICE_VIZ || showTranscript ? (
|
||||||
showIdleHub ? (
|
showIdleHub ? (
|
||||||
@@ -2200,7 +2214,7 @@ function DebugVoicePanel({
|
|||||||
<DebugTranscriptPanel messages={messages} recording={recording} />
|
<DebugTranscriptPanel messages={messages} recording={recording} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="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="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
|
<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"
|
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={{
|
style={{
|
||||||
@@ -2293,34 +2307,63 @@ function DebugVoicePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Textarea
|
<div
|
||||||
rows={1}
|
className={[
|
||||||
value={textDraft}
|
"flex h-10 min-w-0 items-center gap-1 rounded-[1.4rem] border border-hairline-strong bg-background px-2",
|
||||||
disabled={status !== "connected"}
|
"flex-1",
|
||||||
onChange={(event) => setTextDraft(event.target.value)}
|
].join(" ")}
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
||||||
event.preventDefault();
|
|
||||||
handleSendText();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
status === "connected"
|
|
||||||
? "输入文字发送给助手,将打断当前播报…"
|
|
||||||
: "开始对话后可输入文字…"
|
|
||||||
}
|
|
||||||
className="max-h-24 min-h-10 flex-1 resize-none border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label="发送调试消息"
|
|
||||||
disabled={status !== "connected" || !textDraft.trim()}
|
|
||||||
onClick={handleSendText}
|
|
||||||
>
|
>
|
||||||
<Send size={16} />
|
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-canvas-soft p-0.5">
|
||||||
</Button>
|
<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" ? (
|
||||||
|
<MicrophoneDeviceField preview={preview} />
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
rows={1}
|
||||||
|
value={textDraft}
|
||||||
|
disabled={status !== "connected"}
|
||||||
|
onChange={(event) => setTextDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSendText();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
status === "connected"
|
||||||
|
? "输入文字发送给助手,将打断当前播报…"
|
||||||
|
: "开始对话后可输入文字…"
|
||||||
|
}
|
||||||
|
className="h-8 min-h-8 flex-1 resize-none overflow-hidden 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>
|
||||||
|
{inputMode === "text" && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 shrink-0 rounded-full"
|
||||||
|
aria-label="发送调试消息"
|
||||||
|
disabled={status !== "connected" || !textDraft.trim()}
|
||||||
|
onClick={handleSendText}
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!showIdleHub && (
|
{!showIdleHub && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -2333,7 +2376,7 @@ function DebugVoicePanel({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"shrink-0 gap-1.5 rounded-full",
|
"h-10 shrink-0 gap-1.5 rounded-full px-4",
|
||||||
recording
|
recording
|
||||||
? "bg-destructive text-white hover:bg-destructive/90"
|
? "bg-destructive text-white hover:bg-destructive/90"
|
||||||
: "",
|
: "",
|
||||||
@@ -2356,7 +2399,7 @@ function DebugVoicePanel({
|
|||||||
{/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
|
{/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
|
||||||
<WaveformTimelinePanel
|
<WaveformTimelinePanel
|
||||||
key={status === "connected" ? "active" : "idle"}
|
key={status === "connected" ? "active" : "idle"}
|
||||||
defaultOpen={status === "connected"}
|
defaultOpen={false}
|
||||||
userStream={localStream}
|
userStream={localStream}
|
||||||
agentStream={remoteStream}
|
agentStream={remoteStream}
|
||||||
active={status === "connected"}
|
active={status === "connected"}
|
||||||
@@ -2365,10 +2408,9 @@ function DebugVoicePanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空闲态:图标 + 说明 + 开始按钮合一,替代「撑满高度的空 transcript + 底部细控制条」
|
// 空闲态只保留一个明确入口,避免中间区域显得拥挤。
|
||||||
function DebugIdleHub({
|
function DebugIdleHub({
|
||||||
status,
|
status,
|
||||||
error,
|
|
||||||
assistantId,
|
assistantId,
|
||||||
connect,
|
connect,
|
||||||
}: {
|
}: {
|
||||||
@@ -2377,42 +2419,23 @@ function DebugIdleHub({
|
|||||||
assistantId: string | null;
|
assistantId: string | null;
|
||||||
connect: () => Promise<void>;
|
connect: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const hint =
|
|
||||||
status === "failed"
|
|
||||||
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
|
|
||||||
: !assistantId
|
|
||||||
? "请先保存助手,再开始语音预览。"
|
|
||||||
: "测试语音识别、响应速度与助手的播报效果。";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
|
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
|
||||||
<div className="flex max-w-xs flex-col items-center gap-3 text-center">
|
<Button
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-canvas-soft">
|
disabled={!assistantId}
|
||||||
<Mic size={22} className="text-muted-soft" />
|
onClick={() => void connect()}
|
||||||
</div>
|
className="h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm"
|
||||||
<div className="font-display display-sm text-foreground">
|
aria-label={status === "failed" ? "重新连接" : "开始语音测试"}
|
||||||
{status === "failed" ? "连接失败" : "开始一次语音对话"}
|
>
|
||||||
</div>
|
<Mic size={18} />
|
||||||
<p className="text-xs leading-5 text-muted-foreground">{hint}</p>
|
{status === "failed" ? "重新连接" : "开始对话"}
|
||||||
<Button
|
</Button>
|
||||||
disabled={!assistantId}
|
|
||||||
onClick={() => void connect()}
|
|
||||||
className="h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm"
|
|
||||||
aria-label={status === "failed" ? "重新连接" : "开始语音测试"}
|
|
||||||
>
|
|
||||||
<Mic size={18} />
|
|
||||||
{status === "failed" ? "重新连接" : "开始对话"}
|
|
||||||
</Button>
|
|
||||||
<p className="text-[11px] leading-4 text-muted-soft">
|
|
||||||
在右上角「设备」中选择麦克风
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
|
function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
|
||||||
const { stream, error, starting, active, start } = camera;
|
const { stream, error, starting, active } = camera;
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2445,28 +2468,14 @@ function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
|
|||||||
预览中
|
预览中
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex max-w-xs flex-col items-center gap-3 px-6 text-center">
|
<div className="flex max-w-xs flex-col items-center gap-2 px-6 text-center">
|
||||||
<Video size={28} className="text-muted-soft" />
|
<Video size={28} className="text-muted-soft" />
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
{starting ? "正在开启摄像头…" : "摄像头未开启"}
|
{starting ? "正在开启摄像头…" : "摄像头不可用"}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
{error && (
|
||||||
{error ?? "开启摄像头预览画面。后续可将视频流接入后端视觉理解管线。"}
|
<p className="text-xs leading-5 text-muted-foreground">{error}</p>
|
||||||
</p>
|
)}
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={starting}
|
|
||||||
onClick={() => void start()}
|
|
||||||
className="gap-1.5 rounded-full"
|
|
||||||
aria-label="开启摄像头"
|
|
||||||
>
|
|
||||||
{starting ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Video size={14} />
|
|
||||||
)}
|
|
||||||
开启摄像头
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2536,7 +2545,7 @@ function DebugTranscriptPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-3",
|
"scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-3",
|
||||||
recording ? "items-center justify-center" : "pt-5",
|
recording ? "items-center justify-center" : "pt-5",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
@@ -2558,7 +2567,7 @@ function DebugTranscriptPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
|
className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
@@ -2949,21 +2958,51 @@ function ResourceSelectField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
|
icon,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
hint,
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
icon?: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
|
hint?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const hasIcon = Boolean(icon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between rounded-xl border border-hairline bg-canvas-soft p-4">
|
<div
|
||||||
|
className={[
|
||||||
|
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
||||||
|
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">{title}</div>
|
<div
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{description}</div>
|
className={[
|
||||||
|
"flex items-center font-medium text-foreground",
|
||||||
|
hasIcon ? "gap-3" : "gap-1.5",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span>{title}</span>
|
||||||
|
{hint && <HelpHint text={hint} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={checked} onCheckedChange={onChange} />
|
<Switch checked={checked} onCheckedChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export function useVoicePreview(
|
|||||||
releaseResources();
|
releaseResources();
|
||||||
setLocalStream(null);
|
setLocalStream(null);
|
||||||
setRemoteStream(null);
|
setRemoteStream(null);
|
||||||
|
setMessages([]);
|
||||||
|
messageSeqRef.current = 0;
|
||||||
setError(null);
|
setError(null);
|
||||||
setMicWarning(null);
|
setMicWarning(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|||||||
Reference in New Issue
Block a user