mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-29 14:43:57 +08:00
new version
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v3"
|
- uses: actions/checkout@v3
|
||||||
- uses: home-assistant/actions/hassfest@master
|
- uses: home-assistant/actions/hassfest@master
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: HACS Validation
|
name: Validate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -8,10 +8,20 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v3"
|
- uses: actions/checkout@v3
|
||||||
- name: HACS validation
|
- name: Set up Python
|
||||||
uses: "hacs/action@main"
|
uses: actions/setup-python@v4
|
||||||
with:
|
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
|
||||||
|
|||||||
@@ -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
|
## Features
|
||||||
|
|
||||||
- Easy integration with OpenAI-compatible APIs
|
- Configure all settings through the Home Assistant UI
|
||||||
- Automatic Text Helper creation for responses
|
- Support for OpenAI API and compatible endpoints
|
||||||
- Support for custom API endpoints
|
- Configurable parameters (temperature, max tokens, etc.)
|
||||||
- Built-in rate limiting and queue management
|
- Request queue system to prevent API rate limiting
|
||||||
- Blueprint for easy automation setup
|
- Long response storage
|
||||||
- Supports responses up to 65,536 characters
|
- Multiple parallel questions support
|
||||||
|
- Real-time updates
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### HACS (Recommended)
|
### HACS Installation
|
||||||
|
1. Go to HACS in your Home Assistant installation
|
||||||
1. Open HACS in your Home Assistant instance
|
|
||||||
2. Click on "Integrations"
|
2. Click on "Integrations"
|
||||||
3. Click the three dots in the top right corner
|
3. Click the "+" button
|
||||||
4. Select "Custom repositories"
|
4. Search for "HA text AI"
|
||||||
5. Add this repository URL and select "Integration" as the category
|
5. Click "Install"
|
||||||
6. Click "Install"
|
6. Restart Home Assistant
|
||||||
7. Restart Home Assistant
|
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|
||||||
1. Copy the `custom_components/ha_text_ai` directory to your Home Assistant's `custom_components` directory
|
1. Copy the `custom_components/ha_text_ai` directory to your Home Assistant's `custom_components` directory
|
||||||
2. Restart Home Assistant
|
2. Restart Home Assistant
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
1. Go to Settings -> Devices & Services
|
1. Go to Configuration -> Integrations
|
||||||
2. Click "Add Integration"
|
2. Click "+" to add a new integration
|
||||||
3. Search for "HA Text AI"
|
3. Search for "HA text AI"
|
||||||
4. Enter your configuration:
|
4. Enter your OpenAI API key and configure other settings
|
||||||
- API Key: Your OpenAI API key
|
|
||||||
- API Base URL (optional): Custom endpoint for OpenAI-compatible APIs
|
|
||||||
- Request Interval: Minimum time between requests (in seconds)
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Service Call
|
### Service Calls
|
||||||
|
You can use the `ha_text_ai.ask_question` service to ask questions:
|
||||||
You can call the integration using the service `ha_text_ai.text_ai_call`:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
service: ha_text_ai.text_ai_call
|
service: ha_text_ai.ask_question
|
||||||
data:
|
data:
|
||||||
prompt: "Tell me a joke about home automation"
|
question: "What is the weather like today?"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using the Blueprint
|
### Automation Example
|
||||||
|
|
||||||
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:
|
|
||||||
```yaml
|
```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
|
| Option | Description | Default |
|
||||||
trigger_entity: sensor.temperature
|
|--------|-------------|---------|
|
||||||
prompt_template: "Tell me a joke about {{ states('sensor.temperature') }} degree weather"
|
| `api_key` | Your OpenAI API key | Required |
|
||||||
response_id: weather_joke
|
| `model` | The AI model to use | gpt-3.5-turbo |
|
||||||
model: gpt-3.5-turbo
|
| `temperature` | Response randomness (0-1) | 0.7 |
|
||||||
temperature: 0.7
|
| `max_tokens` | Maximum response length | 1000 |
|
||||||
max_tokens: 150
|
| `api_endpoint` | Custom API endpoint | https://api.openai.com/v1 |
|
||||||
```
|
| `request_interval` | Minimum time between requests | 1.0 |
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you have any questions or need help, please:
|
For bugs and feature requests, please create an issue on GitHub.
|
||||||
- Open an [issue](https://github.com/smkrv/ha-text-ai/issues)
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 }}"
|
|
||||||
@@ -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 }}"
|
|
||||||
@@ -1,150 +1,133 @@
|
|||||||
"""The HA Text AI Integration."""
|
"""The HA text AI integration."""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY, Platform
|
from homeassistant.const import CONF_API_KEY, Platform
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.helpers import entity_registry as er
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.components import input_text
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
CONF_API_BASE,
|
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,
|
CONF_REQUEST_INTERVAL,
|
||||||
DEFAULT_API_BASE,
|
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
|
||||||
TEXT_HELPER_PREFIX,
|
|
||||||
TEXT_HELPER_MAX_LENGTH,
|
|
||||||
)
|
)
|
||||||
|
from .coordinator import HATextAICoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = []
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
"""Set up the HA Text AI component."""
|
"""Set up the HA text AI component from configuration.yaml."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up HA Text AI from a config entry."""
|
"""Set up HA text AI from a config entry."""
|
||||||
client = AsyncOpenAI(
|
coordinator = HATextAICoordinator(
|
||||||
|
hass,
|
||||||
api_key=entry.data[CONF_API_KEY],
|
api_key=entry.data[CONF_API_KEY],
|
||||||
base_url=entry.data.get(CONF_API_BASE, DEFAULT_API_BASE)
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
await coordinator.async_config_entry_first_refresh()
|
||||||
"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:
|
hass.data.setdefault(DOMAIN, {})
|
||||||
"""Create a Text Helper if it doesn't exist."""
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
object_id = f"{TEXT_HELPER_PREFIX}{name}"
|
|
||||||
|
|
||||||
entity_id = f"input_text.{object_id}"
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
|
|
||||||
if entity_registry.async_get(entity_id) is None:
|
async def async_ask_question(call: ServiceCall) -> None:
|
||||||
await hass.services.async_call(
|
"""Handle the ask_question service call."""
|
||||||
"input_text",
|
question = call.data["question"]
|
||||||
"create",
|
model = call.data.get("model", coordinator.model)
|
||||||
{
|
temperature = call.data.get("temperature", coordinator.temperature)
|
||||||
"name": f"AI Response {name}",
|
max_tokens = call.data.get("max_tokens", coordinator.max_tokens)
|
||||||
"id": object_id,
|
|
||||||
"max": TEXT_HELPER_MAX_LENGTH,
|
|
||||||
"initial": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return entity_id
|
# Temporarily update parameters if they were overridden
|
||||||
|
original_model = coordinator.model
|
||||||
|
original_temperature = coordinator.temperature
|
||||||
|
original_max_tokens = coordinator.max_tokens
|
||||||
|
|
||||||
async def handle_text_ai_call(call: ServiceCall) -> None:
|
try:
|
||||||
"""Handle the text AI service call."""
|
coordinator.model = model
|
||||||
entry_id = list(hass.data[DOMAIN].keys())[0] # Используем первую настроенную интеграцию
|
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
|
||||||
|
|
||||||
response_id = call.data.get("response_id", "default")
|
async def async_clear_history(call: ServiceCall) -> None:
|
||||||
entity_id = await create_text_helper(response_id)
|
"""Handle the clear_history service call."""
|
||||||
|
coordinator._responses.clear()
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
request_data = {
|
async def async_get_history(call: ServiceCall) -> None:
|
||||||
"prompt": call.data["prompt"],
|
"""Handle the get_history service call."""
|
||||||
"model": call.data.get("model", "gpt-3.5-turbo"),
|
limit = call.data.get("limit", 10)
|
||||||
"temperature": call.data.get("temperature", 0.7),
|
history = list(coordinator._responses.items())[-limit:]
|
||||||
"max_tokens": call.data.get("max_tokens", 150),
|
return {
|
||||||
"top_p": call.data.get("top_p", 1.0),
|
"history": [
|
||||||
"frequency_penalty": call.data.get("frequency_penalty", 0.0),
|
{"question": q, "response": r} for q, r in history
|
||||||
"presence_penalty": call.data.get("presence_penalty", 0.0),
|
]
|
||||||
"entity_id": entity_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hass.data[DOMAIN][entry_id]["queue"].append(request_data)
|
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||||
|
"""Handle the set_system_prompt service call."""
|
||||||
|
prompt = call.data["prompt"]
|
||||||
|
coordinator.system_prompt = prompt
|
||||||
|
|
||||||
if not hass.data[DOMAIN][entry_id]["processing"]:
|
# Register all services
|
||||||
asyncio.create_task(process_queue(hass, entry_id))
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
async def process_queue(hass: HomeAssistant, entry_id: str) -> None:
|
SERVICE_ASK_QUESTION,
|
||||||
"""Process the queue of requests."""
|
async_ask_question,
|
||||||
if hass.data[DOMAIN][entry_id]["processing"]:
|
schema=vol.Schema({
|
||||||
return
|
vol.Required("question"): cv.string,
|
||||||
|
vol.Optional("model"): cv.string,
|
||||||
hass.data[DOMAIN][entry_id]["processing"] = True
|
vol.Optional("temperature"): vol.Coerce(float),
|
||||||
client = hass.data[DOMAIN][entry_id]["client"]
|
vol.Optional("max_tokens"): vol.Coerce(int),
|
||||||
|
})
|
||||||
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(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
"text_ai_call",
|
SERVICE_CLEAR_HISTORY,
|
||||||
handle_text_ai_call,
|
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({
|
schema=vol.Schema({
|
||||||
vol.Required("prompt"): cv.string,
|
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),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,5 +135,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
return True
|
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
|
||||||
|
|||||||
@@ -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
|
import voluptuous as vol
|
||||||
from openai import AsyncOpenAI
|
|
||||||
from openai import OpenAIError, AuthenticationError, APIConnectionError
|
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import HomeAssistant, callback
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
CONF_API_KEY,
|
CONF_MODEL,
|
||||||
CONF_API_BASE,
|
CONF_TEMPERATURE,
|
||||||
|
CONF_MAX_TOKENS,
|
||||||
|
CONF_API_ENDPOINT,
|
||||||
CONF_REQUEST_INTERVAL,
|
CONF_REQUEST_INTERVAL,
|
||||||
DEFAULT_API_BASE,
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
DEFAULT_API_ENDPOINT,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for HA text AI."""
|
||||||
class TextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for HA Text AI Integration."""
|
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(self, user_input=None):
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
return self.async_create_entry(title="HA text AI", data=user_input)
|
||||||
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"
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema({
|
||||||
{
|
vol.Required("api_key"): str,
|
||||||
vol.Required(CONF_API_KEY): str,
|
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str,
|
||||||
vol.Optional(CONF_API_BASE, default=DEFAULT_API_BASE): str,
|
vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.Coerce(float),
|
||||||
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): int,
|
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,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(config_entry):
|
||||||
config_entry: config_entries.ConfigEntry,
|
|
||||||
) -> TextAIOptionsFlow:
|
|
||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return TextAIOptionsFlow(config_entry)
|
return OptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
|
"""Handle options flow for HA text AI."""
|
||||||
|
|
||||||
class TextAIOptionsFlow(config_entries.OptionsFlow):
|
def __init__(self, config_entry):
|
||||||
"""Handle options flow for HA Text AI Integration."""
|
|
||||||
|
|
||||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
|
||||||
"""Initialize options flow."""
|
"""Initialize options flow."""
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(self, user_input=None):
|
||||||
self, user_input: dict[str, Any] | None = None
|
"""Handle options flow."""
|
||||||
) -> FlowResult:
|
|
||||||
"""Manage options."""
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return self.async_create_entry(title="", data=user_input)
|
return self.async_create_entry(title="", data=user_input)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema({
|
||||||
{
|
vol.Optional(
|
||||||
vol.Optional(
|
CONF_TEMPERATURE,
|
||||||
CONF_REQUEST_INTERVAL,
|
default=self.config_entry.options.get(
|
||||||
default=self.config_entry.options.get(
|
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
|
||||||
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
|
),
|
||||||
),
|
): vol.Coerce(float),
|
||||||
): int,
|
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),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyError(HomeAssistantError):
|
|
||||||
"""Error to indicate there is an invalid API key."""
|
|
||||||
|
|
||||||
|
|
||||||
class ApiConnectionError(HomeAssistantError):
|
|
||||||
"""Error to indicate there is a connection error."""
|
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
"""Constants for the HA Text AI Integration."""
|
"""Constants for the HA text AI integration."""
|
||||||
DOMAIN = "ha_text_ai"
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
CONF_API_KEY = "api_key"
|
DOMAIN = "ha_text_ai"
|
||||||
CONF_API_BASE = "api_base"
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONF_MODEL = "model"
|
||||||
|
CONF_TEMPERATURE = "temperature"
|
||||||
|
CONF_MAX_TOKENS = "max_tokens"
|
||||||
|
CONF_API_ENDPOINT = "api_endpoint"
|
||||||
CONF_REQUEST_INTERVAL = "request_interval"
|
CONF_REQUEST_INTERVAL = "request_interval"
|
||||||
|
|
||||||
DEFAULT_API_BASE = "https://api.openai.com/v1"
|
# Defaults
|
||||||
DEFAULT_REQUEST_INTERVAL = 5 # 5 seconds
|
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
|
||||||
|
|
||||||
# Text Helper related
|
# Attributes
|
||||||
TEXT_HELPER_MAX_LENGTH = 65536
|
ATTR_QUESTION = "question"
|
||||||
TEXT_HELPER_PREFIX = "text_ai_response_"
|
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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
{
|
{
|
||||||
"domain": "ha_text_ai",
|
"domain": "ha_text_ai",
|
||||||
"name": "HA Text AI",
|
"name": "HA text AI",
|
||||||
"version": "1.0.0",
|
"config_flow": true,
|
||||||
"config_flow": true,
|
"documentation": "https://github.com/smkrv/ha_text_ai",
|
||||||
"documentation": "https://github.com/smkrv/ha-text-ai",
|
"issue_tracker": "https://github.com/smkrv/ha_text_ai/issues",
|
||||||
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
"requirements": ["openai>=1.0.0"],
|
||||||
"requirements": ["openai>=1.0.0"],
|
"ssdp": [],
|
||||||
"ssdp": [],
|
"zeroconf": [],
|
||||||
"zeroconf": [],
|
"homekit": {},
|
||||||
"homekit": {},
|
"dependencies": [],
|
||||||
"dependencies": [],
|
"codeowners": ["@smkrv"],
|
||||||
"codeowners": ["@smkrv"],
|
"version": "1.0.1",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling"
|
||||||
"min_ha_version": "2023.8.0",
|
|
||||||
"icon": "images/icon.svg"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,34 +1,38 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"step": {
|
||||||
"already_configured": "Service is already configured"
|
"user": {
|
||||||
},
|
"title": "Set up HA text AI",
|
||||||
"error": {
|
"description": "Set up your OpenAI integration",
|
||||||
"cannot_connect": "Failed to connect",
|
"data": {
|
||||||
"invalid_api_key": "Invalid API key",
|
"api_key": "API Key",
|
||||||
"unknown": "Unexpected error"
|
"model": "Model",
|
||||||
},
|
"temperature": "Temperature",
|
||||||
"step": {
|
"max_tokens": "Max Tokens",
|
||||||
"user": {
|
"api_endpoint": "API Endpoint",
|
||||||
"data": {
|
"request_interval": "Request Interval (seconds)"
|
||||||
"api_key": "API Key",
|
|
||||||
"api_base": "API Base URL",
|
|
||||||
"request_interval": "Request interval (seconds)"
|
|
||||||
},
|
|
||||||
"description": "Enter your OpenAI API credentials",
|
|
||||||
"title": "OpenAI API"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"error": {
|
||||||
"step": {
|
"auth": "API key is invalid.",
|
||||||
"init": {
|
"cannot_connect": "Failed to connect to API.",
|
||||||
"data": {
|
"unknown": "Unexpected error occurred."
|
||||||
"request_interval": "Request interval (seconds)"
|
},
|
||||||
},
|
"abort": {
|
||||||
"description": "Configure HA Text AI options",
|
"already_configured": "Device is already configured"
|
||||||
"title": "HA Text AI Options"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "HA text AI Options",
|
||||||
|
"data": {
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"max_tokens": "Max Tokens",
|
||||||
|
"request_interval": "Request Interval (seconds)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "HA Text AI Integration",
|
"name": "HA text AI",
|
||||||
"content_in_root": false,
|
"render_readme": true,
|
||||||
"render_readme": true
|
"domains": ["sensor"],
|
||||||
|
"homeassistant": "2023.8.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
|
|
||||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
||||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
|
||||||
<rect x="8" y="14" width="8" height="5" rx="1"/>
|
|
||||||
<path d="M2 12h4"/>
|
|
||||||
<path d="M18 12h4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 451 B |
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user