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:
Xin Wang
2026-07-07 10:07:36 +08:00
parent e857828fe5
commit 051a5dd51b
3 changed files with 287 additions and 206 deletions

View File

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

View File

@@ -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>

View File

@@ -187,6 +187,8 @@ export function useVoicePreview(
releaseResources();
setLocalStream(null);
setRemoteStream(null);
setMessages([]);
messageSeqRef.current = 0;
setError(null);
setMicWarning(null);
setStatus("idle");