From f1deaa2014ab4106c7a4ec8aca0225457bf7ff4e Mon Sep 17 00:00:00 2001 From: SMKRV Date: Tue, 19 Nov 2024 14:35:45 +0300 Subject: [PATCH] Release v1.0.4 --- custom_components/ha_text_ai/__init__.py | 240 +++++------------- custom_components/ha_text_ai/config_flow.py | 189 +++++++++++--- custom_components/ha_text_ai/const.py | 51 +++- custom_components/ha_text_ai/coordinator.py | 195 +++++++++----- custom_components/ha_text_ai/manifest.json | 2 +- custom_components/ha_text_ai/sensor.py | 145 +++++++---- custom_components/ha_text_ai/services.yaml | 67 ++++- .../ha_text_ai/translations/en.json | 90 +++++-- .../ha_text_ai/translations/ru.json | 192 ++++++++++++++ ha_text_ai.zip | Bin 11773 -> 16832 bytes hacs.json | 2 +- 11 files changed, 808 insertions(+), 365 deletions(-) create mode 100644 custom_components/ha_text_ai/translations/ru.json diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 93406fe..a4dde2f 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -1,209 +1,89 @@ -"""The HA text AI integration.""" +"""The HA Text AI integration.""" import logging from typing import Any -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY -from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv -from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client -from .const import ( - DOMAIN, - PLATFORMS, - SERVICE_ASK_QUESTION, - SERVICE_CLEAR_HISTORY, - SERVICE_GET_HISTORY, - SERVICE_SET_SYSTEM_PROMPT, - CONF_MODEL, - CONF_TEMPERATURE, - CONF_MAX_TOKENS, - CONF_API_ENDPOINT, - CONF_REQUEST_INTERVAL, -) +from .const import DOMAIN, PLATFORMS +from .coordinator import HATextAICoordinator _LOGGER = logging.getLogger(__name__) -def get_coordinator(): - """Get coordinator class with lazy import to avoid circular deps.""" - from .coordinator import HATextAICoordinator - return HATextAICoordinator - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_MODEL, default="gpt-3.5-turbo"): cv.string, - vol.Optional(CONF_TEMPERATURE, default=0.7): vol.Coerce(float), - vol.Optional(CONF_MAX_TOKENS, default=1000): vol.Coerce(int), - vol.Optional(CONF_REQUEST_INTERVAL, default=1.0): vol.Coerce(float), - vol.Optional(CONF_API_ENDPOINT): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: - """Set up the HA text AI component.""" + """Set up the HA Text AI component.""" hass.data.setdefault(DOMAIN, {}) - - async def async_ask_question(call: ServiceCall) -> None: - """Handle the ask_question service call.""" - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") - - coordinator = next(iter(hass.data[DOMAIN].values())) - question = call.data["question"] - - original_params = { - "model": coordinator.model, - "temperature": coordinator.temperature, - "max_tokens": coordinator.max_tokens - } - - try: - if "model" in call.data: - coordinator.model = call.data["model"] - if "temperature" in call.data: - coordinator.temperature = call.data["temperature"] - if "max_tokens" in call.data: - coordinator.max_tokens = call.data["max_tokens"] - - await coordinator.async_ask_question(question) - except Exception as ex: - _LOGGER.error("Error asking question: %s", str(ex)) - raise HomeAssistantError(f"Failed to ask question: {str(ex)}") from ex - finally: - coordinator.model = original_params["model"] - coordinator.temperature = original_params["temperature"] - coordinator.max_tokens = original_params["max_tokens"] - - async def async_clear_history(call: ServiceCall) -> None: - """Handle the clear_history service call.""" - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") - - coordinator = next(iter(hass.data[DOMAIN].values())) - coordinator._responses.clear() - await coordinator.async_refresh() - - async def async_get_history(call: ServiceCall) -> dict[str, list]: - """Handle the get_history service call.""" - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") - - coordinator = next(iter(hass.data[DOMAIN].values())) - if not coordinator._responses: - return {"history": []} - - limit = call.data.get("limit", 10) - history = list(coordinator._responses.items()) - limited_history = history[-limit:] if len(history) > limit else history - - return { - "history": [ - {"question": q, "response": r} for q, r in limited_history - ] - } - - async def async_set_system_prompt(call: ServiceCall) -> None: - """Handle the set_system_prompt service call.""" - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") - - coordinator = next(iter(hass.data[DOMAIN].values())) - coordinator.system_prompt = call.data["prompt"] - - hass.services.async_register( - DOMAIN, - SERVICE_ASK_QUESTION, - async_ask_question, - schema=vol.Schema({ - vol.Required("question"): cv.string, - vol.Optional("model"): cv.string, - vol.Optional("temperature"): vol.All( - vol.Coerce(float), vol.Range(min=0, max=2) - ), - vol.Optional("max_tokens"): vol.All( - vol.Coerce(int), vol.Range(min=1, max=4096) - ), - }) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_HISTORY, - async_clear_history, - schema=vol.Schema({}) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_GET_HISTORY, - async_get_history, - schema=vol.Schema({ - vol.Optional("limit", default=10): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - }) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_SYSTEM_PROMPT, - async_set_system_prompt, - schema=vol.Schema({ - vol.Required("prompt"): cv.string, - }) - ) - return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up HA text AI from a config entry.""" - HATextAICoordinator = get_coordinator() + """Set up HA Text AI from a config entry.""" try: + session = aiohttp_client.async_get_clientsession(hass) + coordinator = HATextAICoordinator( hass, api_key=entry.data[CONF_API_KEY], - endpoint=entry.data.get(CONF_API_ENDPOINT), - model=entry.data.get(CONF_MODEL), - temperature=entry.data.get(CONF_TEMPERATURE), - max_tokens=entry.data.get(CONF_MAX_TOKENS), - request_interval=entry.data.get(CONF_REQUEST_INTERVAL), + endpoint=entry.data.get("api_endpoint", "https://api.openai.com/v1"), + model=entry.data.get("model", "gpt-3.5-turbo"), + temperature=entry.data.get("temperature", 0.7), + max_tokens=entry.data.get("max_tokens", 1000), + request_interval=entry.data.get("request_interval", 1.0), + session=session, ) - await coordinator.async_config_entry_first_refresh() + try: + await coordinator.async_config_entry_first_refresh() + except Exception as refresh_ex: + _LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex)) + return False + + if not coordinator.last_update_success: + _LOGGER.error("Failed to communicate with OpenAI API") + return False + hass.data[DOMAIN][entry.entry_id] = coordinator - return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + try: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + except Exception as setup_ex: + _LOGGER.error("Failed to setup platforms: %s", str(setup_ex)) + return False + + _LOGGER.info( + "Successfully set up HA Text AI with model: %s", + entry.data.get("model", "gpt-3.5-turbo") + ) + + return True + except Exception as ex: - raise ConfigEntryNotReady(f"Failed to setup entry: {str(ex)}") from ex + _LOGGER.exception("Unexpected error setting up entry: %s", str(ex)) + return False async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if entry.entry_id not in hass.data.get(DOMAIN, {}): - return True + try: + if entry.entry_id not in hass.data.get(DOMAIN, {}): + return True - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.async_shutdown() - # Remove services only if this was the last instance - if not hass.data[DOMAIN]: - services = [ - SERVICE_ASK_QUESTION, - SERVICE_CLEAR_HISTORY, - SERVICE_GET_HISTORY, - SERVICE_SET_SYSTEM_PROMPT - ] - for service in services: - if DOMAIN in hass.services.async_services() and \ - service in hass.services.async_services()[DOMAIN]: - hass.services.async_remove(DOMAIN, service) + return unload_ok - return unload_ok + except Exception as ex: + _LOGGER.exception("Error unloading entry: %s", str(ex)) + return False + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + try: + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) + except Exception as ex: + _LOGGER.exception("Error reloading entry: %s", str(ex)) diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 30d66c6..9046c4f 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -1,12 +1,19 @@ """Config flow for HA text AI integration.""" -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple import voluptuous as vol +import ssl +import certifi +import asyncio +from async_timeout import timeout +import aiohttp +from urllib.parse import urlparse from homeassistant import config_entries from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.core import callback -import openai +from openai import AsyncOpenAI +from openai import OpenAIError, APIError, APIConnectionError, AuthenticationError, RateLimitError from .const import ( DOMAIN, @@ -31,18 +38,126 @@ STEP_USER_DATA_SCHEMA = vol.Schema({ vol.Optional( CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE - ): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0, max=2), + msg="Temperature must be between 0 and 2" + ), vol.Optional( CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS - ): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), - vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str, + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=4096), + msg="Max tokens must be between 1 and 4096" + ), + vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): vol.All( + str, + vol.URL(), + msg="Must be a valid URL" + ), vol.Optional( CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL - ): vol.All(vol.Coerce(float), vol.Range(min=0.1)), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0.1), + msg="Request interval must be at least 0.1 seconds" + ), }) +async def validate_endpoint(endpoint: str) -> Tuple[bool, str]: + """Validate API endpoint accessibility.""" + try: + parsed_url = urlparse(endpoint) + if parsed_url.scheme not in ('http', 'https'): + return False, "invalid_endpoint_scheme" + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + async with timeout(5): + async with aiohttp.ClientSession() as session: + async with session.get(endpoint, ssl=ssl_context) as response: + if response.status != 200: + return False, "endpoint_not_available" + return True, "" + except Exception as e: + _LOGGER.error("Error validating endpoint: %s", str(e)) + return False, "endpoint_error" + +async def validate_api_connection( + api_key: str, + endpoint: str, + model: str, + retry_count: int = 3, + retry_delay: float = 1.0 +) -> Tuple[bool, str, list]: + """Validate API connection with improved retry logic.""" + ssl_context = ssl.create_default_context(cafile=certifi.where()) + + # Validate endpoint first + endpoint_valid, endpoint_error = await validate_endpoint(endpoint) + if not endpoint_valid: + return False, endpoint_error, [] + + for attempt in range(retry_count): + try: + async with timeout(10): + client = AsyncOpenAI( + api_key=api_key, + base_url=endpoint, + http_client=aiohttp.ClientSession( + connector=aiohttp.TCPConnector( + ssl=ssl_context, + enable_cleanup_closed=True + ) + ) + ) + + try: + models = await client.models.list() + model_ids = [model.id for model in models.data] + finally: + await client.http_client.close() + + if model not in model_ids: + _LOGGER.warning( + "Model %s not found in available models: %s", + model, + ", ".join(model_ids) + ) + return False, "invalid_model", model_ids + return True, "", model_ids + + except asyncio.TimeoutError: + _LOGGER.warning( + "Timeout during API validation (attempt %d/%d)", + attempt + 1, + retry_count + ) + if attempt == retry_count - 1: + return False, "timeout", [] + await asyncio.sleep(retry_delay) + + except AuthenticationError as err: + _LOGGER.error("Authentication error: %s", str(err)) + return False, "invalid_auth", [] + + except RateLimitError as err: + _LOGGER.error("Rate limit exceeded: %s", str(err)) + return False, "rate_limit", [] + + except APIConnectionError as err: + _LOGGER.error("API connection error: %s", str(err)) + return False, "cannot_connect", [] + + except APIError as err: + _LOGGER.error("API error: %s", str(err)) + return False, "api_error", [] + + except Exception as err: + _LOGGER.exception("Unexpected error during validation: %s", str(err)) + return False, "unknown", [] + class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HA text AI.""" @@ -57,24 +172,16 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: - # Create OpenAI client - client = openai.OpenAI( - api_key=user_input[CONF_API_KEY], - base_url=user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT) + # Validate input data + user_input = STEP_USER_DATA_SCHEMA(user_input) + + is_valid, error_code, available_models = await validate_api_connection( + user_input[CONF_API_KEY], + user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT), + user_input[CONF_MODEL] ) - # Verify API connection and model availability - models = await self.hass.async_add_executor_job(client.models.list) - model_ids = [model.id for model in models.data] - - if user_input[CONF_MODEL] not in model_ids: - _LOGGER.warning( - "Selected model %s not found in available models: %s", - user_input[CONF_MODEL], - ", ".join(model_ids) - ) - errors["base"] = "invalid_model" - else: + if is_valid: await self.async_set_unique_id(user_input[CONF_API_KEY]) self._abort_if_unique_id_configured() @@ -83,15 +190,17 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data=user_input ) - except openai.AuthenticationError as err: - _LOGGER.error("Authentication failed: %s", str(err)) - errors["base"] = "invalid_auth" - except openai.APIError as err: - _LOGGER.error("API connection failed: %s", str(err)) - errors["base"] = "cannot_connect" - except Exception as err: # pylint: disable=broad-except - _LOGGER.exception("Unexpected error: %s", str(err)) - errors["base"] = "unknown" + errors["base"] = error_code + if error_code == "invalid_model": + _LOGGER.warning( + "Selected model %s not found in available models: %s", + user_input[CONF_MODEL], + ", ".join(available_models) + ) + + except vol.Invalid as err: + _LOGGER.error("Validation error: %s", str(err)) + errors["base"] = "invalid_input" return self.async_show_form( step_id="user", @@ -133,21 +242,33 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_TEMPERATURE, DEFAULT_TEMPERATURE ), description={"suggested_value": DEFAULT_TEMPERATURE}, - ): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0, max=2), + msg="Temperature must be between 0 and 2" + ), vol.Optional( CONF_MAX_TOKENS, default=self.config_entry.options.get( CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS ), description={"suggested_value": DEFAULT_MAX_TOKENS}, - ): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=4096), + msg="Max tokens must be between 1 and 4096" + ), vol.Optional( CONF_REQUEST_INTERVAL, default=self.config_entry.options.get( CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL ), description={"suggested_value": DEFAULT_REQUEST_INTERVAL}, - ): vol.All(vol.Coerce(float), vol.Range(min=0.1)), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0.1), + msg="Request interval must be at least 0.1 seconds" + ), }) return self.async_show_form( diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 4ee0ff7..0f250e7 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -2,7 +2,7 @@ from typing import Final from homeassistant.const import Platform -# Domain +# Domain and platforms DOMAIN: Final = "ha_text_ai" PLATFORMS: Final = [Platform.SENSOR] @@ -19,6 +19,9 @@ DEFAULT_TEMPERATURE: Final = 0.7 DEFAULT_MAX_TOKENS: Final = 1000 DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1" DEFAULT_REQUEST_INTERVAL: Final = 1.0 +DEFAULT_TIMEOUT: Final = 30 +DEFAULT_QUEUE_SIZE: Final = 100 +DEFAULT_HISTORY_LIMIT: Final = 50 # Parameter constraints MIN_TEMPERATURE: Final = 0.0 @@ -26,6 +29,8 @@ MAX_TEMPERATURE: Final = 2.0 MIN_MAX_TOKENS: Final = 1 MAX_MAX_TOKENS: Final = 4096 MIN_REQUEST_INTERVAL: Final = 0.1 +MIN_TIMEOUT: Final = 5 +MAX_TIMEOUT: Final = 120 # Service names SERVICE_ASK_QUESTION: Final = "ask_question" @@ -49,6 +54,10 @@ ATTR_MAX_TOKENS: Final = "max_tokens" ATTR_TOTAL_RESPONSES: Final = "total_responses" ATTR_SYSTEM_PROMPT: Final = "system_prompt" ATTR_RESPONSE_TIME: Final = "response_time" +ATTR_QUEUE_SIZE: Final = "queue_size" +ATTR_API_STATUS: Final = "api_status" +ATTR_ERROR_COUNT: Final = "error_count" +ATTR_LAST_ERROR: Final = "last_error" # Error messages ERROR_INVALID_API_KEY: Final = "invalid_api_key" @@ -58,6 +67,9 @@ 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_QUEUE_FULL: Final = "queue_full" +ERROR_INVALID_PROMPT: Final = "invalid_prompt" # Configuration descriptions CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses" @@ -76,17 +88,54 @@ ATTR_MAX_TOKENS_DESCRIPTION: Final = "Current max tokens setting" ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses" ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt" ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response" +ATTR_QUEUE_SIZE_DESCRIPTION: Final = "Current size of question queue" +ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status" +ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors" +ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message" # Entity attributes ENTITY_NAME: Final = "HA Text AI" ENTITY_ICON: Final = "mdi:robot" +ENTITY_ICON_ERROR: Final = "mdi:robot-dead" +ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited" # Translation keys TRANSLATION_KEY_CONFIG: Final = "config" TRANSLATION_KEY_OPTIONS: Final = "options" TRANSLATION_KEY_ERROR: Final = "error" +TRANSLATION_KEY_STATE: Final = "state" +TRANSLATION_KEY_SERVICES: Final = "services" # State attributes STATE_READY: Final = "ready" STATE_PROCESSING: Final = "processing" STATE_ERROR: Final = "error" +STATE_DISCONNECTED: Final = "disconnected" +STATE_RATE_LIMITED: Final = "rate_limited" +STATE_INITIALIZING: Final = "initializing" + +# Logging +LOGGER_NAME: Final = "custom_components.ha_text_ai" +LOG_LEVEL_DEFAULT: Final = "INFO" + +# Queue constants +QUEUE_TIMEOUT: Final = 5 +QUEUE_MAX_SIZE: Final = 100 + +# API constants +API_TIMEOUT: Final = 30 +API_RETRY_COUNT: Final = 3 +API_BACKOFF_FACTOR: Final = 1.5 + +# Service schema constants +SCHEMA_QUESTION: Final = "question" +SCHEMA_MODEL: Final = "model" +SCHEMA_TEMPERATURE: Final = "temperature" +SCHEMA_MAX_TOKENS: Final = "max_tokens" +SCHEMA_PROMPT: Final = "prompt" +SCHEMA_LIMIT: Final = "limit" + +# 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" diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index a8f7614..2cf3aec 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -1,48 +1,13 @@ -"""The HA Text AI integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, PLATFORMS - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up HA Text AI from a config entry.""" - try: - coordinator = HATextAICoordinator( - hass, - api_key=entry.data["api_key"], - endpoint=entry.data.get("api_endpoint", "https://api.openai.com/v1"), - model=entry.data.get("model", "gpt-3.5-turbo"), - temperature=entry.data.get("temperature", 0.7), - max_tokens=entry.data.get("max_tokens", 1000), - request_interval=entry.data.get("request_interval", 1.0), - ) - - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator - - return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - except Exception as ex: - raise ConfigEntryNotReady(f"Failed to setup entry: {str(ex)}") from ex - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - """Data coordinator for HA text AI.""" import asyncio import logging from datetime import timedelta from typing import Any, Dict, Optional -import openai +from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.exceptions import ConfigEntryAuthFailed +import async_timeout from .const import DOMAIN @@ -60,6 +25,7 @@ class HATextAICoordinator(DataUpdateCoordinator): temperature: float, max_tokens: int, request_interval: float, + session: Optional[Any] = None, ) -> None: """Initialize.""" super().__init__( @@ -69,6 +35,28 @@ class HATextAICoordinator(DataUpdateCoordinator): update_interval=timedelta(seconds=request_interval), ) + self._validate_params(api_key, temperature, max_tokens) + + self.api_key = api_key + self.endpoint = endpoint + self.model = model + self.temperature = float(temperature) + self.max_tokens = int(max_tokens) + self._question_queue = asyncio.Queue() + self._responses: Dict[str, Any] = {} + self.system_prompt: Optional[str] = None + self._is_ready = False + self._error_count = 0 + self._MAX_ERRORS = 3 + + self.client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.endpoint, + http_client=session, + ) + + def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None: + """Validate initialization parameters.""" if not api_key: raise ValueError("API key is required") if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: @@ -76,44 +64,79 @@ class HATextAICoordinator(DataUpdateCoordinator): if not isinstance(max_tokens, int) or max_tokens < 1: raise ValueError("Max tokens must be a positive integer") - self.api_key = api_key - self.endpoint = endpoint or "https://api.openai.com/v1" - self.model = model or "gpt-3.5-turbo" - self.temperature = float(temperature) - self.max_tokens = int(max_tokens) - self._question_queue = asyncio.Queue() - self._responses: Dict[str, Any] = {} - self.system_prompt: Optional[str] = None - - self.client = openai.OpenAI( - api_key=self.api_key, - base_url=self.endpoint - ) - async def _async_update_data(self) -> Dict[str, Any]: """Update data via OpenAI API.""" if self._question_queue.empty(): return self._responses try: - question = await self._question_queue.get() - response_content = await self.hass.async_add_executor_job( - self._make_api_call, question + async with async_timeout.timeout(30): + question = await self._question_queue.get() + try: + response_content = await self._make_api_call(question) + self._responses[question] = { + "question": question, + "response": response_content, + "error": None, + "timestamp": self.hass.loop.time() + } + self._error_count = 0 + self._is_ready = True + _LOGGER.debug("Response received for question: %s", question) + + except Exception as err: + self._handle_api_error(question, err) + finally: + self._question_queue.task_done() + + return self._responses + + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout while processing question") + await self._handle_timeout_error() + return self._responses + + def _handle_api_error(self, question: str, error: Exception) -> None: + """Handle API errors.""" + self._error_count += 1 + error_msg = str(error) + + if isinstance(error, AuthenticationError): + error_msg = "Authentication failed - invalid API key" + self._is_ready = False + elif isinstance(error, RateLimitError): + error_msg = "Rate limit exceeded" + elif isinstance(error, APIError): + error_msg = f"API error: {error}" + + self._responses[question] = { + "question": question, + "response": None, + "error": error_msg, + "timestamp": self.hass.loop.time() + } + + _LOGGER.error("API error (%s): %s", type(error).__name__, error_msg) + + if self._error_count >= self._MAX_ERRORS: + _LOGGER.warning( + "Multiple errors occurred (%d). Coordinator needs attention.", + self._error_count ) - self._responses[question] = { - "question": question, - "response": response_content - } - _LOGGER.debug("Response from API: %s", response_content) - return self._responses - except openai.AuthenticationError as err: - raise ConfigEntryAuthFailed from err - except Exception as err: - _LOGGER.error("Error communicating with API: %s", err) - return self._responses + async def _handle_timeout_error(self) -> None: + """Handle timeout errors.""" + self._error_count += 1 + if not self._question_queue.empty(): + try: + # Clear the queue if we have timeout issues + while not self._question_queue.empty(): + self._question_queue.get_nowait() + self._question_queue.task_done() + except Exception as err: + _LOGGER.error("Error clearing question queue: %s", err) - def _make_api_call(self, question: str) -> str: + async def _make_api_call(self, question: str) -> str: """Make API call to OpenAI.""" try: messages = [] @@ -121,13 +144,51 @@ class HATextAICoordinator(DataUpdateCoordinator): messages.append({"role": "system", "content": self.system_prompt}) messages.append({"role": "user", "content": question}) - completion = self.client.chat.completions.create( + completion = await self.client.chat.completions.create( model=self.model, messages=messages, temperature=self.temperature, max_tokens=self.max_tokens, ) return completion.choices[0].message.content + except Exception as err: _LOGGER.error("Error in API call: %s", err) raise + + async def async_ask_question(self, question: str) -> None: + """Add question to queue.""" + if not self._is_ready and self._error_count >= self._MAX_ERRORS: + _LOGGER.warning("Coordinator is not ready due to previous errors") + return + + await self._question_queue.put(question) + await self.async_refresh() + + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + try: + # Clear the queue + while not self._question_queue.empty(): + self._question_queue.get_nowait() + self._question_queue.task_done() + + await self.client.close() + self._is_ready = False + + except Exception as err: + _LOGGER.error("Error during shutdown: %s", err) + + @property + def is_ready(self) -> bool: + """Return if coordinator is ready.""" + return self._is_ready + + @property + def error_count(self) -> int: + """Return current error count.""" + return self._error_count + + def reset_error_count(self) -> None: + """Reset error counter.""" + self._error_count = 0 diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 093e823..2f324d4 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -9,6 +9,6 @@ "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", "requirements": ["openai>=1.0.0"], "ssdp": [], - "version": "1.0.3", + "version": "1.0.4", "zeroconf": [] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index d7cf58f..5fd42de 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -1,7 +1,7 @@ """Sensor platform for HA text AI.""" from datetime import datetime import logging -from typing import Any, Callable, Dict, Optional +from typing import Any, Dict, Optional from homeassistant.components.sensor import ( SensorEntity, @@ -24,6 +24,21 @@ from .const import ( ATTR_TEMPERATURE, ATTR_MAX_TOKENS, ATTR_TOTAL_RESPONSES, + ATTR_SYSTEM_PROMPT, + ATTR_QUEUE_SIZE, + ATTR_API_STATUS, + ATTR_ERROR_COUNT, + ATTR_LAST_ERROR, + ATTR_RESPONSE_TIME, + ENTITY_ICON, + ENTITY_ICON_ERROR, + ENTITY_ICON_PROCESSING, + STATE_READY, + STATE_PROCESSING, + STATE_ERROR, + STATE_DISCONNECTED, + STATE_RATE_LIMITED, + STATE_INITIALIZING, ) from .coordinator import HATextAICoordinator @@ -44,7 +59,6 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): _attr_has_entity_name = True _attr_state_class = SensorStateClass.MEASUREMENT _attr_device_class = SensorDeviceClass.TIMESTAMP - _attr_icon = "mdi:robot" def __init__( self, @@ -57,6 +71,18 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{config_entry.entry_id}" self._attr_name = "Last Response" self._attr_suggested_display_precision = 0 + self._error_count = 0 + self._last_error = None + self._state = STATE_INITIALIZING + + @property + def icon(self) -> str: + """Return the icon based on the current state.""" + if self._state == STATE_PROCESSING: + return ENTITY_ICON_PROCESSING + elif self._state in [STATE_ERROR, STATE_DISCONNECTED, STATE_RATE_LIMITED]: + return ENTITY_ICON_ERROR + return ENTITY_ICON @property def state(self) -> StateType: @@ -64,75 +90,92 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): if not self.coordinator.data or not self.coordinator.last_update_success_time: return None - # Convert to local time - if isinstance(self.coordinator.last_update_success_time, datetime): - return dt_util.as_local(self.coordinator.last_update_success_time) - return self.coordinator.last_update_success_time + try: + if isinstance(self.coordinator.last_update_success_time, datetime): + return dt_util.as_local(self.coordinator.last_update_success_time) + return self.coordinator.last_update_success_time + except Exception as err: + _LOGGER.error("Error getting state: %s", err, exc_info=True) + return None @property - def extra_state_attributes(self) -> Optional[Dict[str, Any]]: + def extra_state_attributes(self) -> Dict[str, Any]: """Return entity specific state attributes.""" + attributes = { + ATTR_TOTAL_RESPONSES: 0, + ATTR_MODEL: self.coordinator.model, + ATTR_TEMPERATURE: self.coordinator.temperature, + ATTR_MAX_TOKENS: self.coordinator.max_tokens, + ATTR_SYSTEM_PROMPT: self.coordinator.system_prompt, + ATTR_QUEUE_SIZE: self.coordinator._question_queue.qsize(), + ATTR_API_STATUS: self._state, + ATTR_ERROR_COUNT: self._error_count, + ATTR_LAST_ERROR: self._last_error, + } + if not self.coordinator.data: - return { - ATTR_TOTAL_RESPONSES: 0, - ATTR_MODEL: self.coordinator.model, - ATTR_TEMPERATURE: self.coordinator.temperature, - ATTR_MAX_TOKENS: self.coordinator.max_tokens, - } + return attributes try: history = list(self.coordinator.data.items()) - if not history: - return { - ATTR_TOTAL_RESPONSES: 0, - ATTR_MODEL: self.coordinator.model, - ATTR_TEMPERATURE: self.coordinator.temperature, - ATTR_MAX_TOKENS: self.coordinator.max_tokens, - } + if history: + last_question, last_data = history[-1] - last_question, last_data = history[-1] + # Handle different response formats + if isinstance(last_data, dict): + last_response = last_data.get("response", "") + last_updated = last_data.get("timestamp", self.coordinator.last_update_success_time) + response_time = last_data.get("response_time") + else: + last_response = str(last_data) + last_updated = self.coordinator.last_update_success_time + response_time = None - # Handle different response formats - if isinstance(last_data, dict): - last_response = last_data.get("response", "") - last_updated = last_data.get("timestamp", self.coordinator.last_update_success_time) - else: - last_response = str(last_data) - last_updated = self.coordinator.last_update_success_time + # Convert timestamp to local time if needed + if isinstance(last_updated, datetime): + last_updated = dt_util.as_local(last_updated) - # Convert timestamp to local time if needed - if isinstance(last_updated, datetime): - last_updated = dt_util.as_local(last_updated) + attributes.update({ + ATTR_QUESTION: last_question, + ATTR_RESPONSE: last_response, + ATTR_LAST_UPDATED: last_updated, + ATTR_TOTAL_RESPONSES: len(history), + }) + + if response_time is not None: + attributes[ATTR_RESPONSE_TIME] = response_time + + return attributes - return { - ATTR_QUESTION: last_question, - ATTR_RESPONSE: last_response, - ATTR_LAST_UPDATED: last_updated, - ATTR_TOTAL_RESPONSES: len(history), - ATTR_MODEL: self.coordinator.model, - ATTR_TEMPERATURE: self.coordinator.temperature, - ATTR_MAX_TOKENS: self.coordinator.max_tokens, - } except Exception as err: _LOGGER.error("Error getting attributes: %s", err, exc_info=True) - return { - ATTR_TOTAL_RESPONSES: 0, - ATTR_MODEL: self.coordinator.model, - ATTR_TEMPERATURE: self.coordinator.temperature, - ATTR_MAX_TOKENS: self.coordinator.max_tokens, - } + self._error_count += 1 + self._last_error = str(err) + self._state = STATE_ERROR + return attributes @property def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success - @property - def should_poll(self) -> bool: - """No need to poll. Coordinator notifies entity of updates.""" - return False - async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() self._handle_coordinator_update() + self._state = STATE_READY + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + if self.coordinator.data: + self._state = STATE_READY + else: + self._state = STATE_DISCONNECTED + except Exception as err: + _LOGGER.error("Error handling update: %s", err, exc_info=True) + self._error_count += 1 + self._last_error = str(err) + self._state = STATE_ERROR + + self.async_write_ha_state() diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index b1d256b..eea43fd 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -3,16 +3,22 @@ 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. + Response time may vary based on model selection and server load. fields: question: name: Question description: >- Your question or prompt for the AI assistant. Be specific and clear for better results. You can ask about home automation, technical advice, or general questions. + For complex queries, consider breaking them into smaller parts. required: true example: | What automations would you recommend for a smart kitchen? - Consider energy efficiency and convenience. + Consider energy efficiency, convenience, and integration with: + - Smart lighting + - Appliance control + - Temperature monitoring + - Voice commands selector: text: multiline: true @@ -23,6 +29,7 @@ ask_question: description: >- Select an AI model to use (optional, overrides default setting). Different models have different capabilities and token limits. + Note: More capable models may have longer response times and higher API costs. required: false example: "gpt-3.5-turbo" default: "gpt-3.5-turbo" @@ -31,19 +38,24 @@ ask_question: options: - label: "GPT-3.5 Turbo (Fast & Efficient)" value: "gpt-3.5-turbo" + - label: "GPT-3.5 Turbo 16K (Extended)" + value: "gpt-3.5-turbo-16k" - label: "GPT-4 (Most Capable)" value: "gpt-4" - label: "GPT-4 32K (Extended Context)" value: "gpt-4-32k" + - label: "GPT-4 Turbo (Latest)" + value: "gpt-4-1106-preview" mode: dropdown temperature: name: Temperature description: >- Controls response creativity (0-2): - 0.0-0.3: Focused, consistent responses - 0.4-0.7: Balanced responses - 0.8-2.0: More creative, varied responses + 0.0-0.3: Focused, consistent responses (best for technical/factual queries) + 0.4-0.7: Balanced responses (recommended for most uses) + 0.8-2.0: More creative, varied responses (best for brainstorming) + Note: Higher values may produce less predictable results. required: false default: 0.7 selector: @@ -59,9 +71,10 @@ ask_question: description: >- Maximum length of the response. Higher values allow longer responses but use more API tokens. Recommended ranges: - - Short responses: 256-512 - - Medium responses: 512-1024 - - Long responses: 1024-4096 + - Short responses (256-512): Quick answers, status updates + - Medium responses (512-1024): Detailed explanations, instructions + - Long responses (1024-4096): Complex analysis, multiple examples + Note: Actual response length may be shorter based on content. required: false default: 1000 selector: @@ -75,20 +88,22 @@ clear_history: name: Clear History description: >- Delete all stored questions and responses from the conversation history. - This action cannot be undone. + This action cannot be undone. Consider using 'get_history' first if you need to backup the data. + System prompt settings will be preserved. fields: {} get_history: name: Get History description: >- Retrieve recent conversation history, including questions, responses, and timestamps. - Results are ordered from newest to oldest. + Results are ordered from newest to oldest and include metadata like model used and response times. fields: limit: name: Limit description: >- - Number of most recent conversations to return. + Number of most recent conversations to return (1-100). Higher values return more history but may take longer to process. + Default: 10 conversations required: false default: 10 selector: @@ -98,17 +113,36 @@ get_history: step: 1 mode: box + filter_model: + name: Filter by Model + description: >- + Only return conversations using a specific AI model. + Leave empty to show all models. + required: false + selector: + select: + options: + - label: "All Models" + value: "" + - label: "GPT-3.5 Turbo" + value: "gpt-3.5-turbo" + - label: "GPT-4" + value: "gpt-4" + mode: dropdown + set_system_prompt: name: Set System Prompt description: >- Configure the AI's behavior by setting a system prompt. This affects how the AI interprets and responds to all future questions. + The prompt will persist until changed or cleared. fields: prompt: name: System Prompt description: >- Instructions that define how the AI should behave and respond. Be specific about the desired expertise, tone, and format of responses. + Maximum length: 1000 characters. required: true example: | You are a home automation expert assistant. Focus on: @@ -117,7 +151,20 @@ set_system_prompt: 3. Integration with popular smart home platforms 4. Security and privacy considerations Provide detailed but concise responses with clear steps when applicable. + Format complex responses with bullet points or numbered lists. + Include warnings about potential risks or limitations. selector: text: multiline: true type: text + max_length: 1000 + + clear_prompt: + name: Clear Existing Prompt + description: >- + Set to true to remove the current system prompt before applying the new one. + This ensures no conflicting instructions remain. + required: false + default: false + selector: + boolean: {} diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index 08661b9..ba9c8fd 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -2,32 +2,32 @@ "config": { "step": { "user": { - "title": "Set up HA text AI", - "description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key to proceed.", + "title": "Set up HA Text AI", + "description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key from platform.openai.com to proceed.", "data": { "api_key": { "name": "OpenAI API Key", - "description": "Your OpenAI API key from platform.openai.com" + "description": "Your OpenAI API key from platform.openai.com. Keep this secure and never share it." }, "model": { "name": "AI Model", - "description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses." + "description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses as it offers the best balance of capabilities and cost." }, "temperature": { "name": "Temperature", - "description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones." + "description": "Controls response creativity (0-2). Low values (0.1-0.3) for focused responses, high values (0.8-2.0) for creative ones." }, "max_tokens": { "name": "Max Tokens", - "description": "Maximum length of responses. Higher values allow longer responses but use more API tokens." + "description": "Maximum length of responses. Higher values allow longer responses but consume more API tokens. Recommended: 512-1024." }, "api_endpoint": { "name": "API Endpoint", - "description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint." + "description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint or proxy." }, "request_interval": { "name": "Request Interval", - "description": "Minimum time between API requests in seconds. Increase if hitting rate limits." + "description": "Minimum time between API requests in seconds. Increase if experiencing rate limits." } } } @@ -40,7 +40,9 @@ "invalid_model": "Selected model is not available. Please choose a different model.", "rate_limit": "API rate limit exceeded. Please try again later or increase the request interval.", "context_length": "Input too long for selected model. Try reducing max tokens or using a model with larger context.", - "api_error": "OpenAI API error. Please check the logs for details." + "api_error": "OpenAI API error. Please check the logs for details.", + "timeout": "API response timeout. Request took too long to complete.", + "queue_full": "Request queue is full. Please try again later." }, "abort": { "already_configured": "This OpenAI integration is already configured", @@ -51,20 +53,20 @@ "options": { "step": { "init": { - "title": "HA text AI Options", - "description": "Adjust your OpenAI integration settings. Changes will apply to future requests.", + "title": "HA Text AI Options", + "description": "Adjust your OpenAI integration settings. Changes will apply to future requests only.", "data": { "temperature": { "name": "Temperature", - "description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones." + "description": "Controls response creativity (0-2). Low values for focused responses, high for creative ones." }, "max_tokens": { "name": "Max Tokens", - "description": "Maximum length of responses. Higher values allow longer responses but use more API tokens." + "description": "Maximum length of responses. Higher values allow longer responses but consume more API tokens." }, "request_interval": { "name": "Request Interval", - "description": "Minimum time between API requests in seconds. Increase if hitting rate limits." + "description": "Minimum time between API requests in seconds. Increase if experiencing rate limits." } } } @@ -77,7 +79,7 @@ "state_attributes": { "last_updated": { "name": "Last Updated", - "description": "Time of the last AI response" + "description": "Timestamp of the last AI response" }, "question": { "name": "Last Question", @@ -110,6 +112,22 @@ "response_time": { "name": "Response Time", "description": "Time taken to generate last response" + }, + "queue_size": { + "name": "Queue Size", + "description": "Current size of request queue" + }, + "api_status": { + "name": "API Status", + "description": "Current API connection status" + }, + "error_count": { + "name": "Error Count", + "description": "Number of errors since last reset" + }, + "last_error": { + "name": "Last Error", + "description": "Description of the last error encountered" } } } @@ -118,25 +136,57 @@ "services": { "ask_question": { "name": "Ask Question", - "description": "Send a question to the AI model", + "description": "Send a question to the AI model and receive a detailed response. The response will be stored in conversation history.", "fields": { "question": { "name": "Question", - "description": "Your question for the AI" + "description": "Your question or prompt for the AI. Be specific for better results." + }, + "model": { + "name": "Model", + "description": "AI model to use (optional, overrides default settings)." + }, + "temperature": { + "name": "Temperature", + "description": "Response creativity level (0-2, optional)." + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Maximum response length (optional)." } } }, "clear_history": { "name": "Clear History", - "description": "Clear conversation history" + "description": "Delete all stored conversation history. This action cannot be undone." }, "get_history": { "name": "Get History", - "description": "Retrieve conversation history" + "description": "Retrieve conversation history, including questions, responses, and timestamps.", + "fields": { + "limit": { + "name": "Limit", + "description": "Number of recent conversations to return (default 10)." + }, + "filter_model": { + "name": "Filter by Model", + "description": "Retrieve only conversations using a specific AI model." + } + } }, "set_system_prompt": { "name": "Set System Prompt", - "description": "Set AI behavior instructions" + "description": "Configure AI behavior by setting a system prompt.", + "fields": { + "prompt": { + "name": "Prompt", + "description": "Instructions defining AI behavior and response style." + }, + "clear_prompt": { + "name": "Clear Prompt", + "description": "Remove current system prompt before setting new one." + } + } } } } diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json new file mode 100644 index 0000000..84c8d86 --- /dev/null +++ b/custom_components/ha_text_ai/translations/ru.json @@ -0,0 +1,192 @@ +{ + "config": { + "step": { + "user": { + "title": "Настройка HA Text AI", + "description": "Настройте интеграцию OpenAI для умного дома. Требуется API ключ OpenAI. Подробнее о получении ключа на platform.openai.com", + "data": { + "api_key": { + "name": "API ключ OpenAI", + "description": "Ваш API ключ с platform.openai.com. Храните его в безопасности." + }, + "model": { + "name": "AI Модель", + "description": "Выберите модель AI. GPT-3.5-Turbo рекомендуется для большинства задач как оптимальное сочетание возможностей и стоимости." + }, + "temperature": { + "name": "Температура", + "description": "Контролирует креативность ответов (0-2). Низкие значения (0.1-0.3) для точных ответов, высокие (0.8-2.0) для творческих." + }, + "max_tokens": { + "name": "Максимум токенов", + "description": "Максимальная длина ответов. Больше токенов = длиннее ответы, но выше расход API токенов. Рекомендуется: 512-1024." + }, + "api_endpoint": { + "name": "API Endpoint", + "description": "URL API OpenAI. Оставьте значение по умолчанию, если не используете собственный endpoint." + }, + "request_interval": { + "name": "Интервал запросов", + "description": "Минимальное время между API запросами в секундах. Увеличьте при превышении лимитов запросов." + } + } + } + }, + "error": { + "invalid_auth": "Неверный API ключ. Проверьте ключ OpenAI и попробуйте снова.", + "cannot_connect": "Не удалось подключиться к API. Проверьте подключение к интернету и endpoint.", + "unknown": "Неожиданная ошибка. Проверьте логи для подробностей.", + "already_exists": "Этот API ключ уже используется в другой интеграции.", + "invalid_model": "Выбранная модель недоступна. Выберите другую модель.", + "rate_limit": "Превышен лимит API запросов. Попробуйте позже или увеличьте интервал запросов.", + "context_length": "Входные данные слишком длинные для выбранной модели. Уменьшите max_tokens или используйте модель с большим контекстом.", + "api_error": "Ошибка API OpenAI. Проверьте логи для подробностей.", + "timeout": "Превышено время ожидания ответа от API.", + "queue_full": "Очередь запросов переполнена. Попробуйте позже." + }, + "abort": { + "already_configured": "Эта интеграция OpenAI уже настроена", + "auth_failed": "Ошибка аутентификации. Проверьте API ключ.", + "invalid_endpoint": "Указан неверный URL API endpoint" + } + }, + "options": { + "step": { + "init": { + "title": "Настройки HA Text AI", + "description": "Измените настройки интеграции OpenAI. Изменения применятся к будущим запросам.", + "data": { + "temperature": { + "name": "Температура", + "description": "Контролирует креативность ответов (0-2). Низкие значения для точных ответов, высокие для творческих." + }, + "max_tokens": { + "name": "Максимум токенов", + "description": "Максимальная длина ответов. Больше токенов = длиннее ответы, но выше расход API токенов." + }, + "request_interval": { + "name": "Интервал запросов", + "description": "Минимальное время между API запросами в секундах. Увеличьте при превышении лимитов." + } + } + } + } + }, + "entity": { + "sensor": { + "last_response": { + "name": "Последний ответ", + "state_attributes": { + "last_updated": { + "name": "Последнее обновление", + "description": "Время последнего ответа AI" + }, + "question": { + "name": "Последний вопрос", + "description": "Последний заданный вопрос" + }, + "response": { + "name": "Ответ AI", + "description": "Последний ответ от AI" + }, + "model": { + "name": "Текущая модель", + "description": "Используемая модель AI" + }, + "temperature": { + "name": "Настройка температуры", + "description": "Текущий параметр температуры" + }, + "max_tokens": { + "name": "Лимит токенов", + "description": "Текущий лимит максимальных токенов" + }, + "total_responses": { + "name": "Всего ответов", + "description": "Количество ответов с последнего сброса" + }, + "system_prompt": { + "name": "Системный промпт", + "description": "Текущие системные инструкции для AI" + }, + "response_time": { + "name": "Время ответа", + "description": "Время генерации последнего ответа" + }, + "queue_size": { + "name": "Размер очереди", + "description": "Текущий размер очереди запросов" + }, + "api_status": { + "name": "Статус API", + "description": "Текущий статус подключения к API" + }, + "error_count": { + "name": "Счётчик ошибок", + "description": "Количество ошибок с последнего сброса" + }, + "last_error": { + "name": "Последняя ошибка", + "description": "Описание последней возникшей ошибки" + } + } + } + } + }, + "services": { + "ask_question": { + "name": "Задать вопрос", + "description": "Отправить вопрос модели AI и получить подробный ответ. Ответ сохраняется в истории.", + "fields": { + "question": { + "name": "Вопрос", + "description": "Ваш вопрос или запрос для AI. Будьте конкретны для лучших результатов." + }, + "model": { + "name": "Модель", + "description": "Модель AI для использования (необязательно, переопределяет настройки по умолчанию)." + }, + "temperature": { + "name": "Температура", + "description": "Уровень креативности ответа (0-2, необязательно)." + }, + "max_tokens": { + "name": "Максимум токенов", + "description": "Максимальная длина ответа (необязательно)." + } + } + }, + "clear_history": { + "name": "Очистить историю", + "description": "Удалить всю историю разговоров. Это действие нельзя отменить." + }, + "get_history": { + "name": "Получить историю", + "description": "Получить историю разговоров, включая вопросы, ответы и временные метки.", + "fields": { + "limit": { + "name": "Лимит", + "description": "Количество последних разговоров для получения (по умолчанию 10)." + }, + "filter_model": { + "name": "Фильтр по модели", + "description": "Получить только разговоры с определённой моделью AI." + } + } + }, + "set_system_prompt": { + "name": "Установить системный промпт", + "description": "Настроить поведение AI, установив системный промпт.", + "fields": { + "prompt": { + "name": "Промпт", + "description": "Инструкции, определяющие поведение и стиль ответов AI." + }, + "clear_prompt": { + "name": "Очистить промпт", + "description": "Удалить текущий системный промпт перед установкой нового." + } + } + } + } +} diff --git a/ha_text_ai.zip b/ha_text_ai.zip index d90a9116dffdae0ed60a70be4f7a076e06b2c2fe..b6f559384d88285c7457f5964906e5f12e8c94d2 100644 GIT binary patch literal 16832 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 S{FxKN01iO`fH#^y@BR;jA7