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>
);
}
+6
View File
@@ -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,
};
+146
View File
@@ -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%; }