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;
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
Bug,
|
||||
Video,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -1321,7 +1320,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
icon={<Boxes size={18} />}
|
||||
title="Dify 应用配置"
|
||||
@@ -1420,7 +1419,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
icon={<Database size={18} />}
|
||||
title="FastGPT 应用配置"
|
||||
@@ -1534,7 +1533,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
icon={<Terminal size={18} />}
|
||||
title="OpenCode 服务配置"
|
||||
@@ -1652,7 +1651,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
@@ -1727,8 +1726,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
<div className="mt-4 border-t border-hairline pt-4">
|
||||
<ToggleRow
|
||||
icon={<Eye size={16} />}
|
||||
title="视觉理解"
|
||||
description="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头"
|
||||
hint="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头。"
|
||||
checked={visionEnabled}
|
||||
onChange={setVisionEnabled}
|
||||
/>
|
||||
@@ -1841,6 +1841,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
||||
|
||||
// 调试面板顶部主视图:聊天记录 / 视频流(仅视觉理解开启时可选)
|
||||
type DebugView = "chat" | "video";
|
||||
type DebugInputMode = "mic" | "text";
|
||||
|
||||
const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
|
||||
[
|
||||
@@ -1917,38 +1918,29 @@ function DebugDrawer({
|
||||
const camera = useCameraPreview();
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
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 stopCamera = camera.stop;
|
||||
const startCamera = camera.start;
|
||||
const cameraShouldRun =
|
||||
vision &&
|
||||
(preview.status === "connecting" || preview.status === "connected");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setView(vision ? "video" : "chat");
|
||||
if (!vision) stopCamera();
|
||||
if (!vision) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setView("chat");
|
||||
stopCamera();
|
||||
}
|
||||
}, [vision, stopCamera]);
|
||||
|
||||
const { start: startCamera, active: cameraActive, starting: cameraStarting } =
|
||||
camera;
|
||||
useEffect(() => {
|
||||
if (
|
||||
vision &&
|
||||
effectiveView === "video" &&
|
||||
!cameraActive &&
|
||||
!cameraStarting
|
||||
) {
|
||||
if (cameraShouldRun) {
|
||||
void startCamera();
|
||||
} else {
|
||||
stopCamera();
|
||||
}
|
||||
}, [
|
||||
vision,
|
||||
effectiveView,
|
||||
cameraActive,
|
||||
cameraStarting,
|
||||
startCamera,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveView !== "video") stopCamera();
|
||||
}, [effectiveView, stopCamera]);
|
||||
}, [cameraShouldRun, camera.deviceId, startCamera, stopCamera]);
|
||||
|
||||
const containerClass = asSheet
|
||||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
||||
@@ -2001,7 +1993,6 @@ function DebugDrawer({
|
||||
</SegmentedIconGroup>
|
||||
</>
|
||||
)}
|
||||
<DeviceMenu preview={preview} camera={camera} vision={vision} />
|
||||
<SegmentedIconGroup label="预览视图">
|
||||
<SegmentedIconButton
|
||||
selected={effectiveView === "chat"}
|
||||
@@ -2022,7 +2013,13 @@ function DebugDrawer({
|
||||
</SegmentedIconGroup>
|
||||
</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
|
||||
view={effectiveView}
|
||||
showTranscript={showTranscript}
|
||||
@@ -2030,75 +2027,15 @@ function DebugDrawer({
|
||||
assistantId={assistantId}
|
||||
preview={preview}
|
||||
camera={camera}
|
||||
vision={vision}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceMenu({
|
||||
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({
|
||||
function DeviceSelectField({
|
||||
icon,
|
||||
label,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
fallbackLabel,
|
||||
value,
|
||||
@@ -2106,7 +2043,7 @@ function DeviceSelectRow({
|
||||
onSelect,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
placeholder: string;
|
||||
fallbackLabel: string;
|
||||
value: string;
|
||||
@@ -2114,30 +2051,96 @@ function DeviceSelectRow({
|
||||
onSelect: (deviceId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<Select
|
||||
value={value || "default"}
|
||||
onValueChange={(v) => onSelect(v === "default" ? "" : v)}
|
||||
<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}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-full gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
|
||||
aria-label={`选择${label}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{placeholder}</SelectItem>
|
||||
{devices.map((device, index) => (
|
||||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `${fallbackLabel} ${index + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<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 }: { 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,
|
||||
preview,
|
||||
camera,
|
||||
vision,
|
||||
}: {
|
||||
view: DebugView;
|
||||
showTranscript: boolean;
|
||||
@@ -2155,6 +2159,7 @@ function DebugVoicePanel({
|
||||
assistantId: string | null;
|
||||
preview: VoicePreview;
|
||||
camera: CameraPreview;
|
||||
vision: boolean;
|
||||
}) {
|
||||
const {
|
||||
status,
|
||||
@@ -2170,11 +2175,13 @@ function DebugVoicePanel({
|
||||
} = preview;
|
||||
const recording = status === "connecting" || status === "connected";
|
||||
const [textDraft, setTextDraft] = useState("");
|
||||
const [inputMode, setInputMode] = useState<DebugInputMode>("mic");
|
||||
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
|
||||
const idleOrFailed = status === "idle" || status === "failed";
|
||||
const showIdleHub =
|
||||
inChatView &&
|
||||
messages.length === 0 &&
|
||||
(status === "idle" || status === "failed");
|
||||
idleOrFailed &&
|
||||
(inChatView || (vision && view === "video"));
|
||||
|
||||
function handleSendText() {
|
||||
if (sendText(textDraft)) {
|
||||
@@ -2186,7 +2193,14 @@ function DebugVoicePanel({
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
||||
<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} />
|
||||
) : !SHOW_VOICE_VIZ || showTranscript ? (
|
||||
showIdleHub ? (
|
||||
@@ -2200,7 +2214,7 @@ function DebugVoicePanel({
|
||||
<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
|
||||
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={{
|
||||
@@ -2293,34 +2307,63 @@ function DebugVoicePanel({
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<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="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}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={[
|
||||
"flex h-10 min-w-0 items-center gap-1 rounded-[1.4rem] border border-hairline-strong bg-background px-2",
|
||||
"flex-1",
|
||||
].join(" ")}
|
||||
>
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
<div className="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" ? (
|
||||
<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 && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -2333,7 +2376,7 @@ function DebugVoicePanel({
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"shrink-0 gap-1.5 rounded-full",
|
||||
"h-10 shrink-0 gap-1.5 rounded-full px-4",
|
||||
recording
|
||||
? "bg-destructive text-white hover:bg-destructive/90"
|
||||
: "",
|
||||
@@ -2356,7 +2399,7 @@ function DebugVoicePanel({
|
||||
{/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
|
||||
<WaveformTimelinePanel
|
||||
key={status === "connected" ? "active" : "idle"}
|
||||
defaultOpen={status === "connected"}
|
||||
defaultOpen={false}
|
||||
userStream={localStream}
|
||||
agentStream={remoteStream}
|
||||
active={status === "connected"}
|
||||
@@ -2365,10 +2408,9 @@ function DebugVoicePanel({
|
||||
);
|
||||
}
|
||||
|
||||
// 空闲态:图标 + 说明 + 开始按钮合一,替代「撑满高度的空 transcript + 底部细控制条」
|
||||
// 空闲态只保留一个明确入口,避免中间区域显得拥挤。
|
||||
function DebugIdleHub({
|
||||
status,
|
||||
error,
|
||||
assistantId,
|
||||
connect,
|
||||
}: {
|
||||
@@ -2377,42 +2419,23 @@ function DebugIdleHub({
|
||||
assistantId: string | null;
|
||||
connect: () => Promise<void>;
|
||||
}) {
|
||||
const hint =
|
||||
status === "failed"
|
||||
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
|
||||
: !assistantId
|
||||
? "请先保存助手,再开始语音预览。"
|
||||
: "测试语音识别、响应速度与助手的播报效果。";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-canvas-soft">
|
||||
<Mic size={22} className="text-muted-soft" />
|
||||
</div>
|
||||
<div className="font-display display-sm text-foreground">
|
||||
{status === "failed" ? "连接失败" : "开始一次语音对话"}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{hint}</p>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
|
||||
const { stream, error, starting, active, start } = camera;
|
||||
const { stream, error, starting, active } = camera;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2445,28 +2468,14 @@ function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
|
||||
预览中
|
||||
</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" />
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{starting ? "正在开启摄像头…" : "摄像头未开启"}
|
||||
{starting ? "正在开启摄像头…" : "摄像头不可用"}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{error ?? "开启摄像头预览画面。后续可将视频流接入后端视觉理解管线。"}
|
||||
</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>
|
||||
{error && (
|
||||
<p className="text-xs leading-5 text-muted-foreground">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2536,7 +2545,7 @@ function DebugTranscriptPanel({
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
].join(" ")}
|
||||
>
|
||||
@@ -2558,7 +2567,7 @@ function DebugTranscriptPanel({
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
{messages.map((message) => {
|
||||
@@ -2949,21 +2958,51 @@ function ResourceSelectField({
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const hasIcon = Boolean(icon);
|
||||
|
||||
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 className="font-medium text-foreground">{title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{description}</div>
|
||||
<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>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
|
||||
@@ -187,6 +187,8 @@ export function useVoicePreview(
|
||||
releaseResources();
|
||||
setLocalStream(null);
|
||||
setRemoteStream(null);
|
||||
setMessages([]);
|
||||
messageSeqRef.current = 0;
|
||||
setError(null);
|
||||
setMicWarning(null);
|
||||
setStatus("idle");
|
||||
|
||||
Reference in New Issue
Block a user