Release v2.0.0

This commit is contained in:
SMKRV
2024-11-24 02:20:29 +03:00
parent 4f46d077df
commit 2688da5a82
7 changed files with 562 additions and 997 deletions
+70 -99
View File
@@ -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))
+121 -117
View File
@@ -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)
+26 -118
View File
@@ -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"
+150 -533
View File
@@ -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()
+38 -33
View File
@@ -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)
+83 -35
View File
@@ -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"
}
}
}