Files
DPM/src/pages/VoiceAssistant.jsx
T
Pine 18d28be943 feat: 添加后端部署和API基本检索的Docker Compose配置
- 引入新的`docker-compose.yml`文件,便于后端和MQTT代理(EMQX)的部署。
- 更新多个组件中的API基本检索,使用函数进行动态解析。
- 加强了多个组件中API调用的错误处理和日志记录。
- 优化了AI聊天面板离线场景的回退响应。
- 更新DataScreen和MediaScreen组件中的数据显示和统计,以反映准确的指标。
- 重构MQTT连接逻辑,以支持动态凭证和客户端ID。
2026-08-18 06:50:50 +08:00

610 lines
28 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================
实时语音对话页(/voice)—— React 完全复刻 live-avatar 对话 UI
· 无自带 topbarDPM 页眉由 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: <svg className="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 10a7 7 0 0 0 14 0" /><line x1="12" y1="19" x2="12" y2="22" /></svg>,
micOff: <svg className="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22" /><path d="M9 5a3 3 0 0 1 6 0v4" /><path d="M9 10v1a3 3 0 0 0 5.1 2.1" /><path d="M19 10a7 7 0 0 1-1.24 3.97" /><path d="M5 10a7 7 0 0 0 11 5.67" /><line x1="12" y1="19" x2="12" y2="22" /></svg>,
indMic: <svg className="ind ind-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none" /><path d="M5 10a7 7 0 0 0 14 0" /><line x1="12" y1="19" x2="12" y2="22" /><line x1="8" y1="22" x2="16" y2="22" /></svg>,
indError: <svg className="ind ind-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><line x1="12" y1="8" x2="12" y2="13" /><line x1="12" y1="16" x2="12" y2="16" /></svg>,
indVoice: <svg className="ind ind-voice" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 10v4a1 1 0 0 0 1 1h3l5 4V5L7 9H4a1 1 0 0 0-1 1z" fill="currentColor" stroke="none" /><path className="wave wave-1" d="M16 8a5 5 0 0 1 0 8" /><path className="wave wave-2" d="M19 5a9 9 0 0 1 0 14" /></svg>,
stop: <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>,
};
// 单条气泡:挂载后 rAF 加 `.in` 触发淡入(复刻原版 _spawnBubble 的进场动画)
function Bubble({ m, latest }) {
const [show, setShow] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={`bubble ${m.role}${show ? ' in' : ''}${latest ? ' latest' : ' history'}`}>
<div className="bubble-role">{m.role === 'user' ? 'You' : 'Assistant'}</div>
<div className={`bubble-body${m.partial ? ' partial' : ''}`}>{m.text}</div>
</div>
);
}
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 (
<div id="app">
{/* 非安全上下文提示(局域网 IP 访问时浏览器禁用麦克风/摄像头) */}
{!window.isSecureContext && (
<div className="voice-secure-warn">
当前页面不是安全上下文浏览器禁止麦克风/摄像头
请改用 <b>http://localhost</b> 访问(或为后端配置 HTTPS)。
</div>
)}
{/* 主舞台:中间圆球 */}
<main className="stage">
<div className={`orb-wrap${live ? ' live' : ''}`}>
<div id="mic-gate" className="mic-gate" ref={micGateRef}>
<svg id="mic-gate-arc" className="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true">
<path id="mga-track" className="mga-track" fill="none" ref={mgaTrackRef} />
<path id="mga-fill" className="mga-fill" fill="none" ref={mgaFillRef} />
<path id="mga-hit" className="mga-hit" fill="none" ref={mgaHitRef} />
<circle id="mga-handle" className="mga-handle" r="3" ref={mgaHandleRef} />
</svg>
<button
id="mic-btn"
className={`side-btn${muted ? ' muted' : ''}`}
type="button"
aria-label={muted ? 'Unmute' : 'Mute'}
title={muted ? 'Unmute' : 'Mute'}
aria-hidden={!live}
tabIndex={live ? 0 : -1}
onClick={toggleMute}
>
{icons.micOn}
{icons.micOff}
</button>
</div>
<button
id="main-circle"
className={`circle ${STATE_CLASS[state] ?? 'state-idle'}`}
type="button"
aria-label="Start voice conversation"
disabled={state === 'connecting'}
onClick={live ? stop : start}
>
<span className="circle-glow" aria-hidden="true" />
<span className="circle-ring" aria-hidden="true" />
<span className="circle-ring-outer" aria-hidden="true" />
<span className="circle-core">
<span className="circle-indicator" aria-hidden="true">
{icons.indMic}
{icons.indError}
<span className="ind ind-spinner" />
<span className="ind ind-thinking"><span className="dot" /><span className="dot" /><span className="dot" /></span>
<span className="ind ind-bars"><span className="bar" /><span className="bar" /><span className="bar" /><span className="bar" /><span className="bar" /></span>
{icons.indVoice}
</span>
</span>
</button>
<button
id="stop-btn"
className="side-btn"
type="button"
aria-label="End"
title="End"
aria-hidden={!live}
tabIndex={live ? 0 : -1}
onClick={stop}
>
{icons.stop}
</button>
</div>
<p id="circle-caption" className={`circle-caption${caption ? '' : ' empty'}${state === 'error' ? ' error' : ''}`} role="status">{caption}</p>
<p id="circle-subcaption" className="circle-subcaption" hidden />
</main>
{/* 右下角气泡(复刻原版:进场 in / 4s 自动淡出 out / 阶梯字号) */}
<div id="bubble-stack" className="bubble-stack" aria-live="polite" aria-atomic="false">
{messages.slice(-4).map((m, i, arr) => (
<Bubble key={m.id} m={m} latest={i === arr.length - 1} />
))}
</div>
{/* 视觉识别:摄像头默认开启;video 始终渲染(ref 先于启动存在),未开启时隐藏 */}
<div className={`voice-cam-pip${vision.cameraOn ? '' : ' hidden'}`}>
<video ref={vision.videoRef} autoPlay playsInline muted />
<div className="voice-cam-bar">
<span className="voice-cam-title">
<span className={`voice-cam-dot ${vision.status === 'triggered' ? 'trigger' : vision.status === 'facing' ? 'face' : 'on'}`} />
智能识别中
</span>
<span>
{vision.gesture === 'raise' && (
<b style={{ color: '#2f6bff' }}>您好呀 · {live ? '开始对话' : '结束对话'}</b>
)}
{visionStatusText}
</span>
</div>
</div>
{vision.status === 'error' && !vision.cameraOn && (
<div className="voice-vision-status"> {visionErrorText}</div>
)}
</div>
);
}