diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..edb2e86 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae5d9f6 --- /dev/null +++ b/README.md @@ -0,0 +1,249 @@ + +# 智谱清言 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 的智能家居管理者,你的名字叫“自定义”,我将为您提供智能家居信息和问题的解答。请查看以下可用设备、状态及操作示例。 + + +### 可用设备展示 +```csv +entity_id,name,state,aliases +{% for entity in exposed_entities -%} +{{ entity.entity_id }},{{ entity.name }},{{ entity.state }},{{entity.aliases | join('/')}} +{% endfor -%} +``` + +### 服务执行指令示例 +```json +{ + "list": [ + { + "domain": "light", + "service": "turn_on", + "service_data": { + "entity_id": "light.living_room" + } + }, + { + "domain": "switch", + "service": "turn_off", + "service_data": { + "entity_id": "switch.kitchen_light" + } + }, + { + "domain": "climate", + "service": "set_temperature", + "service_data": { + "entity_id": "climate.bedroom", + "temperature": 22 + } + }, + { + "domain": "media_player", + "service": "media_play", + "service_data": { + "entity_id": "media_player.tv" + } + } + ] +} +``` + +### 逻辑修复和执行约束 +1. **状态检查**:确保设备状态有变化时才执行命令,避免重复操作。 +2. **过滤不必要的命令**:`HassTurnOff`、`HassTurnOn`等冗余命令不再生成,直接使用 `execute_services` 函数。 +3. **简化用户操作**:在响应中只返回必要信息,减少多余内容。 + +### 示例指令 + +1. **将客厅灯打开** + ```json + { + "domain": "light", + "service": "turn_on", + "service_data": { + "entity_id": "light.living_room" + } + } + ``` + +2. **将厨房开关关闭** + ```json + { + "domain": "switch", + "service": "turn_off", + "service_data": { + "entity_id": "switch.kitchen_light" + } + } + ``` + +### 检查避免重复执行 +在执行服务时先检查当前状态,确保设备在目标状态时不会重复执行。例如: +```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 %} + } +} +```` + +--- + +### 使用内置 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 0000000..22e4a92 Binary files /dev/null and b/custom_components/.DS_Store differ diff --git a/custom_components/zhipuai/__init__.py b/custom_components/zhipuai/__init__.py new file mode 100644 index 0000000..f5b6b08 --- /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 + +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 = [] + + @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 + # Call all cleanup callbacks + 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 \ No newline at end of file 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..ec8dd6c --- /dev/null +++ b/custom_components/zhipuai/const.py @@ -0,0 +1,23 @@ +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 \ No newline at end of file diff --git a/custom_components/zhipuai/conversation.py b/custom_components/zhipuai/conversation.py new file mode 100644 index 0000000..73a92c5 --- /dev/null +++ b/custom_components/zhipuai/conversation.py @@ -0,0 +1,280 @@ +import aiohttp +import json +import asyncio +import time +from typing import Any, Literal, TypedDict +from voluptuous_openapi import convert +import voluptuous as vol +from aiohttp import TCPConnector +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 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 = "https://open.bigmodel.cn/api/paas/v4/chat/completions" + +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) + + @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)) + + 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) + llm_api: llm.APIInstance | None = None + 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: + llm_api = await llm.async_get_api(self.hass, options[CONF_LLM_HASS_API], llm_context) + tools = [_format_tool(tool, llm_api.custom_serializer) for tool in 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) + intent_response.async_set_error(intent.IntentResponseErrorCode.UNKNOWN, f"抱歉,我的模板有问题: {err}") + return conversation.ConversationResult(response=intent_response, conversation_id=conversation_id) + + if llm_api: + prompt_parts.append(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": llm_api.tools if llm_api else None}, + ) + + api_key = self.entry.data[CONF_API_KEY] + try: + connector = TCPConnector(ssl=False) + async with aiohttp.ClientSession(connector=connector) as session: + for _iteration in range(self.max_tool_iterations): + try: + 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 + + 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() + except Exception as err: + raise HomeAssistantError(f"与 AI 通信时出错: {err}") + + LOGGER.debug("AI 响应: %s", result) + response = result["choices"][0]["message"] + + if "HassTurn" in response.get("content", "您好,请再试一次哦~"): + continue + + messages.append(response) + tool_calls = response.get("tool_calls") + + if not tool_calls or not llm_api: + 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 llm_api.async_call_tool(tool_input) + messages.append( + ChatCompletionMessageParam( + role="tool", + tool_call_id=tool_call["id"], + content=json.dumps(tool_response), + ) + ) + except Exception as e: + LOGGER.error("工具调用失败: %s", e) + + except Exception as err: + LOGGER.error("处理 AI 请求时出错: %s", err) + intent_response.async_set_error(intent.IntentResponseErrorCode.UNKNOWN, f"处理请求时出错: {err}") + return conversation.ConversationResult(response=intent_response, conversation_id=conversation_id) + + self.history[conversation_id] = messages + intent_response.async_set_speech(response.get("content")) + return conversation.ConversationResult(response=intent_response, conversation_id=conversation_id) + + @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 \ No newline at end of file diff --git a/custom_components/zhipuai/manifest.json b/custom_components/zhipuai/manifest.json new file mode 100644 index 0000000..d6413ef --- /dev/null +++ b/custom_components/zhipuai/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "zhipuai", + "name": "智谱清言", + "version": "2024.10.14", + "codeowners": ["@KNOOP7"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/knoop7/zhipuai", + "issue_tracker": "https://github.com/knoop7/zhipuai/issues", + "homekit": {}, + "iot_class": "cloud_polling", + "requirements": ["aiohttp"], + "ssdp": [], + "zeroconf": [], + "manufacturer": "KNOOP7" +} 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" +}