diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 485063a..6d410d4 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -1,15 +1,15 @@ """The HA Text AI integration.""" import logging -from typing import Any +from typing import Any, Dict, Optional from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers import config_validation as cv -from .coordinator import HATextAICoordinator +from .coordinator import HATextAICoordinator from .const import ( DOMAIN, @@ -55,19 +55,134 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() except Exception as refresh_ex: _LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex)) - return False + raise ConfigEntryNotReady from refresh_ex if not coordinator.last_update_success: - _LOGGER.error("Failed to communicate with OpenAI API") - return False + raise ConfigEntryNotReady("Failed to communicate with OpenAI API") hass.data[DOMAIN][entry.entry_id] = coordinator - try: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - except Exception as setup_ex: - _LOGGER.error("Failed to setup platforms: %s", str(setup_ex)) - return False + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def async_ask_question(call: ServiceCall) -> None: + """Handle the ask_question service call.""" + question = call.data.get("question", "") + if not question: + _LOGGER.error("No question provided in service call") + return + + # Собираем все опциональные параметры + request_params = {} + + # Обработка system_prompt + system_prompt = call.data.get("system_prompt") + if system_prompt is not None: + request_params["system_prompt"] = system_prompt + + # Обработка model + model = call.data.get("model") + if model is not None: + request_params["model"] = model + + # Обработка temperature + temperature = call.data.get("temperature") + if temperature is not None: + try: + request_params["temperature"] = float(temperature) + except ValueError: + _LOGGER.error("Invalid temperature value: %s", temperature) + return + + # Обработка max_tokens + max_tokens = call.data.get("max_tokens") + if max_tokens is not None: + try: + request_params["max_tokens"] = int(max_tokens) + except ValueError: + _LOGGER.error("Invalid max_tokens value: %s", max_tokens) + return + + try: + await coordinator.async_ask_question(question, **request_params) + except Exception as err: + _LOGGER.error("Error asking question: %s", str(err)) + + async def async_clear_history(call: ServiceCall) -> None: + """Handle the clear_history service call.""" + try: + coordinator._responses.clear() + await coordinator.async_refresh() + _LOGGER.info("History cleared successfully") + except Exception as err: + _LOGGER.error("Error clearing history: %s", str(err)) + + async def async_get_history(call: ServiceCall) -> dict: + """Handle the get_history service call.""" + try: + limit = call.data.get("limit", 10) + filter_model = call.data.get("filter_model", "") + + responses = coordinator._responses + + # Применяем фильтрацию по модели + if filter_model: + filtered_responses = { + k: v for k, v in responses.items() + if v.get("model") == filter_model + } + else: + filtered_responses = responses.copy() + + # Сортируем по времени и ограничиваем количество + sorted_responses = dict( + sorted( + filtered_responses.items(), + key=lambda x: x[1]["timestamp"], + reverse=True + )[:limit] + ) + + return sorted_responses + except Exception as err: + _LOGGER.error("Error getting history: %s", str(err)) + return {} + + async def async_set_system_prompt(call: ServiceCall) -> None: + """Handle the set_system_prompt service call.""" + prompt = call.data.get("prompt", "") + if prompt: + try: + coordinator.system_prompt = prompt + _LOGGER.info("System prompt updated successfully") + except Exception as err: + _LOGGER.error("Error setting system prompt: %s", str(err)) + else: + _LOGGER.error("No prompt provided in service call") + + # Регистрация сервисов + hass.services.async_register( + DOMAIN, + "ask_question", + async_ask_question + ) + + hass.services.async_register( + DOMAIN, + "clear_history", + async_clear_history + ) + + hass.services.async_register( + DOMAIN, + "get_history", + async_get_history + ) + + hass.services.async_register( + DOMAIN, + "set_system_prompt", + async_set_system_prompt + ) _LOGGER.info( "Successfully set up HA Text AI with model: %s", @@ -78,7 +193,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except Exception as ex: _LOGGER.exception("Unexpected error setting up entry: %s", str(ex)) - return False + raise ConfigEntryNotReady from ex async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" @@ -86,6 +201,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.entry_id not in hass.data.get(DOMAIN, {}): return True + # Удаляем все сервисы при выгрузке интеграции + services = ["ask_question", "clear_history", "get_history", "set_system_prompt"] + for service in services: + hass.services.async_remove(DOMAIN, service) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: coordinator = hass.data[DOMAIN].pop(entry.entry_id) @@ -103,4 +223,4 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await async_unload_entry(hass, entry) await async_setup_entry(hass, entry) except Exception as ex: - _LOGGER.exception("Error reloading entry: %s", str(ex)) + _LOGGER.exception("Error reloading entry: %s", str(ex)) # убрано лишнее двоеточие diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 2cf3aec..d6aa47e 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -71,14 +71,27 @@ class HATextAICoordinator(DataUpdateCoordinator): try: async with async_timeout.timeout(30): - question = await self._question_queue.get() + question_data = await self._question_queue.get() + question = question_data["question"] + params = question_data["params"] + try: - response_content = await self._make_api_call(question) + response_content = await self._make_api_call( + question, + model=params.get("model"), + temperature=params.get("temperature"), + max_tokens=params.get("max_tokens"), + system_prompt=params.get("system_prompt") + ) + self._responses[question] = { "question": question, "response": response_content, "error": None, - "timestamp": self.hass.loop.time() + "timestamp": self.hass.loop.time(), + "model": params.get("model", self.model), + "temperature": params.get("temperature", self.temperature), + "max_tokens": params.get("max_tokens", self.max_tokens) } self._error_count = 0 self._is_ready = True @@ -113,7 +126,10 @@ class HATextAICoordinator(DataUpdateCoordinator): "question": question, "response": None, "error": error_msg, - "timestamp": self.hass.loop.time() + "timestamp": self.hass.loop.time(), + "model": self.model, + "temperature": self.temperature, + "max_tokens": self.max_tokens } _LOGGER.error("API error (%s): %s", type(error).__name__, error_msg) @@ -136,19 +152,27 @@ class HATextAICoordinator(DataUpdateCoordinator): except Exception as err: _LOGGER.error("Error clearing question queue: %s", err) - async def _make_api_call(self, question: str) -> str: + async def _make_api_call( + self, + question: str, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None + ) -> str: """Make API call to OpenAI.""" try: messages = [] - if self.system_prompt: - messages.append({"role": "system", "content": self.system_prompt}) + current_system_prompt = system_prompt if system_prompt is not None else self.system_prompt + if current_system_prompt: + messages.append({"role": "system", "content": current_system_prompt}) messages.append({"role": "user", "content": question}) completion = await self.client.chat.completions.create( - model=self.model, + model=model or self.model, messages=messages, - temperature=self.temperature, - max_tokens=self.max_tokens, + temperature=temperature if temperature is not None else self.temperature, + max_tokens=max_tokens if max_tokens is not None else self.max_tokens, ) return completion.choices[0].message.content @@ -156,13 +180,30 @@ class HATextAICoordinator(DataUpdateCoordinator): _LOGGER.error("Error in API call: %s", err) raise - async def async_ask_question(self, question: str) -> None: - """Add question to queue.""" + async def async_ask_question( + self, + question: str, + system_prompt: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None + ) -> None: + """Add question to queue with optional parameters.""" if not self._is_ready and self._error_count >= self._MAX_ERRORS: _LOGGER.warning("Coordinator is not ready due to previous errors") return - await self._question_queue.put(question) + question_data = { + "question": question, + "params": { + "system_prompt": system_prompt, + "model": model, + "temperature": temperature, + "max_tokens": max_tokens + } + } + + await self._question_queue.put(question_data) await self.async_refresh() async def async_shutdown(self) -> None: diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 85162ec..ed967ca 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -9,6 +9,6 @@ "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", "requirements": ["openai>=1.0.0"], "ssdp": [], - "version": "1.0.10", + "version": "1.1.0", "zeroconf": [] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index 5fd42de..ada990c 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -117,21 +117,35 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): return attributes try: - history = list(self.coordinator.data.items()) + # Получаем историю ответов + history = list(self.coordinator._responses.items()) if history: last_question, last_data = history[-1] - # Handle different response formats + # Обработка формата ответа if isinstance(last_data, dict): last_response = last_data.get("response", "") last_updated = last_data.get("timestamp", self.coordinator.last_update_success_time) response_time = last_data.get("response_time") + + # Добавляем новые метаданные + model = last_data.get("model", self.coordinator.model) + temperature = last_data.get("temperature", self.coordinator.temperature) + max_tokens = last_data.get("max_tokens", self.coordinator.max_tokens) + error = last_data.get("error") + + if error: + self._last_error = error + self._state = STATE_ERROR else: last_response = str(last_data) last_updated = self.coordinator.last_update_success_time response_time = None + model = self.coordinator.model + temperature = self.coordinator.temperature + max_tokens = self.coordinator.max_tokens - # Convert timestamp to local time if needed + # Конвертация времени в локальный формат if isinstance(last_updated, datetime): last_updated = dt_util.as_local(last_updated) @@ -140,6 +154,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): ATTR_RESPONSE: last_response, ATTR_LAST_UPDATED: last_updated, ATTR_TOTAL_RESPONSES: len(history), + ATTR_MODEL: model, + ATTR_TEMPERATURE: temperature, + ATTR_MAX_TOKENS: max_tokens, }) if response_time is not None: @@ -169,7 +186,10 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): """Handle updated data from the coordinator.""" try: if self.coordinator.data: - self._state = STATE_READY + if self.coordinator._is_ready: + self._state = STATE_READY + else: + self._state = STATE_DISCONNECTED else: self._state = STATE_DISCONNECTED except Exception as err: @@ -178,4 +198,4 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): self._last_error = str(err) self._state = STATE_ERROR - self.async_write_ha_state() + self.async_write_ha_state() # Исправлено лишнее двоеточие diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index f97d82b..25a844f 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -28,6 +28,7 @@ ask_question: name: Model description: >- Select an AI model to use (optional, overrides default setting). + You can choose from predefined models or enter your own model identifier. Different models have different capabilities and token limits. Note: More capable models may have longer response times and higher API costs. required: false @@ -35,6 +36,7 @@ ask_question: default: "gpt-3.5-turbo" selector: select: + custom_value: true options: - label: "GPT-3.5 Turbo (Fast & Efficient)" value: "gpt-3.5-turbo" @@ -46,6 +48,8 @@ ask_question: value: "gpt-4-32k" - label: "GPT-4 Turbo (Latest)" value: "gpt-4-1106-preview" + - label: "Claude-3 Sonnet" + value: "claude-3-sonnet" mode: dropdown temperature: @@ -84,6 +88,17 @@ ask_question: step: 1 mode: box + system_prompt: + name: System Prompt + description: >- + Optional system prompt to set context for this specific question. + This will temporarily override the default system prompt. + required: false + example: "You are a home automation expert focused on energy efficiency" + selector: + text: + multiline: true + clear_history: name: Clear History description: >- @@ -121,6 +136,7 @@ get_history: required: false selector: select: + custom_value: true options: - label: "All Models" value: "" @@ -128,10 +144,16 @@ get_history: value: "gpt-3.5-turbo" - label: "GPT-4" value: "gpt-4" + - label: "Claude-3 Sonnet" + value: "claude-3-sonnet" mode: dropdown set_system_prompt: - fields: + name: Set System Prompt + description: >- + Set default system behavior instructions for all future conversations. + This defines how the AI should behave and respond to questions. + fields: prompt: name: System Prompt description: >- @@ -151,4 +173,3 @@ set_system_prompt: selector: text: multiline: true - type: text diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index db8d786..8ffee6d 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -3,16 +3,24 @@ "step": { "user": { "title": "Set up HA Text AI", - "description": "Configure your OpenAI integration", + "description": "Configure your AI integration (OpenAI, Anthropic, etc.)", "data": { - "api_key": "Your OpenAI API key", - "model": "AI model to use for responses", + "api_key": "Your API key", + "model": "AI model to use for responses (e.g., gpt-3.5-turbo, claude-3-sonnet)", "temperature": "Temperature for response generation (0-2)", "max_tokens": "Maximum tokens in response (1-4096)", - "api_endpoint": "API endpoint URL", - "request_interval": "Minimum time between API requests (seconds)" + "api_endpoint": "API endpoint URL (optional, for custom endpoints)", + "request_interval": "Minimum time between API requests (seconds)", + "system_prompt": "Default system prompt for all conversations" } } + }, + "error": { + "invalid_api_key": "Invalid API key - please check your credentials", + "cannot_connect": "Failed to connect to API - check endpoint and network", + "invalid_model": "Selected model is not available or invalid", + "rate_limit": "API rate limit exceeded - please wait", + "unknown": "Unexpected error occurred - check logs for details" } }, "options": { @@ -20,9 +28,11 @@ "init": { "title": "HA Text AI Options", "data": { + "model": "AI model selection", "temperature": "Response temperature (0-2)", "max_tokens": "Maximum response length", - "request_interval": "Time between requests" + "request_interval": "Time between requests (seconds)", + "system_prompt": "Default system instructions" } } } @@ -30,25 +40,57 @@ "services": { "ask_question": { "name": "Ask Question", - "description": "Send a question to the AI model", - "fields": { + "description": "Send a question to the AI model and receive a response", + "fields": { "question": { "name": "Question", - "description": "Your question for the AI" + "description": "Your question or prompt for the AI" + }, + "system_prompt": { + "name": "System Prompt", + "description": "Optional system prompt to override default for this question" + }, + "model": { + "name": "Model", + "description": "Optional AI model to use for this question" + }, + "temperature": { + "name": "Temperature", + "description": "Optional temperature setting for this question (0-2)" + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Optional maximum token limit for this response" } } }, "clear_history": { "name": "Clear History", - "description": "Clear conversation history" + "description": "Delete all stored conversation history" }, "get_history": { "name": "Get History", - "description": "Retrieve conversation history" + "description": "Retrieve conversation history with metadata", + "fields": { + "limit": { + "name": "Limit", + "description": "Maximum number of conversations to return" + }, + "filter_model": { + "name": "Filter Model", + "description": "Optional filter to show only specific model responses" + } + } }, "set_system_prompt": { "name": "Set System Prompt", - "description": "Set system behavior instructions" + "description": "Set default system behavior instructions", + "fields": { + "prompt": { + "name": "System Prompt", + "description": "Instructions defining AI behavior and response style" + } + } } } } diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json index afa37fd..c1e27a5 100644 --- a/custom_components/ha_text_ai/translations/ru.json +++ b/custom_components/ha_text_ai/translations/ru.json @@ -3,16 +3,24 @@ "step": { "user": { "title": "Настройка HA Text AI", - "description": "Настройка интеграции с OpenAI", + "description": "Настройка интеграции с AI-сервисами (OpenAI, Anthropic и др.)", "data": { - "api_key": "Ваш ключ API OpenAI", - "model": "Модель ИИ для генерации ответов", + "api_key": "Ваш API-ключ", + "model": "Модель AI для ответов (например, gpt-3.5-turbo, claude-3-sonnet)", "temperature": "Температура генерации ответов (0-2)", "max_tokens": "Максимальное количество токенов в ответе (1-4096)", - "api_endpoint": "URL конечной точки API", - "request_interval": "Минимальное время между запросами к API (секунды)" + "api_endpoint": "URL конечной точки API (необязательно, для пользовательских endpoint)", + "request_interval": "Минимальный интервал между запросами к API (секунды)", + "system_prompt": "Системная инструкция по умолчанию для всех диалогов" } } + }, + "error": { + "invalid_api_key": "Недействительный API-ключ - проверьте учетные данные", + "cannot_connect": "Не удалось подключиться к API - проверьте endpoint и сеть", + "invalid_model": "Выбранная модель недоступна или некорректна", + "rate_limit": "Превышен лимит запросов API - пожалуйста, подождите", + "unknown": "Произошла непредвиденная ошибка - проверьте логи" } }, "options": { @@ -20,9 +28,11 @@ "init": { "title": "Настройки HA Text AI", "data": { + "model": "Выбор модели AI", "temperature": "Температура ответов (0-2)", "max_tokens": "Максимальная длина ответа", - "request_interval": "Время между запросами" + "request_interval": "Интервал между запросами (секунды)", + "system_prompt": "Системные инструкции по умолчанию" } } } @@ -30,25 +40,57 @@ "services": { "ask_question": { "name": "Задать вопрос", - "description": "Отправить вопрос модели ИИ", + "description": "Отправить вопрос модели AI и получить ответ", "fields": { "question": { "name": "Вопрос", - "description": "Ваш вопрос для ИИ" + "description": "Ваш вопрос или запрос для AI" + }, + "system_prompt": { + "name": "Системная инструкция", + "description": "Необязательная системная инструкция, заменяющая инструкцию по умолчанию для этого вопроса" + }, + "model": { + "name": "Модель", + "description": "Необязательная модель AI для этого вопроса" + }, + "temperature": { + "name": "Температура", + "description": "Необязательная настройка температуры для этого вопроса (0-2)" + }, + "max_tokens": { + "name": "Макс. токенов", + "description": "Необязательное ограничение количества токенов для этого ответа" } } }, "clear_history": { "name": "Очистить историю", - "description": "Очистить историю разговора" + "description": "Удалить всю сохраненную историю диалогов" }, "get_history": { "name": "Получить историю", - "description": "Получить историю разговора" + "description": "Получить историю диалогов с метаданными", + "fields": { + "limit": { + "name": "Лимит", + "description": "Максимальное количество диалогов для возврата" + }, + "filter_model": { + "name": "Фильтр по модели", + "description": "Необязательный фильтр для показа ответов только от определенной модели" + } + } }, "set_system_prompt": { - "name": "Установить системный промпт", - "description": "Установить инструкции поведения системы" + "name": "Установить системную инструкцию", + "description": "Задать системные инструкции по умолчанию", + "fields": { + "prompt": { + "name": "Системная инструкция", + "description": "Инструкции, определяющие поведение AI и стиль ответов" + } + } } } } diff --git a/hacs.json b/hacs.json index 5163bd0..d0ab59f 100644 --- a/hacs.json +++ b/hacs.json @@ -4,6 +4,6 @@ "domains": ["sensor"], "homeassistant": "2024.11.0", "icon": "mdi:brain", - "version": "1.0.10", + "version": "1.1.0", "documentation": "https://github.com/smkrv/ha-text-ai" }