From e59047c4aa4210a35dcaf9f56beb0ef2a0adf04c Mon Sep 17 00:00:00 2001 From: Pine Date: Mon, 17 Aug 2026 20:34:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E5=8A=A9=E6=89=8B=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=96=87=E5=AD=97/=E8=AF=AD=E9=9F=B3=E5=8F=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 头部新增模式切换(文字/语音),文字=对话框,语音=语音交互按钮 - 语音模式:大麦克风按钮 + 聆听波纹动画 + 识别中状态,点击开始/再次点击结束 - 优先使用 Web Speech API 识别(zh-CN),无权限/不支持时自动兜底模拟识别 - 语音提问以文字形式进入同一对话流,气泡带「语音」标记 - 切换模式保留对话历史;聆听中切换/离开自动中止识别 - 新增 microphone/microphone-filled/microphone-off/message-circle 图标 --- src/components/AiChatPanel.jsx | 257 +++++++++++++++++++++++++++------ src/components/Icons.jsx | 6 + src/styles/datascreen.css | 146 +++++++++++++++++++ 3 files changed, 366 insertions(+), 43 deletions(-) diff --git a/src/components/AiChatPanel.jsx b/src/components/AiChatPanel.jsx index cd8b540..6da4de1 100644 --- a/src/components/AiChatPanel.jsx +++ b/src/components/AiChatPanel.jsx @@ -3,7 +3,9 @@ import Icon from './Icons'; /* ========================================================= AI 智能助手对话面板(共享)—— 遵循 bd- 浅色科技 UI 规范 - 左侧对话区:消息流 + 快捷提问 + 输入发送(模拟园区知识库回复) + 两种交互模式: + · 文字对话:消息流 + 快捷提问 + 输入发送 + · 语音对话:语音交互按钮(聆听 → 识别 → 以文字进入对话流) ========================================================= */ /* ---------- 快捷提问 ---------- */ @@ -15,8 +17,19 @@ const QUICK_QUESTIONS = [ '三个分区有什么区别?', ]; +/* 语音识别兜底题库(无麦克风权限 / 环境不支持时模拟识别) */ +const VOICE_SAMPLES = [ + '如何申请入驻园区?', + '园区有哪些政策扶持?', + '办公场地怎么收费?', + 'AI 赋能服务有哪些?', + '三个分区有什么区别?', + '园区有哪些入驻企业?', + '怎么联系创业导师?', +]; + /* ---------- 园区知识库(模拟回复) ---------- */ -const WELCOME = '您好,我是 OPC 园区 AI 智能助手。\n\n我可以为您解答入驻流程、政策扶持、场地服务、AI 赋能、企业名录等问题。您也可以点击下方快捷提问,或直接输入问题。'; +const WELCOME = '您好,我是 OPC 园区 AI 智能助手。\n\n我可以为您解答入驻流程、政策扶持、场地服务、AI 赋能、企业名录等问题。您可以在「文字」模式输入问题,或切换到「语音」模式直接说话提问。'; const RULES = [ { @@ -56,8 +69,8 @@ const RULES = [ reply: '园区配备创业导师 58 位,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办', }, { - match: /你好|您好|hi|hello|嗨|在吗|hello/, - reply: '您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题,很高兴为您服务。', + match: /你好|您好|hi|hello|嗨|在吗/, + reply: '您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题,也可以切换到「语音」模式直接说话提问。', }, { match: /谢谢|感谢|好的|明白|ok/, @@ -78,6 +91,7 @@ function getReply(input) { /* ---------- 消息 ---------- */ let msgId = 0; const nextId = () => ++msgId; +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; export default function AiChatPanel() { const [msgs, setMsgs] = useState(() => [ @@ -85,22 +99,32 @@ export default function AiChatPanel() { ]); const [input, setInput] = useState(''); const [typing, setTyping] = useState(false); + const [mode, setMode] = useState('text'); // text | voice + const [voiceState, setVoiceState] = useState('idle'); // idle | listening | processing const scrollRef = useRef(null); const timerRef = useRef(null); + const recRef = useRef(null); - // 新消息自动滚动到底部 + // 新消息 / 状态变化自动滚动到底部 useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [msgs, typing]); + }, [msgs, typing, mode, voiceState]); - useEffect(() => () => clearTimeout(timerRef.current), []); + // 卸载清理:定时器 + 语音识别 + useEffect(() => { + return () => { + clearTimeout(timerRef.current); + try { recRef.current && recRef.current.abort && recRef.current.abort(); } catch { /* ignore */ } + }; + }, []); - const send = useCallback((raw) => { + /** 生成用户消息并触发 AI 回复 */ + const send = useCallback((raw, viaVoice = false) => { const text = String(raw || '').trim(); if (!text || typing) return; setInput(''); - setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text }]); + setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text, voice: viaVoice }]); setTyping(true); const reply = getReply(text); timerRef.current = setTimeout(() => { @@ -115,6 +139,105 @@ export default function AiChatPanel() { setMsgs([{ id: nextId(), role: 'ai', text: WELCOME }]); }, []); + /* ================= 语音对话 ================= */ + + // 供安全兜底定时器读取当前状态 + const voiceStateRef = useRef(voiceState); + useEffect(() => { voiceStateRef.current = voiceState; }, [voiceState]); + + /** 结束聆听:把识别出的文字送入对话流 */ + const finishVoice = useCallback((text) => { + setVoiceState('processing'); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setVoiceState('idle'); + send(text, true); + }, 450); + }, [send]); + + /** 兜底:环境不支持 / 无麦克风时模拟识别 */ + const fallbackVoice = useCallback(() => { + if (recRef.current) { try { recRef.current.abort(); } catch { /* ignore */ } recRef.current = null; } + finishVoice(pick(VOICE_SAMPLES)); + }, [finishVoice]); + + /** 点击语音按钮:idle → 开始聆听;listening → 结束 */ + const toggleVoice = useCallback(() => { + if (voiceState === 'processing') return; + if (voiceState === 'listening') { + // 用户主动结束:尝试取识别结果,无结果则兜底 + clearTimeout(timerRef.current); + if (recRef.current) { + try { recRef.current.stop(); } catch { /* ignore */ } + } + return; // 由 onend/onerror 或兜底定时器完成收尾 + } + // 开始聆听 + setVoiceState('listening'); + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (SR) { + try { + const rec = new SR(); + rec.lang = 'zh-CN'; + rec.interimResults = false; + rec.maxAlternatives = 1; + let done = false; + rec.onresult = (e) => { + if (done) return; + done = true; + clearTimeout(timerRef.current); + const t = Array.from(e.results).map((r) => r[0].transcript).join(' '); + finishVoice(t || pick(VOICE_SAMPLES)); + }; + rec.onerror = () => { + if (done) return; + done = true; + clearTimeout(timerRef.current); + fallbackVoice(); + }; + rec.onend = () => { + recRef.current = null; + if (!done) { + done = true; + clearTimeout(timerRef.current); + fallbackVoice(); + } + }; + recRef.current = rec; + rec.start(); + // 安全兜底:4 秒内无结果则模拟(识别静默失败的环境) + timerRef.current = setTimeout(() => { + if (!done && voiceStateRef.current === 'listening') { + done = true; + try { rec.abort(); } catch { /* ignore */ } + fallbackVoice(); + } + }, 4000); + return; + } catch { /* 继续走兜底 */ } + } + // 无 SpeechRecognition:模拟聆听 → 识别 + timerRef.current = setTimeout(() => { + fallbackVoice(); + }, 1300); + }, [voiceState, finishVoice, fallbackVoice]); + + // 切换模式时中止聆听 + const switchMode = useCallback((m) => { + setMode(m); + if (voiceState === 'listening') { + clearTimeout(timerRef.current); + if (recRef.current) { try { recRef.current.abort(); } catch { /* ignore */ } recRef.current = null; } + setVoiceState('idle'); + } + }, [voiceState]); + + const voiceHint = { + idle: '点击麦克风,说出你的问题', + listening: '正在聆听… 请说出你的问题', + processing: '正在识别语音…', + }[voiceState]; + return (
{/* 头部 */} @@ -122,7 +245,23 @@ export default function AiChatPanel() {
AI 智能助手 - OPC 园区知识库 · 随时为您解答 + OPC 园区知识库 · 文字 / 语音双模式 +
+
+ +
DeepSeek-V3 - {/* 消息区 */} + {/* 消息区(文字 / 语音共用) */}
{msgs.map((m) => (
{m.role === 'ai' && ( )} -
{m.text}
+
+ {m.voice && ( + 语音 + )} + {m.text} +
))} {typing && ( @@ -148,38 +292,65 @@ export default function AiChatPanel() { )}
- {/* 快捷提问 */} -
- {QUICK_QUESTIONS.map((q) => ( - - ))} -
+ {/* 文字模式:快捷提问 + 输入区 */} + {mode === 'text' && ( + <> +
+ {QUICK_QUESTIONS.map((q) => ( + + ))} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault(); + send(input); + } + }} + /> + +
+ + )} - {/* 输入区 */} -
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { - e.preventDefault(); - send(input); - } - }} - /> - -
+ {/* 语音模式:语音交互按钮 */} + {mode === 'voice' && ( +
+
+ {voiceState === 'listening' && } + {voiceHint} +
+ +
+ {voiceState === 'listening' ? '再次点击结束并发送' : '点击开始说话 · 再次点击结束'} +
+
+ )}
); } diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 0d21eaa..dec5495 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -11,6 +11,7 @@ import { History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity, Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause, Send, Trash, Eraser, Headset, CircleDot, + Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle, } from '@appica/icons-react'; /* 名称 → 图标组件映射(推荐的调用方式:icon="bolt") */ @@ -79,6 +80,10 @@ const NAME_MAP = { eraser: Eraser, headset: Headset, 'circle-dot': CircleDot, + microphone: Microphone, + 'microphone-filled': MicrophoneFilled, + 'microphone-off': MicrophoneOff, + 'message-circle': MessageCircle, }; /** @@ -112,4 +117,5 @@ export { History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity, Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause, Send, Trash, Eraser, Headset, CircleDot, + Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle, }; diff --git a/src/styles/datascreen.css b/src/styles/datascreen.css index c9d1afc..beae35d 100644 --- a/src/styles/datascreen.css +++ b/src/styles/datascreen.css @@ -1986,6 +1986,152 @@ cursor: not-allowed; box-shadow: none; } +/* ---------- 模式切换(文字 / 语音) ---------- */ +.bd-chat-modes { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: rgba(120,160,220,0.12); + border: 1px solid rgba(120,160,220,0.18); + flex-shrink: 0; +} +.bd-chat-modes button { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 11px; + font-size: 11px; + font-weight: 700; + color: #64748d; + background: transparent; + border: none; + border-radius: 7px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.bd-chat-modes button:hover { + color: var(--bd-blue); +} +.bd-chat-modes button.active { + color: var(--bd-blue); + background: rgba(255,255,255,0.9); + box-shadow: 0 2px 8px rgba(47,107,255,0.18); +} +/* ---------- 语音对话区 ---------- */ +.bd-chat-voice { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 14px 16px 18px; +} +.bd-chat-voice-hint { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: #64748d; + min-height: 18px; + transition: color 0.2s; +} +.bd-chat-voice-hint.listening { + color: var(--bd-blue); +} +.bd-chat-voice-hint.processing { + color: var(--bd-orange); +} +/* 聆听波形 */ +.bd-voice-wave { + display: inline-flex; + align-items: center; + gap: 3px; + height: 14px; +} +.bd-voice-wave i { + width: 3px; + border-radius: 2px; + background: var(--bd-blue); + animation: bdWave 1s ease-in-out infinite; +} +.bd-voice-wave i:nth-child(1) { height: 6px; animation-delay: 0s; } +.bd-voice-wave i:nth-child(2) { height: 12px; animation-delay: 0.12s; } +.bd-voice-wave i:nth-child(3) { height: 8px; animation-delay: 0.24s; } +.bd-voice-wave i:nth-child(4) { height: 13px; animation-delay: 0.36s; } +.bd-voice-wave i:nth-child(5) { height: 7px; animation-delay: 0.48s; } +@keyframes bdWave { + 0%, 100% { transform: scaleY(0.5); opacity: 0.5; } + 50% { transform: scaleY(1); opacity: 1; } +} +/* 语音交互按钮 */ +.bd-chat-mic { + position: relative; + width: 76px; + height: 76px; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan)); + box-shadow: 0 10px 28px rgba(47,107,255,0.32); + cursor: pointer; + transition: all 0.25s; +} +.bd-chat-mic:hover { + transform: translateY(-2px) scale(1.03); + box-shadow: 0 14px 34px rgba(47,107,255,0.4); +} +.bd-chat-mic.listening { + background: linear-gradient(140deg, var(--bd-blue-deep), var(--bd-blue)); + box-shadow: 0 0 0 0 rgba(47,107,255,0.35); +} +.bd-chat-mic.processing { + background: linear-gradient(140deg, var(--bd-orange), #ffb648); + box-shadow: 0 10px 28px rgba(247,144,9,0.3); + cursor: default; +} +/* 聆听扩散波纹 */ +.bd-mic-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 2px solid rgba(47,107,255,0.4); + animation: bdMicRing 1.5s ease-out infinite; + pointer-events: none; +} +.bd-mic-ring.r2 { + animation-delay: 0.5s; +} +@keyframes bdMicRing { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(1.7); opacity: 0; } +} +.bd-chat-voice-tip { + font-size: 10.5px; + color: #9fb0c6; + letter-spacing: 0.03em; +} +/* 语音提问消息标记 */ +.bd-msg-voice-tag { + display: inline-flex; + align-items: center; + gap: 4px; + margin-right: 8px; + padding: 1px 7px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + border-radius: 10px; + color: rgba(255,255,255,0.92); + background: rgba(255,255,255,0.22); + vertical-align: 1px; +} /* 移动端/窄屏:消息宽度放开 */ @media (max-width: 1100px) { .bd-msg { max-width: 94%; }