mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-21 22:54:00 +08:00
Release v1.0.4
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Удалить текущий системный промпт перед установкой нового."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user