feat: AI 助手支持文字/语音双模式
- 头部新增模式切换(文字/语音),文字=对话框,语音=语音交互按钮 - 语音模式:大麦克风按钮 + 聆听波纹动画 + 识别中状态,点击开始/再次点击结束 - 优先使用 Web Speech API 识别(zh-CN),无权限/不支持时自动兜底模拟识别 - 语音提问以文字形式进入同一对话流,气泡带「语音」标记 - 切换模式保留对话历史;聆听中切换/离开自动中止识别 - 新增 microphone/microphone-filled/microphone-off/message-circle 图标
This commit is contained in:
+214
-43
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
Reference in New Issue
Block a user