/* =========================================================
实时语音对话页(/voice)—— React 完全复刻 live-avatar 对话 UI
· 无自带 topbar(DPM 页眉由 ScreenLayout 注入)
· 中间圆球(状态机 + 指示器 + 噪声门圆环)、右下角气泡渲染、
底部文字输入 —— 结构与类名与原版一致,样式引入原版 style.css
· 逻辑:本地 VAD + 云 ASR(qwen3-asr-flash-realtime) + 云 LLM(qwen-plus) + 云 TTS(qwen3-tts-flash-realtime)
========================================================= */
import { useCallback, useEffect, useRef, useState } from 'react';
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
import { getApiBase as API_BASE } from '../config';
import '../styles/datascreen.css';
import './voice-original.css';
import './voice-overrides.css';
const VOICE_URL = window.__DPM_VOICE_WS__ || `ws://${window.location.hostname}:8765/v1/realtime`;
// 基础提示词由后端统一管理(GET /api/s2s/instructions:配置提示词 + 园区知识库),
// 前端不硬编码;此处仅为后端不可用时的兜底
const FALLBACK_INSTRUCTIONS = '你是 PineSound 园区智能语音助手,请用简洁专业的中文回答,不超过三句话。';
const DEFAULT_VOICE = 'Cherry';
const DEFAULT_GATE_DB = -50;
// 工具定义(与后端 app/tools.py 的 tool_schemas 一致,经 session.update 传给 LLM)
const TOOL_DEFS = [
{
type: 'function', name: 'get_park_overview',
description: '获取园区实时运营概览:在园项目数、累计孵化企业、带动就业、营收(今日/累计)、设备在线率、在园人数、能耗等。回答园区数据类问题时使用。',
parameters: { type: 'object', properties: {} },
},
{
type: 'function', name: 'query_companies',
description: '查询园区入驻企业名录,支持按企业名关键词过滤。回答"有哪些企业/某企业是否入驻"时使用。',
parameters: { type: 'object', properties: { keyword: { type: 'string', description: '企业名关键词,可为空字符串' } } },
},
{
type: 'function', name: 'control_display',
description: '控制大屏显示:switch_page 切换页面、alert 弹通知、media_play/media_pause 控制媒体播放。用户要求控制大屏时使用。',
parameters: {
type: 'object',
properties: {
action: { type: 'string', enum: ['switch_page', 'alert', 'media_play', 'media_pause'] },
target: { type: 'string', description: '目标:页面路径(如 /、/twin、/ai、/voice)或通知文本' },
},
},
},
{
type: 'function', name: 'get_time',
description: '获取当前日期时间。用户问"现在几点/今天几号"时使用。',
parameters: { type: 'object', properties: {} },
},
];
const STATE_VIEWS = {
idle: '点击开始',
connecting: '思考中',
listening: '正在聆听',
'user-speaking': '',
processing: '',
'ai-speaking': '',
error: '点击重试',
};
const STATE_CLASS = {
idle: 'state-idle', connecting: 'state-connecting', listening: 'state-listening',
'user-speaking': 'state-user-speaking', processing: 'state-processing',
'ai-speaking': 'state-ai-speaking', error: 'state-error',
};
const LIVE_STATES = new Set(['listening', 'user-speaking', 'processing', 'ai-speaking']);
const GATE_OFF_DB = -66;
const GATE_MAX_DB = -3;
// ── 噪声门弧线几何(复刻原版 main.js:200° 弧,缺口朝向圆球)──
const ARC_R = 40;
const ARC_SPAN_DEG = 200;
const ARC_START_DEG = 180 - ARC_SPAN_DEG / 2; // 80°:左下起点
function arcPoint(f, r = ARC_R) {
const deg = ARC_START_DEG + f * ARC_SPAN_DEG;
const rad = (deg * Math.PI) / 180;
return { x: 50 + r * Math.cos(rad), y: 50 + r * Math.sin(rad) };
}
function fullArcD() {
const a = arcPoint(0);
const b = arcPoint(1);
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 1 1 ${b.x} ${b.y}`;
}
function dbToFraction(db) {
const clamped = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, db));
return (clamped - GATE_OFF_DB) / (GATE_MAX_DB - GATE_OFF_DB);
}
const icons = { micOn: ,
micOff: ,
indMic: ,
indError: ,
indVoice: ,
stop: ,
};
// 单条气泡:挂载后 rAF 加 `.in` 触发淡入(复刻原版 _spawnBubble 的进场动画)
function Bubble({ m, latest }) {
const [show, setShow] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}, []);
return (
{m.role === 'user' ? 'You' : 'Assistant'}
{m.text}
);
}
export default function VoiceAssistant() {
const [state, setState] = useState('idle');
const [messages, setMessages] = useState([]);
const [muted, setMuted] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
// 全局人物识别开关(MQTT vision_set 控制)
const [visionEnabled, setVisionEnabled] = useState(true);
const clientRef = useRef(null);
const audioCtxRef = useRef(null);
const streamRef = useRef(null);
// 噪声门弧线元素
const micGateRef = useRef(null);
const mgaTrackRef = useRef(null);
const mgaFillRef = useRef(null);
const mgaHitRef = useRef(null);
const mgaHandleRef = useRef(null);
// 气泡定时器:id -> { dismiss?, remove? }
const bubbleTimersRef = useRef(new Map());
const messagesRef = useRef([]);
const live = LIVE_STATES.has(state);
// ── 自动问候生命周期状态(须在 start 之前定义:start 依赖数组引用 sendGreeting)──
const GREETING_TEXT = '您好呀,需要我的帮助吗?';
const GREETING_WAIT_MS = 25000; // 问候播完后等待用户回答的超时
const voiceStateRef = useRef(state);
voiceStateRef.current = state;
const pendingGreetingRef = useRef(false); // 连接成功后待发送的问候
const greetingSentRef = useRef(false); // 问候已发出,等回复完成启动倒计时
const greetingTimerRef = useRef(null); // 等待回答的倒计时句柄
// 主动发送问候(sendUserText → 后端 LLM 生成 → TTS 语音播放)
const sendGreeting = useCallback(() => {
const c = clientRef.current;
if (!c) return;
pendingGreetingRef.current = false;
greetingSentRef.current = true;
c.sendUserText(GREETING_TEXT);
console.warn('[vision] 自动问候已发送:', GREETING_TEXT);
}, []);
// ── 右下角气泡渲染(复刻原版 ui/chat.js)───────────────────────────────
// 流程:新气泡插入 → rAF 加 `.in`(淡入)→ 4s 后加 `.out`(淡出)→ 400ms 移除。
// 持续更新的气泡(partial 逐字流)续期 4s;栈上限 8 条,最旧优先淡出,
// 但永不驱逐正在更新的用户气泡。全部动画定时器集中管理,卸载时清理。
const clearBubbleTimers = useCallback((id) => {
const t = bubbleTimersRef.current.get(id);
if (!t) return;
if (t.dismiss) clearTimeout(t.dismiss);
if (t.remove) clearTimeout(t.remove);
bubbleTimersRef.current.delete(id);
}, []);
/** 立即把气泡标记为 `.out`,delay(默认400ms) 后从数组移除。 */
const markLeaving = useCallback((id, delay = 400) => {
setMessages((prev) => {
const m = prev.find((x) => x.id === id);
if (!m || m.leaving) return prev;
return prev.map((x) => (x.id === id ? { ...x, leaving: true } : x));
});
const remove = setTimeout(() => {
setMessages((prev) => prev.filter((x) => x.id !== id));
bubbleTimersRef.current.delete(id);
}, delay);
bubbleTimersRef.current.set(id, { remove });
}, []);
/** delay(默认4s) 后自动淡出;再次调用即续期(清掉旧计时重排)。 */
const scheduleBubbleDismiss = useCallback((id, delay = 4000) => {
clearBubbleTimers(id);
const dismiss = setTimeout(() => {
setMessages((prev) => {
const m = prev.find((x) => x.id === id);
if (!m || m.leaving) return prev;
return prev.map((x) => (x.id === id ? { ...x, leaving: true } : x));
});
const remove = setTimeout(() => {
setMessages((prev) => prev.filter((x) => x.id !== id));
bubbleTimersRef.current.delete(id);
}, 400);
bubbleTimersRef.current.set(id, { remove });
}, delay);
bubbleTimersRef.current.set(id, { dismiss });
}, [clearBubbleTimers]);
const pushMessage = useCallback((role, text, partial = false) => {
const key = role === 'user' ? 'user' : 'assistant';
let next = [...messagesRef.current];
const last = next.length ? next[next.length - 1] : null;
if (partial && last && last.role === key) {
// 同一条逐字流:更新文本,持续展示不消失
next[next.length - 1] = { ...last, text, partial: true };
} else {
const id = `${Date.now()}-${Math.random()}`;
next.push({ id, role: key, text, partial });
// 最多保留 4 条(最新大字 + 向上历史小字;正在逐字更新的消息位于末位,必然保留)
next = next.slice(-4);
}
messagesRef.current = next;
setMessages(next);
}, []);
// ── 工具调用执行器:LLM 请求工具 → 转发后端 /api/tools/exec → 结果回传 ──
const onToolCall = useCallback(async (e) => {
const { name, arguments: argsStr, callId } = e?.detail ?? {};
if (!name || !callId) return;
let args = {};
try { args = JSON.parse(argsStr || '{}'); } catch { /* 忽略非法参数 */ }
console.warn(`[tools] LLM 调用工具: ${name}`, args);
pushMessage('assistant', `🔧 调用工具:${name}`, false);
let output;
try {
const res = await fetch(`${API_BASE()}/api/tools/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
output = data?.result ?? JSON.stringify(data);
} catch (err) {
output = JSON.stringify({ error: String(err) });
}
const c = clientRef.current;
if (c) {
c.sendToolOutput(callId, output);
c.requestResponse();
}
}, [pushMessage]);
// 结束会话:淡出全部气泡(原版 teardown 行为)
const dismissAllBubbles = useCallback(() => {
const ids = messagesRef.current.map((m) => m.id);
messagesRef.current = [];
ids.forEach((id) => {
if (id !== undefined) markLeaving(id);
});
}, [markLeaving]);
// 卸载时清理所有气泡定时器
useEffect(() => {
return () => {
for (const t of bubbleTimersRef.current.values()) {
if (t.dismiss) clearTimeout(t.dismiss);
if (t.remove) clearTimeout(t.remove);
}
bubbleTimersRef.current.clear();
};
}, []);
// 噪声门弧线:一次性几何(track/fill/hit 同一条 200° 弧 + 阈值圆点)
useEffect(() => {
const d = fullArcD();
mgaTrackRef.current?.setAttribute('d', d);
mgaFillRef.current?.setAttribute('d', d);
mgaHitRef.current?.setAttribute('d', d);
const fill = mgaFillRef.current;
if (fill) {
fill.setAttribute('pathLength', '100');
fill.style.strokeDasharray = '100 100';
fill.style.strokeDashoffset = '100'; // 无电平时空弧
}
const p = arcPoint(dbToFraction(DEFAULT_GATE_DB));
mgaHandleRef.current?.setAttribute('cx', String(p.x));
mgaHandleRef.current?.setAttribute('cy', String(p.y));
}, []);
// 实时麦克风电平 → 弧线填充 + 过门点亮(复刻原版 paintInputLevel)
const paintInputLevel = useCallback((rms) => {
if (!mgaFillRef.current || !micGateRef.current) return;
const db = rms > 0 ? 20 * Math.log10(rms) : GATE_OFF_DB;
const f = dbToFraction(db);
mgaFillRef.current.style.strokeDashoffset = String(100 * (1 - f));
const enabled = DEFAULT_GATE_DB > GATE_OFF_DB;
micGateRef.current.classList.toggle('gate-open', enabled && f >= dbToFraction(DEFAULT_GATE_DB));
}, []);
const start = useCallback(async () => {
if (clientRef.current) return;
setState('connecting');
try {
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
throw new Error('非安全上下文:请通过 http://localhost 或 HTTPS 访问,浏览器才允许麦克风');
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
streamRef.current = stream;
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
if (audioCtxRef.current.state === 'suspended') await audioCtxRef.current.resume();
// 基础提示词:从后端拉取(配置 + 园区知识库已由服务端组装);失败用极简兜底
let instructions = FALLBACK_INSTRUCTIONS;
try {
const insRes = await fetch(`${API_BASE()}/api/s2s/instructions`, { cache: 'no-store' });
const insData = await insRes.json();
if (insData?.ok && insData.instructions) instructions = insData.instructions;
} catch { /* 后端不可用不影响对话 */ }
const client = new S2sWsRealtimeClient({
directUrl: VOICE_URL,
voice: DEFAULT_VOICE,
instructions,
tools: TOOL_DEFS,
noiseGate: { enabled: true, thresholdDb: DEFAULT_GATE_DB },
audioContext: audioCtxRef.current,
micStream: stream,
});
const statusMap = {
connected: 'listening', 'user-speaking': 'user-speaking',
processing: 'processing', 'ai-speaking': 'ai-speaking', idle: 'idle',
};
client.addEventListener('status', (e) => {
const s = e.detail?.status;
setState(statusMap[s] ?? 'listening');
});
client.addEventListener('transcript', (e) => {
const d = e.detail;
pushMessage(d.role === 'user' ? 'user' : 'assistant', d.text, d.partial);
// 用户开始说话(回答了问候)→ 取消挂断倒计时
if (d.role === 'user' && d.text.trim() && greetingTimerRef.current) {
clearTimeout(greetingTimerRef.current);
greetingTimerRef.current = null;
console.warn('[vision] 用户已回答,取消挂断倒计时');
}
});
client.addEventListener('input-level', (e) => paintInputLevel(e.detail?.rms));
client.addEventListener('toolcall', (e) => void onToolCall(e));
client.addEventListener('response-finished', () => {
setState('listening');
// 自动问候的回复播放完毕 → 启动"等待用户回答"倒计时;无回答则自动挂断
if (greetingSentRef.current) {
greetingSentRef.current = false;
if (greetingTimerRef.current) clearTimeout(greetingTimerRef.current);
greetingTimerRef.current = setTimeout(() => {
console.warn('[vision] 问候后用户未回答,自动挂断回初始界面');
void stop();
}, GREETING_WAIT_MS);
}
});
client.addEventListener('error', () => setState('error'));
clientRef.current = client;
await client.connect();
setState('listening');
// 自动问候:连接成功后发送问候(语音)
if (pendingGreetingRef.current) sendGreeting();
} catch (err) {
setErrorMsg(err?.message || String(err));
setState('error');
}
}, [pushMessage, paintInputLevel, sendGreeting, onToolCall]);
const stop = useCallback(async () => {
// 清理自动问候相关状态/倒计时
if (greetingTimerRef.current) {
clearTimeout(greetingTimerRef.current);
greetingTimerRef.current = null;
}
pendingGreetingRef.current = false;
greetingSentRef.current = false;
const c = clientRef.current;
clientRef.current = null;
if (c) { try { await c.close(); } catch { /* noop */ } }
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setMuted(false);
setErrorMsg('');
dismissAllBubbles();
setState('idle');
}, [dismissAllBubbles]);
const toggleMute = useCallback(() => {
const c = clientRef.current;
if (!c) return;
setMuted((m) => { c.setMuted(!m); return !m; });
}, []);
// ── 视觉识别触发:主动问候 + 后端记录/广播 ─────────────────────────────
// 完整生命周期:
// 触发(仅 idle 状态)→ 打开对话(start) → 连接成功发问候(sendUserText→LLM→语音)
// → 回复播放完(response-finished) → 等待用户回答 25s → 无回答则自动挂断回 idle
// → 静默 3 分钟(hook VISION_SILENT_MS)期间不再打扰
// 循环依赖:onVisionTrigger 需读取 vision.faces,而它又要作为参数传给 useVisionDetection。
// 解法:hook 的 onTrigger/onGesture 从 ref 取最新回调;回调在 vision 声明之后再定义。
const visionTriggerRef = useRef(null);
const gestureRef = useRef(null);
// MQTT 调度器 → 语音页操作:启动/关闭对话、刷新页面
useEffect(() => {
const onVoice = (e) => {
const { action } = e.detail || {};
if (action === 'start') {
if (!clientRef.current) void start();
} else if (action === 'stop') {
void stop();
} else if (action === 'refresh') {
window.location.reload();
}
};
window.addEventListener('dpm:voice-control', onVoice);
return () => window.removeEventListener('dpm:voice-control', onVoice);
}, [start, stop]);
// MQTT 调度器 → 全局人物识别开关
useEffect(() => {
const onVision = (e) => setVisionEnabled(Boolean(e.detail?.enabled));
window.addEventListener('dpm:vision-control', onVision);
return () => window.removeEventListener('dpm:vision-control', onVision);
}, []);
const vision = useVisionDetection({
enabled: visionEnabled,
onTrigger: () => visionTriggerRef.current?.(),
onGesture: (g) => gestureRef.current?.(g),
});
// 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束
const onGesture = useCallback((g) => {
if (g !== 'raise') return;
if (clientRef.current) {
void stop();
} else {
void start();
}
}, [start, stop]);
useEffect(() => {
gestureRef.current = onGesture;
}, [onGesture]);
const onVisionTrigger = useCallback(() => {
// 仅 idle 允许自动问候:对话中/连接中/其他状态一律不打扰
if (voiceStateRef.current !== 'idle') {
console.warn('[vision] 自动问候跳过:当前状态', voiceStateRef.current);
return;
}
pendingGreetingRef.current = true;
if (clientRef.current) {
sendGreeting();
} else {
void start(); // start 连接成功后检查 pendingGreetingRef 再发问候
}
fetch(`${API_BASE()}/api/vision/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'triggered', faces: vision.faces, dwell_ms: VISION_DWELL_MS }),
}).catch(() => { /* 上报失败不影响本地问候 */ });
}, [vision.faces, start, sendGreeting]);
useEffect(() => {
visionTriggerRef.current = onVisionTrigger;
}, [onVisionTrigger]);
// 摄像头识别状态变化 → 上报后端(详细日志定位)
useEffect(() => {
if (!vision.status || vision.status === 'off' || vision.status === 'loading') return;
fetch(`${API_BASE()}/api/vision/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: vision.status === 'model-error' ? 'model_error' : vision.status,
faces: vision.faces,
dwell_ms: vision.dwellMs,
detail: vision.errorInfo ? `${vision.errorInfo.name}: ${vision.errorInfo.message}` : '',
}),
}).catch(() => { /* 静默 */ });
}, [vision.status]); // eslint-disable-line react-hooks/exhaustive-deps
const visionStatusText = {
off: '摄像头关闭',
loading: '摄像头启动中…',
detecting: `识别中 · ${vision.faces} 人${vision.faces > 0 ? `(后端 ${vision.latency ?? '-'}ms)` : ' · 未检到人脸'} · 链路${vision.modelPhase === 'ok' ? 'OK' : vision.modelPhase === 'failed' ? `失败:${(vision.modelErr || '').slice(0, 40)}` : '连接中'} · 亮度${vision.frameLum ?? '-'}${vision.detectErr ? ` · 异常:${String(vision.detectErr).slice(0, 50)}` : ''}`,
facing: `面向大屏 ${(vision.dwellMs / 1000).toFixed(1)}s / ${VISION_DWELL_MS / 1000}s`,
triggered: '已触发 · 主动问候',
silent: '静默中',
error: '摄像头不可用',
}[vision.status] ?? '';
// 摄像头失败的具体原因
const visionErrorText = (() => {
if (vision.status !== 'error') return null;
const name = vision.errorInfo?.name ?? '';
const msg = vision.errorInfo?.message ?? '';
if (!window.isSecureContext) return '非安全上下文:请用 localhost 或 HTTPS 访问';
if (name === 'NotFoundError') return `未检测到摄像头设备:${msg || '请确认 USB 摄像头已接入本机'}`;
if (name === 'NotAllowedError') return `摄像头权限被拒:请在浏览器地址栏允许 ${location.host} 使用摄像头`;
if (name === 'NotReadableError') return '摄像头被其他应用占用或不可用,请关闭占用程序后刷新';
return `摄像头启动失败(${name}):${msg}`;
})();
const caption = state === 'error' ? (errorMsg || 'Tap to retry') : (STATE_VIEWS[state] ?? '');
return (
{/* 非安全上下文提示(局域网 IP 访问时浏览器禁用麦克风/摄像头) */}
{!window.isSecureContext && (
⚠️ 当前页面不是安全上下文,浏览器禁止麦克风/摄像头。
请改用 http://localhost 访问(或为后端配置 HTTPS)。
)}
{/* 主舞台:中间圆球 */}
{caption}
{/* 右下角气泡(复刻原版:进场 in / 4s 自动淡出 out / 阶梯字号) */}
{messages.slice(-4).map((m, i, arr) => (
))}
{/* 视觉识别:摄像头默认开启;video 始终渲染(ref 先于启动存在),未开启时隐藏 */}
智能识别中
{vision.gesture === 'raise' && (
您好呀 · {live ? '开始对话' : '结束对话'}
)}
{visionStatusText}
{vision.status === 'error' && !vision.cameraOn && (
⚠️ {visionErrorText}
)}
);
}