feat: 手势开启新对话 —— 确认弹窗卡片 + 新对话首条用默认欢迎语

- 对话中举手 → 弹出确认卡片(再次举手或点击「确认」开启新对话,10s 倒计时自动取消;取消按钮可手动关闭)
- 欢迎页(未开始对话)举手不响应
- 开启新对话后第一条消息 = 默认完整欢迎语(「问问小园」问候),不再只发「已开启新的对话」
- 1 分钟无新消息自动清空本轮,回到默认欢迎语(原 2 分钟)
- 新增 hand-stop/camera 图标与确认卡片样式
This commit is contained in:
Pine
2026-08-18 06:13:47 +08:00
parent 0af159a175
commit 418d94d7b7
4 changed files with 333 additions and 98 deletions
-54
View File
@@ -1148,9 +1148,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1164,9 +1161,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1180,9 +1174,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1196,9 +1187,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1212,9 +1200,6 @@
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1228,9 +1213,6 @@
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1244,9 +1226,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1260,9 +1239,6 @@
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1276,9 +1252,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1292,9 +1265,6 @@
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1308,9 +1278,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1324,9 +1291,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1340,9 +1304,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1783,9 +1744,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1803,9 +1761,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1823,9 +1778,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1843,9 +1795,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1863,9 +1812,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
+108 -44
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import Icon from './Icons';
import { API_BASE } from '../config';
import { getMqttStatus } from '../utils/mqtt';
import { renderMarkdown } from '../voice/markdown.jsx';
/* =========================================================
AI 智能助手对话面板 —— 文字 / 语音双模式
@@ -30,6 +31,9 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
/* 本地兜底识别题库(ASR 不可用时) */
const VOICE_SAMPLES = QUICK_QUESTIONS;
/* 默认欢迎语(新对话第一条消息,与初始一致) */
const WELCOME_TEXT = '您好,我是「问问小园」,昆明市大学生创业园的专属 AI 助手(通义千问)。\n\n可以为您解答入驻申请、创业政策、创业担保贷款、OPC 概念、AI 工具等问题,也可以介绍园区入驻企业。试试右侧的预设问题吧。';
/* ---------- 消息 ---------- */
let msgId = 0;
const nextId = () => ++msgId;
@@ -50,16 +54,22 @@ function executeLocalTools(tools, mqttPublished) {
}
export default function AiChatPanel() {
const [msgs, setMsgs] = useState(() => [{ id: nextId(), role: 'ai', text: '您好,我是 OPC 园区 AI 智能助手(阿里云通义千问)。\n\n支持文字与语音提问,可以为您解答入驻流程、政策扶持、场地服务、AI 赋能等问题,还可以通过语音或文字让我切换页面、展示企业卡片。' }]);
const [msgs, setMsgs] = useState(() => [{ id: nextId(), role: 'ai', text: WELCOME_TEXT }]);
const [input, setInput] = useState('');
const [typing, setTyping] = useState(false);
const [mode, setMode] = useState('text');
const [voiceState, setVoiceState] = useState('idle'); // idle | recording | transcribing
const [newTalkAsk, setNewTalkAsk] = useState(false); // 手势确认开启新对话中
const [confirmLeft, setConfirmLeft] = useState(0); // 确认倒计时(秒)
const scrollRef = useRef(null);
const timerRef = useRef(null);
const idleTimerRef = useRef(null); // 1 分钟空闲自动清空
const confirmTimerRef = useRef(null); // 手势确认 10s 自动取消
const recorderRef = useRef(null);
const chunksRef = useRef([]);
const voiceStateRef = useRef(voiceState);
const msgsRef = useRef(msgs);
useEffect(() => { msgsRef.current = msgs; }, [msgs]);
useEffect(() => { voiceStateRef.current = voiceState; }, [voiceState]);
@@ -77,7 +87,22 @@ export default function AiChatPanel() {
};
}, []);
/** 发送消息 → 后端 LLM */
/** 1 分钟无新消息 → 自动清空本轮,回到默认欢迎语 */
const resetIdleTimer = useCallback(() => {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
setMsgs([{ id: nextId(), role: 'ai', text: WELCOME_TEXT }]);
setTyping(false);
setNewTalkAsk(false);
}, 60000);
}, []);
useEffect(() => {
resetIdleTimer();
return () => { if (idleTimerRef.current) clearTimeout(idleTimerRef.current); };
}, [resetIdleTimer]);
/** 发送消息 → 后端 LLM(多轮:携带历史上下文) */
const send = useCallback(async (raw, viaVoice = false) => {
const text = String(raw || '').trim();
if (!text || typing) return;
@@ -88,10 +113,15 @@ export default function AiChatPanel() {
let reply = null;
let tools = [];
try {
// 多轮:将历史对话(去掉系统首条问候)作为 messages 上下文
const history = msgsRef.current
.filter((m) => m.role === 'me' || (m.role === 'ai' && !/我是「问问小园」/.test(m.text)))
.map((m) => ({ role: m.role === 'me' ? 'user' : 'assistant', content: m.text }));
history.push({ role: 'user', content: text });
const res = await fetch(`${API_BASE}/api/ai/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: text }] }),
body: JSON.stringify({ messages: history }),
});
if (res.ok) {
const data = await res.json();
@@ -104,13 +134,19 @@ export default function AiChatPanel() {
setTyping(false);
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
executeLocalTools(tools);
}, [typing]);
resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时
}, [typing, resetIdleTimer]);
/** 清空对话:新对话第一条消息与默认一致(欢迎语) */
const clear = useCallback(() => {
clearTimeout(timerRef.current);
setTyping(false);
setMsgs([{ id: nextId(), role: 'ai', text: '您好,我是 OPC 园区 AI 智能助手。有什么可以帮您?' }]);
}, []);
setNewTalkAsk(false);
setConfirmLeft(0);
if (confirmTimerRef.current) { clearTimeout(confirmTimerRef.current); confirmTimerRef.current = null; }
setMsgs([{ id: nextId(), role: 'ai', text: WELCOME_TEXT }]);
resetIdleTimer();
}, [resetIdleTimer]);
/* ================= 语音对话(录音 → 后端 ASR ================= */
@@ -200,6 +236,54 @@ export default function AiChatPanel() {
return () => window.removeEventListener('dpm:ai-action', onAiAction);
}, [send]);
// 手势控制:对话中举手 → 弹出确认卡片;再次举手或点击确认 → 开启新对话;10s 超时取消
const confirmNewTalk = useCallback(() => {
setNewTalkAsk(false);
setConfirmLeft(0);
if (confirmTimerRef.current) { clearTimeout(confirmTimerRef.current); confirmTimerRef.current = null; }
clear();
}, [clear]);
const cancelNewTalk = useCallback(() => {
setNewTalkAsk(false);
setConfirmLeft(0);
if (confirmTimerRef.current) { clearTimeout(confirmTimerRef.current); confirmTimerRef.current = null; }
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: '已取消开启新对话。' }]);
}, []);
useEffect(() => {
const onGesture = (e) => {
if (e.detail?.type !== 'raise') return;
if (newTalkAsk) {
// 再次举手:确认开启新对话
confirmNewTalk();
} else {
// 仅对话中响应(默认欢迎语不算对话)
if (msgsRef.current.length <= 1) return;
setNewTalkAsk(true);
setConfirmLeft(10);
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
confirmTimerRef.current = setTimeout(() => {
setNewTalkAsk(false);
setConfirmLeft(0);
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: '已取消开启新对话。' }]);
}, 10000);
}
};
window.addEventListener('dpm:gesture', onGesture);
return () => {
window.removeEventListener('dpm:gesture', onGesture);
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
};
}, [newTalkAsk, confirmNewTalk]);
// 确认卡片倒计时显示
useEffect(() => {
if (!newTalkAsk) { setConfirmLeft(0); return; }
const iv = setInterval(() => setConfirmLeft((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(iv);
}, [newTalkAsk]);
const toggleVoice = useCallback(() => {
if (voiceStateRef.current === 'recording') {
const rec = recorderRef.current;
@@ -233,16 +317,8 @@ export default function AiChatPanel() {
<div className="bd-chat-head">
<span className="bd-chat-logo"><Icon name="robot" size={17} /></span>
<div className="bd-chat-titles">
<b>AI 智能助手</b>
<span>阿里云通义千问 · 语音识别</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>
<b>问问小园</b>
<span>昆明市大学生创业园 · 通义千问</span>
</div>
<span className="bd-chat-model"><i />通义千问</span>
<button className="bd-chat-clear" title="清空对话" onClick={clear}>
@@ -257,7 +333,7 @@ export default function AiChatPanel() {
{m.role === 'ai' && <span className="bd-msg-avatar"><Icon name="robot" size={13} /></span>}
<div className="bd-msg-bubble">
{m.voice && <span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>}
{m.text}
{m.role === 'ai' ? renderMarkdown(m.text) : m.text}
</div>
</div>
))}
@@ -267,9 +343,22 @@ export default function AiChatPanel() {
<div className="bd-msg-bubble bd-typing"><i /><i /><i /></div>
</div>
)}
{/* 手势确认弹窗:检测到举手 → 是否开启新对话 */}
{newTalkAsk && (
<div className="bd-chat-confirm">
<span className="bd-chat-confirm-ico"><Icon name="hand-stop" size={18} /></span>
<div className="bd-chat-confirm-text">
<b>检测到举手是否开启新的对话</b>
<span>再次举手或点击确认开始新对话 · {confirmLeft}s 后自动取消</span>
</div>
<button className="bd-chat-confirm-btn" onClick={confirmNewTalk}>确认</button>
<button className="bd-chat-confirm-btn ghost" onClick={cancelNewTalk}>取消</button>
</div>
)}
</div>
{/* 文字模式 */}
{/* 文字输入区(固定,无文字/语音切换) */}
{mode === 'text' && (
<>
<div className="bd-chat-quick">
@@ -283,7 +372,7 @@ export default function AiChatPanel() {
<div className="bd-chat-input">
<input
value={input}
placeholder="输入你的问题,如:展示入驻企业卡片"
placeholder="输入你的问题,如:什么是 OPC"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
@@ -298,31 +387,6 @@ export default function AiChatPanel() {
</div>
</>
)}
{/* 语音模式 */}
{mode === 'voice' && (
<div className="bd-chat-voice">
<div className={`bd-chat-voice-hint ${voiceState}`}>
{voiceState === 'recording' && <span className="bd-voice-wave"><i /><i /><i /><i /><i /></span>}
{voiceHint}
</div>
<button
className={`bd-chat-mic ${voiceState}`}
onClick={toggleVoice}
title={voiceState === 'recording' ? '结束并发送' : '开始语音提问'}
>
{voiceState === 'recording' && <span className="bd-mic-ring r1" />}
{voiceState === 'recording' && <span className="bd-mic-ring r2" />}
<Icon
name={voiceState === 'recording' ? 'microphone-filled' : 'microphone'}
size={30}
/>
</button>
<div className="bd-chat-voice-tip">
{voiceState === 'recording' ? '再次点击结束并识别' : '点击开始说话 · 阿里云语音识别'}
</div>
</div>
)}
</section>
);
}
+5
View File
@@ -12,6 +12,7 @@ import {
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
Send, Trash, Eraser, Headset, CircleDot, AlertCircle,
Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle,
Camera, CameraOff, HandStop,
} from '@appica/icons-react';
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt" */
@@ -85,6 +86,9 @@ const NAME_MAP = {
'microphone-filled': MicrophoneFilled,
'microphone-off': MicrophoneOff,
'message-circle': MessageCircle,
camera: Camera,
'camera-off': CameraOff,
'hand-stop': HandStop,
};
/**
@@ -119,4 +123,5 @@ export {
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
Send, Trash, Eraser, Headset, CircleDot, AlertCircle,
Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle,
Camera, CameraOff, HandStop,
};
+220
View File
@@ -3222,3 +3222,223 @@
.bd-twin-detail-head {
align-items: flex-start;
}
/* ============ 预设问题面板(AI 助手右侧) ============ */
.bd-prompt-side {
width: 300px;
height: 100%;
flex-shrink: 0;
overflow-y: auto;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
border-left: 1px solid rgba(47, 107, 255, 0.1);
background: rgba(255, 255, 255, 0.6);
}
.bd-prompt-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(47, 107, 255, 0.1), rgba(76, 194, 255, 0.08));
border: 1px solid rgba(47, 107, 255, 0.14);
}
.bd-prompt-head .appica-ico { color: #2f6bff; flex-shrink: 0; }
.bd-prompt-head b { font-size: 14px; color: var(--bd-ink); }
.bd-prompt-head span {
margin-left: auto;
font-size: 10.5px;
color: var(--bd-sub);
}
.bd-prompt-group { display: flex; flex-direction: column; gap: 7px; }
.bd-prompt-group-title {
font-size: 11px;
font-weight: 700;
color: var(--bd-sub);
letter-spacing: 0.06em;
border-left: 3px solid #2f6bff;
padding-left: 8px;
}
.bd-prompt-list { display: flex; flex-direction: column; gap: 5px; }
.bd-prompt-item {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 11px;
border: 1px solid rgba(47, 107, 255, 0.1);
border-radius: 9px;
background: rgba(255, 255, 255, 0.85);
color: var(--bd-ink);
font-size: 12.5px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.bd-prompt-item:hover {
border-color: #2f6bff;
color: #2f6bff;
background: rgba(47, 107, 255, 0.06);
transform: translateX(2px);
}
.bd-prompt-item .appica-ico { color: #f59e0b; flex-shrink: 0; margin-right: 2px; }
.bd-prompt-tools { display: flex; flex-wrap: wrap; gap: 6px; }
.bd-prompt-tool {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
color: #0e7490;
background: rgba(34, 211, 238, 0.1);
border: 1px solid rgba(34, 211, 238, 0.22);
}
/* ============ Markdown 富文本渲染(问问小园回复) ============ */
.md-block {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12.5px;
line-height: 1.7;
color: var(--bd-ink);
word-break: break-word;
}
.md-h1, .md-h2, .md-h3 {
margin: 4px 0 0;
font-weight: 700;
line-height: 1.4;
color: var(--bd-ink);
}
.md-h1 { font-size: 15px; }
.md-h2 { font-size: 13.5px; }
.md-h3 { font-size: 12.5px; }
.md-p { margin: 0; }
.md-li {
display: flex;
align-items: flex-start;
gap: 6px;
padding-left: 2px;
}
.md-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
margin-top: 7px;
background: #2f6bff;
}
.md-oli { color: var(--bd-ink); }
.md-quote {
padding: 6px 10px;
border-left: 3px solid #4cc2ff;
background: rgba(47, 107, 255, 0.05);
border-radius: 0 8px 8px 0;
color: #4a5b76;
}
.md-hr { border-top: 1px dashed rgba(47, 107, 255, 0.25); margin: 2px 0; }
.md-pre {
margin: 2px 0;
padding: 8px 10px;
background: #0f172a;
color: #dbe7ff;
border-radius: 8px;
font-size: 11.5px;
overflow-x: auto;
}
.md-table {
width: 100%;
border-collapse: collapse;
margin: 2px 0;
font-size: 12px;
}
.md-table th, .md-table td {
border: 1px solid rgba(47, 107, 255, 0.15);
padding: 5px 8px;
text-align: left;
}
.md-table th {
background: rgba(47, 107, 255, 0.08);
font-weight: 700;
color: var(--bd-ink);
}
.md-table tr:nth-child(even) td { background: rgba(244, 248, 253, 0.6); }
.md-bold { font-weight: 700; color: var(--bd-ink); }
.md-code {
padding: 1px 5px;
border-radius: 4px;
background: rgba(47, 107, 255, 0.08);
color: #1d4fd8;
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
}
/* ============ 手势确认弹窗(新对话确认卡片) ============ */
.bd-chat-confirm {
align-self: center;
display: flex;
align-items: center;
gap: 12px;
max-width: 92%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(47, 107, 255, 0.25);
border-radius: 14px;
box-shadow: 0 12px 34px rgba(47, 107, 255, 0.18);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
animation: bdOverlayIn 0.3s ease;
}
.bd-chat-confirm-ico {
width: 38px;
height: 38px;
flex-shrink: 0;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
box-shadow: 0 5px 14px rgba(47, 107, 255, 0.32);
}
.bd-chat-confirm-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.bd-chat-confirm-text b {
font-size: 13.5px;
color: var(--bd-ink);
}
.bd-chat-confirm-text span {
font-size: 11px;
color: var(--bd-sub);
}
.bd-chat-confirm-btn {
flex-shrink: 0;
padding: 7px 18px;
font-size: 12.5px;
font-weight: 700;
border: none;
border-radius: 9px;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-blue-deep));
box-shadow: 0 4px 12px rgba(47, 107, 255, 0.3);
cursor: pointer;
transition: all 0.2s;
}
.bd-chat-confirm-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(47, 107, 255, 0.4);
}
.bd-chat-confirm-btn.ghost {
color: var(--bd-sub);
background: rgba(120, 160, 220, 0.1);
border: 1px solid rgba(120, 160, 220, 0.2);
box-shadow: none;
}
.bd-chat-confirm-btn.ghost:hover {
color: var(--bd-red);
background: rgba(240, 68, 56, 0.06);
}