mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-21 22:54:00 +08:00
Release v2.0.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 и стиль ответов"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user