diff --git a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py index cd150bc59..1dd5bb172 100644 --- a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py +++ b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from core.connection import ConnectionHandler from ..base import IntentProviderBase from plugins_func.functions.play_music import initialize_music_handler +from plugins_func.functions.hass_init import _devices_text, _hass_plugin_config from config.logger import setup_logging from core.utils.util import get_system_error_response import re @@ -181,16 +182,11 @@ class IntentProvider(IntentProviderBase): music_file_names = music_config["music_file_names"] prompt_music = f"{self.promot}\n{music_file_names}\n" - home_assistant_cfg = conn.config["plugins"].get("home_assistant") - if home_assistant_cfg: - devices = home_assistant_cfg.get("devices", []) - else: - devices = [] - if len(devices) > 0: + plugin_config = _hass_plugin_config(conn.config.get("plugins", {})) + devices_text = _devices_text(plugin_config.get("devices", "")) + if devices_text: hass_prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n" - for device in devices: - hass_prompt += device + "\n" - prompt_music += hass_prompt + prompt_music += hass_prompt + devices_text + "\n" logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}") diff --git a/main/xiaozhi-server/plugins_func/functions/hass_init.py b/main/xiaozhi-server/plugins_func/functions/hass_init.py index a106d16d3..2a0c2edd4 100644 --- a/main/xiaozhi-server/plugins_func/functions/hass_init.py +++ b/main/xiaozhi-server/plugins_func/functions/hass_init.py @@ -1,27 +1,53 @@ from config.logger import setup_logging from core.utils.util import check_model_key +from plugins_func.register import module_func_map TAG = __name__ logger = setup_logging() +def _expand_module_names(funcs): + """把 functions 列表里的模块名(如 hass_state)展开成具体函数名,与工具加载口径一致。""" + expanded = [] + for name in funcs: + expanded.extend(module_func_map.get(name, [name])) + return expanded + + +def _devices_text(devices) -> str: + """把 devices 配置转成拼进提示词的文字。 + + config.yaml 示例和手机端智控台给的是列表,每项一条"位置,设备名,entity_id",这里拼成每行一条; + 网页端智控台和 HA 集成文档是一段字符串,原样用;其它(如 None)当空。 + """ + if isinstance(devices, (list, tuple)): + return "\n".join( + str(d).strip() for d in devices if d is not None and str(d).strip() + ) + return devices if isinstance(devices, str) else "" + + +def _hass_plugin_config(plugins_config: dict) -> dict: + """取 HA 插件配置:优先本地 config.yaml 的 home_assistant,其次智控台下发的 hass_state。""" + for key in ("home_assistant", "hass_state"): + cfg = plugins_config.get(key) + if cfg: + return cfg + return {} + + def append_devices_to_prompt(conn): if conn.intent_type == "function_call": - funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get( - "functions", [] - ) - - # 安全地获取插件配置 - plugins_config = conn.config.get("plugins", {}) - config_source = ( - "home_assistant" - if plugins_config.get("home_assistant") - else "hass_state" + funcs = _expand_module_names( + conn.config["Intent"][conn.config["selected_module"]["Intent"]].get( + "functions", [] + ) ) if "hass_get_state" in funcs or "hass_set_state" in funcs: prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n" - deviceStr = plugins_config.get(config_source, {}).get("devices", "") + plugin_config = _hass_plugin_config(conn.config.get("plugins", {})) + deviceStr = _devices_text(plugin_config.get("devices", "")) conn.prompt += prompt + deviceStr + "\n" # 更新提示词 conn.dialogue.update_system_message(conn.prompt) @@ -32,17 +58,10 @@ def initialize_hass_handler(conn): if not conn.load_function_plugin: return ha_config - # 安全地获取插件配置 - plugins_config = conn.config.get("plugins", {}) - # 确定配置来源 - config_source = ( - "home_assistant" if plugins_config.get("home_assistant") else "hass_state" - ) - if not plugins_config.get(config_source): + plugin_config = _hass_plugin_config(conn.config.get("plugins", {})) + if not plugin_config: return ha_config - # 统一获取配置 - plugin_config = plugins_config[config_source] ha_config["base_url"] = plugin_config.get("base_url") ha_config["api_key"] = plugin_config.get("api_key") diff --git a/main/xiaozhi-server/tests/plugins_func/test_hass_init.py b/main/xiaozhi-server/tests/plugins_func/test_hass_init.py new file mode 100644 index 000000000..4e2ee69e9 --- /dev/null +++ b/main/xiaozhi-server/tests/plugins_func/test_hass_init.py @@ -0,0 +1,84 @@ +"""append_devices_to_prompt:devices 写成列表或一段字符串都能拼进提示词。 + +config.yaml 示例和手机端智控台给的是列表,以前会报 +can only concatenate str (not "list") to str,设备清单进不了提示词,模型只能猜 entity_id。 +""" +import pytest + +import plugins_func.functions.hass_state # noqa: F401 注册 hass_state 模块,填充 module_func_map +from plugins_func.functions.hass_init import append_devices_to_prompt + +HEADER = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n" +BASE_PROMPT = "你是小智。" + + +class FakeDialogue: + def __init__(self): + self.system_messages = [] + + def update_system_message(self, prompt): + self.system_messages.append(prompt) + + +class FakeConn: + def __init__(self, plugin, source="home_assistant", functions=("hass_get_state", "hass_set_state")): + self.intent_type = "function_call" + self.config = { + "selected_module": {"Intent": "function_call"}, + "Intent": {"function_call": {"functions": list(functions)}}, + "plugins": {source: plugin}, + } + self.prompt = BASE_PROMPT + self.dialogue = FakeDialogue() + + +def _plugin(**extra): + return {"base_url": "http://homeassistant.local:8123", "api_key": "test-token", **extra} + + +def test_list_devices_one_per_line(): + conn = FakeConn(_plugin(devices=[ + "客厅,玩具灯,switch.toy_lamp", + " 卧室,台灯,switch.desk_lamp ", + "", + ])) + append_devices_to_prompt(conn) + expected = BASE_PROMPT + HEADER + "客厅,玩具灯,switch.toy_lamp\n卧室,台灯,switch.desk_lamp\n" + assert conn.prompt == expected + assert conn.dialogue.system_messages == [expected] + + +def test_string_devices_kept_as_is(): + # 网页端智控台和 HA 集成文档给的是一段字符串 + devices = "客厅,玩具灯,switch.toy_lamp;\n卧室,台灯,switch.desk_lamp;" + conn = FakeConn(_plugin(devices=devices)) + append_devices_to_prompt(conn) + assert conn.prompt == BASE_PROMPT + HEADER + devices + "\n" + + +def test_mobile_console_hass_state_list(): + # 智控台下发的插件配置键是 hass_state;手机端把 array 字段按行切成了列表 + conn = FakeConn(_plugin(devices=["客厅,玩具灯,switch.toy_lamp"]), source="hass_state") + append_devices_to_prompt(conn) + assert conn.prompt.endswith(HEADER + "客厅,玩具灯,switch.toy_lamp\n") + + +def test_module_name_in_functions(): + # functions 里写模块名 hass_state(config.yaml 注释的写法),展开后应能匹配 + conn = FakeConn(_plugin(devices=["客厅,玩具灯,switch.toy_lamp"]), functions=("hass_state",)) + append_devices_to_prompt(conn) + assert conn.prompt.endswith(HEADER + "客厅,玩具灯,switch.toy_lamp\n") + + +@pytest.mark.parametrize("plugin", [_plugin(), _plugin(devices=None), _plugin(devices=[])]) +def test_missing_or_empty_devices_does_not_raise(plugin): + conn = FakeConn(plugin) + append_devices_to_prompt(conn) + assert conn.prompt == BASE_PROMPT + HEADER + "\n" + + +def test_without_hass_functions_prompt_unchanged(): + conn = FakeConn(_plugin(devices=["客厅,玩具灯,switch.toy_lamp"]), functions=("get_weather",)) + append_devices_to_prompt(conn) + assert conn.prompt == BASE_PROMPT + assert conn.dialogue.system_messages == []