1
0

4 Commitit a1faf294e3 ... 966c124462

Tekijä SHA1 Viesti Päivämäärä
  liulinag 966c124462 Fix GPS hazard warning flow 2 kuukautta sitten
  liulinag 564874d2a5 1 2 kuukautta sitten
  liulinag bde88ee550 plus 2 kuukautta sitten
  liulinag 728cc0036b 预警 2 kuukautta sitten
28 muutettua tiedostoa jossa 1848 lisäystä ja 416 poistoa
  1. 80 0
      deployments/single-server-api/README.md
  2. 18 0
      deployments/single-server-api/docker-compose.yml
  3. BIN
      main/xiaozhi-server.tar.gz
  4. 12 0
      main/xiaozhi-server/.dockerignore
  5. 23 0
      main/xiaozhi-server/Dockerfile
  6. 26 51
      main/xiaozhi-server/config.yaml
  7. 4 0
      main/xiaozhi-server/config/manage_api_client.py
  8. 186 78
      main/xiaozhi-server/core/handle/intentHandler.py
  9. 1 2
      main/xiaozhi-server/core/handle/receiveAudioHandle.py
  10. 2 3
      main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
  11. 26 1
      main/xiaozhi-server/core/providers/asr/fun_local.py
  12. 77 2
      main/xiaozhi-server/core/providers/llm/openai/openai.py
  13. 14 0
      main/xiaozhi-server/core/providers/tools/device_mcp/mcp_client.py
  14. 7 0
      main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py
  15. 39 0
      main/xiaozhi-server/core/providers/tts/base.py
  16. 62 27
      main/xiaozhi-server/core/utils/current_time.py
  17. 3 3
      main/xiaozhi-server/core/utils/dialogue.py
  18. 5 0
      main/xiaozhi-server/core/utils/prompt_manager.py
  19. 3 1
      main/xiaozhi-server/core/websocket_server.py
  20. 30 0
      main/xiaozhi-server/deploy/tencent/car_ai_override.yaml
  21. 188 0
      main/xiaozhi-server/deploy/tencent/deploy_tencent.py
  22. 16 0
      main/xiaozhi-server/docker-compose.tencent.yml
  23. 3 3
      main/xiaozhi-server/docker-compose.yml
  24. 14 14
      main/xiaozhi-server/docker-compose_all.yml
  25. 166 49
      main/xiaozhi-server/plugins_func/functions/nav_copilot.py
  26. 686 163
      main/xiaozhi-server/plugins_func/nav_copilot/service.py
  27. 157 19
      main/xiaozhi-server/plugins_func/nav_copilot/storage.py
  28. BIN
      xiaozhi-server.tar.gz

+ 80 - 0
deployments/single-server-api/README.md

@@ -0,0 +1,80 @@
+## 目标
+
+在腾讯云(Ubuntu 22.04 x86_64)上仅部署 `xiaozhi-esp32-server` 的 **Server** 模块:
+
+- ASR:百度(API)
+- LLM:DeepSeek(API)
+- TTS:EdgeTTS(无需 key)
+- 无域名:直接使用 `ws://公网IP:8000` 和 `http://公网IP:8003`
+
+> 注意:不要把任何真实 `API Key/Secret` 提交到公网仓库;也不要把 key 发到聊天里。
+
+---
+
+## 1) 服务器侧安全组/防火墙
+
+放行端口:
+
+- `8000/tcp`(WebSocket)
+- `8003/tcp`(HTTP:OTA/视觉接口)
+
+强烈建议:先只允许你自己的公网出口 IP(而不是全网开放)。
+
+---
+
+## 2) 拷贝文件到服务器
+
+把本目录下这两个文件上传到服务器,例如:
+
+- `/opt/xiaozhi-server/docker-compose.yml`
+- `/opt/xiaozhi-server/data/.config.yaml`
+
+目录结构应为:
+
+```
+/opt/xiaozhi-server
+  docker-compose.yml
+  data/
+    .config.yaml
+```
+
+---
+
+## 3) 修改配置
+
+编辑 `/opt/xiaozhi-server/data/.config.yaml`:
+
+- 把 `PUBLIC_IP` 改成你的公网 IP
+- 填上百度 ASR、DeepSeek 的密钥
+
+---
+
+## 4) 启动/查看日志
+
+在服务器执行:
+
+```bash
+cd /opt/xiaozhi-server
+docker compose up -d
+docker logs -f xiaozhi-esp32-server
+```
+
+---
+
+## 5) ESP32 需要填的地址
+
+- WebSocket:`ws://PUBLIC_IP:8000/xiaozhi/v1/`
+- OTA:`http://PUBLIC_IP:8003/xiaozhi/ota/`
+
+---
+
+## 6) 排查命令(把输出贴出来即可)
+
+```bash
+docker ps
+docker logs --tail 120 xiaozhi-esp32-server
+ss -lntp | grep -E '(:8000|:8003)'
+curl -sS -D- http://127.0.0.1:8000/ | head
+curl -sS -D- http://127.0.0.1:8003/xiaozhi/ota/ | head
+```
+

+ 18 - 0
deployments/single-server-api/docker-compose.yml

@@ -0,0 +1,18 @@
+# 仅部署 Server(API 版:不挂载本地 ASR 模型)
+
+version: "3"
+services:
+  xiaozhi-esp32-server:
+    image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
+    container_name: xiaozhi-esp32-server
+    restart: always
+    security_opt:
+      - seccomp:unconfined
+    environment:
+      - TZ=Asia/Shanghai
+    ports:
+      - "8000:8000"
+      - "8003:8003"
+    volumes:
+      - ./data:/opt/xiaozhi-esp32-server/data
+

BIN
main/xiaozhi-server.tar.gz


+ 12 - 0
main/xiaozhi-server/.dockerignore

@@ -0,0 +1,12 @@
+.git
+.gitignore
+.venv
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+tmp
+node_modules
+data/.config.yaml
+data/bin
+data/*.sqlite3

+ 23 - 0
main/xiaozhi-server/Dockerfile

@@ -0,0 +1,23 @@
+FROM python:3.11-slim-bookworm
+
+ENV TZ=Asia/Shanghai \
+    PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+RUN sed -i 's#deb.debian.org#mirrors.aliyun.com#g; s#security.debian.org#mirrors.aliyun.com#g' /etc/apt/sources.list.d/debian.sources \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt /app/requirements.txt
+RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ \
+ && pip install -U pip \
+ && pip install -r /app/requirements.txt
+
+COPY . /app
+
+EXPOSE 8000 8003
+
+CMD ["python", "app.py"]

+ 26 - 51
main/xiaozhi-server/config.yaml

@@ -24,7 +24,7 @@ server:
   # 当你使用docker部署或使用公网部署(使用ssl、域名)时,不一定准确
   # 所以如果你使用docker部署时,将vision_explain设置成局域网地址
   # 如果你使用公网部署时,将vision_explain设置成公网地址
-  vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
+  vision_explain: ""
   # OTA返回信息时区偏移量
   timezone_offset: +8
   # 认证配置
@@ -105,17 +105,8 @@ module_test:
 
 # 唤醒词,用于识别唤醒词还是讲话内容
 wakeup_words:
-  - "你好星星"
-  - "嘿你好呀"
-  - "你好小志"
-  - "小爱同学"
-  - "你好小鑫"
-  - "你好小新"
-  - "小美同学"
-  - "小龙小龙"
-  - "喵喵同学"
-  - "小滨小滨"
-  - "小冰小冰"
+  - "爱驰尔"
+  - "你好爱驰尔"
 # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
 # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
 mcp_endpoint: 你的接入点 websocket地址
@@ -172,41 +163,23 @@ plugins:
     # ragflow知识库id
     dataset_ids: ["123456789"]
 # 声纹识别配置
-voiceprint:
-  # 声纹接口地址
-  url: 
-  # 说话人配置:speaker_id,名称,描述
-  speakers:
-    - "test1,张三,张三是一个程序员"
-    - "test2,李四,李四是一个产品经理"
-    - "test3,王五,王五是一个设计师"
-  # 声纹识别相似度阈值,范围0.0-1.0,默认0.4
-  # 数值越高越严格,减少误识别但可能增加拒识率
-  similarity_threshold: 0.4
+voiceprint: {}
 
 # #####################################################################################
 # ################################以下是角色模型配置######################################
 
 prompt: |
-  你是xingxing/星星,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
-  [核心特征]
-  - 讲话像连珠炮,但会突然冒出超温柔语气
-  - 用梗密度高
-  - 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
-  [交互指南]
-  当用户:
-  - 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
-  - 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
-  - 问专业知识 → 先用梗回答,被追问才展示真实理解
-  绝不:
-  - 长篇大论,叽叽歪歪
-  - 长时间严肃对话
+  你是爱驰尔,运行在 xingxing-server 上的车载 AI 模块。
+  你的职责是语音问答、路线录制与风险点预警、天气查询、音乐播放、Home Assistant 设备控制、知识库检索和车辆相关信息播报。
+  所有回复都只说一句话,优先直接给结论,简短、自然、可靠,不说多余寒暄。
+  涉及行车、路线、故障码、氢系统时,先保证安全,再给出明确建议。
 
 # 默认系统提示词模板文件
 prompt_template: agent-base-prompt.txt
+llm_one_sentence_only: true
 
 # 系统错误时的回复
-system_error_response: "主人,xingxing现在有点忙,我们稍后再试吧。"
+system_error_response: "爱驰尔当前处理超时,请再说一次。"
 
 # 结束语prompt
 end_prompt:
@@ -223,12 +196,10 @@ selected_module:
   ASR: FunASR
   # 将根据配置名称对应的type调用实际的LLM适配器
   LLM: ChatGLMLLM
-  # 视觉语言大模型
-  VLLM: ChatGLMVLLM
   # TTS将根据配置名称对应的type调用实际的TTS适配器
   TTS: EdgeTTS
   # 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short
-  Memory: nomem
+  Memory: mem_local_short
   # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
   # 不想开通意图识别,就设置成:nointent
   # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
@@ -254,8 +225,18 @@ Intent:
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     functions:
       - get_weather
-      - get_news_from_newsnow
+      - search_from_ragflow
+      - nav_start_record
+      - nav_stop_record
+      - nav_mark_hazard
+      - nav_start_run
+      - nav_stop_run
+      - nav_list_routes
+      - nav_set_active_route
       - play_music
+      - hass_get_state
+      - hass_set_state
+      - hass_play_music
   function_call:
     # 不需要动type
     type: function_call
@@ -263,26 +244,20 @@ Intent:
     # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     functions:
-      - change_role
       - get_weather
-      # - search_from_ragflow
-      # - get_news_from_chinanews
-      - get_news_from_newsnow
+      - search_from_ragflow
       # 越野/赛道领航播报(路线录制 + 风险点预警)
       - nav_start_record
       - nav_stop_record
       - nav_mark_hazard
       - nav_start_run
       - nav_stop_run
-      - nav_status
       - nav_list_routes
       - nav_set_active_route
-      # play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
-      # 如果用了hass_play_music,就不要开启play_music,两者只留一个
       - play_music
-      #- hass_get_state
-      #- hass_set_state
-      #- hass_play_music
+      - hass_get_state
+      - hass_set_state
+      - hass_play_music
 
 Memory:
   mem0ai:

+ 4 - 0
main/xiaozhi-server/config/manage_api_client.py

@@ -185,6 +185,8 @@ async def get_agent_models(
 
 async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
     """生成并保存聊天记录总结"""
+    if not ManageApiClient._instance:
+        return None
     try:
         return await ManageApiClient._instance._execute_async_request(
             "POST",
@@ -197,6 +199,8 @@ async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
 
 async def generate_and_save_chat_title(session_id: str) -> Optional[Dict]:
     """生成并保存聊天标题"""
+    if not ManageApiClient._instance:
+        return None
     try:
         return await ManageApiClient._instance._execute_async_request(
             "POST",

+ 186 - 78
main/xiaozhi-server/core/handle/intentHandler.py

@@ -17,6 +17,41 @@ from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
 TAG = __name__
 
 
+STRICT_HAZARD_MARK_PHRASES = {
+    "标记急转弯": "sharp_turn",
+    "标记起转弯": "sharp_turn",
+    "标记急弯": "sharp_turn",
+    "标记左转弯": "left_turn",
+    "标记右转弯": "right_turn",
+    "标记障碍": "obstacle",
+    "标记颠簸": "bump",
+    "标记大坑": "bump",
+    "标记坑": "bump",
+    "标记跳台": "jump",
+    "标记陡坡": "slope",
+    "标记涉水": "water",
+    "标记水坑": "water",
+}
+
+NAV_ALERT_ECHO_PHRASES = {
+    "前方左转弯",
+    "前方右转弯",
+    "准备急转弯",
+    "前方颠簸",
+    "前方水坑",
+    "前方障碍",
+    "前方跳台",
+    "前方陡坡",
+    "前方风险点",
+    "注意左转弯",
+    "注意右转弯",
+    "注意急转弯",
+    "注意颠簸",
+    "注意水坑",
+    "注意障碍",
+}
+
+
 async def handle_user_intent(conn: "ConnectionHandler", text):
     # 预处理输入文本,处理可能的JSON格式
     try:
@@ -37,6 +72,23 @@ async def handle_user_intent(conn: "ConnectionHandler", text):
     if await checkWakeupWords(conn, filtered_text):
         return True
 
+    if should_ignore_nav_alert_echo(filtered_text):
+        conn.logger.bind(tag=TAG).info(f"忽略导航播报回采文本: {filtered_text}")
+        return True
+
+    direct_function_call = match_direct_nav_command(filtered_text)
+    if direct_function_call is not None:
+        function_name, function_args = direct_function_call
+        conn.logger.bind(tag=TAG).info(
+            f"识别到直达导航命令: {filtered_text} -> {function_name}"
+        )
+        return await dispatch_function_call(
+            conn,
+            text,
+            function_name,
+            function_args,
+        )
+
     if conn.intent_type == "function_call":
         # 使用支持function calling的聊天方法,不再进行意图分析
         return False
@@ -80,6 +132,131 @@ async def analyze_intent_with_llm(conn: "ConnectionHandler", text):
     return None
 
 
+def match_direct_nav_command(filtered_text: str):
+    text = (filtered_text or "").strip()
+    if not text:
+        return None
+
+    if text in {
+        "开始录制路线",
+        "开始记录路线",
+        "录制路线",
+        "开始记录",
+        "开始记路线",
+    }:
+        return "nav_start_record", {}
+
+    if text in {
+        "结束录制",
+        "停止录制",
+        "结束记录",
+        "停止记录",
+        "录制结束",
+    }:
+        return "nav_stop_record", {}
+
+    if text in {
+        "开始预警",
+        "开始播报",
+        "开始领航",
+        "开始导航",
+    }:
+        return "nav_start_run", {}
+
+    if text in {
+        "停止预警",
+        "结束预警",
+        "停止播报",
+        "关闭预警",
+    }:
+        return "nav_stop_run", {}
+
+    if text.startswith("标记"):
+        if text in STRICT_HAZARD_MARK_PHRASES:
+            return "nav_mark_hazard", {"hazard_type": STRICT_HAZARD_MARK_PHRASES[text]}
+        return "nav_mark_hazard_reject", {}
+
+    return None
+
+
+def should_ignore_nav_alert_echo(filtered_text: str) -> bool:
+    text = (filtered_text or "").strip()
+    if not text:
+        return False
+    return text in NAV_ALERT_ECHO_PHRASES
+
+
+async def dispatch_function_call(
+    conn: "ConnectionHandler",
+    original_text: str,
+    function_name: str,
+    function_args: dict | None = None,
+):
+    conn.sentence_id = str(uuid.uuid4().hex)
+    function_args = function_args or {}
+    function_args_json = json.dumps(function_args, ensure_ascii=False)
+    function_call_data = {
+        "name": function_name,
+        "id": str(uuid.uuid4().hex),
+        "arguments": function_args_json,
+    }
+
+    await send_stt_message(conn, original_text)
+    conn.client_abort = False
+    enqueue_tool_report(conn, function_name, function_args)
+
+    def process_function_call():
+        conn.dialogue.put(Message(role="user", content=original_text))
+
+        tool_call_timeout = int(conn.config.get("tool_call_timeout", 30))
+        try:
+            result = asyncio.run_coroutine_threadsafe(
+                conn.func_handler.handle_llm_function_call(conn, function_call_data),
+                conn.loop,
+            ).result(timeout=tool_call_timeout)
+        except Exception as e:
+            conn.logger.bind(tag=TAG).error(f"工具调用失败: {e}")
+            result = ActionResponse(
+                action=Action.ERROR,
+                result="工具调用超时,请一会再试下哈",
+                response="工具调用超时,请一会再试下哈",
+            )
+
+        if result:
+            enqueue_tool_report(
+                conn,
+                function_name,
+                function_args,
+                str(result.result) if result.result else None,
+                report_tool_call=False,
+            )
+
+            if result.action == Action.RESPONSE:
+                text = result.response
+                if text is not None:
+                    speak_txt(conn, text)
+            elif result.action == Action.REQLLM:
+                text = result.result
+                conn.dialogue.put(Message(role="tool", content=text))
+                llm_result = conn.intent.replyResult(text, original_text)
+                if llm_result is None:
+                    llm_result = text
+                speak_txt(conn, llm_result)
+            elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
+                text = result.response if result.response else result.result
+                if text is not None:
+                    speak_txt(conn, text)
+            elif function_name != "play_music":
+                text = result.response
+                if text is None:
+                    text = result.result
+                if text is not None:
+                    speak_txt(conn, text)
+
+    conn.executor.submit(process_function_call)
+    return True
+
+
 async def process_intent_result(
     conn: "ConnectionHandler", intent_result, original_text
 ):
@@ -129,84 +306,15 @@ async def process_intent_result(
                 function_args = intent_data["function_call"]["arguments"]
                 if function_args is None:
                     function_args = {}
-            # 确保参数是字符串格式的JSON
-            if isinstance(function_args, dict):
-                function_args = json.dumps(function_args)
-
-            function_call_data = {
-                "name": function_name,
-                "id": str(uuid.uuid4().hex),
-                "arguments": function_args,
-            }
-
-            await send_stt_message(conn, original_text)
-            conn.client_abort = False
-
-            # 准备工具调用参数
-            tool_input = {}
-            if function_args:
-                if isinstance(function_args, str):
-                    tool_input = json.loads(function_args) if function_args else {}
-                elif isinstance(function_args, dict):
-                    tool_input = function_args
-
-            # 上报工具调用
-            enqueue_tool_report(conn, function_name, tool_input)
-
-            # 使用executor执行函数调用和结果处理
-            def process_function_call():
-                conn.dialogue.put(Message(role="user", content=original_text))
-                
-                # 工具调用超时时间
-                tool_call_timeout = int(conn.config.get("tool_call_timeout", 30))
-                # 使用统一工具处理器处理所有工具调用
-                try:
-                    result = asyncio.run_coroutine_threadsafe(
-                        conn.func_handler.handle_llm_function_call(
-                            conn, function_call_data
-                        ),
-                        conn.loop,
-                    ).result(timeout=tool_call_timeout)
-                except Exception as e:
-                    conn.logger.bind(tag=TAG).error(f"工具调用失败: {e}")
-                    result = ActionResponse(
-                        action=Action.ERROR, result="工具调用超时,请一会再试下哈", response="工具调用超时,请一会再试下哈"
-                    )
-
-                # 上报工具调用结果
-                if result:
-                    enqueue_tool_report(conn, function_name, tool_input, str(result.result) if result.result else None, report_tool_call=False)
-
-                    if result.action == Action.RESPONSE:  # 直接回复前端
-                        text = result.response
-                        if text is not None:
-                            speak_txt(conn, text)
-                    elif result.action == Action.REQLLM:  # 调用函数后再请求llm生成回复
-                        text = result.result
-                        conn.dialogue.put(Message(role="tool", content=text))
-                        llm_result = conn.intent.replyResult(text, original_text)
-                        if llm_result is None:
-                            llm_result = text
-                        speak_txt(conn, llm_result)
-                    elif (
-                        result.action == Action.NOTFOUND
-                        or result.action == Action.ERROR
-                    ):
-                        text = result.response if result.response else result.result
-                        if text is not None:
-                            speak_txt(conn, text)
-                    elif function_name != "play_music":
-                        # For backward compatibility with original code
-                        # 获取当前最新的文本索引
-                        text = result.response
-                        if text is None:
-                            text = result.result
-                        if text is not None:
-                            speak_txt(conn, text)
-
-            # 将函数执行放在线程池中
-            conn.executor.submit(process_function_call)
-            return True
+            if isinstance(function_args, str):
+                function_args = json.loads(function_args) if function_args else {}
+
+            return await dispatch_function_call(
+                conn,
+                original_text,
+                function_name,
+                function_args if isinstance(function_args, dict) else {},
+            )
         return False
     except json.JSONDecodeError as e:
         conn.logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")

+ 1 - 2
main/xiaozhi-server/core/handle/receiveAudioHandle.py

@@ -23,7 +23,6 @@ async def handleAudioMessage(conn: "ConnectionHandler", audio):
         # 设置一个短暂延迟后恢复VAD检测
         if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
             conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
-        return
     # 设备长时间空闲检测,用于say goodbye
     await no_voice_close_connect(conn, have_voice)
     # 接收音频
@@ -32,7 +31,7 @@ async def handleAudioMessage(conn: "ConnectionHandler", audio):
 
 async def resume_vad_detection(conn: "ConnectionHandler"):
     # 等待2秒后恢复VAD检测
-    await asyncio.sleep(2)
+    await asyncio.sleep(0.35)
     conn.just_woken_up = False
 
 

+ 2 - 3
main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py

@@ -61,8 +61,7 @@ class ListenTextMessageHandler(TextMessageHandler):
 
                 if is_wakeup_words and not enable_greeting:
                     # 如果是唤醒词,且关闭了唤醒词回复,就不用回答
-                    await send_stt_message(conn, original_text)
-                    await send_tts_message(conn, "stop", None)
+                    conn.just_woken_up = True
                     conn.client_is_speaking = False
                 elif is_wakeup_words:
                     conn.just_woken_up = True
@@ -74,4 +73,4 @@ class ListenTextMessageHandler(TextMessageHandler):
                     # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
                     enqueue_asr_report(conn, original_text, [])
                     # 否则需要LLM对文字内容进行答复
-                    await startToChat(conn, original_text)
+                    await startToChat(conn, original_text)

+ 26 - 1
main/xiaozhi-server/core/providers/asr/fun_local.py

@@ -7,6 +7,7 @@ import psutil
 import asyncio
 
 from funasr import AutoModel
+from funasr.utils.postprocess_utils import rich_transcription_postprocess
 from config.logger import setup_logging
 from typing import Optional, Tuple, List
 from core.providers.asr.utils import lang_tag_filter
@@ -63,6 +64,20 @@ class ASRProvider(ASRProviderBase):
                 # device="cuda:0",  # 启用GPU加速
             )
 
+    @staticmethod
+    def _extract_result_text(result) -> str:
+        if isinstance(result, str):
+            return result
+        if isinstance(result, dict):
+            for key in ("text", "sentence", "result"):
+                value = result.get(key)
+                if isinstance(value, str):
+                    return value
+            return ""
+        if isinstance(result, list) and result:
+            return ASRProvider._extract_result_text(result[0])
+        return ""
+
     async def speech_to_text(
         self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
     ) -> Tuple[Optional[str], Optional[str]]:
@@ -84,7 +99,17 @@ class ASRProvider(ASRProviderBase):
                     use_itn=True,
                     batch_size_s=60,
                 )
-                text = lang_tag_filter(result[0]["text"])
+                raw_text = self._extract_result_text(result)
+                if not raw_text:
+                    logger.bind(tag=TAG).warning(
+                        f"FunASR未返回可识别文本,原始结果类型: {type(result).__name__}"
+                    )
+                    return "", artifacts.file_path
+
+                raw_text = rich_transcription_postprocess(raw_text)
+                text = lang_tag_filter(raw_text)
+                if not isinstance(text, dict):
+                    text = {"content": text}
                 logger.bind(tag=TAG).debug(
                     f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text['content']}"
                 )

+ 77 - 2
main/xiaozhi-server/core/providers/llm/openai/openai.py

@@ -1,5 +1,6 @@
 import httpx
 import openai
+import time
 from openai.types import CompletionUsage
 from config.logger import setup_logging
 from core.utils.util import check_model_key
@@ -17,6 +18,8 @@ THINKING_DISABLED_DOMAINS = {
     "volces.com": {"thinking": {"type": "disabled"}},
 }
 
+RETRYABLE_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504}
+
 
 class LLMProvider(LLMProviderBase):
     def __init__(self, config):
@@ -61,6 +64,20 @@ class LLMProvider(LLMProviderBase):
             except (ValueError, TypeError):
                 setattr(self, param, None)
 
+        try:
+            self.stream_retry_attempts = max(
+                0, int(config.get("stream_retry_attempts", 1))
+            )
+        except (ValueError, TypeError):
+            self.stream_retry_attempts = 1
+
+        try:
+            self.stream_retry_backoff = max(
+                0.0, float(config.get("stream_retry_backoff", 0.8))
+            )
+        except (ValueError, TypeError):
+            self.stream_retry_backoff = 0.8
+
         logger.debug(
             f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}"
         )
@@ -88,6 +105,64 @@ class LLMProvider(LLMProviderBase):
                 logger.bind(tag=TAG).info(f"为域名 {domain} 禁用思考模式,参数: {params}")
                 break
 
+    @staticmethod
+    def _extract_status_code(exc):
+        status_code = getattr(exc, "status_code", None)
+        if isinstance(status_code, int):
+            return status_code
+
+        response = getattr(exc, "response", None)
+        if response is not None:
+            response_status = getattr(response, "status_code", None)
+            if isinstance(response_status, int):
+                return response_status
+
+        return None
+
+    def _is_retryable_error(self, exc):
+        status_code = self._extract_status_code(exc)
+        if status_code in RETRYABLE_STATUS_CODES:
+            return True
+
+        error_text = str(exc).lower()
+        retryable_keywords = (
+            "timeout",
+            "timed out",
+            "temporarily unavailable",
+            "server error",
+            "connection error",
+            "connection reset",
+            "service unavailable",
+            "bad gateway",
+            "gateway timeout",
+        )
+        return any(keyword in error_text for keyword in retryable_keywords)
+
+    def _create_stream_with_retry(self, request_params):
+        attempt = 0
+        delay = self.stream_retry_backoff
+
+        while True:
+            try:
+                return self.client.chat.completions.create(**request_params)
+            except Exception as e:
+                should_retry = (
+                    attempt < self.stream_retry_attempts
+                    and self._is_retryable_error(e)
+                )
+                if not should_retry:
+                    raise
+
+                attempt += 1
+                status_code = self._extract_status_code(e)
+                logger.bind(tag=TAG).warning(
+                    f"LLM首包前请求失败,准备重试 {attempt}/{self.stream_retry_attempts},"
+                    f"status={status_code}, error={e}"
+                )
+                if delay > 0:
+                    time.sleep(delay)
+                    delay *= 2
+
     def response(self, session_id, dialogue, **kwargs):
         dialogue = self.normalize_dialogue(dialogue)
 
@@ -112,7 +187,7 @@ class LLMProvider(LLMProviderBase):
         # 禁用思考模式
         self._apply_thinking_disabled(request_params)
 
-        responses = self.client.chat.completions.create(**request_params)
+        responses = self._create_stream_with_retry(request_params)
 
         is_active = True
         try:            
@@ -158,7 +233,7 @@ class LLMProvider(LLMProviderBase):
         # 禁用思考模式
         self._apply_thinking_disabled(request_params)
 
-        stream = self.client.chat.completions.create(**request_params)
+        stream = self._create_stream_with_retry(request_params)
 
         try:
             for chunk in stream:

+ 14 - 0
main/xiaozhi-server/core/providers/tools/device_mcp/mcp_client.py

@@ -8,6 +8,16 @@ from config.logger import setup_logging
 TAG = __name__
 logger = setup_logging()
 
+TOOL_DESCRIPTION_OVERRIDES = {
+    "self_gnss_get_fix": (
+        "Get the device's live GNSS/GPS fix, including whether a fix exists, "
+        "latitude, longitude, speed, and heading. "
+        "Use this tool only when the user explicitly asks for GPS, GNSS, current location, "
+        "coordinates, position, or satellite-fix status. "
+        "Do not use it for weather, temperature, rainfall, time, date, news, or general chat."
+    )
+}
+
 
 class MCPClient:
     """设备端MCP客户端,用于管理MCP状态和工具"""
@@ -57,6 +67,10 @@ class MCPClient:
     async def add_tool(self, tool_data: dict):
         async with self.lock:
             sanitized_name = sanitize_tool_name(tool_data["name"])
+            override_description = TOOL_DESCRIPTION_OVERRIDES.get(sanitized_name)
+            if override_description:
+                tool_data = dict(tool_data)
+                tool_data["description"] = override_description
             self.tools[sanitized_name] = tool_data
             self.name_mapping[sanitized_name] = tool_data["name"]
             self._cached_available_tools = (

+ 7 - 0
main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py

@@ -230,6 +230,13 @@ async def handle_mcp_message(
                 if hasattr(conn, "func_handler") and conn.func_handler:
                     conn.func_handler.tool_manager.refresh_tools()
                     conn.func_handler.current_support_functions()
+                if hasattr(conn, "nav_copilot_service") and conn.nav_copilot_service:
+                    try:
+                        await conn.nav_copilot_service.recover_monitoring_if_needed()
+                    except Exception as exc:
+                        logger.bind(tag=TAG).warning(
+                            f"Failed to recover nav monitoring after MCP ready: {exc}"
+                        )
             return
 
     elif "method" in payload:

+ 39 - 0
main/xiaozhi-server/core/providers/tts/base.py

@@ -74,6 +74,7 @@ class TTSProviderBase(ABC):
         self.tts_stop_request = False
         self.processed_chars = 0
         self.is_first_sentence = True
+        self.one_sentence_emitted = False
 
     def generate_filename(self, extension=".wav"):
         return os.path.join(
@@ -333,6 +334,7 @@ class TTSProviderBase(ABC):
                     self.tts_text_buff = []
                     self.is_first_sentence = True
                     self.tts_audio_first_sentence = True
+                    self.one_sentence_emitted = False
                 elif ContentType.TEXT == message.content_type:
                     self.tts_text_buff.append(message.content_detail)
                     segment_text = self._get_segment_text()
@@ -435,6 +437,27 @@ class TTSProviderBase(ABC):
         # 合并当前全部文本并处理未分割部分
         full_text = "".join(self.tts_text_buff)
         current_text = full_text[self.processed_chars :]  # 从未处理的位置开始
+        one_sentence_only = bool(
+            getattr(self.conn, "config", {}).get("llm_one_sentence_only", False)
+        )
+        if one_sentence_only:
+            if self.one_sentence_emitted:
+                return None
+            sentence_punctuations = ("。", "?", "?", "!", "!", ";", ";", "\n")
+            first_end_pos = -1
+            for punct in sentence_punctuations:
+                pos = current_text.find(punct)
+                if pos != -1 and (first_end_pos == -1 or pos < first_end_pos):
+                    first_end_pos = pos
+            if first_end_pos != -1:
+                segment_text_raw = current_text[: first_end_pos + 1]
+                segment_text = textUtils.get_string_no_punctuation_or_emoji(
+                    segment_text_raw
+                )
+                self.processed_chars += len(segment_text_raw)
+                self.one_sentence_emitted = True
+                return segment_text
+            return None
         last_punct_pos = -1
 
         # 根据是否是第一句话选择不同的标点符号集合
@@ -510,11 +533,27 @@ class TTSProviderBase(ABC):
         """
         full_text = "".join(self.tts_text_buff)
         remaining_text = full_text[self.processed_chars :]
+        one_sentence_only = bool(
+            getattr(self.conn, "config", {}).get("llm_one_sentence_only", False)
+        )
+        if one_sentence_only:
+            if self.one_sentence_emitted:
+                return False
+            sentence_punctuations = ("。", "?", "?", "!", "!", ";", ";", "\n")
+            first_end_pos = -1
+            for punct in sentence_punctuations:
+                pos = remaining_text.find(punct)
+                if pos != -1 and (first_end_pos == -1 or pos < first_end_pos):
+                    first_end_pos = pos
+            if first_end_pos != -1:
+                remaining_text = remaining_text[: first_end_pos + 1]
         if remaining_text:
             segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
             if segment_text:
                 self.to_tts_stream(segment_text, opus_handler=opus_handler)
                 self.processed_chars += len(full_text)
+                if one_sentence_only:
+                    self.one_sentence_emitted = True
                 return True
         return False
 

+ 62 - 27
main/xiaozhi-server/core/utils/current_time.py

@@ -1,14 +1,17 @@
 """
-时间工具模块
-提供统一的时间获取功能
+Time helpers used by prompt/context generation.
+
+All user-facing time values are derived from server.timezone_offset so they do
+not depend on the container's local timezone setting.
 """
 
-import cnlunar
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
+
+DEFAULT_TIMEZONE_OFFSET = "+8"
 
 WEEKDAY_MAP = {
     "Monday": "星期一",
-    "Tuesday": "星期二", 
+    "Tuesday": "星期二",
     "Wednesday": "星期三",
     "Thursday": "星期四",
     "Friday": "星期五",
@@ -17,34 +20,71 @@ WEEKDAY_MAP = {
 }
 
 
+def _parse_timezone_offset(offset_value) -> timezone:
+    if isinstance(offset_value, (int, float)):
+        return timezone(timedelta(minutes=int(round(float(offset_value) * 60))))
+
+    if isinstance(offset_value, str):
+        offset_text = offset_value.strip()
+        if not offset_text:
+            offset_text = DEFAULT_TIMEZONE_OFFSET
+
+        sign = 1
+        if offset_text[0] in "+-":
+            sign = -1 if offset_text[0] == "-" else 1
+            offset_text = offset_text[1:]
+
+        if ":" in offset_text:
+            hour_text, minute_text = offset_text.split(":", 1)
+            hours = int(hour_text or "0")
+            minutes = int(minute_text or "0")
+            total_minutes = sign * (hours * 60 + minutes)
+            return timezone(timedelta(minutes=total_minutes))
+
+        if "." in offset_text:
+            total_minutes = int(round(float(offset_text) * 60))
+            return timezone(timedelta(minutes=sign * total_minutes))
+
+        hours = int(offset_text or "0")
+        return timezone(timedelta(hours=sign * hours))
+
+    return timezone(timedelta(hours=8))
+
+
+def _get_configured_timezone() -> timezone:
+    try:
+        from config.config_loader import load_config
+
+        config = load_config()
+        offset_value = config.get("server", {}).get(
+            "timezone_offset", DEFAULT_TIMEZONE_OFFSET
+        )
+        return _parse_timezone_offset(offset_value)
+    except Exception:
+        return timezone(timedelta(hours=8))
+
+
+def get_now() -> datetime:
+    return datetime.now(timezone.utc).astimezone(_get_configured_timezone())
+
+
 def get_current_time() -> str:
-    """
-    获取当前时间字符串 (格式: HH:MM)
-    """
-    return datetime.now().strftime("%H:%M")
+    return get_now().strftime("%H:%M")
 
 
 def get_current_date() -> str:
-    """
-    获取今天日期字符串 (格式: YYYY-MM-DD)
-    """
-    return datetime.now().strftime("%Y-%m-%d")
+    return get_now().strftime("%Y-%m-%d")
 
 
 def get_current_weekday() -> str:
-    """
-    获取今天星期几
-    """
-    now = datetime.now()
-    return WEEKDAY_MAP[now.strftime("%A")]
+    return WEEKDAY_MAP[get_now().strftime("%A")]
 
 
 def get_current_lunar_date() -> str:
-    """
-    获取农历日期字符串
-    """
     try:
-        now = datetime.now()
+        import cnlunar
+
+        now = get_now().replace(tzinfo=None)
         today_lunar = cnlunar.Lunar(now, godType="8char")
         return "%s年%s%s" % (
             today_lunar.lunarYearCn,
@@ -56,13 +96,8 @@ def get_current_lunar_date() -> str:
 
 
 def get_current_time_info() -> tuple:
-    """
-    获取当前时间信息
-    返回: (当前时间字符串, 今天日期, 今天星期, 农历日期)
-    """
     current_time = get_current_time()
     today_date = get_current_date()
     today_weekday = get_current_weekday()
     lunar_date = get_current_lunar_date()
-    
     return current_time, today_date, today_weekday, lunar_date

+ 3 - 3
main/xiaozhi-server/core/utils/dialogue.py

@@ -1,7 +1,7 @@
 import uuid
 import re
 from typing import List, Dict
-from datetime import datetime
+from core.utils.current_time import get_current_time, get_now
 
 
 class Message:
@@ -26,7 +26,7 @@ class Dialogue:
     def __init__(self):
         self.dialogue: List[Message] = []
         # 获取当前时间
-        self.current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        self.current_time = get_now().strftime("%Y-%m-%d %H:%M:%S")
 
     def put(self, message: Message):
         self.dialogue.append(message)
@@ -139,7 +139,7 @@ class Dialogue:
             enhanced_system_prompt = system_message.content
             # 替换时间占位符
             enhanced_system_prompt = enhanced_system_prompt.replace(
-                "{{current_time}}", datetime.now().strftime("%H:%M")
+                "{{current_time}}", get_current_time()
             )
 
             # 添加说话人个性化描述

+ 5 - 0
main/xiaozhi-server/core/utils/prompt_manager.py

@@ -164,6 +164,11 @@ class PromptManager:
     def _get_weather_info(self, conn: "ConnectionHandler", location: str) -> str:
         """获取天气信息"""
         try:
+            weather_config = conn.config.get("plugins", {}).get("get_weather", {})
+            if not weather_config or weather_config.get("enabled", True) is False:
+                self.logger.bind(tag=TAG).debug("Weather plugin disabled, skip weather context")
+                return ""
+
             # 先从缓存获取
             cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location)
             if cached_weather is not None:

+ 3 - 1
main/xiaozhi-server/core/websocket_server.py

@@ -145,7 +145,9 @@ class WebSocketServer:
 
     async def _http_response(self, websocket, request_headers):
         # 检查是否为 WebSocket 升级请求
-        if request_headers.headers.get("connection", "").lower() == "upgrade":
+        connection_header = request_headers.headers.get("connection", "").lower()
+        upgrade_header = request_headers.headers.get("upgrade", "").lower()
+        if "upgrade" in connection_header and "websocket" in upgrade_header:
             # 如果是 WebSocket 请求,返回 None 允许握手继续
             return None
         else:

+ 30 - 0
main/xiaozhi-server/deploy/tencent/car_ai_override.yaml

@@ -0,0 +1,30 @@
+# 车载 AI 的行为覆盖片段。
+# 注意:
+# 1. 这里只放非敏感配置。
+# 2. 远端部署时会与现有 data/.config.yaml 合并,保留 API Key 等私密配置。
+
+wakeup_words:
+  - "爱驰尔"
+  - "你好爱驰尔"
+  - "嗨爱驰尔"
+  - "艾驰尔"
+
+prompt: |
+  你是爱驰尔,一个部署在车内的 AI 语音模块,也是用户驾驶过程中的智能副驾。
+  你的职责是用简洁、可靠、自然的语气,为用户提供导航说明、车辆信息解读、路况提醒、基础问答和出行陪伴。
+  你必须始终保持车载助手身份,不扮演可爱人设,不使用网络流行梗,不撒娇,不阴阳怪气。
+  你的回复必须像真实车机语音一样克制、清楚、直接。
+  [回复规则]
+  - 每次只回复一句话。
+  - 每次回复尽量控制在30个字以内,非必要不要超过40个字。
+  - 优先直接回答结论,不寒暄,不铺垫,不重复用户问题。
+  - 涉及驾驶、道路、安全时,优先给出安全提醒。
+  - 遇到不确定的信息,直接明确说明,不猜测,不编造。
+  [禁止事项]
+  - 禁止一次回复多句话。
+  - 禁止长篇解释。
+  - 禁止使用夸张语气词、卖萌语气和聊天陪聊腔。
+
+llm_one_sentence_only: true
+
+system_error_response: "当前服务暂不可用,请稍后再试。"

+ 188 - 0
main/xiaozhi-server/deploy/tencent/deploy_tencent.py

@@ -0,0 +1,188 @@
+import argparse
+import getpass
+import os
+import shlex
+import sys
+import tarfile
+import tempfile
+import time
+from pathlib import Path
+
+import paramiko
+
+
+EXCLUDED_DIRS = {
+    ".git",
+    ".venv",
+    "__pycache__",
+    "node_modules",
+    "tmp",
+}
+
+EXCLUDED_FILES = {
+    "data/.config.yaml",
+}
+
+EXCLUDED_PREFIXES = (
+    "data/bin/",
+)
+
+EXCLUDED_SUFFIXES = (
+    ".pyc",
+    ".pyo",
+    ".pyd",
+)
+
+
+def should_exclude(rel_path: str) -> bool:
+    normalized = rel_path.replace("\\", "/").strip("./")
+    if not normalized:
+        return False
+    if normalized in EXCLUDED_FILES:
+        return True
+    if normalized.endswith(EXCLUDED_SUFFIXES):
+        return True
+    for prefix in EXCLUDED_PREFIXES:
+        if normalized.startswith(prefix):
+            return True
+    parts = normalized.split("/")
+    if any(part in EXCLUDED_DIRS for part in parts):
+        return True
+    if normalized.startswith("data/") and normalized.endswith(".sqlite3"):
+        return True
+    return False
+
+
+def create_archive(source_dir: Path) -> str:
+    temp_file = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
+    temp_file.close()
+    archive_path = temp_file.name
+    with tarfile.open(archive_path, "w:gz") as tar:
+        for path in source_dir.rglob("*"):
+            if not path.is_file():
+                continue
+            rel_path = path.relative_to(source_dir).as_posix()
+            if should_exclude(rel_path):
+                continue
+            tar.add(path, arcname=rel_path)
+    return archive_path
+
+
+def run_remote_command(client: paramiko.SSHClient, command: str) -> None:
+    stdin, stdout, stderr = client.exec_command(command, get_pty=True)
+    exit_status = stdout.channel.recv_exit_status()
+    out = stdout.read().decode("utf-8", "ignore")
+    err = stderr.read().decode("utf-8", "ignore")
+
+    def _safe_write(stream, text: str) -> None:
+        if not text:
+            return
+        if not text.endswith("\n"):
+            text += "\n"
+        encoding = getattr(stream, "encoding", None) or "utf-8"
+        stream.buffer.write(text.encode(encoding, errors="replace"))
+        stream.flush()
+
+    if out:
+        _safe_write(sys.stdout, out)
+    if err:
+        _safe_write(sys.stderr, err)
+    if exit_status != 0:
+        raise RuntimeError(f"Remote command failed with exit code {exit_status}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Deploy xingxing-server to Tencent Cloud over SSH.")
+    parser.add_argument("--host", required=True, help="Remote host or IP.")
+    parser.add_argument("--user", required=True, help="SSH username.")
+    parser.add_argument("--password", help="SSH password. If omitted, prompt securely.")
+    parser.add_argument(
+        "--remote-dir",
+        default="/home/ubuntu/xingxing-server",
+        help="Remote project directory.",
+    )
+    parser.add_argument(
+        "--source-dir",
+        default=str(Path(__file__).resolve().parents[2]),
+        help="Local xingxing-server source directory.",
+    )
+    parser.add_argument(
+        "--compose-file",
+        default="docker-compose.tencent.yml",
+        help="Compose file to use on the remote host.",
+    )
+    args = parser.parse_args()
+
+    source_dir = Path(args.source_dir).resolve()
+    if not source_dir.exists():
+        raise FileNotFoundError(f"Source directory does not exist: {source_dir}")
+
+    password = args.password or getpass.getpass("SSH password: ")
+    archive_path = create_archive(source_dir)
+    timestamp = time.strftime("%Y%m%d%H%M%S")
+    remote_archive = f"/tmp/xingxing-server-{timestamp}.tar.gz"
+    remote_stage = f"{args.remote_dir}.stage.{timestamp}"
+    remote_backup = f"{args.remote_dir}.bak.{timestamp}"
+
+    client = paramiko.SSHClient()
+    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+    try:
+        print(f"Connecting to {args.user}@{args.host} ...")
+        client.connect(
+            hostname=args.host,
+            username=args.user,
+            password=password,
+            timeout=20,
+            banner_timeout=20,
+            auth_timeout=20,
+        )
+
+        print(f"Uploading archive to {remote_archive} ...")
+        sftp = client.open_sftp()
+        sftp.put(archive_path, remote_archive)
+        sftp.close()
+
+        remote_dir_q = shlex.quote(args.remote_dir)
+        remote_stage_q = shlex.quote(remote_stage)
+        remote_backup_q = shlex.quote(remote_backup)
+        remote_archive_q = shlex.quote(remote_archive)
+        compose_file_q = shlex.quote(args.compose_file)
+        sudo_password_q = shlex.quote(password)
+
+        command = f"""
+set -e
+REMOTE_DIR={remote_dir_q}
+STAGE_DIR={remote_stage_q}
+BACKUP_DIR={remote_backup_q}
+ARCHIVE={remote_archive_q}
+SUDO_PASSWORD={sudo_password_q}
+rm -rf "$STAGE_DIR"
+mkdir -p "$STAGE_DIR"
+tar -xzf "$ARCHIVE" -C "$STAGE_DIR"
+if [ -d "$REMOTE_DIR/data" ]; then
+  mkdir -p "$STAGE_DIR/data"
+  cp -a "$REMOTE_DIR/data/." "$STAGE_DIR/data/"
+fi
+if [ -d "$REMOTE_DIR" ]; then
+  rm -rf "$BACKUP_DIR"
+  mv "$REMOTE_DIR" "$BACKUP_DIR"
+fi
+mv "$STAGE_DIR" "$REMOTE_DIR"
+rm -f "$ARCHIVE"
+cd "$REMOTE_DIR"
+printf '%s\\n' "$SUDO_PASSWORD" | sudo -S -p '' docker compose -f {compose_file_q} up -d --build
+printf '%s\\n' "$SUDO_PASSWORD" | sudo -S -p '' docker compose -f {compose_file_q} ps
+"""
+        print("Rebuilding and restarting remote service ...")
+        run_remote_command(client, command)
+        print(f"Deploy completed. Backup kept at {remote_backup}")
+        return 0
+    finally:
+        client.close()
+        if os.path.exists(archive_path):
+            os.remove(archive_path)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 16 - 0
main/xiaozhi-server/docker-compose.tencent.yml

@@ -0,0 +1,16 @@
+services:
+  xingxing-server:
+    build: .
+    container_name: xingxing-server
+    restart: always
+    environment:
+      - TZ=Asia/Shanghai
+      - LANG=C.UTF-8
+      - LC_ALL=C.UTF-8
+      - PYTHONIOENCODING=UTF-8
+    ports:
+      - "8000:8000"
+      - "8003:8003"
+    volumes:
+      - ./data:/app/data
+      - ./tmp:/app/tmp

+ 3 - 3
main/xiaozhi-server/docker-compose.yml

@@ -2,9 +2,9 @@
 
 version: '3'
 services:
-  xiaozhi-esp32-server:
+  xingxing-server:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
-    container_name: xiaozhi-esp32-server
+    container_name: xingxing-server
     restart: always
     security_opt:
       - seccomp:unconfined
@@ -19,4 +19,4 @@ services:
       # 配置文件目录
       - ./data:/opt/xiaozhi-esp32-server/data
       # 模型文件挂接,很重要
-      - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
+      - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt

+ 14 - 14
main/xiaozhi-server/docker-compose_all.yml

@@ -3,12 +3,12 @@
 version: '3'
 services:
   # Server模块
-  xiaozhi-esp32-server:
+  xingxing-server:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
-    container_name: xiaozhi-esp32-server
+    container_name: xingxing-server
     depends_on:
-      - xiaozhi-esp32-server-db
-      - xiaozhi-esp32-server-redis
+      - xingxing-server-db
+      - xingxing-server-redis
     restart: always
     networks:
       - default
@@ -28,35 +28,35 @@ services:
       - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
 
   # manager-api和manager-web模块
-  xiaozhi-esp32-server-web:
+  xingxing-server-web:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
-    container_name: xiaozhi-esp32-server-web
+    container_name: xingxing-server-web
     restart: always
     networks:
       - default
     depends_on:
-      xiaozhi-esp32-server-db:
+      xingxing-server-db:
         condition: service_healthy
-      xiaozhi-esp32-server-redis:
+      xingxing-server-redis:
         condition: service_healthy
     ports:
       # 智控台
       - "8002:8002"
     environment:
       - TZ=Asia/Shanghai
-      - SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xiaozhi-esp32-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
+      - SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xingxing-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
       - SPRING_DATASOURCE_DRUID_USERNAME=root
       - SPRING_DATASOURCE_DRUID_PASSWORD=123456
-      - SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis
+      - SPRING_DATA_REDIS_HOST=xingxing-server-redis
       - SPRING_DATA_REDIS_PASSWORD=
       - SPRING_DATA_REDIS_PORT=6379
     volumes:
       # 配置文件目录
       - ./uploadfile:/uploadfile
   # 数据库模块
-  xiaozhi-esp32-server-db:
+  xingxing-server-db:
     image: mysql:latest
-    container_name: xiaozhi-esp32-server-db
+    container_name: xingxing-server-db
     healthcheck:
       test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
       timeout: 45s
@@ -75,11 +75,11 @@ services:
       - MYSQL_DATABASE=xiaozhi_esp32_server
       - MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
   # redis模块
-  xiaozhi-esp32-server-redis:
+  xingxing-server-redis:
     image: redis:8.0
     expose:
       - 6379
-    container_name: xiaozhi-esp32-server-redis
+    container_name: xingxing-server-redis
     restart: always
     healthcheck:
       test: ["CMD", "redis-cli", "ping"]

+ 166 - 49
main/xiaozhi-server/plugins_func/functions/nav_copilot.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from plugins_func.register import register_function, ToolType, ActionResponse, Action
+from plugins_func.register import Action, ActionResponse, ToolType, register_function
 
 from plugins_func.nav_copilot.service import get_service
 
@@ -10,18 +10,25 @@ nav_start_record_desc = {
     "function": {
         "name": "nav_start_record",
         "description": (
-            "开始录制越野路线(教练/带跑模式)。"
-            "录制期间服务器会持续读取车载端GPS(GNSS)位置并保存为轨迹。"
-            "你可以随时再调用 nav_mark_hazard 来标记障碍点/风险点。"
-            "当用户说“开始录制路线/开始录路线/带我跑一遍”等,优先调用本工具。"
+            "开始记录路线。GPS 模式下,标记风险点会保存当前 GPS 位置;"
+            "结束录制后会进入预警状态。"
+            "当用户说“开始记录路线”“开始录制路线”“开始记录”时,优先调用这个工具。"
         ),
         "parameters": {
             "type": "object",
             "properties": {
-                "route_name": {"type": "string", "description": "路线名称(可选)"},
+                "route_name": {
+                    "type": "string",
+                    "description": "路线名称,可选",
+                },
                 "sample_hz": {
                     "type": "integer",
-                    "description": "采样频率Hz(默认1,建议1~5)",
+                    "description": "采样频率,默认 10Hz",
+                },
+                "mode": {
+                    "type": "string",
+                    "description": "路线模式。通常使用 gps",
+                    "enum": ["auto", "counter", "gps"],
                 },
             },
             "required": [],
@@ -31,18 +38,37 @@ nav_start_record_desc = {
 
 
 @register_function("nav_start_record", nav_start_record_desc, ToolType.SYSTEM_CTL)
-async def nav_start_record(conn, route_name: str | None = None, sample_hz: int = 1):
+async def nav_start_record(
+    conn,
+    route_name: str | None = None,
+    sample_hz: int = 10,
+    mode: str = "gps",
+):
     svc = get_service(conn)
-    rid = await svc.start_recording(route_name=route_name, sample_hz=sample_hz)
-    text = f"已开始录制路线,路线ID是{rid}。你可以说“标记急转弯/标记大坑/标记障碍”来添加风险点。"
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response=text)
+    route_id = await svc.start_recording(
+        route_name=route_name,
+        sample_hz=sample_hz,
+        mode=mode,
+    )
+    route_mode = svc.record_mode or "auto"
+    if route_mode == "counter":
+        text = (
+            f"已经进入计数路线录制准备状态,路线 ID 是 {route_id}。"
+            "到风险点时直接说“标记急转弯”或“标记障碍”,说“结束录制”后会进入预警。"
+        )
+    else:
+        text = (
+            f"已经开始记录 GPS 路线,路线 ID 是 {route_id}。"
+            "到风险点时可以直接说“标记水坑”“标记急转弯”或“标记障碍”。"
+        )
+    return ActionResponse(action=Action.RESPONSE, result=str(route_id), response=text)
 
 
 nav_stop_record_desc = {
     "type": "function",
     "function": {
         "name": "nav_stop_record",
-        "description": "结束录制越野路线,并保存路线轨迹。",
+        "description": "结束当前路线记录并保存。如果已经标记过风险点,则自动进入预警状态。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -51,11 +77,32 @@ nav_stop_record_desc = {
 @register_function("nav_stop_record", nav_stop_record_desc, ToolType.SYSTEM_CTL)
 async def nav_stop_record(conn):
     svc = get_service(conn)
-    rid = await svc.stop_recording()
-    if rid is None:
-        return ActionResponse(action=Action.RESPONSE, response="当前没有在录制路线。")
-    text = f"路线录制已结束,路线ID是{rid}。你可以说“开始领航”进行预警播报。"
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response=text)
+    result = await svc.stop_recording()
+    if result is None:
+        return ActionResponse(action=Action.RESPONSE, response="当前没有正在记录的路线。")
+
+    if result.monitor_started:
+        if result.mode == "counter":
+            response = (
+                f"路线录制已结束,路线 ID 是 {result.route_id}。"
+                f"已记录 {result.hazard_count} 个风险点,并且已经开始监听预警。"
+            )
+        else:
+            response = (
+                f"路线记录已结束,路线 ID 是 {result.route_id}。"
+                f"已记录 {result.hazard_count} 个风险点,并且已经进入预警状态。"
+            )
+    else:
+        response = (
+            f"路线记录已结束,路线 ID 是 {result.route_id}。"
+            "但你还没有标记风险点,所以还没有进入预警状态。"
+        )
+
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(result.route_id),
+        response=response,
+    )
 
 
 nav_mark_hazard_desc = {
@@ -63,9 +110,9 @@ nav_mark_hazard_desc = {
     "function": {
         "name": "nav_mark_hazard",
         "description": (
-            "在当前位置标记一个障碍点/风险点(例如急转弯、大坑、跳台、陡坡、涉水、障碍)。"
-            "用于“带着AI跑一遍”时把赛道风险点记录下来,之后领航时自动提前播报。"
-            "当用户说“标记急转弯/标记大坑/标记跳台/标记障碍点”等,优先调用本工具。"
+            "在当前位置标记一个风险点。"
+            "GPS 模式下会保存当前 GPS 数据,后续进入该风险点范围内时触发预警。"
+            "典型口令包括“标记水坑”“标记急转弯”“标记障碍”。"
         ),
         "parameters": {
             "type": "object",
@@ -83,7 +130,10 @@ nav_mark_hazard_desc = {
                         "other",
                     ],
                 },
-                "note": {"type": "string", "description": "补充说明(可选)"},
+                "note": {
+                    "type": "string",
+                    "description": "补充说明,可选",
+                },
             },
             "required": ["hazard_type"],
         },
@@ -94,9 +144,36 @@ nav_mark_hazard_desc = {
 @register_function("nav_mark_hazard", nav_mark_hazard_desc, ToolType.SYSTEM_CTL)
 async def nav_mark_hazard(conn, hazard_type: str, note: str | None = None):
     svc = get_service(conn)
-    hid = await svc.mark_hazard(hazard_type=hazard_type, note=note or "")
-    text = "已标记风险点。"
-    return ActionResponse(action=Action.RESPONSE, result=str(hid), response=text)
+    result = await svc.mark_hazard(hazard_type=hazard_type, note=note or "")
+    if result.mode == "counter" and result.counter_value is not None:
+        response = f"已标记风险点,当前串口值是 {result.counter_value}。"
+    elif result.mode == "gps" and result.lat is not None and result.lon is not None:
+        response = f"已标记风险点,GPS 数据是 {result.lat:.6f},{result.lon:.6f}。"
+    else:
+        response = "已标记风险点。"
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(result.hazard_id),
+        response=response,
+    )
+
+
+nav_mark_hazard_reject_desc = {
+    "type": "function",
+    "function": {
+        "name": "nav_mark_hazard_reject",
+        "description": "当用户只说标记风险点但没有说具体类型时,提示其重新说清楚。",
+        "parameters": {"type": "object", "properties": {}, "required": []},
+    },
+}
+
+
+@register_function("nav_mark_hazard_reject", nav_mark_hazard_reject_desc, ToolType.SYSTEM_CTL)
+async def nav_mark_hazard_reject(conn):
+    return ActionResponse(
+        action=Action.RESPONSE,
+        response="请直接说要标记的风险类型,例如:标记水坑、标记急转弯、标记障碍。",
+    )
 
 
 nav_start_run_desc = {
@@ -104,17 +181,24 @@ nav_start_run_desc = {
     "function": {
         "name": "nav_start_run",
         "description": (
-            "开始领航播报(预警模式)。服务器会持续读取GPS定位,匹配到已录制路线,"
-            "在接近前方障碍点时自动语音播报提示。"
-            "当用户说“开始领航/开始预警/开始播报”等,优先调用本工具。"
+            "开始预警播报。通常结束录制后会自动进入预警;"
+            "这个工具用于手动补启。"
         ),
         "parameters": {
             "type": "object",
             "properties": {
-                "route_id": {"type": "integer", "description": "指定路线ID(可选)"},
+                "route_id": {
+                    "type": "integer",
+                    "description": "指定路线 ID,可选",
+                },
                 "tick_hz": {
                     "type": "integer",
-                    "description": "领航刷新频率Hz(默认1,建议1~10;频率越高越实时,但更耗资源)",
+                    "description": "刷新频率,默认 10Hz",
+                },
+                "mode": {
+                    "type": "string",
+                    "description": "路线模式,通常不需要手动指定",
+                    "enum": ["auto", "counter", "gps"],
                 },
             },
             "required": [],
@@ -124,18 +208,30 @@ nav_start_run_desc = {
 
 
 @register_function("nav_start_run", nav_start_run_desc, ToolType.SYSTEM_CTL)
-async def nav_start_run(conn, route_id: int | None = None, tick_hz: int = 5):
+async def nav_start_run(
+    conn,
+    route_id: int | None = None,
+    tick_hz: int = 10,
+    mode: str = "auto",
+):
     svc = get_service(conn)
-    rid = await svc.start_run(route_id=route_id, tick_hz=tick_hz)
-    text = f"已开始领航,使用路线ID={rid}。接近风险点时我会自动提醒。"
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response=text)
+    active_route_id = await svc.start_run(route_id=route_id, tick_hz=tick_hz, mode=mode)
+    if svc.run_mode == "counter":
+        response = f"已经开始监听预警,使用路线 ID={active_route_id}。"
+    else:
+        response = f"已经开始预警,使用路线 ID={active_route_id}。"
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(active_route_id),
+        response=response,
+    )
 
 
 nav_stop_run_desc = {
     "type": "function",
     "function": {
         "name": "nav_stop_run",
-        "description": "停止领航播报(预警模式)。",
+        "description": "停止当前预警播报。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -144,17 +240,21 @@ nav_stop_run_desc = {
 @register_function("nav_stop_run", nav_stop_run_desc, ToolType.SYSTEM_CTL)
 async def nav_stop_run(conn):
     svc = get_service(conn)
-    rid = await svc.stop_run()
-    if rid is None:
-        return ActionResponse(action=Action.RESPONSE, response="当前没有在领航。")
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response="领航已停止。")
+    route_id = await svc.stop_run()
+    if route_id is None:
+        return ActionResponse(action=Action.RESPONSE, response="当前没有正在运行的预警。")
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(route_id),
+        response="预警已停止。",
+    )
 
 
 nav_status_desc = {
     "type": "function",
     "function": {
         "name": "nav_status",
-        "description": "查询路线录制/领航播报的当前状态。",
+        "description": "查询路线记录和预警状态。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -170,10 +270,15 @@ nav_list_routes_desc = {
     "type": "function",
     "function": {
         "name": "nav_list_routes",
-        "description": "列出最近保存的路线(含点数和障碍点数量)。",
+        "description": "列出最近保存的路线。",
         "parameters": {
             "type": "object",
-            "properties": {"limit": {"type": "integer", "description": "数量(默认10)"}},
+            "properties": {
+                "limit": {
+                    "type": "integer",
+                    "description": "返回数量,默认 10",
+                }
+            },
             "required": [],
         },
     },
@@ -185,22 +290,31 @@ async def nav_list_routes(conn, limit: int = 10):
     svc = get_service(conn)
     routes = svc.db.list_routes(limit=int(limit or 10))
     if not routes:
-        return ActionResponse(action=Action.RESPONSE, response="暂无路线。先说“开始录制路线”。")
+        return ActionResponse(action=Action.RESPONSE, response="暂无路线,请先开始记录路线。")
+
     lines = []
-    for r in routes:
-        active = "(激活)" if r.active else ""
-        lines.append(f"路线{r.id}{active}:{r.name},点{r.points},风险点{r.hazards}")
-    return ActionResponse(action=Action.RESPONSE, response=";".join(lines))
+    for route in routes:
+        active = "(激活)" if route.active else ""
+        monitoring = "(预警中)" if route.monitoring else ""
+        lines.append(
+            f"路线 {route.id}{active}{monitoring}:{route.name},模式 {route.mode},点数 {route.points},风险点 {route.hazards}"
+        )
+    return ActionResponse(action=Action.RESPONSE, response="\n".join(lines))
 
 
 nav_set_active_route_desc = {
     "type": "function",
     "function": {
         "name": "nav_set_active_route",
-        "description": "设置某条路线为当前激活路线(领航默认使用激活路线)。",
+        "description": "将某条路线设置为当前激活路线。",
         "parameters": {
             "type": "object",
-            "properties": {"route_id": {"type": "integer", "description": "路线ID"}},
+            "properties": {
+                "route_id": {
+                    "type": "integer",
+                    "description": "路线 ID",
+                }
+            },
             "required": ["route_id"],
         },
     },
@@ -211,4 +325,7 @@ nav_set_active_route_desc = {
 async def nav_set_active_route(conn, route_id: int):
     svc = get_service(conn)
     svc.db.set_active_route(int(route_id))
-    return ActionResponse(action=Action.RESPONSE, response=f"已激活路线ID={int(route_id)}。")
+    return ActionResponse(
+        action=Action.RESPONSE,
+        response=f"已激活路线 ID={int(route_id)}。",
+    )

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 686 - 163
main/xiaozhi-server/plugins_func/nav_copilot/service.py


+ 157 - 19
main/xiaozhi-server/plugins_func/nav_copilot/storage.py

@@ -6,18 +6,20 @@ import threading
 import time
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Dict, List, Optional, Tuple
+from typing import List, Optional
 
 
 @dataclass(frozen=True)
 class RouteInfo:
     id: int
     name: str
+    mode: str
     created_at: int
     finished_at: Optional[int]
     points: int
     hazards: int
     active: bool
+    monitoring: bool
 
 
 @dataclass(frozen=True)
@@ -28,6 +30,8 @@ class RoutePoint:
     lon: float
     speed_mps: float
     cum_dist_m: float
+    counter_value: int = 0
+    counter_absolute: int = 0
 
 
 @dataclass(frozen=True)
@@ -41,6 +45,8 @@ class Hazard:
     cum_dist_m: float
     type: str
     note: str
+    counter_value: int = 0
+    counter_absolute: int = 0
 
 
 class NavDB:
@@ -54,7 +60,6 @@ class NavDB:
 
     @classmethod
     def default(cls) -> "NavDB":
-        # .../main/xiaozhi-server/plugins_func/nav_copilot/storage.py -> parents[2] == .../main/xiaozhi-server
         base = Path(__file__).resolve().parents[2]
         return cls(str(base / "data" / "nav_copilot.sqlite3"))
 
@@ -66,9 +71,11 @@ class NavDB:
                 CREATE TABLE IF NOT EXISTS routes (
                     id INTEGER PRIMARY KEY AUTOINCREMENT,
                     name TEXT NOT NULL,
+                    mode TEXT NOT NULL DEFAULT 'gps',
                     created_at INTEGER NOT NULL,
                     finished_at INTEGER,
-                    active INTEGER NOT NULL DEFAULT 0
+                    active INTEGER NOT NULL DEFAULT 0,
+                    monitoring INTEGER NOT NULL DEFAULT 0
                 )
                 """
             )
@@ -82,6 +89,8 @@ class NavDB:
                     lon REAL NOT NULL,
                     speed_mps REAL NOT NULL,
                     cum_dist_m REAL NOT NULL,
+                    counter_value INTEGER NOT NULL DEFAULT 0,
+                    counter_absolute INTEGER NOT NULL DEFAULT 0,
                     PRIMARY KEY(route_id, seq)
                 )
                 """
@@ -97,19 +106,70 @@ class NavDB:
                     lon REAL NOT NULL,
                     cum_dist_m REAL NOT NULL,
                     type TEXT NOT NULL,
-                    note TEXT NOT NULL DEFAULT ''
+                    note TEXT NOT NULL DEFAULT '',
+                    counter_value INTEGER NOT NULL DEFAULT 0,
+                    counter_absolute INTEGER NOT NULL DEFAULT 0
                 )
                 """
             )
+
+            self._ensure_column(cur, "routes", "mode", "TEXT NOT NULL DEFAULT 'gps'")
+            self._ensure_column(
+                cur,
+                "routes",
+                "monitoring",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "route_points",
+                "counter_value",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "route_points",
+                "counter_absolute",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "hazards",
+                "counter_value",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "hazards",
+                "counter_absolute",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
             self._conn.commit()
 
-    def create_route(self, name: str) -> int:
+    def _ensure_column(
+        self,
+        cur: sqlite3.Cursor,
+        table_name: str,
+        column_name: str,
+        column_ddl: str,
+    ) -> None:
+        columns = {
+            str(row["name"])
+            for row in cur.execute(f"PRAGMA table_info({table_name})").fetchall()
+        }
+        if column_name not in columns:
+            cur.execute(
+                f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
+            )
+
+    def create_route(self, name: str, mode: str = "gps") -> int:
         now = int(time.time())
+        route_mode = (mode or "gps").strip() or "gps"
         with self._lock:
-            self._conn.execute("UPDATE routes SET active=0")
+            self._conn.execute("UPDATE routes SET active=0, monitoring=0")
             cur = self._conn.execute(
-                "INSERT INTO routes(name, created_at, active) VALUES(?, ?, 1)",
-                (name, now),
+                "INSERT INTO routes(name, mode, created_at, active, monitoring) VALUES(?, ?, ?, 1, 0)",
+                (name, route_mode, now),
             )
             self._conn.commit()
             return int(cur.lastrowid)
@@ -136,6 +196,49 @@ class NavDB:
             ).fetchone()
             return int(row["id"]) if row else None
 
+    def set_monitoring_route(self, route_id: Optional[int]) -> None:
+        with self._lock:
+            self._conn.execute("UPDATE routes SET monitoring=0")
+            if route_id is not None:
+                self._conn.execute(
+                    "UPDATE routes SET monitoring=1 WHERE id=?",
+                    (int(route_id),),
+                )
+            self._conn.commit()
+
+    def get_monitoring_route_id(self) -> Optional[int]:
+        with self._lock:
+            row = self._conn.execute(
+                "SELECT id FROM routes WHERE monitoring=1 ORDER BY id DESC LIMIT 1"
+            ).fetchone()
+            return int(row["id"]) if row else None
+
+    # Backward-compatible aliases for older call sites that used sanitized names.
+    def setmonitoringroute(self, route_id: Optional[int]) -> None:
+        self.set_monitoring_route(route_id)
+
+    def getmonitoringrouteid(self) -> Optional[int]:
+        return self.get_monitoring_route_id()
+
+    def get_route_mode(self, route_id: int) -> str:
+        with self._lock:
+            row = self._conn.execute(
+                "SELECT mode FROM routes WHERE id=?",
+                (route_id,),
+            ).fetchone()
+        if not row:
+            return "gps"
+        return str(row["mode"] or "gps")
+
+    def update_route_mode(self, route_id: int, mode: str) -> None:
+        route_mode = (mode or "gps").strip() or "gps"
+        with self._lock:
+            self._conn.execute(
+                "UPDATE routes SET mode=? WHERE id=?",
+                (route_mode, route_id),
+            )
+            self._conn.commit()
+
     def add_point(
         self,
         *,
@@ -146,14 +249,28 @@ class NavDB:
         lon: float,
         speed_mps: float,
         cum_dist_m: float,
+        counter_value: int = 0,
+        counter_absolute: int = 0,
     ) -> None:
         with self._lock:
             self._conn.execute(
                 """
-                INSERT OR REPLACE INTO route_points(route_id, seq, t, lat, lon, speed_mps, cum_dist_m)
-                VALUES(?, ?, ?, ?, ?, ?, ?)
+                INSERT OR REPLACE INTO route_points(
+                    route_id, seq, t, lat, lon, speed_mps, cum_dist_m, counter_value, counter_absolute
+                )
+                VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
                 """,
-                (route_id, seq, t, lat, lon, speed_mps, cum_dist_m),
+                (
+                    route_id,
+                    seq,
+                    t,
+                    lat,
+                    lon,
+                    speed_mps,
+                    cum_dist_m,
+                    int(counter_value or 0),
+                    int(counter_absolute or 0),
+                ),
             )
             self._conn.commit()
 
@@ -168,14 +285,29 @@ class NavDB:
         cum_dist_m: float,
         hazard_type: str,
         note: str = "",
+        counter_value: int = 0,
+        counter_absolute: int = 0,
     ) -> int:
         with self._lock:
             cur = self._conn.execute(
                 """
-                INSERT INTO hazards(route_id, seq, t, lat, lon, cum_dist_m, type, note)
-                VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+                INSERT INTO hazards(
+                    route_id, seq, t, lat, lon, cum_dist_m, type, note, counter_value, counter_absolute
+                )
+                VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                 """,
-                (route_id, seq, t, lat, lon, cum_dist_m, hazard_type, note or ""),
+                (
+                    route_id,
+                    seq,
+                    t,
+                    lat,
+                    lon,
+                    cum_dist_m,
+                    hazard_type,
+                    note or "",
+                    int(counter_value or 0),
+                    int(counter_absolute or 0),
+                ),
             )
             self._conn.commit()
             return int(cur.lastrowid)
@@ -185,7 +317,7 @@ class NavDB:
             rows = self._conn.execute(
                 """
                 SELECT
-                    r.id, r.name, r.created_at, r.finished_at, r.active,
+                    r.id, r.name, r.mode, r.created_at, r.finished_at, r.active, r.monitoring,
                     (SELECT COUNT(1) FROM route_points p WHERE p.route_id=r.id) AS points,
                     (SELECT COUNT(1) FROM hazards h WHERE h.route_id=r.id) AS hazards
                 FROM routes r
@@ -198,11 +330,13 @@ class NavDB:
             RouteInfo(
                 id=int(row["id"]),
                 name=str(row["name"]),
+                mode=str(row["mode"] or "gps"),
                 created_at=int(row["created_at"]),
                 finished_at=int(row["finished_at"]) if row["finished_at"] else None,
                 points=int(row["points"]),
                 hazards=int(row["hazards"]),
                 active=bool(int(row["active"])),
+                monitoring=bool(int(row["monitoring"] or 0)),
             )
             for row in rows
         ]
@@ -211,7 +345,7 @@ class NavDB:
         with self._lock:
             rows = self._conn.execute(
                 """
-                SELECT seq, t, lat, lon, speed_mps, cum_dist_m
+                SELECT seq, t, lat, lon, speed_mps, cum_dist_m, counter_value, counter_absolute
                 FROM route_points
                 WHERE route_id=?
                 ORDER BY seq ASC
@@ -226,6 +360,8 @@ class NavDB:
                 lon=float(row["lon"]),
                 speed_mps=float(row["speed_mps"]),
                 cum_dist_m=float(row["cum_dist_m"]),
+                counter_value=int(row["counter_value"] or 0),
+                counter_absolute=int(row["counter_absolute"] or 0),
             )
             for row in rows
         ]
@@ -234,10 +370,11 @@ class NavDB:
         with self._lock:
             rows = self._conn.execute(
                 """
-                SELECT id, route_id, seq, t, lat, lon, cum_dist_m, type, note
+                SELECT
+                    id, route_id, seq, t, lat, lon, cum_dist_m, type, note, counter_value, counter_absolute
                 FROM hazards
                 WHERE route_id=?
-                ORDER BY cum_dist_m ASC
+                ORDER BY cum_dist_m ASC, id ASC
                 """,
                 (route_id,),
             ).fetchall()
@@ -252,7 +389,8 @@ class NavDB:
                 cum_dist_m=float(row["cum_dist_m"]),
                 type=str(row["type"]),
                 note=str(row["note"] or ""),
+                counter_value=int(row["counter_value"] or 0),
+                counter_absolute=int(row["counter_absolute"] or 0),
             )
             for row in rows
         ]
-

BIN
xiaozhi-server.tar.gz