feat: 增加 screen_id 参数以支持针对特定屏幕的工具执行,优化大屏控制逻辑

This commit is contained in:
Pine
2026-08-19 14:28:10 +08:00
parent e72442dae0
commit 3688d5d0b1
7 changed files with 52 additions and 42 deletions
+12 -12
View File
@@ -14,34 +14,34 @@ log = logging.getLogger("dpm.ai_tools")
# ---------------- 工具执行 ---------------- # ---------------- 工具执行 ----------------
def execute_tool(tool): def execute_tool(tool, screen_id=""):
"""执行一个工具调用:发布 MQTT 控制指令,返回是否发布成功""" """执行一个工具调用:发布 MQTT 控制指令(带发起屏幕 screen_id,只发到该屏),返回是否发布成功"""
t = tool.get("type") t = tool.get("type")
params = tool.get("params") or {} params = tool.get("params") or {}
if t == "navigate": 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": if t == "control":
action = params.get("action") action = params.get("action")
if action in ("play", "pause", "next", "prev"): 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": 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 return False
if t == "alert": 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": 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 return False
def run_tools(tools): def run_tools(tools, screen_id=""):
"""执行一组工具,返回 [{type, ok}] 并汇总是否全部发布成功""" """执行一组工具(带发起屏幕 screen_id,返回 [{type, ok}] 并汇总是否全部发布成功"""
results = [] results = []
all_ok = True all_ok = True
for tool in tools or []: for tool in tools or []:
ok = bool(execute_tool(tool)) ok = bool(execute_tool(tool, screen_id))
log.info("ai_tools: 执行工具 %s params=%s -> %s", tool.get("type"), log.info("ai_tools: 执行工具 %s params=%s screen=%s -> %s", tool.get("type"),
json.dumps(tool.get("params") or {}, ensure_ascii=False), ok) json.dumps(tool.get("params") or {}, ensure_ascii=False), screen_id or "all", ok)
results.append({"type": tool.get("type"), "ok": ok}) results.append({"type": tool.get("type"), "ok": ok})
all_ok = all_ok and ok all_ok = all_ok and ok
return results, all_ok return results, all_ok
+10 -10
View File
@@ -135,32 +135,32 @@ def _chat_once(messages, with_tools=True):
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
def _fallback(messages): def _fallback(messages, screen_id=""):
"""DashScope 不可用时回退本地规则引擎""" """DashScope 不可用时回退本地规则引擎"""
from .ai_tools import run_chat as rule_run from .ai_tools import run_chat as rule_run
result = rule_run(messages) result = rule_run(messages)
# 规则引擎产出的工具同样执行(MQTT 广播),并汇总发布结果 # 规则引擎产出的工具同样执行(MQTT 广播,带发起屏幕),并汇总发布结果
_, all_ok = _exec_all(result.get("tools", [])) _, all_ok = _exec_all(result.get("tools", []), screen_id)
result["model"] = "rule-engine" result["model"] = "rule-engine"
result["mqtt_published"] = all_ok result["mqtt_published"] = all_ok
return result return result
def _exec_all(tools): def _exec_all(tools, screen_id=""):
"""执行工具列表,返回 (results, all_ok)""" """执行工具列表(带发起屏幕 screen_id,工具结果只发到该屏),返回 (results, all_ok)"""
from .ai_tools import run_tools from .ai_tools import run_tools
results, all_ok = run_tools(tools) results, all_ok = run_tools(tools, screen_id)
return results, all_ok return results, all_ok
def run_chat(messages): def run_chat(messages, screen_id=""):
"""入口:{reply, tools, model} """入口:{reply, tools, model}
tools 已在后端执行(MQTT 广播),返回值供请求端本地同步执行""" tools 已在后端执行(MQTT 广播,带发起屏幕 screen_id),返回值供请求端本地同步执行"""
if not settings.DASHSCOPE_API_KEY: if not settings.DASHSCOPE_API_KEY:
log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎") log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎")
return _fallback(messages) return _fallback(messages, screen_id)
# 注入园区知识库(标准资料口径):按用户 query 检索命中段落动态注入, # 注入园区知识库(标准资料口径):按用户 query 检索命中段落动态注入,
# 替代原 1200 字截断全量注入(避免尾部知识丢失)。无命中时回退 brief()。 # 替代原 1200 字截断全量注入(避免尾部知识丢失)。无命中时回退 brief()。
@@ -226,7 +226,7 @@ def run_chat(messages):
"content": json.dumps({"ok": True}, ensure_ascii=False), "content": json.dumps({"ok": True}, ensure_ascii=False),
}) })
# 工具执行(MQTT 广播),并汇总发布结果 # 工具执行(MQTT 广播),并汇总发布结果
results, all_ok = _exec_all(executed) results, all_ok = _exec_all(executed, screen_id)
log.info("llm: 工具执行结果 %s", results) log.info("llm: 工具执行结果 %s", results)
for t in executed: for t in executed:
log.info("llm: 执行 %s %s", t["type"], json.dumps(t["params"], ensure_ascii=False)) log.info("llm: 执行 %s %s", t["type"], json.dumps(t["params"], ensure_ascii=False))
+6 -4
View File
@@ -84,6 +84,7 @@ class ChatMessage(BaseModel):
class ChatBody(BaseModel): class ChatBody(BaseModel):
messages: list[ChatMessage] messages: list[ChatMessage]
screen_id: str = "" # 发起对话的屏幕设备 id(工具结果只发到该屏)
# ==================== 认证 / 设置 ==================== # ==================== 认证 / 设置 ====================
@@ -451,7 +452,7 @@ async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏 """大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
返回 {reply, tools, model}tools 供请求端本地同步执行""" 返回 {reply, tools, model}tools 供请求端本地同步执行"""
messages = [m.model_dump() for m in body.messages] 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 return result
@@ -604,6 +605,7 @@ def vision_llm(body: VisionLlmBody):
class ToolsExecBody(BaseModel): class ToolsExecBody(BaseModel):
name: str = "" name: str = ""
args: dict = {} args: dict = {}
screen_id: str = "" # 发起语音对话的屏幕设备 id(工具结果只发到该屏)
@router.post("/api/tools/exec") @router.post("/api/tools/exec")
def tools_exec(body: ToolsExecBody): def tools_exec(body: ToolsExecBody):
@@ -612,10 +614,10 @@ def tools_exec(body: ToolsExecBody):
if not name: if not name:
return {"ok": False, "error": "missing tool name"} return {"ok": False, "error": "missing tool name"}
from .tools import exec_tool from .tools import exec_tool
result = exec_tool(name, body.args or {}) result = exec_tool(name, body.args or {}, body.screen_id or "")
log.info("api: /api/tools/exec name=%s args=%s result=%s", name, log.info("api: /api/tools/exec name=%s args=%s screen=%s result=%s", name,
json.dumps(body.args or {}, ensure_ascii=False)[:300], 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} return {"ok": True, "result": result}
@router.get("/api/kb/brief") @router.get("/api/kb/brief")
+11 -7
View File
@@ -60,15 +60,15 @@ def _query_companies(args):
}, ensure_ascii=False) }, ensure_ascii=False)
def _control_display(args): def _control_display(args, screen_id=""):
"""大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 广播到大屏""" """大屏显示控制:切页 / 弹通知 / 媒体播放暂停(经 MQTT 按发起屏幕下发"""
from .mqtt import hub from .mqtt import hub
action = (args.get("action") or "").strip() action = (args.get("action") or "").strip()
target = (args.get("target") or "").strip() target = (args.get("target") or "").strip()
if action not in ("switch_page", "alert", "media_play", "media_pause"): if action not in ("switch_page", "alert", "media_play", "media_pause"):
return json.dumps({"error": f"不支持的 action: {action}"}, ensure_ascii=False) return json.dumps({"error": f"不支持的 action: {action}"}, ensure_ascii=False)
params = {"target": target} if target else {} 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) 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): def exec_tool(name: str, args: dict, screen_id: str = ""):
"""执行工具 → 返回给 LLM 的字符串结果(JSON)""" """执行工具 → 返回给 LLM 的字符串结果(JSON)
控制类工具(control_display)按发起屏幕 screen_id 只下发到该屏。"""
t = TOOLS.get(name) t = TOOLS.get(name)
if not t: if not t:
log.warning("tools: 未知工具 %s", name) log.warning("tools: 未知工具 %s", name)
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False) return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
try: try:
result = t["fn"](args or {}) if name == "control_display":
log.info("tools: 执行工具 %s args=%s", name, json.dumps(args, ensure_ascii=False)) 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)) log.info("tools: 结果 -> %s", _trunc(result, 1000))
return result return result
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
+9 -6
View File
@@ -193,7 +193,7 @@
if (btn.id === 'mqttAiSend') { if (btn.id === 'mqttAiSend') {
var text = (document.getElementById('mqttAiInput').value || '').trim(); var text = (document.getElementById('mqttAiInput').value || '').trim();
if (!text) { toast('请输入问题', 'error'); return; } 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 页'); toast('已发送问题到大屏 AI 页');
}); });
return; return;
@@ -203,17 +203,20 @@
if (btn.id === 'mqttAlertSend') { if (btn.id === 'mqttAlertSend') {
var alertText = (document.getElementById('mqttAlertText').value || '').trim(); var alertText = (document.getElementById('mqttAlertText').value || '').trim();
if (!alertText) { toast('请输入通知内容', 'error'); return; } if (!alertText) { toast('请输入通知内容', 'error'); return; }
post('/api/display/command', { action: 'alert', params: { text: alertText } }, function () { post('/api/display/command', { action: 'alert', params: { text: alertText }, screen_id: screenId, screen_role: screenRole }, function () {
toast('通知已广播'); toast('通知已发送');
}); });
return; return;
} }
// 页面切换 // 页面切换(绑定目标屏幕:screen_id 精确某台;空=全部)
if (btn.hasAttribute('data-nav')) { if (btn.hasAttribute('data-nav')) {
var page = btn.getAttribute('data-nav'); var page = btn.getAttribute('data-nav');
post('/api/display/command', { action: 'navigate', params: { page: page } }, function () { var tgtTxt = screenId
toast('已切换大屏至「' + btn.textContent.trim() + ''); ? (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; return;
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import Icon from './Icons';
import ShowCard from './ShowCard'; import ShowCard from './ShowCard';
import { getApiBase as API_BASE } from '../config'; import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds'; import { Sfx } from '../utils/sounds';
import { getMqttStatus } from '../utils/mqtt'; import { getMqttStatus, getClientId } from '../utils/mqtt';
import { aiStatus } from '../utils/statusBus'; import { aiStatus } from '../utils/statusBus';
import { renderMarkdown } from '../voice/markdown.jsx'; import { renderMarkdown } from '../voice/markdown.jsx';
@@ -161,7 +161,7 @@ export default function AiChatPanel() {
const res = await fetch(`${API_BASE()}/api/ai/chat`, { const res = await fetch(`${API_BASE()}/api/ai/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: history }), body: JSON.stringify({ messages: history, screen_id: getClientId() }),
}); });
if (res.ok) { if (res.ok) {
data = await res.json(); data = await res.json();
+2 -1
View File
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js'; import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js'; import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
import { getApiBase as API_BASE, getVoiceWsUrl } from '../config'; import { getApiBase as API_BASE, getVoiceWsUrl } from '../config';
import { getClientId } from '../utils/mqtt';
import { aiStatus } from '../utils/statusBus'; import { aiStatus } from '../utils/statusBus';
import '../styles/datascreen.css'; import '../styles/datascreen.css';
import './voice-original.css'; import './voice-original.css';
@@ -242,7 +243,7 @@ export default function VoiceAssistant() {
const res = await fetch(`${API_BASE()}/api/tools/exec`, { const res = await fetch(`${API_BASE()}/api/tools/exec`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, args }), body: JSON.stringify({ name, args, screen_id: getClientId() }),
}); });
const data = await res.json(); const data = await res.json();
output = data?.result ?? JSON.stringify(data); output = data?.result ?? JSON.stringify(data);