feat: 拆出 FastAPI 独立后端 + MQTT 控制 + 阿里云 AI
后端(backend/,FastAPI :10085): - 全部数据 REST 接口:dashboard 快照 / 企业分区 / 播放列表 / 媒体 / 设置 - MQTT 控制通道:opc/display/command(切页/播放/卡片/通知), 管理端 POST /api/display/command → MQTT 广播 → 所有大屏同步响应 - 阿里云 DashScope:通义千问 LLM + 函数调用(工具经 MQTT 广播), paraformer-realtime-v2 语音识别 /api/ai/asr - 媒体资源统一由后端存储返回(上传/列表/静态服务) - SSE /api/events 保留作 MQTT 不可用时的兼容回退 前端: - config.js + .env.local 配置后端地址与 MQTT 账号(前端 dpm / 服务端 dpmserver) - mqtt.js 客户端 + useMqttControl(MQTT 驱动切页/媒体/卡片/通知) - DpmOverlays 全局覆盖层(通知 toast + 企业/分区/总览卡片) - useParkSim 改为后端 API 数据源(离线回退本地模拟) - AiChatPanel:对话走后端 LLM(工具调用),语音走本地录音 + 后端 ASR - MediaScreen:媒体控制走 MQTT(保留 SSE 回退),修复 useEffect TDZ Rust:lib.rs 移除内嵌 HTTP 服务器,只保留薄壳(窗口/权限/自启/npc 隧道) 安全:backend/.env、.env.local、media、data.json 已 gitignore
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""AI 助手工具调用引擎
|
||||
后端收到对话 → 规则引擎判断意图 → 产出文字回复 + 工具调用列表
|
||||
工具执行时通过 MQTT 广播 opc/display/command,前端(所有大屏)同步响应
|
||||
工具:navigate(切页) / control(媒体控制) / alert(通知) / show_card(展示卡片)
|
||||
"""
|
||||
|
||||
from .mqtt import hub
|
||||
|
||||
# ---------------- 工具执行 ----------------
|
||||
|
||||
def execute_tool(tool):
|
||||
"""执行一个工具调用:发布 MQTT 控制指令,返回是否发布成功"""
|
||||
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)
|
||||
if t == "control":
|
||||
action = params.get("action")
|
||||
if action in ("play", "pause", "next", "prev"):
|
||||
return hub.publish_command(action).get("published", False)
|
||||
if action == "set_mode":
|
||||
return hub.publish_command("set_mode", {"mode": params.get("mode", "auto")}).get("published", False)
|
||||
return False
|
||||
if t == "alert":
|
||||
return hub.publish_command("alert", {"title": params.get("title", "提示"), "content": params.get("content", "")}).get("published", False)
|
||||
if t == "show_card":
|
||||
return hub.publish_command("show_card", params).get("published", False)
|
||||
return False
|
||||
|
||||
|
||||
def run_tools(tools):
|
||||
"""执行一组工具,返回 [{type, ok}] 并汇总是否全部发布成功"""
|
||||
results = []
|
||||
all_ok = True
|
||||
for tool in tools or []:
|
||||
ok = bool(execute_tool(tool))
|
||||
results.append({"type": tool.get("type"), "ok": ok})
|
||||
all_ok = all_ok and ok
|
||||
return results, all_ok
|
||||
|
||||
|
||||
# ---------------- 意图 → 回复 + 工具 ----------------
|
||||
|
||||
PAGES = {
|
||||
"home": "/", "数据大屏": "/", "首页": "/",
|
||||
"twin": "/twin", "数字孪生": "/twin", "3d": "/twin", "孪生": "/twin",
|
||||
"ai": "/ai", "ai助手": "/ai", "智能助手": "/ai",
|
||||
"screen": "/screen", "媒体": "/screen", "轮播": "/screen", "媒体轮播": "/screen",
|
||||
}
|
||||
|
||||
RULES = [
|
||||
{
|
||||
"match": ["入驻", "申请", "入孵", "入园", "如何加入", "怎样加入"],
|
||||
"reply": "入驻流程如下:\n\n1. 提交入驻申请(线上表单或到园区服务中心现场办理)\n2. 项目评审(园区组织专家对项目可行性、团队背景进行评估)\n3. 签订入驻协议,办理入驻手续\n4. 安排办公场地,对接创业导师与孵化服务\n\n目前园区在园项目 158 家,累计孵化企业 208 余家。如需协助,可直接联系园区服务中心。",
|
||||
"tools": [{"type": "show_card", "params": {"card": "overview"}}],
|
||||
},
|
||||
{
|
||||
"match": ["政策", "补贴", "优惠", "扶持", "减免", "奖励", "资助"],
|
||||
"reply": "园区政策服务覆盖「七位一体」中的政策维度:\n\n· 场地租金减免:符合条件的大学生创业项目可享受场地优惠\n· 创业补贴:协助对接省、市两级创业补贴与一次性创业资助\n· 税费优惠:对接小微企业所得税减免、增值税优惠等政策\n· 项目申报:指导高企认定(目前在园高新技术企业 32 家)、科技型中小企业备案\n\n更多政策细节可咨询园区政策服务窗口。",
|
||||
"tools": [],
|
||||
},
|
||||
{
|
||||
"match": ["场地", "办公室", "工位", "租金", "收费", "面积", "多少钱", "价格"],
|
||||
"reply": "园区总建筑面积 1.2 万㎡,按功能划分为三个区域:\n\n· 加速区(A1–A12):成长型项目办公室,共 11 间独立办公室 + 1 间园区管理办公室\n· 国际区(I1–I7):面向国际合作的办公空间(7 间)\n· 成长区(G1–G8):初创团队成长空间(8 间)\n\n租金按创业项目阶段与政策给予差异化优惠,具体可到园区服务中心咨询。",
|
||||
"tools": [{"type": "show_card", "params": {"card": "zones"}}],
|
||||
},
|
||||
{
|
||||
"match": ["企业", "公司", "名录", "名单", "入驻企业", "有哪些企业", "查企业"],
|
||||
"reply": "园区目前有 39 家入驻企业,分布在三个区域:\n\n· 加速区:云南派音人工智能科技、米勒克尔蓝宝石珠宝、云南宸中低空经济等 9 家\n· 国际区:仰光客厅、中越生物医疗、酷享野农AI农业等 7 家\n· 成长区:Facebook越南跨境电商、昆明云韵体育、云南星瑞航空等 8 家\n\n我已在屏幕上为您展示企业分布卡片。",
|
||||
"tools": [{"type": "show_card", "params": {"card": "companies"}}],
|
||||
},
|
||||
{
|
||||
"match": ["融资", "投资", "资金", "贷款", "估值", "资本"],
|
||||
"reply": "园区融资服务包含:\n\n· 融资对接:定期组织路演与投资机构对接活动\n· 孵化估值:累计孵化企业估值约 5.2 亿元\n· 金融支持:协助对接银行创业贷款、政府创业担保贷款\n· 财务辅导:提供融资方案设计与商业计划书辅导",
|
||||
"tools": [],
|
||||
},
|
||||
{
|
||||
"match": ["就业", "招聘", "岗位", "人才", "用工"],
|
||||
"reply": "园区累计带动就业 2,186 人(其中高校毕业生 1,680 人,实习岗位 506 个)。\n\n· 企业招聘:在驻企业持续发布岗位\n· 人才对接:与在昆高校共建实习实训基地\n· 创业带动:园区鼓励以创业带动就业",
|
||||
"tools": [],
|
||||
},
|
||||
{
|
||||
"match": ["导师", "培训", "课程", "辅导", "孵化服务", "孵化器"],
|
||||
"reply": "园区配备创业导师 58 位,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办",
|
||||
"tools": [],
|
||||
},
|
||||
{
|
||||
"match": ["AI", "模型", "算力", "tokens", "token", "工具", "智能", "赋能", "大模型"],
|
||||
"reply": "园区 AI 赋能服务是「七位一体」的核心特色:\n\n· 大模型推理:接入 DeepSeek-V3、通义千问、智谱 GLM-4、豆包、讯飞星火等多模型\n· 工具调用:文档生成、数据查询、图像创作、代码执行、语音合成等智能工具\n· 算力与成本:实时提供 token 消耗统计与速率监控\n\n今日园区 AI 推理任务持续运行,服务节点 12/12 在线。",
|
||||
"tools": [],
|
||||
},
|
||||
{
|
||||
"match": ["加速区", "国际区", "成长区", "分区", "区域", "区别", "布局"],
|
||||
"reply": "园区按企业成长阶段划分三个区域:\n\n· 加速区(蓝色):成长型项目独立办公室(A1–A12),在驻 9 家\n· 国际区(青色):国际合作与跨境业务(I1–I7),在驻 7 家\n· 成长区(绿色):初创团队共享空间(G1–G8),在驻 8 家\n\n园区管理办公室位于加速区 M 房间。",
|
||||
"tools": [{"type": "show_card", "params": {"card": "zones"}}],
|
||||
},
|
||||
{
|
||||
"match": ["切", "去", "打开", "显示", "进入"],
|
||||
"reply": None, # 由下方页面/控制规则补充
|
||||
},
|
||||
{
|
||||
"match": ["你好", "您好", "hi", "hello", "嗨", "在吗"],
|
||||
"reply": "您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题;也可以让我「切换到数字孪生页」「打开媒体轮播」或「展示企业卡片」。",
|
||||
"tools": [],
|
||||
},
|
||||
{
|
||||
"match": ["谢谢", "感谢", "好的", "明白"],
|
||||
"reply": "不客气,随时为您服务。您也可以让我展示园区数据卡片,或切换到任意展示页面。",
|
||||
"tools": [],
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_REPLY = "园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务,在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。\n\n您可以这样问我:\n· 如何申请入驻园区?\n· 展示入驻企业卡片\n· 切换到数字孪生页面"
|
||||
|
||||
|
||||
def _detect_page(text):
|
||||
for key, path in PAGES.items():
|
||||
if key in text:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _detect_control(text):
|
||||
if any(k in text for k in ["播放", "继续播放", "开始播放"]):
|
||||
return "play"
|
||||
if any(k in text for k in ["暂停", "停止播放"]):
|
||||
return "pause"
|
||||
if any(k in text for k in ["下一个", "下一张", "下一屏", "切换下一"]):
|
||||
return "next"
|
||||
if any(k in text for k in ["上一个", "上一张", "上一屏", "切换上一"]):
|
||||
return "prev"
|
||||
return None
|
||||
|
||||
|
||||
def run_chat(messages):
|
||||
"""输入对话消息列表([{"role","content"}]),返回 {reply, tools}"""
|
||||
text = ""
|
||||
for m in reversed(messages or []):
|
||||
if m.get("role") in ("user", "me"):
|
||||
text = (m.get("content") or "").strip()
|
||||
break
|
||||
|
||||
reply = None
|
||||
tools = []
|
||||
|
||||
# 1) 页面切换意图(优先级最高)
|
||||
page = _detect_page(text)
|
||||
if page:
|
||||
label = {"/": "数据大屏", "/twin": "数字孪生", "/ai": "AI 助手", "/screen": "媒体轮播"}.get(page, page)
|
||||
reply = f"好的,已为您切换到「{label}」页面。"
|
||||
tools = [{"type": "navigate", "params": {"page": page}}]
|
||||
return {"reply": reply, "tools": tools}
|
||||
|
||||
# 2) 媒体控制意图
|
||||
ctrl = _detect_control(text)
|
||||
if ctrl:
|
||||
names = {"play": "播放", "pause": "暂停", "next": "切换下一项", "prev": "切换上一项"}
|
||||
reply = f"好的,已执行:{names[ctrl]}。"
|
||||
tools = [{"type": "control", "params": {"action": ctrl}}]
|
||||
return {"reply": reply, "tools": tools}
|
||||
|
||||
# 3) 知识库规则
|
||||
for rule in RULES:
|
||||
if any(k in text for k in rule["match"]):
|
||||
reply = rule["reply"]
|
||||
tools = rule["tools"]
|
||||
break
|
||||
|
||||
if reply is None:
|
||||
reply = DEFAULT_REPLY
|
||||
|
||||
# 工具已在后端通过 MQTT 广播;返回值供请求端本地同步执行
|
||||
return {"reply": reply, "tools": tools}
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""语音识别 —— 阿里云 DashScope paraformer-realtime-v2(流式)
|
||||
前端录音上传(m4a/wav/ogg/opus/pcm)→ 这里转写为文本
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import certifi
|
||||
|
||||
# macOS 系统 Python 无系统 CA:必须在导入 dashscope 之前设置,
|
||||
# aiohttp 才会使用 certifi 根证书(否则 WS 连接报 SSL 证书错误)
|
||||
os.environ.setdefault("SSL_CERT_FILE", certifi.where())
|
||||
|
||||
import dashscope # noqa: E402
|
||||
from dashscope.audio.asr import Recognition, RecognitionCallback # noqa: E402
|
||||
|
||||
from .config import settings # noqa: E402
|
||||
|
||||
log = logging.getLogger("dpm.asr")
|
||||
|
||||
|
||||
def _err_text(result):
|
||||
"""安全提取 RecognitionResult 的错误描述(其 __str__ 有缺陷)"""
|
||||
for attr in ("message", "code"):
|
||||
v = getattr(result, attr, None)
|
||||
if v:
|
||||
return f"{attr}={v}"
|
||||
return repr(result)
|
||||
|
||||
# 前端 MediaRecorder 可能产生的容器/编码 → paraformer 格式名
|
||||
FORMAT_ALIASES = {
|
||||
"mp4": "m4a", "m4a": "m4a", "aac": "m4a",
|
||||
"webm": "ogg", "opus": "opus", "ogg": "ogg",
|
||||
"wav": "wav", "pcm": "pcm", "mp3": "mp3",
|
||||
}
|
||||
|
||||
# m4a/mp3 等压缩格式采样率由服务端自动识别
|
||||
_AUTO_SAMPLE_FORMATS = {"m4a", "mp3", "ogg", "opus", "wav"}
|
||||
|
||||
|
||||
class _Callback(RecognitionCallback):
|
||||
def __init__(self):
|
||||
self.sentences = []
|
||||
self.error = None
|
||||
|
||||
def on_message(self, message):
|
||||
try:
|
||||
header = message.get("header", {})
|
||||
if header.get("action") != "result":
|
||||
return
|
||||
payload = message.get("payload", {})
|
||||
sentences = payload.get("sentence", {}).get("sentences") or []
|
||||
for s in sentences:
|
||||
if s.get("sentence_end"):
|
||||
self.sentences.append(s.get("text", ""))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("ASR 消息解析异常: %s", e)
|
||||
|
||||
def on_error(self, result):
|
||||
self.error = result
|
||||
log.warning("ASR 错误: %s", _err_text(result))
|
||||
|
||||
|
||||
def transcribe(audio_bytes: bytes, fmt: str = "m4a") -> str:
|
||||
"""上传音频字节 → 返回转写文本(空串表示未识别到内容)"""
|
||||
if not settings.DASHSCOPE_API_KEY:
|
||||
raise RuntimeError("未配置 DASHSCOPE_API_KEY,无法使用阿里云语音识别")
|
||||
|
||||
dashscope.api_key = settings.DASHSCOPE_API_KEY
|
||||
fmt = FORMAT_ALIASES.get((fmt or "m4a").lower().lstrip("."), "m4a")
|
||||
|
||||
cb = _Callback()
|
||||
rec = Recognition(
|
||||
model=settings.ASR_MODEL,
|
||||
format=fmt,
|
||||
sample_rate=16000 if fmt in ("pcm", "wav") else 0, # 压缩格式自动识别
|
||||
callback=cb,
|
||||
)
|
||||
|
||||
# 分块发送,模拟流式(适当间隔,避免触发服务端 batching 报错)
|
||||
rec.start()
|
||||
chunk = 16 * 1024
|
||||
for i in range(0, len(audio_bytes), chunk):
|
||||
rec.send_audio_frame(audio_bytes[i:i + chunk])
|
||||
time.sleep(0.08)
|
||||
time.sleep(0.5) # 等待尾部识别
|
||||
rec.stop()
|
||||
|
||||
if cb.error:
|
||||
raise RuntimeError(f"阿里云语音识别失败: {_err_text(cb.error)}")
|
||||
return "".join(cb.sentences).strip()
|
||||
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OPC 智能园区后端 —— 全局配置(backend/.env 可覆盖,未配置时用默认值)"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent # backend/
|
||||
|
||||
|
||||
def _load_dotenv(path):
|
||||
"""极简 .env 解析:KEY=VALUE,支持 # 注释与引号"""
|
||||
if not Path(path).exists():
|
||||
return
|
||||
for raw in Path(path).read_text("utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
|
||||
def _env(key, default):
|
||||
return os.getenv(key, default)
|
||||
|
||||
|
||||
class Settings:
|
||||
# ---- HTTP ----
|
||||
HOST = _env("DPM_HOST", _env("FASTAPI_HOST", "0.0.0.0"))
|
||||
PORT = int(_env("DPM_PORT", _env("FASTAPI_PORT", "8000")))
|
||||
|
||||
# ---- 数据目录 ----
|
||||
MEDIA_DIR = Path(_env("DPM_MEDIA_DIR", str(BASE_DIR / "media")))
|
||||
DATA_FILE = Path(_env("DPM_DATA_FILE", str(BASE_DIR / "data.json")))
|
||||
DIST_DIR = Path(_env("DPM_DIST_DIR", str(BASE_DIR.parent / "dist"))) # 前端构建产物(可选托管)
|
||||
|
||||
# ---- MQTT(优先使用 .env 中的局域网 Broker 配置) ----
|
||||
MQTT_ENABLED = _env("DPM_MQTT_ENABLED", "1") == "1"
|
||||
MQTT_HOST = _env("DPM_MQTT_HOST", _env("MQTT_BROKER_HOST", "127.0.0.1"))
|
||||
MQTT_PORT = int(_env("DPM_MQTT_PORT", _env("MQTT_BROKER_PORT", "1883")))
|
||||
MQTT_USERNAME = _env("DPM_MQTT_USERNAME", _env("MQTT_USERNAME", "")) or None
|
||||
MQTT_PASSWORD = _env("DPM_MQTT_PASSWORD", _env("MQTT_PASSWORD", "")) or None
|
||||
MQTT_CLIENT_ID = _env("DPM_MQTT_CLIENT_ID", "dpm-backend")
|
||||
# 前端 WebSocket 接入地址(默认按 .env 的 broker 主机 + EMQX 常规 ws 端口)
|
||||
MQTT_WS_URL = _env(
|
||||
"DPM_MQTT_WS",
|
||||
f"ws://{_env('MQTT_PUBLIC_HOST', _env('MQTT_BROKER_HOST', 'localhost'))}:8083/mqtt",
|
||||
)
|
||||
MQTT_TICK_INTERVAL = float(_env("DPM_MQTT_TICK_INTERVAL", "2.2"))
|
||||
|
||||
# ---- 主题 ----
|
||||
TOPIC_COMMAND = _env("DPM_TOPIC_COMMAND", "opc/display/command")
|
||||
TOPIC_TICK = _env("DPM_TOPIC_TICK", "opc/dashboard/tick")
|
||||
TOPIC_ACK = _env("DPM_TOPIC_ACK", "opc/display/ack")
|
||||
|
||||
# ---- 阿里云 DashScope(LLM + 语音识别) ----
|
||||
DASHSCOPE_API_KEY = _env("DASHSCOPE_API_KEY", "")
|
||||
LLM_MODEL = _env("DPM_LLM_MODEL", "qwen-plus") # 通义千问
|
||||
ASR_MODEL = _env("DPM_ASR_MODEL", "paraformer-realtime-v2") # 流式语音识别
|
||||
LLM_BASE_URL = _env("DPM_LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
ASR_TIMEOUT = float(_env("DPM_ASR_TIMEOUT", "30"))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SSE 事件总线 —— 兼容旧通道(前端 MQTT 不可用时回退)
|
||||
所有 MQTT 控制指令同时推送到 SSE /api/events
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self):
|
||||
self._queues = set()
|
||||
self._loop = None
|
||||
|
||||
def set_loop(self, loop):
|
||||
self._loop = loop
|
||||
|
||||
def subscribe(self):
|
||||
q = asyncio.Queue(maxsize=256)
|
||||
self._queues.add(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q):
|
||||
self._queues.discard(q)
|
||||
|
||||
def emit(self, payload: dict):
|
||||
"""线程安全:从任意线程广播事件"""
|
||||
if not self._loop:
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(self._emit(payload), self._loop)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _emit(self, payload):
|
||||
data = json.dumps(payload, ensure_ascii=False)
|
||||
for q in list(self._queues):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
bus = EventBus()
|
||||
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""AI 对话引擎 —— 阿里云 DashScope(通义千问)+ 函数调用
|
||||
工具调用执行后通过 MQTT 广播,所有大屏同步响应
|
||||
DashScope 不可用时自动回退本地规则引擎(离线兜底)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import certifi
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger("dpm.llm")
|
||||
|
||||
# macOS 系统 Python 无系统 CA,使用 certifi 提供的根证书
|
||||
_SSL_CTX = ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是「昆明市大学生创业园 · OPC 智能园区」的 AI 智能助手,运行在大屏展播系统上。"
|
||||
"园区提供空间、孵化、融资、政策、资源、AI 赋能、综合服务七位一体服务,"
|
||||
"在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。"
|
||||
"当用户要求「切换页面 / 控制播放 / 展示卡片 / 弹出通知」时,必须调用对应工具;"
|
||||
"其余园区相关问题用简洁、专业的中文回答,可适当使用列表。"
|
||||
)
|
||||
|
||||
# 暴露给大模型的工具定义(执行时经 MQTT 广播到前端)
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "navigate_page",
|
||||
"description": "切换大屏展示页面(数据大屏 / 数字孪生 / AI 助手 / 媒体轮播)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"type": "string", "enum": ["/", "/twin", "/ai", "/screen"],
|
||||
"description": "目标页面路径"},
|
||||
},
|
||||
"required": ["page"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "media_control",
|
||||
"description": "控制媒体播放(播放/暂停/下一项/上一项)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["play", "pause", "next", "prev"]},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "show_card",
|
||||
"description": "在大屏上展示信息卡片(企业分布 / 分区介绍 / 园区总览 / 自定义内容)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"card": {"type": "string", "enum": ["companies", "zones", "overview", "custom"]},
|
||||
"title": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
"required": ["card"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "send_alert",
|
||||
"description": "在大屏上弹出通知提示",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
_TOOL_MAP = {
|
||||
"navigate_page": "navigate",
|
||||
"media_control": "control",
|
||||
"show_card": "show_card",
|
||||
"send_alert": "alert",
|
||||
}
|
||||
|
||||
|
||||
def _chat_once(messages, with_tools=True):
|
||||
payload = {
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": 0.6,
|
||||
}
|
||||
if with_tools:
|
||||
payload["tools"] = TOOLS
|
||||
req = urllib.request.Request(
|
||||
f"{settings.LLM_BASE_URL}/chat/completions",
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60, context=_SSL_CTX) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _fallback(messages):
|
||||
"""DashScope 不可用时回退本地规则引擎"""
|
||||
from .ai_tools import run_chat as rule_run
|
||||
|
||||
result = rule_run(messages)
|
||||
# 规则引擎产出的工具同样执行(MQTT 广播),并汇总发布结果
|
||||
_, all_ok = _exec_all(result.get("tools", []))
|
||||
result["model"] = "rule-engine"
|
||||
result["mqtt_published"] = all_ok
|
||||
return result
|
||||
|
||||
|
||||
def _exec_all(tools):
|
||||
"""执行工具列表,返回 (results, all_ok)"""
|
||||
from .ai_tools import run_tools
|
||||
|
||||
results, all_ok = run_tools(tools)
|
||||
return results, all_ok
|
||||
|
||||
|
||||
def run_chat(messages):
|
||||
"""入口:{reply, tools, model}
|
||||
tools 已在后端执行(MQTT 广播),返回值供请求端本地同步执行"""
|
||||
if not settings.DASHSCOPE_API_KEY:
|
||||
log.warning("未配置 DASHSCOPE_API_KEY,使用本地规则引擎")
|
||||
return _fallback(messages)
|
||||
|
||||
msgs = [{"role": "system", "content": SYSTEM_PROMPT}] + [
|
||||
{"role": m.get("role") == "me" and "user" or m.get("role", "user"), "content": m.get("content", "")}
|
||||
for m in (messages or [])
|
||||
]
|
||||
|
||||
try:
|
||||
data = _chat_once(msgs)
|
||||
choice = data["choices"][0]["message"]
|
||||
reply = choice.get("content") or ""
|
||||
tool_calls = choice.get("tool_calls") or []
|
||||
executed = []
|
||||
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
tool = {"type": _TOOL_MAP.get(name, name), "params": args}
|
||||
executed.append(tool)
|
||||
msgs.append({"role": "assistant", "content": None, "tool_calls": tool_calls})
|
||||
msgs.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"content": json.dumps({"ok": True}, ensure_ascii=False),
|
||||
})
|
||||
# 工具执行(MQTT 广播),并汇总发布结果
|
||||
_, all_ok = _exec_all(executed)
|
||||
# 二次调用:携带工具结果生成最终回复
|
||||
try:
|
||||
data2 = _chat_once(msgs, with_tools=False)
|
||||
reply = data2["choices"][0]["message"].get("content") or reply
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("LLM 二次调用失败(保留工具回复): %s", e)
|
||||
else:
|
||||
all_ok = True
|
||||
|
||||
if not reply:
|
||||
reply = "已完成操作。您还可以让我切换页面、控制播放或展示园区卡片。"
|
||||
|
||||
return {"reply": reply, "tools": executed, "model": settings.LLM_MODEL, "mqtt_published": all_ok}
|
||||
except urllib.error.HTTPError as e:
|
||||
log.warning("DashScope HTTP %s: %s", e.code, e.read()[:300])
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("DashScope 调用失败,回退规则引擎: %s", e)
|
||||
|
||||
return _fallback(messages)
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OPC 智能园区后端 —— FastAPI 入口
|
||||
启动:uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
或:python main.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import settings
|
||||
from .event_bus import bus
|
||||
from .mqtt import hub
|
||||
from .routers import router
|
||||
from .sim_engine import sim_engine
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
log = logging.getLogger("dpm.main")
|
||||
|
||||
DIST = Path(settings.DIST_DIR)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 启动准备
|
||||
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
bus.set_loop(asyncio.get_running_loop())
|
||||
hub.start()
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
def tick_loop():
|
||||
"""数据引擎每 2.2s 推进一次;MQTT 连接时同步推送快照"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
snap = sim_engine.tick()
|
||||
hub.publish_tick(snap)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("数据 tick 异常: %s", e)
|
||||
stop.wait(settings.MQTT_TICK_INTERVAL)
|
||||
|
||||
threading.Thread(target=tick_loop, daemon=True).start()
|
||||
log.info("OPC 智能园区后端启动于 http://%s:%s", settings.HOST, settings.PORT)
|
||||
if settings.MQTT_ENABLED:
|
||||
log.info("MQTT Broker: %s:%s(前端 WS: %s)", settings.MQTT_HOST, settings.MQTT_PORT, settings.MQTT_WS_URL)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop.set()
|
||||
hub.stop()
|
||||
log.info("OPC 智能园区后端已停止")
|
||||
|
||||
|
||||
app = FastAPI(title="OPC 智能园区后端", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
# 媒体资源静态服务(上传/播放的文件)
|
||||
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media")
|
||||
|
||||
|
||||
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
async def spa_fallback(path: str):
|
||||
if not DIST.exists():
|
||||
return JSONResponse({"detail": "前端未构建(dist 目录不存在)"}, status_code=404)
|
||||
dist = DIST.resolve()
|
||||
if not path:
|
||||
target = dist / "index.html"
|
||||
else:
|
||||
target = (dist / path).resolve()
|
||||
if target.is_file() and target.is_relative_to(dist):
|
||||
return FileResponse(target)
|
||||
index = dist / "index.html"
|
||||
if index.exists():
|
||||
return FileResponse(index)
|
||||
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""MQTT 发布中心 —— 后端 → 前端控制通道
|
||||
页面控制 / 媒体控制 / 卡片展示指令统一走 opc/display/command
|
||||
数据快照按 DPM_MQTT_TICK_INTERVAL 推送 opc/dashboard/tick
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from .config import settings
|
||||
from .event_bus import bus
|
||||
|
||||
log = logging.getLogger("dpm.mqtt")
|
||||
|
||||
|
||||
def new_cmd_id():
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
class MqttHub:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.connected = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# ---------- 生命周期 ----------
|
||||
def start(self):
|
||||
if not settings.MQTT_ENABLED:
|
||||
log.info("MQTT 已禁用(DPM_MQTT_ENABLED=0)")
|
||||
return
|
||||
try:
|
||||
self.client = mqtt.Client(
|
||||
client_id=settings.MQTT_CLIENT_ID,
|
||||
clean_session=True,
|
||||
protocol=mqtt.MQTTv311,
|
||||
)
|
||||
if settings.MQTT_USERNAME:
|
||||
self.client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD)
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.connect_async(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=30)
|
||||
self.client.loop_start()
|
||||
log.info("MQTT 连接中 %s:%s ...", settings.MQTT_HOST, settings.MQTT_PORT)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("MQTT 初始化失败(数据 API 不受影响): %s", e)
|
||||
|
||||
def stop(self):
|
||||
if self.client:
|
||||
try:
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self.client = None
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
self.connected = True
|
||||
log.info("MQTT 已连接 %s:%s", settings.MQTT_HOST, settings.MQTT_PORT)
|
||||
else:
|
||||
log.warning("MQTT 连接失败 rc=%s", rc)
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
self.connected = False
|
||||
if rc != 0:
|
||||
log.warning("MQTT 断开(rc=%s),自动重连中...", rc)
|
||||
|
||||
# ---------- 发布 ----------
|
||||
def publish(self, topic, payload, qos=1, retain=False):
|
||||
if not self.client or not self.connected:
|
||||
log.debug("MQTT 未连接,丢弃发布 %s", topic)
|
||||
return False
|
||||
try:
|
||||
info = self.client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=qos, retain=retain)
|
||||
return info.rc == mqtt.MQTT_ERR_SUCCESS
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("MQTT 发布失败: %s", e)
|
||||
return False
|
||||
|
||||
def publish_command(self, action, params=None):
|
||||
"""统一指令信封:{cmd_id, ts, action, params, published}
|
||||
MQTT 广播 + SSE 兼容推送;published 标记是否真正发布成功"""
|
||||
payload = {
|
||||
"cmd_id": new_cmd_id(),
|
||||
"ts": int(time.time() * 1000),
|
||||
"action": action,
|
||||
"params": params or {},
|
||||
}
|
||||
ok = self.publish(settings.TOPIC_COMMAND, payload)
|
||||
payload["published"] = ok
|
||||
if ok:
|
||||
bus.emit(payload) # SSE 兼容通道(仅发布成功时推送)
|
||||
return payload
|
||||
|
||||
def publish_tick(self, snapshot):
|
||||
payload = {"ts": int(time.time() * 1000), "snapshot": snapshot}
|
||||
self.publish(settings.TOPIC_TICK, payload, qos=0)
|
||||
|
||||
|
||||
hub = MqttHub()
|
||||
@@ -0,0 +1,383 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全部 REST 路由 —— 数据 / 媒体 / 播放列表 / 设置 / AI / 展示控制"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile, File
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .ai_tools import run_chat, run_tools
|
||||
from .asr import transcribe as asr_transcribe
|
||||
from .config import settings
|
||||
from .event_bus import bus
|
||||
from .llm import run_chat as llm_run_chat
|
||||
from .mqtt import hub
|
||||
from .sim_engine import sim_engine
|
||||
from .storage import storage
|
||||
|
||||
log = logging.getLogger("dpm.api")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_EXT = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
|
||||
|
||||
|
||||
def _media_type(path):
|
||||
lower = path.lower()
|
||||
if lower.endswith((".mp4", ".mkv", ".avi")):
|
||||
return "video"
|
||||
return "image"
|
||||
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
volume: int | None = None
|
||||
play_mode: str | None = None
|
||||
image_duration: int | None = None
|
||||
fullscreen: bool | None = None
|
||||
autostart: bool | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class ActionBody(BaseModel):
|
||||
action: str
|
||||
|
||||
|
||||
class PathBody(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class UrlBody(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class StatePayload(BaseModel):
|
||||
status: str = ""
|
||||
index: int = 0
|
||||
name: str = ""
|
||||
media_type: str = ""
|
||||
|
||||
|
||||
class StateBody(BaseModel):
|
||||
state: StatePayload
|
||||
|
||||
|
||||
class DisplayCommandBody(BaseModel):
|
||||
action: str
|
||||
params: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatBody(BaseModel):
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
# ==================== 认证 / 设置 ====================
|
||||
|
||||
@router.post("/api/login")
|
||||
async def login(request: Request):
|
||||
form = await request.form()
|
||||
s = storage.get_settings()
|
||||
return JSONResponse({"success": form.get("username") == s["username"] and form.get("password") == s["password"]})
|
||||
|
||||
|
||||
@router.get("/api/settings")
|
||||
async def get_settings():
|
||||
s = storage.get_settings()
|
||||
return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")}
|
||||
|
||||
|
||||
@router.post("/api/settings")
|
||||
async def update_settings(body: SettingsBody):
|
||||
storage.update_settings(**body.model_dump(exclude_none=True))
|
||||
s = storage.get_settings()
|
||||
hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ==================== 媒体 ====================
|
||||
|
||||
def _list_media():
|
||||
files = []
|
||||
media_dir = Path(settings.MEDIA_DIR)
|
||||
if media_dir.exists():
|
||||
for p in sorted(media_dir.iterdir()):
|
||||
if not p.is_file() or p.suffix.lower() not in ALLOWED_EXT:
|
||||
continue
|
||||
files.append({
|
||||
"name": p.name, "relative_path": p.name,
|
||||
"type": _media_type(p.name), "url": f"/file/{p.name}", "source": "local",
|
||||
})
|
||||
for item in storage.get_url_media():
|
||||
files.append({
|
||||
"name": item.get("name", item.get("url", "")),
|
||||
"relative_path": item.get("url", ""),
|
||||
"type": item.get("type", "image"),
|
||||
"url": item.get("url", ""),
|
||||
"source": "url",
|
||||
})
|
||||
return {"files": files}
|
||||
|
||||
|
||||
@router.get("/media")
|
||||
async def list_media():
|
||||
return _list_media()
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload(file: UploadFile = File(...)):
|
||||
name = file.filename or "file"
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return JSONResponse({"ok": False, "error": "不支持的文件类型"}, status_code=400)
|
||||
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stem = Path(name).stem
|
||||
new_name = f"{stem}_{time.strftime('%Y%m%d%H%M%S')}{ext}"
|
||||
save_path = settings.MEDIA_DIR / new_name
|
||||
with save_path.open("wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
hub.publish_command("playlist_changed")
|
||||
return {"ok": True, "name": new_name}
|
||||
|
||||
|
||||
@router.post("/api/delete")
|
||||
async def delete_media(body: PathBody):
|
||||
path = body.path
|
||||
if not path.startswith(("http://", "https://")):
|
||||
target = settings.MEDIA_DIR / path
|
||||
try:
|
||||
if target.exists() and target.is_file():
|
||||
target.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
storage.delete_media(path)
|
||||
hub.publish_command("playlist_changed")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/media/add-url")
|
||||
async def add_url(body: UrlBody):
|
||||
url = body.url.strip()
|
||||
if not url:
|
||||
return {"ok": False}
|
||||
name = url.rsplit("/", 1)[-1].split("?", 1)[0] or url
|
||||
t = _media_type(url)
|
||||
is_new = storage.add_url_media(url, name, t)
|
||||
return {"ok": True, "duplicate": None if is_new else True}
|
||||
|
||||
|
||||
# ==================== 播放列表 ====================
|
||||
|
||||
def _playlist_files():
|
||||
files = []
|
||||
media_dir = Path(settings.MEDIA_DIR)
|
||||
for item in storage.get_playlist():
|
||||
path = item.get("path", "")
|
||||
if item.get("source") == "url":
|
||||
files.append({
|
||||
"name": item.get("name") or path,
|
||||
"relative_path": path, "type": _media_type(path), "url": path, "source": "url",
|
||||
})
|
||||
else:
|
||||
fp = media_dir / path
|
||||
if fp.exists():
|
||||
files.append({
|
||||
"name": fp.name, "relative_path": path,
|
||||
"type": _media_type(path), "url": f"/file/{path}", "source": "local",
|
||||
})
|
||||
s = storage.get_settings()
|
||||
return {"files": files, "volume": s["volume"], "play_mode": s["play_mode"], "image_duration": s["image_duration"]}
|
||||
|
||||
|
||||
@router.get("/api/playlist")
|
||||
async def get_playlist():
|
||||
return _playlist_files()
|
||||
|
||||
|
||||
@router.post("/api/playlist/add")
|
||||
async def add_playlist(body: PathBody):
|
||||
storage.add_to_playlist(body.path)
|
||||
hub.publish_command("playlist_changed")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/playlist/remove")
|
||||
async def remove_playlist(body: PathBody):
|
||||
storage.remove_from_playlist(body.path)
|
||||
hub.publish_command("playlist_changed")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ==================== 播放控制 / 状态 ====================
|
||||
|
||||
_CONTROL_ACTIONS = {"play", "pause", "next", "prev"}
|
||||
|
||||
|
||||
@router.post("/api/control")
|
||||
async def control(body: ActionBody):
|
||||
if body.action not in _CONTROL_ACTIONS:
|
||||
return JSONResponse({"ok": False, "error": "action 必须是 play/pause/next/prev"}, status_code=400)
|
||||
hub.publish_command(body.action)
|
||||
return {"ok": True, "action": body.action}
|
||||
|
||||
|
||||
@router.get("/api/state")
|
||||
async def get_state():
|
||||
return {"state": {"status": "unknown", "index": 0, "name": "", "media_type": ""}}
|
||||
|
||||
|
||||
@router.post("/api/state")
|
||||
async def update_state(body: StateBody):
|
||||
hub.publish_command("state_update", body.state.model_dump())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/display-command")
|
||||
async def display_command(body: ActionBody):
|
||||
if body.action == "minimize":
|
||||
hub.publish_command("minimize")
|
||||
return {"ok": True, "action": "minimize"}
|
||||
return JSONResponse({"ok": False, "error": "不支持的显示命令"}, status_code=400)
|
||||
|
||||
|
||||
# ==================== 数据(大屏全部数据来自后端) ====================
|
||||
|
||||
@router.get("/api/dashboard/snapshot")
|
||||
async def dashboard_snapshot():
|
||||
return sim_engine.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/dashboard/overview")
|
||||
async def dashboard_overview():
|
||||
return sim_engine.snapshot()
|
||||
|
||||
|
||||
# ==================== 园区企业 ====================
|
||||
|
||||
_COMPANIES = [
|
||||
{"name": "云南派音人工智能科技", "zone": "加速区", "room": "A1", "color": "#4c8dff"},
|
||||
{"name": "米勒克尔蓝宝石珠宝", "zone": "加速区", "room": "A2", "color": "#4c8dff"},
|
||||
{"name": "中泰研学合作", "zone": "加速区", "room": "A3", "color": "#4c8dff"},
|
||||
{"name": "云南宸中低空经济", "zone": "加速区", "room": "A4", "color": "#4c8dff"},
|
||||
{"name": "昆明智海银高文化科技", "zone": "加速区", "room": "A5", "color": "#4c8dff"},
|
||||
{"name": "AI机器人大模型训练", "zone": "加速区", "room": "A6", "color": "#4c8dff"},
|
||||
{"name": "云南廷秀文旅康养", "zone": "加速区", "room": "A7", "color": "#4c8dff"},
|
||||
{"name": "瀚颖AI+教育信息咨询", "zone": "加速区", "room": "A8", "color": "#4c8dff"},
|
||||
{"name": "云南大学AI+创业平台", "zone": "加速区", "room": "A9", "color": "#4c8dff"},
|
||||
{"name": "仰光客厅", "zone": "国际区", "room": "I1", "color": "#22d3ee"},
|
||||
{"name": "云南上古绝学文化", "zone": "国际区", "room": "I2", "color": "#22d3ee"},
|
||||
{"name": "中越生物医疗", "zone": "国际区", "room": "I3", "color": "#22d3ee"},
|
||||
{"name": "酷享野农AI农业", "zone": "国际区", "room": "I4", "color": "#22d3ee"},
|
||||
{"name": "滇缅国际设计", "zone": "国际区", "room": "I5", "color": "#22d3ee"},
|
||||
{"name": "昆明舒诺生物科技", "zone": "国际区", "room": "I6", "color": "#22d3ee"},
|
||||
{"name": "达岸教育管理", "zone": "国际区", "room": "I7", "color": "#22d3ee"},
|
||||
{"name": "Facebook越南跨境电商", "zone": "成长区", "room": "G1", "color": "#34d399"},
|
||||
{"name": "研X同行者网络", "zone": "成长区", "room": "G2", "color": "#34d399"},
|
||||
{"name": "朵哈·玫瑰特色产业链", "zone": "成长区", "room": "G3", "color": "#34d399"},
|
||||
{"name": "昆明云韵体育", "zone": "成长区", "room": "G4", "color": "#34d399"},
|
||||
{"name": "南菌优培食用菌", "zone": "成长区", "room": "G5", "color": "#34d399"},
|
||||
{"name": "五华区丽裳文化", "zone": "成长区", "room": "G6", "color": "#34d399"},
|
||||
{"name": "云南星瑞航空", "zone": "成长区", "room": "G7", "color": "#34d399"},
|
||||
{"name": "综合直播私域平台", "zone": "成长区", "room": "G8", "color": "#34d399"},
|
||||
{"name": "园区管理办公室", "zone": "园区管理", "room": "M", "color": "#fbbf24"},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/api/park/companies")
|
||||
async def park_companies():
|
||||
return {"companies": _COMPANIES}
|
||||
|
||||
|
||||
@router.get("/api/park/zones")
|
||||
async def park_zones():
|
||||
zones = ["加速区", "国际区", "成长区"]
|
||||
counts = {z: sum(1 for c in _COMPANIES if c["zone"] == z) for z in zones}
|
||||
colors = {"加速区": "#4c8dff", "国际区": "#22d3ee", "成长区": "#34d399"}
|
||||
return {"zones": [{"name": z, "count": counts[z], "color": colors[z]} for z in zones]}
|
||||
|
||||
|
||||
# ==================== AI 对话(通义千问 + 工具调用) ====================
|
||||
|
||||
@router.post("/api/ai/chat")
|
||||
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)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/ai/asr")
|
||||
async def ai_asr(file: UploadFile = File(...), format: str = "m4a"):
|
||||
"""语音识别:上传录音 → 阿里云 paraformer 转写为文本"""
|
||||
data = await file.read()
|
||||
if not data:
|
||||
return JSONResponse({"ok": False, "error": "空音频"}, status_code=400)
|
||||
try:
|
||||
text = asr_transcribe(data, fmt=format)
|
||||
return {"ok": True, "text": text}
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("ASR 转写失败: %s", e)
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ==================== 展示控制(管理端 → MQTT) ====================
|
||||
|
||||
_VALID_DISPLAY_ACTIONS = {"navigate", "navigate_rel", "play", "pause", "next", "prev",
|
||||
"alert", "show_card", "set_mode", "minimize"}
|
||||
|
||||
|
||||
@router.post("/api/display/command")
|
||||
async def display_command_publish(body: DisplayCommandBody):
|
||||
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
|
||||
if body.action not in _VALID_DISPLAY_ACTIONS:
|
||||
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
|
||||
cmd = hub.publish_command(body.action, body.params)
|
||||
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
|
||||
"mqtt_connected": hub.connected}
|
||||
|
||||
|
||||
@router.get("/api/display/state")
|
||||
async def display_state():
|
||||
return {
|
||||
"mqtt_connected": hub.connected,
|
||||
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
|
||||
"page": None, # 前端可通过 MQTT 回执上报当前页(后续扩展)
|
||||
}
|
||||
|
||||
|
||||
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================
|
||||
|
||||
@router.get("/api/events")
|
||||
async def sse_events(request: Request):
|
||||
q = bus.subscribe()
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
data = await asyncio.wait_for(q.get(), timeout=15)
|
||||
yield f"data: {data}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
bus.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""园区数据模拟引擎 —— 由前端 parkData.js 移植,后端统一产生数据
|
||||
前端通过 GET /api/dashboard/snapshot 获取,或订阅 MQTT opc/dashboard/tick
|
||||
"""
|
||||
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
MODEL_NAMES = ["DeepSeek-V3", "通义千问", "智谱 GLM-4", "豆包", "讯飞星火"]
|
||||
TOOL_NAMES = ["文档生成", "数据查询", "图像创作", "代码执行", "语音合成"]
|
||||
COMPANY_NAMES = [
|
||||
"云南派音人工智能科技", "米勒克尔蓝宝石珠宝", "中泰研学合作", "云南宸中低空经济",
|
||||
"昆明智海银高文化科技", "云南廷秀文旅康养", "瀚颖AI+教育信息咨询",
|
||||
"仰光客厅", "云南上古绝学文化", "中越生物医疗", "酷享野农AI农业",
|
||||
"滇缅国际设计", "昆明舒诺生物科技", "达岸教育管理", "花仙子园艺肥料",
|
||||
"研X同行者网络", "鬼才明AI创意工作室", "朵哈·玫瑰特色产业链", "昆明云韵体育",
|
||||
"南菌优培食用菌", "五华区丽裳文化", "云南星瑞航空", "综合直播私域平台",
|
||||
"昆明屿澈电商", "蓝智科技", "云品出滇·纸享万家", "启元人工智能科技",
|
||||
]
|
||||
|
||||
|
||||
def _pick(arr):
|
||||
return arr[random.randrange(len(arr))]
|
||||
|
||||
|
||||
def _now_time():
|
||||
return time.strftime("%H:%M:%S")
|
||||
|
||||
|
||||
def make_series(base, growth, noise, n=30):
|
||||
out = []
|
||||
v = base
|
||||
for _ in range(n):
|
||||
v = v * (1 + growth) + (random.random() - 0.5) * noise
|
||||
out.append(round(max(1, v * 100) / 100, 2))
|
||||
return out
|
||||
|
||||
|
||||
def gen_event():
|
||||
r = random.random
|
||||
pool = [
|
||||
{"icon": "bolt", "text": f"AI 推理任务完成 · 消耗 {round(800 + r() * 9000):,} tokens({_pick(MODEL_NAMES)})"},
|
||||
{"icon": "wrench", "text": f"工具「{_pick(TOOL_NAMES)}」被调用 {round(10 + r() * 90)} 次"},
|
||||
{"icon": "building", "text": f"「{_pick(COMPANY_NAMES)}」提交入驻申请 · 进入评审流程"},
|
||||
{"icon": "users", "text": f"「{_pick(COMPANY_NAMES)}」新增招聘岗位 {round(1 + r() * 5)} 个"},
|
||||
{"icon": "coin", "text": f"园区企业完成一笔 ¥{(0.5 + r() * 9):.1f}万 交易"},
|
||||
{"icon": "robot", "text": f"「{_pick(MODEL_NAMES)}」模型完成一次微调任务"},
|
||||
]
|
||||
e = _pick(pool)
|
||||
return {"id": f"{int(time.time()*1000)}-{random.random()}", "icon": e["icon"], "text": e["text"], "time": _now_time()}
|
||||
|
||||
|
||||
def init_feed():
|
||||
return [
|
||||
{"id": 1, "icon": "bolt", "text": "云南派音AI 完成音频向量嵌入任务 · 消耗 12,480 tokens", "time": _now_time()},
|
||||
{"id": 2, "icon": "building", "text": "「启元人工智能科技」通过评审 · 正式入驻 OPC 创业空间", "time": _now_time()},
|
||||
{"id": 3, "icon": "users", "text": "「昆明舒护安养老服务」新增招聘岗位 2 个", "time": _now_time()},
|
||||
{"id": 4, "icon": "coin", "text": "园区企业完成一笔 ¥3.6万 交易", "time": _now_time()},
|
||||
]
|
||||
|
||||
|
||||
def init_snapshot():
|
||||
return {
|
||||
"t": 0,
|
||||
"token": {"today": 128.64, "total": 12840, "rate": 84.6, "series": make_series(82, 0.012, 9)},
|
||||
"tools": {"today": 3568, "total": 365204, "success": 98.7},
|
||||
"projects": {"inPark": 158, "cum": 208, "todayNew": 2},
|
||||
"jobs": {"total": 2186, "todayNew": 3},
|
||||
"revenue": {"today": 38.6, "total": 20800, "growth": 8.2, "series": make_series(30, 0.006, 4)},
|
||||
"park": {"devices": 98.6, "energy": 386, "people": 127, "desk": 76, "meeting": 3, "nodes": 12},
|
||||
"feed": init_feed(),
|
||||
}
|
||||
|
||||
|
||||
def next_snapshot(s):
|
||||
r = random.random
|
||||
t_delta = round(0.26 + r() * 0.34, 2)
|
||||
|
||||
token = {
|
||||
"today": round(s["token"]["today"] + t_delta, 2),
|
||||
"total": round(s["token"]["total"] + t_delta, 2),
|
||||
"rate": round(76 + r() * 20, 1),
|
||||
}
|
||||
tool_delta = round(13 + r() * 22)
|
||||
tools = {
|
||||
"today": s["tools"]["today"] + tool_delta,
|
||||
"total": s["tools"]["total"] + tool_delta,
|
||||
"success": round(98.1 + r() * 1.2, 1),
|
||||
}
|
||||
rev_delta = round(0.6 + r() * 1.7, 1)
|
||||
revenue = {
|
||||
"today": round(s["revenue"]["today"] + rev_delta, 1),
|
||||
"total": round(s["revenue"]["total"] + rev_delta, 1),
|
||||
"growth": round(7.2 + r() * 2.2, 1),
|
||||
}
|
||||
park = {
|
||||
"devices": round(97.6 + r() * 1.6, 1),
|
||||
"energy": round(320 + r() * 130),
|
||||
"people": round(80 + r() * 95),
|
||||
"desk": round(62 + r() * 24),
|
||||
"meeting": round(2 + r() * 4),
|
||||
"nodes": 12,
|
||||
}
|
||||
|
||||
projects = dict(s["projects"])
|
||||
if r() < 0.055:
|
||||
projects["todayNew"] += 1
|
||||
if r() < 0.035:
|
||||
projects["inPark"] += 1
|
||||
projects["cum"] += 1
|
||||
jobs = dict(s["jobs"])
|
||||
if r() < 0.08:
|
||||
jobs["todayNew"] += 1
|
||||
if r() < 0.05:
|
||||
jobs["total"] += 1
|
||||
|
||||
t_series = list(s["token"]["series"])
|
||||
t_series[-1] = round(t_series[-1] + t_delta, 2)
|
||||
if s["t"] % 12 == 11:
|
||||
t_series = t_series[1:] + [round(72 + r() * 30, 2)]
|
||||
token["series"] = t_series
|
||||
|
||||
r_series = list(s["revenue"]["series"])
|
||||
r_series[-1] = round(r_series[-1] + rev_delta, 1)
|
||||
if s["t"] % 12 == 11:
|
||||
r_series = r_series[1:] + [round(26 + r() * 9, 1)]
|
||||
revenue["series"] = r_series
|
||||
|
||||
feed = [gen_event()] + s["feed"][:6] if s["t"] % 3 == 2 else s["feed"]
|
||||
|
||||
return {
|
||||
"t": s["t"] + 1,
|
||||
"token": token, "tools": tools, "revenue": revenue,
|
||||
"park": park, "projects": projects, "jobs": jobs, "feed": feed,
|
||||
}
|
||||
|
||||
|
||||
class SimEngine:
|
||||
"""带锁的快照引擎:单例供 API 与 MQTT tick 共用"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._snap = init_snapshot()
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
return dict(self._snap)
|
||||
|
||||
def tick(self):
|
||||
with self._lock:
|
||||
self._snap = next_snapshot(self._snap)
|
||||
return dict(self._snap)
|
||||
|
||||
|
||||
sim_engine = SimEngine()
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JSON 持久化:设置 / 播放列表 / URL 媒体(对齐原 Rust storage 语义)"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .config import settings
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"volume": 80,
|
||||
"auto_play": True,
|
||||
"play_mode": "sequential",
|
||||
"image_duration": 5,
|
||||
"fullscreen": True,
|
||||
"autostart": False,
|
||||
}
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self, data_file: Path):
|
||||
self.data_file = data_file
|
||||
self._lock = threading.RLock()
|
||||
self._data = self._load()
|
||||
|
||||
# ---------- 底层 ----------
|
||||
def _load(self):
|
||||
if self.data_file.exists():
|
||||
try:
|
||||
return json.loads(self.data_file.read_text("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {"settings": dict(DEFAULT_SETTINGS), "playlist": [], "url_media": []}
|
||||
|
||||
def _save(self):
|
||||
self.data_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.data_file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), "utf-8")
|
||||
tmp.replace(self.data_file)
|
||||
|
||||
# ---------- 设置 ----------
|
||||
def get_settings(self):
|
||||
with self._lock:
|
||||
s = dict(DEFAULT_SETTINGS)
|
||||
s.update(self._data.get("settings", {}))
|
||||
return s
|
||||
|
||||
def update_settings(self, **kw):
|
||||
with self._lock:
|
||||
s = dict(DEFAULT_SETTINGS)
|
||||
s.update(self._data.get("settings", {}))
|
||||
for k, v in kw.items():
|
||||
if v is not None and k in s:
|
||||
s[k] = v
|
||||
self._data["settings"] = s
|
||||
self._save()
|
||||
|
||||
# ---------- 播放列表 ----------
|
||||
def get_playlist(self):
|
||||
with self._lock:
|
||||
return list(self._data.get("playlist", []))
|
||||
|
||||
def add_to_playlist(self, path):
|
||||
with self._lock:
|
||||
items = self._data.setdefault("playlist", [])
|
||||
if not any(it.get("path") == path for it in items):
|
||||
items.append({"path": path, "source": None, "name": None})
|
||||
self._save()
|
||||
|
||||
def remove_from_playlist(self, path):
|
||||
with self._lock:
|
||||
self._data.setdefault("playlist", [])
|
||||
self._data["playlist"] = [it for it in self._data["playlist"] if it.get("path") != path]
|
||||
self._save()
|
||||
|
||||
# ---------- URL 媒体 ----------
|
||||
def get_url_media(self):
|
||||
with self._lock:
|
||||
return list(self._data.get("url_media", []))
|
||||
|
||||
def add_url_media(self, url, name, media_type):
|
||||
"""返回 True 表示新增,False 表示已存在(重复)"""
|
||||
with self._lock:
|
||||
items = self._data.setdefault("url_media", [])
|
||||
if any(it.get("url") == url for it in items):
|
||||
return False
|
||||
items.append({
|
||||
"url": url, "name": name, "type": media_type,
|
||||
"added_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
})
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def delete_media(self, path):
|
||||
with self._lock:
|
||||
self._data.setdefault("url_media", [])
|
||||
self._data["url_media"] = [it for it in self._data["url_media"] if it.get("url") != path]
|
||||
self._data.setdefault("playlist", [])
|
||||
self._data["playlist"] = [it for it in self._data["playlist"] if it.get("path") != path]
|
||||
self._save()
|
||||
|
||||
|
||||
storage = Storage(settings.DATA_FILE)
|
||||
Reference in New Issue
Block a user