feat: Refactor DpmOverlays to use ShowCard for rendering cards and add ToolStatusToast for operation status notifications

- Moved card rendering logic from DpmOverlays to a new ShowCard component for better reusability.
- Introduced ToolStatusToast to display real-time operation statuses in the top right corner.
- Updated PageHeader to conditionally render credits based on the current path.
- Modified PromptPanel to change tool names and update prompt titles.
- Enhanced ScreenLayout to include ToolStatusToast.
- Updated styles for new components and adjusted existing styles for consistency.
- Implemented statusBus utility for dispatching tool status events.
- Updated useMqttControl to integrate tool status notifications during navigation and card display actions.
This commit is contained in:
Pine
2026-08-19 12:28:35 +08:00
parent 81a888f5ca
commit 107727ea5e
29 changed files with 2076 additions and 247 deletions
+77 -4
View File
@@ -1,8 +1,10 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import Icon from './Icons';
import ShowCard from './ShowCard';
import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds';
import { getMqttStatus } from '../utils/mqtt';
import { aiStatus } from '../utils/statusBus';
import { renderMarkdown } from '../voice/markdown.jsx';
/* =========================================================
@@ -41,19 +43,49 @@ const nextId = () => ++msgId;
/** 执行后端返回的工具调用。
后端已通过 MQTT 广播(发布成功时所有大屏都会收到,本地跳过避免重复);
仅当 MQTT 发布失败 / 后端不可达时本地执行,保证当前屏幕也能响应 */
仅当 MQTT 发布失败 / 后端不可达时本地执行,保证当前屏幕也能响应
show_card 除外:卡片已内联渲染进对话气泡,不再走全局弹窗。 */
function executeLocalTools(tools, mqttPublished) {
if (!Array.isArray(tools) || tools.length === 0) return;
if (mqttPublished !== false) return;
for (const tool of tools) {
const type = tool.type;
const params = tool.params || {};
if (type === 'navigate' || type === 'control' || type === 'alert' || type === 'show_card') {
if (type === 'navigate' || type === 'control' || type === 'alert') {
if (type === 'navigate') aiStatus('正在切换页面', { icon: 'page' });
else if (type === 'control') aiStatus('正在控制媒体播放', { icon: 'tool' });
// alert:通知本身即展示,不再加状态提示
window.dispatchEvent(new CustomEvent('dpm:mqtt-local', { detail: { action: type, params } }));
}
}
}
/** 由 /api/ai/chat 响应生成「状态芯片」:知识库检索 + 工具/技能调用。 */
function buildChips(data) {
const chips = [];
if (data && data.retrieved) chips.push({ icon: 'database', text: '知识库检索' });
const labels = {
navigate: '切换页面', control: '媒体控制', show_card: '展示卡片', alert: '发送通知',
};
const icons = { navigate: 'compass', control: 'player-play', show_card: 'sparkles', alert: 'broadcast' };
(data?.tool_names || []).forEach((name) => {
chips.push({ icon: icons[name] || 'wrench', text: `调用·${labels[name] || name}` });
});
return chips;
}
/** 由 /api/ai/chat 响应提取「内联卡片」(show_card 工具 → 可视化卡片数据)。 */
function buildCards(data) {
const cards = [];
(data?.tools || []).forEach((t) => {
if (t && t.type === 'show_card') {
const p = t.params || {};
cards.push({ card: p.card || 'custom', title: p.title || '', content: p.content || '' });
}
});
return cards;
}
export default function AiChatPanel() {
const [msgs, setMsgs] = useState(() => [{ id: nextId(), role: 'ai', text: WELCOME_TEXT }]);
const [input, setInput] = useState('');
@@ -111,8 +143,15 @@ export default function AiChatPanel() {
setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text, voice: viaVoice }]);
setTyping(true);
// 右上角状态提示:检索 → 查询 → 语义检索(进行中)
let s1, s2;
aiStatus('正在检索知识库');
s1 = setTimeout(() => aiStatus('正在查询相关信息'), 350);
s2 = setTimeout(() => aiStatus('正在构建语义检索'), 800);
let reply = null;
let tools = [];
let data = null; // 提升到函数作用域,供 chips/cards 使用
try {
// 多轮:将历史对话(去掉系统首条问候)作为 messages 上下文
const history = msgsRef.current
@@ -125,16 +164,34 @@ export default function AiChatPanel() {
body: JSON.stringify({ messages: history }),
});
if (res.ok) {
const data = await res.json();
data = await res.json();
reply = data.reply;
tools = data.tools || [];
}
} catch { /* 后端不可用 */ }
if (!reply) reply = pick(FALLBACK_REPLIES);
clearTimeout(s1); clearTimeout(s2);
setTyping(false);
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
setMsgs((prev) => [...prev, {
id: nextId(), role: 'ai', text: reply,
chips: buildChips(data), cards: buildCards(data),
}]);
Sfx.notify();
// 右上角状态:工具 / 检索 / 完成(通知 alert 除外——通知本身即展示)
const statusTools = tools.filter((t) => t.type !== 'alert');
if (statusTools.length) {
const lbl = statusTools
.map((t) => ({ navigate: '切换页面', control: '媒体控制', show_card: '展示卡片' }[t.type] || t.type))
.filter(Boolean).join('、');
aiStatus(`正在调用工具:${lbl}`, { icon: 'tool' });
setTimeout(() => aiStatus('操作完成', { state: 'done', icon: 'check' }), 700);
} else if (data?.retrieved) {
aiStatus('已完成知识检索', { state: 'done', icon: 'check' });
} else {
aiStatus('已回复', { state: 'done', icon: 'check' });
}
executeLocalTools(tools);
resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时
}, [typing, resetIdleTimer]);
@@ -336,6 +393,22 @@ export default function AiChatPanel() {
<div className="bd-msg-bubble">
{m.voice && <span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>}
{m.role === 'ai' ? renderMarkdown(m.text) : m.text}
{m.chips && m.chips.length > 0 && (
<div className="bd-msg-chips">
{m.chips.map((c, i) => (
<span className="bd-msg-chip" key={i}><Icon name={c.icon} size={11} />{c.text}</span>
))}
</div>
)}
{m.cards && m.cards.length > 0 && m.cards.map((card, i) => (
<div className="bd-msg-card" key={i}>
<div className="bd-msg-card-head">
<span className="bd-msg-card-ico"><Icon name="sparkles" size={12} /></span>
<b>{card.title || '信息卡片'}</b>
</div>
<ShowCard card={card} />
</div>
))}
</div>
</div>
))}
+44 -101
View File
@@ -1,110 +1,39 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import Icon from './Icons';
import { getApiBase as API_BASE } from '../config';
import ShowCard from './ShowCard';
import { Sfx } from '../utils/sounds';
/* =========================================================
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
· alert → 右上角通知(自动消失)
· show_card → 中央信息卡片(企业分布 / 分区 / 园区总览 / 自定义)
卡片渲染复用 ShowCard(也可内联进对话气泡)
遵循 bd- 浅色科技 UI 规范,图标全部使用 appica SVG
========================================================= */
function CardContent({ card }) {
const [data, setData] = useState(null);
const { card: type, title, content } = card || {};
useEffect(() => {
let alive = true;
if (type === 'companies') {
fetch(`${API_BASE()}/api/park/companies`)
.then((r) => r.json())
.then((d) => alive && setData(d.companies || []))
.catch(() => alive && setData([]));
} else if (type === 'zones') {
fetch(`${API_BASE()}/api/park/zones`)
.then((r) => r.json())
.then((d) => alive && setData(d.zones || []))
.catch(() => alive && setData([]));
} else if (type === 'overview') {
fetch(`${API_BASE()}/api/dashboard/snapshot`)
.then((r) => r.json())
.then((d) => alive && setData(d))
.catch(() => alive && setData(null));
}
return () => { alive = false; };
}, [type]);
if (type === 'companies') {
const groups = {};
(data || []).forEach((c) => {
(groups[c.zone] = groups[c.zone] || []).push(c);
});
return (
<div className="bd-ov-card-body">
{Object.keys(groups).map((z) => (
<div className="bd-ov-card-group" key={z}>
<div className="bd-ov-card-group-title">{z}</div>
<div className="bd-ov-card-chips">
{groups[z].map((c) => (
<span className="bd-ov-card-chip" key={c.room}>
<b style={{ color: c.color, borderColor: c.color }}>{c.room}</b>
{c.name}
</span>
))}
</div>
</div>
))}
</div>
);
}
if (type === 'zones') {
return (
<div className="bd-ov-card-body">
<div className="bd-ov-card-chips">
{(data || []).map((z) => (
<span className="bd-ov-card-chip" key={z.name}>
<b style={{ color: z.color, borderColor: z.color }}>{z.name}</b>
{z.count}
</span>
))}
</div>
</div>
);
}
if (type === 'overview' && data) {
const rows = [
['在园项目', `${data.projects?.inPark ?? '-'}`],
['累计孵化', `${data.projects?.cum ?? '-'}`],
['带动就业', `${(data.jobs?.total ?? 0).toLocaleString()}`],
['累计营收', `${(data.revenue?.total ?? 0) / 10000} 亿`],
['今日营收', `${data.revenue?.today ?? '-'}`],
['工具调用', `${(data.tools?.today ?? 0).toLocaleString()}`],
['AI 速率', `${data.token?.rate ?? '-'} t/s`],
['设备在线', `${data.park?.devices ?? '-'}%`],
];
return (
<div className="bd-ov-card-grid">
{rows.map(([k, v]) => (
<div className="bd-ov-card-stat" key={k}>
<span>{k}</span>
<b>{v}</b>
</div>
))}
</div>
);
}
// custom / 未知类型:标题 + 内容
/* 关闭按钮图标:内联 SVG,避免依赖图标库中不存在的 "x" 名称导致空白按钮 */
function CloseIcon({ size = 14 }) {
return (
<div className="bd-ov-card-body">
<div className="bd-ov-card-text">{content || '(无内容)'}</div>
</div>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
/* AI 对话页(/ai):卡片已内联进对话气泡,不再以全局弹窗弹出;其余页面保留弹窗 */
const isAiChatPage = () =>
typeof window !== 'undefined' && window.location.pathname.startsWith('/ai');
export default function DpmOverlays() {
const [alert, setAlert] = useState(null); // {title, content, id}
const [card, setCard] = useState(null); // {card, title, content, id}
@@ -137,24 +66,38 @@ export default function DpmOverlays() {
return (
<>
{alert && (
<div className="bd-overlay bd-overlay-alert" key={alert.id}>
<div className="bd-overlay-alert-ico"><Icon name="broadcast" size={15} /></div>
<div className="bd-overlay-alert-body">
<b>{alert.title}</b>
{alert.content && <span>{alert.content}</span>}
<div className="bd-overlay bd-overlay-alert" key={alert.id} role="alert">
<div className="bd-alert-ico"><Icon name="broadcast" size={16} /></div>
<div className="bd-alert-main">
<div className="bd-alert-title">{alert.title}</div>
{alert.content && <div className="bd-alert-text">{alert.content}</div>}
</div>
<button className="bd-overlay-close" onClick={() => setAlert(null)}><Icon name="x" size={13} /></button>
<button
className="bd-overlay-close"
aria-label="关闭通知"
title="关闭"
onClick={() => setAlert(null)}
>
<CloseIcon size={14} />
</button>
</div>
)}
{card && (
{card && !isAiChatPage() && (
<div className="bd-overlay bd-overlay-mask" onClick={() => setCard(null)}>
<div className="bd-overlay-card" onClick={(e) => e.stopPropagation()}>
<div className="bd-overlay-card-head">
<span className="bd-overlay-card-ico"><Icon name="sparkles" size={15} /></span>
<b>{card.title || 'AI 助手卡片'}</b>
<button className="bd-overlay-close" onClick={() => setCard(null)}><Icon name="x" size={14} /></button>
<button
className="bd-overlay-close"
aria-label="关闭卡片"
title="关闭"
onClick={() => setCard(null)}
>
<CloseIcon size={15} />
</button>
</div>
<CardContent card={card} />
<ShowCard card={card} />
</div>
</div>
)}
+8 -6
View File
@@ -148,13 +148,15 @@ export default function PageHeader() {
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
</div>
<span className="bd-credit">
<Icon name="copyright" size={13} />
<span className="bd-credit-text">
<span className="bd-credit-name">云南派音人工智能科技有限公司</span>
<span className="bd-credit-dev">开发</span>
{location.pathname !== '/screen' && (
<span className="bd-credit">
<Icon name="copyright" size={13} />
<span className="bd-credit-text">
<span className="bd-credit-name">云南派音人工智能科技有限公司</span>
<span className="bd-credit-dev">开发</span>
</span>
</span>
</span>
)}
</div>
<div className="bd-header-right">
{/* 媒体轮播页:页眉内嵌播放控制 */}
+3 -3
View File
@@ -33,7 +33,7 @@ export default function PromptPanel({ onSend }) {
{ title: 'OPC 概念', questions: ['什么是 OPC', 'OPC 常用的人工智能工具有哪些?'] },
{ title: '园区企业介绍', questions: ['介绍一下园区入驻企业', '介绍一下云南派音人工智能科技'] },
]);
setTools(['DeepSeek-V3', '通义千问', '豆包', '剪映']);
setTools(['DeepSeek', '通义千问', '豆包', '剪映']);
}
}
})();
@@ -51,8 +51,8 @@ export default function PromptPanel({ onSend }) {
<aside className="bd-prompt-side">
<div className="bd-prompt-head">
<Icon name="bulb" size={15} />
<b>您可以这样</b>
<span>预设问题 · 点击即向 AI 提问</span>
<b></b>
<span>点击即向 AI 提问</span>
</div>
<div className="bd-prompt-groups">
+2
View File
@@ -1,6 +1,7 @@
import { Outlet } from 'react-router-dom';
import PageHeader from './PageHeader';
import DpmOverlays from './DpmOverlays';
import ToolStatusToast from './ToolStatusToast';
import GlobalVision from './GlobalVision';
import { usePageNav } from '../utils/pageNav';
import { useMqttControl } from '../utils/useMqttControl';
@@ -27,6 +28,7 @@ export default function ScreenLayout() {
{/* 全局覆盖层(通知 / 卡片 / 手势识别徽标) */}
<GlobalVision />
<DpmOverlays />
<ToolStatusToast />
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react';
import { getApiBase as API_BASE } from '../config';
/* =========================================================
ShowCard —— 信息卡片(企业分布 / 分区介绍 / 园区总览 / 自定义)
复用 bd-ov-card-* 样式,既可用于 DpmOverlays 全局弹窗,
也可内联渲染进对话气泡(AiChatPanel / VoiceAssistant)。
card = { card: 'companies'|'zones'|'overview'|'custom', title?, content? }
========================================================= */
export default function ShowCard({ card }) {
const [data, setData] = useState(null);
const { card: type, content } = card || {};
useEffect(() => {
let alive = true;
if (type === 'companies') {
fetch(`${API_BASE()}/api/park/companies`)
.then((r) => r.json())
.then((d) => alive && setData(d.companies || []))
.catch(() => alive && setData([]));
} else if (type === 'zones') {
fetch(`${API_BASE()}/api/park/zones`)
.then((r) => r.json())
.then((d) => alive && setData(d.zones || []))
.catch(() => alive && setData([]));
} else if (type === 'overview') {
fetch(`${API_BASE()}/api/dashboard/snapshot`)
.then((r) => r.json())
.then((d) => alive && setData(d))
.catch(() => alive && setData(null));
}
return () => { alive = false; };
}, [type]);
if (type === 'companies') {
const groups = {};
(data || []).forEach((c) => {
(groups[c.zone] = groups[c.zone] || []).push(c);
});
return (
<div className="bd-ov-card-body">
{Object.keys(groups).map((z) => (
<div className="bd-ov-card-group" key={z}>
<div className="bd-ov-card-group-title">{z}</div>
<div className="bd-ov-card-chips">
{groups[z].map((c) => (
<span className="bd-ov-card-chip" key={c.room}>
<b style={{ color: c.color, borderColor: c.color }}>{c.room}</b>
{c.name}
</span>
))}
</div>
</div>
))}
</div>
);
}
if (type === 'zones') {
return (
<div className="bd-ov-card-body">
<div className="bd-ov-card-chips">
{(data || []).map((z) => (
<span className="bd-ov-card-chip" key={z.name}>
<b style={{ color: z.color, borderColor: z.color }}>{z.name}</b>
{z.count}
</span>
))}
</div>
</div>
);
}
if (type === 'overview' && data) {
const rows = [
['在园项目', `${data.projects?.inPark ?? '-'}`],
['累计孵化', `${data.projects?.cum ?? '-'}`],
['带动就业', `${(data.jobs?.total ?? 0).toLocaleString()}`],
['累计营收', `${(data.revenue?.total ?? 0) / 10000} 亿`],
['今日营收', `${data.revenue?.today ?? '-'}`],
['工具调用', `${(data.tools?.today ?? 0).toLocaleString()}`],
['AI 速率', `${data.token?.rate ?? '-'} t/s`],
['设备在线', `${data.park?.devices ?? '-'}%`],
];
return (
<div className="bd-ov-card-grid">
{rows.map(([k, v]) => (
<div className="bd-ov-card-stat" key={k}>
<span>{k}</span>
<b>{v}</b>
</div>
))}
</div>
);
}
// custom / 未知类型:标题 + 内容
return (
<div className="bd-ov-card-body">
<div className="bd-ov-card-text">{content || '(无内容)'}</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Icon from './Icons';
/* =========================================================
ToolStatusToast —— 右上角「操作进行中」状态提示
监听 dpm:tool-status 事件,展示 AI 检索/查询/调用工具等实时状态。
state: working(转圈) | done(绿色对勾) | error(红色)
========================================================= */
const ICON_MAP = {
search: 'database', // 检索知识库
info: 'activity', // 查询/语义检索
tool: 'wrench', // 调用工具
page: 'compass', // 切换页面
check: 'check',
error: 'info',
};
export default function ToolStatusToast() {
const [toast, setToast] = useState(null);
const timerRef = useRef(null);
const idRef = useRef(0);
const show = useCallback((d = {}) => {
const id = ++idRef.current;
const state = d.state || 'working';
setToast({ id, text: d.text || '', state, icon: ICON_MAP[d.icon] || 'activity' });
clearTimeout(timerRef.current);
const dur = d.duration != null ? d.duration : (state === 'working' ? 4200 : 2400);
timerRef.current = setTimeout(() => setToast((t) => (t && t.id === id ? null : t)), dur);
}, []);
useEffect(() => {
const on = (e) => show(e.detail || {});
window.addEventListener('dpm:tool-status', on);
return () => {
window.removeEventListener('dpm:tool-status', on);
clearTimeout(timerRef.current);
};
}, [show]);
if (!toast) return null;
return (
<div className="ts-toast" key={toast.id} role="status">
<span className={`ts-ico ${toast.state}`}><Icon name={toast.icon} size={14} /></span>
<span className="ts-text">{toast.text}</span>
{toast.state === 'working' && <span className="ts-spinner" />}
</div>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ import { useParkSim } from '../utils/parkData';
/* ---------- 模型占比(TOKEN 构成演示分布) ---------- */
const MODELS = [
{ name: 'DeepSeek-V3', value: 38, color: '#4c8dff' },
{ name: 'DeepSeek', value: 38, color: '#4c8dff' },
{ name: '通义千问', value: 21, color: '#22d3ee' },
{ name: '智谱 GLM-4', value: 16, color: '#34d399' },
{ name: '豆包', value: 12, color: '#fbbf24' },
+7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import Icon from '../components/Icons';
import * as api from '../utils/api';
import { getApiBase } from '../config';
import { Sfx } from '../utils/sounds';
@@ -557,6 +558,12 @@ export default function MediaScreen() {
)}
{/* 播放控制已上移页眉(PageHeader 内渲染,媒体区保持纯净全屏) */}
{/* 左下角:版权/开发 玻璃质感芯片(顶部已移除) */}
<div className="ms-credit-chip">
<Icon name="copyright" size={12} />
<span>云南派音人工智能科技有限公司 · 开发</span>
</div>
</main>
{/* 页脚:园区概览(与数据大屏一致) */}
+44 -29
View File
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
import { getApiBase as API_BASE, getVoiceWsUrl } from '../config';
import { aiStatus } from '../utils/statusBus';
import '../styles/datascreen.css';
import './voice-original.css';
import './voice-overrides.css';
@@ -115,8 +116,11 @@ export default function VoiceAssistant() {
const [messages, setMessages] = useState([]);
const [muted, setMuted] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
// 全局人物识别开关(MQTT vision_set 控制)
// 摄像头常开:左下角视频预览保持显示(识别到人也不会自动问候,问候由 greetEnabled 控制)
const [visionEnabled, setVisionEnabled] = useState(true);
// 主动问候开关:默认关闭「识别到人 → 主动问候」,但摄像头/视频仍常开。
// 需要恢复自动问候时改回 true,或用 MQTT 下发开启。
const [greetEnabled, setGreetEnabled] = useState(false);
const clientRef = useRef(null);
const audioCtxRef = useRef(null);
@@ -133,6 +137,7 @@ export default function VoiceAssistant() {
const live = LIVE_STATES.has(state);
// ── 自动问候生命周期状态(须在 start 之前定义:start 依赖数组引用 sendGreeting)──
const GREETING_TEXT = '您好呀,需要我的帮助吗?';
const GREETING_WAIT_MS = 25000; // 问候播完后等待用户回答的超时
@@ -224,6 +229,14 @@ export default function VoiceAssistant() {
try { args = JSON.parse(argsStr || '{}'); } catch { /* 忽略非法参数 */ }
console.warn(`[tools] LLM 调用工具: ${name}`, args);
pushMessage('assistant', `🔧 调用工具:${name}`, false);
// 右上角状态提示
const toolLabel = {
control_display: args?.action === 'switch_page' ? '切换页面' : '控制大屏',
get_park_overview: '查询园区数据',
query_companies: '查询企业名录',
get_time: '查询时间',
}[name] || name;
aiStatus(`正在调用工具:${toolLabel}`, { icon: name === 'control_display' && args?.action === 'switch_page' ? 'page' : 'tool' });
let output;
try {
const res = await fetch(`${API_BASE()}/api/tools/exec`, {
@@ -301,10 +314,23 @@ export default function VoiceAssistant() {
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
streamRef.current = stream;
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
if (audioCtxRef.current.state === 'suspended') await audioCtxRef.current.resume();
// 复用一个仍在运行的上下文;若已关闭(此前被误关)或未创建,则重建。
// 注意:client.close() 不再关闭共享上下文,故会话结束(含手势重启)后仍可复用。
let _ctx = audioCtxRef.current;
if (!_ctx || _ctx.state === 'closed') {
_ctx = new AudioContext();
audioCtxRef.current = _ctx;
}
if (_ctx.state === 'suspended') {
try { await _ctx.resume(); } catch { /* ignore */ }
// 无用户激活(手势)时 resume 可能异步生效,稍等再试一次
if (_ctx.state === 'suspended') {
await new Promise((r) => setTimeout(r, 80));
try { await _ctx.resume(); } catch { /* ignore */ }
}
}
// 基础提示词:从后端拉取(配置 + 园区知识库已由服务端组装);失败用极简兜底
// 基础提示词:从后端拉取(配置 + 园区知识库已由服务端组装);失败用极简兜底
let instructions = FALLBACK_INSTRUCTIONS;
try {
const insRes = await fetch(`${API_BASE()}/api/s2s/instructions`, { cache: 'no-store' });
@@ -386,12 +412,20 @@ export default function VoiceAssistant() {
if (c) { try { await c.close(); } catch { /* noop */ } }
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
// 结束会话后丢弃 AudioContext 引用:下次启动一律新建,避免复用可能被
// Chromium 自动挂起/关闭的旧上下文(否则麦克风不处理 → 一直「聆听」)。
audioCtxRef.current = null;
setMuted(false);
setErrorMsg('');
dismissAllBubbles();
setState('idle');
}, [dismissAllBubbles]);
// 离开语音对话页时强制停止会话与麦克风,避免残留触发/占用
useEffect(() => {
return () => { void stop(); };
}, [stop]);
const toggleMute = useCallback(() => {
const c = clientRef.current;
if (!c) return;
@@ -446,37 +480,18 @@ export default function VoiceAssistant() {
});
// 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束
const onGesture = useCallback((g) => {
if (g !== 'raise') return;
if (clientRef.current) {
void stop();
} else {
void start();
}
}, [start, stop]);
// 【已按需求关闭】视觉手势识别误判会触发右上角弹窗/误开对话,故这里不再响应手势。
const onGesture = useCallback(() => {
// 视觉手势已关闭:识别到人脸/手势不再自动开始/结束对话
}, []);
useEffect(() => {
gestureRef.current = onGesture;
}, [onGesture]);
const onVisionTrigger = useCallback(() => {
// 仅 idle 允许自动问候:对话中/连接中/其他状态一律不打扰
if (voiceStateRef.current !== 'idle') {
console.warn('[vision] 自动问候跳过:当前状态', voiceStateRef.current);
return;
}
pendingGreetingRef.current = true;
if (clientRef.current) {
sendGreeting('');
} else {
void start(); // start 连接成功后检查 pendingGreetingRef 再发问候
}
fetch(`${API_BASE()}/api/vision/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'triggered', faces: vision.faces, dwell_ms: VISION_DWELL_MS }),
}).catch(() => { /* 上报失败不影响本地问候 */ });
}, [vision.faces, start, sendGreeting]);
// 主动问候已彻底关闭:识别到人(含停留 10 秒)不再自动问候/开始对话,也不弹任何提示
}, []);
useEffect(() => {
visionTriggerRef.current = onVisionTrigger;
+224 -28
View File
@@ -2149,71 +2149,267 @@
.bd-msg { max-width: 94%; }
}
/* ---- AI 消息内联:状态芯片 + 可视化卡片(随气泡潜入对话) ---- */
.bd-msg.ai { max-width: 92%; }
.bd-msg-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(120,160,220,0.28);
}
.bd-msg-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
font-size: 10.5px;
font-weight: 600;
border-radius: 20px;
color: var(--bd-blue-deep);
background: rgba(47,107,255,0.08);
border: 1px solid rgba(47,107,255,0.18);
}
.bd-msg-chip .appica-ico { color: var(--bd-blue); }
.bd-msg-card {
margin-top: 10px;
padding: 12px 14px;
border-radius: 10px;
background: #f7fafd;
border: 1px solid var(--bd-line);
}
.bd-msg-card-head {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 10px;
}
.bd-msg-card-head b {
font-size: 13px;
font-weight: 700;
color: var(--bd-ink);
}
.bd-msg-card-ico {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
/* ---- 右上角:工具 / 操作进行中状态提示 ---- */
.ts-toast {
position: fixed;
top: 84px;
right: 22px;
z-index: 3100;
display: flex;
align-items: center;
gap: 10px;
max-width: 320px;
padding: 11px 14px;
background: #fff;
border: 1px solid var(--bd-line);
border-left: 3px solid var(--bd-blue);
border-radius: 12px;
box-shadow: 0 12px 30px rgba(29, 44, 68, 0.14);
animation: bdOverlayIn 0.25s ease;
}
.ts-ico {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
.ts-ico.done { background: linear-gradient(140deg, var(--bd-green), #4ade80); }
.ts-ico.error { background: linear-gradient(140deg, var(--bd-red), #f87171); }
.ts-text {
font-size: 12.5px;
line-height: 1.4;
color: var(--bd-ink);
word-break: break-word;
}
.ts-spinner {
width: 14px;
height: 14px;
flex-shrink: 0;
margin-left: auto;
border: 2px solid rgba(47, 107, 255, 0.2);
border-top-color: var(--bd-blue);
border-radius: 50%;
animation: tsSpin 0.8s linear infinite;
}
@keyframes tsSpin { to { transform: rotate(360deg); } }
.bd-msg-card .bd-ov-card-body { padding: 0; max-height: 40vh; }
/* ---- 信息卡片内部布局(ShowCard:企业 / 分区 / 总览 / 自定义;弹窗与内联共用) ---- */
.bd-ov-card-body {
max-height: 46vh;
overflow-y: auto;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 14px;
}
.bd-ov-card-group-title {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--bd-sub);
margin-bottom: 9px;
}
.bd-ov-card-group-title::before {
content: "";
width: 4px;
height: 13px;
border-radius: 2px;
background: linear-gradient(180deg, var(--bd-blue), var(--bd-cyan));
}
.bd-ov-card-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bd-ov-card-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 10px;
font-size: 12px;
line-height: 1.4;
color: var(--bd-ink);
background: #fff;
border: 1px solid var(--bd-line);
border-radius: 8px;
}
.bd-ov-card-chip b {
font-weight: 700;
font-size: 10px;
padding: 1px 6px;
border-radius: 6px;
border: 1px solid;
white-space: nowrap;
}
.bd-ov-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 8px;
}
.bd-msg-card .bd-ov-card-grid { grid-template-columns: repeat(2, 1fr); }
.bd-ov-card-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
background: #fff;
border: 1px solid var(--bd-line);
border-radius: 8px;
}
.bd-ov-card-stat span {
font-size: 10.5px;
color: var(--bd-sub);
}
.bd-ov-card-stat b {
font-size: 15px;
font-weight: 700;
color: var(--bd-ink);
}
.bd-ov-card-text {
font-size: 12.5px;
line-height: 1.7;
color: var(--bd-ink);
white-space: pre-wrap;
word-break: break-word;
}
/* ============ 全局覆盖层(后端/AI 指令:通知 + 卡片) ============ */
.bd-overlay {
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
.bd-overlay-alert {
position: fixed;
top: 74px;
right: 18px;
top: 84px;
right: 22px;
z-index: 3000;
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 360px;
padding: 12px 14px;
background: rgba(255,255,255,0.92);
border: 1px solid rgba(47,107,255,0.22);
gap: 12px;
min-width: 280px;
max-width: 380px;
padding: 14px 14px 14px 12px;
background: #fff;
border: 1px solid var(--bd-line);
border-left: 3px solid var(--bd-blue);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(47,107,255,0.16);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
animation: bdOverlayIn 0.3s ease;
box-shadow: 0 12px 32px rgba(29, 44, 68, 0.14);
animation: bdOverlayIn 0.28s ease;
}
.bd-overlay-alert-ico {
width: 28px;
height: 28px;
.bd-alert-ico {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
background: linear-gradient(140deg, var(--bd-blue) 0%, var(--bd-cyan) 100%);
box-shadow: 0 4px 10px rgba(47, 107, 255, 0.28);
}
.bd-overlay-alert-body {
.bd-alert-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
gap: 4px;
padding-top: 1px;
}
.bd-overlay-alert-body b {
font-size: 13px;
.bd-alert-title {
font-size: 14px;
font-weight: 600;
color: var(--bd-ink);
line-height: 1.35;
}
.bd-overlay-alert-body span {
font-size: 11.5px;
line-height: 1.5;
.bd-alert-text {
font-size: 12.5px;
line-height: 1.6;
color: var(--bd-sub);
white-space: pre-wrap;
word-break: break-word;
}
.bd-overlay-close {
width: 22px;
height: 22px;
width: 26px;
height: 26px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: rgba(120,160,220,0.12);
border-radius: 8px;
background: transparent;
color: var(--bd-sub);
cursor: pointer;
transition: all 0.2s;
}
.bd-overlay-close:hover {
color: var(--bd-red);
background: rgba(240,68,56,0.1);
background: rgba(240, 68, 56, 0.12);
}
.bd-overlay-close:active {
transform: scale(0.92);
}
@keyframes bdOverlayIn {
from { opacity: 0; transform: translateY(-8px); }
+23
View File
@@ -67,6 +67,29 @@
-webkit-backdrop-filter: blur(14px);
}
/* 左下角:版权/开发 玻璃质感芯片 */
.ms-credit-chip {
position: absolute;
left: 16px;
bottom: 14px;
z-index: 8;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
font-size: 11px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
border-radius: 20px;
background: rgba(29, 44, 68, 0.32);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.ms-credit-chip .appica-ico { color: rgba(255, 255, 255, 0.85); }
/* 媒体播放器(填满整个媒体区,等比裁切铺满) */
.ms-media {
position: absolute;
+12
View File
@@ -0,0 +1,12 @@
/* =========================================================
工具 / 操作进行中状态提示总线
统一 dispatch `dpm:tool-status` 事件,由 ToolStatusToast 监听显示在右上角。
用法:
aiStatus('正在检索知识库') // 进行中(带转圈)
aiStatus('正在调用工具:切换页面', { icon: 'tool' })
aiStatus('操作完成', { state: 'done', icon: 'check' }) // 完成态
aiStatus('出错了', { state: 'error', icon: 'error' })
========================================================= */
export function aiStatus(text, opts = {}) {
window.dispatchEvent(new CustomEvent('dpm:tool-status', { detail: { text, ...opts } }));
}
+9 -2
View File
@@ -4,6 +4,7 @@ import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } f
import { MQTT_TOPIC_HEARTBEAT } from '../config';
import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav';
import { aiStatus } from './statusBus';
/* =========================================================
MQTT 控制 —— 后端通过 opc/display/command 控制大屏
@@ -72,7 +73,7 @@ export function useMqttControl() {
switch (cmd.action) {
case 'navigate': {
const path = resolvePage(cmd.params?.page);
if (path) { Sfx.page(); navigate(path); }
if (path) { Sfx.page(); aiStatus('正在切换页面', { icon: 'page' }); navigate(path); }
break;
}
case 'navigate_rel': {
@@ -80,14 +81,17 @@ export function useMqttControl() {
const idx = PAGE_ORDER.findIndex((p) => p.path === location.pathname);
if (idx >= 0) {
const n = PAGE_ORDER.length;
aiStatus('正在切换页面', { icon: 'page' });
navigate(PAGE_ORDER[(idx + delta + n) % n].path);
}
break;
}
case 'alert':
// 通知本身即展示,无需再弹「正在发送通知」状态提示
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} }));
break;
case 'show_card':
aiStatus('正在展示信息卡片', { icon: 'tool' });
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: cmd.params || {} }));
break;
case 'play':
@@ -155,12 +159,15 @@ export function useMqttControl() {
const { action, params } = e.detail || {};
if (action === 'navigate') {
const path = resolvePage(params?.page);
if (path) navigate(path);
if (path) { aiStatus('正在切换页面', { icon: 'page' }); navigate(path); }
} else if (action === 'control') {
aiStatus('正在控制媒体播放', { icon: 'tool' });
window.dispatchEvent(new CustomEvent('dpm:media-control', { detail: { action: params?.action } }));
} else if (action === 'alert') {
// 通知本身即展示,无需状态提示
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: params || {} }));
} else if (action === 'show_card') {
aiStatus('正在展示信息卡片', { icon: 'tool' });
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: params || {} }));
}
};