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.
This commit is contained in:
Xin Wang
2026-07-10 15:25:26 +08:00
parent 2a002697aa
commit 5705726cfb
5 changed files with 270 additions and 4 deletions

View File

@@ -22,6 +22,30 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { API_BASE, webrtcApi } from "@/lib/api";
export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
export type NetworkQuality = "unknown" | "good" | "fair" | "poor";
const NETWORK_SAMPLE_INTERVAL_MS = 2_000;
const VIDEO_BITRATE_BY_QUALITY: Record<
Exclude<NetworkQuality, "unknown">,
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<boolean> {
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<string, unknown>;
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)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [networkQuality, setNetworkQuality] =
useState<NetworkQuality>("unknown");
// 可选麦克风/扬声器列表与当前选择(空串表示交给浏览器选默认设备)
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]);
@@ -114,6 +231,8 @@ export function useVoicePreview(
const localStreamRef = useRef<MediaStream | null>(null);
const startingRef = useRef(false);
const messageSeqRef = useRef(0);
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
const videoBitrateRef = useRef(INITIAL_VIDEO_BITRATE);
// 后端主动结束(工作流走到结束节点)标记:据此把随后的断开当作正常结束而非报错
const endedByServerRef = useRef(false);
// 工作流激活节点回调存进 ref,避免把它挂进 connect 依赖反复重建连接
@@ -233,6 +352,9 @@ export function useVoicePreview(
messageSeqRef.current = 0;
setError(null);
setMicWarning(null);
setNetworkQuality("unknown");
networkStatsRef.current = null;
videoBitrateRef.current = INITIAL_VIDEO_BITRATE;
setStatus("idle");
}, [releaseResources]);
@@ -242,6 +364,9 @@ export function useVoicePreview(
setLocalStream(null);
setRemoteStream(null);
setError(message);
setNetworkQuality("unknown");
networkStatsRef.current = null;
videoBitrateRef.current = INITIAL_VIDEO_BITRATE;
setStatus("failed");
},
[releaseResources],
@@ -500,9 +625,10 @@ export function useVoicePreview(
pc.addTransceiver("audio", { direction: "recvonly" });
}
if (options.videoStream) {
options.videoStream
.getVideoTracks()
.forEach((track) => pc.addTrack(track, options.videoStream!));
for (const track of options.videoStream.getVideoTracks()) {
const sender = pc.addTrack(track, options.videoStream);
await setVideoBitrate(sender, INITIAL_VIDEO_BITRATE);
}
}
// 4) 生成 offer 并发给后端(assistant_id 在 payload 顶层)
@@ -531,6 +657,51 @@ export function useVoicePreview(
}
}, [assistantId, applyOutputDevice, fail, closeOnRemoteEnd, refreshDevices]);
useEffect(() => {
if (status !== "connected") return;
const pc = pcRef.current;
if (!pc) return;
let cancelled = false;
const sampleNetwork = async () => {
try {
const metrics = readVideoNetworkMetrics(await pc.getStats());
if (cancelled || pcRef.current !== pc) return;
const quality = classifyNetworkQuality(metrics, networkStatsRef.current);
if (metrics.packetsSent !== null && metrics.packetsLost !== null) {
networkStatsRef.current = {
packetsSent: metrics.packetsSent,
packetsLost: metrics.packetsLost,
};
}
setNetworkQuality(quality);
if (quality === "unknown") return;
const bitrate = VIDEO_BITRATE_BY_QUALITY[quality];
if (bitrate === videoBitrateRef.current) return;
const sender = pc
.getSenders()
.find((item) => item.track?.kind === "video");
if (!sender) return;
if (await setVideoBitrate(sender, bitrate)) {
videoBitrateRef.current = bitrate;
}
} catch {
// 个别浏览器不提供完整 WebRTC 统计信息时,保留底层拥塞控制。
}
};
void sampleNetwork();
const interval = window.setInterval(
() => void sampleNetwork(),
NETWORK_SAMPLE_INTERVAL_MS,
);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [status]);
const replaceVideoStream = useCallback(
async (videoStream: MediaStream | null) => {
const pc = pcRef.current;
@@ -621,6 +792,7 @@ export function useVoicePreview(
localStream,
remoteStream,
messages,
networkQuality,
audioInputs,
audioOutputs,
selectedDeviceId,