From 5705726cfb763352ed6d4b905b6723318dc92502 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 10 Jul 2026 15:25:26 +0800 Subject: [PATCH] Add Network Quality Indicator component and integrate into Assistant and Mobile Call pages - Introduce a new `NetworkQualityIndicator` component to visually represent network quality status during calls. - Integrate the `NetworkQualityIndicator` into `AssistantPage` and `MobileCallPage`, enhancing user awareness of connection quality. - Update `use-voice-preview` hook to manage network quality metrics, improving overall call experience and performance monitoring. --- .../components/network-quality-indicator.tsx | 70 +++++++ .../src/components/pages/AssistantPage.tsx | 5 + .../src/components/pages/MobileCallPage.tsx | 9 + frontend/src/hooks/use-camera-preview.ts | 12 +- frontend/src/hooks/use-voice-preview.ts | 178 +++++++++++++++++- 5 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/network-quality-indicator.tsx diff --git a/frontend/src/components/network-quality-indicator.tsx b/frontend/src/components/network-quality-indicator.tsx new file mode 100644 index 0000000..05e4343 --- /dev/null +++ b/frontend/src/components/network-quality-indicator.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Wifi, WifiOff } from "lucide-react"; + +import type { + NetworkQuality, + VoicePreviewStatus, +} from "@/hooks/use-voice-preview"; + +const QUALITY_VIEW: Record< + NetworkQuality, + { label: string; color: string; darkColor: string } +> = { + unknown: { + label: "检测中", + color: "text-muted-foreground", + darkColor: "text-white/55", + }, + good: { + label: "网络良好", + color: "text-emerald-600", + darkColor: "text-emerald-300", + }, + fair: { + label: "网络一般", + color: "text-amber-600", + darkColor: "text-amber-300", + }, + poor: { + label: "网络较差", + color: "text-red-600", + darkColor: "text-red-300", + }, +}; + +export function NetworkQualityIndicator({ + quality, + status, + dark = false, + className = "", +}: { + quality: NetworkQuality; + status: VoicePreviewStatus; + dark?: boolean; + className?: string; +}) { + const connected = status === "connected"; + const qualityView = QUALITY_VIEW[quality]; + const view = connected + ? { + label: qualityView.label, + color: dark ? qualityView.darkColor : qualityView.color, + } + : { + label: status === "connecting" ? "检测中" : "未连接", + color: dark ? "text-white/55" : "text-muted-foreground", + }; + const Icon = connected || status === "connecting" ? Wifi : WifiOff; + + return ( + + + ); +} diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 5d632cc..9e21b56 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -40,6 +40,7 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { NetworkQualityIndicator } from "@/components/network-quality-indicator"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; @@ -2037,6 +2038,10 @@ function DebugDrawer({
调试与预览
+ } + +
, + number +> = { + good: 600_000, + fair: 500_000, + poor: 300_000, +}; +const INITIAL_VIDEO_BITRATE = VIDEO_BITRATE_BY_QUALITY.fair; + +type NetworkStatsSample = { + packetsSent: number; + packetsLost: number; +}; + +type VideoNetworkMetrics = { + packetsSent: number | null; + packetsLost: number | null; + roundTripTime: number | null; + availableOutgoingBitrate: number | null; +}; type ConnectOptions = { visionEnabled?: boolean; @@ -88,6 +112,97 @@ function microphoneErrorMessage(error: unknown): string { return errorMessage(error, "无法访问麦克风。"); } +async function setVideoBitrate( + sender: RTCRtpSender, + bitrate: number, +): Promise { + try { + const parameters = sender.getParameters(); + if (!parameters.encodings.length) parameters.encodings = [{}]; + parameters.encodings[0].maxBitrate = bitrate; + parameters.degradationPreference = "maintain-resolution"; + await sender.setParameters(parameters); + return true; + } catch { + // 不支持发送参数的浏览器继续使用 WebRTC 自带的拥塞控制。 + return false; + } +} + +function readVideoNetworkMetrics(report: RTCStatsReport): VideoNetworkMetrics { + const metrics: VideoNetworkMetrics = { + packetsSent: null, + packetsLost: null, + roundTripTime: null, + availableOutgoingBitrate: null, + }; + + report.forEach((stat) => { + const values = stat as RTCStats & Record; + const kind = values.kind ?? values.mediaType; + if (values.type === "outbound-rtp" && kind === "video") { + if (typeof values.packetsSent === "number") { + metrics.packetsSent = values.packetsSent; + } + } else if (values.type === "remote-inbound-rtp" && kind === "video") { + if (typeof values.packetsLost === "number") { + metrics.packetsLost = values.packetsLost; + } + if (typeof values.roundTripTime === "number") { + metrics.roundTripTime = values.roundTripTime; + } + } else if ( + values.type === "candidate-pair" && + values.state === "succeeded" && + (values.nominated === true || values.selected === true) + ) { + if (typeof values.currentRoundTripTime === "number") { + metrics.roundTripTime = values.currentRoundTripTime; + } + if (typeof values.availableOutgoingBitrate === "number") { + metrics.availableOutgoingBitrate = values.availableOutgoingBitrate; + } + } + }); + + return metrics; +} + +function classifyNetworkQuality( + metrics: VideoNetworkMetrics, + previous: NetworkStatsSample | null, +): NetworkQuality { + const hasPacketSample = + previous !== null && + metrics.packetsSent !== null && + metrics.packetsLost !== null; + const sentDelta = hasPacketSample + ? Math.max(0, metrics.packetsSent! - previous.packetsSent) + : 0; + const lostDelta = hasPacketSample + ? Math.max(0, metrics.packetsLost! - previous.packetsLost) + : 0; + const lossRate = sentDelta > 0 ? lostDelta / sentDelta : null; + const { roundTripTime: rtt, availableOutgoingBitrate: bandwidth } = metrics; + + if (lossRate === null && rtt === null && bandwidth === null) return "unknown"; + if ( + (lossRate !== null && lossRate >= 0.08) || + (rtt !== null && rtt >= 0.45) || + (bandwidth !== null && bandwidth < 350_000) + ) { + return "poor"; + } + if ( + (lossRate !== null && lossRate >= 0.03) || + (rtt !== null && rtt >= 0.25) || + (bandwidth !== null && bandwidth < 550_000) + ) { + return "fair"; + } + return "good"; +} + export function useVoicePreview( assistantId: string | null, onNodeActive?: (nodeId: string | null) => void, @@ -99,6 +214,8 @@ export function useVoicePreview( // 远端(助手 TTS)媒体流:除挂到