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 (
+
+
+ {view.label}
+
+ );
+}
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)媒体流:除挂到