diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 5028bce..93406fe 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -23,10 +23,13 @@ from .const import ( CONF_API_ENDPOINT, CONF_REQUEST_INTERVAL, ) -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( { @@ -56,7 +59,6 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: coordinator = next(iter(hass.data[DOMAIN].values())) question = call.data["question"] - original_params = { "model": coordinator.model, "temperature": coordinator.temperature, @@ -64,7 +66,6 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: } try: - if "model" in call.data: coordinator.model = call.data["model"] if "temperature" in call.data: @@ -77,7 +78,6 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: _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"] @@ -118,7 +118,6 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: coordinator = next(iter(hass.data[DOMAIN].values())) coordinator.system_prompt = call.data["prompt"] - hass.services.async_register( DOMAIN, SERVICE_ASK_QUESTION, @@ -166,6 +165,7 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HA text AI from a config entry.""" + HATextAICoordinator = get_coordinator() try: coordinator = HATextAICoordinator( hass, @@ -186,11 +186,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - + # Remove services only if this was the last instance if not hass.data[DOMAIN]: services = [ SERVICE_ASK_QUESTION, @@ -199,7 +202,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: SERVICE_SET_SYSTEM_PROMPT ] for service in services: - if service in hass.services.async_services().get(DOMAIN, {}): + if DOMAIN in hass.services.async_services() and \ + service in hass.services.async_services()[DOMAIN]: hass.services.async_remove(DOMAIN, service) return unload_ok diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 197497b..30d66c6 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -22,6 +22,9 @@ from .const import ( DEFAULT_REQUEST_INTERVAL, ) +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, @@ -54,35 +57,50 @@ 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) ) - await self.hass.async_add_executor_job( - client.models.list - ) + # 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] - await self.async_set_unique_id(user_input[CONF_API_KEY]) - self._abort_if_unique_id_configured() + 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: + await self.async_set_unique_id(user_input[CONF_API_KEY]) + self._abort_if_unique_id_configured() - return self.async_create_entry( - title="HA text AI", - data=user_input - ) + return self.async_create_entry( + title="HA text AI", + data=user_input + ) - except openai.AuthenticationError: + except openai.AuthenticationError as err: + _LOGGER.error("Authentication failed: %s", str(err)) errors["base"] = "invalid_auth" - except openai.APIError: + except openai.APIError as err: + _LOGGER.error("API connection failed: %s", str(err)) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error: %s", str(err)) errors["base"] = "unknown" return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors, + description_placeholders={ + "default_model": DEFAULT_MODEL, + "default_endpoint": DEFAULT_API_ENDPOINT, + } ) @staticmethod @@ -114,21 +132,21 @@ class OptionsFlowHandler(config_entries.OptionsFlow): default=self.config_entry.options.get( CONF_TEMPERATURE, DEFAULT_TEMPERATURE ), - description="Temperature for response generation (0-2)", + description={"suggested_value": DEFAULT_TEMPERATURE}, ): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), vol.Optional( CONF_MAX_TOKENS, default=self.config_entry.options.get( CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS ), - description="Maximum tokens in response (1-4096)", + description={"suggested_value": DEFAULT_MAX_TOKENS}, ): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), vol.Optional( CONF_REQUEST_INTERVAL, default=self.config_entry.options.get( CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL ), - description="Minimum time between API requests (seconds)", + description={"suggested_value": DEFAULT_REQUEST_INTERVAL}, ): vol.All(vol.Coerce(float), vol.Range(min=0.1)), }) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 9830c15..4ee0ff7 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -43,11 +43,21 @@ SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set system prompt for AI model" ATTR_QUESTION: Final = "question" ATTR_RESPONSE: Final = "response" ATTR_LAST_UPDATED: Final = "last_updated" +ATTR_MODEL: Final = "model" +ATTR_TEMPERATURE: Final = "temperature" +ATTR_MAX_TOKENS: Final = "max_tokens" +ATTR_TOTAL_RESPONSES: Final = "total_responses" +ATTR_SYSTEM_PROMPT: Final = "system_prompt" +ATTR_RESPONSE_TIME: Final = "response_time" # Error messages ERROR_INVALID_API_KEY: Final = "invalid_api_key" ERROR_CANNOT_CONNECT: Final = "cannot_connect" ERROR_UNKNOWN: Final = "unknown_error" +ERROR_INVALID_MODEL: Final = "invalid_model" +ERROR_RATE_LIMIT: Final = "rate_limit_exceeded" +ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded" +ERROR_API_ERROR: Final = "api_error" # Configuration descriptions CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses" @@ -56,6 +66,27 @@ CONF_MAX_TOKENS_DESCRIPTION: Final = "Maximum tokens in response (1-4096)" CONF_API_ENDPOINT_DESCRIPTION: Final = "API endpoint URL" CONF_REQUEST_INTERVAL_DESCRIPTION: Final = "Minimum time between API requests (seconds)" +# Entity attributes descriptions +ATTR_QUESTION_DESCRIPTION: Final = "Last question asked" +ATTR_RESPONSE_DESCRIPTION: Final = "Last response received" +ATTR_LAST_UPDATED_DESCRIPTION: Final = "Time of last update" +ATTR_MODEL_DESCRIPTION: Final = "Current AI model in use" +ATTR_TEMPERATURE_DESCRIPTION: Final = "Current temperature setting" +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" + # Entity attributes ENTITY_NAME: Final = "HA Text AI" ENTITY_ICON: Final = "mdi:robot" + +# Translation keys +TRANSLATION_KEY_CONFIG: Final = "config" +TRANSLATION_KEY_OPTIONS: Final = "options" +TRANSLATION_KEY_ERROR: Final = "error" + +# State attributes +STATE_READY: Final = "ready" +STATE_PROCESSING: Final = "processing" +STATE_ERROR: Final = "error" diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 9c360ec..a8f7614 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -3,7 +3,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, PLATFORMS -from .coordinator import HATextAICoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HA Text AI from a config entry.""" diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 357e4d3..093e823 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.2", + "version": "1.0.3", "zeroconf": [] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index d1715f8..d7cf58f 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -13,8 +13,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util -from .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED +from .const import ( + DOMAIN, + ATTR_QUESTION, + ATTR_RESPONSE, + ATTR_LAST_UPDATED, + ATTR_MODEL, + ATTR_TEMPERATURE, + ATTR_MAX_TOKENS, + ATTR_TOTAL_RESPONSES, +) from .coordinator import HATextAICoordinator _LOGGER = logging.getLogger(__name__) @@ -46,42 +56,71 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): self._config_entry = config_entry self._attr_unique_id = f"{config_entry.entry_id}" self._attr_name = "Last Response" + self._attr_suggested_display_precision = 0 @property def state(self) -> StateType: """Return the state of the sensor.""" - if not self.coordinator.data: + 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 @property def extra_state_attributes(self) -> Optional[Dict[str, Any]]: """Return entity specific state attributes.""" if not self.coordinator.data: - return None + return { + ATTR_TOTAL_RESPONSES: 0, + ATTR_MODEL: self.coordinator.model, + ATTR_TEMPERATURE: self.coordinator.temperature, + ATTR_MAX_TOKENS: self.coordinator.max_tokens, + } try: - history = list(self.coordinator.data.items()) if not history: - return None + return { + ATTR_TOTAL_RESPONSES: 0, + ATTR_MODEL: self.coordinator.model, + ATTR_TEMPERATURE: self.coordinator.temperature, + ATTR_MAX_TOKENS: self.coordinator.max_tokens, + } 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) 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) return { ATTR_QUESTION: last_question, ATTR_RESPONSE: last_response, - ATTR_LAST_UPDATED: self.coordinator.last_update_success_time, + 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, } - except (IndexError, KeyError, AttributeError) as err: - _LOGGER.warning("Error getting attributes: %s", err) - return None @property def available(self) -> bool: @@ -92,3 +131,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): 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() diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index 7a35454..b1d256b 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -1,35 +1,49 @@ ask_question: name: Ask Question - description: Send a question to the AI model and receive a detailed response + description: >- + Send a question to the AI model and receive a detailed response. + The response will be stored in the conversation history and can be retrieved later. fields: question: name: Question - description: Your question or prompt for the AI assistant + 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. required: true - example: "What automations would you recommend for a smart kitchen?" + example: | + What automations would you recommend for a smart kitchen? + Consider energy efficiency and convenience. selector: text: multiline: true + type: text model: name: Model - description: Select an AI model to use (optional, overrides default setting) + description: >- + Select an AI model to use (optional, overrides default setting). + Different models have different capabilities and token limits. required: false example: "gpt-3.5-turbo" default: "gpt-3.5-turbo" selector: select: options: - - label: "GPT-3.5 Turbo" + - label: "GPT-3.5 Turbo (Fast & Efficient)" value: "gpt-3.5-turbo" - - label: "GPT-4" + - label: "GPT-4 (Most Capable)" value: "gpt-4" - - label: "GPT-4 32K" + - label: "GPT-4 32K (Extended Context)" value: "gpt-4-32k" + mode: dropdown temperature: name: Temperature - description: "Controls response creativity (0-2): Lower values for focused responses, higher for creative ones" + 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 required: false default: 0.7 selector: @@ -38,10 +52,16 @@ ask_question: max: 2.0 step: 0.1 mode: slider + unit_of_measurement: "" max_tokens: name: Max Tokens - description: Maximum length of the response + 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 required: false default: 1000 selector: @@ -53,16 +73,22 @@ ask_question: clear_history: name: Clear History - description: Delete all stored questions and responses + description: >- + Delete all stored questions and responses from the conversation history. + This action cannot be undone. fields: {} get_history: name: Get History - description: Retrieve recent conversation history + description: >- + Retrieve recent conversation history, including questions, responses, and timestamps. + Results are ordered from newest to oldest. fields: limit: name: Limit - description: Number of most recent conversations to return + description: >- + Number of most recent conversations to return. + Higher values return more history but may take longer to process. required: false default: 10 selector: @@ -74,13 +100,24 @@ get_history: set_system_prompt: name: Set System Prompt - description: Configure the AI's behavior by setting a 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. fields: prompt: name: System Prompt - description: Instructions that define how the AI should behave and respond + description: >- + Instructions that define how the AI should behave and respond. + Be specific about the desired expertise, tone, and format of responses. required: true - example: "You are a home automation expert assistant. Provide practical advice focused on smart home technology." + example: | + You are a home automation expert assistant. Focus on: + 1. Practical and efficient solutions + 2. Energy-saving recommendations + 3. Integration with popular smart home platforms + 4. Security and privacy considerations + Provide detailed but concise responses with clear steps when applicable. selector: text: multiline: true + type: text diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index 7245314..08661b9 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -3,14 +3,32 @@ "step": { "user": { "title": "Set up HA text AI", - "description": "Configure your OpenAI integration for smart home interactions", + "description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key to proceed.", "data": { - "api_key": "OpenAI API Key", - "model": "AI Model", - "temperature": "Temperature", - "max_tokens": "Max Tokens", - "api_endpoint": "API Endpoint", - "request_interval": "Request Interval" + "api_key": { + "name": "OpenAI API Key", + "description": "Your OpenAI API key from platform.openai.com" + }, + "model": { + "name": "AI Model", + "description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses." + }, + "temperature": { + "name": "Temperature", + "description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones." + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Maximum length of responses. Higher values allow longer responses but use more API tokens." + }, + "api_endpoint": { + "name": "API Endpoint", + "description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint." + }, + "request_interval": { + "name": "Request Interval", + "description": "Minimum time between API requests in seconds. Increase if hitting rate limits." + } } } }, @@ -18,22 +36,36 @@ "invalid_auth": "Invalid API key. Please check your OpenAI API key and try again.", "cannot_connect": "Failed to connect to API. Please check your internet connection and API endpoint.", "unknown": "Unexpected error occurred. Please check the logs for more details.", - "already_exists": "This API key is already configured in another integration." + "already_exists": "This API key is already configured in another integration.", + "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." }, "abort": { "already_configured": "This OpenAI integration is already configured", - "auth_failed": "Authentication failed. Please verify your API key." + "auth_failed": "Authentication failed. Please verify your API key.", + "invalid_endpoint": "Invalid API endpoint URL provided" } }, "options": { "step": { "init": { "title": "HA text AI Options", - "description": "Adjust your OpenAI integration settings", + "description": "Adjust your OpenAI integration settings. Changes will apply to future requests.", "data": { - "temperature": "Temperature", - "max_tokens": "Max Tokens", - "request_interval": "Request Interval" + "temperature": { + "name": "Temperature", + "description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones." + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Maximum length of responses. Higher values allow longer responses but use more API tokens." + }, + "request_interval": { + "name": "Request Interval", + "description": "Minimum time between API requests in seconds. Increase if hitting rate limits." + } } } } @@ -44,16 +76,67 @@ "name": "Last Response", "state_attributes": { "last_updated": { - "name": "Last Updated" + "name": "Last Updated", + "description": "Time of the last AI response" }, "question": { - "name": "Last Question" + "name": "Last Question", + "description": "Most recent question asked" }, "response": { - "name": "AI Response" + "name": "AI Response", + "description": "Latest response from the AI" + }, + "model": { + "name": "Current Model", + "description": "AI model currently in use" + }, + "temperature": { + "name": "Temperature Setting", + "description": "Current temperature parameter" + }, + "max_tokens": { + "name": "Max Tokens Setting", + "description": "Current maximum tokens limit" + }, + "total_responses": { + "name": "Total Responses", + "description": "Number of responses since last reset" + }, + "system_prompt": { + "name": "System Prompt", + "description": "Current system instructions for the AI" + }, + "response_time": { + "name": "Response Time", + "description": "Time taken to generate last response" } } } } + }, + "services": { + "ask_question": { + "name": "Ask Question", + "description": "Send a question to the AI model", + "fields": { + "question": { + "name": "Question", + "description": "Your question for the AI" + } + } + }, + "clear_history": { + "name": "Clear History", + "description": "Clear conversation history" + }, + "get_history": { + "name": "Get History", + "description": "Retrieve conversation history" + }, + "set_system_prompt": { + "name": "Set System Prompt", + "description": "Set AI behavior instructions" + } } } diff --git a/ha_text_ai.zip b/ha_text_ai.zip index 9f0e90a..d90a911 100644 Binary files a/ha_text_ai.zip and b/ha_text_ai.zip differ diff --git a/hacs.json b/hacs.json index 2255ad6..9f5b93b 100644 --- a/hacs.json +++ b/hacs.json @@ -4,6 +4,6 @@ "domains": ["sensor"], "homeassistant": "2024.11.0", "icon": "mdi:brain", - "version": "1.0.2", + "version": "1.0.3", "documentation": "https://github.com/smkrv/ha-text-ai" }