diff --git a/backend/app/ai_tools.py b/backend/app/ai_tools.py index 936be33..5edbe36 100644 --- a/backend/app/ai_tools.py +++ b/backend/app/ai_tools.py @@ -14,34 +14,34 @@ log = logging.getLogger("dpm.ai_tools") # ---------------- 工具执行 ---------------- -def execute_tool(tool): - """执行一个工具调用:发布 MQTT 控制指令,返回是否发布成功""" +def execute_tool(tool, screen_id=""): + """执行一个工具调用:发布 MQTT 控制指令(带发起屏幕 screen_id,只发到该屏),返回是否发布成功""" t = tool.get("type") params = tool.get("params") or {} if t == "navigate": - return hub.publish_command("navigate", {"page": params.get("page", "home")}).get("published", False) + return hub.publish_command("navigate", {"page": params.get("page", "home")}, screen_id=screen_id).get("published", False) if t == "control": action = params.get("action") if action in ("play", "pause", "next", "prev"): - return hub.publish_command(action).get("published", False) + return hub.publish_command(action, screen_id=screen_id).get("published", False) if action == "set_mode": - return hub.publish_command("set_mode", {"mode": params.get("mode", "auto")}).get("published", False) + return hub.publish_command("set_mode", {"mode": params.get("mode", "auto")}, screen_id=screen_id).get("published", False) return False if t == "alert": - return hub.publish_command("alert", {"title": params.get("title", "提示"), "content": params.get("content", "")}).get("published", False) + return hub.publish_command("alert", {"title": params.get("title", "提示"), "content": params.get("content", "")}, screen_id=screen_id).get("published", False) if t == "show_card": - return hub.publish_command("show_card", params).get("published", False) + return hub.publish_command("show_card", params, screen_id=screen_id).get("published", False) return False -def run_tools(tools): - """执行一组工具,返回 [{type, ok}] 并汇总是否全部发布成功""" +def run_tools(tools, screen_id=""): + """执行一组工具(带发起屏幕 screen_id),返回 [{type, ok}] 并汇总是否全部发布成功""" results = [] all_ok = True for tool in tools or []: - ok = bool(execute_tool(tool)) - log.info("ai_tools: 执行工具 %s params=%s -> %s", tool.get("type"), - json.dumps(tool.get("params") or {}, ensure_ascii=False), ok) + ok = bool(execute_tool(tool, screen_id)) + log.info("ai_tools: 执行工具 %s params=%s screen=%s -> %s", tool.get("type"), + json.dumps(tool.get("params") or {}, ensure_ascii=False), screen_id or "all", ok) results.append({"type": tool.get("type"), "ok": ok}) all_ok = all_ok and ok return results, all_ok diff --git a/backend/app/llm.py b/backend/app/llm.py index a275c0c..08829d6 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -135,32 +135,32 @@ def _chat_once(messages, with_tools=True): return json.loads(resp.read().decode("utf-8")) -def _fallback(messages): +def _fallback(messages, screen_id=""): """DashScope 不可用时回退本地规则引擎""" from .ai_tools import run_chat as rule_run result = rule_run(messages) - # 规则引擎产出的工具同样执行(MQTT 广播),并汇总发布结果 - _, all_ok = _exec_all(result.get("tools", [])) + # 规则引擎产出的工具同样执行(MQTT 广播,带发起屏幕),并汇总发布结果 + _, all_ok = _exec_all(result.get("tools", []), screen_id) result["model"] = "rule-engine" result["mqtt_published"] = all_ok return result -def _exec_all(tools): - """执行工具列表,返回 (results, all_ok)""" +def _exec_all(tools, screen_id=""): + """执行工具列表(带发起屏幕 screen_id,工具结果只发到该屏),返回 (results, all_ok)""" from .ai_tools import run_tools - results, all_ok = run_tools(tools) + results, all_ok = run_tools(tools, screen_id) return results, all_ok -def run_chat(messages): +def run_chat(messages, screen_id=""): """入口:{reply, tools, model} - tools 已在后端执行(MQTT 广播),返回值供请求端本地同步执行""" + tools 已在后端执行(MQTT 广播,带发起屏幕 screen_id),返回值供请求端本地同步执行""" if not settings.DASHSCOPE_API_KEY: log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎") - return _fallback(messages) + return _fallback(messages, screen_id) # 注入园区知识库(标准资料口径):按用户 query 检索命中段落动态注入, # 替代原 1200 字截断全量注入(避免尾部知识丢失)。无命中时回退 brief()。 @@ -226,7 +226,7 @@ def run_chat(messages): "content": json.dumps({"ok": True}, ensure_ascii=False), }) # 工具执行(MQTT 广播),并汇总发布结果 - results, all_ok = _exec_all(executed) + results, all_ok = _exec_all(executed, screen_id) log.info("llm: 工具执行结果 %s", results) for t in executed: log.info("llm: 执行 %s %s", t["type"], json.dumps(t["params"], ensure_ascii=False)) diff --git a/backend/app/routers.py b/backend/app/routers.py index 86c434c..b65c963 100644 --- a/backend/app/routers.py +++ b/backend/app/routers.py @@ -84,6 +84,7 @@ class ChatMessage(BaseModel): class ChatBody(BaseModel): messages: list[ChatMessage] + screen_id: str = "" # 发起对话的屏幕设备 id(工具结果只发到该屏) # ==================== 认证 / 设置 ==================== @@ -451,7 +452,7 @@ async def ai_chat(body: ChatBody): """大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏 返回 {reply, tools, model};tools 供请求端本地同步执行""" messages = [m.model_dump() for m in body.messages] - result = llm_run_chat(messages) + result = llm_run_chat(messages, body.screen_id or "") return result @@ -604,6 +605,7 @@ def vision_llm(body: VisionLlmBody): class ToolsExecBody(BaseModel): name: str = "" args: dict = {} + screen_id: str = "" # 发起语音对话的屏幕设备 id(工具结果只发到该屏) @router.post("/api/tools/exec") def tools_exec(body: ToolsExecBody): @@ -612,10 +614,10 @@ def tools_exec(body: ToolsExecBody): if not name: return {"ok": False, "error": "missing tool name"} from .tools import exec_tool - result = exec_tool(name, body.args or {}) - log.info("api: /api/tools/exec name=%s args=%s result=%s", name, + result = exec_tool(name, body.args or {}, body.screen_id or "") + log.info("api: /api/tools/exec name=%s args=%s screen=%s result=%s", name, json.dumps(body.args or {}, ensure_ascii=False)[:300], - str(result)[:400]) + body.screen_id or "all", str(result)[:400]) return {"ok": True, "result": result} @router.get("/api/kb/brief") diff --git a/backend/app/tools.py b/backend/app/tools.py index e7bf81e..e0562d2 100644 --- a/backend/app/tools.py +++ b/backend/app/tools.py @@ -60,15 +60,15 @@ def _query_companies(args): }, ensure_ascii=False) -def _control_display(args): - """大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 广播到大屏)""" +def _control_display(args, screen_id=""): + """大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 按发起屏幕下发)""" from .mqtt import hub action = (args.get("action") or "").strip() target = (args.get("target") or "").strip() if action not in ("switch_page", "alert", "media_play", "media_pause"): return json.dumps({"error": f"不支持的 action: {action}"}, ensure_ascii=False) params = {"target": target} if target else {} - ok = hub.publish_command(action, params).get("published", False) + ok = hub.publish_command(action, params, screen_id=screen_id).get("published", False) return json.dumps({"ok": ok, "action": action, "target": target}, ensure_ascii=False) @@ -125,15 +125,19 @@ def tool_schemas(): ] -def exec_tool(name: str, args: dict): - """执行工具 → 返回给 LLM 的字符串结果(JSON)""" +def exec_tool(name: str, args: dict, screen_id: str = ""): + """执行工具 → 返回给 LLM 的字符串结果(JSON)。 + 控制类工具(control_display)按发起屏幕 screen_id 只下发到该屏。""" t = TOOLS.get(name) if not t: log.warning("tools: 未知工具 %s", name) return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False) try: - result = t["fn"](args or {}) - log.info("tools: 执行工具 %s args=%s", name, json.dumps(args, ensure_ascii=False)) + if name == "control_display": + result = _control_display(args or {}, screen_id) + else: + result = t["fn"](args or {}) + log.info("tools: 执行工具 %s args=%s screen=%s", name, json.dumps(args, ensure_ascii=False), screen_id or "all") log.info("tools: 结果 -> %s", _trunc(result, 1000)) return result except Exception as e: # noqa: BLE001 diff --git a/backend/static/admin.js b/backend/static/admin.js index 175bb8b..5bd9e9f 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -193,7 +193,7 @@ if (btn.id === 'mqttAiSend') { var text = (document.getElementById('mqttAiInput').value || '').trim(); if (!text) { toast('请输入问题', 'error'); return; } - post('/api/display/command', { action: 'ai_input', params: { text: text } }, function () { + post('/api/display/command', { action: 'ai_input', params: { text: text }, screen_id: screenId, screen_role: screenRole }, function () { toast('已发送问题到大屏 AI 页'); }); return; @@ -203,17 +203,20 @@ if (btn.id === 'mqttAlertSend') { var alertText = (document.getElementById('mqttAlertText').value || '').trim(); if (!alertText) { toast('请输入通知内容', 'error'); return; } - post('/api/display/command', { action: 'alert', params: { text: alertText } }, function () { - toast('通知已广播'); + post('/api/display/command', { action: 'alert', params: { text: alertText }, screen_id: screenId, screen_role: screenRole }, function () { + toast('通知已发送'); }); return; } - // 页面切换 + // 页面切换(绑定目标屏幕:screen_id 精确某台;空=全部) if (btn.hasAttribute('data-nav')) { var page = btn.getAttribute('data-nav'); - post('/api/display/command', { action: 'navigate', params: { page: page } }, function () { - toast('已切换大屏至「' + btn.textContent.trim() + '」'); + var tgtTxt = screenId + ? (screenRole === 'main' ? '主屏' : screenRole === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10) + : '全部'; + post('/api/display/command', { action: 'navigate', params: { page: page }, screen_id: screenId, screen_role: screenRole }, function () { + toast('已切换大屏至「' + btn.textContent.trim() + '」 → ' + tgtTxt); }); return; } diff --git a/src/components/AiChatPanel.jsx b/src/components/AiChatPanel.jsx index cc8b137..21105b3 100644 --- a/src/components/AiChatPanel.jsx +++ b/src/components/AiChatPanel.jsx @@ -3,7 +3,7 @@ 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 { getMqttStatus, getClientId } from '../utils/mqtt'; import { aiStatus } from '../utils/statusBus'; import { renderMarkdown } from '../voice/markdown.jsx'; @@ -161,7 +161,7 @@ export default function AiChatPanel() { const res = await fetch(`${API_BASE()}/api/ai/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages: history }), + body: JSON.stringify({ messages: history, screen_id: getClientId() }), }); if (res.ok) { data = await res.json(); diff --git a/src/pages/VoiceAssistant.jsx b/src/pages/VoiceAssistant.jsx index 17c85a2..a28aea8 100644 --- a/src/pages/VoiceAssistant.jsx +++ b/src/pages/VoiceAssistant.jsx @@ -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 { getClientId } from '../utils/mqtt'; import { aiStatus } from '../utils/statusBus'; import '../styles/datascreen.css'; import './voice-original.css'; @@ -242,7 +243,7 @@ export default function VoiceAssistant() { const res = await fetch(`${API_BASE()}/api/tools/exec`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, args }), + body: JSON.stringify({ name, args, screen_id: getClientId() }), }); const data = await res.json(); output = data?.result ?? JSON.stringify(data);