+
调试与预览
{!showTranscript && (
-
+
+ {VIZ_OPTIONS.map((option) => (
+ setVizStyle(option.style)}
+ >
+ {option.icon}
+
+ ))}
+
)}
-
+
+ setShowTranscript(false)}
+ >
+
+
+ setShowTranscript(true)}
+ >
+
+
+
-
+
);
}
@@ -1736,15 +1784,22 @@ function DebugDrawer() {
function DebugVoicePanel({
showTranscript,
vizStyle,
+ assistantId,
}: {
showTranscript: boolean;
vizStyle: VizStyle;
+ assistantId: string | null;
}) {
- const [recording, setRecording] = useState(false);
const [micError, setMicError] = useState(false);
+ const { status, error, localStream, connect, disconnect, audioRef } =
+ useVoicePreview(assistantId, { onMicError: () => setMicError(true) });
+ // 连接中或已连通都视作"会话进行中"
+ const recording = status === "connecting" || status === "connected";
return (
+ {/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
+
{showTranscript ? (
) : (
@@ -1774,40 +1829,55 @@ function DebugVoicePanel({
{(() => {
const onVizError = () => {
setMicError(true);
- setRecording(false);
+ disconnect();
};
const shared = {
- active: recording,
+ active: Boolean(localStream),
+ stream: localStream,
className: "relative shrink-0",
onError: onVizError,
} as const;
if (vizStyle === "aura")
return
;
+ if (vizStyle === "nebula")
+ return
;
if (vizStyle === "bars")
- return (
-
- );
+ return
;
return
;
})()}
- {recording ? "我在聆听" : "开始一次语音对话"}
+ {status === "connecting"
+ ? "连接中…"
+ : status === "connected"
+ ? "我在聆听"
+ : "开始一次语音对话"}
{micError
? "无法访问麦克风,请检查浏览器权限后重试。"
- : recording
- ? "直接说话即可。助手会在您停顿后自然回应。"
- : "测试语音识别、响应速度与助手的播报效果。"}
+ : status === "failed"
+ ? error ||
+ "连接失败,请确认后端已启动且助手已保存后重试。"
+ : !assistantId
+ ? "请先保存助手,再开始语音预览。"
+ : recording
+ ? "直接说话即可。助手会在您停顿后自然回应。"
+ : "测试语音识别、响应速度与助手的播报效果。"}
diff --git a/frontend/src/hooks/use-voice-preview.ts b/frontend/src/hooks/use-voice-preview.ts
new file mode 100644
index 0000000..a93af04
--- /dev/null
+++ b/frontend/src/hooks/use-voice-preview.ts
@@ -0,0 +1,256 @@
+"use client";
+
+/**
+ * 语音预览:把麦克风接到后端 /ws/voice(WebRTC 信令),听到助手实时回应。
+ *
+ * 走原生 RTCPeerConnection + 一条 ws 信令通道,与后端 voice_webrtc.py 的约定对齐:
+ * client → {type:"offer", payload:{pc_id, sdp, type, assistant_id}}
+ * server → {type:"answer", payload:{pc_id, sdp, type}}
+ * client → {type:"ice-candidate", payload:{pc_id, candidate:{...}}}
+ * 音频本身走 WebRTC 媒体流(Opus),不经 ws;后端 TTS 帧从 ontrack 拿到直接播放。
+ *
+ * 纯本机(localhost)即可跑:localhost 是 secure context,麦克风可用,ws 用明文。
+ * 局域网/别的设备要 https+wss,见 deploy/README.md。
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { API_BASE } from "@/lib/api";
+
+export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
+
+// http→ws、https→wss,自动跟随 API 基址(同源反代时也对)
+function wsBaseUrl(): string {
+ const url = new URL(API_BASE, window.location.origin);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ return url.toString().replace(/\/$/, "");
+}
+
+function generatePcId(): string {
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ return (
+ "PC-" +
+ Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("")
+ );
+}
+
+type UseVoicePreviewOptions = {
+ /** 取麦克风失败(权限/无设备)时回调,供 UI 提示。 */
+ onMicError?: () => void;
+};
+
+function errorMessage(error: unknown, fallback: string): string {
+ if (error instanceof Error && error.message) return error.message;
+ return fallback;
+}
+
+export function useVoicePreview(
+ assistantId: string | null,
+ { onMicError }: UseVoicePreviewOptions = {},
+) {
+ const [status, setStatus] = useState