diff --git a/package-lock.json b/package-lock.json index 2262c2e..65e84af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/src/components/AiChatPanel.jsx b/src/components/AiChatPanel.jsx index 3898177..12c54d6 100644 --- a/src/components/AiChatPanel.jsx +++ b/src/components/AiChatPanel.jsx @@ -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() {
- AI 智能助手 - 阿里云通义千问 · 语音识别 -
-
- - + 问问小园 + 昆明市大学生创业园 · 通义千问
通义千问
))} @@ -267,9 +343,22 @@ export default function AiChatPanel() {
)} + + {/* 手势确认弹窗:检测到举手 → 是否开启新对话 */} + {newTalkAsk && ( +
+ +
+ 检测到举手,是否开启新的对话? + 再次举手或点击「确认」开始新对话 · {confirmLeft}s 后自动取消 +
+ + +
+ )} - {/* 文字模式 */} + {/* 文字输入区(固定,无文字/语音切换) */} {mode === 'text' && ( <>
@@ -283,7 +372,7 @@ export default function AiChatPanel() {
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { @@ -298,31 +387,6 @@ export default function AiChatPanel() {
)} - - {/* 语音模式 */} - {mode === 'voice' && ( -
-
- {voiceState === 'recording' && } - {voiceHint} -
- -
- {voiceState === 'recording' ? '再次点击结束并识别' : '点击开始说话 · 阿里云语音识别'} -
-
- )} ); } diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 3b6c0bf..df32c9a 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -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, }; diff --git a/src/styles/datascreen.css b/src/styles/datascreen.css index 8be514d..d64e018 100644 --- a/src/styles/datascreen.css +++ b/src/styles/datascreen.css @@ -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); +}