From 051a5dd51b6e4d2414f4338dbf15b66829437639 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 7 Jul 2026 10:07:36 +0800 Subject: [PATCH] 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. --- frontend/src/app/globals.css | 40 ++ .../src/components/pages/AssistantPage.tsx | 451 ++++++++++-------- frontend/src/hooks/use-voice-preview.ts | 2 + 3 files changed, 287 insertions(+), 206 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index bdec1f5..9bc0e5c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 + ); + } +} diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index ff56d00..40ffcc7 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -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) {
-
+
} title="Dify 应用配置" @@ -1420,7 +1419,7 @@ export function AssistantPage(props: AssistantPageProps) {
-
+
} title="FastGPT 应用配置" @@ -1534,7 +1533,7 @@ export function AssistantPage(props: AssistantPageProps) {
-
+
} title="OpenCode 服务配置" @@ -1652,7 +1651,7 @@ export function AssistantPage(props: AssistantPageProps) {
-
+
} 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("aura"); - const [view, setView] = useState(vision ? "video" : "chat"); + const [view, setView] = useState("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({ )} -
- + {vision && ( +
+
+ +
+
+ )} ); } -function DeviceMenu({ - preview, - camera, - vision, -}: { - preview: VoicePreview; - camera: CameraPreview; - vision: boolean; -}) { - return ( - - - - - -
-
输入设备
-

- {vision - ? "选择麦克风与摄像头,分别用于语音与视觉预览。" - : "选择麦克风用于语音对话。"} -

-
-
- } - label="麦克风" - placeholder="默认麦克风" - fallbackLabel="麦克风" - value={preview.selectedDeviceId} - devices={preview.audioInputs} - onSelect={(id) => preview.selectDevice(id)} - /> - {vision && ( - <> -
- } - label="摄像头" - placeholder="默认摄像头" - fallbackLabel="摄像头" - value={camera.deviceId} - devices={camera.devices} - onSelect={(id) => void camera.selectCamera(id)} - /> - - )} -
- - - ); -} - -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 ( -
-
{label}
- + onSelect(nextValue === "default" ? "" : nextValue) + } + > + - + {icon} - - - - {placeholder} - {devices.map((device, index) => ( - - {device.label || `${fallbackLabel} ${index + 1}`} - - ))} - - -
+ + + + + + + {placeholder} + {devices.map((device, index) => ( + + {device.label || `${fallbackLabel} ${index + 1}`} + + ))} + + + ); +} + +function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) { + return ( + } + ariaLabel="选择麦克风" + placeholder="默认麦克风" + fallbackLabel="麦克风" + value={preview.selectedDeviceId} + devices={preview.audioInputs} + onSelect={(deviceId) => preview.selectDevice(deviceId)} + /> + ); +} + +function CameraDeviceField({ camera }: { camera: CameraPreview }) { + return ( + } + 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 ( + ); } @@ -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("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({
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}