diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8cd9736 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Home Assistant (please complete the following information):** + - Version: [e.g. 2023.11.0] + - Installation type: [e.g. Home Assistant OS, Container, Core] + - Browser [e.g. chrome, safari] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index 07d1dda..5992c55 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -8,7 +8,7 @@ on: jobs: validate: - runs-on: "ubuntu-latest" + runs-on: ubuntu-latest steps: - - uses: "actions/checkout@v3" + - uses: actions/checkout@v3 - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 8ef65bd..53e48f2 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -1,4 +1,4 @@ -name: HACS Validation +name: Validate on: push: @@ -8,10 +8,20 @@ on: jobs: validate: - runs-on: "ubuntu-latest" + runs-on: ubuntu-latest steps: - - uses: "actions/checkout@v3" - - name: HACS validation - uses: "hacs/action@main" + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - category: "integration" + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pylint + - name: Run tests + run: | + pytest + - name: Lint with pylint + run: | + pylint custom_components/ha_text_ai/*.py diff --git a/README.md b/README.md index 8e45df1..e6e2270 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,78 @@ -# HA Text AI Integration +# HA text AI -A Home Assistant integration that provides a bridge to OpenAI-compatible API services with automatic Text Helper management. +This is a Home Assistant custom integration that allows you to interact with OpenAI's API and compatible endpoints to generate AI responses. ## Features -- Easy integration with OpenAI-compatible APIs -- Automatic Text Helper creation for responses -- Support for custom API endpoints -- Built-in rate limiting and queue management -- Blueprint for easy automation setup -- Supports responses up to 65,536 characters +- Configure all settings through the Home Assistant UI +- Support for OpenAI API and compatible endpoints +- Configurable parameters (temperature, max tokens, etc.) +- Request queue system to prevent API rate limiting +- Long response storage +- Multiple parallel questions support +- Real-time updates ## Installation -### HACS (Recommended) - -1. Open HACS in your Home Assistant instance +### HACS Installation +1. Go to HACS in your Home Assistant installation 2. Click on "Integrations" -3. Click the three dots in the top right corner -4. Select "Custom repositories" -5. Add this repository URL and select "Integration" as the category -6. Click "Install" -7. Restart Home Assistant +3. Click the "+" button +4. Search for "HA text AI" +5. Click "Install" +6. Restart Home Assistant ### Manual Installation - 1. Copy the `custom_components/ha_text_ai` directory to your Home Assistant's `custom_components` directory 2. Restart Home Assistant ## Configuration -1. Go to Settings -> Devices & Services -2. Click "Add Integration" -3. Search for "HA Text AI" -4. Enter your configuration: - - API Key: Your OpenAI API key - - API Base URL (optional): Custom endpoint for OpenAI-compatible APIs - - Request Interval: Minimum time between requests (in seconds) +1. Go to Configuration -> Integrations +2. Click "+" to add a new integration +3. Search for "HA text AI" +4. Enter your OpenAI API key and configure other settings ## Usage -### Service Call - -You can call the integration using the service `ha_text_ai.text_ai_call`: +### Service Calls +You can use the `ha_text_ai.ask_question` service to ask questions: ```yaml -service: ha_text_ai.text_ai_call +service: ha_text_ai.ask_question data: - prompt: "Tell me a joke about home automation" - response_id: "automation_joke" # Optional, defaults to "default" - model: "gpt-3.5-turbo" # Optional - temperature: 0.7 # Optional - max_tokens: 150 # Optional - top_p: 1.0 # Optional - frequency_penalty: 0.0 # Optional - presence_penalty: 0.0 # Optional + question: "What is the weather like today?" ``` -### Using the Blueprint - -1. Go to Settings -> Automations & Scenes -2. Click the "Add Blueprint" button -3. Import this URL: `https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_text_ai_request.yaml`, [copy link](https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_text_ai_request.yaml) - -4. Create a new automation using the blueprint -5. Configure the trigger and prompt template - -### Accessing Responses - -Responses are automatically stored in Text Helpers with the format `input_text.text_ai_response_[response_id]` - -Example template to access a response: +### Automation Example ```yaml -{{ states('input_text.text_ai_response_automation_joke') }} +automation: + - alias: "Daily Weather Question" + trigger: + platform: time + at: "07:00:00" + action: + - service: ha_text_ai.ask_question + data: + question: "What's the weather forecast for today?" ``` -## Blueprint Configuration Example +## Configuration Options -```yaml -trigger_entity: sensor.temperature -prompt_template: "Tell me a joke about {{ states('sensor.temperature') }} degree weather" -response_id: weather_joke -model: gpt-3.5-turbo -temperature: 0.7 -max_tokens: 150 -``` - -## Advanced Usage - -### Custom API Endpoints - -To use with Azure OpenAI or other compatible services, set the API Base URL during configuration: - -- Azure OpenAI: `https://your-resource-name.openai.azure.com` -- Other Compatible APIs: Your API endpoint URL - -### Rate Limiting - -The integration includes built-in rate limiting: -- Requests are queued and processed according to the configured interval -- Default interval is 2 seconds -- Can be adjusted in the integration options - -## Troubleshooting - -Common issues and solutions: - -1. **Response not appearing:** - - Check if the Text Helper was created (`input_text.text_ai_response_[your_id]`) - - Verify your API key permissions - -2. **API errors:** - - Check your API key - - Verify the API endpoint URL - - Ensure you have sufficient API credits - -3. **Rate limiting:** - - Adjust the request interval in the integration options - - Check for multiple automations making simultaneous requests - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - -1. Fork the repository -2. Create your feature branch -3. Commit your changes -4. Push to the branch -5. Open a Pull Request - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. +| Option | Description | Default | +|--------|-------------|---------| +| `api_key` | Your OpenAI API key | Required | +| `model` | The AI model to use | gpt-3.5-turbo | +| `temperature` | Response randomness (0-1) | 0.7 | +| `max_tokens` | Maximum response length | 1000 | +| `api_endpoint` | Custom API endpoint | https://api.openai.com/v1 | +| `request_interval` | Minimum time between requests | 1.0 | ## Support -If you have any questions or need help, please: -- Open an [issue](https://github.com/smkrv/ha-text-ai/issues) +For bugs and feature requests, please create an issue on GitHub. +## License + +This project is licensed under the MIT License. +``` diff --git a/blueprints/automation/ha_text_ai_request.yaml b/blueprints/automation/ha_text_ai_request.yaml deleted file mode 100644 index 3260cf8..0000000 --- a/blueprints/automation/ha_text_ai_request.yaml +++ /dev/null @@ -1,203 +0,0 @@ -blueprint: - name: Text AI Request - description: Send text prompts to AI service and store responses in a Text Helper - domain: automation - source_url: https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_text_ai_request.yaml - input: - # Trigger Configuration - trigger_type: - name: Trigger Type - description: Select the type of trigger for this automation - default: state - selector: - select: - options: - - state - - numeric_state - - time - - template - - trigger_entity: - name: Trigger Entity - description: Entity that will trigger this automation when its state changes - selector: - entity: {} - - # State Trigger Options - state_from: - name: From State - description: Previous state value to trigger the automation (optional, for state trigger) - default: "" - selector: - text: - - state_to: - name: To State - description: New state value to trigger the automation (optional, for state trigger) - default: "" - selector: - text: - - # Numeric State Options - numeric_above: - name: Above Value - description: Trigger when the numeric value exceeds this threshold - selector: - number: - mode: box - step: 0.1 - - numeric_below: - name: Below Value - description: Trigger when the numeric value falls below this threshold - selector: - number: - mode: box - step: 0.1 - - # Time Trigger Options - time_at: - name: Time - description: Specific time to trigger the automation (for time trigger) - selector: - time: {} - - # Template Trigger Options - template_value: - name: Template - description: Template condition that evaluates to true/false (for template trigger) - selector: - template: {} - - # AI Configuration - prompt_template: - name: Prompt Template - description: > - Template for the AI prompt. Supports Home Assistant template syntax. - Example: {{ states('sensor.temperature') }} degrees - what should I wear? - selector: - text: - multiline: true - - response_id: - name: Response ID - description: Unique identifier for the Text Helper entity that will store the AI response - selector: - text: - - model_name: - name: AI Model Name - description: The name of the AI model to use (e.g., gpt-4, gpt-3.5-turbo, claude-2) - selector: - text: - - # Advanced AI Parameters - temperature: - name: Temperature - description: Controls randomness in the response (0 = deterministic, 2 = maximum creativity) - default: 0.7 - selector: - number: - min: 0.0 - max: 2.0 - step: 0.1 - mode: slider - - max_tokens: - name: Maximum Tokens - description: Maximum length of the generated response in tokens - default: 150 - selector: - number: - min: 1 - max: 4096 - step: 1 - - top_p: - name: Top P - description: Controls diversity via nucleus sampling (lower = more focused) - default: 1.0 - selector: - number: - min: 0.0 - max: 1.0 - step: 0.1 - mode: slider - - frequency_penalty: - name: Frequency Penalty - description: Reduces repetition of token sequences (-2.0 to 2.0) - default: 0.0 - selector: - number: - min: -2.0 - max: 2.0 - step: 0.1 - mode: slider - - presence_penalty: - name: Presence Penalty - description: Reduces repetition of topics (-2.0 to 2.0) - default: 0.0 - selector: - number: - min: -2.0 - max: 2.0 - step: 0.1 - mode: slider - -mode: queued -max_exceeded: silent - -trigger: - - choose: - - conditions: - - condition: template - value_template: "{{ trigger_type == 'state' }}" - sequence: - - platform: state - entity_id: !input trigger_entity - from: !input state_from - to: !input state_to - - conditions: - - condition: template - value_template: "{{ trigger_type == 'numeric_state' }}" - sequence: - - platform: numeric_state - entity_id: !input trigger_entity - above: !input numeric_above - below: !input numeric_below - - conditions: - - condition: template - value_template: "{{ trigger_type == 'time' }}" - sequence: - - platform: time - at: !input time_at - - conditions: - - condition: template - value_template: "{{ trigger_type == 'template' }}" - sequence: - - platform: template - value_template: !input template_value - -variables: - prompt_template: !input prompt_template - response_id: !input response_id - model_name: !input model_name - temperature: !input temperature - max_tokens: !input max_tokens - top_p: !input top_p - frequency_penalty: !input frequency_penalty - presence_penalty: !input presence_penalty - -action: - - service: ha_text_ai.text_ai_call - data: - prompt: "{{ prompt_template }}" - response_id: "{{ response_id }}" - model: "{{ model_name }}" - temperature: "{{ temperature }}" - max_tokens: "{{ max_tokens }}" - top_p: "{{ top_p }}" - frequency_penalty: "{{ frequency_penalty }}" - presence_penalty: "{{ presence_penalty }}" diff --git a/blueprints/automation/ha_text_ai_request_push.yaml b/blueprints/automation/ha_text_ai_request_push.yaml deleted file mode 100644 index 91491c2..0000000 --- a/blueprints/automation/ha_text_ai_request_push.yaml +++ /dev/null @@ -1,199 +0,0 @@ -blueprint: - name: Push Notification Sender - description: Send push notifications with customizable content and options - domain: automation - source_url: https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_push_notification.yaml - input: - # Trigger Configuration - trigger_type: - name: Trigger Type - description: Select the type of trigger for this automation - default: state - selector: - select: - options: - - state - - numeric_state - - time - - template - - trigger_entity: - name: Trigger Entity - description: Entity that will trigger this automation when its state changes - selector: - entity: {} - - # State Trigger Options - state_from: - name: From State - description: Previous state value to trigger the automation (optional, for state trigger) - default: "" - selector: - text: - - state_to: - name: To State - description: New state value to trigger the automation (optional, for state trigger) - default: "" - selector: - text: - - # Numeric State Options - numeric_above: - name: Above Value - description: Trigger when the numeric value exceeds this threshold - selector: - number: - mode: box - step: 0.1 - - numeric_below: - name: Below Value - description: Trigger when the numeric value falls below this threshold - selector: - number: - mode: box - step: 0.1 - - # Time Trigger Options - time_at: - name: Time - description: Specific time to trigger the automation (for time trigger) - selector: - time: {} - - # Template Trigger Options - template_value: - name: Template - description: Template condition that evaluates to true/false (for template trigger) - selector: - template: {} - - # Notification Configuration - notification_target: - name: Notification Target - description: The target device or group to receive the notification - selector: - text: - - title_template: - name: Title Template - description: > - Template for the notification title. Supports Home Assistant template syntax. - Example: Temperature Alert: {{ states('sensor.temperature') }}°C - default: Home Assistant Notification - selector: - text: - multiline: true - - message_template: - name: Message Template - description: > - Template for the notification message. Supports Home Assistant template syntax. - Example: Current temperature is {{ states('sensor.temperature') }}°C in {{ states('sensor.location') }} - selector: - text: - multiline: true - - # Advanced Options - importance: - name: Importance Level - description: Set the importance/priority level of the notification - default: default - selector: - select: - options: - - low - - default - - high - - notification_sound: - name: Notification Sound - description: Select a sound to play with the notification (optional) - default: default - selector: - text: - - data_actions: - name: Actions - description: > - JSON array of actions (optional). Example: - [{"action": "URI", "title": "Open Map", "uri": "https://maps.google.com"}] - default: "[]" - selector: - text: - multiline: true - - # Additional Options - group_notifications: - name: Group Notifications - description: Group similar notifications together - default: false - selector: - boolean: - - notification_timeout: - name: Timeout - description: Time in seconds before the notification disappears (optional) - selector: - number: - min: 0 - max: 86400 - step: 1 - mode: box - -mode: queued -max_exceeded: silent - -trigger: - - choose: - - conditions: - - condition: template - value_template: "{{ trigger_type == 'state' }}" - sequence: - - platform: state - entity_id: !input trigger_entity - from: !input state_from - to: !input state_to - - conditions: - - condition: template - value_template: "{{ trigger_type == 'numeric_state' }}" - sequence: - - platform: numeric_state - entity_id: !input trigger_entity - above: !input numeric_above - below: !input numeric_below - - conditions: - - condition: template - value_template: "{{ trigger_type == 'time' }}" - sequence: - - platform: time - at: !input time_at - - conditions: - - condition: template - value_template: "{{ trigger_type == 'template' }}" - sequence: - - platform: template - value_template: !input template_value - -variables: - notification_target: !input notification_target - title_template: !input title_template - message_template: !input message_template - importance: !input importance - notification_sound: !input notification_sound - data_actions: !input data_actions - group_notifications: !input group_notifications - notification_timeout: !input notification_timeout - -action: - - service: notify.mobile_app_{{ notification_target }} - data: - title: "{{ title_template }}" - message: "{{ message_template }}" - data: - importance: "{{ importance }}" - sound: "{{ notification_sound }}" - actions: "{{ data_actions }}" - group: "{{ group_notifications }}" - timeout: "{{ notification_timeout }}" diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index a43c421..2e1bf5c 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -1,156 +1,150 @@ -"""The HA Text AI Integration.""" -from __future__ import annotations +"""The HA text AI integration.""" +import logging +from typing import Any -import asyncio -import logging -from typing import Any +import voluptuous as vol -from openai import AsyncOpenAI -import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.config_validation as cv -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers import config_validation as cv -from homeassistant.components import input_text +from .const import ( + DOMAIN, + PLATFORMS, + SERVICE_ASK_QUESTION, + SERVICE_CLEAR_HISTORY, + SERVICE_GET_HISTORY, + SERVICE_SET_SYSTEM_PROMPT, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_MAX_TOKENS, + CONF_API_ENDPOINT, + CONF_REQUEST_INTERVAL, +) +from .coordinator import HATextAICoordinator -from .const import ( - DOMAIN, - CONF_API_BASE, - CONF_REQUEST_INTERVAL, - DEFAULT_API_BASE, - DEFAULT_REQUEST_INTERVAL, - TEXT_HELPER_PREFIX, - TEXT_HELPER_MAX_LENGTH, -) +_LOGGER = logging.getLogger(__name__) -_LOGGER = logging.getLogger(__name__) - -PLATFORMS: list[Platform] = [] - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the HA Text AI component.""" - hass.data.setdefault(DOMAIN, {}) - return True - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up HA Text AI from a config entry.""" - client = AsyncOpenAI( - api_key=entry.data[CONF_API_KEY], - base_url=entry.data.get(CONF_API_BASE, DEFAULT_API_BASE) - ) - - hass.data[DOMAIN][entry.entry_id] = { - "client": client, - CONF_REQUEST_INTERVAL: entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL), - "queue": [], - "processing": False, - } - - async def create_text_helper(name: str) -> str: - """Create a Text Helper if it doesn't exist.""" - object_id = f"{TEXT_HELPER_PREFIX}{name}" - - entity_id = f"input_text.{object_id}" - entity_registry = er.async_get(hass) - - if entity_registry.async_get(entity_id) is None: - await hass.services.async_call( - "input_text", - "create", - { - "name": f"AI Response {name}", - "id": object_id, - "max": TEXT_HELPER_MAX_LENGTH, - "initial": "", - }, - ) - - return entity_id - - async def handle_text_ai_call(call: ServiceCall) -> None: - """Handle the text AI service call.""" - entry_id = list(hass.data[DOMAIN].keys())[0] # Используем первую настроенную интеграцию - - response_id = call.data.get("response_id", "default") - entity_id = await create_text_helper(response_id) - - request_data = { - "prompt": call.data["prompt"], - "model": call.data.get("model", "gpt-3.5-turbo"), - "temperature": call.data.get("temperature", 0.7), - "max_tokens": call.data.get("max_tokens", 150), - "top_p": call.data.get("top_p", 1.0), - "frequency_penalty": call.data.get("frequency_penalty", 0.0), - "presence_penalty": call.data.get("presence_penalty", 0.0), - "entity_id": entity_id, - } - - hass.data[DOMAIN][entry_id]["queue"].append(request_data) - - if not hass.data[DOMAIN][entry_id]["processing"]: - asyncio.create_task(process_queue(hass, entry_id)) - - async def process_queue(hass: HomeAssistant, entry_id: str) -> None: - """Process the queue of requests.""" - if hass.data[DOMAIN][entry_id]["processing"]: - return - - hass.data[DOMAIN][entry_id]["processing"] = True - client = hass.data[DOMAIN][entry_id]["client"] - - while hass.data[DOMAIN][entry_id]["queue"]: - request = hass.data[DOMAIN][entry_id]["queue"].pop(0) - - try: - response = await client.chat.completions.create( - model=request["model"], - messages=[{"role": "user", "content": request["prompt"]}], - temperature=request["temperature"], - max_tokens=request["max_tokens"], - top_p=request["top_p"], - frequency_penalty=request["frequency_penalty"], - presence_penalty=request["presence_penalty"], - ) - - response_text = response.choices[0].message.content - await hass.services.async_call( - "input_text", - "set_value", - { - "entity_id": request["entity_id"], - "value": response_text - }, - ) - - except Exception as e: - _LOGGER.error("Error processing request: %s", str(e)) - - await asyncio.sleep(hass.data[DOMAIN][entry_id][CONF_REQUEST_INTERVAL]) - - hass.data[DOMAIN][entry_id]["processing"] = False - - hass.services.async_register( - DOMAIN, - "text_ai_call", - handle_text_ai_call, - schema=vol.Schema({ - vol.Required("prompt"): cv.string, - vol.Optional("response_id", default="default"): cv.string, - vol.Optional("model", default="gpt-3.5-turbo"): cv.string, - vol.Optional("temperature", default=0.7): vol.Coerce(float), - vol.Optional("max_tokens", default=150): vol.Coerce(int), - vol.Optional("top_p", default=1.0): vol.Coerce(float), - vol.Optional("frequency_penalty", default=0.0): vol.Coerce(float), - vol.Optional("presence_penalty", default=0.0): vol.Coerce(float), - }) - ) - - return True - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the HA text AI component from configuration.yaml.""" + hass.data.setdefault(DOMAIN, {}) return True + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up HA text AI from a config entry.""" + coordinator = HATextAICoordinator( + hass, + api_key=entry.data[CONF_API_KEY], + endpoint=entry.data.get(CONF_API_ENDPOINT), + model=entry.data.get(CONF_MODEL), + temperature=entry.data.get(CONF_TEMPERATURE), + max_tokens=entry.data.get(CONF_MAX_TOKENS), + request_interval=entry.data.get(CONF_REQUEST_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + 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["question"] + model = call.data.get("model", coordinator.model) + temperature = call.data.get("temperature", coordinator.temperature) + max_tokens = call.data.get("max_tokens", coordinator.max_tokens) + + # Temporarily update parameters if they were overridden + original_model = coordinator.model + original_temperature = coordinator.temperature + original_max_tokens = coordinator.max_tokens + + try: + coordinator.model = model + coordinator.temperature = temperature + coordinator.max_tokens = max_tokens + await coordinator.async_ask_question(question) + finally: + # Restore original parameters + coordinator.model = original_model + coordinator.temperature = original_temperature + coordinator.max_tokens = original_max_tokens + + async def async_clear_history(call: ServiceCall) -> None: + """Handle the clear_history service call.""" + coordinator._responses.clear() + await coordinator.async_refresh() + + async def async_get_history(call: ServiceCall) -> None: + """Handle the get_history service call.""" + limit = call.data.get("limit", 10) + history = list(coordinator._responses.items())[-limit:] + return { + "history": [ + {"question": q, "response": r} for q, r in history + ] + } + + async def async_set_system_prompt(call: ServiceCall) -> None: + """Handle the set_system_prompt service call.""" + prompt = call.data["prompt"] + coordinator.system_prompt = prompt + + # Register all services + hass.services.async_register( + DOMAIN, + SERVICE_ASK_QUESTION, + async_ask_question, + schema=vol.Schema({ + vol.Required("question"): cv.string, + vol.Optional("model"): cv.string, + vol.Optional("temperature"): vol.Coerce(float), + vol.Optional("max_tokens"): vol.Coerce(int), + }) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_HISTORY, + async_clear_history, + schema=vol.Schema({}) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_HISTORY, + async_get_history, + schema=vol.Schema({ + vol.Optional("limit"): vol.Coerce(int), + }) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_SYSTEM_PROMPT, + async_set_system_prompt, + schema=vol.Schema({ + vol.Required("prompt"): cv.string, + }) + ) + + return True + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + # Unregister services + for service in [ + SERVICE_ASK_QUESTION, + SERVICE_CLEAR_HISTORY, + SERVICE_GET_HISTORY, + SERVICE_SET_SYSTEM_PROMPT + ]: + hass.services.async_remove(DOMAIN, service) + + return unload_ok diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 0146e49..e7535f7 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -1,130 +1,89 @@ -"""Config flow for HA Text AI Integration.""" -from __future__ import annotations -import logging -from typing import Any +#### `config_flow.py` +```python +"""Config flow for HA text AI integration.""" +import voluptuous as vol +from homeassistant import config_entries +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback -import voluptuous as vol -from openai import AsyncOpenAI -from openai import OpenAIError, AuthenticationError, APIConnectionError +from .const import ( + DOMAIN, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_MAX_TOKENS, + CONF_API_ENDPOINT, + CONF_REQUEST_INTERVAL, + DEFAULT_MODEL, + DEFAULT_TEMPERATURE, + DEFAULT_MAX_TOKENS, + DEFAULT_API_ENDPOINT, + DEFAULT_REQUEST_INTERVAL, +) -from homeassistant import config_entries -from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError +class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for HA text AI.""" -from .const import ( - DOMAIN, - CONF_API_KEY, - CONF_API_BASE, - CONF_REQUEST_INTERVAL, - DEFAULT_API_BASE, - DEFAULT_REQUEST_INTERVAL, -) + VERSION = 1 -_LOGGER = logging.getLogger(__name__) + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} -class TextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for HA Text AI Integration.""" + if user_input is not None: + return self.async_create_entry(title="HA text AI", data=user_input) - VERSION = 1 + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required("api_key"): str, + vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str, + vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.Coerce(int), + vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str, + vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.Coerce(float), + }), + errors=errors, + ) - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors = {} + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) - if user_input is not None: - try: - await self._test_api_key(user_input[CONF_API_KEY], user_input.get(CONF_API_BASE)) - return self.async_create_entry( - title="HA Text AI", - data=user_input, - ) - except ApiKeyError: - errors["base"] = "invalid_api_key" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for HA text AI.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_API_BASE, default=DEFAULT_API_BASE): str, - vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): int, - } - ), - errors=errors, - ) + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry - @staticmethod - async def _test_api_key(api_key: str, api_base: str | None) -> None: - """Test if the API key is valid.""" - try: - client = AsyncOpenAI( - api_key=api_key, - base_url=api_base if api_base else DEFAULT_API_BASE - ) - - models = await client.models.list() - if not models.data: - raise ApiKeyError - - except AuthenticationError as err: - raise ApiKeyError from err - except APIConnectionError as err: - raise ApiConnectionError from err - except OpenAIError as err: - if getattr(err, 'status_code', None) == 401: - raise ApiKeyError from err - raise ApiConnectionError from err + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> TextAIOptionsFlow: - """Get the options flow for this handler.""" - return TextAIOptionsFlow(config_entry) - - -class TextAIOptionsFlow(config_entries.OptionsFlow): - """Handle options flow for HA Text AI Integration.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_REQUEST_INTERVAL, - default=self.config_entry.options.get( - CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL - ), - ): int, - } - ), - ) - - -class ApiKeyError(HomeAssistantError): - """Error to indicate there is an invalid API key.""" - - -class ApiConnectionError(HomeAssistantError): - """Error to indicate there is a connection error.""" + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Optional( + CONF_TEMPERATURE, + default=self.config_entry.options.get( + CONF_TEMPERATURE, DEFAULT_TEMPERATURE + ), + ): vol.Coerce(float), + vol.Optional( + CONF_MAX_TOKENS, + default=self.config_entry.options.get( + CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS + ), + ): vol.Coerce(int), + vol.Optional( + CONF_REQUEST_INTERVAL, + default=self.config_entry.options.get( + CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL + ), + ): vol.Coerce(float), + }), + ) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 7bc2f2a..27e9eb3 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -1,13 +1,30 @@ -"""Constants for the HA Text AI Integration.""" -DOMAIN = "ha_text_ai" +"""Constants for the HA text AI integration.""" +from homeassistant.const import Platform -CONF_API_KEY = "api_key" -CONF_API_BASE = "api_base" -CONF_REQUEST_INTERVAL = "request_interval" +DOMAIN = "ha_text_ai" +PLATFORMS = [Platform.SENSOR] -DEFAULT_API_BASE = "https://api.openai.com/v1" -DEFAULT_REQUEST_INTERVAL = 5 # 5 seconds +# Configuration +CONF_MODEL = "model" +CONF_TEMPERATURE = "temperature" +CONF_MAX_TOKENS = "max_tokens" +CONF_API_ENDPOINT = "api_endpoint" +CONF_REQUEST_INTERVAL = "request_interval" -# Text Helper related -TEXT_HELPER_MAX_LENGTH = 65536 -TEXT_HELPER_PREFIX = "text_ai_response_" +# Defaults +DEFAULT_MODEL = "gpt-3.5-turbo" +DEFAULT_TEMPERATURE = 0.7 +DEFAULT_MAX_TOKENS = 1000 +DEFAULT_API_ENDPOINT = "https://api.openai.com/v1" +DEFAULT_REQUEST_INTERVAL = 1.0 + +# Attributes +ATTR_QUESTION = "question" +ATTR_RESPONSE = "response" +ATTR_LAST_UPDATED = "last_updated" + +# Services +SERVICE_ASK_QUESTION = "ask_question +SERVICE_CLEAR_HISTORY = "clear_history" +SERVICE_GET_HISTORY = "get_history" +SERVICE_SET_SYSTEM_PROMPT = "set_system_prompt" diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py new file mode 100644 index 0000000..d1290b9 --- /dev/null +++ b/custom_components/ha_text_ai/coordinator.py @@ -0,0 +1,88 @@ +"""Data coordinator for HA text AI.""" +import asyncio +import logging +from datetime import timedelta +from typing import Any, Dict + +import openai +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import ( + DOMAIN, + DEFAULT_REQUEST_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +class HATextAICoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + endpoint: str, + model: str, + temperature: float, + max_tokens: int, + request_interval: float, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=request_interval), + ) + + self.api_key = api_key + self.endpoint = endpoint + self.model = model + self.temperature = temperature + self.max_tokens = max_tokens + self._question_queue = asyncio.Queue() + self._responses: Dict[str, Any] = {} + + openai.api_key = self.api_key + if endpoint != "https://api.openai.com/v1": + openai.api_base = endpoint + + async def _async_update_data(self) -> Dict[str, Any]: + """Update data via OpenAI API.""" + if self._question_queue.empty(): + return self._responses + + try: + question = await self._question_queue.get() + response = await self.hass.async_add_executor_job( + self._make_api_call, question + ) + self._responses[question] = response + return self._responses + + except openai.error.AuthenticationError as err: + raise ConfigEntryAuthFailed from err + except Exception as err: + _LOGGER.error("Error communicating with API: %s", err) + return self._responses + + def _make_api_call(self, question: str) -> str: + """Make API call to OpenAI.""" + try: + completion = openai.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": question}], + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + return completion.choices[0].message.content + except Exception as err: + _LOGGER.error("Error in API call: %s", err) + raise + + async def async_ask_question(self, question: str) -> None: + """Add question to queue.""" + await self._question_queue.put(question) + await self.async_refresh() diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 3cd342b..b733a38 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -1,17 +1,15 @@ -{ - "domain": "ha_text_ai", - "name": "HA Text AI", - "version": "1.0.0", - "config_flow": true, - "documentation": "https://github.com/smkrv/ha-text-ai", - "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", - "requirements": ["openai>=1.0.0"], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], - "codeowners": ["@smkrv"], - "iot_class": "cloud_polling", - "min_ha_version": "2023.8.0", - "icon": "images/icon.svg" +{ + "domain": "ha_text_ai", + "name": "HA text AI", + "config_flow": true, + "documentation": "https://github.com/smkrv/ha_text_ai", + "issue_tracker": "https://github.com/smkrv/ha_text_ai/issues", + "requirements": ["openai>=1.0.0"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": ["@smkrv"], + "version": "1.0.1", + "iot_class": "cloud_polling" } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py new file mode 100644 index 0000000..28a4f97 --- /dev/null +++ b/custom_components/ha_text_ai/sensor.py @@ -0,0 +1,44 @@ +"""Sensor platform for HA text AI.""" +from typing import Any, Callable, Dict, Optional + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED +from .coordinator import HATextAICoordinator + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HA text AI sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([HATextAISensor(coordinator, entry)], True) + +class HATextAISensor(CoordinatorEntity, SensorEntity): + """HA text AI Sensor.""" + + def __init__( + self, + coordinator: HATextAICoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._config_entry = config_entry + self._attr_unique_id = f"{config_entry.entry_id}" + self._attr_name = "HA text AI" + + @property + def extra_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return entity specific state attributes.""" + return { + ATTR_QUESTION: list(self.coordinator.data.keys())[-1] if self.coordinator.data else None, + ATTR_RESPONSE: list(self.coordinator.data.values())[-1] if self.coordinator.data else None, + ATTR_LAST_UPDATED: self.coordinator.last_update_success_time, + } diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index dc17ffd..8ff38f1 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -1,34 +1,38 @@ -{ - "config": { - "abort": { - "already_configured": "Service is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_api_key": "Invalid API key", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "api_key": "API Key", - "api_base": "API Base URL", - "request_interval": "Request interval (seconds)" - }, - "description": "Enter your OpenAI API credentials", - "title": "OpenAI API" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "request_interval": "Request interval (seconds)" - }, - "description": "Configure HA Text AI options", - "title": "HA Text AI Options" - } - } - } +{ + "config": { + "step": { + "user": { + "title": "Set up HA text AI", + "description": "Set up your OpenAI integration", + "data": { + "api_key": "API Key", + "model": "Model", + "temperature": "Temperature", + "max_tokens": "Max Tokens", + "api_endpoint": "API Endpoint", + "request_interval": "Request Interval (seconds)" + } + } + }, + "error": { + "auth": "API key is invalid.", + "cannot_connect": "Failed to connect to API.", + "unknown": "Unexpected error occurred." + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "HA text AI Options", + "data": { + "temperature": "Temperature", + "max_tokens": "Max Tokens", + "request_interval": "Request Interval (seconds)" + } + } + } + } } diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json deleted file mode 100644 index 9023633..0000000 --- a/custom_components/ha_text_ai/translations/ru.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Сервис уже настроен" - }, - "error": { - "cannot_connect": "Ошибка подключения", - "invalid_api_key": "Неверный API ключ", - "unknown": "Неожиданная ошибка" - }, - "step": { - "user": { - "data": { - "api_key": "API ключ", - "api_base": "Базовый URL API", - "request_interval": "Интервал запросов (секунды)" - }, - "description": "Введите учетные данные OpenAI API", - "title": "OpenAI API" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "request_interval": "Интервал запросов (секунды)" - }, - "description": "Настройка параметров HA Text AI", - "title": "Настройки HA Text AI" - } - } - } -} diff --git a/hacs.json b/hacs.json index 21feeb4..aea9b9d 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,6 @@ -{ - "name": "HA Text AI Integration", - "content_in_root": false, - "render_readme": true +{ + "name": "HA text AI", + "render_readme": true, + "domains": ["sensor"], + "homeassistant": "2023.8.0" } diff --git a/images/icon.svg b/images/icon.svg deleted file mode 100644 index 945457e..0000000 --- a/images/icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/structure.md b/structure.md new file mode 100644 index 0000000..181a28a --- /dev/null +++ b/structure.md @@ -0,0 +1,27 @@ +ha_text_ai/ +├── custom_components/ +│ └── ha_text_ai/ +│ ├── __init__.py +│ ├── manifest.json +│ ├── config_flow.py +│ ├── const.py +│ ├── coordinator.py +│ ├── sensor.py +│ ├── services.yaml +│ └── translations/ +│ ├── en.json +│ └── ru.json +├── .github/ +│ ├── ISSUE_TEMPLATE/ +│ │ ├── bug_report.md +│ │ └── feature_request.md +│ └── workflows/ +│ ├── hassfest.yaml +│ └── validate.yaml +├── tests/ +│ └── test_init.py +├── .gitignore +├── LICENSE +├── README.md +├── hacs.json +└── info.md diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..66fd5a4 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,23 @@ +"""Tests for the HA text AI integration.""" +from unittest.mock import patch +import pytest +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from custom_components.ha_text_ai.const import DOMAIN + +@pytest.mark.asyncio +async def test_setup(hass: HomeAssistant): + """Test the setup.""" + with patch('custom_components.ha_text_ai.coordinator.HATextAICoordinator'): + assert await async_setup_component(hass, DOMAIN, { + DOMAIN: { + "api_key": "test_key", + "model": "gpt-3.5-turbo", + "temperature": 0.7, + "max_tokens": 1000, + "api_endpoint": "https://api.openai.com/v1", + "request_interval": 1.0 + } + }) + await hass.async_block_till_done() + assert DOMAIN in hass.data