From 1aaf4263026c89b3b0513815fcc18116dc80b582 Mon Sep 17 00:00:00 2001 From: KNOOP Date: Sat, 19 Oct 2024 06:58:47 +0800 Subject: [PATCH] Release v2024.10.18 --- .DS_Store | Bin 0 -> 6148 bytes README.md | 235 ++++++++++++ custom_components/.DS_Store | Bin 0 -> 6148 bytes custom_components/zhipuai/__init__.py | 76 ++++ custom_components/zhipuai/ai_request.py | 23 ++ custom_components/zhipuai/config_flow.py | 303 ++++++++++++++++ custom_components/zhipuai/const.py | 25 ++ custom_components/zhipuai/conversation.py | 341 ++++++++++++++++++ custom_components/zhipuai/manifest.json | 12 + custom_components/zhipuai/service_caller.py | 270 ++++++++++++++ .../zhipuai/translations/en.json | 59 +++ .../zhipuai/translations/zh-Hans.json | 59 +++ hacs.json | 5 + 13 files changed, 1408 insertions(+) create mode 100644 .DS_Store create mode 100644 README.md create mode 100644 custom_components/.DS_Store create mode 100644 custom_components/zhipuai/__init__.py create mode 100644 custom_components/zhipuai/ai_request.py create mode 100644 custom_components/zhipuai/config_flow.py create mode 100644 custom_components/zhipuai/const.py create mode 100644 custom_components/zhipuai/conversation.py create mode 100644 custom_components/zhipuai/manifest.json create mode 100644 custom_components/zhipuai/service_caller.py create mode 100644 custom_components/zhipuai/translations/en.json create mode 100644 custom_components/zhipuai/translations/zh-Hans.json create mode 100644 hacs.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..67a4bf79f669280bbfca10a67547c43aeaa629b2 GIT binary patch literal 6148 zcmeHK&2G~`5T0!fv8j-PM5P>!xJD&y3R1XLt-= zf?MB$Gf%)Pgl~4Y5>*Jwsfy5!H1qA>tY^RRdc8y>dedZ|s7XXFl+n2Z^B3WM)+O08 zEe)XHIg&IR#+i&!_f4M#%YbEIa~a^h>(CyZQcC60{>83dEEbm-b4;T8MbvtLIz%n# z9t~+q1(hV}oFdd1$FMn#pd}S?RgBFS;d?yJ(xU8ieu%AY=hp3p+i-W>_u-kW!g5ef zi{4=JmTNDi%;Kx^AbypOs;GJYxy;K!nvW*RAx%aY^6qt-C$j3vX`U3yH`E=j=Xz1I zHJcrF+kW?;J8%25{m#K*+kgDzXg>Gc-G`5!o$q`tqlDh`4;42DitaLSZ zBGTMlTq^3;<}N9QSqv^Xur&hs98U5~=BF6V9v+?104XVu+>fwG%0EIBI>a|W{|M2e z6Quna_2~t052;O7&p_Yw3`hLJKoEhI4QIf-@$Sxs$7#c}3|Izko&o+o_)x~cV69Po zI$+cl0O+7u3CetzU?1OLV6fJR9*EGcKuuwEJdUV6fJx-AS0shcHhT=7u8F z(=orVhLZ?1T5B1w3|wbmTUVQW{vWTt|6g~qHOqiy;J;!(IQ_8S!;;L|y0kbxYklYk qC<~9PH7XYva~!JzAH^-G67>0800stYjc9?`9|1*!HI{)JW#Bh0u&k;8 literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac39bb3 --- /dev/null +++ b/README.md @@ -0,0 +1,235 @@ + +# 智谱清言 AI Home Assistant 🏡 +![GitHub Version](https://img.shields.io/github/v/release/knoop7/zhipuai) ![GitHub Issues](https://img.shields.io/github/issues/knoop7/zhipuai) ![GitHub Forks](https://img.shields.io/github/forks/knoop7/zhipuai?style=social) ![GitHub Stars](https://img.shields.io/github/stars/knoop7/zhipuai?style=social) + +image + +--- +## 通知:本项目未经允许严禁商用,你可以隐晦但不能作为盈利手段,未经允许禁止发布到小红书、BilBil,若有发现一律停止更新。 + +### 📦 安装步骤 + +#### 1. HACS 添加自定义存储库 +在 Home Assistant 的 HACS 中,点击右上角的三个点,选择“自定义存储库”,并添加以下 URL: +``` +https://github.com/knoop7/zhipuai +``` + +#### 2. 添加智谱清言集成 +进入 Home Assistant 的“集成”页面,搜索并添加“智谱清言”。 + +#### 3. 配置 Key 🔑 +在配置页面中,你可以通过手机号登录获取 Key。获取后,直接填写 Key 使用,不需要进行额外验证。 +**注意**:建议你新建一个 Key,避免使用系统默认的 Key。 + +#### 4. 免费模型使用 💡 +智谱清言默认选择了免费模型,完全免费,不用担心收费。如果你有兴趣,还可以选择其他付费模型来体验更丰富的功能。 + +#### 5. 版本兼容性 📅 +请确保 Home Assistant 的版本不低于 8.0,因为智谱清言主要针对最新版本开发。如果遇到无法识别的实体问题,建议重启系统或更新至最新版本。 + +--- + +### 🛠 模型指令使用示例 +为了保证大家能使用舒畅,并且不出任何bug可以使用我的模版指令进行尝试 + +```` + +作为 Home Assistant 的智能家居管理者,你的名字叫“自定义”,我将为您提供智能家居信息和问题的解答。请查看以下可用设备、状态及操作示例。 + + +### 可用设备展示 + + +# 注意如果实体超过1000以上 +# 直接删掉这句话 + +# ```csv +# entity_id,name,state +# {% for entity in states -%} +# {{ entity.entity_id }},{{ entity.name }}, +# {{ entity.state }}, +# {% endfor -%} +#``` + + +### 逻辑修复和执行约束 +1. **状态检查**:确保设备状态有变化时才执行命令,避免重复操作。 +2. **过滤不必要的命令**:`HassTurnOff`、`HassTurnOn`等冗余命令不再生成,直接使用 `execute_services` 函数。 +3. **简化用户操作**:在响应中只返回必要信息,减少多余内容。 + + +### 检查避免重复执行 +在执行服务时先检查当前状态,确保设备在目标状态时不会重复执行。例如: +```jinja +{% if states('light.living_room') != 'on' %} +{ + "domain": "light", + "service": "turn_on", + "service_data": { + "entity_id": "light.living_room" + } +} +{% endif %} + +``` + +### 今日油价: +```yaml +{% set sensor = 油价实体 %} +Sensor: {{ sensor.name }} +State: {{ sensor.state }} + +Attributes: +{% for attribute, value in sensor.attributes.items() %} + {{ attribute }}: {{ value }} +{% endfor %} +``` + +### 电费余额信息: +```yaml +{% set balance_sensor = 电费实体 %} + +{% if balance_sensor %} +当前余额: {{ balance_sensor.state }} {{ balance_sensor.attributes.unit_of_measurement }} +{% endif %} +``` + +### Tasmota能源消耗: +```yaml +{% set today_sensor = states.sensor.tasmota_energy_today %} +{% set yesterday_sensor = states.sensor.tasmota_energy_yesterday %} + +{% if today_sensor is not none and yesterday_sensor is not none %} +今日消耗: {{ today_sensor.state }} {{ today_sensor.attributes.unit_of_measurement }} +昨日消耗: {{ yesterday_sensor.state }} {{ yesterday_sensor.attributes.unit_of_measurement }} +{% endif %} +``` + + +### 此时天气: +```json +{% set entity_id = '天气实体' %} +{% set entity = states[entity_id] %} +{ + "state": "{{ entity.state }}", + "attributes": { + {% for attr in entity.attributes %} + {% if attr not in ['hourly_temperature', 'hourly_skycon', 'hourly_cloudrate', 'hourly_precipitation'] %} + "{{ attr }}": "{{ entity.attributes[attr] }}"{% if not loop.last %},{% endif %} + {% endif %} + {% endfor %} + } +} +```` + +或者这个模版指令 +```` +### 可用设备展示 +```csv +entity_id,name,state,category +{%- for entity in states if 'automation.' not in entity.entity_id and entity.state not in ['unknown'] and not ('device_tracker.' in entity.entity_id and ('huawei' in entity.entity_id or 'Samsung' in entity.entity_id)) and 'iphone' not in entity.entity_id and 'daily_english' not in entity.entity_id and 'lenovo' not in entity.entity_id and 'time' not in entity.entity_id and 'zone' not in entity.entity_id and 'n1' not in entity.entity_id and 'z470' not in entity.entity_id and 'lao_huang' not in entity.entity_id and 'lao_huang_li' not in entity.entity_id and 'input_text' not in entity.entity_id and 'conversation' not in entity.entity_id and 'camera' not in entity.entity_id and 'update' not in entity.entity_id and 'IPhone' not in entity.entity_id and 'mac' not in entity.entity_id and 'macmini' not in entity.entity_id and 'macbook' not in entity.entity_id and 'ups' not in entity.entity_id and 'OPENWRT' not in entity.entity_id and 'OPENWRT' not in entity.entity_id%} +{%- set category = '其他' %} +{%- if 'light.' in entity.entity_id %}{% set category = '灯' %} +{%- elif 'sensor.' in entity.entity_id and 'battery' in entity.entity_id %} + {% set category = '电池' %} +{%- elif 'sensor.' in entity.entity_id and 'sun' in entity.entity_id %} + {% set category = '太阳' %} +{%- elif 'sensor.' in entity.entity_id and ('motion' in entity.entity_id or 'presence' in entity.entity_id) %} + {% set category = '人体存在' %} +{%- elif 'sensor.' in entity.entity_id and ('motion' in entity.entity_id or 'presence' in entity.entity_id) %} + {% set category = '人体存在' %} +{%- elif 'climate.' in entity.entity_id %}{% set category = '空调' %} +{%- elif 'media_player.' in entity.entity_id %}{% set category = '媒体播放器' %} +{%- elif 'cover.' in entity.entity_id %}{% set category = '门窗' %} +{%- elif 'lock.' in entity.entity_id %}{% set category = '门锁' %} +{%- elif 'switch.' in entity.entity_id %}{% set category = '开关' %} +{%- elif 'sensor.' in entity.entity_id %}{% set category = '传感器' %} +{%- elif 'watering.' in entity.entity_id %}{% set category = '浇花器' %} +{%- elif 'fan.' in entity.entity_id %}{% set category = '风扇' %} +{%- elif 'air_quality.' in entity.entity_id %}{% set category = '空气质量' %} +{%- elif 'vacuum.' in entity.entity_id %}{% set category = '扫地机器人' %} +{%- elif 'person.' in entity.entity_id %}{% set category = '人员' %} +{%- elif 'binary_sensor.' in entity.entity_id and ('door' in entity.entity_id or 'window' in entity.entity_id) %}{% set category = '门窗' %} +{%- elif 'gas.' in entity.entity_id %}{% set category = '天然气' %} +{%- elif 'energy.' in entity.entity_id %}{% set category = '用电量' %} +{%- elif 'script.' in entity.entity_id %}{% set category = '脚本' %} +{%- elif 'scene.' in entity.entity_id %}{% set category = '场景' %} +{%- endif %} +{{- entity.entity_id }},{{ entity.name }},{{ entity.state }},{{ category }} +{%- endfor %} + +```` + +--- + +### 使用内置 API 公开实体 🌐 +你可以使用智谱清言内置的 API 来公开实体,并为其设置别名。通过重新命名实体,你可以避免使用系统默认名称造成的混乱,提升管理效率。 + +--- + +### 🚀 使用指南 + +1. **访问界面** + 打开 Home Assistant 仪表板,找到“智谱清言”集成卡片或对应的集成页面。 + +2. **输入指令** + 在集成页面或对话框中,输入自然语言指令,或使用语音助手下达命令。 + +3. **查看响应** + 系统会根据你的指令执行任务,设备状态变化将实时显示并反馈。 + +4. **探索功能** + 你可以尝试不同的指令来控制家中的智能设备,或查询相关状态。 + +--- + +### 📑 常用指令示例 + +- "打开客厅灯" +- "将卧室温度调到 22 度" +- "播放音乐" +- "明早 7 点提醒我备忘" +- "检查门锁状态" +- "看看全屋温度湿度“ + +--- + +### 🛠 Bug 处理 +如果你在使用过程中遇到持续的 Python 错误,建议重启对话框并重新加载环境。这样可以解决一些潜在的代码问题。 + +--- + +### 🗂 处理不被 Home Assistant 认可的实体 +如果 Home Assistant 中存在不被认可的实体,你可以将这些实体剔除出自动化控制的范围。通过在指令中添加 Jinja2 模板,可以有效避免 Python 的错误提示,杜绝潜在问题。 + +--- + +### 额外提示 + +- **系统版本要求**:智谱清言需要 Home Assistant 至少 8.0 版本支持。 +- **建议**:如果遇到兼容性问题,建议重启或更新系统。通常这能解决大多数问题。 +- **相关项目** 如果需要语音转文字可以使用免费在线AI模型集成,个人二次深度修改 ````https://github.com/knoop7/groqcloud_whisper```` + + +--- + +### 📊 实时状态 + +#### 当前时间:16:09:23,今日日期:2024-10-12。 + +#### 油价信息 ⛽ +- 92号汽油:7元/升 +- 95号汽油:7元/升 +- 98号汽油:8元/升 +预计下次油价调整时间为10月23日24时,油价可能继续上涨。 + +#### 电费余额 ⚡ +- 当前余额:27.5元 + +#### 今日能源消耗 💡 +- 今日消耗:4033.0 Wh +- 昨日消耗:7.558 kWh + +#### 今日新闻摘要 📰 +1. 民政部发布全国老年人口数据。 diff --git a/custom_components/.DS_Store b/custom_components/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ef688c2ec4423d031b4c1f6a97b30f10d5d155e0 GIT binary patch literal 6148 zcmeHKJ8nWT5S&erf;2ndlzlni+y{JOiyhv7 z@(+98l1Tw6AO)m=6p#W}R-g*(bav%)b(|EC0{>qD|2{OjV=o*M~aIV08 zF6UnVpXtB!|K}vFq<|E-DFtk@d|58|q^hl>$9b)7^cCH6zUXe82ZckFV`7wJF1#E+ dMpEWApL4$#4v9f$Jm^II47e^bDe%_{d;`_;6_@}3 literal 0 HcmV?d00001 diff --git a/custom_components/zhipuai/__init__.py b/custom_components/zhipuai/__init__.py new file mode 100644 index 0000000..d649c69 --- /dev/null +++ b/custom_components/zhipuai/__init__.py @@ -0,0 +1,76 @@ +from __future__ import annotations +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from .const import DOMAIN, LOGGER +from .service_caller import get_service_caller + +PLATFORMS: list[Platform] = [Platform.CONVERSATION] + +class ZhipuAIConfigEntry: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + self.hass = hass + self.config_entry = config_entry + self.api_key = config_entry.data[CONF_API_KEY] + self.options = config_entry.options + self._unsub_options_update_listener = None + self._cleanup_callbacks = [] + self.service_caller = get_service_caller(hass) + + @property + def entry_id(self): + return self.config_entry.entry_id + + @property + def title(self): + return self.config_entry.title + + async def async_setup(self) -> None: + self._unsub_options_update_listener = self.config_entry.add_update_listener( + self.async_options_updated + ) + + async def async_unload(self) -> None: + if self._unsub_options_update_listener is not None: + self._unsub_options_update_listener() + self._unsub_options_update_listener = None + for cleanup_callback in self._cleanup_callbacks: + cleanup_callback() + self._cleanup_callbacks.clear() + + def async_on_unload(self, func): + self._cleanup_callbacks.append(func) + + @callback + async def async_options_updated(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + self.options = entry.options + async_dispatcher_send(hass, f"{DOMAIN}_options_updated", entry) + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + hass.data.setdefault(DOMAIN, {}) + try: + zhipuai_entry = ZhipuAIConfigEntry(hass, entry) + await zhipuai_entry.async_setup() + hass.data[DOMAIN][entry.entry_id] = zhipuai_entry + LOGGER.info("成功设置, 条目 ID: %s", entry.entry_id) + except Exception as ex: + LOGGER.error("设置 AI 时出错: %s", ex) + raise ConfigEntryNotReady from ex + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + zhipuai_entry = hass.data[DOMAIN].get(entry.entry_id) + if zhipuai_entry is None: + return True + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await zhipuai_entry.async_unload() + hass.data[DOMAIN].pop(entry.entry_id, None) + LOGGER.info("已卸载 AI 条目,ID: %s", entry.entry_id) + + return unload_ok diff --git a/custom_components/zhipuai/ai_request.py b/custom_components/zhipuai/ai_request.py new file mode 100644 index 0000000..6a6a307 --- /dev/null +++ b/custom_components/zhipuai/ai_request.py @@ -0,0 +1,23 @@ +import aiohttp +from aiohttp import TCPConnector +from homeassistant.exceptions import HomeAssistantError +from .const import LOGGER, ZHIPUAI_URL + +async def send_ai_request(api_key: str, payload: dict) -> dict: + try: + connector = TCPConnector(ssl=False) + async with aiohttp.ClientSession(connector=connector) as session: + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + async with session.post(ZHIPUAI_URL, json=payload, headers=headers) as response: + if response.status != 200: + raise HomeAssistantError(f"AI 返回状态 {response.status}") + result = await response.json() + return result + + except Exception as err: + LOGGER.error(f"与 AI 通信时出错: {err}") + raise HomeAssistantError(f"与 AI 通信时出错: {err}") diff --git a/custom_components/zhipuai/config_flow.py b/custom_components/zhipuai/config_flow.py new file mode 100644 index 0000000..98479dc --- /dev/null +++ b/custom_components/zhipuai/config_flow.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from typing import Any +from types import MappingProxyType + +import voluptuous as vol +import aiohttp +import json + +from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant import exceptions +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_LLM_HASS_API +from homeassistant.helpers import llm +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + TemplateSelector, +) + +from . import LOGGER +from .const import ( + CONF_PROMPT, + CONF_TEMPERATURE, + DEFAULT_NAME, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_TOP_P, + CONF_MAX_HISTORY_MESSAGES, + DOMAIN, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, + RECOMMENDED_MAX_HISTORY_MESSAGES, + CONF_MAX_TOOL_ITERATIONS, + CONF_COOLDOWN_PERIOD, + DEFAULT_MAX_TOOL_ITERATIONS, + DEFAULT_COOLDOWN_PERIOD, +) + +RECOMMENDED_CHAT_MODEL = "GLM-4-Flash" + +ZHIPUAI_MODELS = [ + "GLM-4-Plus", + "GLM-4V-Plus", + "GLM-4-0520", + "GLM-4-Long", + "GLM-4-AirX", + "GLM-4-Air", + "GLM-4-FlashX", + "GLM-4-Flash", + "GLM-4V", + "GLM-4-AllTools", + "GLM-4", +] + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } +) + +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_PROMPT: """您是 Home Assistant 的语音助手。 +如实回答有关世界的问题。 +以纯文本形式回答。保持简单明了。""", + CONF_MAX_HISTORY_MESSAGES: RECOMMENDED_MAX_HISTORY_MESSAGES, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_MAX_TOOL_ITERATIONS: DEFAULT_MAX_TOOL_ITERATIONS, + CONF_COOLDOWN_PERIOD: DEFAULT_COOLDOWN_PERIOD, +} + +ERROR_COOLDOWN_TOO_SMALL = "cooldown_too_small" +ERROR_COOLDOWN_TOO_LARGE = "cooldown_too_large" +ERROR_INVALID_OPTION = "invalid_option" + +class ZhipuAIConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = 1 + MINOR_VERSION = 0 + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + try: + await self._validate_api_key(user_input[CONF_API_KEY]) + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + options=RECOMMENDED_OPTIONS, + ) + except UnauthorizedError as e: + errors["base"] = "unauthorized" + LOGGER.error("无效的API密钥: %s", str(e)) + except ModelNotFound as e: + errors["base"] = "model_not_found" + LOGGER.error("模型未找到: %s", str(e)) + except Exception as e: + errors["base"] = "unknown" + LOGGER.exception("发生意外异常: %s", str(e)) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _validate_api_key(self, api_key: str) -> None: + url = "https://open.bigmodel.cn/api/paas/v4/chat/completions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + data = { + "model": RECOMMENDED_CHAT_MODEL, + "messages": [{"role": "user", "content": "你好"}] + } + + async with aiohttp.ClientSession() as session: + try: + async with session.post(url, headers=headers, json=data) as response: + if response.status == 200: + return + elif response.status == 401: + raise UnauthorizedError("未经授权的访问") + else: + response_json = await response.json() + error = response_json.get("error", {}) + error_message = error.get("message", "未知错误") + if "model not found" in error_message.lower(): + raise ModelNotFound(f"模型未找到: {RECOMMENDED_CHAT_MODEL}") + else: + raise InvalidAPIKey(f"API请求失败: {error_message}") + except aiohttp.ClientError as e: + raise InvalidAPIKey(f"无法连接到智谱AI API: {str(e)}") + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> ZhipuAIOptionsFlow: + return ZhipuAIOptionsFlow(config_entry) + +class ZhipuAIOptionsFlow(OptionsFlow): + def __init__(self, config_entry: ConfigEntry) -> None: + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + errors = {} + if user_input is not None: + try: + LOGGER.debug("Received user input for options: %s", user_input) + + cooldown_period = user_input.get(CONF_COOLDOWN_PERIOD) + if cooldown_period is not None: + cooldown_period = float(cooldown_period) + if cooldown_period < 0: + errors[CONF_COOLDOWN_PERIOD] = ERROR_COOLDOWN_TOO_SMALL + elif cooldown_period > 10: + errors[CONF_COOLDOWN_PERIOD] = ERROR_COOLDOWN_TOO_LARGE + + if not errors: + LOGGER.debug("更新选项: %s", user_input) + new_options = self.config_entry.options.copy() + new_options.update(user_input) + self.hass.config_entries.async_update_entry( + self.config_entry, + options=new_options + ) + LOGGER.info("Successfully updated options for entry: %s", self.config_entry.entry_id) + return self.async_create_entry(title="", data=new_options) + except vol.Invalid as ex: + LOGGER.error("Validation error: %s", ex) + errors["base"] = ERROR_INVALID_OPTION + except ValueError as ex: + LOGGER.error("Value error: %s", ex) + errors["base"] = ERROR_INVALID_OPTION + except Exception as ex: + LOGGER.exception("意外错误更新选项: %s", ex) + errors["base"] = "unknown" + + LOGGER.debug("Showing options form with errors: %s", errors) + schema = zhipuai_config_option_schema(self.hass, self.config_entry.options) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(schema), + errors=errors, + ) + +def zhipuai_config_option_schema( + hass: HomeAssistant, + options: dict[str, Any] | MappingProxyType[str, Any], +) -> dict: + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label="No", + value="none", + ) + ] + hass_apis.extend( + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ) + + schema = { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": options.get(CONF_LLM_HASS_API)}, + default="none", + ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + vol.Optional( + CONF_MAX_HISTORY_MESSAGES, + description={"suggested_value": options.get(CONF_MAX_HISTORY_MESSAGES)}, + default=RECOMMENDED_MAX_HISTORY_MESSAGES, + ): int, + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)}, + default=RECOMMENDED_CHAT_MODEL, + ): SelectSelector(SelectSelectorConfig(options=ZHIPUAI_MODELS)), + vol.Optional( + CONF_MAX_TOOL_ITERATIONS, + description={"suggested_value": options.get(CONF_MAX_TOOL_ITERATIONS, DEFAULT_MAX_TOOL_ITERATIONS)}, + default=DEFAULT_MAX_TOOL_ITERATIONS, + ): int, + vol.Optional( + CONF_COOLDOWN_PERIOD, + description={"suggested_value": options.get(CONF_COOLDOWN_PERIOD, DEFAULT_COOLDOWN_PERIOD)}, + default=DEFAULT_COOLDOWN_PERIOD, + ): vol.All( + vol.Coerce(float), + vol.Range(min=0, max=10), + msg="冷却时间必须在0到10秒之间" + ), + } + + if not options.get(CONF_RECOMMENDED, False): + schema.update({ + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + }) + + return schema + +class UnknownError(exceptions.HomeAssistantError): + pass + +class UnauthorizedError(exceptions.HomeAssistantError): + pass + +class InvalidAPIKey(exceptions.HomeAssistantError): + pass + +class ModelNotFound(exceptions.HomeAssistantError): + pass \ No newline at end of file diff --git a/custom_components/zhipuai/const.py b/custom_components/zhipuai/const.py new file mode 100644 index 0000000..65969a5 --- /dev/null +++ b/custom_components/zhipuai/const.py @@ -0,0 +1,25 @@ +import logging + +DOMAIN = "zhipuai" +LOGGER = logging.getLogger(__name__) +NAME = "自定义名称" +DEFAULT_NAME = "智谱清言" +CONF_RECOMMENDED = "recommended" +CONF_PROMPT = "prompt" +CONF_CHAT_MODEL = "chat_model" +RECOMMENDED_CHAT_MODEL = "GLM-4-Flash" +CONF_MAX_TOKENS = "max_tokens" +RECOMMENDED_MAX_TOKENS = 350 +CONF_TOP_P = "top_p" +RECOMMENDED_TOP_P = 0.7 +CONF_TEMPERATURE = "temperature" +RECOMMENDED_TEMPERATURE = 0.4 +CONF_MAX_HISTORY_MESSAGES = "max_history_messages" +RECOMMENDED_MAX_HISTORY_MESSAGES = 5 + +CONF_MAX_TOOL_ITERATIONS = "max_tool_iterations" +DEFAULT_MAX_TOOL_ITERATIONS = 20 +CONF_COOLDOWN_PERIOD = "cooldown_period" +DEFAULT_COOLDOWN_PERIOD = 3 + +ZHIPUAI_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions" diff --git a/custom_components/zhipuai/conversation.py b/custom_components/zhipuai/conversation.py new file mode 100644 index 0000000..86f858f --- /dev/null +++ b/custom_components/zhipuai/conversation.py @@ -0,0 +1,341 @@ +import json +import asyncio +import time +import re +from datetime import datetime +from typing import Any, Literal, TypedDict +from voluptuous_openapi import convert +import voluptuous as vol +from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.conversation import trace +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import device_registry as dr, intent, llm, template, entity_registry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import RGBColor +from .ai_request import send_ai_request +from homeassistant.util import ulid +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_P, + CONF_MAX_HISTORY_MESSAGES, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_MAX_HISTORY_MESSAGES, + RECOMMENDED_TOP_P, + CONF_MAX_TOOL_ITERATIONS, + CONF_COOLDOWN_PERIOD, + DEFAULT_MAX_TOOL_ITERATIONS, + DEFAULT_COOLDOWN_PERIOD, + ZHIPUAI_URL, +) +from .service_caller import get_service_caller + +class ChatCompletionMessageParam(TypedDict, total=False): + role: str + content: str | None + name: str | None + tool_calls: list["ChatCompletionMessageToolCallParam"] | None + +class Function(TypedDict, total=False): + name: str + arguments: str + +class ChatCompletionMessageToolCallParam(TypedDict): + id: str + type: str + function: Function + +class ChatCompletionToolParam(TypedDict): + type: str + function: dict[str, Any] + +def _format_tool(tool: llm.Tool, custom_serializer: Any | None) -> ChatCompletionToolParam: + tool_spec = { + "name": tool.name, + "parameters": convert(tool.parameters, custom_serializer=custom_serializer), + } + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionToolParam(type="function", function=tool_spec) + +class ZhipuAIConversationEntity(conversation.ConversationEntity, conversation.AbstractConversationAgent): + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: ConfigEntry) -> None: + self.entry = entry + self.history: dict[str, list[ChatCompletionMessageParam]] = {} + self._attr_unique_id = entry.entry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="智谱清言", + model="ChatGLM Pro", + entry_type=dr.DeviceEntryType.SERVICE, + ) + if self.entry.options.get(CONF_LLM_HASS_API): + self._attr_supported_features = conversation.ConversationEntityFeature.CONTROL + self.last_request_time = 0 + self.max_tool_iterations = min(entry.options.get(CONF_MAX_TOOL_ITERATIONS, DEFAULT_MAX_TOOL_ITERATIONS), 5) + self.cooldown_period = entry.options.get(CONF_COOLDOWN_PERIOD, DEFAULT_COOLDOWN_PERIOD) + self.llm_api = None + self.service_caller = None + + def _filter_response_content(self, content: str) -> str: + content = re.sub(r'```[\s\S]*?```', '', content) + content = re.sub(r'{[\s\S]*?}', '', content) + content = re.sub(r'(?m)^(import|from|def|class)\s+.*$', '', content) + if not re.search(r'[\u4e00-\u9fff]', content): + content = "您好,因为 Home Assistant 限制,请再次尝试,如果多次尝试失败请编写指令适配。" + return content.strip() + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + return MATCH_ALL + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + assist_pipeline.async_migrate_engine(self.hass, "conversation", self.entry.entry_id, self.entity_id) + conversation.async_set_agent(self.hass, self.entry, self) + self.entry.async_on_unload(self.entry.add_update_listener(self._async_entry_update_listener)) + self.service_caller = get_service_caller(self.hass) + + async def async_will_remove_from_hass(self) -> None: + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() + + async def async_process(self, user_input: conversation.ConversationInput) -> conversation.ConversationResult: + current_time = time.time() + if current_time - self.last_request_time < self.cooldown_period: + await asyncio.sleep(self.cooldown_period - (current_time - self.last_request_time)) + self.last_request_time = time.time() + + options = self.entry.options + intent_response = intent.IntentResponse(language=user_input.language) + tools: list[ChatCompletionToolParam] | None = None + user_name: str | None = None + llm_context = llm.LLMContext( + platform=DOMAIN, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=conversation.DOMAIN, + device_id=user_input.device_id, + ) + + if options.get(CONF_LLM_HASS_API) and options[CONF_LLM_HASS_API] != "none": + try: + self.llm_api = await llm.async_get_api(self.hass, options[CONF_LLM_HASS_API], llm_context) + tools = [_format_tool(tool, self.llm_api.custom_serializer) for tool in self.llm_api.tools][:8] + except HomeAssistantError as err: + LOGGER.warning("获取 LLM API 时出错,将继续使用基本功能:%s", err) + + if user_input.conversation_id is None: + conversation_id = ulid.ulid_now() + messages = [] + elif user_input.conversation_id in self.history: + conversation_id = user_input.conversation_id + messages = self.history[conversation_id] + else: + conversation_id = user_input.conversation_id + messages = [] + + max_history_messages = options.get(CONF_MAX_HISTORY_MESSAGES, RECOMMENDED_MAX_HISTORY_MESSAGES) + use_history = len(messages) < max_history_messages + + if user_input.context and user_input.context.user_id and (user := await self.hass.auth.async_get_user(user_input.context.user_id)): + user_name = user.name + + try: + er = entity_registry.async_get(self.hass) + exposed_entities = [ + er.async_get(entity_id) for entity_id in self.hass.states.async_entity_ids() + if er.async_get(entity_id) and not er.async_get(entity_id).hidden + ] + + prompt_parts = [ + template.Template( + llm.BASE_PROMPT + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + "exposed_entities": exposed_entities, + }, + parse_result=False, + ) + ] + except TemplateError as err: + LOGGER.error("渲染提示时出错: %s", err) + error_message = f"抱歉,我的模板有问题: {err}" + filtered_error = self._filter_response_content(error_message) + intent_response.async_set_error(intent.IntentResponseErrorCode.UNKNOWN, filtered_error) + return conversation.ConversationResult(response=intent_response, conversation_id=conversation_id) + + if self.llm_api: + prompt_parts.append(self.llm_api.api_prompt) + + prompt = "\n".join(prompt_parts) + + messages = [ + ChatCompletionMessageParam(role="system", content=prompt), + *(messages if use_history else []), + ChatCompletionMessageParam(role="user", content=user_input.text), + ] + if len(messages) > max_history_messages + 1: + messages = [messages[0]] + messages[-(max_history_messages):] + + LOGGER.debug("提示: %s", messages) + LOGGER.debug("工具: %s", tools) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + {"messages": messages, "tools": self.llm_api.tools if self.llm_api else None}, + ) + + api_key = self.entry.data[CONF_API_KEY] + try: + for _iteration in range(self.max_tool_iterations): + payload = { + "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + "messages": messages[-10:], + "max_tokens": min(options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), 1000), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "request_id": conversation_id, + } + if tools: + payload["tools"] = tools + + result = await send_ai_request(api_key, payload) + + LOGGER.debug("AI 响应: %s", result) + response = result["choices"][0]["message"] + + messages.append(response) + tool_calls = response.get("tool_calls") + + if not tool_calls: + break + + for tool_call in tool_calls: + try: + tool_input = llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=json.loads(tool_call["function"]["arguments"]), + ) + tool_response = await self._handle_tool_call(tool_input, user_input.text) + + formatted_response = json.dumps(tool_response) + messages.append( + ChatCompletionMessageParam( + role="tool", + tool_call_id=tool_call["id"], + content=formatted_response, + ) + ) + except Exception as e: + LOGGER.error("工具调用失败: %s", e) + error_message = f"操作执行失败: {str(e)}" + messages.append( + ChatCompletionMessageParam( + role="tool", + tool_call_id=tool_call["id"], + content=error_message, + ) + ) + + final_content = response.get("content", "") + filtered_content = self._filter_response_content(final_content) + + self.history[conversation_id] = messages + intent_response.async_set_speech(filtered_content) + return conversation.ConversationResult(response=intent_response, conversation_id=conversation_id) + + except Exception as err: + LOGGER.error("处理 AI 请求时出错: %s", err) + error_message = f"处理请求时出错: {err}" + filtered_error = self._filter_response_content(error_message) + intent_response.async_set_error(intent.IntentResponseErrorCode.UNKNOWN, filtered_error) + return conversation.ConversationResult(response=intent_response, conversation_id=conversation_id) + + async def _handle_tool_call(self, tool_input: llm.ToolInput, user_input: str): + intent_name = tool_input.tool_name.lower() + + if any(keyword in user_input.lower() for keyword in ["调用", "服务", "动作执行", "执行服务", "使用服务"]): + return await self.service_caller.handle_service_call(tool_input) + + if intent_name.startswith("hass"): + method_name = f"_handle_{intent_name[4:]}_intent" + if hasattr(self, method_name): + return await getattr(self, method_name)(tool_input) + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_turn_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_get_state_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_set_position_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_light_set_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_climate_get_temperature_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_shopping_list_add_item_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_get_weather_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_list_add_item_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_vacuum_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_media_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_set_volume_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + async def _handle_timer_intent(self, tool_input: llm.ToolInput): + return await self.llm_api.async_call_tool(tool_input) + + @staticmethod + async def _async_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + entity = hass.data[DOMAIN].get(entry.entry_id) + if entity: + entity.entry = entry + entity.max_tool_iterations = min(entry.options.get(CONF_MAX_TOOL_ITERATIONS, DEFAULT_MAX_TOOL_ITERATIONS), 5) + entity.cooldown_period = entry.options.get(CONF_COOLDOWN_PERIOD, DEFAULT_COOLDOWN_PERIOD) + await entity.async_update_ha_state() + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + entity = ZhipuAIConversationEntity(config_entry) + async_add_entities([entity]) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entity + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, ["conversation"]): + hass.data[DOMAIN].pop(entry.entry_id, None) + return unload_ok diff --git a/custom_components/zhipuai/manifest.json b/custom_components/zhipuai/manifest.json new file mode 100644 index 0000000..b16a007 --- /dev/null +++ b/custom_components/zhipuai/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "zhipuai", + "name": "\u667a\u8c31\u6e05\u8a00", + "codeowners": ["@knoop7"], + "config_flow": true, + "documentation": "https://github.com/knoop7/zhipuai", + "integration_type": "hub", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/knoop7/zhipuai/issues", + "requirements": [], + "version": "2024.10.18" +} diff --git a/custom_components/zhipuai/service_caller.py b/custom_components/zhipuai/service_caller.py new file mode 100644 index 0000000..44c5357 --- /dev/null +++ b/custom_components/zhipuai/service_caller.py @@ -0,0 +1,270 @@ +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent, area_registry, device_registry, entity_registry +from homeassistant.util import color as color_util +from homeassistant.const import ATTR_DEVICE_CLASS +from .const import LOGGER + +class ServiceCaller: + def __init__(self, hass: HomeAssistant): + self.hass = hass + self.area_reg = area_registry.async_get(hass) + self.device_reg = device_registry.async_get(hass) + self.entity_reg = entity_registry.async_get(hass) + + + async def call_service(self, domain: str, service: str, data: dict): + try: + await self.hass.services.async_call(domain, service, data) + return {"success": True, "message": f"成功调用服务 {domain}.{service}", "data": data} + except Exception as e: + LOGGER.error(f"调用服务 {domain}.{service} 时出错:{str(e)}") + return {"success": False, "error": str(e), "data": data} + + + async def handle_service_call(self, tool_input): + if isinstance(tool_input, intent.IntentResponse): + domain = tool_input.intent.intent_type.split('.')[0] + action = tool_input.intent.intent_type.split('.')[-1] + name = tool_input.slots.get('name', {}).get('value') + area = tool_input.slots.get('area', {}).get('value') + data = {k: v['value'] for k, v in tool_input.slots.items() if k not in ['name', 'area']} + else: + domain = tool_input.tool_args.get('domain', 'light') + action = tool_input.tool_args.get('action', 'turn_on') + name = tool_input.tool_args.get('name') + area = tool_input.tool_args.get('area') + data = {k: v for k, v in tool_input.tool_args.items() if k not in ['domain', 'action', 'name', 'area']} + + if domain == "light": + return await self.handle_light_intent(action, name, area, data) + if domain == "fan": + return await self.handle_fan_intent(action, name, area, data) + if domain == "homeassistant" and action == "restart": + return await self.restart_hass() + elif domain == "light": + return await self.handle_light_intent(action, name, area, data) + elif domain == "cover": + return await self.handle_cover_intent(action, name, area, data) + elif domain == "lock": + return await self.handle_lock_intent(action, name, area, data) + else: + return await self.handle_generic_intent(domain, action, name, area, data) + + + async def handle_fan_intent(self, action: str, name: str, area: str, data: dict): + fan_entities = await self._get_entities("fan", name, area) + if not fan_entities: + return {"success": False, "error": f"未找到匹配的风扇"} + + results = [] + for entity_id in fan_entities: + service_data = {"entity_id": entity_id} + if action in ["turn_on", "turn_off"]: + service = action + elif action == "set_speed": + service = "set_percentage" + service_data["percentage"] = data.get("speed") + else: + return {"success": False, "error": f"不支持的风扇操作: {action}"} + + service_data.update(data) + result = await self.call_service("fan", service, service_data) + results.append(result) + + return {"success": True, "message": f"执行了 {len(results)} 个风扇操作", "details": results} + + + def _process_light_attributes(self, data: dict): + processed_data = {} + if "color" in data: + rgb_color = self._convert_color_to_rgb(data["color"]) + if rgb_color: + processed_data["rgb_color"] = rgb_color + if "brightness" in data: + processed_data["brightness"] = int(data["brightness"]) + if "color_temp" in data: + processed_data["color_temp"] = self._convert_color_temp(data["color_temp"]) + return processed_data + + def _convert_color_to_rgb(self, color): + if isinstance(color, color_util.RGBColor): + return f"{color.red},{color.green},{color.blue}" + elif isinstance(color, (list, tuple)) and len(color) == 3: + return f"{color[0]},{color[1]},{color[2]}" + elif isinstance(color, str): + color = color.lower().replace(" ", "") + try: + rgb = color_util.color_name_to_rgb(color) + return f"{rgb[0]},{rgb[1]},{rgb[2]}" + except ValueError: + LOGGER.error(f"无法将颜色 '{color}' 转换为 RGB") + return None + else: + LOGGER.error(f"不支持的颜色格式: {color}") + return None + + + def _convert_color_temp(self, color_temp): + try: + return str(int(color_temp)) + except ValueError: + LOGGER.error(f"无效的色温值: {color_temp}") + return None + + async def handle_light_intent(self, action: str, name: str, area: str, data: dict): + light_entities = await self._get_entities("light", name, area) + if not light_entities: + return {"success": False, "error": f"未找到匹配的灯光"} + + results = [] + for entity_id in light_entities: + service_data = {"entity_id": entity_id} + if action in ["turn_on", "turn_off"]: + service = action + if action == "turn_on": + service_data.update(self._process_light_attributes(data)) + else: + return {"success": False, "error": f"不支持的灯光操作: {action}"} + + result = await self.call_service("light", service, service_data) + results.append(result) + + return {"success": True, "message": f"执行了 {len(results)} 个灯光操作", "details": results} + + async def restart_hass(self, data: dict = None): + try: + await self.hass.services.async_call("homeassistant", "restart", data or {}) + return {"success": True, "message": "Home Assistant 正在重启"} + except Exception as e: + LOGGER.error(f"重启 Home Assistant 时出错:{str(e)}") + return {"success": False, "error": f"重启 Home Assistant 失败:{str(e)}"} + + + async def handle_cover_intent(self, action: str, name: str, area: str, data: dict): + cover_entities = await self._get_entities("cover", name, area, + device_classes={"shutter", "blind", "curtain", "awning", "window", "shade"}) + if not cover_entities: + return {"success": False, "error": f"未找到匹配的窗帘"} + + results = [] + for entity_id in cover_entities: + service_data = {"entity_id": entity_id} + if action in ["open", "close", "stop"]: + service = f"{action}_cover" + elif action == "set_position": + service = "set_cover_position" + service_data["position"] = data.get("position") + else: + return {"success": False, "error": f"不支持的窗帘操作: {action}"} + + service_data.update(data) + result = await self.call_service("cover", service, service_data) + results.append(result) + + return {"success": True, "message": f"执行了 {len(results)} 个窗帘操作", "details": results} + + + async def handle_lock_intent(self, action: str, name: str, area: str, data: dict): + lock_entities = await self._get_entities("lock", name, area) + if not lock_entities: + return {"success": False, "error": f"未找到匹配的门锁"} + + results = [] + for entity_id in lock_entities: + if action in ["lock", "unlock"]: + service_data = {"entity_id": entity_id} + service_data.update(data) + result = await self.call_service("lock", action, service_data) + results.append(result) + else: + return {"success": False, "error": f"不支持的门锁操作: {action}"} + + return {"success": True, "message": f"执行了 {len(results)} 个门锁操作", "details": results} + + + async def handle_generic_intent(self, domain: str, service: str, name: str, area: str, data: dict): + entities = await self._get_entities(domain, name, area) + if not entities: + return {"success": False, "error": f"未找到匹配的 {domain} 实体"} + + results = [] + for entity_id in entities: + service_data = {**data, "entity_id": entity_id} + result = await self.call_service(domain, service, service_data) + results.append(result) + + return {"success": True, "message": f"执行了 {len(results)} 个 {domain} 操作", "details": results} + + + async def _get_entities(self, domain: str, name: str = None, area: str = None, device_classes: set = None): + entities = self.hass.states.async_entity_ids(domain) + matched_entities = [] + + for entity_id in entities: + entity = self.hass.states.get(entity_id) + if entity is None: + continue + + # 检查设备类型 + if device_classes: + entity_device_class = entity.attributes.get(ATTR_DEVICE_CLASS) + if entity_device_class not in device_classes: + continue + + # 检查名称 + if name and name.lower() not in entity.name.lower(): + continue + + # 检查区域 + if area: + entity_entry = self.entity_reg.async_get(entity_id) + if entity_entry and entity_entry.area_id: + area_entry = self.area_reg.async_get_area(entity_entry.area_id) + if area_entry and area.lower() not in area_entry.name.lower(): + continue + else: + continue + + matched_entities.append(entity_id) + + return matched_entities + + + def _process_light_attributes(self, data: dict): + processed_data = {} + if "color" in data: + rgb_color = self._convert_color_to_rgb(data["color"]) + if rgb_color: + processed_data["rgb_color"] = rgb_color + if "brightness" in data: + processed_data["brightness"] = int(data["brightness"]) + if "color_temp" in data: + processed_data["color_temp"] = int(data["color_temp"]) + return processed_data + + + def _convert_color_to_rgb(self, color): + if isinstance(color, (list, tuple)) and len(color) == 3: + return color + elif isinstance(color, str): + return color_util.color_name_to_rgb(color.lower().replace(" ", "")) + return None + + + + async def get_available_services(self): + services = {} + for domain in self.hass.services.async_services(): + domain_services = {} + for service in self.hass.services.async_services()[domain]: + service_data = self.hass.services.async_services()[domain][service] + domain_services[service] = { + "description": service_data.get("description", ""), + "fields": service_data.get("fields", {}) + } + services[domain] = domain_services + return services + + +def get_service_caller(hass: HomeAssistant): + return ServiceCaller(hass) diff --git a/custom_components/zhipuai/translations/en.json b/custom_components/zhipuai/translations/en.json new file mode 100644 index 0000000..e725ee4 --- /dev/null +++ b/custom_components/zhipuai/translations/en.json @@ -0,0 +1,59 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Custom Name", + "api_key": "API Key" + }, + "description": "Get the key: [Click link](https://open.bigmodel.cn/console/modelft/dataset)" + } + }, + "error": { + "cannot_connect": "Unable to connect to the service", + "invalid_auth": "Invalid authentication", + "unknown": "Configuration saved, no further action needed", + "cooldown_too_small": "Cooldown value {value} is too small, please set a value greater than or equal to 0!", + "cooldown_too_large": "Cooldown value {value} is too large, please set a value less than or equal to 10!", + "invalid_option": "Invalid option value" + }, + "abort": { + "single_instance_allowed": "Already configured, only one configuration entry is allowed." + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "Command", + "chat_model": "Chat Model", + "max_tokens": "Maximum number of tokens returned in response", + "temperature": "Temperature", + "top_p": "Top P", + "llm_hass_api": "Options", + "recommended": "Recommended model settings (The default is the free general-purpose 128K model. If needed, other paid models can be selected, and the actual cost is not high.)", + "max_history_messages": "Maximum number of history messages", + "max_tool_iterations": "Maximum number of tool calls", + "cooldown_period": "Cooldown time (seconds)" + }, + "data_description": { + "prompt": "Instructions on how the LLM should respond. This can be a template.", + "chat_model": "Fill in the chat model to use", + "max_tokens": "Set the maximum number of tokens returned in response", + "temperature": "Controls output randomness (0-2)", + "top_p": "Controls output diversity (0-1)", + "llm_hass_api": "Home Assistant LLM API", + "recommended": "Use recommended model settings", + "max_history_messages": "Set the maximum number of history messages to retain. Purpose: Controls memory functionality to ensure smooth contextual conversation. For home devices, it’s best to set within 5 times to handle failed requests. For other daily conversations, set a threshold above 10.", + "max_tool_iterations": "Set the maximum number of tool calls in a single conversation. Purpose: Sets a call request threshold to avoid No system deadlock due to errors, especially designed for low-performance hosts. Recommended: 10-20 times.", + "cooldown_period": "Set the minimum interval time between two conversation requests (0-10 seconds). Purpose: Requests will be delayed before being sent. Recommended: within 3 seconds. Ensures the request isn't rejected due to rate limits." + } + } + } + }, + "exceptions": { + "invalid_config_entry": { + "message": "The provided configuration entry is invalid. Received: {config_entry}" + } + } +} diff --git a/custom_components/zhipuai/translations/zh-Hans.json b/custom_components/zhipuai/translations/zh-Hans.json new file mode 100644 index 0000000..df0a9cf --- /dev/null +++ b/custom_components/zhipuai/translations/zh-Hans.json @@ -0,0 +1,59 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "自定义名称", + "api_key": "API 密钥" + }, + "description": "获取密钥:[点击链接](https://open.bigmodel.cn/console/modelft/dataset)" + } + }, + "error": { + "cannot_connect": "无法连接到服务", + "invalid_auth": "验证无效", + "unknown": "已保存配置,无需二次", + "cooldown_too_small": "冷却时间值 {value} 太小,请设置大于等于 0 的值!", + "cooldown_too_large": "冷却时间值 {value} 太大,请设置小于等于 10 的值!", + "invalid_option": "无效的选项值" + }, + "abort": { + "single_instance_allowed": "已经配置,只允许一个配置条目。" + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "指令", + "chat_model": "聊天模型", + "max_tokens": "响应中返回的最大令牌数", + "temperature": "温度", + "top_p": "Top P", + "llm_hass_api": "选项", + "recommended": "推荐的模型设置", + "max_history_messages": "最大历史消息数", + "max_tool_iterations": "最大工具调用次数", + "cooldown_period": "冷却时间(秒)" + }, + "data_description": { + "prompt": "指示 LLM 应如何响应。这可以是一个模板。", + "chat_model": "请选择要使用的聊天模型 (首选默认已选择免费通用128K模型,若有富哥需求更好的体验可以选择支持其余付费模型,实际费用并不高,具体请查询官方网页计费标准)", + "max_tokens": "设置响应中返回的最大令牌数", + "temperature": "控制输出的随机性(0-2)", + "top_p": "控制输出的多样性(0-1)", + "llm_hass_api": "Home Assistant LLM API", + "recommended": "使用推荐的模型设置", + "max_history_messages": "设置要保留的最大历史消息数,作用:控制输入内容的记忆功能,记忆功能可以保证上下文的流畅对话。通常控制家庭设备在5次内最好,有效针对于无法顺利请求。其他日常对话可以设置阀值10次以上。", + "max_tool_iterations": "设置单次对话中最大的工具调用次数,作用:针对于系统LLM的调用请求的调用阀值,若有错误可以保证系统不被卡死,特别针对于各类性能偏弱的小主机的设计,建议设置20-30次。", + "cooldown_period": "设置两次对话请求之间的最小间隔时间(0-10秒)作用:请求会延迟等待一段时间再发送,建议设置3秒内。保证因为速率的因素所导致内容的发送请求失败。" + } + } + } + }, + "exceptions": { + "invalid_config_entry": { + "message": "提供的配置条目无效。得到的是 {config_entry}" + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..e59be77 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "智谱清言", + "render_readme": true, + "homeassistant": "2024.8.0" +}