From 2a4911f5f804be824d39812fe456e12d72799af0 Mon Sep 17 00:00:00 2001 From: SMKRV Date: Tue, 26 Nov 2024 02:04:23 +0300 Subject: [PATCH] Release v2.0.0-alpha --- ha_text_ai/__init__.py | 295 +++++++++++++++++++++++++ ha_text_ai/api_client.py | 180 +++++++++++++++ ha_text_ai/config_flow.py | 247 +++++++++++++++++++++ ha_text_ai/const.py | 201 +++++++++++++++++ ha_text_ai/coordinator.py | 377 ++++++++++++++++++++++++++++++++ ha_text_ai/manifest.json | 28 +++ ha_text_ai/sensor.py | 294 +++++++++++++++++++++++++ ha_text_ai/services.yaml | 75 +++++++ ha_text_ai/translations/de.json | 261 ++++++++++++++++++++++ ha_text_ai/translations/en.json | 260 ++++++++++++++++++++++ ha_text_ai/translations/ru.json | 260 ++++++++++++++++++++++ 11 files changed, 2478 insertions(+) create mode 100644 ha_text_ai/__init__.py create mode 100644 ha_text_ai/api_client.py create mode 100644 ha_text_ai/config_flow.py create mode 100644 ha_text_ai/const.py create mode 100644 ha_text_ai/coordinator.py create mode 100644 ha_text_ai/manifest.json create mode 100644 ha_text_ai/sensor.py create mode 100644 ha_text_ai/services.yaml create mode 100644 ha_text_ai/translations/de.json create mode 100644 ha_text_ai/translations/en.json create mode 100644 ha_text_ai/translations/ru.json diff --git a/ha_text_ai/__init__.py b/ha_text_ai/__init__.py new file mode 100644 index 0000000..5c639e0 --- /dev/null +++ b/ha_text_ai/__init__.py @@ -0,0 +1,295 @@ +"""The HA Text AI integration.""" +from __future__ import annotations + +import logging +import os +import shutil +from datetime import datetime, timedelta +from typing import Any, Dict + +import voluptuous as vol +from async_timeout import timeout + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import aiohttp_client + +from .coordinator import HATextAICoordinator +from .api_client import APIClient +from .const import ( + DOMAIN, + PLATFORMS, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_MAX_TOKENS, + CONF_API_ENDPOINT, + CONF_REQUEST_INTERVAL, + CONF_API_PROVIDER, + CONF_CONTEXT_MESSAGES, + API_PROVIDER_OPENAI, + API_PROVIDER_ANTHROPIC, + DEFAULT_MODEL, + DEFAULT_TEMPERATURE, + DEFAULT_MAX_TOKENS, + DEFAULT_OPENAI_ENDPOINT, + DEFAULT_ANTHROPIC_ENDPOINT, + DEFAULT_REQUEST_INTERVAL, + DEFAULT_CONTEXT_MESSAGES, + API_TIMEOUT, + SERVICE_ASK_QUESTION, + SERVICE_CLEAR_HISTORY, + SERVICE_GET_HISTORY, + SERVICE_SET_SYSTEM_PROMPT, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ + vol.Required("instance"): cv.string, + vol.Required("question"): cv.string, + vol.Optional("system_prompt"): cv.string, + vol.Optional("model"): cv.string, + vol.Optional("temperature"): cv.positive_float, + vol.Optional("max_tokens"): cv.positive_int, + vol.Optional("context_messages"): cv.positive_int, +}) + +SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({ + vol.Required("instance"): cv.string, + vol.Required("prompt"): cv.string, +}) + +SERVICE_SCHEMA_GET_HISTORY = vol.Schema({ + vol.Required("instance"): cv.string, + vol.Optional("limit"): cv.positive_int, + vol.Optional("filter_model"): cv.string, +}) + +def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAICoordinator: + """Get coordinator by instance name.""" + if instance.startswith("sensor."): + instance = instance.replace("sensor.ha_text_ai_", "", 1) + + for entry_id, coord in hass.data[DOMAIN].items(): + if isinstance(coord, HATextAICoordinator) and coord.instance_name.lower() == instance.lower(): + return coord + + raise HomeAssistantError(f"Instance {instance} not found") + +async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: + """Set up the HA Text AI component.""" + hass.data.setdefault(DOMAIN, {}) + + try: + source = os.path.join(os.path.dirname(__file__), 'icons', 'icon.svg') + dest_dir = os.path.join(hass.config.path('www'), 'icons') + os.makedirs(dest_dir, exist_ok=True) + dest = os.path.join(dest_dir, 'icon.png') + if not os.path.exists(dest): + shutil.copyfile(source, dest) + except Exception as ex: + _LOGGER.warning("Failed to copy custom icon: %s", str(ex)) + + async def async_ask_question(call: ServiceCall) -> None: + """Handle ask_question service.""" + try: + coordinator = get_coordinator_by_instance(hass, call.data["instance"]) + await coordinator.async_ask_question( + question=call.data["question"], + model=call.data.get("model"), + temperature=call.data.get("temperature"), + max_tokens=call.data.get("max_tokens"), + system_prompt=call.data.get("system_prompt"), + context_messages=call.data.get("context_messages"), + ) + except Exception as err: + _LOGGER.error("Error asking question: %s", str(err)) + raise HomeAssistantError(f"Failed to process question: {str(err)}") + + async def async_clear_history(call: ServiceCall) -> None: + """Handle clear_history service.""" + try: + coordinator = get_coordinator_by_instance(hass, call.data["instance"]) + await coordinator.async_clear_history() + except Exception as err: + _LOGGER.error("Error clearing history: %s", str(err)) + raise HomeAssistantError(f"Failed to clear history: {str(err)}") + + async def async_get_history(call: ServiceCall) -> list: + """Handle get_history service.""" + try: + coordinator = get_coordinator_by_instance(hass, call.data["instance"]) + return await coordinator.async_get_history( + limit=call.data.get("limit"), + filter_model=call.data.get("filter_model") + ) + except Exception as err: + _LOGGER.error("Error getting history: %s", str(err)) + raise HomeAssistantError(f"Failed to get history: {str(err)}") + + async def async_set_system_prompt(call: ServiceCall) -> None: + """Handle set_system_prompt service.""" + try: + coordinator = get_coordinator_by_instance(hass, call.data["instance"]) + await coordinator.async_set_system_prompt(call.data["prompt"]) + except Exception as err: + _LOGGER.error("Error setting system prompt: %s", str(err)) + raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") + + hass.services.async_register( + DOMAIN, + SERVICE_ASK_QUESTION, + async_ask_question, + schema=SERVICE_SCHEMA_ASK_QUESTION + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_HISTORY, + async_clear_history, + schema=vol.Schema({vol.Required("instance"): cv.string}) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_HISTORY, + async_get_history, + schema=SERVICE_SCHEMA_GET_HISTORY + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_SYSTEM_PROMPT, + async_set_system_prompt, + schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT + ) + + return True + +async def async_check_api(session, endpoint: str, headers: dict, provider: str) -> bool: + """Check API availability for different providers.""" + try: + if provider == API_PROVIDER_ANTHROPIC: + check_url = f"{endpoint}/v1/models" + else: # OpenAI + check_url = f"{endpoint}/models" + + async with timeout(API_TIMEOUT): + async with session.get(check_url, headers=headers) as response: + if response.status in [200, 404]: + return True + elif response.status == 401: + raise ConfigEntryNotReady("Invalid API key") + elif response.status == 429: + _LOGGER.warning("Rate limit exceeded during API check") + return False + else: + _LOGGER.error("API check failed with status: %d", response.status) + return False + except Exception as ex: + _LOGGER.error("API check error: %s", str(ex)) + return False + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up HA Text AI from a config entry.""" + try: + if CONF_API_PROVIDER not in entry.data: + _LOGGER.error("API provider not specified") + raise ConfigEntryNotReady("API provider is required") + + session = aiohttp_client.async_get_clientsession(hass) + api_provider = entry.data.get(CONF_API_PROVIDER) + model = entry.data.get(CONF_MODEL, DEFAULT_MODEL) + endpoint = entry.data.get( + CONF_API_ENDPOINT, + DEFAULT_OPENAI_ENDPOINT if api_provider == API_PROVIDER_OPENAI + else DEFAULT_ANTHROPIC_ENDPOINT + ).rstrip('/') + api_key = entry.data[CONF_API_KEY] + instance_name = entry.data.get(CONF_NAME, entry.entry_id) + is_anthropic = api_provider == API_PROVIDER_ANTHROPIC + + headers = { + "Content-Type": "application/json", + "Accept": "application/json" + } + + if is_anthropic: + headers["x-api-key"] = api_key + headers["anthropic-version"] = "2023-06-01" + else: + headers["Authorization"] = f"Bearer {api_key}" + + if not await async_check_api(session, endpoint, headers, api_provider): + raise ConfigEntryNotReady("API connection failed") + + _LOGGER.debug("Creating API client for %s with endpoint %s", api_provider, endpoint) + + api_client = APIClient( + session=session, + endpoint=endpoint, + headers=headers, + api_provider=api_provider, + model=model, + ) + + coordinator = HATextAICoordinator( + hass=hass, + client=api_client, + model=model, + update_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL), + instance_name=instance_name, + max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS), + temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE), + is_anthropic=is_anthropic, + context_messages=entry.data.get( + CONF_CONTEXT_MESSAGES, + DEFAULT_CONTEXT_MESSAGES + ), + ) + + coordinator.data = coordinator._initial_state.copy() + _LOGGER.debug(f"Initial state set for coordinator {instance_name}") + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + _LOGGER.info( + "Successfully set up %s instance '%s' with model %s", + api_provider, + instance_name, + model + ) + + return True + + except Exception as ex: + _LOGGER.exception("Setup error: %s", str(ex)) + raise ConfigEntryNotReady(f"Setup error: {str(ex)}") from ex + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + try: + if entry.entry_id in hass.data[DOMAIN]: + coordinator = hass.data[DOMAIN][entry.entry_id] + + if hasattr(coordinator.client, 'shutdown'): + await coordinator.client.shutdown() + + await coordinator.async_shutdown() + hass.data[DOMAIN].pop(entry.entry_id) + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + except Exception as ex: + _LOGGER.exception("Error unloading entry: %s", str(ex)) + return False diff --git a/ha_text_ai/api_client.py b/ha_text_ai/api_client.py new file mode 100644 index 0000000..727af26 --- /dev/null +++ b/ha_text_ai/api_client.py @@ -0,0 +1,180 @@ +"""API Client for HA Text AI.""" +import logging +import asyncio +from typing import Any, Dict, List, Optional +from aiohttp import ClientSession, ClientTimeout +from async_timeout import timeout + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from .const import ( + API_TIMEOUT, + API_RETRY_COUNT, + API_PROVIDER_ANTHROPIC, + MIN_TEMPERATURE, + MAX_TEMPERATURE, + MIN_MAX_TOKENS, + MAX_MAX_TOKENS, +) + +_LOGGER = logging.getLogger(__name__) + +class APIClient: + """API Client for OpenAI and Anthropic.""" + + def __init__( + self, + session: ClientSession, + endpoint: str, + headers: Dict[str, str], + api_provider: str, + model: str, + ) -> None: + """Initialize API client.""" + self.session = session + self.endpoint = endpoint + self.headers = headers + self.api_provider = api_provider + self.model = model + self.timeout = ClientTimeout(total=API_TIMEOUT) + + def _validate_parameters( + self, + temperature: float, + max_tokens: int, + ) -> None: + """Validate API parameters.""" + if not MIN_TEMPERATURE <= temperature <= MAX_TEMPERATURE: + raise ValueError( + f"Temperature must be between {MIN_TEMPERATURE} and {MAX_TEMPERATURE}" + ) + if not MIN_MAX_TOKENS <= max_tokens <= MAX_MAX_TOKENS: + raise ValueError( + f"Max tokens must be between {MIN_MAX_TOKENS} and {MAX_MAX_TOKENS}" + ) + + async def _make_request( + self, + url: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + """Make API request with retry logic.""" + for attempt in range(API_RETRY_COUNT): + try: + async with timeout(API_TIMEOUT): + async with self.session.post( + url, + json=payload, + headers=self.headers, + timeout=self.timeout + ) as response: + if response.status != 200: + error_data = await response.json() + raise HomeAssistantError(f"API error: {error_data}") + return await response.json() + except asyncio.TimeoutError: + if attempt == API_RETRY_COUNT - 1: + raise HomeAssistantError("API request timed out") + await asyncio.sleep(1 * (attempt + 1)) + except Exception as e: + if attempt == API_RETRY_COUNT - 1: + raise + _LOGGER.warning("API request failed, retrying: %s", str(e)) + await asyncio.sleep(1 * (attempt + 1)) + + async def create( + self, + model: str, + messages: List[Dict[str, str]], + temperature: float, + max_tokens: int, + ) -> Dict[str, Any]: + """Create completion using appropriate API.""" + try: + self._validate_parameters(temperature, max_tokens) + + if self.api_provider == API_PROVIDER_ANTHROPIC: + return await self._create_anthropic_completion( + model, messages, temperature, max_tokens + ) + else: + return await self._create_openai_completion( + model, messages, temperature, max_tokens + ) + except Exception as e: + _LOGGER.error("API request failed: %s", str(e)) + raise HomeAssistantError(f"API request failed: {str(e)}") + + async def _create_openai_completion( + self, + model: str, + messages: List[Dict[str, str]], + temperature: float, + max_tokens: int, + ) -> Dict[str, Any]: + """Create completion using OpenAI API.""" + url = f"{self.endpoint}/chat/completions" + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + + data = await self._make_request(url, payload) + return { + "choices": [ + { + "message": { + "content": data["choices"][0]["message"]["content"] + } + } + ], + "usage": { + "prompt_tokens": data["usage"]["prompt_tokens"], + "completion_tokens": data["usage"]["completion_tokens"], + "total_tokens": data["usage"]["total_tokens"] + } + } + + async def _create_anthropic_completion( + self, + model: str, + messages: List[Dict[str, str]], + temperature: float, + max_tokens: int, + ) -> Dict[str, Any]: + """Create completion using Anthropic API.""" + url = f"{self.endpoint}/v1/messages" + + # Convert messages to Anthropic format + system_prompt = next( + (msg["content"] for msg in messages if msg["role"] == "system"), + None + ) + conversation = [msg for msg in messages if msg["role"] != "system"] + + payload = { + "model": model, + "messages": conversation, + "max_tokens": max_tokens, + "temperature": temperature, + } + if system_prompt: + payload["system"] = system_prompt + + data = await self._make_request(url, payload) + return { + "choices": [ + { + "message": { + "content": data["content"][0]["text"] + } + } + ], + "usage": { + "prompt_tokens": data["usage"]["input_tokens"], + "completion_tokens": data["usage"]["output_tokens"], + "total_tokens": data["usage"]["input_tokens"] + data["usage"]["output_tokens"] + } + } diff --git a/ha_text_ai/config_flow.py b/ha_text_ai/config_flow.py new file mode 100644 index 0000000..b87f3d4 --- /dev/null +++ b/ha_text_ai/config_flow.py @@ -0,0 +1,247 @@ +"""Config flow for HA text AI integration.""" +import logging +from typing import Any, Dict, Optional + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import selector + +from .const import ( + DOMAIN, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_MAX_TOKENS, + CONF_API_ENDPOINT, + CONF_REQUEST_INTERVAL, + CONF_API_PROVIDER, + CONF_CONTEXT_MESSAGES, + API_PROVIDER_OPENAI, + API_PROVIDER_ANTHROPIC, + API_PROVIDERS, + DEFAULT_MODEL, + DEFAULT_TEMPERATURE, + DEFAULT_MAX_TOKENS, + DEFAULT_REQUEST_INTERVAL, + DEFAULT_OPENAI_ENDPOINT, + DEFAULT_ANTHROPIC_ENDPOINT, + DEFAULT_CONTEXT_MESSAGES, + MIN_TEMPERATURE, + MAX_TEMPERATURE, + MIN_MAX_TOKENS, + MAX_MAX_TOKENS, + MIN_REQUEST_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for HA text AI.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._errors = {} + self._data = {} + self._provider = None + + async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required(CONF_API_PROVIDER): selector.SelectSelector( + selector.SelectSelectorConfig( + options=API_PROVIDERS, + translation_key="api_provider" + ) + ), + }) + ) + + self._provider = user_input[CONF_API_PROVIDER] + return await self.async_step_provider() + + async def async_step_provider(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: + """Handle provider configuration step.""" + if user_input is None: + default_endpoint = ( + DEFAULT_OPENAI_ENDPOINT if self._provider == API_PROVIDER_OPENAI + else DEFAULT_ANTHROPIC_ENDPOINT + ) + + suggested_name = f"HA Text AI {len(self._async_current_entries()) + 1}" + + return self.async_show_form( + step_id="provider", + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=suggested_name): str, + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MODEL, default=DEFAULT_MODEL): str, + vol.Required(CONF_API_ENDPOINT, default=default_endpoint): str, + vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) + ), + vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + vol.Optional( + CONF_CONTEXT_MESSAGES, + default=DEFAULT_CONTEXT_MESSAGES + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ), + }), + errors=self._errors + ) + + instance_name = user_input[CONF_NAME] + await self._async_validate_name(instance_name) + if self._errors: + return await self.async_step_provider() + + if not await self._async_validate_api(user_input): + return await self.async_step_provider() + + return await self._create_entry(user_input) + + async def _async_validate_name(self, name: str) -> bool: + """Validate that the name is unique.""" + for entry in self._async_current_entries(): + if entry.data.get(CONF_NAME) == name: + self._errors["name"] = "name_exists" + return False + return True + + async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool: + """Validate API connection.""" + try: + session = async_get_clientsession(self.hass) + headers = self._get_api_headers(user_input) + endpoint = user_input[CONF_API_ENDPOINT].rstrip('/') + + check_url = ( + f"{endpoint}/v1/models" if self._provider == API_PROVIDER_ANTHROPIC + else f"{endpoint}/models" + ) + + async with session.get(check_url, headers=headers) as response: + if response.status == 401: + self._errors["base"] = "invalid_auth" + return False + elif response.status not in [200, 404]: + self._errors["base"] = "cannot_connect" + return False + return True + + except Exception as err: + _LOGGER.error("API validation error: %s", str(err)) + self._errors["base"] = "cannot_connect" + return False + + def _get_api_headers(self, user_input: Dict[str, Any]) -> Dict[str, str]: + """Get API headers based on provider.""" + api_key = user_input[CONF_API_KEY] + + if self._provider == API_PROVIDER_ANTHROPIC: + return { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json" + } + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + async def _create_entry(self, user_input: Dict[str, Any]) -> FlowResult: + """Create the config entry.""" + instance_name = user_input[CONF_NAME] + unique_id = f"{DOMAIN}_{instance_name}_{self._provider}".lower().replace(" ", "_") + + return self.async_create_entry( + title=instance_name, + data={ + CONF_API_PROVIDER: self._provider, + CONF_NAME: instance_name, + **user_input, + "unique_id": unique_id, + CONF_CONTEXT_MESSAGES: user_input.get( + CONF_CONTEXT_MESSAGES, + DEFAULT_CONTEXT_MESSAGES) + } + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current_data = {**self.config_entry.data, **self.config_entry.options} + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Optional( + CONF_MODEL, + default=current_data.get(CONF_MODEL, DEFAULT_MODEL) + ): str, + vol.Optional( + CONF_TEMPERATURE, + default=current_data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + ): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional( + CONF_MAX_TOKENS, + default=current_data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) + ), + vol.Optional( + CONF_REQUEST_INTERVAL, + default=current_data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL) + ): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + vol.Optional( + CONF_CONTEXT_MESSAGES, + default=current_data.get( + CONF_CONTEXT_MESSAGES, + DEFAULT_CONTEXT_MESSAGES + ) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ), + }) + ) diff --git a/ha_text_ai/const.py b/ha_text_ai/const.py new file mode 100644 index 0000000..ccd41f2 --- /dev/null +++ b/ha_text_ai/const.py @@ -0,0 +1,201 @@ +"""Constants for the HA text AI integration.""" +from typing import Final +import voluptuous as vol +from homeassistant.const import Platform, CONF_API_KEY, CONF_NAME +from homeassistant.helpers import config_validation as cv + +# Domain and platforms +DOMAIN: Final = "ha_text_ai" +PLATFORMS: Final = [Platform.SENSOR] + +# Provider configuration +CONF_API_PROVIDER: Final = "api_provider" +API_PROVIDER_OPENAI: Final = "openai" +API_PROVIDER_ANTHROPIC: Final = "anthropic" + +API_PROVIDERS: Final = [ + API_PROVIDER_OPENAI, + API_PROVIDER_ANTHROPIC +] + +# Default endpoints +DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1" +DEFAULT_ANTHROPIC_ENDPOINT: Final = "https://api.anthropic.com" + +# Configuration constants +CONF_MODEL: Final = "model" +CONF_TEMPERATURE: Final = "temperature" +CONF_MAX_TOKENS: Final = "max_tokens" +CONF_API_ENDPOINT: Final = "api_endpoint" +CONF_REQUEST_INTERVAL: Final = "request_interval" +CONF_INSTANCE: Final = "instance" +CONF_MAX_HISTORY_SIZE: Final = "max_history_size" +CONF_IS_ANTHROPIC: Final = "is_anthropic" +CONF_CONTEXT_MESSAGES: Final = "context_messages" + +# Default values +DEFAULT_MODEL: Final = "gpt-4o-mini" +DEFAULT_TEMPERATURE: Final = 0.1 +DEFAULT_MAX_TOKENS: Final = 1000 +DEFAULT_REQUEST_INTERVAL: Final = 1.0 +DEFAULT_TIMEOUT: Final = 30 +DEFAULT_MAX_HISTORY: Final = 50 +DEFAULT_NAME: Final = "HA Text AI" +DEFAULT_CONTEXT_MESSAGES: Final = 5 + +# Parameter constraints +MIN_TEMPERATURE: Final = 0.0 +MAX_TEMPERATURE: Final = 2.0 +MIN_MAX_TOKENS: Final = 1 +MAX_MAX_TOKENS: Final = 4096 +MIN_REQUEST_INTERVAL: Final = 0.1 +MAX_REQUEST_INTERVAL: Final = 60.0 + +# API constants +API_TIMEOUT: Final = 30 +API_RETRY_COUNT: Final = 3 + +# Service names +SERVICE_ASK_QUESTION: Final = "ask_question" +SERVICE_CLEAR_HISTORY: Final = "clear_history" +SERVICE_GET_HISTORY: Final = "get_history" +SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt" + +# Attribute keys +ATTR_QUESTION: Final = "question" +ATTR_RESPONSE: Final = "response" +ATTR_INSTANCE: Final = "instance" +ATTR_MODEL: Final = "model" +ATTR_TEMPERATURE: Final = "temperature" +ATTR_MAX_TOKENS: Final = "max_tokens" +ATTR_SYSTEM_PROMPT: Final = "system_prompt" +ATTR_API_STATUS: Final = "api_status" +ATTR_ERROR_COUNT: Final = "error_count" +ATTR_CONVERSATION_HISTORY: Final = "conversation_history" + +# Sensor attributes +ATTR_TOTAL_RESPONSES: Final = "total_responses" +ATTR_TOTAL_ERRORS: Final = "total_errors" +ATTR_AVG_RESPONSE_TIME: Final = "average_response_time" +ATTR_LAST_REQUEST_TIME: Final = "last_request_time" +ATTR_LAST_ERROR: Final = "last_error" +ATTR_IS_PROCESSING: Final = "is_processing" +ATTR_IS_RATE_LIMITED: Final = "is_rate_limited" +ATTR_IS_MAINTENANCE: Final = "is_maintenance" +ATTR_API_VERSION: Final = "api_version" +ATTR_ENDPOINT_STATUS: Final = "endpoint_status" +ATTR_PERFORMANCE_METRICS: Final = "performance_metrics" +ATTR_HISTORY_SIZE: Final = "history_size" +ATTR_UPTIME: Final = "uptime" +ATTR_API_PROVIDER: Final = "api_provider" +ATTR_METRICS: Final = "metrics" +ATTR_STATE: Final = "state" +ATTR_LAST_RESPONSE: Final = "last_response" +ATTR_ERROR: Final = "error" +ATTR_TIMESTAMP: Final = "timestamp" + +# Sensor metrics +METRIC_TOTAL_TOKENS: Final = "total_tokens" +METRIC_PROMPT_TOKENS: Final = "prompt_tokens" +METRIC_COMPLETION_TOKENS: Final = "completion_tokens" +METRIC_SUCCESSFUL_REQUESTS: Final = "successful_requests" +METRIC_FAILED_REQUESTS: Final = "failed_requests" +METRIC_AVERAGE_LATENCY: Final = "average_latency" +METRIC_MAX_LATENCY: Final = "max_latency" +METRIC_MIN_LATENCY: Final = "min_latency" + +# Error messages +ERROR_INVALID_API_KEY: Final = "invalid_api_key" +ERROR_CANNOT_CONNECT: Final = "cannot_connect" +ERROR_UNKNOWN: Final = "unknown_error" +ERROR_INVALID_MODEL: Final = "invalid_model" +ERROR_RATE_LIMIT: Final = "rate_limit_exceeded" +ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded" +ERROR_API_ERROR: Final = "api_error" +ERROR_TIMEOUT: Final = "timeout_error" +ERROR_INVALID_INSTANCE: Final = "invalid_instance" +ERROR_NAME_EXISTS: Final = "name_exists" + +# Entity attributes +ENTITY_ICON: Final = "mdi:robot" +ENTITY_ICON_ERROR: Final = "mdi:robot-dead" +ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited" + +# State attributes +STATE_READY: Final = "ready" +STATE_PROCESSING: Final = "processing" +STATE_ERROR: Final = "error" +STATE_INITIALIZING: Final = "initializing" +STATE_MAINTENANCE: Final = "maintenance" +STATE_RATE_LIMITED: Final = "rate_limited" +STATE_DISCONNECTED: Final = "disconnected" + +# Event names +EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received" +EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred" +EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed" + +# Service schema constants +SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ + vol.Required(CONF_INSTANCE): cv.string, + vol.Required("question"): cv.string, + vol.Optional("system_prompt"): cv.string, + vol.Optional("model"): cv.string, + vol.Optional("temperature"): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional("max_tokens"): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) + ), + vol.Optional("context_messages"): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ) +}) + +SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({ + vol.Required(CONF_INSTANCE): cv.string, + vol.Required("prompt"): cv.string +}) + +SERVICE_SCHEMA_GET_HISTORY = vol.Schema({ + vol.Required(CONF_INSTANCE): cv.string, + vol.Optional("limit", default=10): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=100) + ), + vol.Optional("filter_model"): cv.string +}) + +# Configuration schema +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_PROVIDER): vol.In(API_PROVIDERS), + vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string, + vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) + ), + vol.Optional(CONF_API_ENDPOINT): cv.string, + vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL, max=MAX_REQUEST_INTERVAL) + ), + vol.Optional(CONF_MAX_HISTORY_SIZE, default=DEFAULT_MAX_HISTORY): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=100), + ), + vol.Optional(CONF_CONTEXT_MESSAGES, default=DEFAULT_CONTEXT_MESSAGES): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ) + }) +}, extra=vol.ALLOW_EXTRA) diff --git a/ha_text_ai/coordinator.py b/ha_text_ai/coordinator.py new file mode 100644 index 0000000..9344e5f --- /dev/null +++ b/ha_text_ai/coordinator.py @@ -0,0 +1,377 @@ +"""The HA Text AI coordinator.""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util +from homeassistant.exceptions import HomeAssistantError + +from .const import ( + DOMAIN, + STATE_READY, + STATE_PROCESSING, + STATE_ERROR, + STATE_RATE_LIMITED, + STATE_MAINTENANCE, + DEFAULT_MAX_TOKENS, + DEFAULT_TEMPERATURE, + DEFAULT_MAX_HISTORY, + DEFAULT_CONTEXT_MESSAGES, +) + +_LOGGER = logging.getLogger(__name__) + +class HATextAICoordinator(DataUpdateCoordinator): + def __init__( + self, + hass: HomeAssistant, + client: Any, + model: str, + update_interval: int, + instance_name: str, + max_tokens: int = DEFAULT_MAX_TOKENS, + temperature: float = DEFAULT_TEMPERATURE, + max_history_size: int = DEFAULT_MAX_HISTORY, + context_messages: int = DEFAULT_CONTEXT_MESSAGES, + is_anthropic: bool = False, + ) -> None: + """Initialize coordinator.""" + self.instance_name = instance_name + self.hass = hass + self.client = client + self.model = model + self.temperature = temperature + self.max_tokens = max_tokens + self.max_history_size = max_history_size + self.is_anthropic = is_anthropic + + # Initialize with default state + self._initial_state = { + "state": STATE_READY, + "metrics": { + "total_tokens": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "successful_requests": 0, + "failed_requests": 0, + "total_errors": 0, + "average_latency": 0, + "max_latency": 0, + "min_latency": float('inf'), + }, + "last_response": { + "timestamp": dt_util.utcnow().isoformat(), + "question": "", + "response": "", + "model": model, + "instance": instance_name, + "error": None + }, + "is_processing": False, + "is_rate_limited": False, + "is_maintenance": False, + "endpoint_status": "ready", + "uptime": 0, + "system_prompt": None, + "history_size": 0, + "conversation_history": [], + } + + update_interval_td = timedelta(seconds=update_interval) + + super().__init__( + hass, + _LOGGER, + name=instance_name, + update_interval=update_interval_td, + ) + + # Register instance + self.hass.data.setdefault(DOMAIN, {}) + self.hass.data[DOMAIN][instance_name] = self + self.context_messages = context_messages + + self._system_prompt = None + self._conversation_history = [] + self._performance_metrics = self._initial_state["metrics"].copy() + self._is_processing = False + self._is_rate_limited = False + self._is_maintenance = False + self.endpoint_status = "ready" + self.last_response = self._initial_state["last_response"].copy() + self._start_time = dt_util.utcnow() + + _LOGGER.info(f"Initialized HA Text AI coordinator with instance: {instance_name}") + + async def _async_update_data(self) -> Dict[str, Any]: + """Update data via library.""" + try: + current_state = self._get_current_state() + _LOGGER.debug(f"Updating data for {self.instance_name}, current state: {current_state}") + + data = { + "state": current_state, + "metrics": self._performance_metrics, + "last_response": self.last_response, + "is_processing": self._is_processing, + "is_rate_limited": self._is_rate_limited, + "is_maintenance": self._is_maintenance, + "endpoint_status": self.endpoint_status, + "uptime": (dt_util.utcnow() - self._start_time).total_seconds(), + "system_prompt": self._system_prompt, + "history_size": len(self._conversation_history), + "conversation_history": self._conversation_history, + } + + # Validate data + if not isinstance(data, dict): + raise ValueError("Invalid data format") + + _LOGGER.debug(f"Updated data for {self.instance_name}: {data}") + return data + + except Exception as err: + _LOGGER.error(f"Error updating data for {self.instance_name}: {err}") + return self._initial_state + + async def async_update_ha_state(self) -> None: + """Update Home Assistant state.""" + try: + _LOGGER.debug(f"Requesting state update for {self.instance_name}") + await self.async_request_refresh() + + # Force update of all entities + for entity_id in self.hass.states.async_entity_ids(): + if entity_id.startswith(f"sensor.ha_text_ai_{self.instance_name}"): + self.hass.states.async_set(entity_id, self._get_current_state()) + + except Exception as err: + _LOGGER.error(f"Error updating HA state for {self.instance_name}: {err}") + + def _get_current_state(self) -> str: + """Get current state based on internal flags.""" + if self._is_processing: + return STATE_PROCESSING + elif self._is_rate_limited: + return STATE_RATE_LIMITED + elif self._is_maintenance: + return STATE_MAINTENANCE + elif self.last_response.get("error"): + return STATE_ERROR + return STATE_READY + + def _calculate_context_tokens(self, messages: List[Dict[str, str]]) -> int: + try: + if self.is_anthropic and hasattr(self.client, 'count_tokens'): + return sum(self.client.count_tokens(msg['content']) for msg in messages) + + return sum(len(msg['content']) // 4 for msg in messages) + except Exception as e: + _LOGGER.warning(f"Error calculating context tokens: {e}") + return 0 + + async def async_ask_question( + self, + question: str, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None, + context_messages: Optional[int] = None, + ) -> dict: + """Process a question with optional parameters.""" + return await self.async_process_question( + question, model, temperature, max_tokens, system_prompt, context_messages + ) + + async def async_process_question( + self, + question: str, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None, + context_messages: Optional[int] = None, + ) -> dict: + temp_context_messages = context_messages or self.context_messages + + if not question: + raise ValueError("Question cannot be empty") + + _LOGGER.debug(f"Processing question for instance {self.instance_name}") + + try: + self._is_processing = True + await self.async_update_ha_state() + + temp_model = model or self.model + temp_temperature = temperature or self.temperature + temp_max_tokens = max_tokens or self.max_tokens + temp_system_prompt = system_prompt or self._system_prompt + + start_time = dt_util.utcnow() + + messages = [] + if temp_system_prompt: + if self.is_anthropic: + system_content = f"\n\nHuman: {temp_system_prompt}\n\nAssistant: I understand and will follow these instructions." + messages.append({"role": "user", "content": system_content}) + else: + messages.append({"role": "system", "content": temp_system_prompt}) + + # Add conversation history + context_history = self._conversation_history[-temp_context_messages:] + for entry in context_history: + messages.append({"role": "user", "content": entry["question"]}) + messages.append({"role": "assistant", "content": entry["response"]}) + + messages.append({"role": "user", "content": question}) + + kwargs = { + "model": temp_model, + "temperature": temp_temperature, + "max_tokens": temp_max_tokens, + "messages": messages, + } + + response = await self.async_process_message(question, **kwargs) + + # Update metrics + end_time = dt_util.utcnow() + latency = (end_time - start_time).total_seconds() + self._update_metrics(latency, response) + + # Update history + self._update_history(question, response) + + return response + + except Exception as err: + self._handle_error(err) + raise HomeAssistantError(f"Failed to process question: {err}") + + finally: + self._is_processing = False + await self.async_update_ha_state() + + async def async_process_message(self, question: str, **kwargs) -> dict: + """Process message using the AI client.""" + try: + if self.is_anthropic: + response = await self._process_anthropic_message(question, **kwargs) + else: + response = await self._process_openai_message(question, **kwargs) + + self.last_response = { + "timestamp": dt_util.utcnow().isoformat(), + "question": question, + "response": response["content"], + "model": kwargs.get("model", self.model), + "instance": self.instance_name, + "error": None + } + + return response + + except Exception as err: + self._handle_error(err) + raise + + async def _process_anthropic_message(self, question: str, **kwargs) -> dict: + """Process message using Anthropic API.""" + response = await self.client.messages.create( + model=kwargs["model"], + max_tokens=kwargs["max_tokens"], + messages=kwargs["messages"], + temperature=kwargs["temperature"], + ) + return { + "content": response.content[0].text, + "tokens": { + "prompt": response.usage.input_tokens, + "completion": response.usage.output_tokens, + "total": response.usage.input_tokens + response.usage.output_tokens + } + } + + async def _process_openai_message(self, question: str, **kwargs) -> dict: + """Process message using OpenAI API.""" + try: + response = await self.client.create( + model=kwargs["model"], + messages=kwargs["messages"], + temperature=kwargs["temperature"], + max_tokens=kwargs["max_tokens"], + ) + + return { + "content": response["choices"][0]["message"]["content"], + "tokens": { + "prompt": response["usage"]["prompt_tokens"], + "completion": response["usage"]["completion_tokens"], + "total": response["usage"]["total_tokens"] + } + } + except Exception as e: + _LOGGER.error(f"Error in OpenAI API call: {str(e)}") + raise + + def _update_metrics(self, latency: float, response: dict) -> None: + """Update performance metrics.""" + metrics = self._performance_metrics + tokens = response.get("tokens", {}) + + metrics["total_tokens"] += tokens.get("total", 0) + metrics["prompt_tokens"] += tokens.get("prompt", 0) + metrics["completion_tokens"] += tokens.get("completion", 0) + metrics["successful_requests"] += 1 + + metrics["average_latency"] = ( + (metrics["average_latency"] * (metrics["successful_requests"] - 1) + latency) + / metrics["successful_requests"] + ) + metrics["max_latency"] = max(metrics["max_latency"], latency) + metrics["min_latency"] = min(metrics["min_latency"], latency) + + def _update_history(self, question: str, response: dict) -> None: + """Update conversation history.""" + self._conversation_history.append({ + "timestamp": dt_util.utcnow().isoformat(), + "question": question, + "response": response["content"] + }) + + while len(self._conversation_history) > self.max_history_size: + self._conversation_history.pop(0) + + def _handle_error(self, error: Exception) -> None: + """Handle error and update metrics.""" + self._performance_metrics["total_errors"] += 1 + self._performance_metrics["failed_requests"] += 1 + + self.last_response = { + "timestamp": dt_util.utcnow().isoformat(), + "question": "", + "response": "", + "model": self.model, + "instance": self.instance_name, + "error": str(error) + } + + async def async_clear_history(self) -> None: + """Clear conversation history.""" + self._conversation_history = [] + await self.async_update_ha_state() + + async def async_get_history(self) -> List[Dict[str, str]]: + """Get conversation history.""" + return self._conversation_history + + async def async_set_system_prompt(self, prompt: str) -> None: + """Set system prompt.""" + self._system_prompt = prompt + await self.async_update_ha_state() diff --git a/ha_text_ai/manifest.json b/ha_text_ai/manifest.json new file mode 100644 index 0000000..c37d467 --- /dev/null +++ b/ha_text_ai/manifest.json @@ -0,0 +1,28 @@ +{ + "domain": "ha_text_ai", + "name": "HA Text AI", + "after_dependencies": ["http"], + "bluetooth": [], + "codeowners": ["@smkrv"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/smkrv/ha-text-ai", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", + "loggers": ["custom_components.ha_text_ai"], + "mqtt": [], + "quality_scale": "silver", + "requirements": [ + "openai>=1.12.0", + "anthropic>=0.8.0", + "aiohttp>=3.8.0", + "async-timeout>=4.0.0", + "certifi>=2024.2.2" + ], + "single_config_entry": false, + "ssdp": [], + "usb": [], + "version": "2.0.0-alpha", + "zeroconf": [] +} diff --git a/ha_text_ai/sensor.py b/ha_text_ai/sensor.py new file mode 100644 index 0000000..e18fd2f --- /dev/null +++ b/ha_text_ai/sensor.py @@ -0,0 +1,294 @@ +"""Sensor platform for HA Text AI.""" +import logging +import math +from typing import Any, Dict + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util +from homeassistant.util import slugify + +from .const import ( + DOMAIN, + CONF_MODEL, + CONF_API_PROVIDER, + ATTR_TOTAL_RESPONSES, + ATTR_TOTAL_ERRORS, + ATTR_AVG_RESPONSE_TIME, + ATTR_LAST_REQUEST_TIME, + ATTR_LAST_ERROR, + ATTR_IS_PROCESSING, + ATTR_IS_RATE_LIMITED, + ATTR_IS_MAINTENANCE, + ATTR_API_VERSION, + ATTR_ENDPOINT_STATUS, + ATTR_PERFORMANCE_METRICS, + ATTR_HISTORY_SIZE, + ATTR_UPTIME, + ATTR_API_PROVIDER, + ATTR_MODEL, + ATTR_SYSTEM_PROMPT, + ATTR_API_STATUS, + ATTR_RESPONSE, + ATTR_QUESTION, + ATTR_CONVERSATION_HISTORY, + METRIC_TOTAL_TOKENS, + METRIC_PROMPT_TOKENS, + METRIC_COMPLETION_TOKENS, + METRIC_SUCCESSFUL_REQUESTS, + METRIC_FAILED_REQUESTS, + METRIC_AVERAGE_LATENCY, + METRIC_MAX_LATENCY, + METRIC_MIN_LATENCY, + STATE_READY, + STATE_PROCESSING, + STATE_ERROR, + STATE_INITIALIZING, + STATE_MAINTENANCE, + STATE_RATE_LIMITED, + STATE_DISCONNECTED, + ENTITY_ICON, + ENTITY_ICON_ERROR, + ENTITY_ICON_PROCESSING, +) + +from .coordinator import HATextAICoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HA Text AI sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + instance_name = coordinator.instance_name + + _LOGGER.debug(f"Setting up sensor with instance: {instance_name}") + + sensor = HATextAISensor(coordinator, entry) + async_add_entities([sensor], True) + +class HATextAISensor(CoordinatorEntity, SensorEntity): + """HA Text AI Sensor.""" + + coordinator: HATextAICoordinator + + def __init__( + self, + coordinator: HATextAICoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._config_entry = config_entry + self._instance_name = coordinator.instance_name + self._conversation_history = [] + self._system_prompt = None + + self._attr_name = f"HA Text AI {self._instance_name}" + self.entity_id = f"sensor.ha_text_ai_{slugify(self._instance_name)}" + self._attr_unique_id = f"{config_entry.entry_id}" + + self.entity_description = SensorEntityDescription( + key=f"ha_text_ai_{self._instance_name}", + entity_registry_enabled_default=True, + ) + + self._current_state = STATE_INITIALIZING + self._error_count = 0 + self._last_error = None + self._last_update = None + self._is_processing = False + self._last_response = {} + self._metrics = {} + + model = config_entry.data.get(CONF_MODEL, "Unknown") + api_provider = config_entry.data.get(CONF_API_PROVIDER, "Unknown") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=self._attr_name, # Используем имя сенсора + manufacturer="Community", + model=f"{model} ({api_provider} provider)", + sw_version="1.0.0", + ) + + _LOGGER.debug(f"Initialized sensor: {self.entity_id} for instance: {self._instance_name}") + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.coordinator.last_update_success + and self.coordinator.data is not None + and self._current_state != STATE_DISCONNECTED + ) + + def _sanitize_value(self, value: Any) -> Any: + """Sanitize values for JSON serialization.""" + if isinstance(value, float): + if math.isinf(value) or math.isnan(value): + return None + return value + + def _sanitize_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Sanitize all attributes for JSON serialization.""" + return { + key: self._sanitize_value(value) + for key, value in attributes.items() + if value is not None + } + + @property + def native_value(self) -> StateType: + """Return the native value of the sensor.""" + if not self.coordinator.last_update_success or not self.coordinator.data: + self._current_state = STATE_DISCONNECTED + return self._current_state + + status = self.coordinator.data.get("state", STATE_READY) + self._current_state = status + return status + + @property + def icon(self) -> str: + """Return the icon based on the current state.""" + if self._current_state == STATE_ERROR: + return ENTITY_ICON_ERROR + elif self._current_state == STATE_PROCESSING: + return ENTITY_ICON_PROCESSING + return ENTITY_ICON + + @property + def extra_state_attributes(self) -> Dict[str, Any]: + """Return entity specific state attributes.""" + if not self.coordinator.data: + return {} + + try: + data = self.coordinator.data + attributes = { + ATTR_MODEL: self._config_entry.data.get(CONF_MODEL, "Unknown"), + ATTR_API_PROVIDER: self._config_entry.data.get(CONF_API_PROVIDER, "Unknown"), + ATTR_API_STATUS: self._current_state, + ATTR_TOTAL_ERRORS: self._error_count, + ATTR_LAST_ERROR: self._last_error, + "instance_name": self._instance_name, + ATTR_SYSTEM_PROMPT: data.get("system_prompt"), + ATTR_IS_PROCESSING: data.get("is_processing", False), + ATTR_IS_RATE_LIMITED: data.get("is_rate_limited", False), + ATTR_IS_MAINTENANCE: data.get("is_maintenance", False), + ATTR_ENDPOINT_STATUS: data.get("endpoint_status", "unknown"), + ATTR_UPTIME: data.get("uptime", 0), + ATTR_HISTORY_SIZE: data.get("history_size", 0), + ATTR_CONVERSATION_HISTORY: data.get("conversation_history", []), + } + + # Add metrics + metrics = data.get("metrics", {}) + if isinstance(metrics, dict): + self._metrics = metrics + attributes.update({ + METRIC_TOTAL_TOKENS: metrics.get("total_tokens", 0), + METRIC_PROMPT_TOKENS: metrics.get("prompt_tokens", 0), + METRIC_COMPLETION_TOKENS: metrics.get("completion_tokens", 0), + METRIC_SUCCESSFUL_REQUESTS: metrics.get("successful_requests", 0), + METRIC_FAILED_REQUESTS: metrics.get("failed_requests", 0), + METRIC_AVERAGE_LATENCY: metrics.get("average_latency", 0), + METRIC_MAX_LATENCY: metrics.get("max_latency", 0), + METRIC_MIN_LATENCY: metrics.get("min_latency", float("inf")), + }) + + # Add last response + last_response = data.get("last_response", {}) + if isinstance(last_response, dict): + self._last_response = last_response + attributes.update({ + ATTR_RESPONSE: last_response.get("response", ""), + ATTR_QUESTION: last_response.get("question", ""), + "last_model": last_response.get("model", ""), + "last_timestamp": last_response.get("timestamp", ""), + "last_error": last_response.get("error"), + }) + + # Add performance metrics if available + if ATTR_PERFORMANCE_METRICS in data: + attributes[ATTR_PERFORMANCE_METRICS] = data[ATTR_PERFORMANCE_METRICS] + + # Add API version if available + if ATTR_API_VERSION in data: + attributes[ATTR_API_VERSION] = data[ATTR_API_VERSION] + + return self._sanitize_attributes(attributes) + + except Exception as err: + _LOGGER.error("Error preparing attributes: %s", err, exc_info=True) + return {} + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + _LOGGER.debug(f"Entity {self.entity_id} added to Home Assistant") + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + data = self.coordinator.data + if not self.coordinator.last_update_success or not data: + self._current_state = STATE_DISCONNECTED + _LOGGER.warning(f"No data available for {self.entity_id}") + self.async_write_ha_state() + return + + self._is_processing = data.get("is_processing", False) + + # Update conversation history and system prompt + self._conversation_history = data.get("conversation_history", []) + self._system_prompt = data.get("system_prompt") + + # Update state based on conditions + if self._is_processing: + self._current_state = STATE_PROCESSING + elif data.get("is_rate_limited"): + self._current_state = STATE_RATE_LIMITED + elif data.get("is_maintenance"): + self._current_state = STATE_MAINTENANCE + elif data.get("error"): + self._current_state = STATE_ERROR + self._last_error = data["error"] + self._error_count += 1 + else: + self._current_state = data.get("state", STATE_READY) + + # Update last update timestamp + self._last_update = dt_util.utcnow() + + _LOGGER.debug( + f"Updated {self.entity_id} state to: {self._current_state} " + f"(available: {self.available})" + ) + + except Exception as err: + self._current_state = STATE_ERROR + self._last_error = str(err) + self._error_count += 1 + _LOGGER.error( + "Error handling update for %s: %s", + self.entity_id, + err, + exc_info=True + ) + + self.async_write_ha_state() diff --git a/ha_text_ai/services.yaml b/ha_text_ai/services.yaml new file mode 100644 index 0000000..b69c734 --- /dev/null +++ b/ha_text_ai/services.yaml @@ -0,0 +1,75 @@ +ask_question: + name: Ask Question + description: >- + Send a question to the AI model and receive a detailed response. + The response will be stored in the conversation history and can be retrieved later. + fields: + instance: + name: Instance + description: Name of the HA Text AI instance to use + required: true + selector: + entity: + integration: ha_text_ai + domain: sensor + + question: + name: Question + description: Your question or prompt for the AI assistant + required: true + selector: + text: + multiline: true + type: text + + system_prompt: + name: System Prompt + description: Optional system prompt to set context for this specific question + required: false + selector: + text: + multiline: true + + context_messages: + name: Context Messages + description: Number of previous messages to include in context (1-20) + required: false + default: 5 + selector: + number: + min: 1 + max: 20 + step: 1 + mode: box + + model: + name: Model + description: "Select AI model to use (optional, overrides default setting)" + required: false + selector: + text: + multiline: false + + temperature: + name: Temperature + description: Controls response creativity (0.0-2.0) + required: false + default: 0.7 + selector: + number: + min: 0.0 + max: 2.0 + step: 0.1 + mode: slider + + max_tokens: + name: Max Tokens + description: Maximum length of the response (1-4096 tokens) + required: false + default: 1000 + selector: + number: + min: 1 + max: 4096 + step: 1 + mode: box diff --git a/ha_text_ai/translations/de.json b/ha_text_ai/translations/de.json new file mode 100644 index 0000000..68b954f --- /dev/null +++ b/ha_text_ai/translations/de.json @@ -0,0 +1,261 @@ +{ + "config": { + "step": { + "provider": { + "title": "KI-Anbieter auswählen", + "description": "Wählen Sie den KI-Dienst-Anbieter für diese Instanz", + "data": { + "api_provider": "API-Anbieter", + "context_messages": "Anzahl der zu speichernden Kontextnachrichten (1-20)" + } + }, + "user": { + "title": "HA Text AI Instanz konfigurieren", + "description": "Richten Sie eine neue KI-Assistenten-Instanz mit Ihrem ausgewählten Anbieter ein", + "data": { + "name": "Instanzname (z.B. 'GPT Assistent', 'Claude Helfer')", + "api_key": "API-Schlüssel für Authentifizierung", + "model": "Zu verwendendes KI-Modell", + "temperature": "Antwort-Kreativität (0-2, niedriger = fokussierter)", + "max_tokens": "Maximale Antwortlänge (1-4096 Token)", + "api_endpoint": "Benutzerdefinierte API-Endpunkt-URL (optional)", + "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", + "context_messages": "Anzahl der zu speichernden Kontextnachrichten (1-20)" + } + } + }, + "error": { + "name_exists": "Eine Instanz mit diesem Namen existiert bereits", + "invalid_name": "Ungültiger Instanzname", + "invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel", + "invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldedaten", + "cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen", + "invalid_model": "Ausgewähltes Modell ist nicht verfügbar", + "rate_limit": "Anfragelimit überschritten", + "context_length": "Kontextlänge überschritten", + "rate_limit_exceeded": "API-Anfragelimit überschritten", + "maintenance": "Dienst ist in Wartung", + "invalid_response": "Ungültige API-Antwort erhalten", + "api_error": "API-Dienst-Fehler aufgetreten", + "timeout": "Anfrage-Zeitüberschreitung", + "invalid_instance": "Ungültige Instanz angegeben", + "unknown": "Unerwarteter Fehler aufgetreten" + } + }, + "options": { + "step": { + "init": { + "title": "Instanzeinstellungen aktualisieren", + "description": "Einstellungen für diese KI-Assistenten-Instanz ändern", + "data": { + "model": "KI-Modell", + "temperature": "Antwort-Kreativität (0-2)", + "max_tokens": "Maximale Antwortlänge (1-4096)", + "request_interval": "Minimales Anfragen-Intervall (0,1-60 Sekunden)", + "context_messages": "Anzahl der vorherigen Nachrichten, die in den Kontext einbezogen werden sollen (1-20)" + + } + } + } + }, + "services": { + "ask_question": { + "name": "Frage stellen (HA Text AI)", + "description": "Senden Sie eine Frage an das KI-Modell und erhalten Sie eine detaillierte Antwort. Die Antwort wird in der Gesprächshistorie gespeichert und kann später abgerufen werden.", + "fields": { + "instance": { + "name": "Instanz", + "description": "Name der zu verwendenden HA Text AI Instanz" + }, + "question": { + "name": "Frage", + "description": "Ihre Frage oder Eingabeaufforderung für den KI-Assistenten" + }, + "context_messages": { + "name": "Kontextnachrichten", + "description": "Anzahl der vorherigen Nachrichten, die in den Kontext einbezogen werden sollen (1-20)" + }, + "system_prompt": { + "name": "Systemaufforderung", + "description": "Optionale Systemaufforderung zur Kontexteinstellung für diese spezifische Frage" + }, + "model": { + "name": "Modell", + "description": "Wählen Sie das zu verwendende KI-Modell (optional, überschreibt Standardeinstellung)" + }, + "temperature": { + "name": "Temperatur", + "description": "Steuert die Antwort-Kreativität (0,0-2,0)" + }, + "max_tokens": { + "name": "Max. Token", + "description": "Maximale Länge der Antwort (1-4096 Token)" + } + } + }, + "clear_history": { + "name": "Verlauf löschen", + "description": "Alle gespeicherten Fragen und Antworten aus dem Gesprächsverlauf löschen", + "fields": { + "instance": { + "name": "Instanz", + "description": "Name der HA Text AI Instanz, für die der Verlauf gelöscht werden soll" + } + } + }, + "get_history": { + "name": "Verlauf abrufen", + "description": "Gesprächsverlauf mit optionaler Filterung und Sortierung abrufen", + "fields": { + "instance": { + "name": "Instanz", + "description": "Name der HA Text AI Instanz, aus der der Verlauf abgerufen werden soll" + }, + "limit": { + "name": "Limit", + "description": "Anzahl der zurückzugebenden Gespräche (1-100)" + }, + "filter_model": { + "name": "Modell filtern", + "description": "Gespräche nach bestimmtem KI-Modell filtern" + }, + "start_date": { + "name": "Startdatum", + "description": "Gespräche ab diesem Datum/Zeitpunkt filtern" + }, + "include_metadata": { + "name": "Metadaten einbeziehen", + "description": "Zusätzliche Informationen wie verwendete Token, Antwortzeit usw. einbeziehen" + }, + "sort_order": { + "name": "Sortierreihenfolge", + "description": "Sortierreihenfolge der Ergebnisse (neueste oder älteste zuerst)" + } + } + }, + "set_system_prompt": { + "name": "Systemaufforderung festlegen", + "description": "Standardmäßige Systemverhaltensinstruktionen für alle zukünftigen Gespräche festlegen", + "fields": { + "instance": { + "name": "Instanz", + "description": "Name der HA Text AI Instanz, für die die Systemaufforderung festgelegt werden soll" + }, + "prompt": { + "name": "Systemaufforderung", + "description": "Anweisungen, die definieren, wie sich die KI verhalten und antworten soll" + } + } + } + }, + "entity": { + "sensor": { + "ha_text_ai": { + "name": "{name}", + "state": { + "ready": "Bereit", + "processing": "Verarbeitung", + "error": "Fehler", + "disconnected": "Getrennt", + "rate_limited": "Anfragelimit", + "maintenance": "Wartung", + "initializing": "Initialisierung", + "retrying": "Wiederholung", + "queued": "In Warteschlange" + }, + "state_attributes": { + "question": { + "name": "Letzte Frage" + }, + "response": { + "name": "Letzte Antwort" + }, + "model": { + "name": "Aktuelles Modell" + }, + "temperature": { + "name": "Temperatur" + }, + "max_tokens": { + "name": "Max. Token" + }, + "system_prompt": { + "name": "Systemaufforderung" + }, + "response_time": { + "name": "Letzte Antwortzeit" + }, + "total_responses": { + "name": "Gesamte Antworten" + }, + "error_count": { + "name": "Fehleranzahl" + }, + "last_error": { + "name": "Letzter Fehler" + }, + "api_status": { + "name": "API-Status" + }, + "tokens_used": { + "name": "Insgesamt verwendete Token" + }, + "average_response_time": { + "name": "Durchschnittliche Antwortzeit" + }, + "last_request_time": { + "name": "Letzte Anforderungszeit" + }, + "is_processing": { + "name": "Verarbeitungsstatus" + }, + "is_rate_limited": { + "name": "Status Anfragelimit" + }, + "is_maintenance": { + "name": "Wartungsstatus" + }, + "api_version": { + "name": "API-Version" + }, + "endpoint_status": { + "name": "Endpunktstatus" + }, + "performance_metrics": { + "name": "Leistungsmetriken" + }, + "history_size": { + "name": "Verlaufsgröße" + }, + "uptime": { + "name": "Betriebszeit" + }, + "total_tokens": { + "name": "Gesamte Token" + }, + "prompt_tokens": { + "name": "Prompt-Token" + }, + "completion_tokens": { + "name": "Abschluss-Token" + }, + "successful_requests": { + "name": "Erfolgreiche Anfragen" + }, + "failed_requests": { + "name": "Fehlgeschlagene Anfragen" + }, + "average_latency": { + "name": "Durchschnittliche Latenz" + }, + "max_latency": { + "name": "Maximale Latenz" + }, + "min_latency": { + "name": "Minimale Latenz" + } + } + } + } + } +} diff --git a/ha_text_ai/translations/en.json b/ha_text_ai/translations/en.json new file mode 100644 index 0000000..bd1b825 --- /dev/null +++ b/ha_text_ai/translations/en.json @@ -0,0 +1,260 @@ +{ + "config": { + "step": { + "provider": { + "title": "Select AI Provider", + "description": "Choose which AI service provider to use for this instance", + "data": { + "api_provider": "API Provider", + "context_messages": "Number of context messages to retain (1-20)" + } + }, + "user": { + "title": "Configure HA Text AI Instance", + "description": "Set up a new AI assistant instance with your selected provider", + "data": { + "name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')", + "api_key": "API key for authentication", + "model": "AI model to use", + "temperature": "Response creativity (0-2, lower = more focused)", + "max_tokens": "Maximum response length (1-4096 tokens)", + "api_endpoint": "Custom API endpoint URL (optional)", + "request_interval": "Minimum time between requests (0.1-60 seconds)", + "context_messages": "Number of context messages to retain (1-20)" + } + } + }, + "error": { + "name_exists": "An instance with this name already exists", + "invalid_name": "Invalid instance name", + "invalid_auth": "Authentication failed - check your API key", + "invalid_api_key": "Invalid API key - please verify your credentials", + "cannot_connect": "Failed to connect to API service", + "invalid_model": "Selected model is not available", + "rate_limit": "Rate limit exceeded", + "context_length": "Context length exceeded", + "rate_limit_exceeded": "API rate limit exceeded", + "maintenance": "Service is under maintenance", + "invalid_response": "Invalid API response received", + "api_error": "API service error occurred", + "timeout": "Request timed out", + "invalid_instance": "Invalid instance specified", + "unknown": "Unexpected error occurred" + } + }, + "options": { + "step": { + "init": { + "title": "Update Instance Settings", + "description": "Modify settings for this AI assistant instance", + "data": { + "model": "AI model", + "temperature": "Response creativity (0-2)", + "max_tokens": "Maximum response length (1-4096)", + "request_interval": "Minimum request interval (0.1-60 seconds)", + "context_messages": "Number of previous messages to include in context (1-20)" + } + } + } + }, + "services": { + "ask_question": { + "name": "Ask Question (HA Text AI)", + "description": "Send a question to the AI model and receive a detailed response. The response will be stored in the conversation history and can be retrieved later.", + "fields": { + "instance": { + "name": "Instance", + "description": "Name of the HA Text AI instance to use" + }, + "question": { + "name": "Question", + "description": "Your question or prompt for the AI assistant" + }, + "context_messages": { + "name": "Context Messages", + "description": "Number of previous messages to include in context (1-20)" + }, + "system_prompt": { + "name": "System Prompt", + "description": "Optional system prompt to set context for this specific question" + }, + "model": { + "name": "Model", + "description": "Select AI model to use (optional, overrides default setting)" + }, + "temperature": { + "name": "Temperature", + "description": "Controls response creativity (0.0-2.0)" + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Maximum length of the response (1-4096 tokens)" + } + } + }, + "clear_history": { + "name": "Clear History", + "description": "Delete all stored questions and responses from the conversation history", + "fields": { + "instance": { + "name": "Instance", + "description": "Name of the HA Text AI instance to clear history for" + } + } + }, + "get_history": { + "name": "Get History", + "description": "Retrieve conversation history with optional filtering and sorting", + "fields": { + "instance": { + "name": "Instance", + "description": "Name of the HA Text AI instance to get history from" + }, + "limit": { + "name": "Limit", + "description": "Number of conversations to return (1-100)" + }, + "filter_model": { + "name": "Filter Model", + "description": "Filter conversations by specific AI model" + }, + "start_date": { + "name": "Start Date", + "description": "Filter conversations starting from this date/time" + }, + "include_metadata": { + "name": "Include Metadata", + "description": "Include additional information like tokens used, response time, etc." + }, + "sort_order": { + "name": "Sort Order", + "description": "Sort order for results (newest or oldest first)" + } + } + }, + "set_system_prompt": { + "name": "Set System Prompt", + "description": "Set default system behavior instructions for all future conversations", + "fields": { + "instance": { + "name": "Instance", + "description": "Name of the HA Text AI instance to set system prompt for" + }, + "prompt": { + "name": "System Prompt", + "description": "Instructions that define how the AI should behave and respond" + } + } + } + }, + "entity": { + "sensor": { + "ha_text_ai": { + "name": "{name}", + "state": { + "ready": "Ready", + "processing": "Processing", + "error": "Error", + "disconnected": "Disconnected", + "rate_limited": "Rate Limited", + "maintenance": "Maintenance", + "initializing": "Initializing", + "retrying": "Retrying", + "queued": "Queued" + }, + "state_attributes": { + "question": { + "name": "Last Question" + }, + "response": { + "name": "Last Response" + }, + "model": { + "name": "Current Model" + }, + "temperature": { + "name": "Temperature" + }, + "max_tokens": { + "name": "Max Tokens" + }, + "system_prompt": { + "name": "System Prompt" + }, + "response_time": { + "name": "Last Response Time" + }, + "total_responses": { + "name": "Total Responses" + }, + "error_count": { + "name": "Error Count" + }, + "last_error": { + "name": "Last Error" + }, + "api_status": { + "name": "API Status" + }, + "tokens_used": { + "name": "Total Tokens Used" + }, + "average_response_time": { + "name": "Average Response Time" + }, + "last_request_time": { + "name": "Last Request Time" + }, + "is_processing": { + "name": "Processing Status" + }, + "is_rate_limited": { + "name": "Rate Limited Status" + }, + "is_maintenance": { + "name": "Maintenance Status" + }, + "api_version": { + "name": "API Version" + }, + "endpoint_status": { + "name": "Endpoint Status" + }, + "performance_metrics": { + "name": "Performance Metrics" + }, + "history_size": { + "name": "History Size" + }, + "uptime": { + "name": "Uptime" + }, + "total_tokens": { + "name": "Total Tokens" + }, + "prompt_tokens": { + "name": "Prompt Tokens" + }, + "completion_tokens": { + "name": "Completion Tokens" + }, + "successful_requests": { + "name": "Successful Requests" + }, + "failed_requests": { + "name": "Failed Requests" + }, + "average_latency": { + "name": "Average Latency" + }, + "max_latency": { + "name": "Maximum Latency" + }, + "min_latency": { + "name": "Minimum Latency" + } + } + } + } + } +} diff --git a/ha_text_ai/translations/ru.json b/ha_text_ai/translations/ru.json new file mode 100644 index 0000000..30ca1ea --- /dev/null +++ b/ha_text_ai/translations/ru.json @@ -0,0 +1,260 @@ +{ + "config": { + "step": { + "provider": { + "title": "Выбор провайдера ИИ", + "description": "Выберите сервис ИИ для этого экземпляра", + "data": { + "api_provider": "Провайдер API", + "context_messages": "Количество сообщений в контексте (1-20)" + } + }, + "user": { + "title": "Настройка экземпляра HA Text AI", + "description": "Настройте нового помощника ИИ с выбранным провайдером", + "data": { + "name": "Название экземпляра (например, 'GPT Помощник', 'Claude Ассистент')", + "api_key": "API-ключ для аутентификации", + "model": "Модель ИИ для использования", + "temperature": "Креативность ответов (0-2, меньше = более сфокусированно)", + "max_tokens": "Максимальная длина ответа (1-4096 токенов)", + "api_endpoint": "Пользовательский URL-адрес API (необязательно)", + "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", + "context_messages": "Количество сохраняемых контекстных сообщений (1-20)" + } + } + }, + "error": { + "name_exists": "Экземпляр с таким именем уже существует", + "invalid_name": "Некорректное имя экземпляра", + "invalid_auth": "Ошибка аутентификации - проверьте API-ключ", + "invalid_api_key": "Неверный API-ключ - проверьте учетные данные", + "cannot_connect": "Не удалось подключиться к сервису API", + "invalid_model": "Выбранная модель недоступна", + "rate_limit": "Превышен лимит запросов", + "context_length": "Превышена длина контекста", + "rate_limit_exceeded": "Превышен лимит API", + "maintenance": "Сервис на техническом обслуживании", + "invalid_response": "Получен некорректный ответ API", + "api_error": "Произошла ошибка сервиса API", + "timeout": "Время ожидания истекло", + "invalid_instance": "Указан неверный экземпляр", + "unknown": "Произошла непредвиденная ошибка" + } + }, + "options": { + "step": { + "init": { + "title": "Обновление настроек экземпляра", + "description": "Измените настройки для этого помощника ИИ", + "data": { + "model": "Модель ИИ", + "temperature": "Креативность ответов (0-2)", + "max_tokens": "Максимальная длина ответа (1-4096)", + "request_interval": "Минимальный интервал запросов (0.1-60 секунд)", + "context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)" + } + } + } + }, + "services": { + "ask_question": { + "name": "Задать вопрос (HA Text AI)", + "description": "Отправьте вопрос модели ИИ и получите подробный ответ. Ответ будет сохранен в истории беседы и может быть извлечен позже.", + "fields": { + "instance": { + "name": "Экземпляр", + "description": "Название экземпляра HA Text AI для использования" + }, + "question": { + "name": "Вопрос", + "description": "Ваш вопрос или запрос помощнику ИИ" + }, + "context_messages": { + "name": "Контекстные сообщения", + "description": "Количество предыдущих сообщений для включения в контекст (1-20)" + }, + "system_prompt": { + "name": "Системный промпт", + "description": "Необязательный системный промпт для установки контекста для этого конкретного вопроса" + }, + "model": { + "name": "Модель", + "description": "Выберите модель ИИ (необязательно, переопределяет настройки по умолчанию)" + }, + "temperature": { + "name": "Температура", + "description": "Управляет креативностью ответа (0.0-2.0)" + }, + "max_tokens": { + "name": "Максимальное количество токенов", + "description": "Максимальная длина ответа (1-4096 токенов)" + } + } + }, + "clear_history": { + "name": "Очистить историю", + "description": "Удалить все сохраненные вопросы и ответы из истории беседы", + "fields": { + "instance": { + "name": "Экземпляр", + "description": "Название экземпляра HA Text AI для очистки истории" + } + } + }, + "get_history": { + "name": "Получить историю", + "description": "Получить историю беседы с дополнительной фильтрацией и сортировкой", + "fields": { + "instance": { + "name": "Экземпляр", + "description": "Название экземпляра HA Text AI для получения истории" + }, + "limit": { + "name": "Лимит", + "description": "Количество возвращаемых бесед (1-100)" + }, + "filter_model": { + "name": "Фильтр модели", + "description": "Фильтрация бесед по конкретной модели ИИ" + }, + "start_date": { + "name": "Начальная дата", + "description": "Фильтрация бесед, начиная с указанной даты/времени" + }, + "include_metadata": { + "name": "Включить метаданные", + "description": "Включить дополнительную информацию, например, использованные токены, время ответа и т.д." + }, + "sort_order": { + "name": "Порядок сортировки", + "description": "Порядок результатов (сначала новые или старые)" + } + } + }, + "set_system_prompt": { + "name": "Установить системный промпт", + "description": "Установить инструкции по умолчанию для поведения ИИ во всех будущих разговорах", + "fields": { + "instance": { + "name": "Экземпляр", + "description": "Название экземпляра HA Text AI для установки системного промпта" + }, + "prompt": { + "name": "Системный промпт", + "description": "Инструкции, определяющие, как ИИ должен вести себя и отвечать" + } + } + } + }, + "entity": { + "sensor": { + "ha_text_ai": { + "name": "{name}", + "state": { + "ready": "Готов", + "processing": "Обработка", + "error": "Ошибка", + "disconnected": "Отключен", + "rate_limited": "Лимит запросов", + "maintenance": "Техническое обслуживание", + "initializing": "Инициализация", + "retrying": "Повторная попытка", + "queued": "В очереди" + }, + "state_attributes": { + "question": { + "name": "Последний вопрос" + }, + "response": { + "name": "Последний ответ" + }, + "model": { + "name": "Текущая модель" + }, + "temperature": { + "name": "Температура" + }, + "max_tokens": { + "name": "Максимальное количество токенов" + }, + "system_prompt": { + "name": "Системный промпт" + }, + "response_time": { + "name": "Время последнего ответа" + }, + "total_responses": { + "name": "Всего ответов" + }, + "error_count": { + "name": "Количество ошибок" + }, + "last_error": { + "name": "Последняя ошибка" + }, + "api_status": { + "name": "Статус API" + }, + "tokens_used": { + "name": "Всего использовано токенов" + }, + "average_response_time": { + "name": "Среднее время ответа" + }, + "last_request_time": { + "name": "Время последнего запроса" + }, + "is_processing": { + "name": "Статус обработки" + }, + "is_rate_limited": { + "name": "Статус лимита запросов" + }, + "is_maintenance": { + "name": "Статус обслуживания" + }, + "api_version": { + "name": "Версия API" + }, + "endpoint_status": { + "name": "Статус эндпоинта" + }, + "performance_metrics": { + "name": "Показатели производительности" + }, + "history_size": { + "name": "Размер истории" + }, + "uptime": { + "name": "Время работы" + }, + "total_tokens": { + "name": "Всего токенов" + }, + "prompt_tokens": { + "name": "Токены промпта" + }, + "completion_tokens": { + "name": "Токены завершения" + }, + "successful_requests": { + "name": "Успешные запросы" + }, + "failed_requests": { + "name": "Неудачные запросы" + }, + "average_latency": { + "name": "Средняя задержка" + }, + "max_latency": { + "name": "Максимальная задержка" + }, + "min_latency": { + "name": "Минимальная задержка" + } + } + } + } + } +}