Compare commits

..
Author SHA1 Message Date
hrz d4343cf691 字段名里补上申请网址
两个字段名里写上和风天气控制台的地址,填表的人在界面上能直接照着点。文档末尾提到内部改动编号的两句改成大白话,读文档的人不用知道内部单号。
2026-10-24 07:52:24 +00:00
hrz 7a711564f1 写清楚为什么标 runOnChange
两条清理脚本在这个 PR 里改过内容,跑过旧版的库启动时靠 runOnChange 直接重跑新版。补一句说明在脚本头上,免得以后有人当成手滑删掉。
2026-10-24 07:28:06 +00:00
hrz 1df75f6882 文档写清哪些凭据会被清掉
补上清理的范围、没清掉的那一类情况和管理员自己改的办法,写明要等另外两个改动先落地。
2026-10-24 07:14:36 +00:00
hrz ba5e074bce 清理天气凭据时避开管理员自己填的值
之前那版脚本把当前默认值当成内置值,管理员改过的也会被清掉。现在先看这一行有没有被人编辑过,没动过才拿它的默认值当基准去清智能体参数,动过的一律不碰。内容改了会自动重跑,跑过旧版的库不会因为校验不一致起不来。
2026-10-24 07:14:36 +00:00
hrz bb392930b8 文档说明智控台那两栏为什么是空的
智控台这边插件参数的 API 密钥和 API Host 两栏默认是空的,数据库变更脚本已经把内置值清掉了,得照着本文件里的申请指南自己申请一套再填。之前只说默认是空的,没说清是谁清的。
2026-10-24 06:06:44 +00:00
hrz cdb4c34c4c 系统参数里的天气凭据也顺手置空
那两行后来已经被删掉了,新装的库里没有。这里再置一次空只是兜底,正常不影响任何一行。
2026-10-24 01:14:18 +00:00
hrz 36e34994ba 天气插件的凭据改成自己填,不再预置
模型提供方里内置的和风天气密钥和主机地址清空,字段名后面写清去哪儿申请。已有智能体参数里还等于内置值的那些也清掉,用户自己填的不动。

新增一条变更记录,没有改动以前那些,校验和不受影响。
2026-10-24 01:12:36 +00:00
25 changed files with 234 additions and 438 deletions

No files matched your search

+14
View File
@@ -46,6 +46,10 @@
8. 将复制过来的第二个关键配置`API Host`,填入到`开发者 API Host`里
9. 保存配置,再保存智能体配置
智控台这边插件参数里的「API 密钥」和「API Host」两栏默认是空的:仓库早期版本内置过一把已经失效的和风天气凭据,数据库变更脚本把它清掉了,得照着本文件的「API Key 申请指南」申请自己的一套再填。字段名后面括号里写了申请路径。
脚本只清仓库内置的那一把,你自己填的凭据原样保留。以后要换凭据,在智控台「角色配置 → 编辑功能 → 天气查询」的参数表单里改完保存就行。清理的范围见文末那节。
### 方式2. 如果你只是单模块xiaozhi-server部署
在 `data/.config.yaml` 中配置:
@@ -62,3 +66,13 @@ plugins:
default_location: "你的默认查询城市"
```
## 智控台那边内置的凭据清到了什么程度
早期版本把一把和风天气的 API Key 和一个 API Host 写在模型提供方「天气插件」那一行的字段默认值里,角色配置的表单拿这个默认值预填,不填就等于沿用仓库里那一份。那把 key 已经失效,仓库也不该带着凭据分发。数据库变更脚本做两件事:
- 把那一行的默认值清空,同时把申请路径写进字段名,用户在界面上能直接看到去哪儿申请
- 把已有智能体的参数里还等于那把内置 key 的项清空
清不掉的有两类。一是管理员自己填的值,脚本不动,误清就是误删人家的凭据。二是这一种:管理员在界面上改过那一行的默认值之后,仓库内置的那把失效 key 就被覆盖了,再也认不出来,早于管理员改动建立、参数里还存着那把旧内置 key 的智能体不会被自动清掉,只能由管理员自己在界面上改。
这两条清理跟着另外两个改动走:一个是插件在没有凭据时给出提示而不是报错,另一个是 CI 真的会跑测试,这条变更脚本才在 CI 里被验证过。两者都要先落地。
@@ -0,0 +1,132 @@
-- 天气插件不再内置和风天气凭据
--
-- 这条变更记录在 db.changelog-master.yaml 里标了 runOnChange:脚本内容在这个
-- PR 里改过好几轮,跑过旧版的库启动时直接重跑新版,不会报校验不一致。
-- 那不是手滑,别删掉这个标记
--
-- 早期版本把一把和风天气的 API Key 和一个 API Host 写在模型提供方
-- SYSTEM_PLUGIN_WEATHER 的字段默认值里。智控台的角色配置表单拿这个默认值预填,
-- 用户不填就等于沿用仓库里那一份。那把 key 已经失效,仓库里也不该带着凭据分发,
-- 用的人也不知道它从哪来、该不该用。
--
-- 这里清两处:
-- 1. 字段默认值置空,同时把申请路径写进字段名,用户在界面上就能看到去哪儿申请
-- 2. 已有智能体的参数里还等于内置值的那些,值清空。用户自己填的 key 不动
--
-- 关键前提:模型提供方那一行的「默认值」管理员能在界面上改,不能直接拿它当
-- 「内置值」的基准——那样会把管理员自己填的凭据清掉。判断这一行有没有被人动过,
-- 看两列:种子数据插进来的时候 updater 是 0、update_date 跟 create_date 一样;
-- 界面上保存过一次之后,updater 变成操作人、update_date 变成改的时间。
-- 只有从没被改过的那一行,它里面的默认值才是仓库内置的那一份,才拿来当基准。
-- 被改过的一律不动——那是管理员自己的值,误清就是误删人家的凭据。
--
-- 有一处清不干净,说明白:管理员动过这一行之后,仓库内置的那把失效 key 就再也
-- 认不出来了(它被管理员的值覆盖了),早期建立、参数里还存着那把失效 key 的智能体
-- 只能由管理员自己在界面上改。默认配置那边同理,只能整行不动。
--
-- 内置值从模型提供方那一行现读,不写死在脚本里,免得仓库里再留一份凭据
-- 基准行有没有被人编辑过
SET @weather_provider_untouched = (
SELECT COUNT(*)
FROM `ai_model_provider`
WHERE `id` = 'SYSTEM_PLUGIN_WEATHER'
AND `updater` = 0
AND `update_date` = `create_date`
);
-- 内置值。基准行被改过就没有内置值可比,取不到
SET @weather_api_key = (
SELECT jt.`field_default`
FROM `ai_model_provider` p,
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
`field_key` VARCHAR(64) PATH '$.key',
`field_default` VARCHAR(255) PATH '$.default'
)) jt
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_provider_untouched > 0
AND jt.`field_key` = 'api_key'
LIMIT 1
);
SET @weather_api_host = (
SELECT jt.`field_default`
FROM `ai_model_provider` p,
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
`field_key` VARCHAR(64) PATH '$.key',
`field_default` VARCHAR(255) PATH '$.default'
)) jt
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_provider_untouched > 0
AND jt.`field_key` = 'api_host'
LIMIT 1
);
-- 字段在数组里的位置。数组下标从 0 起,JSON_TABLE 的序号从 1 起,两者差一
SET @weather_api_key_pos = (
SELECT jt.`field_ord`
FROM `ai_model_provider` p,
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
`field_ord` FOR ORDINALITY,
`field_key` VARCHAR(64) PATH '$.key'
)) jt
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_provider_untouched > 0
AND jt.`field_key` = 'api_key'
LIMIT 1
);
SET @weather_api_host_pos = (
SELECT jt.`field_ord`
FROM `ai_model_provider` p,
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
`field_ord` FOR ORDINALITY,
`field_key` VARCHAR(64) PATH '$.key'
)) jt
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_provider_untouched > 0
AND jt.`field_key` = 'api_host'
LIMIT 1
);
-- 智能体参数里还等于内置值的项,清空。基准行被改过就取不到内置值,这时一律不动:
-- 管理员自己填的值不能碰,早于管理员编辑建立的智能体里存的旧内置值也认不出来了
UPDATE `ai_agent_plugin_mapping`
SET `param_info` = JSON_SET(`param_info`, '$.api_key', '')
WHERE `plugin_id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_api_key IS NOT NULL
AND @weather_api_key <> ''
AND JSON_UNQUOTE(JSON_EXTRACT(`param_info`, '$.api_key')) = @weather_api_key;
UPDATE `ai_agent_plugin_mapping`
SET `param_info` = JSON_SET(`param_info`, '$.api_host', '')
WHERE `plugin_id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_api_host IS NOT NULL
AND @weather_api_host <> ''
AND JSON_UNQUOTE(JSON_EXTRACT(`param_info`, '$.api_host')) = @weather_api_host;
-- 字段默认值置空,字段名里写清申请路径。默认值那一栏在界面上还带着提示,
-- 光把值清空没用,填表的人根本不知道该去哪儿拿
-- 两项各自一条更新,各守卫各的字段位置:管理员把其中一项的字段删了,
-- 另一项的默认值照样要清掉,不能因为少一个位置就整条都跳过
UPDATE `ai_model_provider`
SET `fields` = JSON_SET(
`fields`,
CONCAT('$[', @weather_api_key_pos - 1, '].default'), '',
CONCAT('$[', @weather_api_key_pos - 1, '].label'),
'天气插件 API 密钥(到和风天气控制台 https://console.qweather.com/project?lang=zh 申请,创建应用时勾选 GeoAPI 城市查询、实时天气、7 天天气预报三项权限;仓库里的 docs/weather-integration.md 有申请步骤)'
)
WHERE `id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_provider_untouched > 0
AND @weather_api_key_pos IS NOT NULL;
UPDATE `ai_model_provider`
SET `fields` = JSON_SET(
`fields`,
CONCAT('$[', @weather_api_host_pos - 1, '].default'), '',
CONCAT('$[', @weather_api_host_pos - 1, '].label'),
'开发者 API Host(和风天气控制台「设置」https://console.qweather.com/setting?lang=zh 里查看,跟 API 密钥配套使用;仓库里的 docs/weather-integration.md 有申请步骤)'
)
WHERE `id` = 'SYSTEM_PLUGIN_WEATHER'
AND @weather_provider_untouched > 0
AND @weather_api_host_pos IS NOT NULL;
@@ -0,0 +1,15 @@
-- 兜底:早期版本把天气插件的凭据放在系统参数里,那两条记录后来被 202505292203.sql
-- 删掉了,新装的库里本来就没有这两行。这里再置一次空,防止有环境绕过了那步删除。
-- 正常情况下影响零行
--
-- 这条变更记录在 db.changelog-master.yaml 里标了 runOnChange:脚本内容在这个
-- PR 里改过,跑过旧版的库启动时直接重跑新版,不会报校验不一致。那不是手滑
--
-- 只清从没被人改过的行:系统参数界面里这两行也能改,管理员填进去的凭据不能被抹掉。
-- 种子数据插进来时没写 updater 和 update_date,这两列是空的;界面上保存过一次之后
-- 就会写上操作人和时间
UPDATE `sys_params`
SET `param_value` = ''
WHERE `param_code` IN ('plugins.get_weather.api_key', 'plugins.get_weather.api_host')
AND `updater` IS NULL
AND `update_date` IS NULL;
@@ -1,4 +0,0 @@
-- 添加MCP接入点附带说话人身份的开关参数配置
delete from `sys_params` where param_code = 'mcp_forward_speaker';
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
VALUES (313, 'mcp_forward_speaker', 'true', 'boolean', 1, '是否随MCP调用附带说话人身份');
@@ -753,10 +753,21 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202609172100.sql
# 下面两条是清理天气插件内置凭据用的,脚本内容在本 PR 里改过好几次,
# 标成 runOnChange:跑过旧版的库启动时直接重跑新版,不会报校验不一致
- changeSet:
id: 202610261030
author: RanChen
id: 202610241030
author: hrz
runOnChange: true
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610261030.sql
path: classpath:db/changelog/202610241030.sql
- changeSet:
id: 202610241130
author: hrz
runOnChange: true
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610241130.sql
-4
View File
@@ -119,10 +119,6 @@ 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地址
# 声纹识别到说话人后,是否把身份附在 MCP 接入点 tools/call 的 params._meta 上。
# true:写入 speakerId、speakerName。未识别到说话人时不附带,请求与原来一致。
# 工具参数本身不改。false:完全不附带。
mcp_forward_speaker: true
# 上下文源配置
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
+6 -7
View File
@@ -42,7 +42,7 @@ from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
from config.logger import setup_logging, build_module_string, create_connection_logger
from config.manage_api_client import DeviceNotFoundException, DeviceBindException, generate_and_save_chat_title
from core.utils.prompt_manager import PromptManager
from core.utils.voiceprint_provider import VoiceprintProvider, UNKNOWN_SPEAKER_NAME
from core.utils.voiceprint_provider import VoiceprintProvider
from core.utils.util import get_system_error_response
from core.utils import textUtils
@@ -158,7 +158,7 @@ class ConnectionHandler:
# 所以涉及到ASR的变量,需要在这里定义,属于connection的私有变量
self.asr_audio = [] # 存储PCM帧列表,供VAD和ASR共享
self.asr_audio_queue = queue.Queue()
self.current_speaker = None # 当前说话人展示名
self.current_speaker = None # 存储当前说话人
self.introduced_speakers = set() # 已"首次引入"的说话人,控制只在首轮带名字
self.system_introduced_speakers = set() # 已在 system 注入过身份的说话人,控制 system 身份只首轮出现
@@ -1052,8 +1052,7 @@ class ConnectionHandler:
# 更新系统prompt至上下文
self.dialogue.update_system_message(self.prompt)
def chat(self, query, depth=0, speaker=None):
# speaker: 本轮声纹身份快照,随工具调用下传
def chat(self, query, depth=0):
# 保存当前任务的sentence_id到局部变量,避免被新任务覆盖
current_sentence_id = None
@@ -1123,7 +1122,7 @@ class ConnectionHandler:
# 避免每轮在 system 重复出现名字诱导模型反复称呼
speaker_for_system = None
cs = (self.current_speaker or "").strip()
if cs and cs != UNKNOWN_SPEAKER_NAME and cs not in self.system_introduced_speakers:
if cs and cs != "未知说话人" and cs not in self.system_introduced_speakers:
self.system_introduced_speakers.add(cs)
speaker_for_system = cs
@@ -1338,7 +1337,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(
self.func_handler.handle_llm_function_call(
self, tool_call_data, speaker=speaker
self, tool_call_data
),
self.loop,
)
@@ -1501,7 +1500,7 @@ class ConnectionHandler:
)
)
self.chat(None, depth=depth + 1, speaker=speaker)
self.chat(None, depth=depth + 1)
def _report_worker(self):
"""聊天记录上报工作线程"""
@@ -17,7 +17,7 @@ from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
TAG = __name__
async def handle_user_intent(conn: "ConnectionHandler", text, speaker=None):
async def handle_user_intent(conn: "ConnectionHandler", text):
# 预处理输入文本,处理可能的JSON格式
try:
if text.strip().startswith("{") and text.strip().endswith("}"):
@@ -172,7 +172,7 @@ async def process_intent_result(
try:
result = asyncio.run_coroutine_threadsafe(
conn.func_handler.handle_llm_function_call(
conn, function_call_data, speaker=speaker
conn, function_call_data
),
conn.loop,
).result(timeout=tool_call_timeout)
@@ -40,8 +40,7 @@ async def resume_vad_detection(conn: "ConnectionHandler"):
conn.just_woken_up = False
async def startToChat(conn: "ConnectionHandler", text, speaker=None):
"""speaker: 本轮声纹身份快照,随调用链传递。"""
async def startToChat(conn: "ConnectionHandler", text):
# 检查输入是否是JSON格式(包含说话人信息)
speaker_name = None
actual_text = text
@@ -70,8 +69,11 @@ async def startToChat(conn: "ConnectionHandler", text, speaker=None):
# 如果解析失败,继续使用原始文本
pass
# 说话人名供提示词注入;声纹身份走 speaker 快照,不挂连接
conn.current_speaker = speaker_name
# 保存说话人信息到连接对象
if speaker_name:
conn.current_speaker = speaker_name
else:
conn.current_speaker = None
if conn.need_bind:
await check_bind_device(conn)
@@ -90,7 +92,7 @@ async def startToChat(conn: "ConnectionHandler", text, speaker=None):
await handleAbortMessage(conn)
# 首先进行意图分析,使用实际文本内容
intent_handled = await handle_user_intent(conn, actual_text, speaker=speaker)
intent_handled = await handle_user_intent(conn, actual_text)
if intent_handled:
# 如果意图已被处理,不再进行聊天
@@ -102,7 +104,7 @@ async def startToChat(conn: "ConnectionHandler", text, speaker=None):
# 准备开始新会话
conn.client_abort = False
conn.executor.submit(conn.chat, actual_text, depth=0, speaker=speaker)
conn.executor.submit(conn.chat, actual_text)
async def no_voice_close_connect(conn: "ConnectionHandler", have_voice):
+5 -12
View File
@@ -17,7 +17,6 @@ from core.providers.asr.dto.dto import InterfaceType
from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import remove_punctuation_and_length
from core.utils.voiceprint_provider import SpeakerIdentification
from core.handle.receiveAudioHandle import handleAudioMessage
from typing import Optional, Tuple, List, NamedTuple, TYPE_CHECKING
@@ -102,7 +101,7 @@ class ASRProviderBase(ABC):
)
if conn.voiceprint_provider and wav_data:
voiceprint_task = conn.voiceprint_provider.identify_speaker_detail(
voiceprint_task = conn.voiceprint_provider.identify_speaker(
wav_data, conn.session_id
)
# 并发等待两个结果
@@ -120,11 +119,11 @@ class ASRProviderBase(ABC):
else:
raw_text, _ = asr_result
speaker_name = ""
if isinstance(voiceprint_result, Exception):
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
elif voiceprint_result is not None:
speaker_name = voiceprint_result.name or ""
speaker_name = ""
else:
speaker_name = voiceprint_result
# 判断 ASR 结果类型
if isinstance(raw_text, dict):
@@ -165,16 +164,10 @@ class ASRProviderBase(ABC):
self.stop_ws_connection()
if text_len > 0:
# 本轮声纹身份快照,随调用链供 MCP 接入点工具使用
speaker = (
voiceprint_result
if isinstance(voiceprint_result, SpeakerIdentification)
else None
)
audio_snapshot = asr_audio_task.copy()
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text, speaker=speaker)
await startToChat(conn, enhanced_text)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
@@ -11,9 +11,9 @@ class ToolExecutor(ABC):
@abstractmethod
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行工具调用。speaker: 可选的说话人身份快照。"""
"""执行工具调用"""
pass
@abstractmethod
@@ -15,7 +15,7 @@ class DeviceIoTExecutor(ToolExecutor):
self.iot_tools: Dict[str, ToolDefinition] = {}
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行设备端IoT工具"""
if not self.has_tool(tool_name):
@@ -16,7 +16,7 @@ class DeviceMCPExecutor(ToolExecutor):
self.conn = conn
async def execute(
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行设备端MCP工具"""
if not hasattr(conn, "mcp_client") or not conn.mcp_client:
@@ -1,11 +1,9 @@
"""MCP接入点工具执行器"""
from typing import Dict, Any, Optional
from typing import Dict, Any
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
from core.utils.voiceprint_provider import SpeakerIdentification
from .mcp_endpoint_handler import call_mcp_endpoint_tool
from .mcp_speaker import should_forward_speaker, speaker_call_meta
class MCPEndpointExecutor(ToolExecutor):
@@ -15,9 +13,9 @@ class MCPEndpointExecutor(ToolExecutor):
self.conn = conn
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any], speaker: Optional[SpeakerIdentification] = None
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行MCP接入点工具。speaker: 本轮说话人快照。"""
"""执行MCP接入点工具"""
if not hasattr(conn, "mcp_endpoint_client") or not conn.mcp_endpoint_client:
return ActionResponse(
action=Action.ERROR,
@@ -31,20 +29,14 @@ class MCPEndpointExecutor(ToolExecutor):
)
try:
# 转换参数为JSON字符串。说话人身份不写入参数,只放在 _meta。
# 转换参数为JSON字符串
import json
meta = None
if speaker is not None and should_forward_speaker(
getattr(conn, "config", None)
):
meta = speaker_call_meta(speaker.speaker_id, speaker.name)
args_str = json.dumps(arguments) if arguments else "{}"
# 调用MCP接入点工具
result = await call_mcp_endpoint_tool(
conn.mcp_endpoint_client, tool_name, args_str, meta=meta
conn.mcp_endpoint_client, tool_name, args_str
)
resultJson = None
@@ -6,7 +6,6 @@ import re
import websockets
from config.logger import setup_logging
from .mcp_endpoint_client import MCPEndpointClient
from .mcp_speaker import build_mcp_endpoint_tool_payload
TAG = __name__
logger = setup_logging()
@@ -285,11 +284,7 @@ async def send_mcp_endpoint_tools_list_continue(
async def call_mcp_endpoint_tool(
mcp_client: MCPEndpointClient,
tool_name: str,
args: str = "{}",
timeout: int = 30,
meta: dict = None,
mcp_client: MCPEndpointClient, tool_name: str, args: str = "{}", timeout: int = 30
):
"""
调用指定的MCP接入点工具,并等待响应
@@ -355,16 +350,16 @@ async def call_mcp_endpoint_tool(
raise e
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
payload = build_mcp_endpoint_tool_payload(
tool_call_id, actual_name, arguments, meta
)
payload = {
"jsonrpc": "2.0",
"id": tool_call_id,
"method": "tools/call",
"params": {"name": actual_name, "arguments": arguments},
}
message = json.dumps(payload)
log_suffix = ""
if meta:
log_suffix = f",_meta: {json.dumps(meta, ensure_ascii=False)}"
logger.bind(tag=TAG).info(
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}{log_suffix}"
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}"
)
await mcp_client.send_message(message)
@@ -1,51 +0,0 @@
"""把已识别的说话人附到 MCP 接入点 tools/call 上。
工具参数保持模型给出的原样。身份放在 MCP 的 params._meta 里,
接入点上的服务可以读取,不读的服务按协议忽略。
"""
from typing import Any, Dict, Optional
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
def should_forward_speaker(config: Optional[dict]) -> bool:
"""mcp_forward_speaker 缺省为开启。显式 false 时不附带身份。"""
if not isinstance(config, dict):
return True
value = config.get("mcp_forward_speaker", True)
if value is None:
return True
return str(value).strip().lower() in ("true", "1", "yes")
def speaker_call_meta(speaker_id: Any, speaker_name: Any) -> Optional[Dict[str, str]]:
"""有真实身份时返回 _meta;没有则返回 None,调用方不写这个字段。"""
meta: Dict[str, str] = {}
if isinstance(speaker_id, str):
speaker_id = speaker_id.strip()
if speaker_id:
meta["speakerId"] = speaker_id
if isinstance(speaker_name, str):
speaker_name = speaker_name.strip()
if speaker_name and speaker_name != UNKNOWN_SPEAKER_NAME:
meta["speakerName"] = speaker_name
return meta or None
def build_mcp_endpoint_tool_payload(
tool_call_id: int,
name: str,
arguments: Dict[str, Any],
meta: Optional[Dict[str, str]] = None,
) -> dict:
"""组装 tools/call。meta 为空时不出现 _meta,参数字典原样放进 arguments。"""
params: Dict[str, Any] = {"name": name, "arguments": arguments}
if meta:
params["_meta"] = meta
return {
"jsonrpc": "2.0",
"id": tool_call_id,
"method": "tools/call",
"params": params,
}
@@ -22,7 +22,7 @@ class ServerMCPExecutor(ToolExecutor):
await self.mcp_manager.initialize_servers()
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行服务端MCP工具"""
if not self._initialized or not self.mcp_manager:
@@ -17,7 +17,7 @@ class ServerPluginExecutor(ToolExecutor):
self.config = conn.config
async def execute(
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行服务端插件工具"""
func_item = all_function_registry.get(tool_name)
@@ -137,16 +137,16 @@ class UnifiedToolHandler:
return self.tool_manager.has_tool(tool_name)
async def handle_llm_function_call(
self, conn, function_call_data: Dict[str, Any], speaker=None
self, conn, function_call_data: Dict[str, Any]
) -> Optional[ActionResponse]:
"""处理LLM函数调用。speaker: 本轮说话人快照。"""
"""处理LLM函数调用"""
try:
# 处理多函数调用
if "function_calls" in function_call_data:
responses = []
for call in function_call_data["function_calls"]:
result = await self.tool_manager.execute_tool(
call["name"], call.get("arguments", {}), speaker=speaker
call["name"], call.get("arguments", {})
)
responses.append(result)
return self._combine_responses(responses)
@@ -175,9 +175,7 @@ class UnifiedToolHandler:
self.logger.warning(f"发送工具调用显示消息失败: {e}")
# 执行工具调用
result = await self.tool_manager.execute_tool(
function_name, arguments, speaker=speaker
)
result = await self.tool_manager.execute_tool(function_name, arguments)
return result
except Exception as e:
@@ -71,9 +71,9 @@ class ToolManager:
return tool_def.tool_type if tool_def else None
async def execute_tool(
self, tool_name: str, arguments: Dict[str, Any], speaker=None
self, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行工具调用。speaker: 本轮说话人快照。"""
"""执行工具调用"""
try:
# 查找工具类型
tool_type = self.get_tool_type(tool_name)
@@ -93,7 +93,7 @@ class ToolManager:
# 执行工具
self.logger.info(f"执行工具: {tool_name},参数: {arguments}")
result = await executor.execute(self.conn, tool_name, arguments, speaker=speaker)
result = await executor.execute(self.conn, tool_name, arguments)
self.logger.debug(f"工具执行结果: {result}")
return result
+1 -3
View File
@@ -3,8 +3,6 @@ import re
from typing import List, Dict
from datetime import datetime
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
class Message:
def __init__(
@@ -127,7 +125,7 @@ class Dialogue:
current_speaker_name = (current_speaker or "").strip()
# 仅在本轮注入了有效身份时才输出 speakers_info,避免列表里的名字每轮
# 重复出现诱导模型反复称呼;后续轮不再注入身份,靠对话历史首轮保留
if current_speaker_name and current_speaker_name != UNKNOWN_SPEAKER_NAME:
if current_speaker_name and current_speaker_name != "未知说话人":
speakers = voiceprint_config.get("speakers", [])
speakers_info = "\n<speakers_info>"
speakers_info += f"\n当前说话人:{current_speaker_name}"
@@ -3,7 +3,7 @@ import time
import aiohttp
import requests
from urllib.parse import urlparse, parse_qs
from typing import Optional, Dict, NamedTuple
from typing import Optional, Dict
from config.logger import setup_logging
from core.utils.cache.manager import cache_manager
from core.utils.cache.config import CacheType
@@ -11,30 +11,6 @@ from core.utils.cache.config import CacheType
TAG = __name__
logger = setup_logging()
# 未命中声纹时沿用的展示名。下游只把这个名字交给 LLM,不把它当成真实身份。
UNKNOWN_SPEAKER_NAME = "未知说话人"
class SpeakerIdentification(NamedTuple):
"""一次声纹识别结果。speaker_id 仅在命中已配置说话人时有值。"""
speaker_id: Optional[str]
name: str
def resolve_speaker_identification(
speaker_id: Optional[str],
score: float,
speaker_map: Dict[str, Dict[str, str]],
similarity_threshold: float,
) -> SpeakerIdentification:
"""把声纹接口返回的 speaker_id / score 解析成展示名,并保留命中的 id。"""
if score < similarity_threshold:
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
if speaker_id and speaker_id in speaker_map:
return SpeakerIdentification(speaker_id, speaker_map[speaker_id]["name"])
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
class VoiceprintProvider:
"""声纹识别服务提供者"""
@@ -162,16 +138,7 @@ class VoiceprintProvider:
return is_healthy
async def identify_speaker(self, audio_data: bytes, session_id: str) -> Optional[str]:
"""识别说话人,返回展示名。需要 speaker_id 时用 identify_speaker_detail。"""
identified = await self.identify_speaker_detail(audio_data, session_id)
if identified is None:
return None
return identified.name
async def identify_speaker_detail(
self, audio_data: bytes, session_id: str
) -> Optional[SpeakerIdentification]:
"""识别说话人,同时返回声纹 id 和展示名。"""
"""识别说话人"""
if not self.enabled or not self.api_url or not self.api_key:
logger.bind(tag=TAG).debug("声纹识别功能已禁用或未配置,跳过识别")
return None
@@ -204,19 +171,18 @@ class VoiceprintProvider:
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
identified = resolve_speaker_identification(
speaker_id, score, self.speaker_map, self.similarity_threshold
)
# 三种结果:命中 / 低于阈值 / id不在配置中
if identified.speaker_id:
logger.bind(tag=TAG).info(
f"声纹识别成功: {identified.name} (id: {identified.speaker_id}, 相似度: {score:.3f})"
)
elif score < self.similarity_threshold:
# 相似度阈值检查
if score < self.similarity_threshold:
logger.bind(tag=TAG).warning(f"声纹识别相似度{score:.3f}低于阈值{self.similarity_threshold}")
return "未知说话人"
if speaker_id and speaker_id in self.speaker_map:
result_name = self.speaker_map[speaker_id]["name"]
logger.bind(tag=TAG).info(f"声纹识别成功: {result_name} (相似度: {score:.3f})")
return result_name
else:
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
return identified
return "未知说话人"
else:
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
return None
Whitespace-only changes.
@@ -1,162 +0,0 @@
"""MCP 接入点 tools/call 通过 _meta 携带说话人,不改工具参数。"""
import json
import pytest
from core.providers.tools.mcp_endpoint.mcp_speaker import (
build_mcp_endpoint_tool_payload,
should_forward_speaker,
speaker_call_meta,
)
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME, SpeakerIdentification
def test_meta_includes_id_and_name():
assert speaker_call_meta(" test1 ", " 张三 ") == {
"speakerId": "test1",
"speakerName": "张三",
}
def test_meta_omits_unknown_speaker_and_blank_id():
assert speaker_call_meta(None, UNKNOWN_SPEAKER_NAME) is None
assert speaker_call_meta(" ", "张三") == {"speakerName": "张三"}
assert speaker_call_meta("test1", UNKNOWN_SPEAKER_NAME) == {"speakerId": "test1"}
def test_forwarding_defaults_on_and_can_be_disabled():
assert should_forward_speaker(None) is True
assert should_forward_speaker({}) is True
assert should_forward_speaker({"mcp_forward_speaker": True}) is True
assert should_forward_speaker({"mcp_forward_speaker": False}) is False
# 字符串 false 可关闭;空值视为缺省开启
assert should_forward_speaker({"mcp_forward_speaker": "false"}) is False
assert should_forward_speaker({"mcp_forward_speaker": "False"}) is False
assert should_forward_speaker({"mcp_forward_speaker": "0"}) is False
assert should_forward_speaker({"mcp_forward_speaker": "true"}) is True
assert should_forward_speaker({"mcp_forward_speaker": None}) is True
# 空串按关闭处理,与 manager-api 的 Boolean.parseBoolean("") 一致
assert should_forward_speaker({"mcp_forward_speaker": ""}) is False
def test_payload_adds_meta_without_touching_arguments():
arguments = {"name": "灯"}
payload = build_mcp_endpoint_tool_payload(
7,
"HassTurnOn",
arguments,
{"speakerId": "test1", "speakerName": "张三"},
)
assert payload["method"] == "tools/call"
assert payload["params"]["arguments"] is arguments
assert "speakerId" not in arguments
assert payload["params"]["_meta"]["speakerId"] == "test1"
def test_payload_without_meta_matches_previous_shape():
payload = build_mcp_endpoint_tool_payload(7, "calculator", {"a": 1})
assert set(payload["params"]) == {"name", "arguments"}
assert "_meta" not in payload["params"]
class _Client:
async def is_ready(self):
return True
class _Conn:
def __init__(self, config=None):
self.mcp_endpoint_client = _Client()
self.config = {} if config is None else config
@pytest.mark.asyncio
async def test_executor_forwards_speaker_in_meta_only(monkeypatch):
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["tool_name"] = tool_name
captured["args"] = args_str
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
arguments = {"area": "客厅"}
conn = _Conn()
result = await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
arguments,
speaker=SpeakerIdentification("test1", "张三"),
)
assert json.loads(captured["args"]) == {"area": "客厅"}
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
assert arguments == {"area": "客厅"}
assert result.result == "done"
@pytest.mark.asyncio
async def test_executor_skips_meta_when_disabled(monkeypatch):
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
conn = _Conn(config={"mcp_forward_speaker": False})
await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
{"area": "客厅"},
speaker=SpeakerIdentification("test1", "张三"),
)
assert captured["meta"] is None
@pytest.mark.asyncio
async def test_executor_omits_meta_for_unknown_speaker(monkeypatch):
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
conn = _Conn()
await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
{},
speaker=SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME),
)
assert captured["meta"] is None
@pytest.mark.asyncio
async def test_executor_reads_snapshot_not_conn_state(monkeypatch):
"""meta 取自快照,不受连接态影响。"""
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
conn = _Conn()
await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
{},
speaker=SpeakerIdentification("test1", "张三"),
)
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
@@ -1,98 +0,0 @@
"""声纹识别结果要同时保留 speaker_id 和展示名。"""
import aiohttp
import pytest
from core.utils.voiceprint_provider import (
UNKNOWN_SPEAKER_NAME,
VoiceprintProvider,
resolve_speaker_identification,
)
SPEAKERS = {"test1": {"name": "张三", "description": "程序员"}}
def test_matched_speaker_keeps_id_and_name():
identified = resolve_speaker_identification("test1", 0.91, SPEAKERS, 0.4)
assert identified.speaker_id == "test1"
assert identified.name == "张三"
def test_score_equal_to_threshold_still_matches():
identified = resolve_speaker_identification("test1", 0.4, SPEAKERS, 0.4)
assert identified.speaker_id == "test1"
def test_low_score_hides_id_and_uses_unknown_name():
identified = resolve_speaker_identification("test1", 0.39, SPEAKERS, 0.4)
assert identified.speaker_id is None
assert identified.name == UNKNOWN_SPEAKER_NAME
def test_unconfigured_speaker_id_is_unknown():
identified = resolve_speaker_identification("other", 0.99, SPEAKERS, 0.4)
assert identified.speaker_id is None
assert identified.name == UNKNOWN_SPEAKER_NAME
class _Response:
def __init__(self, status, payload):
self.status = status
self._payload = payload
async def json(self):
return self._payload
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _Session:
def __init__(self, response):
self._response = response
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
return self._response
def _enabled_provider():
provider = VoiceprintProvider({})
provider.enabled = True
provider.api_url = "http://voiceprint.local/voiceprint/identify"
provider.api_key = "k"
provider.speaker_ids = ["test1"]
provider.speaker_map = SPEAKERS
provider.similarity_threshold = 0.4
return provider
@pytest.mark.asyncio
async def test_identify_speaker_still_returns_display_name(monkeypatch):
provider = _enabled_provider()
monkeypatch.setattr(
aiohttp,
"ClientSession",
lambda *args, **kwargs: _Session(
_Response(200, {"speaker_id": "test1", "score": 0.88})
),
)
assert await provider.identify_speaker(b"wav", "session") == "张三"
detail = await provider.identify_speaker_detail(b"wav", "session")
assert detail.speaker_id == "test1"
assert detail.name == "张三"
@pytest.mark.asyncio
async def test_identify_speaker_disabled_returns_none():
provider = VoiceprintProvider({})
assert await provider.identify_speaker(b"wav", "session") is None
assert await provider.identify_speaker_detail(b"wav", "session") is None