mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 23:24:03 +08:00
Release v2.0.0
This commit is contained in:
@@ -40,23 +40,32 @@ from .const import (
|
||||
SERVICE_CLEAR_HISTORY,
|
||||
SERVICE_GET_HISTORY,
|
||||
SERVICE_SET_SYSTEM_PROMPT,
|
||||
SERVICE_SCHEMA_ASK_QUESTION,
|
||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT,
|
||||
SERVICE_SCHEMA_GET_HISTORY,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@callback
|
||||
def get_coordinator_by_id(hass: HomeAssistant, entity_id: str) -> Optional[HATextAICoordinator]:
|
||||
"""Get coordinator by entity ID."""
|
||||
if DOMAIN in hass.data:
|
||||
for entry_id, coordinator in hass.data[DOMAIN].items():
|
||||
if coordinator.entity_id == entity_id:
|
||||
return coordinator
|
||||
return None
|
||||
# Обновленные схемы сервисов
|
||||
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||
vol.Required("instance"): cv.string,
|
||||
vol.Required("question"): cv.string,
|
||||
vol.Optional("system_prompt"): cv.string,
|
||||
vol.Optional("model"): cv.string,
|
||||
vol.Optional("temperature"): cv.positive_float,
|
||||
vol.Optional("max_tokens"): cv.positive_int,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
vol.Required("instance"): cv.string,
|
||||
vol.Required("prompt"): cv.string,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||
vol.Required("instance"): cv.string,
|
||||
vol.Optional("limit"): cv.positive_int,
|
||||
vol.Optional("filter_model"): cv.string,
|
||||
})
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
"""Set up the HA Text AI component."""
|
||||
@@ -75,128 +84,93 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
|
||||
async def async_ask_question(call: ServiceCall) -> None:
|
||||
"""Handle ask_question service."""
|
||||
entity_id = call.target.get("entity_id")
|
||||
if not entity_id:
|
||||
raise HomeAssistantError("No target entity specified")
|
||||
|
||||
coordinator = get_coordinator_by_id(hass, entity_id)
|
||||
if not coordinator:
|
||||
raise HomeAssistantError(f"No coordinator found for entity {entity_id}")
|
||||
|
||||
question = call.data.get("question")
|
||||
if not question:
|
||||
raise HomeAssistantError("No question provided")
|
||||
instance = call.data["instance"]
|
||||
if instance not in hass.data[DOMAIN]:
|
||||
raise HomeAssistantError(f"Instance {instance} not found")
|
||||
|
||||
coordinator = hass.data[DOMAIN][instance]["coordinator"]
|
||||
try:
|
||||
params = {
|
||||
"system_prompt": call.data.get("system_prompt"),
|
||||
"model": call.data.get("model"),
|
||||
"temperature": call.data.get("temperature"),
|
||||
"max_tokens": call.data.get("max_tokens"),
|
||||
"priority": call.data.get("priority", False)
|
||||
}
|
||||
await coordinator.async_ask_question(question, **params)
|
||||
await coordinator.async_ask_question(
|
||||
question=call.data["question"],
|
||||
model=call.data.get("model"),
|
||||
temperature=call.data.get("temperature"),
|
||||
max_tokens=call.data.get("max_tokens"),
|
||||
system_prompt=call.data.get("system_prompt"),
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error asking question: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to process question: {str(err)}")
|
||||
|
||||
async def async_clear_history(call: ServiceCall) -> None:
|
||||
"""Handle clear_history service."""
|
||||
entity_id = call.target.get("entity_id")
|
||||
if not entity_id:
|
||||
raise HomeAssistantError("No target entity specified")
|
||||
|
||||
coordinator = get_coordinator_by_id(hass, entity_id)
|
||||
if not coordinator:
|
||||
raise HomeAssistantError(f"No coordinator found for entity {entity_id}")
|
||||
instance = call.data["instance"]
|
||||
if instance not in hass.data[DOMAIN]:
|
||||
raise HomeAssistantError(f"Instance {instance} not found")
|
||||
|
||||
coordinator = hass.data[DOMAIN][instance]["coordinator"]
|
||||
try:
|
||||
await coordinator.clear_history()
|
||||
await coordinator.async_clear_history()
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error clearing history: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to clear history: {str(err)}")
|
||||
|
||||
async def async_get_history(call: ServiceCall) -> None:
|
||||
"""Handle get_history service."""
|
||||
entity_id = call.target.get("entity_id")
|
||||
if not entity_id:
|
||||
raise HomeAssistantError("No target entity specified")
|
||||
|
||||
coordinator = get_coordinator_by_id(hass, entity_id)
|
||||
if not coordinator:
|
||||
raise HomeAssistantError(f"No coordinator found for entity {entity_id}")
|
||||
instance = call.data["instance"]
|
||||
if instance not in hass.data[DOMAIN]:
|
||||
raise HomeAssistantError(f"Instance {instance} not found")
|
||||
|
||||
coordinator = hass.data[DOMAIN][instance]["coordinator"]
|
||||
try:
|
||||
limit = call.data.get("limit", 10)
|
||||
start_date = call.data.get("start_date")
|
||||
include_metadata = call.data.get("include_metadata", False)
|
||||
sort_order = call.data.get("sort_order", "desc")
|
||||
|
||||
history = await coordinator.get_history(
|
||||
limit=limit,
|
||||
start_date=start_date,
|
||||
include_metadata=include_metadata,
|
||||
sort_order=sort_order
|
||||
return await coordinator.async_get_history(
|
||||
limit=call.data.get("limit"),
|
||||
filter_model=call.data.get("filter_model"),
|
||||
instance=instance
|
||||
)
|
||||
return history
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error getting history: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to get history: {str(err)}")
|
||||
|
||||
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||
"""Handle set_system_prompt service."""
|
||||
entity_id = call.target.get("entity_id")
|
||||
if not entity_id:
|
||||
raise HomeAssistantError("No target entity specified")
|
||||
|
||||
coordinator = get_coordinator_by_id(hass, entity_id)
|
||||
if not coordinator:
|
||||
raise HomeAssistantError(f"No coordinator found for entity {entity_id}")
|
||||
|
||||
prompt = call.data.get("prompt")
|
||||
if not prompt:
|
||||
raise HomeAssistantError("No prompt provided")
|
||||
instance = call.data["instance"]
|
||||
if instance not in hass.data[DOMAIN]:
|
||||
raise HomeAssistantError(f"Instance {instance} not found")
|
||||
|
||||
coordinator = hass.data[DOMAIN][instance]["coordinator"]
|
||||
try:
|
||||
coordinator.update_system_prompt(prompt)
|
||||
await coordinator.async_set_system_prompt(call.data["prompt"])
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}")
|
||||
|
||||
# Base schema with target
|
||||
base_schema = vol.Schema({
|
||||
vol.Required("target"): {
|
||||
vol.Required("entity_id"): cv.entity_id
|
||||
}
|
||||
})
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ASK_QUESTION,
|
||||
async_ask_question,
|
||||
schema=base_schema.extend(SERVICE_SCHEMA_ASK_QUESTION.schema)
|
||||
schema=SERVICE_SCHEMA_ASK_QUESTION
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_HISTORY,
|
||||
async_clear_history,
|
||||
schema=base_schema
|
||||
schema=vol.Schema({vol.Required("instance"): cv.string})
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_HISTORY,
|
||||
async_get_history,
|
||||
schema=base_schema.extend(SERVICE_SCHEMA_GET_HISTORY.schema)
|
||||
schema=SERVICE_SCHEMA_GET_HISTORY
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_SYSTEM_PROMPT,
|
||||
async_set_system_prompt,
|
||||
schema=base_schema.extend(SERVICE_SCHEMA_SET_SYSTEM_PROMPT.schema)
|
||||
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -239,6 +213,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
DEFAULT_OPENAI_ENDPOINT if api_provider == API_PROVIDER_OPENAI
|
||||
else DEFAULT_ANTHROPIC_ENDPOINT).rstrip('/')
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
|
||||
|
||||
is_anthropic = api_provider == API_PROVIDER_ANTHROPIC
|
||||
headers = {
|
||||
@@ -256,23 +231,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady("API connection failed")
|
||||
|
||||
coordinator = HATextAICoordinator(
|
||||
hass,
|
||||
api_key=api_key,
|
||||
endpoint=endpoint,
|
||||
hass=hass,
|
||||
client=None, # Будет установлен позже
|
||||
model=model,
|
||||
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
||||
update_interval=int(entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)),
|
||||
instance_name=instance_name,
|
||||
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
|
||||
request_interval=float(entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)),
|
||||
name=entry.title or entry.data.get(CONF_NAME, "HA Text AI"),
|
||||
session=session,
|
||||
is_anthropic=is_anthropic
|
||||
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
||||
is_anthropic=is_anthropic,
|
||||
)
|
||||
|
||||
await coordinator.async_initialize()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
# Сохраняем данные интеграции
|
||||
hass.data[DOMAIN][instance_name] = {
|
||||
"coordinator": coordinator,
|
||||
"config_entry": entry,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
@@ -284,15 +257,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
try:
|
||||
coordinator = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if coordinator:
|
||||
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
|
||||
if instance_name in hass.data[DOMAIN]:
|
||||
coordinator = hass.data[DOMAIN][instance_name]["coordinator"]
|
||||
await coordinator.async_shutdown()
|
||||
hass.data[DOMAIN].pop(instance_name)
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
except Exception as ex:
|
||||
_LOGGER.exception("Error unloading entry: %s", str(ex))
|
||||
|
||||
@@ -4,10 +4,11 @@ from typing import Any, Dict, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -42,145 +43,144 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize flow."""
|
||||
super().__init__()
|
||||
self.provider = None
|
||||
|
||||
def _show_provider_selection(self) -> FlowResult:
|
||||
"""Show the provider selection form."""
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("provider"): vol.In(API_PROVIDERS),
|
||||
}),
|
||||
)
|
||||
|
||||
def _show_provider_config(self, provider: str) -> FlowResult:
|
||||
"""Show the configuration form for the selected provider."""
|
||||
default_endpoint = (
|
||||
DEFAULT_OPENAI_ENDPOINT if provider == API_PROVIDER_OPENAI
|
||||
else DEFAULT_ANTHROPIC_ENDPOINT
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("name", default=f"HA Text AI {len(self._async_current_entries()) + 1}"): str,
|
||||
vol.Required(CONF_API_PROVIDER): vol.In([provider]),
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_MODEL, default=DEFAULT_MODEL): str,
|
||||
vol.Optional(CONF_API_ENDPOINT, default=default_endpoint): str,
|
||||
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_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||
),
|
||||
})
|
||||
)
|
||||
self._errors = {}
|
||||
self._data = {}
|
||||
self._provider = None
|
||||
|
||||
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self._show_provider_selection()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_API_PROVIDER): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=API_PROVIDERS,
|
||||
translation_key="api_provider"
|
||||
)
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
if "provider" in user_input:
|
||||
self.provider = user_input["provider"]
|
||||
return self._show_provider_config(self.provider)
|
||||
self._provider = user_input[CONF_API_PROVIDER]
|
||||
return await self.async_step_provider()
|
||||
|
||||
return await self._process_configuration(user_input)
|
||||
async def async_step_provider(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult:
|
||||
"""Handle provider configuration step."""
|
||||
if user_input is None:
|
||||
default_endpoint = (
|
||||
DEFAULT_OPENAI_ENDPOINT if self._provider == API_PROVIDER_OPENAI
|
||||
else DEFAULT_ANTHROPIC_ENDPOINT
|
||||
)
|
||||
|
||||
async def _process_configuration(self, user_input):
|
||||
"""Validate and process user configuration."""
|
||||
errors = {}
|
||||
suggested_name = f"HA Text AI {len(self._async_current_entries()) + 1}"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="provider",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_NAME, default=suggested_name): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_MODEL, default=DEFAULT_MODEL): str,
|
||||
vol.Required(CONF_API_ENDPOINT, default=default_endpoint): str,
|
||||
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_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||
),
|
||||
}),
|
||||
errors=self._errors
|
||||
)
|
||||
|
||||
# Проверка уникальности имени
|
||||
instance_name = user_input[CONF_NAME]
|
||||
await self._async_validate_name(instance_name)
|
||||
if self._errors:
|
||||
return await self.async_step_provider()
|
||||
|
||||
# Проверка API подключения
|
||||
if not await self._async_validate_api(user_input):
|
||||
return await self.async_step_provider()
|
||||
|
||||
# Создание записи конфигурации
|
||||
return await self._create_entry(user_input)
|
||||
|
||||
async def _async_validate_name(self, name: str) -> bool:
|
||||
"""Validate that the name is unique."""
|
||||
for entry in self._async_current_entries():
|
||||
if entry.data.get(CONF_NAME) == name:
|
||||
self._errors["name"] = "name_exists"
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool:
|
||||
"""Validate API connection."""
|
||||
try:
|
||||
# Создаем уникальный идентификатор, включающий имя экземпляра
|
||||
instance_name = user_input.get("name", f"HA Text AI {len(self._async_current_entries()) + 1}")
|
||||
unique_id = f"{DOMAIN}_{instance_name}_{user_input[CONF_API_PROVIDER]}"
|
||||
|
||||
# Нормализуем уникальный идентификатор
|
||||
unique_id = unique_id.lower().replace(" ", "_")
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Проверяем подключение к API
|
||||
session = async_get_clientsession(self.hass)
|
||||
headers = self._get_api_headers(user_input)
|
||||
endpoint = user_input[CONF_API_ENDPOINT].rstrip('/')
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
f"{user_input[CONF_API_ENDPOINT]}/models",
|
||||
headers=headers
|
||||
) as response:
|
||||
if response.status not in [200, 404]:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Connection test failed: {e}")
|
||||
errors["base"] = "cannot_connect"
|
||||
check_url = (
|
||||
f"{endpoint}/v1/models" if self._provider == API_PROVIDER_ANTHROPIC
|
||||
else f"{endpoint}/models"
|
||||
)
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=instance_name,
|
||||
data={
|
||||
"name": instance_name,
|
||||
**user_input,
|
||||
"unique_id": unique_id # Сохраняем уникальный идентификатор в данных
|
||||
}
|
||||
)
|
||||
async with session.get(check_url, headers=headers) as response:
|
||||
if response.status == 401:
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
elif response.status not in [200, 404]:
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Unexpected error: {e}")
|
||||
errors["base"] = "unknown"
|
||||
except Exception as err:
|
||||
_LOGGER.error("API validation error: %s", str(err))
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
return self._show_configuration_form(user_input, errors)
|
||||
|
||||
def _get_api_headers(self, user_input):
|
||||
def _get_api_headers(self, user_input: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Get API headers based on provider."""
|
||||
if user_input[CONF_API_PROVIDER] == API_PROVIDER_ANTHROPIC:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
|
||||
if self._provider == API_PROVIDER_ANTHROPIC:
|
||||
return {
|
||||
"x-api-key": user_input[CONF_API_KEY],
|
||||
"anthropic-version": "2023-06-01"
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return {
|
||||
"Authorization": f"Bearer {user_input[CONF_API_KEY]}",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def _show_configuration_form(self, user_input, errors=None):
|
||||
"""Show the configuration form to edit location data."""
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("name", default=user_input.get("name", f"HA Text AI {len(self._async_current_entries()) + 1}")): str,
|
||||
vol.Required(CONF_API_PROVIDER): vol.In([user_input[CONF_API_PROVIDER]]),
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_MODEL, default=user_input.get(CONF_MODEL, DEFAULT_MODEL)): str,
|
||||
vol.Optional(CONF_API_ENDPOINT, default=user_input.get(CONF_API_ENDPOINT,
|
||||
DEFAULT_OPENAI_ENDPOINT if user_input[CONF_API_PROVIDER] == API_PROVIDER_OPENAI
|
||||
else DEFAULT_ANTHROPIC_ENDPOINT)): str,
|
||||
vol.Optional(CONF_TEMPERATURE, default=user_input.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||
),
|
||||
vol.Optional(CONF_MAX_TOKENS, default=user_input.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||
),
|
||||
vol.Optional(CONF_REQUEST_INTERVAL, default=user_input.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||
),
|
||||
}),
|
||||
errors=errors or {}
|
||||
async def _create_entry(self, user_input: Dict[str, Any]) -> FlowResult:
|
||||
"""Create the config entry."""
|
||||
instance_name = user_input[CONF_NAME]
|
||||
unique_id = f"{DOMAIN}_{instance_name}_{self._provider}".lower().replace(" ", "_")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=instance_name,
|
||||
data={
|
||||
CONF_API_PROVIDER: self._provider,
|
||||
CONF_NAME: instance_name,
|
||||
**user_input,
|
||||
"unique_id": unique_id
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow."""
|
||||
@@ -194,11 +194,15 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
current_data = self.config_entry.data
|
||||
current_data = {**self.config_entry.data, **self.config_entry.options}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({
|
||||
vol.Optional(
|
||||
CONF_MODEL,
|
||||
default=current_data.get(CONF_MODEL, DEFAULT_MODEL)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_TEMPERATURE,
|
||||
default=current_data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""Constants for the HA text AI integration."""
|
||||
from typing import Final
|
||||
import voluptuous as vol
|
||||
from homeassistant.const import Platform, CONF_API_KEY # Добавлен CONF_API_KEY
|
||||
from homeassistant.const import Platform, CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
# Domain and platforms
|
||||
DOMAIN: Final = "ha_text_ai"
|
||||
PLATFORMS: Final = [Platform.SENSOR]
|
||||
CONF_NAME = "name"
|
||||
DEFAULT_NAME = "HA Text AI"
|
||||
|
||||
# Provider configuration
|
||||
CONF_API_PROVIDER: Final = "api_provider"
|
||||
@@ -22,7 +20,7 @@ API_PROVIDERS: Final = [
|
||||
|
||||
# Default endpoints
|
||||
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
|
||||
DEFAULT_ANTHROPIC_ENDPOINT: Final = "https://api.anthropic.com/v1"
|
||||
DEFAULT_ANTHROPIC_ENDPOINT: Final = "https://api.anthropic.com"
|
||||
|
||||
# Configuration constants
|
||||
CONF_MODEL: Final = "model"
|
||||
@@ -30,12 +28,12 @@ CONF_TEMPERATURE: Final = "temperature"
|
||||
CONF_MAX_TOKENS: Final = "max_tokens"
|
||||
CONF_API_ENDPOINT: Final = "api_endpoint"
|
||||
CONF_REQUEST_INTERVAL: Final = "request_interval"
|
||||
CONF_INSTANCE: Final = "instance"
|
||||
|
||||
# Default values
|
||||
DEFAULT_MODEL: Final = "gpt-4o-mini"
|
||||
DEFAULT_TEMPERATURE: Final = 0.1
|
||||
DEFAULT_MODEL: Final = "gpt-3.5-turbo" # Обновлено на актуальную модель
|
||||
DEFAULT_TEMPERATURE: Final = 0.7
|
||||
DEFAULT_MAX_TOKENS: Final = 1000
|
||||
DEFAULT_API_ENDPOINT: Final = DEFAULT_OPENAI_ENDPOINT
|
||||
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
||||
DEFAULT_TIMEOUT: Final = 30
|
||||
|
||||
@@ -48,53 +46,23 @@ MIN_REQUEST_INTERVAL: Final = 0.1
|
||||
MAX_REQUEST_INTERVAL: Final = 60.0
|
||||
|
||||
# API constants
|
||||
API_CHAT_PATH: Final = "chat/completions"
|
||||
API_TIMEOUT: Final = 30
|
||||
API_RETRY_COUNT: 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"
|
||||
HISTORY_SORT_ASC: Final = "asc"
|
||||
HISTORY_SORT_DESC: Final = "desc"
|
||||
HISTORY_MAX_ENTRIES: Final = 100
|
||||
|
||||
# Service names
|
||||
SERVICE_ASK_QUESTION: Final = "ask_question"
|
||||
SERVICE_CLEAR_HISTORY: Final = "clear_history"
|
||||
SERVICE_GET_HISTORY: Final = "get_history"
|
||||
SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt"
|
||||
|
||||
# Service descriptions
|
||||
SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Send a question to the AI model and receive a detailed response. The response will be stored in the conversation history."
|
||||
SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Delete all stored questions and responses from the conversation history."
|
||||
SERVICE_GET_HISTORY_DESCRIPTION: Final = "Retrieve recent conversation history with optional filtering and sorting."
|
||||
SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set default system behavior instructions for all future conversations."
|
||||
|
||||
# Attribute keys
|
||||
ATTR_QUESTION: Final = "question"
|
||||
ATTR_RESPONSE: Final = "response"
|
||||
ATTR_LAST_UPDATED: Final = "last_updated"
|
||||
ATTR_INSTANCE: Final = "instance"
|
||||
ATTR_MODEL: Final = "model"
|
||||
ATTR_TEMPERATURE: Final = "temperature"
|
||||
ATTR_MAX_TOKENS: Final = "max_tokens"
|
||||
ATTR_TOTAL_RESPONSES: Final = "total_responses"
|
||||
ATTR_SYSTEM_PROMPT: Final = "system_prompt"
|
||||
ATTR_RESPONSE_TIME: Final = "response_time"
|
||||
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"
|
||||
ATTR_SORT_ORDER: Final = "sort_order"
|
||||
|
||||
# Error messages
|
||||
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
||||
@@ -105,80 +73,12 @@ 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"
|
||||
ERROR_INVALID_PARAMETERS: Final = "invalid_parameters"
|
||||
ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable"
|
||||
ERROR_INVALID_SORT_ORDER: Final = "invalid_sort_order"
|
||||
|
||||
# Configuration descriptions
|
||||
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
|
||||
CONF_TEMPERATURE_DESCRIPTION: Final = "Temperature for response generation (0-2)"
|
||||
CONF_MAX_TOKENS_DESCRIPTION: Final = "Maximum tokens in response (1-4096)"
|
||||
CONF_API_ENDPOINT_DESCRIPTION: Final = "API endpoint URL"
|
||||
CONF_REQUEST_INTERVAL_DESCRIPTION: Final = "Minimum time between API requests (seconds)"
|
||||
|
||||
# Entity attributes descriptions
|
||||
ATTR_QUESTION_DESCRIPTION: Final = "Last question asked"
|
||||
ATTR_RESPONSE_DESCRIPTION: Final = "Last response received"
|
||||
ATTR_LAST_UPDATED_DESCRIPTION: Final = "Time of last update"
|
||||
ATTR_MODEL_DESCRIPTION: Final = "Current AI model in use"
|
||||
ATTR_TEMPERATURE_DESCRIPTION: Final = "Current temperature setting"
|
||||
ATTR_MAX_TOKENS_DESCRIPTION: Final = "Current max tokens setting"
|
||||
ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses"
|
||||
ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt"
|
||||
ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response"
|
||||
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"
|
||||
ATTR_SORT_ORDER_DESCRIPTION: Final = "Sort order for history (asc/desc)"
|
||||
|
||||
# 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"
|
||||
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"
|
||||
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"
|
||||
LOG_LEVEL_DEFAULT: Final = "INFO"
|
||||
|
||||
# Queue constants
|
||||
QUEUE_TIMEOUT: Final = 5
|
||||
QUEUE_MAX_SIZE: Final = 100
|
||||
|
||||
# Retry constants
|
||||
MAX_RETRIES: Final = 3
|
||||
RETRY_DELAY: Final = 1.0
|
||||
ERROR_INVALID_INSTANCE: Final = "invalid_instance"
|
||||
ERROR_NAME_EXISTS: Final = "name_exists"
|
||||
|
||||
# Service schema constants
|
||||
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||
vol.Required(CONF_INSTANCE): cv.string,
|
||||
vol.Required("question"): cv.string,
|
||||
vol.Optional("system_prompt"): cv.string,
|
||||
vol.Optional("model"): cv.string,
|
||||
@@ -193,26 +93,25 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
vol.Required(CONF_INSTANCE): cv.string,
|
||||
vol.Required("prompt"): cv.string
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||
vol.Required(CONF_INSTANCE): cv.string,
|
||||
vol.Optional("limit", default=10): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=1, max=HISTORY_MAX_ENTRIES)
|
||||
vol.Range(min=1, max=100)
|
||||
),
|
||||
vol.Optional("start_date"): cv.datetime,
|
||||
vol.Optional("include_metadata"): cv.boolean,
|
||||
vol.Optional("sort_order", default=HISTORY_SORT_DESC): vol.In([
|
||||
HISTORY_SORT_ASC,
|
||||
HISTORY_SORT_DESC
|
||||
])
|
||||
vol.Optional("filter_model"): cv.string
|
||||
})
|
||||
|
||||
# Configuration schema
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Required(CONF_API_PROVIDER): vol.In(API_PROVIDERS),
|
||||
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
|
||||
vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All(
|
||||
vol.Coerce(float),
|
||||
@@ -226,11 +125,20 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_REQUEST_INTERVAL, max=MAX_REQUEST_INTERVAL)
|
||||
),
|
||||
vol.Required(CONF_API_PROVIDER): vol.In(API_PROVIDERS)
|
||||
)
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
# Entity attributes
|
||||
ENTITY_ICON: Final = "mdi:robot"
|
||||
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
|
||||
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
|
||||
|
||||
# State attributes
|
||||
STATE_READY: Final = "ready"
|
||||
STATE_PROCESSING: Final = "processing"
|
||||
STATE_ERROR: Final = "error"
|
||||
|
||||
# Event names
|
||||
EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received"
|
||||
EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred"
|
||||
|
||||
@@ -1,561 +1,117 @@
|
||||
"""Data coordinator for HA text AI."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from anthropic import AsyncAnthropic
|
||||
from openai import APIError, AsyncOpenAI, AuthenticationError, RateLimitError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DEFAULT_TIMEOUT,
|
||||
MAX_RETRIES,
|
||||
QUEUE_MAX_SIZE,
|
||||
RETRY_DELAY,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
|
||||
"""Validate initialization parameters.
|
||||
|
||||
Args:
|
||||
api_key: API key for authentication
|
||||
temperature: Sampling temperature for text generation
|
||||
max_tokens: Maximum number of tokens to generate
|
||||
|
||||
Raises:
|
||||
ValueError: If any parameters are invalid
|
||||
"""
|
||||
if not api_key:
|
||||
raise ValueError("API key cannot be empty")
|
||||
|
||||
try:
|
||||
temp = float(temperature)
|
||||
if not 0 <= temp <= 2:
|
||||
raise ValueError("Temperature must be between 0 and 2")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("Temperature must be a number between 0 and 2")
|
||||
|
||||
try:
|
||||
tokens = int(max_tokens)
|
||||
if tokens < 1:
|
||||
raise ValueError("Max tokens must be a positive number")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("Max tokens must be a positive integer")
|
||||
"""Class to manage fetching data from API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
api_key: str,
|
||||
endpoint: str,
|
||||
client: Any,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
request_interval: float,
|
||||
name: str,
|
||||
session: Optional[Any] = None,
|
||||
update_interval: int,
|
||||
instance_name: str, # Добавляем идентификатор интеграции
|
||||
max_tokens: int = 1000,
|
||||
temperature: float = 0.7,
|
||||
max_history_size: int = 50,
|
||||
is_anthropic: bool = False,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
self._name = name
|
||||
self._validate_params(api_key, temperature, max_tokens)
|
||||
self._entity_id = None
|
||||
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.PriorityQueue(maxsize=QUEUE_MAX_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" # Keep single definition
|
||||
self._endpoint_status = "disconnected"
|
||||
self._last_error = None
|
||||
self._last_request_time = 0
|
||||
self._is_anthropic = is_anthropic
|
||||
self._session = session or aiohttp_client.async_get_clientsession(hass)
|
||||
self.client = None
|
||||
self.hass = hass # Store hass reference for _init_client
|
||||
self._start_time = time.time()
|
||||
self.last_response = None
|
||||
|
||||
# History and metrics
|
||||
self._history: List[Dict[str, Any]] = []
|
||||
self._max_history_size = 100
|
||||
self._performance_metrics: Dict[str, Any] = {
|
||||
"avg_response_time": 0,
|
||||
"total_errors": 0,
|
||||
"success_rate": 100,
|
||||
"requests_per_minute": 0,
|
||||
"avg_tokens_per_request": 0,
|
||||
}
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=self._name,
|
||||
update_interval=timedelta(seconds=float(request_interval)),
|
||||
name=f"HA Text AI {instance_name}", # Уникальное имя для каждой интеграции
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _init_client(self):
|
||||
"""Initialize API client with proper SSL context."""
|
||||
try:
|
||||
if self._is_anthropic:
|
||||
self.client = AsyncAnthropic(
|
||||
api_key=self.api_key
|
||||
)
|
||||
else: # OpenAI
|
||||
# Create transport in separate thread
|
||||
transport = await self.hass.async_add_executor_job(
|
||||
lambda: httpx.AsyncHTTPTransport(retries=3)
|
||||
)
|
||||
self.instance_name = instance_name # Сохраняем идентификатор
|
||||
self.client = client
|
||||
self.model = model
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self._max_history_size = max_history_size
|
||||
self._is_anthropic = is_anthropic
|
||||
self._system_prompt = None
|
||||
|
||||
limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
||||
# Статусы
|
||||
self._is_processing = False
|
||||
self._is_rate_limited = False
|
||||
self._is_maintenance = False
|
||||
|
||||
async_client = httpx.AsyncClient(
|
||||
base_url=self.endpoint,
|
||||
timeout=httpx.Timeout(30.0),
|
||||
transport=transport,
|
||||
limits=limits,
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.endpoint,
|
||||
http_client=async_client,
|
||||
max_retries=3
|
||||
)
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
_LOGGER.error("Error initializing API client: %s", str(e))
|
||||
raise
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize coordinator."""
|
||||
try:
|
||||
await self._init_client()
|
||||
self._is_ready = True
|
||||
self._endpoint_status = "connected"
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
self._endpoint_status = "error"
|
||||
_LOGGER.error("Failed to initialize coordinator: %s", str(e))
|
||||
raise
|
||||
|
||||
async def async_ask(
|
||||
self,
|
||||
question: str,
|
||||
priority: bool = False,
|
||||
system_prompt: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Add question to queue with priority."""
|
||||
if not self._is_ready:
|
||||
raise HomeAssistantError("Coordinator is not ready")
|
||||
|
||||
if not question or not isinstance(question, str):
|
||||
raise ValueError("Invalid question format")
|
||||
|
||||
# Validate optional parameters
|
||||
if temperature is not None and not 0 <= temperature <= 2:
|
||||
raise ValueError("Temperature must be between 0 and 2")
|
||||
if max_tokens is not None and not isinstance(max_tokens, int):
|
||||
raise ValueError("max_tokens must be an integer")
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
request_params = {
|
||||
"id": request_id,
|
||||
"question": question,
|
||||
"system_prompt": system_prompt or self.system_prompt,
|
||||
"model": model or self.model,
|
||||
"temperature": temperature or self.temperature,
|
||||
"max_tokens": max_tokens or self.max_tokens,
|
||||
"timestamp": dt_util.utcnow().isoformat(),
|
||||
# История и метрики
|
||||
self._history: List[Dict[str, Any]] = []
|
||||
self._request_count = 0
|
||||
self._error_count = 0
|
||||
self._last_error = None
|
||||
self._performance_metrics = {
|
||||
"total_requests": 0,
|
||||
"total_errors": 0,
|
||||
"avg_response_time": 0.0,
|
||||
"last_request_time": None,
|
||||
}
|
||||
|
||||
priority_level = 0 if priority else 1
|
||||
await self._question_queue.put((priority_level, request_id, request_params))
|
||||
|
||||
# Wait for response
|
||||
while request_id not in self._responses:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
response = self._responses.pop(request_id)
|
||||
|
||||
# Add to history
|
||||
history_entry = {
|
||||
"timestamp": request_params["timestamp"],
|
||||
"question": question,
|
||||
"response": response,
|
||||
"model": request_params["model"],
|
||||
"metadata": {
|
||||
"temperature": request_params["temperature"],
|
||||
"max_tokens": request_params["max_tokens"],
|
||||
"system_prompt": request_params["system_prompt"],
|
||||
"priority": priority,
|
||||
}
|
||||
}
|
||||
self._add_to_history(history_entry)
|
||||
|
||||
return response
|
||||
|
||||
async def _process_queue(self) -> None:
|
||||
"""Process questions from queue."""
|
||||
while True:
|
||||
try:
|
||||
if self._is_rate_limited:
|
||||
await asyncio.sleep(60)
|
||||
self._is_rate_limited = False
|
||||
continue
|
||||
|
||||
priority, request_id, params = await self._question_queue.get()
|
||||
|
||||
try:
|
||||
response = await self._make_api_request(params)
|
||||
self._responses[request_id] = response
|
||||
self._update_metrics(response)
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
_LOGGER.error("Error processing request: %s", str(e))
|
||||
self._responses[request_id] = f"Error: {str(e)}"
|
||||
finally:
|
||||
self._question_queue.task_done()
|
||||
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
_LOGGER.error("Queue processing error: %s", str(e))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _make_api_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Make API request with retry logic."""
|
||||
attempts = 0
|
||||
while attempts < MAX_RETRIES:
|
||||
try:
|
||||
if self._is_anthropic:
|
||||
response = await self._make_anthropic_call(**params)
|
||||
else:
|
||||
response = await self._make_openai_call(**params)
|
||||
return response
|
||||
except (AuthenticationError, ValueError) as e:
|
||||
# Don't retry auth errors
|
||||
raise
|
||||
except RateLimitError:
|
||||
self._is_rate_limited = True
|
||||
await asyncio.sleep(RETRY_DELAY * (2 ** attempts))
|
||||
attempts += 1
|
||||
except Exception as e:
|
||||
attempts += 1
|
||||
if attempts >= MAX_RETRIES:
|
||||
raise
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
|
||||
async def _make_anthropic_call(self, **params) -> Dict[str, Any]:
|
||||
"""Make API call to Anthropic."""
|
||||
start_time = time.time()
|
||||
messages = []
|
||||
if params.get("system_prompt"):
|
||||
messages.append({"role": "system", "content": params["system_prompt"]})
|
||||
messages.append({"role": "user", "content": params["question"]})
|
||||
|
||||
try:
|
||||
completion = await self.client.messages.create(
|
||||
model=params.get("model", self.model),
|
||||
messages=messages,
|
||||
temperature=params.get("temperature", self.temperature),
|
||||
max_tokens=params.get("max_tokens", self.max_tokens),
|
||||
)
|
||||
|
||||
response_time = time.time() - start_time
|
||||
|
||||
return {
|
||||
"response": completion.content[0].text,
|
||||
"model": completion.model,
|
||||
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0,
|
||||
"response_time": response_time
|
||||
}
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
_LOGGER.error("Anthropic API call error: %s", str(e))
|
||||
raise
|
||||
|
||||
async def _make_openai_call(self, **params) -> Dict[str, Any]:
|
||||
"""Make API call to OpenAI."""
|
||||
start_time = time.time()
|
||||
try:
|
||||
if not params.get("question"):
|
||||
raise ValueError("Question cannot be empty")
|
||||
|
||||
messages = []
|
||||
if params.get("system_prompt"):
|
||||
messages.append({"role": "system", "content": params["system_prompt"]})
|
||||
messages.append({"role": "user", "content": params["question"]})
|
||||
|
||||
api_params = {
|
||||
"model": params.get("model", self.model),
|
||||
"messages": messages,
|
||||
"temperature": params.get("temperature", self.temperature),
|
||||
"max_tokens": params.get("max_tokens", self.max_tokens),
|
||||
}
|
||||
|
||||
completion = await self.client.chat.completions.create(**api_params)
|
||||
response_time = time.time() - start_time
|
||||
|
||||
if not completion.choices:
|
||||
raise ValueError("No response choices available")
|
||||
|
||||
return {
|
||||
"response": completion.choices[0].message.content.strip(),
|
||||
"model": completion.model,
|
||||
"tokens": completion.usage.total_tokens,
|
||||
"response_time": response_time
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
error_msg = f"OpenAI API call failed: {str(e)}"
|
||||
_LOGGER.error(error_msg, exc_info=True)
|
||||
raise RuntimeError(error_msg) from e
|
||||
|
||||
def _add_to_history(self, entry: Dict[str, Any]) -> None:
|
||||
"""Add entry to history with size limit."""
|
||||
self._history.append(entry)
|
||||
if len(self._history) > self._max_history_size:
|
||||
self._history.pop(0)
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
limit: Optional[int] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
filter_model: Optional[str] = None, # Добавляем параметр
|
||||
include_metadata: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get conversation history."""
|
||||
history = self._history
|
||||
|
||||
if start_date:
|
||||
history = [
|
||||
h for h in history
|
||||
if datetime.fromisoformat(h["timestamp"]) >= start_date
|
||||
]
|
||||
|
||||
# Добавляем фильтрацию по модели
|
||||
if filter_model:
|
||||
history = [h for h in history if h["model"] == filter_model]
|
||||
|
||||
if not include_metadata:
|
||||
history = [{
|
||||
"timestamp": h["timestamp"],
|
||||
"question": h["question"],
|
||||
"response": h["response"],
|
||||
"model": h["model"]
|
||||
} for h in history]
|
||||
|
||||
if limit and limit > 0:
|
||||
history = history[-limit:]
|
||||
|
||||
return history
|
||||
|
||||
def _update_metrics(self, response_data: Dict[str, Any]) -> None:
|
||||
"""Update performance metrics."""
|
||||
# Update response time 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)
|
||||
)
|
||||
|
||||
# Update success rate
|
||||
total_requests = self._request_count + 1
|
||||
self._performance_metrics["success_rate"] = (
|
||||
(total_requests - self._performance_metrics["total_errors"]) /
|
||||
total_requests * 100
|
||||
)
|
||||
|
||||
# Update tokens metrics
|
||||
tokens = response_data.get("tokens", 0)
|
||||
self._tokens_used += tokens
|
||||
self._performance_metrics["avg_tokens_per_request"] = (
|
||||
self._tokens_used / total_requests
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
self._last_request_time = time.time()
|
||||
self._request_count += 1
|
||||
|
||||
async def async_refresh_metrics(self) -> None:
|
||||
"""Refresh performance metrics."""
|
||||
try:
|
||||
self._performance_metrics.update({
|
||||
"queue_size": self._question_queue.qsize(),
|
||||
"last_update": dt_util.utcnow().isoformat(),
|
||||
"uptime": time.time() - self._start_time if hasattr(self, '_start_time') else 0,
|
||||
"memory_usage": {
|
||||
"history_size": len(self._history),
|
||||
"response_cache_size": len(self._responses)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
_LOGGER.error("Error refreshing metrics: %s", str(e))
|
||||
|
||||
async def export_metrics(self) -> Dict[str, Any]:
|
||||
"""Export detailed metrics and statistics."""
|
||||
await self.async_refresh_metrics()
|
||||
return {
|
||||
"entity_id": self._entity_id,
|
||||
"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._performance_metrics["avg_tokens_per_request"]
|
||||
},
|
||||
"status": {
|
||||
"is_ready": self.is_ready,
|
||||
"endpoint_status": self._endpoint_status,
|
||||
"error_count": self._error_count,
|
||||
"last_error": self._last_error
|
||||
},
|
||||
"queue": {
|
||||
"size": self.queue_size,
|
||||
"is_full": self.is_queue_full
|
||||
}
|
||||
}
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
"""Return if coordinator is ready."""
|
||||
return self._is_ready
|
||||
# Версия API и последний ответ
|
||||
self.api_version = "v1"
|
||||
self.last_response = None
|
||||
self.endpoint_status = "initialized"
|
||||
|
||||
@property
|
||||
def is_processing(self) -> bool:
|
||||
"""Return if coordinator is processing."""
|
||||
"""Return True if currently processing a request."""
|
||||
return self._is_processing
|
||||
|
||||
@property
|
||||
def is_rate_limited(self) -> bool:
|
||||
"""Return if coordinator is rate limited."""
|
||||
"""Return True if currently rate limited."""
|
||||
return self._is_rate_limited
|
||||
|
||||
@property
|
||||
def is_maintenance(self) -> bool:
|
||||
"""Return if coordinator is in maintenance mode."""
|
||||
"""Return True if in maintenance mode."""
|
||||
return self._is_maintenance
|
||||
|
||||
@property
|
||||
def queue_size(self) -> int:
|
||||
"""Return current queue size."""
|
||||
return self._question_queue.qsize()
|
||||
async def async_ask_question(
|
||||
self,
|
||||
question: str,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Process a question with optional parameters."""
|
||||
try:
|
||||
temp_model = model or self.model
|
||||
temp_temperature = temperature or self.temperature
|
||||
temp_max_tokens = max_tokens or self.max_tokens
|
||||
temp_system_prompt = system_prompt or self._system_prompt
|
||||
|
||||
@property
|
||||
def is_queue_full(self) -> bool:
|
||||
"""Return if queue is full."""
|
||||
return self.queue_size >= QUEUE_MAX_SIZE
|
||||
messages = []
|
||||
if temp_system_prompt:
|
||||
messages.append({"role": "system", "content": temp_system_prompt})
|
||||
messages.append({"role": "user", "content": question})
|
||||
|
||||
@property
|
||||
def last_error(self) -> Optional[str]:
|
||||
"""Return last error message."""
|
||||
return self._last_error
|
||||
kwargs = {
|
||||
"model": temp_model,
|
||||
"temperature": temp_temperature,
|
||||
"max_tokens": temp_max_tokens,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
@property
|
||||
def endpoint_status(self) -> str:
|
||||
"""Return endpoint connection status."""
|
||||
return self._endpoint_status
|
||||
return await self.async_process_message(question, **kwargs)
|
||||
|
||||
@property
|
||||
def api_version(self) -> str:
|
||||
"""Return the API version."""
|
||||
return self._api_version
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async enter."""
|
||||
await self.async_start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async exit."""
|
||||
await self.async_stop()
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start coordinator operations."""
|
||||
if not self._is_ready:
|
||||
await self.async_initialize()
|
||||
|
||||
self._start_time = time.time()
|
||||
self._is_processing = True
|
||||
asyncio.create_task(self._process_queue())
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Stop coordinator operations."""
|
||||
self._is_processing = False
|
||||
self._is_ready = False
|
||||
# Clear queues and caches
|
||||
while not self._question_queue.empty():
|
||||
try:
|
||||
await self._question_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
self._responses.clear()
|
||||
|
||||
async def async_reset(self) -> None:
|
||||
"""Reset coordinator state."""
|
||||
await self.async_stop()
|
||||
self._error_count = 0
|
||||
self._request_count = 0
|
||||
self._tokens_used = 0
|
||||
self._last_error = None
|
||||
self._history.clear()
|
||||
self._performance_metrics = {
|
||||
"avg_response_time": 0,
|
||||
"total_errors": 0,
|
||||
"success_rate": 100,
|
||||
"requests_per_minute": 0,
|
||||
"avg_tokens_per_request": 0,
|
||||
}
|
||||
await self.async_start()
|
||||
|
||||
async def async_clear_history(self) -> None:
|
||||
"""Clear conversation history."""
|
||||
self._history.clear()
|
||||
|
||||
def set_system_prompt(self, prompt: str) -> None:
|
||||
"""Set system prompt."""
|
||||
if not isinstance(prompt, str):
|
||||
raise ValueError("System prompt must be a string")
|
||||
self.system_prompt = prompt
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error processing question: %s", err)
|
||||
raise HomeAssistantError(f"Failed to process question: {err}")
|
||||
|
||||
async def _async_update_data(self) -> Dict[str, Any]:
|
||||
"""Update data from API."""
|
||||
@@ -565,17 +121,28 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"status": self.endpoint_status,
|
||||
"metrics": self._performance_metrics,
|
||||
"last_update": dt_util.utcnow().isoformat(),
|
||||
"last_response": self.last_response, # Добавляем last_response
|
||||
"last_response": self.last_response,
|
||||
"is_processing": self._is_processing,
|
||||
"is_rate_limited": self._is_rate_limited,
|
||||
"is_maintenance": self._is_maintenance,
|
||||
"instance": self.instance_name, # Добавляем идентификатор интеграции
|
||||
}
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
self._error_count += 1
|
||||
_LOGGER.error("Error updating data: %s", str(e))
|
||||
raise HomeAssistantError(f"Error updating data: {str(e)}")
|
||||
|
||||
async def async_process_message(self, message: str, **kwargs) -> dict:
|
||||
async def async_refresh_metrics(self) -> None:
|
||||
"""Refresh performance metrics."""
|
||||
self._performance_metrics.update({
|
||||
"total_requests": self._request_count,
|
||||
"total_errors": self._error_count,
|
||||
"last_request_time": dt_util.utcnow().isoformat(),
|
||||
"instance": self.instance_name, # Добавляем идентификатор интеграции
|
||||
})
|
||||
|
||||
async def async_process_message(self, message: str, **kwargs) -> dict:
|
||||
"""Process message and update last_response."""
|
||||
try:
|
||||
self._is_processing = True
|
||||
@@ -583,36 +150,47 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
messages = kwargs.pop("messages", [{"role": "user", "content": message}])
|
||||
model = kwargs.pop("model", self.model)
|
||||
temperature = kwargs.pop("temperature", self.temperature)
|
||||
max_tokens = kwargs.pop("max_tokens", self.max_tokens)
|
||||
|
||||
if self._is_anthropic:
|
||||
response = await self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
messages=[{"role": "user", "content": message}],
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
messages=messages,
|
||||
**kwargs
|
||||
)
|
||||
response_text = response.content[0].text
|
||||
else:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
temperature=self.temperature,
|
||||
max_tokens=self.max_tokens,
|
||||
messages=[{"role": "user", "content": message}],
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
**kwargs
|
||||
)
|
||||
response_text = response.choices[0].message.content
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
self._request_count += 1
|
||||
self._performance_metrics["avg_response_time"] = (
|
||||
(self._performance_metrics["avg_response_time"] * (self._request_count - 1) + elapsed_time)
|
||||
/ self._request_count
|
||||
)
|
||||
|
||||
if self._performance_metrics["avg_response_time"] == 0:
|
||||
self._performance_metrics["avg_response_time"] = elapsed_time
|
||||
else:
|
||||
self._performance_metrics["avg_response_time"] = (
|
||||
(self._performance_metrics["avg_response_time"] * (self._request_count - 1) + elapsed_time)
|
||||
/ self._request_count
|
||||
)
|
||||
|
||||
self.last_response = {
|
||||
"timestamp": dt_util.utcnow().isoformat(),
|
||||
"question": message,
|
||||
"response": response_text,
|
||||
"model": model,
|
||||
"instance": self.instance_name, # Добавляем идентификатор интеграции
|
||||
"error": None
|
||||
}
|
||||
|
||||
@@ -620,6 +198,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
if len(self._history) > self._max_history_size:
|
||||
self._history.pop(0)
|
||||
|
||||
self.endpoint_status = "ready"
|
||||
self._is_processing = False
|
||||
self.async_update_listeners()
|
||||
|
||||
@@ -634,18 +213,56 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"timestamp": dt_util.utcnow().isoformat(),
|
||||
"question": message,
|
||||
"response": None,
|
||||
"instance": self.instance_name, # Добавляем идентификатор интеграции
|
||||
"error": str(err)
|
||||
}
|
||||
|
||||
self.endpoint_status = "error"
|
||||
self._is_processing = False
|
||||
self.async_update_listeners()
|
||||
raise
|
||||
raise
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async enter."""
|
||||
await self.async_start()
|
||||
return self
|
||||
async def async_set_system_prompt(self, prompt: str) -> None:
|
||||
"""Set the system prompt."""
|
||||
self._system_prompt = prompt
|
||||
_LOGGER.debug("[%s] System prompt updated: %s", self.instance_name, prompt)
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async exit."""
|
||||
await self.async_stop()
|
||||
async def async_clear_history(self) -> None:
|
||||
"""Clear conversation history."""
|
||||
self._history.clear()
|
||||
_LOGGER.debug("[%s] Conversation history cleared", self.instance_name)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_get_history(
|
||||
self,
|
||||
limit: Optional[int] = None,
|
||||
filter_model: Optional[str] = None,
|
||||
instance: Optional[str] = None # Добавляем фильтр по интеграции
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get conversation history with optional filtering."""
|
||||
history = self._history.copy()
|
||||
|
||||
if instance and instance != self.instance_name:
|
||||
return [] # Возвращаем пустой список для чужих интеграций
|
||||
|
||||
if filter_model:
|
||||
history = [h for h in history if h.get("model") == filter_model]
|
||||
|
||||
if limit and limit > 0:
|
||||
history = history[-limit:]
|
||||
|
||||
return history
|
||||
|
||||
async def async_reset_metrics(self) -> None:
|
||||
"""Reset all metrics."""
|
||||
self._request_count = 0
|
||||
self._error_count = 0
|
||||
self._performance_metrics = {
|
||||
"total_requests": 0,
|
||||
"total_errors": 0,
|
||||
"avg_response_time": 0.0,
|
||||
"last_request_time": None,
|
||||
"instance": self.instance_name, # Добавляем идентификатор интеграции
|
||||
}
|
||||
_LOGGER.debug("[%s] Metrics reset", self.instance_name)
|
||||
self.async_update_listeners()
|
||||
|
||||
@@ -57,19 +57,10 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._config_entry = config_entry
|
||||
|
||||
# Используем имя из конфигурации
|
||||
self._name = config_entry.title
|
||||
|
||||
# Создаем уникальный ID используя имя
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_{slugify(self._name)}"
|
||||
|
||||
# Создаем entity_id используя имя
|
||||
self.entity_id = f"sensor.ha_text_ai_{slugify(self._name)}"
|
||||
|
||||
# Устанавливаем отображаемое имя
|
||||
self._attr_name = self._name
|
||||
|
||||
self._current_state = STATE_INITIALIZING
|
||||
self._error_count = 0
|
||||
self._last_error = None
|
||||
@@ -80,7 +71,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
name=self._name,
|
||||
manufacturer="Community",
|
||||
model=f"{coordinator.model} ({self._config_entry.data.get(CONF_API_PROVIDER, 'Unknown')} provider)",
|
||||
sw_version=coordinator._api_version,
|
||||
sw_version=getattr(coordinator, 'api_version', 'v1'),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -95,8 +86,14 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
@property
|
||||
def state(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
if self.coordinator.data:
|
||||
return self.coordinator.data.get("last_update", self._current_state)
|
||||
try:
|
||||
if self.coordinator.data and "last_update" in self.coordinator.data:
|
||||
timestamp = self.coordinator.data["last_update"]
|
||||
if isinstance(timestamp, str):
|
||||
return dt_util.parse_datetime(timestamp)
|
||||
return timestamp
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Error parsing state: %s", err)
|
||||
return self._current_state
|
||||
|
||||
@property
|
||||
@@ -112,17 +109,22 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
if self.coordinator.data:
|
||||
data = self.coordinator.data
|
||||
if "metrics" in data:
|
||||
|
||||
# Обновляем метрики если они есть
|
||||
if "metrics" in data and isinstance(data["metrics"], dict):
|
||||
attributes.update({"metrics": data["metrics"]})
|
||||
|
||||
# Обновляем статус API
|
||||
if "status" in data:
|
||||
attributes[ATTR_API_STATUS] = data["status"]
|
||||
if "last_response" in data:
|
||||
last_response = data["last_response"]
|
||||
if last_response:
|
||||
attributes.update({
|
||||
ATTR_RESPONSE: last_response.get("response", ""),
|
||||
ATTR_QUESTION: last_response.get("question", ""),
|
||||
})
|
||||
|
||||
# Обновляем информацию о последнем ответе
|
||||
last_response = data.get("last_response", {})
|
||||
if isinstance(last_response, dict):
|
||||
attributes.update({
|
||||
ATTR_RESPONSE: last_response.get("response", ""),
|
||||
ATTR_QUESTION: last_response.get("question", ""),
|
||||
})
|
||||
|
||||
return attributes
|
||||
|
||||
@@ -130,20 +132,23 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
"""Handle updated data from the coordinator."""
|
||||
try:
|
||||
data = self.coordinator.data
|
||||
if data:
|
||||
if data.get("is_processing"):
|
||||
self._current_state = STATE_PROCESSING
|
||||
elif data.get("is_rate_limited"):
|
||||
self._current_state = STATE_RATE_LIMITED
|
||||
elif data.get("is_maintenance"):
|
||||
self._current_state = STATE_MAINTENANCE
|
||||
else:
|
||||
self._current_state = STATE_READY
|
||||
|
||||
if "metrics" in data:
|
||||
self._error_count = data["metrics"].get("total_errors", 0)
|
||||
else:
|
||||
if not data:
|
||||
self._current_state = STATE_DISCONNECTED
|
||||
return
|
||||
|
||||
# Определяем текущее состояние
|
||||
if data.get("is_processing"):
|
||||
self._current_state = STATE_PROCESSING
|
||||
elif data.get("is_rate_limited"):
|
||||
self._current_state = STATE_RATE_LIMITED
|
||||
elif data.get("is_maintenance"):
|
||||
self._current_state = STATE_MAINTENANCE
|
||||
else:
|
||||
self._current_state = STATE_READY
|
||||
|
||||
# Обновляем счетчик ошибок из метрик
|
||||
if "metrics" in data and isinstance(data["metrics"], dict):
|
||||
self._error_count = data["metrics"].get("total_errors", self._error_count)
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error handling update: %s", err, exc_info=True)
|
||||
|
||||
@@ -3,23 +3,36 @@ 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.
|
||||
target:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: ha_text_ai
|
||||
fields:
|
||||
instance:
|
||||
name: Instance
|
||||
description: Name of the HA Text AI instance to use
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: ha_text_ai
|
||||
domain: sensor
|
||||
|
||||
question:
|
||||
name: Question
|
||||
description: Your question or prompt for the AI assistant.
|
||||
description: Your question or prompt for the AI assistant
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
type: text
|
||||
|
||||
system_prompt:
|
||||
name: System Prompt
|
||||
description: Optional system prompt to set context for this specific question
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
model:
|
||||
name: Model
|
||||
description: "Select AI model to use (optional, overrides default setting)."
|
||||
description: "Select AI model to use (optional, overrides default setting)"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
@@ -27,7 +40,7 @@ ask_question:
|
||||
|
||||
temperature:
|
||||
name: Temperature
|
||||
description: Controls response creativity (0.0-2.0).
|
||||
description: Controls response creativity (0.0-2.0)
|
||||
required: false
|
||||
default: 0.7
|
||||
selector:
|
||||
@@ -39,7 +52,7 @@ ask_question:
|
||||
|
||||
max_tokens:
|
||||
name: Max Tokens
|
||||
description: Maximum length of the response (1-4096 tokens).
|
||||
description: Maximum length of the response (1-4096 tokens)
|
||||
required: false
|
||||
default: 1000
|
||||
selector:
|
||||
@@ -49,34 +62,35 @@ ask_question:
|
||||
step: 1
|
||||
mode: box
|
||||
|
||||
system_prompt:
|
||||
name: System Prompt
|
||||
description: Optional system prompt to set context for this specific question.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
clear_history:
|
||||
name: Clear History
|
||||
description: Delete all stored questions and responses from the conversation history.
|
||||
target:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: ha_text_ai
|
||||
fields: {}
|
||||
description: Delete all stored questions and responses from the conversation history
|
||||
fields:
|
||||
instance:
|
||||
name: Instance
|
||||
description: Name of the HA Text AI instance to clear history for
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: ha_text_ai
|
||||
domain: sensor
|
||||
|
||||
get_history:
|
||||
name: Get History
|
||||
description: Retrieve recent conversation history.
|
||||
target:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: ha_text_ai
|
||||
description: Retrieve conversation history with optional filtering and sorting
|
||||
fields:
|
||||
instance:
|
||||
name: Instance
|
||||
description: Name of the HA Text AI instance to get history from
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: ha_text_ai
|
||||
domain: sensor
|
||||
|
||||
limit:
|
||||
name: Limit
|
||||
description: Number of most recent conversations to return (1-100).
|
||||
description: Number of conversations to return (1-100)
|
||||
required: false
|
||||
default: 10
|
||||
selector:
|
||||
@@ -86,24 +100,58 @@ get_history:
|
||||
step: 1
|
||||
mode: box
|
||||
|
||||
filter_model:
|
||||
name: Filter Model
|
||||
description: Filter conversations by specific AI model
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: false
|
||||
|
||||
start_date:
|
||||
name: Start Date
|
||||
description: Optional start date for filtering history.
|
||||
description: Filter conversations starting from this date/time
|
||||
required: false
|
||||
selector:
|
||||
datetime:
|
||||
|
||||
include_metadata:
|
||||
name: Include Metadata
|
||||
description: Include additional information like tokens used, response time, etc.
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
sort_order:
|
||||
name: Sort Order
|
||||
description: Sort order for results (newest or oldest first)
|
||||
required: false
|
||||
default: "desc"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "Newest First"
|
||||
value: "desc"
|
||||
- label: "Oldest First"
|
||||
value: "asc"
|
||||
|
||||
set_system_prompt:
|
||||
name: Set System Prompt
|
||||
description: Set default system behavior instructions for all future conversations.
|
||||
target:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: ha_text_ai
|
||||
description: Set default system behavior instructions for all future conversations
|
||||
fields:
|
||||
instance:
|
||||
name: Instance
|
||||
description: Name of the HA Text AI instance to set system prompt for
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: ha_text_ai
|
||||
domain: sensor
|
||||
|
||||
prompt:
|
||||
name: System Prompt
|
||||
description: Instructions that define how the AI should behave and respond.
|
||||
description: Instructions that define how the AI should behave and respond
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up HA Text AI",
|
||||
"description": "Configure your AI integration with OpenAI or Anthropic providers.",
|
||||
"provider": {
|
||||
"title": "Select AI Provider",
|
||||
"description": "Choose which AI service provider to use for this instance",
|
||||
"data": {
|
||||
"api_provider": "Select API Provider",
|
||||
"api_key": "API key for authentication (required)",
|
||||
"model": "AI model to use (provider-specific)",
|
||||
"temperature": "Response creativity (0-2, lower = more focused and consistent)",
|
||||
"api_provider": "API Provider"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "Configure HA Text AI Instance",
|
||||
"description": "Set up a new AI assistant instance with your selected provider",
|
||||
"data": {
|
||||
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
|
||||
"api_key": "API key for authentication",
|
||||
"model": "AI model to use",
|
||||
"temperature": "Response creativity (0-2, lower = more focused)",
|
||||
"max_tokens": "Maximum response length (1-4096 tokens)",
|
||||
"api_endpoint": "Custom API endpoint URL (optional)",
|
||||
"request_interval": "Minimum time between requests in seconds (0.1-60)",
|
||||
"name": "Name for this integration instance (e.g., 'GPT Assistant', 'Claude Helper')"
|
||||
"request_interval": "Minimum time between requests (0.1-60 seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_name": "Invalid integration name",
|
||||
"name_exists": "An instance with this name already exists",
|
||||
"invalid_name": "Invalid instance name",
|
||||
"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 selected provider",
|
||||
"rate_limit": "Rate limit exceeded - please reduce request frequency",
|
||||
"api_error": "API service error - check provider status",
|
||||
"timeout": "Request timeout - server not responding",
|
||||
"queue_full": "Request queue full - try again later",
|
||||
"invalid_url_format": "Invalid API endpoint URL format",
|
||||
"invalid_input": "Invalid configuration parameters",
|
||||
"invalid_parameters": "Invalid service parameters provided",
|
||||
"service_unavailable": "Service temporarily unavailable",
|
||||
"context_length": "Maximum context length exceeded",
|
||||
"unknown": "Unexpected error - check logs for details"
|
||||
"cannot_connect": "Failed to connect to API service",
|
||||
"invalid_model": "Selected model is not available",
|
||||
"rate_limit": "Rate limit exceeded",
|
||||
"api_error": "API service error occurred",
|
||||
"timeout": "Request timed out",
|
||||
"invalid_instance": "Invalid instance specified",
|
||||
"unknown": "Unexpected error occurred"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "HA Text AI Settings",
|
||||
"description": "Adjust your AI integration parameters",
|
||||
"title": "Update Instance Settings",
|
||||
"description": "Modify settings for this AI assistant instance",
|
||||
"data": {
|
||||
"model": "Select AI model (provider-specific)",
|
||||
"model": "AI model",
|
||||
"temperature": "Response creativity (0-2)",
|
||||
"max_tokens": "Maximum response length in tokens (1-4096)",
|
||||
"request_interval": "Minimum seconds between requests (0.1-60)"
|
||||
"max_tokens": "Maximum response length (1-4096)",
|
||||
"request_interval": "Minimum request interval (0.1-60 seconds)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,63 +53,85 @@
|
||||
"services": {
|
||||
"ask_question": {
|
||||
"name": "Ask Question",
|
||||
"description": "Send a question to the AI model and get a detailed response.",
|
||||
"description": "Send a question to the specified AI instance",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "The HA Text AI instance to use"
|
||||
},
|
||||
"question": {
|
||||
"name": "Question",
|
||||
"description": "Your question or prompt for the AI model"
|
||||
"description": "Your question or prompt"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "System Prompt",
|
||||
"description": "Optional context or instructions for this specific question"
|
||||
"description": "Optional context for this question"
|
||||
},
|
||||
"model": {
|
||||
"name": "Model",
|
||||
"description": "Optional specific AI model for this request"
|
||||
"description": "Override default model for this request"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature",
|
||||
"description": "Optional creativity setting (0-2)"
|
||||
"description": "Override default creativity setting"
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens",
|
||||
"description": "Optional maximum response length (1-4096 tokens)"
|
||||
"description": "Override default response length limit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clear_history": {
|
||||
"name": "Clear History",
|
||||
"description": "Delete all stored questions and responses from the conversation history."
|
||||
"description": "Delete conversation history for specified instance",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "The HA Text AI instance to clear history for"
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_history": {
|
||||
"name": "Get History",
|
||||
"description": "Retrieve recent conversation history with optional filtering.",
|
||||
"description": "Retrieve conversation history for specified instance",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "The HA Text AI instance to get history from"
|
||||
},
|
||||
"limit": {
|
||||
"name": "Limit",
|
||||
"description": "Maximum number of entries to return (1-100)"
|
||||
"description": "Maximum number of entries (1-100)"
|
||||
},
|
||||
"filter_model": {
|
||||
"name": "Filter Model",
|
||||
"description": "Filter by specific AI model"
|
||||
},
|
||||
"start_date": {
|
||||
"name": "Start Date",
|
||||
"description": "Optional date to filter history from"
|
||||
"description": "Filter from specific date/time"
|
||||
},
|
||||
"include_metadata": {
|
||||
"name": "Include Metadata",
|
||||
"description": "Include additional response metadata"
|
||||
"description": "Include additional response information"
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "Sort Order",
|
||||
"description": "Order of results (asc/desc)"
|
||||
"description": "Sort order (newest/oldest first)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_system_prompt": {
|
||||
"name": "Set System Prompt",
|
||||
"description": "Set default system behavior instructions for all future conversations.",
|
||||
"description": "Set default behavior for specified instance",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "The HA Text AI instance to configure"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "System Prompt",
|
||||
"description": "Instructions that define how the AI should behave"
|
||||
"description": "Default behavior instructions"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,16 +141,13 @@
|
||||
"ha_text_ai": {
|
||||
"name": "{name}",
|
||||
"state": {
|
||||
"initializing": "Initializing",
|
||||
"ready": "Ready",
|
||||
"processing": "Processing",
|
||||
"error": "Error",
|
||||
"disconnected": "Disconnected",
|
||||
"rate_limited": "Rate Limited",
|
||||
"maintenance": "Maintenance",
|
||||
"retrying": "Retrying",
|
||||
"queued": "Queued",
|
||||
"updating": "Updating"
|
||||
"queued": "Queued"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -135,11 +156,8 @@
|
||||
"response": {
|
||||
"name": "Last Response"
|
||||
},
|
||||
"last_updated": {
|
||||
"name": "Last Updated"
|
||||
},
|
||||
"model": {
|
||||
"name": "Model"
|
||||
"name": "Current Model"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
@@ -147,20 +165,14 @@
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens"
|
||||
},
|
||||
"total_responses": {
|
||||
"name": "Total Responses"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "System Prompt"
|
||||
},
|
||||
"response_time": {
|
||||
"name": "Response Time"
|
||||
"name": "Last Response Time"
|
||||
},
|
||||
"queue_size": {
|
||||
"name": "Queue Size"
|
||||
},
|
||||
"api_status": {
|
||||
"name": "API Status"
|
||||
"total_responses": {
|
||||
"name": "Total Responses"
|
||||
},
|
||||
"error_count": {
|
||||
"name": "Error Count"
|
||||
@@ -168,11 +180,11 @@
|
||||
"last_error": {
|
||||
"name": "Last Error"
|
||||
},
|
||||
"tokens_used": {
|
||||
"name": "Tokens Used"
|
||||
"api_status": {
|
||||
"name": "API Status"
|
||||
},
|
||||
"request_count": {
|
||||
"name": "Request Count"
|
||||
"tokens_used": {
|
||||
"name": "Total Tokens Used"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user