diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 6d410d4..bcceddb 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -1,16 +1,20 @@ """The HA Text AI integration.""" import logging from typing import Any, Dict, Optional +import asyncio +import voluptuous as vol +import json +from datetime import datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import aiohttp_client from homeassistant.helpers import config_validation as cv +from async_timeout import timeout from .coordinator import HATextAICoordinator - from .const import ( DOMAIN, PLATFORMS, @@ -24,15 +28,94 @@ from .const import ( DEFAULT_MAX_TOKENS, DEFAULT_API_ENDPOINT, DEFAULT_REQUEST_INTERVAL, + API_VERSION, + API_MODELS_PATH, + API_CHAT_PATH, + API_TIMEOUT, + API_RETRY_COUNT, + API_BACKOFF_FACTOR, + LOGGER_NAME, + STATE_ERROR, + STATE_READY, + STATE_PROCESSING, + STATE_RATE_LIMITED, + STATE_MAINTENANCE, + STATE_DISCONNECTED, + STATE_RETRYING, + STATE_QUEUED, + STATE_UPDATING, + SUPPORTED_MODELS, + EVENT_RESPONSE_RECEIVED, + EVENT_ERROR_OCCURRED, + EVENT_STATE_CHANGED, ) -_LOGGER = logging.getLogger(__name__) +_LOGGER = logging.getLogger(LOGGER_NAME) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +# Service validation schemas +SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ + vol.Required("question"): cv.string, + vol.Optional("system_prompt"): cv.string, + vol.Optional("model"): vol.In(SUPPORTED_MODELS), + 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) + ), + vol.Optional("priority"): vol.Boolean, +}) + +SERVICE_SCHEMA_GET_HISTORY = vol.Schema({ + vol.Optional("limit", default=10): vol.All( + vol.Coerce(int), vol.Range(min=1, max=100) + ), + vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS), + vol.Optional("start_date"): cv.datetime, + vol.Optional("include_metadata"): vol.Boolean, +}) + +SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({ + vol.Required("prompt"): cv.string, +}) + +async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: bool = False) -> bool: + """Check API availability for different providers.""" + try: + if is_anthropic: + check_url = f"{endpoint}/v1/models" + else: + check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}" + + async with timeout(API_TIMEOUT): + async with session.get(check_url, headers=headers) as response: + if response.status == 200: + return True + elif response.status == 401: + raise ConfigEntryNotReady("Invalid API key") + elif response.status == 429: + _LOGGER.warning("Rate limit exceeded during API check") + return False + else: + _LOGGER.error("API check failed with status: %d", response.status) + return False + except Exception as ex: + _LOGGER.error("API check error: %s", str(ex)) + return False + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the HA Text AI component.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, { + "coordinators": {}, + "metrics": { + "total_requests": 0, + "total_tokens": 0, + "errors": {}, + "model_usage": {}, + } + }) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -40,30 +123,77 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: session = aiohttp_client.async_get_clientsession(hass) + # Determine API type based on model + model = entry.data.get(CONF_MODEL, DEFAULT_MODEL) + is_anthropic = any(model.startswith("claude") for model in SUPPORTED_MODELS) + + api_key = entry.data[CONF_API_KEY] + endpoint = entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT).rstrip('/') + + # Configure headers based on API type + headers = { + "Content-Type": "application/json", + "Accept": "application/json" + } + + if is_anthropic: + headers["x-api-key"] = api_key + headers["anthropic-version"] = "2023-06-01" + else: + headers["Authorization"] = f"Bearer {api_key}" + + # Check API with retries + for attempt in range(API_RETRY_COUNT): + if await async_check_api(session, endpoint, headers, is_anthropic): + break + if attempt < API_RETRY_COUNT - 1: + delay = API_BACKOFF_FACTOR * (2 ** attempt) + await asyncio.sleep(delay) + else: + raise ConfigEntryNotReady("Failed to connect to API") + + # Create and initialize coordinator coordinator = HATextAICoordinator( hass, - api_key=entry.data[CONF_API_KEY], - endpoint=entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT), - model=entry.data.get(CONF_MODEL, DEFAULT_MODEL), + api_key=api_key, + endpoint=endpoint, + model=model, temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE), max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS), request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL), session=session, + is_anthropic=is_anthropic ) - try: - await coordinator.async_config_entry_first_refresh() - except Exception as refresh_ex: - _LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex)) - raise ConfigEntryNotReady from refresh_ex + # Initialize coordinator + await coordinator.async_config_entry_first_refresh() - if not coordinator.last_update_success: - raise ConfigEntryNotReady("Failed to communicate with OpenAI API") + # Check coordinator status + if coordinator.endpoint_status == "auth_error": + raise ConfigEntryNotReady("Authentication failed") + elif coordinator.endpoint_status == "rate_limited": + _LOGGER.warning("API rate limited during setup") + elif coordinator.endpoint_status == "maintenance": + raise ConfigEntryNotReady("API is in maintenance mode") + elif coordinator.endpoint_status == "error": + raise ConfigEntryNotReady("API error during setup") + elif not coordinator.last_update_success: + raise ConfigEntryNotReady("Failed to initialize coordinator") hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Register event handlers + @callback + def handle_state_change(event): + """Handle state changes.""" + if event.data.get("entity_id").startswith(f"{DOMAIN}."): + _LOGGER.debug("State changed: %s", event.data) + + hass.bus.async_listen(EVENT_STATE_CHANGED, handle_state_change) + + # Register services async def async_ask_question(call: ServiceCall) -> None: """Handle the ask_question service call.""" question = call.data.get("question", "") @@ -71,36 +201,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("No question provided in service call") return - # Собираем все опциональные параметры request_params = {} - - # Обработка system_prompt - system_prompt = call.data.get("system_prompt") - if system_prompt is not None: - request_params["system_prompt"] = system_prompt - - # Обработка model - model = call.data.get("model") - if model is not None: - request_params["model"] = model - - # Обработка temperature - temperature = call.data.get("temperature") - if temperature is not None: - try: - request_params["temperature"] = float(temperature) - except ValueError: - _LOGGER.error("Invalid temperature value: %s", temperature) - return - - # Обработка max_tokens - max_tokens = call.data.get("max_tokens") - if max_tokens is not None: - try: - request_params["max_tokens"] = int(max_tokens) - except ValueError: - _LOGGER.error("Invalid max_tokens value: %s", max_tokens) - return + for param in ["system_prompt", "model", "temperature", "max_tokens"]: + if param in call.data: + request_params[param] = call.data[param] try: await coordinator.async_ask_question(question, **request_params) @@ -119,21 +223,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_get_history(call: ServiceCall) -> dict: """Handle the get_history service call.""" try: - limit = call.data.get("limit", 10) - filter_model = call.data.get("filter_model", "") + limit = min(int(call.data.get("limit", 10)), 100) + filter_model = str(call.data.get("filter_model", "")) + start_date = call.data.get("start_date") + include_metadata = call.data.get("include_metadata", False) responses = coordinator._responses + metrics = { + "total_requests": coordinator.request_count, + "total_tokens": coordinator.tokens_used, + "api_version": coordinator.api_version, + "endpoint_status": coordinator.endpoint_status, + "error_count": coordinator.error_count + } + + filtered_responses = responses.copy() - # Применяем фильтрацию по модели if filter_model: filtered_responses = { - k: v for k, v in responses.items() + k: v for k, v in filtered_responses.items() if v.get("model") == filter_model } - else: - filtered_responses = responses.copy() - # Сортируем по времени и ограничиваем количество + if start_date: + filtered_responses = { + k: v for k, v in filtered_responses.items() + if v.get("timestamp") >= start_date + } + + if not include_metadata: + filtered_responses = { + k: { + "question": v["question"], + "response": v["response"], + "timestamp": v["timestamp"] + } for k, v in filtered_responses.items() + } + sorted_responses = dict( sorted( filtered_responses.items(), @@ -142,28 +268,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: )[:limit] ) - return sorted_responses + return { + "metrics": metrics, + "responses": sorted_responses + } except Exception as err: _LOGGER.error("Error getting history: %s", str(err)) return {} async def async_set_system_prompt(call: ServiceCall) -> None: """Handle the set_system_prompt service call.""" - prompt = call.data.get("prompt", "") - if prompt: - try: + try: + prompt = str(call.data.get("prompt", "")).strip() + if prompt: coordinator.system_prompt = prompt _LOGGER.info("System prompt updated successfully") - except Exception as err: - _LOGGER.error("Error setting system prompt: %s", str(err)) - else: - _LOGGER.error("No prompt provided in service call") + else: + _LOGGER.error("Empty prompt provided") + except Exception as err: + _LOGGER.error("Error setting system prompt: %s", str(err)) - # Регистрация сервисов + # Register services with validation hass.services.async_register( DOMAIN, "ask_question", - async_ask_question + async_ask_question, + schema=SERVICE_SCHEMA_ASK_QUESTION ) hass.services.async_register( @@ -175,13 +305,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.services.async_register( DOMAIN, "get_history", - async_get_history + async_get_history, + schema=SERVICE_SCHEMA_GET_HISTORY ) hass.services.async_register( DOMAIN, "set_system_prompt", - async_set_system_prompt + async_set_system_prompt, + schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT ) _LOGGER.info( @@ -192,24 +324,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True except Exception as ex: - _LOGGER.exception("Unexpected error setting up entry: %s", str(ex)) + _LOGGER.exception("Setup error: %s", str(ex)) raise ConfigEntryNotReady from ex async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" try: - if entry.entry_id not in hass.data.get(DOMAIN, {}): - return True + coordinator = hass.data[DOMAIN].get(entry.entry_id) + if coordinator: + # Clear queue and history + coordinator._responses.clear() + while not coordinator._question_queue.empty(): + try: + coordinator._question_queue.get_nowait() + coordinator._question_queue.task_done() + except Exception: + pass - # Удаляем все сервисы при выгрузке интеграции - services = ["ask_question", "clear_history", "get_history", "set_system_prompt"] - for service in services: + # Close connection + await coordinator.async_shutdown() + + # Remove services + for service in ["ask_question", "clear_history", "get_history", "set_system_prompt"]: hass.services.async_remove(DOMAIN, service) 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() + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -217,10 +358,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _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)) # убрано лишнее двоеточие +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + new = {**entry.data} + + # Migrate settings + if CONF_MODEL in new and new[CONF_MODEL] not in SUPPORTED_MODELS: + new[CONF_MODEL] = DEFAULT_MODEL + + entry.version = 2 + hass.config_entries.async_update_entry(entry, data=new) + + return True diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 43a50bd..5a5266c 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -4,7 +4,7 @@ import voluptuous as vol import asyncio import aiohttp from async_timeout import timeout -from urllib.parse import urlparse +from urllib.parse import urlparse, urljoin from homeassistant import config_entries from homeassistant.const import CONF_API_KEY @@ -25,37 +25,56 @@ from .const import ( DEFAULT_MAX_TOKENS, DEFAULT_API_ENDPOINT, DEFAULT_REQUEST_INTERVAL, + MIN_TEMPERATURE, + MAX_TEMPERATURE, + MIN_MAX_TOKENS, + MAX_MAX_TOKENS, + MIN_REQUEST_INTERVAL, + API_VERSION, + API_MODELS_PATH, + ERROR_INVALID_API_KEY, + ERROR_CANNOT_CONNECT, + ERROR_UNKNOWN, + ERROR_INVALID_MODEL, + ERROR_RATE_LIMIT, + ERROR_API_ERROR, + ERROR_TIMEOUT, ) import logging _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema({ - vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str, - vol.Optional( - CONF_TEMPERATURE, - default=DEFAULT_TEMPERATURE - ): vol.All( - vol.Coerce(float), - vol.Range(min=0, max=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.Optional( - CONF_REQUEST_INTERVAL, - default=DEFAULT_REQUEST_INTERVAL - ): vol.All( - vol.Coerce(float), - vol.Range(min=0.1) - ), -}) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string, + vol.Optional( + CONF_TEMPERATURE, + default=DEFAULT_TEMPERATURE + ): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional( + CONF_MAX_TOKENS, + default=DEFAULT_MAX_TOKENS + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) + ), + vol.Optional( + CONF_API_ENDPOINT, + default=DEFAULT_API_ENDPOINT + ): cv.string, + vol.Optional( + CONF_REQUEST_INTERVAL, + default=DEFAULT_REQUEST_INTERVAL + ): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + } +) async def validate_api_connection( hass, @@ -69,10 +88,17 @@ async def validate_api_connection( session = async_get_clientsession(hass) headers = { "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json; charset=utf-8", + "Accept-Charset": "utf-8" } - models_url = f"{endpoint}/v1/models" + base_url = endpoint.rstrip('/') + if not base_url.endswith(f"/{API_VERSION}"): + base_url = f"{base_url}/{API_VERSION}" + + models_url = f"{base_url}/{API_MODELS_PATH}" + _LOGGER.debug("Attempting to connect to: %s", models_url) for attempt in range(retry_count): try: @@ -82,30 +108,33 @@ async def validate_api_connection( data = await response.json() model_ids = [m["id"] for m in data.get("data", [])] + _LOGGER.debug("Available models: %s", ", ".join(model_ids)) + 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 False, ERROR_INVALID_MODEL, model_ids return True, "", model_ids elif response.status == 401: _LOGGER.error("Authentication failed") - return False, "invalid_auth", [] + return False, ERROR_INVALID_API_KEY, [] elif response.status == 429: _LOGGER.error("Rate limit exceeded") - return False, "rate_limit", [] + return False, ERROR_RATE_LIMIT, [] else: + response_text = await response.text() _LOGGER.error( "API error: %s - %s", response.status, - await response.text() + response_text ) - return False, "api_error", [] + return False, ERROR_API_ERROR, [] except asyncio.TimeoutError: _LOGGER.warning( @@ -114,18 +143,18 @@ async def validate_api_connection( retry_count ) if attempt == retry_count - 1: - return False, "timeout", [] + return False, ERROR_TIMEOUT, [] await asyncio.sleep(retry_delay) except aiohttp.ClientError as err: _LOGGER.error("Connection error: %s", str(err)) - return False, "cannot_connect", [] + return False, ERROR_CANNOT_CONNECT, [] except Exception as err: _LOGGER.exception("Unexpected error during validation: %s", str(err)) - return False, "unknown", [] + return False, ERROR_UNKNOWN, [] - return False, "unknown", [] + return False, ERROR_UNKNOWN, [] class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HA text AI.""" @@ -141,7 +170,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: - # Validate URL format endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT) try: result = urlparse(endpoint) @@ -161,7 +189,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors ) - # Validate input data validated_input = STEP_USER_DATA_SCHEMA(user_input) is_valid, error_code, available_models = await validate_api_connection( @@ -181,7 +208,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) errors["base"] = error_code - if error_code == "invalid_model": + if error_code == ERROR_INVALID_MODEL: _LOGGER.warning( "Selected model %s not found in available models: %s", validated_input[CONF_MODEL], @@ -233,7 +260,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ), ): vol.All( vol.Coerce(float), - vol.Range(min=0, max=2) + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) ), vol.Optional( CONF_MAX_TOKENS, @@ -242,7 +269,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ), ): vol.All( vol.Coerce(int), - vol.Range(min=1, max=4096) + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) ), vol.Optional( CONF_REQUEST_INTERVAL, @@ -251,7 +278,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ), ): vol.All( vol.Coerce(float), - vol.Range(min=0.1) + vol.Range(min=MIN_REQUEST_INTERVAL) ), }) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 47930f2..63d0f36 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -13,15 +13,27 @@ CONF_MAX_TOKENS: Final = "max_tokens" CONF_API_ENDPOINT: Final = "api_endpoint" CONF_REQUEST_INTERVAL: Final = "request_interval" +# Model constants +SUPPORTED_MODELS: Final = [ + "gpt-3.5-turbo", + "gpt-3.5-turbo-16k", + "gpt-4", + "gpt-4-32k", + "gpt-4-1106-preview", + "claude-3-sonnet", + "claude-3-opus" +] + # Default values DEFAULT_MODEL: Final = "gpt-3.5-turbo" DEFAULT_TEMPERATURE: Final = 0.1 DEFAULT_MAX_TOKENS: Final = 1000 -DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1" +DEFAULT_API_ENDPOINT: Final = "https://api.openai.com" DEFAULT_REQUEST_INTERVAL: Final = 1.0 DEFAULT_TIMEOUT: Final = 30 DEFAULT_QUEUE_SIZE: Final = 100 DEFAULT_HISTORY_LIMIT: Final = 50 +DEFAULT_RETRY_COUNT: Final = 3 # Parameter constraints MIN_TEMPERATURE: Final = 0.0 @@ -31,6 +43,23 @@ MAX_MAX_TOKENS: Final = 4096 MIN_REQUEST_INTERVAL: Final = 0.1 MIN_TIMEOUT: Final = 5 MAX_TIMEOUT: Final = 120 +MAX_PROMPT_LENGTH: Final = 1000 +MAX_HISTORY_LIMIT: Final = 100 + +# API constants +API_VERSION: Final = "v1" +API_MODELS_PATH: Final = "models" +API_CHAT_PATH: Final = "chat/completions" +API_TIMEOUT: Final = 30 +API_RETRY_COUNT: Final = 3 +API_BACKOFF_FACTOR: Final = 1.5 +API_MAX_RETRIES: Final = 3 + +# History constants +HISTORY_FILTER_MODEL: Final = "filter_model" +HISTORY_FILTER_DATE: Final = "start_date" +HISTORY_SORT_ORDER: Final = "sort_order" +HISTORY_INCLUDE_METADATA: Final = "include_metadata" # Service names SERVICE_ASK_QUESTION: Final = "ask_question" @@ -58,6 +87,13 @@ ATTR_QUEUE_SIZE: Final = "queue_size" ATTR_API_STATUS: Final = "api_status" ATTR_ERROR_COUNT: Final = "error_count" ATTR_LAST_ERROR: Final = "last_error" +ATTR_API_VERSION: Final = "api_version" +ATTR_ENDPOINT_STATUS: Final = "endpoint_status" +ATTR_REQUEST_COUNT: Final = "request_count" +ATTR_TOKENS_USED: Final = "tokens_used" +ATTR_RETRY_COUNT: Final = "retry_count" +ATTR_QUEUE_POSITION: Final = "queue_position" +ATTR_ESTIMATED_WAIT: Final = "estimated_wait" # Error messages ERROR_INVALID_API_KEY: Final = "invalid_api_key" @@ -70,6 +106,8 @@ ERROR_API_ERROR: Final = "api_error" ERROR_TIMEOUT: Final = "timeout_error" ERROR_QUEUE_FULL: Final = "queue_full" ERROR_INVALID_PROMPT: Final = "invalid_prompt" +ERROR_INVALID_PARAMETERS: Final = "invalid_parameters" +ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable" # Configuration descriptions CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses" @@ -92,12 +130,18 @@ 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" +ATTR_API_VERSION_DESCRIPTION: Final = "Current API version" +ATTR_ENDPOINT_STATUS_DESCRIPTION: Final = "Current endpoint status" +ATTR_REQUEST_COUNT_DESCRIPTION: Final = "Total number of API requests" +ATTR_TOKENS_USED_DESCRIPTION: Final = "Total tokens used" # 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" +ENTITY_ICON_OFFLINE: Final = "mdi:robot-off" +ENTITY_ICON_QUEUE: Final = "mdi:robot-confused" # Translation keys TRANSLATION_KEY_CONFIG: Final = "config" @@ -113,6 +157,10 @@ STATE_ERROR: Final = "error" STATE_DISCONNECTED: Final = "disconnected" STATE_RATE_LIMITED: Final = "rate_limited" STATE_INITIALIZING: Final = "initializing" +STATE_MAINTENANCE: Final = "maintenance" +STATE_RETRYING: Final = "retrying" +STATE_QUEUED: Final = "queued" +STATE_UPDATING: Final = "updating" # Logging LOGGER_NAME: Final = "custom_components.ha_text_ai" @@ -122,11 +170,6 @@ LOG_LEVEL_DEFAULT: Final = "INFO" 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" diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 9f7090e..ab6d7ac 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -2,15 +2,24 @@ import asyncio import logging from datetime import timedelta -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, List +import time from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError +from anthropic import AsyncAnthropic from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util import async_timeout -from .const import DOMAIN +from .const import ( + DOMAIN, + DEFAULT_TIMEOUT, + MAX_QUEUE_SIZE, + MAX_RETRIES, + RETRY_DELAY, + SUPPORTED_MODELS, +) _LOGGER = logging.getLogger(__name__) @@ -27,8 +36,21 @@ class HATextAICoordinator(DataUpdateCoordinator): max_tokens: int, request_interval: float, session: Optional[Any] = None, + is_anthropic: bool = False, ) -> None: - """Initialize.""" + """Initialize coordinator. + + Args: + hass: HomeAssistant instance + api_key: API key for the service + endpoint: API endpoint URL + model: Model name to use + temperature: Temperature parameter for generation + max_tokens: Maximum tokens to generate + request_interval: Interval between requests + session: Optional session object + is_anthropic: Whether to use Anthropic API + """ super().__init__( hass, _LOGGER, @@ -43,18 +65,36 @@ class HATextAICoordinator(DataUpdateCoordinator): self.model = model self.temperature = float(temperature) self.max_tokens = int(max_tokens) - self._question_queue = asyncio.Queue() + self._question_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE) self._responses: Dict[str, Any] = {} self.system_prompt: Optional[str] = None self._is_ready = False + self._is_processing = False + self._is_rate_limited = False + self._is_maintenance = False self._error_count = 0 self._MAX_ERRORS = 3 + self._request_count = 0 + self._tokens_used = 0 + self._api_version = "v1" + self._endpoint_status = "disconnected" + self._performance_metrics: Dict[str, Any] = { + "avg_response_time": 0, + "total_errors": 0, + "success_rate": 100, + "requests_per_minute": 0, + } + self._last_request_time = 0 + self._is_anthropic = is_anthropic - self.client = AsyncOpenAI( - api_key=self.api_key, - base_url=self.endpoint, - http_client=session, - ) + if is_anthropic: + self.client = AsyncAnthropic(api_key=self.api_key) + else: + 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.""" @@ -66,13 +106,14 @@ class HATextAICoordinator(DataUpdateCoordinator): raise ValueError("Max tokens must be a positive integer") async def _async_update_data(self) -> Dict[str, Any]: - """Update data via OpenAI API.""" + """Update data via API.""" if self._question_queue.empty(): return self._responses try: - async with async_timeout.timeout(30): - question_data = await self._question_queue.get() + async with async_timeout.timeout(DEFAULT_TIMEOUT): + self._is_processing = True + priority, question_data = await self._question_queue.get() question = question_data["question"] params = question_data["params"] @@ -85,6 +126,7 @@ class HATextAICoordinator(DataUpdateCoordinator): system_prompt=params.get("system_prompt") ) + self._update_metrics(response_data) self._responses[question] = { "question": question, "response": response_data["response"], @@ -93,15 +135,30 @@ class HATextAICoordinator(DataUpdateCoordinator): "model": response_data["model"], "temperature": params.get("temperature", self.temperature), "max_tokens": params.get("max_tokens", self.max_tokens), - "response_time": response_data.get("response_time") + "response_time": response_data.get("response_time"), + "tokens": response_data.get("tokens", 0), + "priority": priority } self._error_count = 0 self._is_ready = True + self._endpoint_status = "connected" + self._request_count += 1 + self._tokens_used += response_data.get("tokens", 0) + self._last_request_time = time.time() + + # Fire event for successful response + self.hass.bus.async_fire(f"{DOMAIN}_response_received", { + "question": question, + "model": response_data["model"], + "tokens": response_data.get("tokens", 0) + }) + _LOGGER.debug("Response received for question: %s", question) except Exception as err: - self._handle_api_error(question, err) + await self._handle_api_error(question, err) finally: + self._is_processing = False self._question_queue.task_done() return self._responses @@ -111,18 +168,29 @@ class HATextAICoordinator(DataUpdateCoordinator): await self._handle_timeout_error() return self._responses - def _handle_api_error(self, question: str, error: Exception) -> None: - """Handle API errors.""" + async def _handle_api_error(self, question: str, error: Exception) -> None: + """Handle API errors with retry logic.""" self._error_count += 1 + self._performance_metrics["total_errors"] += 1 error_msg = str(error) if isinstance(error, AuthenticationError): error_msg = "Authentication failed - invalid API key" self._is_ready = False + self._endpoint_status = "auth_error" elif isinstance(error, RateLimitError): error_msg = "Rate limit exceeded" + self._is_rate_limited = True + self._endpoint_status = "rate_limited" + # Implement exponential backoff + await asyncio.sleep(RETRY_DELAY * (2 ** (self._error_count - 1))) elif isinstance(error, APIError): + if "maintenance" in str(error).lower(): + self._is_maintenance = True + self._endpoint_status = "maintenance" error_msg = f"API error: {error}" + else: + self._endpoint_status = "error" self._responses[question] = { "question": question, @@ -134,6 +202,13 @@ class HATextAICoordinator(DataUpdateCoordinator): "max_tokens": self.max_tokens } + # Fire error event + self.hass.bus.async_fire(f"{DOMAIN}_error_occurred", { + "error_type": type(error).__name__, + "error_message": error_msg, + "question": question + }) + _LOGGER.error("API error (%s): %s", type(error).__name__, error_msg) if self._error_count >= self._MAX_ERRORS: @@ -142,16 +217,26 @@ class HATextAICoordinator(DataUpdateCoordinator): self._error_count ) - async def _handle_timeout_error(self) -> None: - """Handle timeout errors.""" - self._error_count += 1 - if not self._question_queue.empty(): - try: - 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 _update_metrics(self, response_data: Dict[str, Any]) -> None: + """Update performance metrics.""" + response_time = response_data.get("response_time", 0) + current_avg = self._performance_metrics["avg_response_time"] + self._performance_metrics["avg_response_time"] = ( + (current_avg * self._request_count + response_time) / + (self._request_count + 1) + ) + + total_requests = self._request_count + 1 + self._performance_metrics["success_rate"] = ( + (total_requests - self._performance_metrics["total_errors"]) / + total_requests * 100 + ) + + # Calculate requests per minute + if self._last_request_time: + time_diff = time.time() - self._last_request_time + if time_diff > 0: + self._performance_metrics["requests_per_minute"] = 60 / time_diff async def _make_api_call( self, @@ -161,26 +246,23 @@ class HATextAICoordinator(DataUpdateCoordinator): max_tokens: Optional[int] = None, system_prompt: Optional[str] = None ) -> Dict[str, Any]: - """Make API call to OpenAI.""" + """Make API call to the selected service.""" try: - messages = [] - current_system_prompt = system_prompt if system_prompt is not None else self.system_prompt - if current_system_prompt: - messages.append({"role": "system", "content": current_system_prompt}) - messages.append({"role": "user", "content": question}) - start_time = dt_util.utcnow() - completion = await self.client.chat.completions.create( - model=model or self.model, - messages=messages, - temperature=temperature if temperature is not None else self.temperature, - max_tokens=max_tokens if max_tokens is not None else self.max_tokens, - ) + + if self._is_anthropic: + response = await self._make_anthropic_call( + question, model, temperature, max_tokens, system_prompt + ) + else: + response = await self._make_openai_call( + question, model, temperature, max_tokens, system_prompt + ) + response_time = (dt_util.utcnow() - start_time).total_seconds() return { - "response": completion.choices[0].message.content, - "model": completion.model, + **response, "response_time": response_time } @@ -188,15 +270,70 @@ class HATextAICoordinator(DataUpdateCoordinator): _LOGGER.error("Error in API call: %s", err) raise + async def _make_anthropic_call( + self, + question: str, + model: Optional[str], + temperature: Optional[float], + max_tokens: Optional[int], + system_prompt: Optional[str] + ) -> Dict[str, Any]: + """Make API call to Anthropic.""" + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": question}) + + completion = await self.client.messages.create( + model=model or self.model, + messages=messages, + temperature=temperature if temperature is not None else self.temperature, + max_tokens=max_tokens if max_tokens is not None else self.max_tokens, + ) + + return { + "response": completion.content[0].text, + "model": completion.model, + "tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0 + } + + async def _make_openai_call( + self, + question: str, + model: Optional[str], + temperature: Optional[float], + max_tokens: Optional[int], + system_prompt: Optional[str] + ) -> Dict[str, Any]: + """Make API call to OpenAI.""" + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": question}) + + completion = await self.client.chat.completions.create( + model=model or self.model, + messages=messages, + temperature=temperature if temperature is not None else self.temperature, + max_tokens=max_tokens if max_tokens is not None else self.max_tokens, + ) + + return { + "response": completion.choices[0].message.content, + "model": completion.model, + "tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0 + } + async def async_ask_question( self, question: str, system_prompt: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None, - max_tokens: Optional[int] = None + max_tokens: Optional[int] = None, + priority: bool = False ) -> None: - """Add question to queue with optional parameters.""" + """Add question to queue with priority support.""" if not self._is_ready and self._error_count >= self._MAX_ERRORS: _LOGGER.warning("Coordinator is not ready due to previous errors") return @@ -211,32 +348,271 @@ class HATextAICoordinator(DataUpdateCoordinator): } } - await self._question_queue.put(question_data) - await self.async_refresh() + # Priority: 0 for high priority, 1 for normal + priority_level = 0 if priority else 1 + + try: + await self._question_queue.put((priority_level, question_data)) + await self.async_refresh() + except asyncio.QueueFull: + _LOGGER.error("Question queue is full. Try again later.") + raise RuntimeError("Queue is full") async def async_shutdown(self) -> None: """Shutdown the coordinator.""" try: while not self._question_queue.empty(): - self._question_queue.get_nowait() - self._question_queue.task_done() + try: + self._question_queue.get_nowait() + self._question_queue.task_done() + except asyncio.QueueEmpty: + break + + if hasattr(self.client, 'close'): + await self.client.close() - await self.client.close() self._is_ready = False + self._endpoint_status = "disconnected" + + # Final metrics update + self._update_final_metrics() except Exception as err: _LOGGER.error("Error during shutdown: %s", err) + def _update_final_metrics(self) -> None: + """Update final metrics before shutdown.""" + if self._request_count > 0: + self._performance_metrics["final_success_rate"] = ( + (self._request_count - self._performance_metrics["total_errors"]) / + self._request_count * 100 + ) + self._performance_metrics["total_requests"] = self._request_count + self._performance_metrics["total_tokens"] = self._tokens_used + + @property + def performance_metrics(self) -> Dict[str, Any]: + """Return current performance metrics.""" + return self._performance_metrics + + @property + def queue_size(self) -> int: + """Return current queue size.""" + return self._question_queue.qsize() + + @property + def is_queue_full(self) -> bool: + """Return whether queue is full.""" + return self._question_queue.full() + @property def is_ready(self) -> bool: """Return if coordinator is ready.""" - return self._is_ready + return self._is_ready and self._error_count < self._MAX_ERRORS + + @property + def is_processing(self) -> bool: + """Return if coordinator is processing.""" + return self._is_processing + + @property + def is_rate_limited(self) -> bool: + """Return if coordinator is rate limited.""" + return self._is_rate_limited + + @property + def is_maintenance(self) -> bool: + """Return if API is in maintenance.""" + return self._is_maintenance @property def error_count(self) -> int: """Return current error count.""" return self._error_count + @property + def request_count(self) -> int: + """Return total request count.""" + return self._request_count + + @property + def tokens_used(self) -> int: + """Return total tokens used.""" + return self._tokens_used + + @property + def api_version(self) -> str: + """Return API version.""" + return self._api_version + + @property + def endpoint_status(self) -> str: + """Return endpoint status.""" + return self._endpoint_status + + @property + def responses(self) -> Dict[str, Any]: + """Return all responses.""" + return self._responses + + @property + def last_response(self) -> Optional[Dict[str, Any]]: + """Return the last response.""" + if not self._responses: + return None + return next(iter(self._responses.values())) + def reset_error_count(self) -> None: """Reset error counter.""" self._error_count = 0 + self._is_rate_limited = False + self._is_maintenance = False + if not self._is_ready: + self._is_ready = True + self._endpoint_status = "connected" + + async def clear_queue(self) -> None: + """Clear the question queue.""" + try: + while not self._question_queue.empty(): + try: + self._question_queue.get_nowait() + self._question_queue.task_done() + except asyncio.QueueEmpty: + break + except Exception as err: + _LOGGER.error("Error clearing queue: %s", err) + + async def clear_history(self) -> None: + """Clear response history.""" + self._responses.clear() + await self.async_refresh() + + def get_response(self, question: str) -> Optional[Dict[str, Any]]: + """Get specific response by question.""" + return self._responses.get(question) + + def get_recent_responses(self, limit: int = 10) -> List[Dict[str, Any]]: + """Get most recent responses.""" + return list(sorted( + self._responses.values(), + key=lambda x: x["timestamp"], + reverse=True + ))[:limit] + + async def retry_failed_requests(self) -> None: + """Retry failed requests.""" + failed_requests = [ + (q, r) for q, r in self._responses.items() + if r.get("error") is not None + ] + + for question, response in failed_requests: + await self.async_ask_question( + question, + system_prompt=response.get("system_prompt"), + model=response.get("model"), + temperature=response.get("temperature"), + max_tokens=response.get("max_tokens"), + priority=True + ) + + def update_system_prompt(self, new_prompt: str) -> None: + """Update system prompt.""" + self.system_prompt = new_prompt + _LOGGER.info("System prompt updated") + + async def health_check(self) -> Dict[str, Any]: + """Perform health check.""" + health_status = { + "is_ready": self.is_ready, + "is_processing": self.is_processing, + "is_rate_limited": self.is_rate_limited, + "is_maintenance": self.is_maintenance, + "error_count": self.error_count, + "endpoint_status": self.endpoint_status, + "queue_size": self.queue_size, + "request_count": self.request_count, + "tokens_used": self.tokens_used, + "performance_metrics": self.performance_metrics + } + + return health_status + + async def _handle_timeout_error(self) -> None: + """Handle timeout errors.""" + self._error_count += 1 + self._endpoint_status = "timeout" + self._performance_metrics["total_errors"] += 1 + + if not self._question_queue.empty(): + await self.clear_queue() + + # Fire timeout event + self.hass.bus.async_fire(f"{DOMAIN}_timeout_error", { + "error_count": self._error_count, + "endpoint_status": self._endpoint_status + }) + + def export_metrics(self) -> Dict[str, Any]: + """Export all metrics and statistics.""" + return { + "performance": self._performance_metrics, + "requests": { + "total": self._request_count, + "successful": self._request_count - self._performance_metrics["total_errors"], + "failed": self._performance_metrics["total_errors"] + }, + "tokens": { + "total_used": self._tokens_used, + "average_per_request": self._tokens_used / self._request_count if self._request_count > 0 else 0 + }, + "status": { + "is_ready": self.is_ready, + "endpoint_status": self._endpoint_status, + "error_count": self._error_count + }, + "queue": { + "size": self.queue_size, + "is_full": self.is_queue_full + } + } + + async def validate_model(self, model: str) -> bool: + """Validate if model is supported.""" + return model in SUPPORTED_MODELS + + async def estimate_tokens(self, text: str) -> int: + """Estimate token count for text.""" + # Простая оценка: примерно 4 символа на токен + return len(text) // 4 + + def get_rate_limit_info(self) -> Dict[str, Any]: + """Get rate limit information.""" + return { + "is_rate_limited": self._is_rate_limited, + "retry_after": RETRY_DELAY * (2 ** (self._error_count - 1)) if self._is_rate_limited else 0 + } + + async def optimize_queue(self) -> None: + """Optimize queue by removing duplicate requests.""" + if self._question_queue.empty(): + return + + seen_questions = set() + optimized_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE) + + while not self._question_queue.empty(): + try: + priority, question_data = self._question_queue.get_nowait() + question = question_data["question"] + + if question not in seen_questions: + seen_questions.add(question) + await optimized_queue.put((priority, question_data)) + + self._question_queue.task_done() + except asyncio.QueueEmpty: + break + + self._question_queue = optimized_queue diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index ed967ca..52e9f03 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -4,11 +4,39 @@ "codeowners": ["@smkrv"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/smkrv/ha-text-ai/wiki", + "documentation": "https://github.com/smkrv/ha-text-ai", + "homepage": "https://github.com/smkrv/ha-text-ai", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", - "requirements": ["openai>=1.0.0"], + "requirements": [ + "openai>=1.12.0", + "anthropic>=0.8.0", + "aiohttp>=3.8.0", + "async-timeout>=4.0.0" + ], + "version": "2.0.0", + "homeassistant": "2024.1.0", + "quality_scale": "silver", + "integration_type": "service", + "translations": [ + "en" + ], + "zeroconf": [], "ssdp": [], - "version": "1.1.0", - "zeroconf": [] + "usb": [], + "bluetooth": [], + "mqtt": [], + "loggers": ["custom_components.ha_text_ai"], + "after_dependencies": ["http"], + "title": "Text AI Assistant", + "description": "AI-powered text assistant supporting multiple models including GPT and Claude", + "render_readme": true, + "tags": [ + "ai", + "automation", + "chat", + "assistant", + "openai", + "claude" + ] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index dc820b7..849b9c5 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -30,6 +30,10 @@ from .const import ( ATTR_ERROR_COUNT, ATTR_LAST_ERROR, ATTR_RESPONSE_TIME, + ATTR_API_VERSION, + ATTR_ENDPOINT_STATUS, + ATTR_REQUEST_COUNT, + ATTR_TOKENS_USED, ENTITY_ICON, ENTITY_ICON_ERROR, ENTITY_ICON_PROCESSING, @@ -39,6 +43,7 @@ from .const import ( STATE_DISCONNECTED, STATE_RATE_LIMITED, STATE_INITIALIZING, + STATE_MAINTENANCE, ) from .coordinator import HATextAICoordinator @@ -53,6 +58,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([HATextAISensor(coordinator, entry)], True) + class HATextAISensor(CoordinatorEntity, SensorEntity): """HA text AI Sensor.""" @@ -74,6 +80,13 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): self._error_count = 0 self._last_error = None self._state = STATE_INITIALIZING + self._attr_device_info = { + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "name": "HA Text AI", + "manufacturer": "Community", + "model": coordinator.model, + "sw_version": coordinator.api_version, + } @property def icon(self) -> str: @@ -114,6 +127,10 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): ATTR_API_STATUS: self._state, ATTR_ERROR_COUNT: self._error_count, ATTR_LAST_ERROR: self._last_error, + ATTR_API_VERSION: self.coordinator.api_version, + ATTR_ENDPOINT_STATUS: self.coordinator.endpoint_status, + ATTR_REQUEST_COUNT: self.coordinator.request_count, + ATTR_TOKENS_USED: self.coordinator.tokens_used, } if not self.coordinator.data: @@ -186,11 +203,23 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): try: if self.coordinator.data: if self.coordinator._is_ready: - self._state = STATE_READY + if self.coordinator._is_processing: + self._state = STATE_PROCESSING + elif self.coordinator._is_rate_limited: + self._state = STATE_RATE_LIMITED + elif self.coordinator._is_maintenance: + self._state = STATE_MAINTENANCE + else: + self._state = STATE_READY else: self._state = STATE_DISCONNECTED else: self._state = STATE_DISCONNECTED + + # Обновляем счетчик ошибок только если статус изменился на ошибку + if self._state == STATE_ERROR: + self._error_count += 1 + except Exception as err: _LOGGER.error("Error handling update: %s", err, exc_info=True) self._error_count += 1 @@ -198,3 +227,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): self._state = STATE_ERROR self.async_write_ha_state() + + async def async_reset_error_count(self) -> None: + """Reset the error counter.""" + self._error_count = 0 + self._last_error = None + self.async_write_ha_state() diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index 25a844f..c8cffd4 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -4,6 +4,7 @@ ask_question: 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. + Supports various AI models with different capabilities and pricing. fields: question: name: Question @@ -11,6 +12,7 @@ ask_question: 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. + The system will maintain conversation context for follow-up questions. required: true example: | What automations would you recommend for a smart kitchen? @@ -28,8 +30,10 @@ ask_question: name: Model description: >- Select an AI model to use (optional, overrides default setting). - You can choose from predefined models or enter your own model identifier. - Different models have different capabilities and token limits. + Different models have different capabilities, context limits, and response characteristics: + - GPT-3.5 Turbo: Fast, efficient, good for most tasks + - GPT-4: More capable, better reasoning, slower + - Claude-3: Advanced capabilities, longer context Note: More capable models may have longer response times and higher API costs. required: false example: "gpt-3.5-turbo" @@ -48,14 +52,16 @@ ask_question: value: "gpt-4-32k" - label: "GPT-4 Turbo (Latest)" value: "gpt-4-1106-preview" - - label: "Claude-3 Sonnet" + - label: "Claude-3 Sonnet (Balanced)" value: "claude-3-sonnet" + - label: "Claude-3 Opus (Most Advanced)" + value: "claude-3-opus" mode: dropdown temperature: name: Temperature description: >- - Controls response creativity (0-2): + Controls response creativity (0.0-2.0): 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) @@ -73,12 +79,12 @@ ask_question: max_tokens: name: Max Tokens description: >- - Maximum length of the response. Higher values allow longer responses but use more API tokens. + Maximum length of the response (1-4096 tokens). Recommended ranges: - - 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. + - Short (256-512): Quick answers, status updates + - Medium (512-1024): Detailed explanations + - Long (1024-4096): Complex analysis + Note: Token limits vary by model. Actual length may be shorter. required: false default: 1000 selector: @@ -93,8 +99,9 @@ ask_question: description: >- Optional system prompt to set context for this specific question. This will temporarily override the default system prompt. + Use this to specify expertise areas, response format, or special instructions. required: false - example: "You are a home automation expert focused on energy efficiency" + example: "You are a home automation expert focused on energy efficiency and security" selector: text: multiline: true @@ -104,14 +111,18 @@ clear_history: description: >- Delete all stored questions and responses from the conversation history. This action cannot be undone. Consider using 'get_history' first if you need to backup the data. - System prompt settings will be preserved. + System prompt settings and configuration 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 and include metadata like model used and response times. + Retrieve recent conversation history, including: + - Questions and responses + - Timestamps and response times + - Models used and token counts + - Temperature and other settings + Results are ordered from newest to oldest. fields: limit: name: Limit @@ -144,22 +155,41 @@ get_history: value: "gpt-3.5-turbo" - label: "GPT-4" value: "gpt-4" + - label: "GPT-4 Turbo" + value: "gpt-4-1106-preview" - label: "Claude-3 Sonnet" value: "claude-3-sonnet" + - label: "Claude-3 Opus" + value: "claude-3-opus" mode: dropdown + start_date: + name: Start Date + description: >- + Optional start date for filtering history. + Format: YYYY-MM-DD + required: false + selector: + datetime: + type: date + set_system_prompt: name: Set System Prompt description: >- Set default system behavior instructions for all future conversations. This defines how the AI should behave and respond to questions. + The prompt will be used for all models unless overridden per question. 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. + Be specific about: + - Desired expertise and knowledge areas + - Response tone and style + - Output format preferences + - Special handling instructions + Maximum length: 1000 characters required: true example: | You are a home automation expert assistant. Focus on: diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index 8ffee6d..66f0c13 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -3,36 +3,48 @@ "step": { "user": { "title": "Set up HA Text AI", - "description": "Configure your AI integration (OpenAI, Anthropic, etc.)", + "description": "Configure your AI integration for various providers (OpenAI, Anthropic, etc.). Different models have different capabilities and pricing.", "data": { - "api_key": "Your API key", - "model": "AI model to use for responses (e.g., gpt-3.5-turbo, claude-3-sonnet)", - "temperature": "Temperature for response generation (0-2)", - "max_tokens": "Maximum tokens in response (1-4096)", - "api_endpoint": "API endpoint URL (optional, for custom endpoints)", - "request_interval": "Minimum time between API requests (seconds)", - "system_prompt": "Default system prompt for all conversations" + "api_key": "API key for authentication (required)", + "model": "AI model to use (e.g., gpt-3.5-turbo, gpt-4, claude-3-sonnet, claude-3-opus)", + "temperature": "Response creativity (0-2, lower = more focused and consistent)", + "max_tokens": "Maximum response length (1-4096 tokens)", + "api_endpoint": "Custom API endpoint URL (default varies by provider)", + "request_interval": "Minimum time between requests in seconds (min: 0.1)", + "system_prompt": "Default instructions for AI behavior and expertise" } } }, "error": { - "invalid_api_key": "Invalid API key - please check your credentials", - "cannot_connect": "Failed to connect to API - check endpoint and network", - "invalid_model": "Selected model is not available or invalid", - "rate_limit": "API rate limit exceeded - please wait", - "unknown": "Unexpected error occurred - check logs for details" + "invalid_auth": "Authentication failed - check your API key", + "invalid_api_key": "Invalid API key - please verify your credentials", + "cannot_connect": "Connection failed - check endpoint and network status", + "invalid_model": "Model unavailable or not supported by the API", + "rate_limit": "Rate limit exceeded - please reduce request frequency", + "context_length": "Input exceeds maximum context length for model", + "api_error": "API service error - check provider status", + "timeout": "Request timeout - server not responding", + "queue_full": "Request queue full - try again later", + "invalid_prompt": "Invalid system prompt format or length", + "invalid_url_format": "Invalid API endpoint URL format", + "invalid_input": "Invalid configuration parameters", + "unknown": "Unexpected error - check logs for details" } }, "options": { "step": { "init": { - "title": "HA Text AI Options", + "title": "HA Text AI Settings", + "description": "Adjust your AI integration parameters and behavior", "data": { - "model": "AI model selection", - "temperature": "Response temperature (0-2)", - "max_tokens": "Maximum response length", - "request_interval": "Time between requests (seconds)", - "system_prompt": "Default system instructions" + "model": "Select AI model for responses (capabilities vary)", + "temperature": "Response creativity (0-2, affects variation)", + "max_tokens": "Maximum response length in tokens", + "request_interval": "Minimum seconds between requests (rate limiting)", + "system_prompt": "Default AI behavior and expertise instructions", + "timeout": "Request timeout in seconds (default: 30)", + "retry_count": "Number of retry attempts for failed requests", + "queue_size": "Maximum pending requests in queue" } } } @@ -40,55 +52,94 @@ "services": { "ask_question": { "name": "Ask Question", - "description": "Send a question to the AI model and receive a response", + "description": "Send a question to the AI model and get a detailed response. Supports context awareness for follow-up questions.", "fields": { "question": { "name": "Question", - "description": "Your question or prompt for the AI" + "description": "Your question or prompt for the AI model. Be specific for better results." }, "system_prompt": { "name": "System Prompt", - "description": "Optional system prompt to override default for this question" + "description": "Optional behavior instructions for this specific question" }, "model": { "name": "Model", - "description": "Optional AI model to use for this question" + "description": "Optional specific AI model for this request (overrides default)" }, "temperature": { "name": "Temperature", - "description": "Optional temperature setting for this question (0-2)" + "description": "Optional creativity setting (0-2, affects response variation)" }, "max_tokens": { "name": "Max Tokens", - "description": "Optional maximum token limit for this response" + "description": "Optional maximum response length (1-4096 tokens)" } } }, "clear_history": { "name": "Clear History", - "description": "Delete all stored conversation history" + "description": "Delete all stored conversation history, responses, and metadata" }, "get_history": { "name": "Get History", - "description": "Retrieve conversation history with metadata", + "description": "Retrieve conversation history with detailed metadata including timestamps", "fields": { "limit": { "name": "Limit", - "description": "Maximum number of conversations to return" + "description": "Maximum number of conversations to return (1-100)" }, "filter_model": { "name": "Filter Model", - "description": "Optional filter to show only specific model responses" + "description": "Show only responses from a specific model" + }, + "start_date": { + "name": "Start Date", + "description": "Filter conversations from this date (YYYY-MM-DD)" } } }, "set_system_prompt": { "name": "Set System Prompt", - "description": "Set default system behavior instructions", + "description": "Update default AI behavior and expertise instructions", "fields": { "prompt": { "name": "System Prompt", - "description": "Instructions defining AI behavior and response style" + "description": "Instructions for AI behavior, expertise, and response style" + } + } + } + }, + "state": { + "ready": "Ready for requests", + "processing": "Processing request", + "error": "Error occurred", + "disconnected": "API disconnected", + "rate_limited": "Rate limit reached", + "initializing": "Starting up", + "retrying": "Retrying request", + "queued": "Request queued" + }, + "entity": { + "sensor": { + "status": { + "name": "AI Status", + "state": { + "ready": "Ready", + "processing": "Processing", + "error": "Error", + "disconnected": "Disconnected", + "rate_limited": "Rate Limited", + "initializing": "Initializing", + "retrying": "Retrying", + "queued": "Queued" + } + }, + "last_response": { + "name": "Last Response", + "state": { + "success": "Success", + "error": "Error", + "timeout": "Timeout" } } } diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json deleted file mode 100644 index c1e27a5..0000000 --- a/custom_components/ha_text_ai/translations/ru.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Настройка HA Text AI", - "description": "Настройка интеграции с AI-сервисами (OpenAI, Anthropic и др.)", - "data": { - "api_key": "Ваш API-ключ", - "model": "Модель AI для ответов (например, gpt-3.5-turbo, claude-3-sonnet)", - "temperature": "Температура генерации ответов (0-2)", - "max_tokens": "Максимальное количество токенов в ответе (1-4096)", - "api_endpoint": "URL конечной точки API (необязательно, для пользовательских endpoint)", - "request_interval": "Минимальный интервал между запросами к API (секунды)", - "system_prompt": "Системная инструкция по умолчанию для всех диалогов" - } - } - }, - "error": { - "invalid_api_key": "Недействительный API-ключ - проверьте учетные данные", - "cannot_connect": "Не удалось подключиться к API - проверьте endpoint и сеть", - "invalid_model": "Выбранная модель недоступна или некорректна", - "rate_limit": "Превышен лимит запросов API - пожалуйста, подождите", - "unknown": "Произошла непредвиденная ошибка - проверьте логи" - } - }, - "options": { - "step": { - "init": { - "title": "Настройки HA Text AI", - "data": { - "model": "Выбор модели AI", - "temperature": "Температура ответов (0-2)", - "max_tokens": "Максимальная длина ответа", - "request_interval": "Интервал между запросами (секунды)", - "system_prompt": "Системные инструкции по умолчанию" - } - } - } - }, - "services": { - "ask_question": { - "name": "Задать вопрос", - "description": "Отправить вопрос модели AI и получить ответ", - "fields": { - "question": { - "name": "Вопрос", - "description": "Ваш вопрос или запрос для AI" - }, - "system_prompt": { - "name": "Системная инструкция", - "description": "Необязательная системная инструкция, заменяющая инструкцию по умолчанию для этого вопроса" - }, - "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": "Максимальное количество диалогов для возврата" - }, - "filter_model": { - "name": "Фильтр по модели", - "description": "Необязательный фильтр для показа ответов только от определенной модели" - } - } - }, - "set_system_prompt": { - "name": "Установить системную инструкцию", - "description": "Задать системные инструкции по умолчанию", - "fields": { - "prompt": { - "name": "Системная инструкция", - "description": "Инструкции, определяющие поведение AI и стиль ответов" - } - } - } - } -} diff --git a/hacs.json b/hacs.json index d0ab59f..cb24c5e 100644 --- a/hacs.json +++ b/hacs.json @@ -4,6 +4,6 @@ "domains": ["sensor"], "homeassistant": "2024.11.0", "icon": "mdi:brain", - "version": "1.1.0", + "version": "2.0.0", "documentation": "https://github.com/smkrv/ha-text-ai" }