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() {