2026-06-12 17:46:25 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// useVisionSession — 核心视觉对话会话 Hook
|
|
|
|
|
|
// 职责:封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)
|
|
|
|
|
|
// 来源:docs/02-系统架构.md 核心 Hook 设计
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-06-13 11:43:16 +08:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-06-12 17:46:25 +08:00
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
|
|
import { wsClient } from "../lib/websocket";
|
|
|
|
|
|
import { encodeAudioToBase64, dataUrlToBase64 } from "../lib/audio";
|
2026-06-13 11:43:16 +08:00
|
|
|
|
import { getErrorMessage } from "../lib/errors";
|
2026-06-13 13:57:36 +08:00
|
|
|
|
import { TTSPlayer } from "../lib/ttsPlayer";
|
2026-06-13 11:43:16 +08:00
|
|
|
|
import { showToast } from "../lib/toast";
|
2026-06-13 14:19:47 +08:00
|
|
|
|
import { loadConfig, saveConfig } from "../lib/storage";
|
2026-06-12 17:46:25 +08:00
|
|
|
|
import { useCamera } from "../components/CameraManager";
|
2026-06-13 11:10:29 +08:00
|
|
|
|
import { useMicrophone } from "../components/MicManager";
|
2026-06-12 17:46:25 +08:00
|
|
|
|
import { useVAD } from "../components/EdgeProcessor";
|
|
|
|
|
|
import { useWebSocketManager } from "../components/WebSocketManager";
|
2026-06-13 14:19:47 +08:00
|
|
|
|
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_HISTORY_ROUNDS = 10;
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
export function useVisionSession() {
|
|
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
|
|
|
|
const [currentReply, setCurrentReply] = useState<string>("");
|
|
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
2026-06-13 13:57:36 +08:00
|
|
|
|
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
2026-06-13 14:19:47 +08:00
|
|
|
|
const [config, setConfig] = useState<SessionConfig>(loadConfig);
|
|
|
|
|
|
|
|
|
|
|
|
// 对话历史(role + content),用于多轮上下文
|
|
|
|
|
|
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
2026-06-13 13:57:36 +08:00
|
|
|
|
|
|
|
|
|
|
// TTS 播放器
|
|
|
|
|
|
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
|
|
|
|
|
|
const getTTSPlayer = useCallback(() => {
|
|
|
|
|
|
if (!ttsPlayerRef.current) {
|
|
|
|
|
|
const player = new TTSPlayer();
|
|
|
|
|
|
player.onEnd(() => setIsAudioPlaying(false));
|
|
|
|
|
|
ttsPlayerRef.current = player;
|
|
|
|
|
|
}
|
|
|
|
|
|
return ttsPlayerRef.current;
|
|
|
|
|
|
}, []);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
const { videoRef, captureFrame, startCamera, stopCamera, stream } = useCamera();
|
2026-06-13 11:10:29 +08:00
|
|
|
|
const { startMic, stopMic } = useMicrophone();
|
2026-06-12 17:46:25 +08:00
|
|
|
|
const { status, connect, disconnect, send } = useWebSocketManager();
|
|
|
|
|
|
|
2026-06-13 11:43:16 +08:00
|
|
|
|
// 用 ref 跟踪 isProcessing,避免 VAD 回调闭包问题
|
|
|
|
|
|
const isProcessingRef = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
isProcessingRef.current = isProcessing;
|
|
|
|
|
|
}, [isProcessing]);
|
|
|
|
|
|
|
2026-06-13 14:19:47 +08:00
|
|
|
|
// WebSocket 连接成功后发送 config
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (status === "connected") {
|
|
|
|
|
|
send({
|
|
|
|
|
|
type: "config",
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
tts_enabled: config.ttsEnabled,
|
|
|
|
|
|
detail_level: config.detailLevel,
|
|
|
|
|
|
language: config.language,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送
|
|
|
|
|
|
|
|
|
|
|
|
/** 更新会话配置 */
|
|
|
|
|
|
const updateConfig = useCallback((partial: Partial<SessionConfig>) => {
|
|
|
|
|
|
setConfig((prev) => {
|
|
|
|
|
|
const next = { ...prev, ...partial };
|
|
|
|
|
|
saveConfig(next);
|
|
|
|
|
|
// 如果已连接,立即发送更新
|
|
|
|
|
|
if (status === "connected") {
|
|
|
|
|
|
send({
|
|
|
|
|
|
type: "config",
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
tts_enabled: next.ttsEnabled,
|
|
|
|
|
|
detail_level: next.detailLevel,
|
|
|
|
|
|
language: next.language,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [status, send]);
|
|
|
|
|
|
|
2026-06-12 17:46:25 +08:00
|
|
|
|
// VAD:语音结束时自动发送 query
|
2026-06-13 11:10:29 +08:00
|
|
|
|
const {
|
|
|
|
|
|
isSpeaking,
|
|
|
|
|
|
isReady: isVADReady,
|
|
|
|
|
|
error: vadError,
|
|
|
|
|
|
start: startVAD,
|
|
|
|
|
|
stop: stopVAD,
|
|
|
|
|
|
} = useVAD({
|
2026-06-12 17:46:25 +08:00
|
|
|
|
onSpeechEnd: useCallback(
|
|
|
|
|
|
(audio: Float32Array) => {
|
2026-06-13 11:43:16 +08:00
|
|
|
|
// 处理中忽略,防止重复发送
|
|
|
|
|
|
if (isProcessingRef.current) {
|
|
|
|
|
|
console.warn("[Session] 正在处理中,忽略语音输入");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:46:25 +08:00
|
|
|
|
const frame = captureFrame();
|
|
|
|
|
|
if (!frame) {
|
|
|
|
|
|
console.warn("[Session] 无法捕获图像帧");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const requestId = uuidv4();
|
|
|
|
|
|
send({
|
|
|
|
|
|
type: "query",
|
|
|
|
|
|
request_id: requestId,
|
|
|
|
|
|
image: dataUrlToBase64(frame),
|
|
|
|
|
|
audio: encodeAudioToBase64(audio),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-13 11:43:16 +08:00
|
|
|
|
// 添加用户消息(STT 流式结果会逐步更新文本)
|
2026-06-12 17:46:25 +08:00
|
|
|
|
setMessages((prev) => [
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
{ role: "user", content: "(语音识别中...)", timestamp: Date.now() },
|
|
|
|
|
|
]);
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
},
|
|
|
|
|
|
[captureFrame, send]
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 处理服务端消息
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const unsub = wsClient.onMessage((msg: ServerMessage) => {
|
|
|
|
|
|
switch (msg.type) {
|
2026-06-13 11:43:16 +08:00
|
|
|
|
case "stt_result": {
|
|
|
|
|
|
// 流式更新用户消息文本(包括中间结果和最终结果)
|
|
|
|
|
|
setMessages((prev) => {
|
|
|
|
|
|
const updated = [...prev];
|
|
|
|
|
|
const lastUserIdx = updated.findLastIndex((m) => m.role === "user");
|
|
|
|
|
|
if (lastUserIdx >= 0) {
|
|
|
|
|
|
updated[lastUserIdx] = {
|
|
|
|
|
|
...updated[lastUserIdx],
|
|
|
|
|
|
content: msg.text || "(未识别到语音)",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return updated;
|
|
|
|
|
|
});
|
2026-06-12 17:46:25 +08:00
|
|
|
|
break;
|
2026-06-13 11:43:16 +08:00
|
|
|
|
}
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
case "llm_chunk":
|
|
|
|
|
|
setCurrentReply((prev) => prev + msg.delta);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-06-13 11:43:16 +08:00
|
|
|
|
case "llm_done": {
|
|
|
|
|
|
const done = msg as LLMDoneMessage;
|
2026-06-13 14:19:47 +08:00
|
|
|
|
// 记录到对话历史
|
|
|
|
|
|
historyRef.current.push({ role: "assistant", content: done.full_text });
|
|
|
|
|
|
// 裁剪历史到最近 N 轮
|
|
|
|
|
|
if (historyRef.current.length > MAX_HISTORY_ROUNDS * 2) {
|
|
|
|
|
|
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:46:25 +08:00
|
|
|
|
setMessages((prev) => [
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
{
|
|
|
|
|
|
role: "assistant",
|
2026-06-13 11:43:16 +08:00
|
|
|
|
content: done.full_text,
|
2026-06-12 17:46:25 +08:00
|
|
|
|
timestamp: Date.now(),
|
2026-06-13 11:43:16 +08:00
|
|
|
|
tokensUsed: done.tokens_used?.total,
|
|
|
|
|
|
latencyMs: done.latency_ms,
|
|
|
|
|
|
model: done.model,
|
2026-06-12 17:46:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
setCurrentReply("");
|
|
|
|
|
|
setIsProcessing(false);
|
|
|
|
|
|
break;
|
2026-06-13 11:43:16 +08:00
|
|
|
|
}
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
case "tts_audio":
|
2026-06-13 13:57:36 +08:00
|
|
|
|
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
|
|
|
|
|
|
if (!msg.is_last) {
|
|
|
|
|
|
setIsAudioPlaying(true);
|
|
|
|
|
|
}
|
2026-06-12 17:46:25 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case "error":
|
|
|
|
|
|
console.error("[Session] 服务端错误:", msg.code, msg.message);
|
2026-06-13 11:43:16 +08:00
|
|
|
|
showToast(getErrorMessage(msg.code), "error");
|
2026-06-12 17:46:25 +08:00
|
|
|
|
setIsProcessing(false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return unsub;
|
2026-06-13 13:57:36 +08:00
|
|
|
|
}, [getTTSPlayer]);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
/** 启动会话 */
|
|
|
|
|
|
const startSession = useCallback(async () => {
|
2026-06-13 11:10:29 +08:00
|
|
|
|
// 1. 获取摄像头和麦克风
|
2026-06-12 17:46:25 +08:00
|
|
|
|
await startCamera();
|
2026-06-13 11:10:29 +08:00
|
|
|
|
const micStream = await startMic();
|
|
|
|
|
|
if (!micStream) {
|
2026-06-13 11:43:16 +08:00
|
|
|
|
showToast("无法获取麦克风权限", "error");
|
2026-06-13 11:10:29 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 连接 WebSocket
|
2026-06-12 17:46:25 +08:00
|
|
|
|
connect();
|
2026-06-13 11:10:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 3. 启动 VAD(传入麦克风 stream)
|
|
|
|
|
|
await startVAD(micStream);
|
|
|
|
|
|
}, [startCamera, startMic, connect, startVAD]);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
/** 结束会话 */
|
2026-06-13 11:10:29 +08:00
|
|
|
|
const stopSession = useCallback(async () => {
|
|
|
|
|
|
await stopVAD();
|
|
|
|
|
|
stopMic();
|
2026-06-12 17:46:25 +08:00
|
|
|
|
stopCamera();
|
|
|
|
|
|
disconnect();
|
2026-06-13 13:57:36 +08:00
|
|
|
|
// 停止 TTS 并清理状态
|
|
|
|
|
|
ttsPlayerRef.current?.stop();
|
|
|
|
|
|
setIsAudioPlaying(false);
|
2026-06-13 11:43:16 +08:00
|
|
|
|
setMessages([]);
|
|
|
|
|
|
setCurrentReply("");
|
|
|
|
|
|
setIsProcessing(false);
|
2026-06-13 14:19:47 +08:00
|
|
|
|
historyRef.current = [];
|
2026-06-13 11:10:29 +08:00
|
|
|
|
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
/** 打断当前回复 */
|
|
|
|
|
|
const interrupt = useCallback(() => {
|
|
|
|
|
|
send({ type: "interrupt" });
|
2026-06-13 13:57:36 +08:00
|
|
|
|
// 停止 TTS 播放
|
|
|
|
|
|
ttsPlayerRef.current?.stop();
|
|
|
|
|
|
setIsAudioPlaying(false);
|
2026-06-13 11:43:16 +08:00
|
|
|
|
// 将未完成的流式内容保存为最终消息
|
|
|
|
|
|
if (currentReply) {
|
2026-06-13 14:19:47 +08:00
|
|
|
|
const interrupted = currentReply + "(已打断)";
|
|
|
|
|
|
historyRef.current.push({ role: "assistant", content: interrupted });
|
2026-06-13 11:43:16 +08:00
|
|
|
|
setMessages((prev) => [
|
|
|
|
|
|
...prev,
|
2026-06-13 14:19:47 +08:00
|
|
|
|
{ role: "assistant", content: interrupted, timestamp: Date.now() },
|
2026-06-13 11:43:16 +08:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
setCurrentReply("");
|
2026-06-12 17:46:25 +08:00
|
|
|
|
setIsProcessing(false);
|
2026-06-13 11:43:16 +08:00
|
|
|
|
}, [send, currentReply]);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
messages,
|
|
|
|
|
|
currentReply,
|
|
|
|
|
|
isProcessing,
|
2026-06-13 13:57:36 +08:00
|
|
|
|
isAudioPlaying,
|
2026-06-12 17:46:25 +08:00
|
|
|
|
isSpeaking,
|
2026-06-13 11:10:29 +08:00
|
|
|
|
isVADReady,
|
|
|
|
|
|
vadError,
|
2026-06-12 17:46:25 +08:00
|
|
|
|
connectionStatus: status,
|
|
|
|
|
|
videoRef,
|
|
|
|
|
|
stream,
|
2026-06-13 14:19:47 +08:00
|
|
|
|
config,
|
|
|
|
|
|
updateConfig,
|
2026-06-12 17:46:25 +08:00
|
|
|
|
startSession,
|
|
|
|
|
|
stopSession,
|
|
|
|
|
|
interrupt,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|