import { useState, useRef, useEffect, useCallback } from 'react'; import Icon from './Icons'; import { API_BASE } from '../config'; import { getMqttStatus } from '../utils/mqtt'; /* ========================================================= AI 智能助手对话面板 —— 文字 / 语音双模式 · 文字:后端 /api/ai/chat(阿里云通义千问 + 工具调用) · 语音:本地录音 → 后端 /api/ai/asr(阿里云 paraformer)→ 进入对话流 后端不可用时自动回退本地模拟(离线兜底) ========================================================= */ const QUICK_QUESTIONS = [ '如何申请入驻园区?', '园区有哪些政策扶持?', '展示入驻企业卡片', 'AI 赋能服务有哪些?', '切换到数字孪生页面', ]; /* 离线兜底回复(后端不可用时) */ const FALLBACK_REPLIES = [ '园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务,在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。', '入驻流程:提交申请 → 项目评审 → 签订协议 → 安排场地并对接导师服务。在园项目 158 家,累计孵化 208 余家。', '园区政策覆盖场地租金减免、创业补贴、税费优惠与高企认定申报辅导(在园高企 32 家)。', '园区按成长阶段分三个区域:加速区(A1–A12)、国际区(I1–I7)、成长区(G1–G8),共 39 家入驻企业。', ]; const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; /* 本地兜底识别题库(ASR 不可用时) */ const VOICE_SAMPLES = QUICK_QUESTIONS; /* ---------- 消息 ---------- */ let msgId = 0; const nextId = () => ++msgId; /** 执行后端返回的工具调用。 后端已通过 MQTT 广播(发布成功时所有大屏都会收到,本地跳过避免重复); 仅当 MQTT 发布失败 / 后端不可达时本地执行,保证当前屏幕也能响应 */ function executeLocalTools(tools, mqttPublished) { if (!Array.isArray(tools) || tools.length === 0) return; if (mqttPublished !== false) return; for (const tool of tools) { const type = tool.type; const params = tool.params || {}; if (type === 'navigate' || type === 'control' || type === 'alert' || type === 'show_card') { window.dispatchEvent(new CustomEvent('dpm:mqtt-local', { detail: { action: type, params } })); } } } export default function AiChatPanel() { const [msgs, setMsgs] = useState(() => [{ id: nextId(), role: 'ai', text: '您好,我是 OPC 园区 AI 智能助手(阿里云通义千问)。\n\n支持文字与语音提问,可以为您解答入驻流程、政策扶持、场地服务、AI 赋能等问题,还可以通过语音或文字让我切换页面、展示企业卡片。' }]); const [input, setInput] = useState(''); const [typing, setTyping] = useState(false); const [mode, setMode] = useState('text'); const [voiceState, setVoiceState] = useState('idle'); // idle | recording | transcribing const scrollRef = useRef(null); const timerRef = useRef(null); const recorderRef = useRef(null); const chunksRef = useRef([]); const voiceStateRef = useRef(voiceState); useEffect(() => { voiceStateRef.current = voiceState; }, [voiceState]); useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs, typing, mode, voiceState]); useEffect(() => { return () => { clearTimeout(timerRef.current); if (recorderRef.current && recorderRef.current.state !== 'inactive') { try { recorderRef.current.stop(); } catch { /* ignore */ } } }; }, []); /** 发送消息 → 后端 LLM */ const send = useCallback(async (raw, viaVoice = false) => { const text = String(raw || '').trim(); if (!text || typing) return; setInput(''); setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text, voice: viaVoice }]); setTyping(true); let reply = null; let tools = []; try { const res = await fetch(`${API_BASE}/api/ai/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', content: text }] }), }); if (res.ok) { const data = await res.json(); reply = data.reply; tools = data.tools || []; } } catch { /* 后端不可用 */ } if (!reply) reply = pick(FALLBACK_REPLIES); setTyping(false); setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]); executeLocalTools(tools); }, [typing]); const clear = useCallback(() => { clearTimeout(timerRef.current); setTyping(false); setMsgs([{ id: nextId(), role: 'ai', text: '您好,我是 OPC 园区 AI 智能助手。有什么可以帮您?' }]); }, []); /* ================= 语音对话(录音 → 后端 ASR) ================= */ const pickMimeType = () => { const types = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/webm']; for (const t of types) { if (window.MediaRecorder && MediaRecorder.isTypeSupported(t)) return t; } return ''; }; const startRecording = useCallback(() => { const mime = pickMimeType(); if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia) { // 无录音能力:直接模拟 setVoiceState('transcribing'); timerRef.current = setTimeout(() => { setVoiceState('idle'); send(pick(VOICE_SAMPLES), true); }, 1200); return; } navigator.mediaDevices.getUserMedia({ audio: true }) .then((stream) => { const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); chunksRef.current = []; rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); }; rec.onstop = async () => { stream.getTracks().forEach((t) => t.stop()); setVoiceState('transcribing'); const blob = new Blob(chunksRef.current, { type: mime || 'audio/webm' }); try { const fmt = (mime || 'webm').split(';')[0].split('/')[1] || 'm4a'; const form = new FormData(); form.append('file', blob, `recording.${fmt === 'mp4' ? 'm4a' : fmt}`); form.append('format', fmt === 'mp4' ? 'm4a' : fmt); const res = await fetch(`${API_BASE}/api/ai/asr`, { method: 'POST', body: form }); const data = await res.json(); const text = (data && data.text || '').trim(); if (text) { send(text, true); } else { setVoiceState('idle'); setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: '抱歉,没有听清您的问题,请再试一次,或改用文字输入。' }]); } } catch { // 后端不可用:回退本地模拟识别 setVoiceState('transcribing'); timerRef.current = setTimeout(() => { setVoiceState('idle'); send(pick(VOICE_SAMPLES), true); }, 1000); } }; rec.onerror = () => { stream.getTracks().forEach((t) => t.stop()); setVoiceState('idle'); send(pick(VOICE_SAMPLES), true); }; recorderRef.current = rec; rec.start(); setVoiceState('recording'); }) .catch(() => { // 麦克风权限被拒:模拟 setVoiceState('transcribing'); timerRef.current = setTimeout(() => { setVoiceState('idle'); send(pick(VOICE_SAMPLES), true); }, 1200); }); }, [send]); // MQTT 调度器 → AI 页操作:输入并发送 / 选择预设问题 useEffect(() => { const onAiAction = (e) => { const { type, text, index } = e.detail || {}; if (type === 'input' && text && String(text).trim()) { setInput(String(text).trim()); void send(String(text).trim()); } else if (type === 'preset') { const q = QUICK_QUESTIONS[Number(index)]; if (q) void send(q); } }; window.addEventListener('dpm:ai-action', onAiAction); return () => window.removeEventListener('dpm:ai-action', onAiAction); }, [send]); const toggleVoice = useCallback(() => { if (voiceStateRef.current === 'recording') { const rec = recorderRef.current; if (rec && rec.state === 'recording') { rec.stop(); // 触发 onstop → 上传识别 } else { setVoiceState('idle'); } } else if (voiceStateRef.current === 'idle') { startRecording(); } }, [startRecording]); const switchMode = useCallback((m) => { setMode(m); if (voiceStateRef.current === 'recording') { try { recorderRef.current && recorderRef.current.stop(); } catch { /* ignore */ } setVoiceState('idle'); } }, []); const voiceHint = { idle: '点击麦克风,说出你的问题', recording: '正在聆听… 再次点击结束', transcribing: '正在识别语音…', }[voiceState]; return (
{/* 头部 */}
AI 智能助手 阿里云通义千问 · 语音识别
通义千问
{/* 消息区 */}
{msgs.map((m) => (
{m.role === 'ai' && }
{m.voice && 语音} {m.text}
))} {typing && (
)}
{/* 文字模式 */} {mode === 'text' && ( <>
{QUICK_QUESTIONS.map((q) => ( ))}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); send(input); } }} />
)} {/* 语音模式 */} {mode === 'voice' && (
{voiceState === 'recording' && } {voiceHint}
{voiceState === 'recording' ? '再次点击结束并识别' : '点击开始说话 · 阿里云语音识别'}
)}
); }