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 d90a911..b6f5593 100644 Binary files a/ha_text_ai.zip and b/ha_text_ai.zip differ diff --git a/hacs.json b/hacs.json index 9f5b93b..348d60b 100644 --- a/hacs.json +++ b/hacs.json @@ -4,6 +4,6 @@ "domains": ["sensor"], "homeassistant": "2024.11.0", "icon": "mdi:brain", - "version": "1.0.3", + "version": "1.0.4", "documentation": "https://github.com/smkrv/ha-text-ai" }