feat: AI 助手支持文字/语音双模式

- 头部新增模式切换(文字/语音),文字=对话框,语音=语音交互按钮
- 语音模式:大麦克风按钮 + 聆听波纹动画 + 识别中状态,点击开始/再次点击结束
- 优先使用 Web Speech API 识别(zh-CN),无权限/不支持时自动兜底模拟识别
- 语音提问以文字形式进入同一对话流,气泡带「语音」标记
- 切换模式保留对话历史;聆听中切换/离开自动中止识别
- 新增 microphone/microphone-filled/microphone-off/message-circle 图标
This commit is contained in:
Pine
2026-08-17 20:34:45 +08:00
parent 7da9749953
commit e59047c4aa
3 changed files with 366 additions and 43 deletions
+214 -43
View File
@@ -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 (
<section className="bd-chat">
{/* 头部 */}
@@ -122,7 +245,23 @@ export default function AiChatPanel() {
<span className="bd-chat-logo"><Icon name="robot" size={17} /></span>
<div className="bd-chat-titles">
<b>AI 智能助手</b>
<span>OPC 园区知识库 · 随时为您解答</span>
<span>OPC 园区知识库 · 文字 / 语音双模式</span>
</div>
<div className="bd-chat-modes">
<button
className={mode === 'text' ? 'active' : ''}
onClick={() => switchMode('text')}
title="文字对话"
>
<Icon name="message-circle" size={13} />文字
</button>
<button
className={mode === 'voice' ? 'active' : ''}
onClick={() => switchMode('voice')}
title="语音对话"
>
<Icon name="microphone" size={13} />语音
</button>
</div>
<span className="bd-chat-model"><i />DeepSeek-V3</span>
<button className="bd-chat-clear" title="清空对话" onClick={clear}>
@@ -130,14 +269,19 @@ export default function AiChatPanel() {
</button>
</div>
{/* 消息区 */}
{/* 消息区(文字 / 语音共用) */}
<div className="bd-chat-msgs" ref={scrollRef}>
{msgs.map((m) => (
<div className={`bd-msg ${m.role}`} key={m.id}>
{m.role === 'ai' && (
<span className="bd-msg-avatar"><Icon name="robot" size={13} /></span>
)}
<div className="bd-msg-bubble">{m.text}</div>
<div className="bd-msg-bubble">
{m.voice && (
<span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>
)}
{m.text}
</div>
</div>
))}
{typing && (
@@ -148,38 +292,65 @@ export default function AiChatPanel() {
)}
</div>
{/* 快捷提问 */}
<div className="bd-chat-quick">
{QUICK_QUESTIONS.map((q) => (
<button key={q} onClick={() => send(q)}>
<Icon name="sparkles" size={12} />
{q}
</button>
))}
</div>
{/* 文字模式:快捷提问 + 输入区 */}
{mode === 'text' && (
<>
<div className="bd-chat-quick">
{QUICK_QUESTIONS.map((q) => (
<button key={q} onClick={() => send(q)}>
<Icon name="sparkles" size={12} />
{q}
</button>
))}
</div>
<div className="bd-chat-input">
<input
value={input}
placeholder="输入你的问题,如:如何申请入驻园区?"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault();
send(input);
}
}}
/>
<button
className="bd-chat-send"
disabled={!input.trim() || typing}
onClick={() => send(input)}
title="发送"
>
<Icon name="send" size={16} />
</button>
</div>
</>
)}
{/* 输入区 */}
<div className="bd-chat-input">
<input
value={input}
placeholder="输入你的问题,如:如何申请入驻园区?"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault();
send(input);
}
}}
/>
<button
className="bd-chat-send"
disabled={!input.trim() || typing}
onClick={() => send(input)}
title="发送"
>
<Icon name="send" size={16} />
</button>
</div>
{/* 语音模式:语音交互按钮 */}
{mode === 'voice' && (
<div className="bd-chat-voice">
<div className={`bd-chat-voice-hint ${voiceState}`}>
{voiceState === 'listening' && <span className="bd-voice-wave"><i /><i /><i /><i /><i /></span>}
{voiceHint}
</div>
<button
className={`bd-chat-mic ${voiceState}`}
onClick={toggleVoice}
title={voiceState === 'listening' ? '结束并发送' : '开始语音提问'}
>
{voiceState === 'listening' && <span className="bd-mic-ring r1" />}
{voiceState === 'listening' && <span className="bd-mic-ring r2" />}
<Icon
name={voiceState === 'listening' ? 'microphone-filled' : 'microphone'}
size={30}
/>
</button>
<div className="bd-chat-voice-tip">
{voiceState === 'listening' ? '再次点击结束并发送' : '点击开始说话 · 再次点击结束'}
</div>
</div>
)}
</section>
);
}