mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 23:24:03 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5037622fb6 | ||
|
|
962a089bf4 | ||
|
|
4c56565b66 | ||
|
|
8432038f09 | ||
|
|
929d916d41 | ||
|
|
675975d951 | ||
|
|
524ec87395 | ||
|
|
94f8193996 | ||
|
|
7c3fcf73c3 | ||
|
|
20bbf89679 | ||
|
|
86dca52d07 | ||
|
|
78d1561e74 | ||
|
|
054e4af258 | ||
|
|
dc03faa97e | ||
|
|
9a7635c2ae | ||
|
|
df3d79c20c | ||
|
|
d6144be7ed | ||
|
|
9afbb904b3 | ||
|
|
93558b2444 | ||
|
|
9f93f1ee18 | ||
|
|
30a9b53ba1 | ||
|
|
5175970d55 | ||
|
|
f6bfbd4a07 | ||
|
|
4ddb0dc977 | ||
|
|
24dc4ac4d4 |
@@ -1,73 +1,81 @@
|
|||||||
# 🤖 HA Text AI for Home Assistant
|
# 🤖 HA Text AI for Home Assistant
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|
    [](https://opensource.org/licenses/MIT) [](https://github.com/hacs/integration)
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
[](https://github.com/hacs/integration)
|
|
||||||
[](https://community.home-assistant.io/t/ha-text-ai-integration)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Transform your smart home experience with powerful AI assistance powered by OpenAI's GPT models. Get intelligent responses, automate complex scenarios, and enhance your home automation with natural language processing.
|
Transform your smart home experience with powerful AI assistance powered by multiple AI providers including OpenAI GPT and Anthropic Claude models. Get intelligent responses, automate complex scenarios, and enhance your home automation with advanced natural language processing.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🧠 **Advanced AI Integration**:
|
- 🧠 **Multi-Provider AI Integration**:
|
||||||
- Support for latest GPT models
|
- Support for OpenAI GPT models
|
||||||
|
- Anthropic Claude integration
|
||||||
|
- Custom API endpoints
|
||||||
|
- Flexible model selection
|
||||||
|
- 💬 **Advanced Language Processing**:
|
||||||
- Context-aware responses
|
- Context-aware responses
|
||||||
- Multi-turn conversations
|
- Multi-turn conversations
|
||||||
- 💬 **Natural Language Control**:
|
- Custom system instructions
|
||||||
- Control devices using everyday language
|
|
||||||
- Get detailed explanations and recommendations
|
|
||||||
- Natural conversation flow
|
- Natural conversation flow
|
||||||
- 📝 **Smart Memory Management**:
|
- 📝 **Enhanced Memory Management**:
|
||||||
- Persistent conversation history
|
- Persistent conversation history
|
||||||
- Context-aware responses
|
- Context-aware responses
|
||||||
- Customizable history limits
|
- Customizable history limits
|
||||||
- ⚡ **Performance Optimized**:
|
- Model-specific filtering
|
||||||
|
- ⚡ **Performance Optimization**:
|
||||||
- Efficient token usage
|
- Efficient token usage
|
||||||
- Rate limit handling
|
- Smart rate limiting
|
||||||
- Response caching
|
- Response caching
|
||||||
|
- Request interval control
|
||||||
- 🎯 **Advanced Customization**:
|
- 🎯 **Advanced Customization**:
|
||||||
- Adjustable response parameters
|
- Per-request model selection
|
||||||
|
- Adjustable parameters
|
||||||
- Custom system prompts
|
- Custom system prompts
|
||||||
- Model selection per request
|
- Temperature control
|
||||||
- 🔒 **Enhanced Security**:
|
- 🔒 **Enhanced Security**:
|
||||||
- Secure API key storage
|
- Secure API key storage
|
||||||
- Rate limiting protection
|
- Rate limiting protection
|
||||||
- Error handling
|
- Error handling
|
||||||
- 🎨 **User Experience**:
|
- Usage monitoring
|
||||||
|
- 🎨 **Improved User Experience**:
|
||||||
- Intuitive configuration UI
|
- Intuitive configuration UI
|
||||||
- Detailed sensor attributes
|
- Detailed sensor attributes
|
||||||
- Rich service interface
|
- Rich service interface
|
||||||
|
- Model selection UI
|
||||||
- 🔄 **Automation Integration**:
|
- 🔄 **Automation Integration**:
|
||||||
- Event-driven responses
|
- Event-driven responses
|
||||||
- Conditional logic support
|
- Conditional logic support
|
||||||
- Template compatibility
|
- Template compatibility
|
||||||
|
- Model-specific automation
|
||||||
|
|
||||||
## 📋 Prerequisites
|
## 📋 Prerequisites
|
||||||
|
|
||||||
- Home Assistant 2023.8.0 or newer
|
- Home Assistant 2023.8.0 or newer
|
||||||
- OpenAI API key ([Get one here](https://platform.openai.com/account/api-keys))
|
- API key from supported providers:
|
||||||
|
- OpenAI ([Get key](https://platform.openai.com/account/api-keys))
|
||||||
|
- Anthropic ([Get key](https://console.anthropic.com/))
|
||||||
- Python 3.9 or newer
|
- Python 3.9 or newer
|
||||||
- Stable internet connection
|
- Stable internet connection
|
||||||
|
|
||||||
## ⚡ Installation
|
## ⚡ Installation
|
||||||
|
|
||||||
### HACS Installation (Recommended)
|
### HACS Installation (Recommended)
|
||||||
1. Open HACS in Home Assistant
|
1. Open HACS in Home Assistant
|
||||||
2. Click the "+" button
|
2. Click on the three dots in the top right corner
|
||||||
3. Search for "HA Text AI"
|
3. Select "Custom repositories"
|
||||||
4. Click "Install"
|
4. Add `https://github.com/smkrv/ha-text-ai` as Integration
|
||||||
5. Restart Home Assistant
|
5. Click "Add"
|
||||||
|
6. Click on "Integrations" in HACS
|
||||||
|
7. Search for "HA Text AI"
|
||||||
|
8. Click "Download"
|
||||||
|
9. Restart Home Assistant
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
1. Download the latest release
|
1. Download the latest release
|
||||||
@@ -86,12 +94,15 @@ Transform your smart home experience with powerful AI assistance powered by Open
|
|||||||
### Via YAML
|
### Via YAML
|
||||||
```yaml
|
```yaml
|
||||||
ha_text_ai:
|
ha_text_ai:
|
||||||
api_key: !secret openai_api_key
|
api_key: !secret ai_api_key
|
||||||
model: gpt-3.5-turbo
|
model: gpt-3.5-turbo # or claude-3-sonnet
|
||||||
temperature: 0.7
|
temperature: 0.7
|
||||||
max_tokens: 1000
|
max_tokens: 1000
|
||||||
request_interval: 1.0
|
request_interval: 1.0
|
||||||
api_endpoint: https://api.openai.com/v1 # optional
|
api_endpoint: https://api.openai.com/v1 # optional, for custom endpoints
|
||||||
|
system_prompt: |
|
||||||
|
You are a home automation expert assistant.
|
||||||
|
Focus on practical and efficient solutions.
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Available Services
|
## 🛠️ Available Services
|
||||||
@@ -101,9 +112,10 @@ ha_text_ai:
|
|||||||
service: ha_text_ai.ask_question
|
service: ha_text_ai.ask_question
|
||||||
data:
|
data:
|
||||||
question: "What's the optimal temperature for sleeping?"
|
question: "What's the optimal temperature for sleeping?"
|
||||||
model: "gpt-4o" # optional
|
model: "claude-3-sonnet" # optional
|
||||||
temperature: 0.5 # optional
|
temperature: 0.5 # optional
|
||||||
max_tokens: 500 # optional
|
max_tokens: 500 # optional
|
||||||
|
system_prompt: "You are a sleep optimization expert" # optional
|
||||||
```
|
```
|
||||||
|
|
||||||
### set_system_prompt
|
### set_system_prompt
|
||||||
@@ -128,114 +140,37 @@ service: ha_text_ai.clear_history
|
|||||||
service: ha_text_ai.get_history
|
service: ha_text_ai.get_history
|
||||||
data:
|
data:
|
||||||
limit: 5 # optional
|
limit: 5 # optional
|
||||||
|
filter_model: "gpt-4" # optional
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Advanced Examples
|
|
||||||
|
|
||||||
### Smart Energy Management
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
alias: "AI Energy Optimization"
|
|
||||||
trigger:
|
|
||||||
platform: time_pattern
|
|
||||||
hours: "/2"
|
|
||||||
action:
|
|
||||||
- service: ha_text_ai.ask_question
|
|
||||||
data:
|
|
||||||
question: >
|
|
||||||
Current power usage: {{ states('sensor.total_power') }}W
|
|
||||||
Temperature: {{ states('sensor.indoor_temperature') }}°C
|
|
||||||
Time: {{ now().strftime('%H:%M') }}
|
|
||||||
Occupancy: {{ states('binary_sensor.occupancy') }}
|
|
||||||
|
|
||||||
Analyze current energy usage and suggest optimizations
|
|
||||||
considering comfort and efficiency.
|
|
||||||
temperature: 0.3
|
|
||||||
max_tokens: 200
|
|
||||||
- service: notify.mobile_app
|
|
||||||
data:
|
|
||||||
message: "{{ states.sensor.ha_text_ai.attributes.response }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contextual Lighting Control
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
alias: "AI Lighting Assistant"
|
|
||||||
trigger:
|
|
||||||
platform: state
|
|
||||||
entity_id: binary_sensor.motion
|
|
||||||
variables:
|
|
||||||
context: >
|
|
||||||
Time: {{ now().strftime('%H:%M') }}
|
|
||||||
Light Level: {{ states('sensor.illuminance') }}
|
|
||||||
Room: {{ trigger.to_state.attributes.room }}
|
|
||||||
Activity: {{ states('input_select.current_activity') }}
|
|
||||||
Weather: {{ states('weather.home') }}
|
|
||||||
action:
|
|
||||||
- service: ha_text_ai.ask_question
|
|
||||||
data:
|
|
||||||
question: >
|
|
||||||
Based on this context:
|
|
||||||
{{ context }}
|
|
||||||
|
|
||||||
Suggest optimal lighting settings for current conditions.
|
|
||||||
model: gpt-3.5-turbo
|
|
||||||
temperature: 0.4
|
|
||||||
- service: scene.turn_on
|
|
||||||
data:
|
|
||||||
entity_id: >
|
|
||||||
{{ states.sensor.ha_text_ai.attributes.response | regex_findall('scene\.[a-z_]+') | first }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Performance Optimization
|
|
||||||
|
|
||||||
### Token Usage
|
|
||||||
- Use focused system prompts
|
|
||||||
- Implement response caching
|
|
||||||
- Clear history periodically
|
|
||||||
- Monitor token usage
|
|
||||||
|
|
||||||
### Response Time
|
|
||||||
- Adjust request_interval
|
|
||||||
- Use faster models for simple queries
|
|
||||||
- Implement timeout handling
|
|
||||||
- Cache frequent responses
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
- Set appropriate history limits
|
|
||||||
- Clear unused contexts
|
|
||||||
- Monitor memory usage
|
|
||||||
- Use efficient data structures
|
|
||||||
|
|
||||||
## ❗ Troubleshooting
|
|
||||||
|
|
||||||
### API Issues
|
|
||||||
- Verify API key validity
|
|
||||||
- Check rate limits
|
|
||||||
- Monitor usage quotas
|
|
||||||
- Test endpoint accessibility
|
|
||||||
|
|
||||||
### Performance Issues
|
|
||||||
- Reduce max_tokens
|
|
||||||
- Increase request_interval
|
|
||||||
- Clear conversation history
|
|
||||||
- Check network connectivity
|
|
||||||
|
|
||||||
### Integration Issues
|
|
||||||
- Verify HA version compatibility
|
|
||||||
- Check component dependencies
|
|
||||||
- Review log files
|
|
||||||
- Update configuration
|
|
||||||
|
|
||||||
## 📘 FAQ
|
## 📘 FAQ
|
||||||
|
|
||||||
|
**Q: Which AI providers are supported?**
|
||||||
|
|
||||||
|
A: Currently OpenAI (GPT models) and Anthropic (Claude models) are supported, with more providers planned.
|
||||||
|
|
||||||
**Q: How can I reduce API costs?**
|
**Q: How can I reduce API costs?**
|
||||||
|
|
||||||
|
A: Use GPT-3.5-Turbo or Claude-3-Sonnet for most queries, implement caching, and optimize token usage.
|
||||||
|
|
||||||
|
**Q: Can I use custom models?**
|
||||||
|
|
||||||
|
A: Yes, you can configure custom endpoints and use any compatible model by specifying it in the configuration.
|
||||||
|
|
||||||
|
**Q: How do I switch between different AI providers?**
|
||||||
|
|
||||||
|
A: Simply change the model parameter in your configuration or service calls to use the desired provider's model.
|
||||||
|
|
||||||
|
**Q: How can I reduce API costs?**
|
||||||
|
|
||||||
A: Use GPT-3.5-Turbo for most queries, implement caching, and optimize token usage.
|
A: Use GPT-3.5-Turbo for most queries, implement caching, and optimize token usage.
|
||||||
|
|
||||||
**Q: Is my data secure?**
|
**Q: Is my data secure?**
|
||||||
|
|
||||||
A: Yes, API keys are stored securely and data is transmitted via encrypted connections.
|
A: Yes, API keys are stored securely and data is transmitted via encrypted connections.
|
||||||
|
|
||||||
**Q: Can I use custom models?**
|
**Q: Can I use custom models?**
|
||||||
|
|
||||||
A: Yes, configure custom endpoints and models via configuration options.
|
A: Yes, configure custom endpoints and models via configuration options.
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
"""The HA Text AI integration."""
|
"""The HA Text AI integration."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Dict, Optional
|
||||||
|
import asyncio
|
||||||
|
import voluptuous as vol
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
from homeassistant.helpers import aiohttp_client
|
from homeassistant.helpers import aiohttp_client
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from async_timeout import timeout
|
||||||
|
|
||||||
|
from .coordinator import HATextAICoordinator
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
@@ -22,15 +28,94 @@ from .const import (
|
|||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
DEFAULT_API_ENDPOINT,
|
DEFAULT_API_ENDPOINT,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
|
API_VERSION,
|
||||||
|
API_MODELS_PATH,
|
||||||
|
API_CHAT_PATH,
|
||||||
|
API_TIMEOUT,
|
||||||
|
API_RETRY_COUNT,
|
||||||
|
API_BACKOFF_FACTOR,
|
||||||
|
LOGGER_NAME,
|
||||||
|
STATE_ERROR,
|
||||||
|
STATE_READY,
|
||||||
|
STATE_PROCESSING,
|
||||||
|
STATE_RATE_LIMITED,
|
||||||
|
STATE_MAINTENANCE,
|
||||||
|
STATE_DISCONNECTED,
|
||||||
|
STATE_RETRYING,
|
||||||
|
STATE_QUEUED,
|
||||||
|
STATE_UPDATING,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
|
EVENT_RESPONSE_RECEIVED,
|
||||||
|
EVENT_ERROR_OCCURRED,
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(LOGGER_NAME)
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
# Service validation schemas
|
||||||
|
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||||
|
vol.Required("question"): cv.string,
|
||||||
|
vol.Optional("system_prompt"): cv.string,
|
||||||
|
vol.Optional("model"): vol.In(SUPPORTED_MODELS),
|
||||||
|
vol.Optional("temperature"): vol.All(
|
||||||
|
vol.Coerce(float), vol.Range(min=0, max=2)
|
||||||
|
),
|
||||||
|
vol.Optional("max_tokens"): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=4096)
|
||||||
|
),
|
||||||
|
vol.Optional("priority"): vol.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||||
|
vol.Optional("limit", default=10): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=100)
|
||||||
|
),
|
||||||
|
vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS),
|
||||||
|
vol.Optional("start_date"): cv.datetime,
|
||||||
|
vol.Optional("include_metadata"): vol.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||||
|
vol.Required("prompt"): cv.string,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: bool = False) -> bool:
|
||||||
|
"""Check API availability for different providers."""
|
||||||
|
try:
|
||||||
|
if is_anthropic:
|
||||||
|
check_url = f"{endpoint}/v1/models"
|
||||||
|
else:
|
||||||
|
check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}"
|
||||||
|
|
||||||
|
async with timeout(API_TIMEOUT):
|
||||||
|
async with session.get(check_url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return True
|
||||||
|
elif response.status == 401:
|
||||||
|
raise ConfigEntryNotReady("Invalid API key")
|
||||||
|
elif response.status == 429:
|
||||||
|
_LOGGER.warning("Rate limit exceeded during API check")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
_LOGGER.error("API check failed with status: %d", response.status)
|
||||||
|
return False
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.error("API check error: %s", str(ex))
|
||||||
|
return False
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Set up the HA Text AI component."""
|
"""Set up the HA Text AI component."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {
|
||||||
|
"coordinators": {},
|
||||||
|
"metrics": {
|
||||||
|
"total_requests": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"errors": {},
|
||||||
|
"model_usage": {},
|
||||||
|
}
|
||||||
|
})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@@ -38,34 +123,198 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
try:
|
try:
|
||||||
session = aiohttp_client.async_get_clientsession(hass)
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
# Determine API type based on model
|
||||||
|
model = entry.data.get(CONF_MODEL, DEFAULT_MODEL)
|
||||||
|
is_anthropic = any(model.startswith("claude") for model in SUPPORTED_MODELS)
|
||||||
|
|
||||||
|
api_key = entry.data[CONF_API_KEY]
|
||||||
|
endpoint = entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT).rstrip('/')
|
||||||
|
|
||||||
|
# Configure headers based on API type
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_anthropic:
|
||||||
|
headers["x-api-key"] = api_key
|
||||||
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
# Check API with retries
|
||||||
|
for attempt in range(API_RETRY_COUNT):
|
||||||
|
if await async_check_api(session, endpoint, headers, is_anthropic):
|
||||||
|
break
|
||||||
|
if attempt < API_RETRY_COUNT - 1:
|
||||||
|
delay = API_BACKOFF_FACTOR * (2 ** attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise ConfigEntryNotReady("Failed to connect to API")
|
||||||
|
|
||||||
|
# Create and initialize coordinator
|
||||||
coordinator = HATextAICoordinator(
|
coordinator = HATextAICoordinator(
|
||||||
hass,
|
hass,
|
||||||
api_key=entry.data[CONF_API_KEY],
|
api_key=api_key,
|
||||||
endpoint=entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT),
|
endpoint=endpoint,
|
||||||
model=entry.data.get(CONF_MODEL, DEFAULT_MODEL),
|
model=model,
|
||||||
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
||||||
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
|
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
|
||||||
request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
|
request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
|
||||||
session=session,
|
session=session,
|
||||||
|
is_anthropic=is_anthropic
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Initialize coordinator
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except Exception as refresh_ex:
|
|
||||||
_LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex))
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not coordinator.last_update_success:
|
# Check coordinator status
|
||||||
_LOGGER.error("Failed to communicate with OpenAI API")
|
if coordinator.endpoint_status == "auth_error":
|
||||||
return False
|
raise ConfigEntryNotReady("Authentication failed")
|
||||||
|
elif coordinator.endpoint_status == "rate_limited":
|
||||||
|
_LOGGER.warning("API rate limited during setup")
|
||||||
|
elif coordinator.endpoint_status == "maintenance":
|
||||||
|
raise ConfigEntryNotReady("API is in maintenance mode")
|
||||||
|
elif coordinator.endpoint_status == "error":
|
||||||
|
raise ConfigEntryNotReady("API error during setup")
|
||||||
|
elif not coordinator.last_update_success:
|
||||||
|
raise ConfigEntryNotReady("Failed to initialize coordinator")
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
try:
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
except Exception as setup_ex:
|
# Register event handlers
|
||||||
_LOGGER.error("Failed to setup platforms: %s", str(setup_ex))
|
@callback
|
||||||
return False
|
def handle_state_change(event):
|
||||||
|
"""Handle state changes."""
|
||||||
|
if event.data.get("entity_id").startswith(f"{DOMAIN}."):
|
||||||
|
_LOGGER.debug("State changed: %s", event.data)
|
||||||
|
|
||||||
|
hass.bus.async_listen(EVENT_STATE_CHANGED, handle_state_change)
|
||||||
|
|
||||||
|
# Register services
|
||||||
|
async def async_ask_question(call: ServiceCall) -> None:
|
||||||
|
"""Handle the ask_question service call."""
|
||||||
|
question = call.data.get("question", "")
|
||||||
|
if not question:
|
||||||
|
_LOGGER.error("No question provided in service call")
|
||||||
|
return
|
||||||
|
|
||||||
|
request_params = {}
|
||||||
|
for param in ["system_prompt", "model", "temperature", "max_tokens"]:
|
||||||
|
if param in call.data:
|
||||||
|
request_params[param] = call.data[param]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await coordinator.async_ask_question(question, **request_params)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error asking question: %s", str(err))
|
||||||
|
|
||||||
|
async def async_clear_history(call: ServiceCall) -> None:
|
||||||
|
"""Handle the clear_history service call."""
|
||||||
|
try:
|
||||||
|
coordinator._responses.clear()
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
_LOGGER.info("History cleared successfully")
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error clearing history: %s", str(err))
|
||||||
|
|
||||||
|
async def async_get_history(call: ServiceCall) -> dict:
|
||||||
|
"""Handle the get_history service call."""
|
||||||
|
try:
|
||||||
|
limit = min(int(call.data.get("limit", 10)), 100)
|
||||||
|
filter_model = str(call.data.get("filter_model", ""))
|
||||||
|
start_date = call.data.get("start_date")
|
||||||
|
include_metadata = call.data.get("include_metadata", False)
|
||||||
|
|
||||||
|
responses = coordinator._responses
|
||||||
|
metrics = {
|
||||||
|
"total_requests": coordinator.request_count,
|
||||||
|
"total_tokens": coordinator.tokens_used,
|
||||||
|
"api_version": coordinator.api_version,
|
||||||
|
"endpoint_status": coordinator.endpoint_status,
|
||||||
|
"error_count": coordinator.error_count
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered_responses = responses.copy()
|
||||||
|
|
||||||
|
if filter_model:
|
||||||
|
filtered_responses = {
|
||||||
|
k: v for k, v in filtered_responses.items()
|
||||||
|
if v.get("model") == filter_model
|
||||||
|
}
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
filtered_responses = {
|
||||||
|
k: v for k, v in filtered_responses.items()
|
||||||
|
if v.get("timestamp") >= start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
if not include_metadata:
|
||||||
|
filtered_responses = {
|
||||||
|
k: {
|
||||||
|
"question": v["question"],
|
||||||
|
"response": v["response"],
|
||||||
|
"timestamp": v["timestamp"]
|
||||||
|
} for k, v in filtered_responses.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted_responses = dict(
|
||||||
|
sorted(
|
||||||
|
filtered_responses.items(),
|
||||||
|
key=lambda x: x[1]["timestamp"],
|
||||||
|
reverse=True
|
||||||
|
)[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metrics": metrics,
|
||||||
|
"responses": sorted_responses
|
||||||
|
}
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error getting history: %s", str(err))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||||
|
"""Handle the set_system_prompt service call."""
|
||||||
|
try:
|
||||||
|
prompt = str(call.data.get("prompt", "")).strip()
|
||||||
|
if prompt:
|
||||||
|
coordinator.system_prompt = prompt
|
||||||
|
_LOGGER.info("System prompt updated successfully")
|
||||||
|
else:
|
||||||
|
_LOGGER.error("Empty prompt provided")
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||||
|
|
||||||
|
# Register services with validation
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"ask_question",
|
||||||
|
async_ask_question,
|
||||||
|
schema=SERVICE_SCHEMA_ASK_QUESTION
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"clear_history",
|
||||||
|
async_clear_history
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"get_history",
|
||||||
|
async_get_history,
|
||||||
|
schema=SERVICE_SCHEMA_GET_HISTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"set_system_prompt",
|
||||||
|
async_set_system_prompt,
|
||||||
|
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Successfully set up HA Text AI with model: %s",
|
"Successfully set up HA Text AI with model: %s",
|
||||||
@@ -75,19 +324,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.exception("Unexpected error setting up entry: %s", str(ex))
|
_LOGGER.exception("Setup error: %s", str(ex))
|
||||||
return False
|
raise ConfigEntryNotReady from ex
|
||||||
|
|
||||||
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."""
|
||||||
try:
|
try:
|
||||||
if entry.entry_id not in hass.data.get(DOMAIN, {}):
|
coordinator = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
return True
|
if coordinator:
|
||||||
|
# Clear queue and history
|
||||||
|
coordinator._responses.clear()
|
||||||
|
while not coordinator._question_queue.empty():
|
||||||
|
try:
|
||||||
|
coordinator._question_queue.get_nowait()
|
||||||
|
coordinator._question_queue.task_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Close connection
|
||||||
|
await coordinator.async_shutdown()
|
||||||
|
|
||||||
|
# Remove services
|
||||||
|
for service in ["ask_question", "clear_history", "get_history", "set_system_prompt"]:
|
||||||
|
hass.services.async_remove(DOMAIN, service)
|
||||||
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
await coordinator.async_shutdown()
|
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
@@ -95,10 +358,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.exception("Error unloading entry: %s", str(ex))
|
_LOGGER.exception("Error unloading entry: %s", str(ex))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Reload config entry."""
|
"""Migrate old entry."""
|
||||||
try:
|
_LOGGER.debug("Migrating from version %s", entry.version)
|
||||||
await async_unload_entry(hass, entry)
|
|
||||||
await async_setup_entry(hass, entry)
|
if entry.version == 1:
|
||||||
except Exception as ex:
|
new = {**entry.data}
|
||||||
_LOGGER.exception("Error reloading entry: %s", str(ex))
|
|
||||||
|
# Migrate settings
|
||||||
|
if CONF_MODEL in new and new[CONF_MODEL] not in SUPPORTED_MODELS:
|
||||||
|
new[CONF_MODEL] = DEFAULT_MODEL
|
||||||
|
|
||||||
|
entry.version = 2
|
||||||
|
hass.config_entries.async_update_entry(entry, data=new)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
"""Config flow for HA text AI integration."""
|
"""Config flow for HA text AI integration."""
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
import ssl
|
|
||||||
import certifi
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from async_timeout import timeout
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from urllib.parse import urlparse
|
from async_timeout import timeout
|
||||||
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from openai import AsyncOpenAI
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from openai import OpenAIError, APIError, APIConnectionError, AuthenticationError, RateLimitError
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -27,124 +25,136 @@ from .const import (
|
|||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
DEFAULT_API_ENDPOINT,
|
DEFAULT_API_ENDPOINT,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
|
MIN_TEMPERATURE,
|
||||||
|
MAX_TEMPERATURE,
|
||||||
|
MIN_MAX_TOKENS,
|
||||||
|
MAX_MAX_TOKENS,
|
||||||
|
MIN_REQUEST_INTERVAL,
|
||||||
|
API_VERSION,
|
||||||
|
API_MODELS_PATH,
|
||||||
|
ERROR_INVALID_API_KEY,
|
||||||
|
ERROR_CANNOT_CONNECT,
|
||||||
|
ERROR_UNKNOWN,
|
||||||
|
ERROR_INVALID_MODEL,
|
||||||
|
ERROR_RATE_LIMIT,
|
||||||
|
ERROR_API_ERROR,
|
||||||
|
ERROR_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Create SSL context at module level
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
{
|
||||||
|
vol.Required(CONF_API_KEY): cv.string,
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema({
|
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
|
||||||
vol.Required(CONF_API_KEY): str,
|
vol.Optional(
|
||||||
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str,
|
CONF_TEMPERATURE,
|
||||||
vol.Optional(
|
default=DEFAULT_TEMPERATURE
|
||||||
CONF_TEMPERATURE,
|
): vol.All(
|
||||||
default=DEFAULT_TEMPERATURE
|
vol.Coerce(float),
|
||||||
): vol.All(
|
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||||
vol.Coerce(float),
|
),
|
||||||
vol.Range(min=0, max=2)
|
vol.Optional(
|
||||||
),
|
CONF_MAX_TOKENS,
|
||||||
vol.Optional(
|
default=DEFAULT_MAX_TOKENS
|
||||||
CONF_MAX_TOKENS,
|
): vol.All(
|
||||||
default=DEFAULT_MAX_TOKENS
|
vol.Coerce(int),
|
||||||
): vol.All(
|
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||||
vol.Coerce(int),
|
),
|
||||||
vol.Range(min=1, max=4096)
|
vol.Optional(
|
||||||
),
|
CONF_API_ENDPOINT,
|
||||||
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str,
|
default=DEFAULT_API_ENDPOINT
|
||||||
vol.Optional(
|
): cv.string,
|
||||||
CONF_REQUEST_INTERVAL,
|
vol.Optional(
|
||||||
default=DEFAULT_REQUEST_INTERVAL
|
CONF_REQUEST_INTERVAL,
|
||||||
): vol.All(
|
default=DEFAULT_REQUEST_INTERVAL
|
||||||
vol.Coerce(float),
|
): vol.All(
|
||||||
vol.Range(min=0.1)
|
vol.Coerce(float),
|
||||||
),
|
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||||
})
|
),
|
||||||
|
}
|
||||||
async def validate_endpoint(endpoint: str) -> Tuple[bool, str]:
|
)
|
||||||
"""Validate API endpoint accessibility."""
|
|
||||||
try:
|
|
||||||
parsed_url = urlparse(endpoint)
|
|
||||||
if parsed_url.scheme not in ('http', 'https'):
|
|
||||||
return False, "invalid_endpoint_scheme"
|
|
||||||
|
|
||||||
connector = aiohttp.TCPConnector(ssl=SSL_CONTEXT)
|
|
||||||
async with timeout(5):
|
|
||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
|
||||||
async with session.get(endpoint) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
return False, "endpoint_not_available"
|
|
||||||
return True, ""
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.error("Error validating endpoint: %s", str(e))
|
|
||||||
return False, "endpoint_error"
|
|
||||||
|
|
||||||
async def validate_api_connection(
|
async def validate_api_connection(
|
||||||
|
hass,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
model: str,
|
model: str,
|
||||||
retry_count: int = 3,
|
retry_count: int = 3,
|
||||||
retry_delay: float = 1.0
|
retry_delay: float = 1.0
|
||||||
) -> Tuple[bool, str, list]:
|
) -> Tuple[bool, str, list]:
|
||||||
"""Validate API connection with improved retry logic."""
|
"""Validate API connection with retry logic."""
|
||||||
# Validate endpoint first
|
session = async_get_clientsession(hass)
|
||||||
endpoint_valid, endpoint_error = await validate_endpoint(endpoint)
|
headers = {
|
||||||
if not endpoint_valid:
|
"Authorization": f"Bearer {api_key}",
|
||||||
return False, endpoint_error, []
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Accept": "application/json; charset=utf-8",
|
||||||
|
"Accept-Charset": "utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
connector = aiohttp.TCPConnector(ssl=SSL_CONTEXT)
|
base_url = endpoint.rstrip('/')
|
||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
if not base_url.endswith(f"/{API_VERSION}"):
|
||||||
for attempt in range(retry_count):
|
base_url = f"{base_url}/{API_VERSION}"
|
||||||
try:
|
|
||||||
async with timeout(10):
|
|
||||||
client = AsyncOpenAI(
|
|
||||||
api_key=api_key,
|
|
||||||
base_url=endpoint,
|
|
||||||
http_client=session
|
|
||||||
)
|
|
||||||
|
|
||||||
models = await client.models.list()
|
models_url = f"{base_url}/{API_MODELS_PATH}"
|
||||||
model_ids = [model.id for model in models.data]
|
_LOGGER.debug("Attempting to connect to: %s", models_url)
|
||||||
|
|
||||||
if model not in model_ids:
|
for attempt in range(retry_count):
|
||||||
_LOGGER.warning(
|
try:
|
||||||
"Model %s not found in available models: %s",
|
async with timeout(10):
|
||||||
model,
|
async with session.get(models_url, headers=headers) as response:
|
||||||
", ".join(model_ids)
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
model_ids = [m["id"] for m in data.get("data", [])]
|
||||||
|
|
||||||
|
_LOGGER.debug("Available models: %s", ", ".join(model_ids))
|
||||||
|
|
||||||
|
if model not in model_ids:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Model %s not found in available models: %s",
|
||||||
|
model,
|
||||||
|
", ".join(model_ids)
|
||||||
|
)
|
||||||
|
return False, ERROR_INVALID_MODEL, model_ids
|
||||||
|
return True, "", model_ids
|
||||||
|
|
||||||
|
elif response.status == 401:
|
||||||
|
_LOGGER.error("Authentication failed")
|
||||||
|
return False, ERROR_INVALID_API_KEY, []
|
||||||
|
|
||||||
|
elif response.status == 429:
|
||||||
|
_LOGGER.error("Rate limit exceeded")
|
||||||
|
return False, ERROR_RATE_LIMIT, []
|
||||||
|
|
||||||
|
else:
|
||||||
|
response_text = await response.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"API error: %s - %s",
|
||||||
|
response.status,
|
||||||
|
response_text
|
||||||
)
|
)
|
||||||
return False, "invalid_model", model_ids
|
return False, ERROR_API_ERROR, []
|
||||||
return True, "", model_ids
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Timeout during API validation (attempt %d/%d)",
|
"Timeout during API validation (attempt %d/%d)",
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
retry_count
|
retry_count
|
||||||
)
|
)
|
||||||
if attempt == retry_count - 1:
|
if attempt == retry_count - 1:
|
||||||
return False, "timeout", []
|
return False, ERROR_TIMEOUT, []
|
||||||
await asyncio.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
|
|
||||||
except AuthenticationError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error("Authentication error: %s", str(err))
|
_LOGGER.error("Connection error: %s", str(err))
|
||||||
return False, "invalid_auth", []
|
return False, ERROR_CANNOT_CONNECT, []
|
||||||
|
|
||||||
except RateLimitError as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Rate limit exceeded: %s", str(err))
|
_LOGGER.exception("Unexpected error during validation: %s", str(err))
|
||||||
return False, "rate_limit", []
|
return False, ERROR_UNKNOWN, []
|
||||||
|
|
||||||
except APIConnectionError as err:
|
return False, ERROR_UNKNOWN, []
|
||||||
_LOGGER.error("API connection error: %s", str(err))
|
|
||||||
return False, "cannot_connect", []
|
|
||||||
|
|
||||||
except APIError as err:
|
|
||||||
_LOGGER.error("API error: %s", str(err))
|
|
||||||
return False, "api_error", []
|
|
||||||
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.exception("Unexpected error during validation: %s", str(err))
|
|
||||||
return False, "unknown", []
|
|
||||||
|
|
||||||
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for HA text AI."""
|
"""Handle a config flow for HA text AI."""
|
||||||
@@ -154,13 +164,12 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self,
|
self,
|
||||||
user_input: Optional[Dict[str, Any]] = None
|
user_input: Optional[Dict[str, Any]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> FlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors: Dict[str, str] = {}
|
errors: Dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
# Validate URL format
|
|
||||||
endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
|
endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
|
||||||
try:
|
try:
|
||||||
result = urlparse(endpoint)
|
result = urlparse(endpoint)
|
||||||
@@ -180,29 +189,29 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors
|
errors=errors
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate input data
|
validated_input = STEP_USER_DATA_SCHEMA(user_input)
|
||||||
user_input = STEP_USER_DATA_SCHEMA(user_input)
|
|
||||||
|
|
||||||
is_valid, error_code, available_models = await validate_api_connection(
|
is_valid, error_code, available_models = await validate_api_connection(
|
||||||
user_input[CONF_API_KEY],
|
self.hass,
|
||||||
|
validated_input[CONF_API_KEY],
|
||||||
endpoint,
|
endpoint,
|
||||||
user_input[CONF_MODEL]
|
validated_input[CONF_MODEL]
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
await self.async_set_unique_id(user_input[CONF_API_KEY])
|
await self.async_set_unique_id(validated_input[CONF_API_KEY])
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="HA text AI",
|
title="HA Text AI",
|
||||||
data=user_input
|
data=validated_input
|
||||||
)
|
)
|
||||||
|
|
||||||
errors["base"] = error_code
|
errors["base"] = error_code
|
||||||
if error_code == "invalid_model":
|
if error_code == ERROR_INVALID_MODEL:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Selected model %s not found in available models: %s",
|
"Selected model %s not found in available models: %s",
|
||||||
user_input[CONF_MODEL],
|
validated_input[CONF_MODEL],
|
||||||
", ".join(available_models)
|
", ".join(available_models)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,7 +247,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
self,
|
self,
|
||||||
user_input: Optional[Dict[str, Any]] = None
|
user_input: Optional[Dict[str, Any]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> FlowResult:
|
||||||
"""Handle options flow."""
|
"""Handle options flow."""
|
||||||
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)
|
||||||
@@ -249,30 +258,27 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
default=self.config_entry.options.get(
|
default=self.config_entry.options.get(
|
||||||
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
|
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
|
||||||
),
|
),
|
||||||
description={"suggested_value": DEFAULT_TEMPERATURE},
|
|
||||||
): vol.All(
|
): vol.All(
|
||||||
vol.Coerce(float),
|
vol.Coerce(float),
|
||||||
vol.Range(min=0, max=2)
|
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_MAX_TOKENS,
|
CONF_MAX_TOKENS,
|
||||||
default=self.config_entry.options.get(
|
default=self.config_entry.options.get(
|
||||||
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
|
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
|
||||||
),
|
),
|
||||||
description={"suggested_value": DEFAULT_MAX_TOKENS},
|
|
||||||
): vol.All(
|
): vol.All(
|
||||||
vol.Coerce(int),
|
vol.Coerce(int),
|
||||||
vol.Range(min=1, max=4096)
|
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_REQUEST_INTERVAL,
|
CONF_REQUEST_INTERVAL,
|
||||||
default=self.config_entry.options.get(
|
default=self.config_entry.options.get(
|
||||||
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
|
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
|
||||||
),
|
),
|
||||||
description={"suggested_value": DEFAULT_REQUEST_INTERVAL},
|
|
||||||
): vol.All(
|
): vol.All(
|
||||||
vol.Coerce(float),
|
vol.Coerce(float),
|
||||||
vol.Range(min=0.1)
|
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,27 @@ CONF_MAX_TOKENS: Final = "max_tokens"
|
|||||||
CONF_API_ENDPOINT: Final = "api_endpoint"
|
CONF_API_ENDPOINT: Final = "api_endpoint"
|
||||||
CONF_REQUEST_INTERVAL: Final = "request_interval"
|
CONF_REQUEST_INTERVAL: Final = "request_interval"
|
||||||
|
|
||||||
|
# Model constants
|
||||||
|
SUPPORTED_MODELS: Final = [
|
||||||
|
"gpt-3.5-turbo",
|
||||||
|
"gpt-3.5-turbo-16k",
|
||||||
|
"gpt-4",
|
||||||
|
"gpt-4-32k",
|
||||||
|
"gpt-4-1106-preview",
|
||||||
|
"claude-3-sonnet",
|
||||||
|
"claude-3-opus"
|
||||||
|
]
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULT_MODEL: Final = "gpt-3.5-turbo"
|
DEFAULT_MODEL: Final = "gpt-3.5-turbo"
|
||||||
DEFAULT_TEMPERATURE: Final = 0.7
|
DEFAULT_TEMPERATURE: Final = 0.1
|
||||||
DEFAULT_MAX_TOKENS: Final = 1000
|
DEFAULT_MAX_TOKENS: Final = 1000
|
||||||
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1"
|
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com"
|
||||||
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
||||||
DEFAULT_TIMEOUT: Final = 30
|
DEFAULT_TIMEOUT: Final = 30
|
||||||
DEFAULT_QUEUE_SIZE: Final = 100
|
DEFAULT_QUEUE_SIZE: Final = 100
|
||||||
DEFAULT_HISTORY_LIMIT: Final = 50
|
DEFAULT_HISTORY_LIMIT: Final = 50
|
||||||
|
DEFAULT_RETRY_COUNT: Final = 3
|
||||||
|
|
||||||
# Parameter constraints
|
# Parameter constraints
|
||||||
MIN_TEMPERATURE: Final = 0.0
|
MIN_TEMPERATURE: Final = 0.0
|
||||||
@@ -31,6 +43,23 @@ MAX_MAX_TOKENS: Final = 4096
|
|||||||
MIN_REQUEST_INTERVAL: Final = 0.1
|
MIN_REQUEST_INTERVAL: Final = 0.1
|
||||||
MIN_TIMEOUT: Final = 5
|
MIN_TIMEOUT: Final = 5
|
||||||
MAX_TIMEOUT: Final = 120
|
MAX_TIMEOUT: Final = 120
|
||||||
|
MAX_PROMPT_LENGTH: Final = 1000
|
||||||
|
MAX_HISTORY_LIMIT: Final = 100
|
||||||
|
|
||||||
|
# API constants
|
||||||
|
API_VERSION: Final = "v1"
|
||||||
|
API_MODELS_PATH: Final = "models"
|
||||||
|
API_CHAT_PATH: Final = "chat/completions"
|
||||||
|
API_TIMEOUT: Final = 30
|
||||||
|
API_RETRY_COUNT: Final = 3
|
||||||
|
API_BACKOFF_FACTOR: Final = 1.5
|
||||||
|
API_MAX_RETRIES: Final = 3
|
||||||
|
|
||||||
|
# History constants
|
||||||
|
HISTORY_FILTER_MODEL: Final = "filter_model"
|
||||||
|
HISTORY_FILTER_DATE: Final = "start_date"
|
||||||
|
HISTORY_SORT_ORDER: Final = "sort_order"
|
||||||
|
HISTORY_INCLUDE_METADATA: Final = "include_metadata"
|
||||||
|
|
||||||
# Service names
|
# Service names
|
||||||
SERVICE_ASK_QUESTION: Final = "ask_question"
|
SERVICE_ASK_QUESTION: Final = "ask_question"
|
||||||
@@ -58,6 +87,13 @@ ATTR_QUEUE_SIZE: Final = "queue_size"
|
|||||||
ATTR_API_STATUS: Final = "api_status"
|
ATTR_API_STATUS: Final = "api_status"
|
||||||
ATTR_ERROR_COUNT: Final = "error_count"
|
ATTR_ERROR_COUNT: Final = "error_count"
|
||||||
ATTR_LAST_ERROR: Final = "last_error"
|
ATTR_LAST_ERROR: Final = "last_error"
|
||||||
|
ATTR_API_VERSION: Final = "api_version"
|
||||||
|
ATTR_ENDPOINT_STATUS: Final = "endpoint_status"
|
||||||
|
ATTR_REQUEST_COUNT: Final = "request_count"
|
||||||
|
ATTR_TOKENS_USED: Final = "tokens_used"
|
||||||
|
ATTR_RETRY_COUNT: Final = "retry_count"
|
||||||
|
ATTR_QUEUE_POSITION: Final = "queue_position"
|
||||||
|
ATTR_ESTIMATED_WAIT: Final = "estimated_wait"
|
||||||
|
|
||||||
# Error messages
|
# Error messages
|
||||||
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
||||||
@@ -70,6 +106,8 @@ ERROR_API_ERROR: Final = "api_error"
|
|||||||
ERROR_TIMEOUT: Final = "timeout_error"
|
ERROR_TIMEOUT: Final = "timeout_error"
|
||||||
ERROR_QUEUE_FULL: Final = "queue_full"
|
ERROR_QUEUE_FULL: Final = "queue_full"
|
||||||
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
|
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
|
||||||
|
ERROR_INVALID_PARAMETERS: Final = "invalid_parameters"
|
||||||
|
ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable"
|
||||||
|
|
||||||
# Configuration descriptions
|
# Configuration descriptions
|
||||||
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
|
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
|
||||||
@@ -92,12 +130,18 @@ ATTR_QUEUE_SIZE_DESCRIPTION: Final = "Current size of question queue"
|
|||||||
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
|
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
|
||||||
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
|
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
|
||||||
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
|
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
|
||||||
|
ATTR_API_VERSION_DESCRIPTION: Final = "Current API version"
|
||||||
|
ATTR_ENDPOINT_STATUS_DESCRIPTION: Final = "Current endpoint status"
|
||||||
|
ATTR_REQUEST_COUNT_DESCRIPTION: Final = "Total number of API requests"
|
||||||
|
ATTR_TOKENS_USED_DESCRIPTION: Final = "Total tokens used"
|
||||||
|
|
||||||
# Entity attributes
|
# Entity attributes
|
||||||
ENTITY_NAME: Final = "HA Text AI"
|
ENTITY_NAME: Final = "HA Text AI"
|
||||||
ENTITY_ICON: Final = "mdi:robot"
|
ENTITY_ICON: Final = "mdi:robot"
|
||||||
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
|
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
|
||||||
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
|
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
|
||||||
|
ENTITY_ICON_OFFLINE: Final = "mdi:robot-off"
|
||||||
|
ENTITY_ICON_QUEUE: Final = "mdi:robot-confused"
|
||||||
|
|
||||||
# Translation keys
|
# Translation keys
|
||||||
TRANSLATION_KEY_CONFIG: Final = "config"
|
TRANSLATION_KEY_CONFIG: Final = "config"
|
||||||
@@ -113,6 +157,10 @@ STATE_ERROR: Final = "error"
|
|||||||
STATE_DISCONNECTED: Final = "disconnected"
|
STATE_DISCONNECTED: Final = "disconnected"
|
||||||
STATE_RATE_LIMITED: Final = "rate_limited"
|
STATE_RATE_LIMITED: Final = "rate_limited"
|
||||||
STATE_INITIALIZING: Final = "initializing"
|
STATE_INITIALIZING: Final = "initializing"
|
||||||
|
STATE_MAINTENANCE: Final = "maintenance"
|
||||||
|
STATE_RETRYING: Final = "retrying"
|
||||||
|
STATE_QUEUED: Final = "queued"
|
||||||
|
STATE_UPDATING: Final = "updating"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOGGER_NAME: Final = "custom_components.ha_text_ai"
|
LOGGER_NAME: Final = "custom_components.ha_text_ai"
|
||||||
@@ -122,11 +170,6 @@ LOG_LEVEL_DEFAULT: Final = "INFO"
|
|||||||
QUEUE_TIMEOUT: Final = 5
|
QUEUE_TIMEOUT: Final = 5
|
||||||
QUEUE_MAX_SIZE: Final = 100
|
QUEUE_MAX_SIZE: Final = 100
|
||||||
|
|
||||||
# API constants
|
|
||||||
API_TIMEOUT: Final = 30
|
|
||||||
API_RETRY_COUNT: Final = 3
|
|
||||||
API_BACKOFF_FACTOR: Final = 1.5
|
|
||||||
|
|
||||||
# Service schema constants
|
# Service schema constants
|
||||||
SCHEMA_QUESTION: Final = "question"
|
SCHEMA_QUESTION: Final = "question"
|
||||||
SCHEMA_MODEL: Final = "model"
|
SCHEMA_MODEL: Final = "model"
|
||||||
|
|||||||
@@ -2,14 +2,24 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, List
|
||||||
|
import time
|
||||||
|
|
||||||
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
|
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
import async_timeout
|
import async_timeout
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
MAX_QUEUE_SIZE,
|
||||||
|
MAX_RETRIES,
|
||||||
|
RETRY_DELAY,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,8 +36,21 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
request_interval: float,
|
request_interval: float,
|
||||||
session: Optional[Any] = None,
|
session: Optional[Any] = None,
|
||||||
|
is_anthropic: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize coordinator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hass: HomeAssistant instance
|
||||||
|
api_key: API key for the service
|
||||||
|
endpoint: API endpoint URL
|
||||||
|
model: Model name to use
|
||||||
|
temperature: Temperature parameter for generation
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
request_interval: Interval between requests
|
||||||
|
session: Optional session object
|
||||||
|
is_anthropic: Whether to use Anthropic API
|
||||||
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
@@ -42,18 +65,36 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.temperature = float(temperature)
|
self.temperature = float(temperature)
|
||||||
self.max_tokens = int(max_tokens)
|
self.max_tokens = int(max_tokens)
|
||||||
self._question_queue = asyncio.Queue()
|
self._question_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
|
||||||
self._responses: Dict[str, Any] = {}
|
self._responses: Dict[str, Any] = {}
|
||||||
self.system_prompt: Optional[str] = None
|
self.system_prompt: Optional[str] = None
|
||||||
self._is_ready = False
|
self._is_ready = False
|
||||||
|
self._is_processing = False
|
||||||
|
self._is_rate_limited = False
|
||||||
|
self._is_maintenance = False
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self._MAX_ERRORS = 3
|
self._MAX_ERRORS = 3
|
||||||
|
self._request_count = 0
|
||||||
|
self._tokens_used = 0
|
||||||
|
self._api_version = "v1"
|
||||||
|
self._endpoint_status = "disconnected"
|
||||||
|
self._performance_metrics: Dict[str, Any] = {
|
||||||
|
"avg_response_time": 0,
|
||||||
|
"total_errors": 0,
|
||||||
|
"success_rate": 100,
|
||||||
|
"requests_per_minute": 0,
|
||||||
|
}
|
||||||
|
self._last_request_time = 0
|
||||||
|
self._is_anthropic = is_anthropic
|
||||||
|
|
||||||
self.client = AsyncOpenAI(
|
if is_anthropic:
|
||||||
api_key=self.api_key,
|
self.client = AsyncAnthropic(api_key=self.api_key)
|
||||||
base_url=self.endpoint,
|
else:
|
||||||
http_client=session,
|
self.client = AsyncOpenAI(
|
||||||
)
|
api_key=self.api_key,
|
||||||
|
base_url=self.endpoint,
|
||||||
|
http_client=session,
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
|
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
|
||||||
"""Validate initialization parameters."""
|
"""Validate initialization parameters."""
|
||||||
@@ -65,28 +106,59 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
raise ValueError("Max tokens must be a positive integer")
|
raise ValueError("Max tokens must be a positive integer")
|
||||||
|
|
||||||
async def _async_update_data(self) -> Dict[str, Any]:
|
async def _async_update_data(self) -> Dict[str, Any]:
|
||||||
"""Update data via OpenAI API."""
|
"""Update data via API."""
|
||||||
if self._question_queue.empty():
|
if self._question_queue.empty():
|
||||||
return self._responses
|
return self._responses
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(30):
|
async with async_timeout.timeout(DEFAULT_TIMEOUT):
|
||||||
question = await self._question_queue.get()
|
self._is_processing = True
|
||||||
|
priority, question_data = await self._question_queue.get()
|
||||||
|
question = question_data["question"]
|
||||||
|
params = question_data["params"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_content = await self._make_api_call(question)
|
response_data = await self._make_api_call(
|
||||||
|
question,
|
||||||
|
model=params.get("model"),
|
||||||
|
temperature=params.get("temperature"),
|
||||||
|
max_tokens=params.get("max_tokens"),
|
||||||
|
system_prompt=params.get("system_prompt")
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_metrics(response_data)
|
||||||
self._responses[question] = {
|
self._responses[question] = {
|
||||||
"question": question,
|
"question": question,
|
||||||
"response": response_content,
|
"response": response_data["response"],
|
||||||
"error": None,
|
"error": None,
|
||||||
"timestamp": self.hass.loop.time()
|
"timestamp": dt_util.utcnow(),
|
||||||
|
"model": response_data["model"],
|
||||||
|
"temperature": params.get("temperature", self.temperature),
|
||||||
|
"max_tokens": params.get("max_tokens", self.max_tokens),
|
||||||
|
"response_time": response_data.get("response_time"),
|
||||||
|
"tokens": response_data.get("tokens", 0),
|
||||||
|
"priority": priority
|
||||||
}
|
}
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self._is_ready = True
|
self._is_ready = True
|
||||||
|
self._endpoint_status = "connected"
|
||||||
|
self._request_count += 1
|
||||||
|
self._tokens_used += response_data.get("tokens", 0)
|
||||||
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
# Fire event for successful response
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_response_received", {
|
||||||
|
"question": question,
|
||||||
|
"model": response_data["model"],
|
||||||
|
"tokens": response_data.get("tokens", 0)
|
||||||
|
})
|
||||||
|
|
||||||
_LOGGER.debug("Response received for question: %s", question)
|
_LOGGER.debug("Response received for question: %s", question)
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
self._handle_api_error(question, err)
|
await self._handle_api_error(question, err)
|
||||||
finally:
|
finally:
|
||||||
|
self._is_processing = False
|
||||||
self._question_queue.task_done()
|
self._question_queue.task_done()
|
||||||
|
|
||||||
return self._responses
|
return self._responses
|
||||||
@@ -96,26 +168,47 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
await self._handle_timeout_error()
|
await self._handle_timeout_error()
|
||||||
return self._responses
|
return self._responses
|
||||||
|
|
||||||
def _handle_api_error(self, question: str, error: Exception) -> None:
|
async def _handle_api_error(self, question: str, error: Exception) -> None:
|
||||||
"""Handle API errors."""
|
"""Handle API errors with retry logic."""
|
||||||
self._error_count += 1
|
self._error_count += 1
|
||||||
|
self._performance_metrics["total_errors"] += 1
|
||||||
error_msg = str(error)
|
error_msg = str(error)
|
||||||
|
|
||||||
if isinstance(error, AuthenticationError):
|
if isinstance(error, AuthenticationError):
|
||||||
error_msg = "Authentication failed - invalid API key"
|
error_msg = "Authentication failed - invalid API key"
|
||||||
self._is_ready = False
|
self._is_ready = False
|
||||||
|
self._endpoint_status = "auth_error"
|
||||||
elif isinstance(error, RateLimitError):
|
elif isinstance(error, RateLimitError):
|
||||||
error_msg = "Rate limit exceeded"
|
error_msg = "Rate limit exceeded"
|
||||||
|
self._is_rate_limited = True
|
||||||
|
self._endpoint_status = "rate_limited"
|
||||||
|
# Implement exponential backoff
|
||||||
|
await asyncio.sleep(RETRY_DELAY * (2 ** (self._error_count - 1)))
|
||||||
elif isinstance(error, APIError):
|
elif isinstance(error, APIError):
|
||||||
|
if "maintenance" in str(error).lower():
|
||||||
|
self._is_maintenance = True
|
||||||
|
self._endpoint_status = "maintenance"
|
||||||
error_msg = f"API error: {error}"
|
error_msg = f"API error: {error}"
|
||||||
|
else:
|
||||||
|
self._endpoint_status = "error"
|
||||||
|
|
||||||
self._responses[question] = {
|
self._responses[question] = {
|
||||||
"question": question,
|
"question": question,
|
||||||
"response": None,
|
"response": None,
|
||||||
"error": error_msg,
|
"error": error_msg,
|
||||||
"timestamp": self.hass.loop.time()
|
"timestamp": dt_util.utcnow(),
|
||||||
|
"model": self.model,
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"max_tokens": self.max_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fire error event
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_error_occurred", {
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"error_message": error_msg,
|
||||||
|
"question": question
|
||||||
|
})
|
||||||
|
|
||||||
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
|
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
|
||||||
|
|
||||||
if self._error_count >= self._MAX_ERRORS:
|
if self._error_count >= self._MAX_ERRORS:
|
||||||
@@ -124,71 +217,402 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self._error_count
|
self._error_count
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_timeout_error(self) -> None:
|
def _update_metrics(self, response_data: Dict[str, Any]) -> None:
|
||||||
"""Handle timeout errors."""
|
"""Update performance metrics."""
|
||||||
self._error_count += 1
|
response_time = response_data.get("response_time", 0)
|
||||||
if not self._question_queue.empty():
|
current_avg = self._performance_metrics["avg_response_time"]
|
||||||
try:
|
self._performance_metrics["avg_response_time"] = (
|
||||||
# Clear the queue if we have timeout issues
|
(current_avg * self._request_count + response_time) /
|
||||||
while not self._question_queue.empty():
|
(self._request_count + 1)
|
||||||
self._question_queue.get_nowait()
|
)
|
||||||
self._question_queue.task_done()
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.error("Error clearing question queue: %s", err)
|
|
||||||
|
|
||||||
async def _make_api_call(self, question: str) -> str:
|
total_requests = self._request_count + 1
|
||||||
"""Make API call to OpenAI."""
|
self._performance_metrics["success_rate"] = (
|
||||||
|
(total_requests - self._performance_metrics["total_errors"]) /
|
||||||
|
total_requests * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate requests per minute
|
||||||
|
if self._last_request_time:
|
||||||
|
time_diff = time.time() - self._last_request_time
|
||||||
|
if time_diff > 0:
|
||||||
|
self._performance_metrics["requests_per_minute"] = 60 / time_diff
|
||||||
|
|
||||||
|
async def _make_api_call(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to the selected service."""
|
||||||
try:
|
try:
|
||||||
messages = []
|
start_time = dt_util.utcnow()
|
||||||
if self.system_prompt:
|
|
||||||
messages.append({"role": "system", "content": self.system_prompt})
|
|
||||||
messages.append({"role": "user", "content": question})
|
|
||||||
|
|
||||||
completion = await self.client.chat.completions.create(
|
if self._is_anthropic:
|
||||||
model=self.model,
|
response = await self._make_anthropic_call(
|
||||||
messages=messages,
|
question, model, temperature, max_tokens, system_prompt
|
||||||
temperature=self.temperature,
|
)
|
||||||
max_tokens=self.max_tokens,
|
else:
|
||||||
)
|
response = await self._make_openai_call(
|
||||||
return completion.choices[0].message.content
|
question, model, temperature, max_tokens, system_prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
response_time = (dt_util.utcnow() - start_time).total_seconds()
|
||||||
|
|
||||||
|
return {
|
||||||
|
**response,
|
||||||
|
"response_time": response_time
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error in API call: %s", err)
|
_LOGGER.error("Error in API call: %s", err)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def async_ask_question(self, question: str) -> None:
|
async def _make_anthropic_call(
|
||||||
"""Add question to queue."""
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str],
|
||||||
|
temperature: Optional[float],
|
||||||
|
max_tokens: Optional[int],
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to Anthropic."""
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
completion = await self.client.messages.create(
|
||||||
|
model=model or self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature if temperature is not None else self.temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": completion.content[0].text,
|
||||||
|
"model": completion.model,
|
||||||
|
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _make_openai_call(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str],
|
||||||
|
temperature: Optional[float],
|
||||||
|
max_tokens: Optional[int],
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to OpenAI."""
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
completion = await self.client.chat.completions.create(
|
||||||
|
model=model or self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature if temperature is not None else self.temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": completion.choices[0].message.content,
|
||||||
|
"model": completion.model,
|
||||||
|
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_ask_question(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
priority: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Add question to queue with priority support."""
|
||||||
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
|
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
|
||||||
_LOGGER.warning("Coordinator is not ready due to previous errors")
|
_LOGGER.warning("Coordinator is not ready due to previous errors")
|
||||||
return
|
return
|
||||||
|
|
||||||
await self._question_queue.put(question)
|
question_data = {
|
||||||
await self.async_refresh()
|
"question": question,
|
||||||
|
"params": {
|
||||||
|
"system_prompt": system_prompt,
|
||||||
|
"model": model,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Priority: 0 for high priority, 1 for normal
|
||||||
|
priority_level = 0 if priority else 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._question_queue.put((priority_level, question_data))
|
||||||
|
await self.async_refresh()
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
_LOGGER.error("Question queue is full. Try again later.")
|
||||||
|
raise RuntimeError("Queue is full")
|
||||||
|
|
||||||
async def async_shutdown(self) -> None:
|
async def async_shutdown(self) -> None:
|
||||||
"""Shutdown the coordinator."""
|
"""Shutdown the coordinator."""
|
||||||
try:
|
try:
|
||||||
# Clear the queue
|
|
||||||
while not self._question_queue.empty():
|
while not self._question_queue.empty():
|
||||||
self._question_queue.get_nowait()
|
try:
|
||||||
self._question_queue.task_done()
|
self._question_queue.get_nowait()
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasattr(self.client, 'close'):
|
||||||
|
await self.client.close()
|
||||||
|
|
||||||
await self.client.close()
|
|
||||||
self._is_ready = False
|
self._is_ready = False
|
||||||
|
self._endpoint_status = "disconnected"
|
||||||
|
|
||||||
|
# Final metrics update
|
||||||
|
self._update_final_metrics()
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error during shutdown: %s", err)
|
_LOGGER.error("Error during shutdown: %s", err)
|
||||||
|
|
||||||
|
def _update_final_metrics(self) -> None:
|
||||||
|
"""Update final metrics before shutdown."""
|
||||||
|
if self._request_count > 0:
|
||||||
|
self._performance_metrics["final_success_rate"] = (
|
||||||
|
(self._request_count - self._performance_metrics["total_errors"]) /
|
||||||
|
self._request_count * 100
|
||||||
|
)
|
||||||
|
self._performance_metrics["total_requests"] = self._request_count
|
||||||
|
self._performance_metrics["total_tokens"] = self._tokens_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def performance_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Return current performance metrics."""
|
||||||
|
return self._performance_metrics
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_size(self) -> int:
|
||||||
|
"""Return current queue size."""
|
||||||
|
return self._question_queue.qsize()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_queue_full(self) -> bool:
|
||||||
|
"""Return whether queue is full."""
|
||||||
|
return self._question_queue.full()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""Return if coordinator is ready."""
|
"""Return if coordinator is ready."""
|
||||||
return self._is_ready
|
return self._is_ready and self._error_count < self._MAX_ERRORS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_processing(self) -> bool:
|
||||||
|
"""Return if coordinator is processing."""
|
||||||
|
return self._is_processing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_rate_limited(self) -> bool:
|
||||||
|
"""Return if coordinator is rate limited."""
|
||||||
|
return self._is_rate_limited
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_maintenance(self) -> bool:
|
||||||
|
"""Return if API is in maintenance."""
|
||||||
|
return self._is_maintenance
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error_count(self) -> int:
|
def error_count(self) -> int:
|
||||||
"""Return current error count."""
|
"""Return current error count."""
|
||||||
return self._error_count
|
return self._error_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_count(self) -> int:
|
||||||
|
"""Return total request count."""
|
||||||
|
return self._request_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tokens_used(self) -> int:
|
||||||
|
"""Return total tokens used."""
|
||||||
|
return self._tokens_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_version(self) -> str:
|
||||||
|
"""Return API version."""
|
||||||
|
return self._api_version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint_status(self) -> str:
|
||||||
|
"""Return endpoint status."""
|
||||||
|
return self._endpoint_status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def responses(self) -> Dict[str, Any]:
|
||||||
|
"""Return all responses."""
|
||||||
|
return self._responses
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_response(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the last response."""
|
||||||
|
if not self._responses:
|
||||||
|
return None
|
||||||
|
return next(iter(self._responses.values()))
|
||||||
|
|
||||||
def reset_error_count(self) -> None:
|
def reset_error_count(self) -> None:
|
||||||
"""Reset error counter."""
|
"""Reset error counter."""
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
|
self._is_rate_limited = False
|
||||||
|
self._is_maintenance = False
|
||||||
|
if not self._is_ready:
|
||||||
|
self._is_ready = True
|
||||||
|
self._endpoint_status = "connected"
|
||||||
|
|
||||||
|
async def clear_queue(self) -> None:
|
||||||
|
"""Clear the question queue."""
|
||||||
|
try:
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
self._question_queue.get_nowait()
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error clearing queue: %s", err)
|
||||||
|
|
||||||
|
async def clear_history(self) -> None:
|
||||||
|
"""Clear response history."""
|
||||||
|
self._responses.clear()
|
||||||
|
await self.async_refresh()
|
||||||
|
|
||||||
|
def get_response(self, question: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get specific response by question."""
|
||||||
|
return self._responses.get(question)
|
||||||
|
|
||||||
|
def get_recent_responses(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""Get most recent responses."""
|
||||||
|
return list(sorted(
|
||||||
|
self._responses.values(),
|
||||||
|
key=lambda x: x["timestamp"],
|
||||||
|
reverse=True
|
||||||
|
))[:limit]
|
||||||
|
|
||||||
|
async def retry_failed_requests(self) -> None:
|
||||||
|
"""Retry failed requests."""
|
||||||
|
failed_requests = [
|
||||||
|
(q, r) for q, r in self._responses.items()
|
||||||
|
if r.get("error") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
for question, response in failed_requests:
|
||||||
|
await self.async_ask_question(
|
||||||
|
question,
|
||||||
|
system_prompt=response.get("system_prompt"),
|
||||||
|
model=response.get("model"),
|
||||||
|
temperature=response.get("temperature"),
|
||||||
|
max_tokens=response.get("max_tokens"),
|
||||||
|
priority=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_system_prompt(self, new_prompt: str) -> None:
|
||||||
|
"""Update system prompt."""
|
||||||
|
self.system_prompt = new_prompt
|
||||||
|
_LOGGER.info("System prompt updated")
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""Perform health check."""
|
||||||
|
health_status = {
|
||||||
|
"is_ready": self.is_ready,
|
||||||
|
"is_processing": self.is_processing,
|
||||||
|
"is_rate_limited": self.is_rate_limited,
|
||||||
|
"is_maintenance": self.is_maintenance,
|
||||||
|
"error_count": self.error_count,
|
||||||
|
"endpoint_status": self.endpoint_status,
|
||||||
|
"queue_size": self.queue_size,
|
||||||
|
"request_count": self.request_count,
|
||||||
|
"tokens_used": self.tokens_used,
|
||||||
|
"performance_metrics": self.performance_metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
return health_status
|
||||||
|
|
||||||
|
async def _handle_timeout_error(self) -> None:
|
||||||
|
"""Handle timeout errors."""
|
||||||
|
self._error_count += 1
|
||||||
|
self._endpoint_status = "timeout"
|
||||||
|
self._performance_metrics["total_errors"] += 1
|
||||||
|
|
||||||
|
if not self._question_queue.empty():
|
||||||
|
await self.clear_queue()
|
||||||
|
|
||||||
|
# Fire timeout event
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_timeout_error", {
|
||||||
|
"error_count": self._error_count,
|
||||||
|
"endpoint_status": self._endpoint_status
|
||||||
|
})
|
||||||
|
|
||||||
|
def export_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Export all metrics and statistics."""
|
||||||
|
return {
|
||||||
|
"performance": self._performance_metrics,
|
||||||
|
"requests": {
|
||||||
|
"total": self._request_count,
|
||||||
|
"successful": self._request_count - self._performance_metrics["total_errors"],
|
||||||
|
"failed": self._performance_metrics["total_errors"]
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"total_used": self._tokens_used,
|
||||||
|
"average_per_request": self._tokens_used / self._request_count if self._request_count > 0 else 0
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"is_ready": self.is_ready,
|
||||||
|
"endpoint_status": self._endpoint_status,
|
||||||
|
"error_count": self._error_count
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"size": self.queue_size,
|
||||||
|
"is_full": self.is_queue_full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def validate_model(self, model: str) -> bool:
|
||||||
|
"""Validate if model is supported."""
|
||||||
|
return model in SUPPORTED_MODELS
|
||||||
|
|
||||||
|
async def estimate_tokens(self, text: str) -> int:
|
||||||
|
"""Estimate token count for text."""
|
||||||
|
# Простая оценка: примерно 4 символа на токен
|
||||||
|
return len(text) // 4
|
||||||
|
|
||||||
|
def get_rate_limit_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get rate limit information."""
|
||||||
|
return {
|
||||||
|
"is_rate_limited": self._is_rate_limited,
|
||||||
|
"retry_after": RETRY_DELAY * (2 ** (self._error_count - 1)) if self._is_rate_limited else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def optimize_queue(self) -> None:
|
||||||
|
"""Optimize queue by removing duplicate requests."""
|
||||||
|
if self._question_queue.empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_questions = set()
|
||||||
|
optimized_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
|
||||||
|
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
priority, question_data = self._question_queue.get_nowait()
|
||||||
|
question = question_data["question"]
|
||||||
|
|
||||||
|
if question not in seen_questions:
|
||||||
|
seen_questions.add(question)
|
||||||
|
await optimized_queue.put((priority, question_data))
|
||||||
|
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._question_queue = optimized_queue
|
||||||
|
|||||||
@@ -4,11 +4,39 @@
|
|||||||
"codeowners": ["@smkrv"],
|
"codeowners": ["@smkrv"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://github.com/smkrv/ha-text-ai/wiki",
|
"documentation": "https://github.com/smkrv/ha-text-ai",
|
||||||
|
"homepage": "https://github.com/smkrv/ha-text-ai",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"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.12.0",
|
||||||
|
"anthropic>=0.8.0",
|
||||||
|
"aiohttp>=3.8.0",
|
||||||
|
"async-timeout>=4.0.0"
|
||||||
|
],
|
||||||
|
"version": "2.0.0",
|
||||||
|
"homeassistant": "2024.1.0",
|
||||||
|
"quality_scale": "silver",
|
||||||
|
"integration_type": "service",
|
||||||
|
"translations": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"zeroconf": [],
|
||||||
"ssdp": [],
|
"ssdp": [],
|
||||||
"version": "1.0.7",
|
"usb": [],
|
||||||
"zeroconf": []
|
"bluetooth": [],
|
||||||
|
"mqtt": [],
|
||||||
|
"loggers": ["custom_components.ha_text_ai"],
|
||||||
|
"after_dependencies": ["http"],
|
||||||
|
"title": "Text AI Assistant",
|
||||||
|
"description": "AI-powered text assistant supporting multiple models including GPT and Claude",
|
||||||
|
"render_readme": true,
|
||||||
|
"tags": [
|
||||||
|
"ai",
|
||||||
|
"automation",
|
||||||
|
"chat",
|
||||||
|
"assistant",
|
||||||
|
"openai",
|
||||||
|
"claude"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ from .const import (
|
|||||||
ATTR_ERROR_COUNT,
|
ATTR_ERROR_COUNT,
|
||||||
ATTR_LAST_ERROR,
|
ATTR_LAST_ERROR,
|
||||||
ATTR_RESPONSE_TIME,
|
ATTR_RESPONSE_TIME,
|
||||||
|
ATTR_API_VERSION,
|
||||||
|
ATTR_ENDPOINT_STATUS,
|
||||||
|
ATTR_REQUEST_COUNT,
|
||||||
|
ATTR_TOKENS_USED,
|
||||||
ENTITY_ICON,
|
ENTITY_ICON,
|
||||||
ENTITY_ICON_ERROR,
|
ENTITY_ICON_ERROR,
|
||||||
ENTITY_ICON_PROCESSING,
|
ENTITY_ICON_PROCESSING,
|
||||||
@@ -39,6 +43,7 @@ from .const import (
|
|||||||
STATE_DISCONNECTED,
|
STATE_DISCONNECTED,
|
||||||
STATE_RATE_LIMITED,
|
STATE_RATE_LIMITED,
|
||||||
STATE_INITIALIZING,
|
STATE_INITIALIZING,
|
||||||
|
STATE_MAINTENANCE,
|
||||||
)
|
)
|
||||||
from .coordinator import HATextAICoordinator
|
from .coordinator import HATextAICoordinator
|
||||||
|
|
||||||
@@ -53,6 +58,7 @@ async def async_setup_entry(
|
|||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([HATextAISensor(coordinator, entry)], True)
|
async_add_entities([HATextAISensor(coordinator, entry)], True)
|
||||||
|
|
||||||
|
|
||||||
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||||
"""HA text AI Sensor."""
|
"""HA text AI Sensor."""
|
||||||
|
|
||||||
@@ -74,6 +80,13 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._state = STATE_INITIALIZING
|
self._state = STATE_INITIALIZING
|
||||||
|
self._attr_device_info = {
|
||||||
|
"identifiers": {(DOMAIN, self._attr_unique_id)},
|
||||||
|
"name": "HA Text AI",
|
||||||
|
"manufacturer": "Community",
|
||||||
|
"model": coordinator.model,
|
||||||
|
"sw_version": coordinator.api_version,
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
@@ -87,13 +100,16 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def state(self) -> StateType:
|
def state(self) -> StateType:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
if not self.coordinator.data or not self.coordinator.last_update_success_time:
|
if not self.coordinator.data or not self.coordinator.last_update_success:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(self.coordinator.last_update_success_time, datetime):
|
if self.coordinator.data and isinstance(self.coordinator.data, dict):
|
||||||
return dt_util.as_local(self.coordinator.last_update_success_time)
|
last_update = self.coordinator.data.get("last_update")
|
||||||
return self.coordinator.last_update_success_time
|
if isinstance(last_update, datetime):
|
||||||
|
return dt_util.as_local(last_update)
|
||||||
|
return last_update
|
||||||
|
return None
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error getting state: %s", err, exc_info=True)
|
_LOGGER.error("Error getting state: %s", err, exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -111,27 +127,41 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
ATTR_API_STATUS: self._state,
|
ATTR_API_STATUS: self._state,
|
||||||
ATTR_ERROR_COUNT: self._error_count,
|
ATTR_ERROR_COUNT: self._error_count,
|
||||||
ATTR_LAST_ERROR: self._last_error,
|
ATTR_LAST_ERROR: self._last_error,
|
||||||
|
ATTR_API_VERSION: self.coordinator.api_version,
|
||||||
|
ATTR_ENDPOINT_STATUS: self.coordinator.endpoint_status,
|
||||||
|
ATTR_REQUEST_COUNT: self.coordinator.request_count,
|
||||||
|
ATTR_TOKENS_USED: self.coordinator.tokens_used,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return attributes
|
return attributes
|
||||||
|
|
||||||
try:
|
try:
|
||||||
history = list(self.coordinator.data.items())
|
history = list(self.coordinator._responses.items())
|
||||||
if history:
|
if history:
|
||||||
last_question, last_data = history[-1]
|
last_question, last_data = history[-1]
|
||||||
|
|
||||||
# Handle different response formats
|
|
||||||
if isinstance(last_data, dict):
|
if isinstance(last_data, dict):
|
||||||
last_response = last_data.get("response", "")
|
last_response = last_data.get("response", "")
|
||||||
last_updated = last_data.get("timestamp", self.coordinator.last_update_success_time)
|
last_updated = last_data.get("timestamp") or self.coordinator.data.get("last_update")
|
||||||
response_time = last_data.get("response_time")
|
response_time = last_data.get("response_time")
|
||||||
|
|
||||||
|
model = last_data.get("model", self.coordinator.model)
|
||||||
|
temperature = last_data.get("temperature", self.coordinator.temperature)
|
||||||
|
max_tokens = last_data.get("max_tokens", self.coordinator.max_tokens)
|
||||||
|
error = last_data.get("error")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
self._last_error = error
|
||||||
|
self._state = STATE_ERROR
|
||||||
else:
|
else:
|
||||||
last_response = str(last_data)
|
last_response = str(last_data)
|
||||||
last_updated = self.coordinator.last_update_success_time
|
last_updated = self.coordinator.data.get("last_update")
|
||||||
response_time = None
|
response_time = None
|
||||||
|
model = self.coordinator.model
|
||||||
|
temperature = self.coordinator.temperature
|
||||||
|
max_tokens = self.coordinator.max_tokens
|
||||||
|
|
||||||
# Convert timestamp to local time if needed
|
|
||||||
if isinstance(last_updated, datetime):
|
if isinstance(last_updated, datetime):
|
||||||
last_updated = dt_util.as_local(last_updated)
|
last_updated = dt_util.as_local(last_updated)
|
||||||
|
|
||||||
@@ -140,6 +170,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
ATTR_RESPONSE: last_response,
|
ATTR_RESPONSE: last_response,
|
||||||
ATTR_LAST_UPDATED: last_updated,
|
ATTR_LAST_UPDATED: last_updated,
|
||||||
ATTR_TOTAL_RESPONSES: len(history),
|
ATTR_TOTAL_RESPONSES: len(history),
|
||||||
|
ATTR_MODEL: model,
|
||||||
|
ATTR_TEMPERATURE: temperature,
|
||||||
|
ATTR_MAX_TOKENS: max_tokens,
|
||||||
})
|
})
|
||||||
|
|
||||||
if response_time is not None:
|
if response_time is not None:
|
||||||
@@ -169,9 +202,24 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
"""Handle updated data from the coordinator."""
|
"""Handle updated data from the coordinator."""
|
||||||
try:
|
try:
|
||||||
if self.coordinator.data:
|
if self.coordinator.data:
|
||||||
self._state = STATE_READY
|
if self.coordinator._is_ready:
|
||||||
|
if self.coordinator._is_processing:
|
||||||
|
self._state = STATE_PROCESSING
|
||||||
|
elif self.coordinator._is_rate_limited:
|
||||||
|
self._state = STATE_RATE_LIMITED
|
||||||
|
elif self.coordinator._is_maintenance:
|
||||||
|
self._state = STATE_MAINTENANCE
|
||||||
|
else:
|
||||||
|
self._state = STATE_READY
|
||||||
|
else:
|
||||||
|
self._state = STATE_DISCONNECTED
|
||||||
else:
|
else:
|
||||||
self._state = STATE_DISCONNECTED
|
self._state = STATE_DISCONNECTED
|
||||||
|
|
||||||
|
# Обновляем счетчик ошибок только если статус изменился на ошибку
|
||||||
|
if self._state == STATE_ERROR:
|
||||||
|
self._error_count += 1
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error handling update: %s", err, exc_info=True)
|
_LOGGER.error("Error handling update: %s", err, exc_info=True)
|
||||||
self._error_count += 1
|
self._error_count += 1
|
||||||
@@ -179,3 +227,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
self._state = STATE_ERROR
|
self._state = STATE_ERROR
|
||||||
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_reset_error_count(self) -> None:
|
||||||
|
"""Reset the error counter."""
|
||||||
|
self._error_count = 0
|
||||||
|
self._last_error = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ ask_question:
|
|||||||
Send a question to the AI model and receive a detailed response.
|
Send a question to the AI model and receive a detailed response.
|
||||||
The response will be stored in the conversation history and can be retrieved later.
|
The response will be stored in the conversation history and can be retrieved later.
|
||||||
Response time may vary based on model selection and server load.
|
Response time may vary based on model selection and server load.
|
||||||
|
Supports various AI models with different capabilities and pricing.
|
||||||
fields:
|
fields:
|
||||||
question:
|
question:
|
||||||
name: Question
|
name: Question
|
||||||
@@ -11,6 +12,7 @@ ask_question:
|
|||||||
Your question or prompt for the AI assistant. Be specific and clear for better results.
|
Your question or prompt for the AI assistant. Be specific and clear for better results.
|
||||||
You can ask about home automation, technical advice, or general questions.
|
You can ask about home automation, technical advice, or general questions.
|
||||||
For complex queries, consider breaking them into smaller parts.
|
For complex queries, consider breaking them into smaller parts.
|
||||||
|
The system will maintain conversation context for follow-up questions.
|
||||||
required: true
|
required: true
|
||||||
example: |
|
example: |
|
||||||
What automations would you recommend for a smart kitchen?
|
What automations would you recommend for a smart kitchen?
|
||||||
@@ -28,13 +30,17 @@ ask_question:
|
|||||||
name: Model
|
name: Model
|
||||||
description: >-
|
description: >-
|
||||||
Select an AI model to use (optional, overrides default setting).
|
Select an AI model to use (optional, overrides default setting).
|
||||||
Different models have different capabilities and token limits.
|
Different models have different capabilities, context limits, and response characteristics:
|
||||||
|
- GPT-3.5 Turbo: Fast, efficient, good for most tasks
|
||||||
|
- GPT-4: More capable, better reasoning, slower
|
||||||
|
- Claude-3: Advanced capabilities, longer context
|
||||||
Note: More capable models may have longer response times and higher API costs.
|
Note: More capable models may have longer response times and higher API costs.
|
||||||
required: false
|
required: false
|
||||||
example: "gpt-3.5-turbo"
|
example: "gpt-3.5-turbo"
|
||||||
default: "gpt-3.5-turbo"
|
default: "gpt-3.5-turbo"
|
||||||
selector:
|
selector:
|
||||||
select:
|
select:
|
||||||
|
custom_value: true
|
||||||
options:
|
options:
|
||||||
- label: "GPT-3.5 Turbo (Fast & Efficient)"
|
- label: "GPT-3.5 Turbo (Fast & Efficient)"
|
||||||
value: "gpt-3.5-turbo"
|
value: "gpt-3.5-turbo"
|
||||||
@@ -46,12 +52,16 @@ ask_question:
|
|||||||
value: "gpt-4-32k"
|
value: "gpt-4-32k"
|
||||||
- label: "GPT-4 Turbo (Latest)"
|
- label: "GPT-4 Turbo (Latest)"
|
||||||
value: "gpt-4-1106-preview"
|
value: "gpt-4-1106-preview"
|
||||||
|
- label: "Claude-3 Sonnet (Balanced)"
|
||||||
|
value: "claude-3-sonnet"
|
||||||
|
- label: "Claude-3 Opus (Most Advanced)"
|
||||||
|
value: "claude-3-opus"
|
||||||
mode: dropdown
|
mode: dropdown
|
||||||
|
|
||||||
temperature:
|
temperature:
|
||||||
name: Temperature
|
name: Temperature
|
||||||
description: >-
|
description: >-
|
||||||
Controls response creativity (0-2):
|
Controls response creativity (0.0-2.0):
|
||||||
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
|
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
|
||||||
0.4-0.7: Balanced responses (recommended for most uses)
|
0.4-0.7: Balanced responses (recommended for most uses)
|
||||||
0.8-2.0: More creative, varied responses (best for brainstorming)
|
0.8-2.0: More creative, varied responses (best for brainstorming)
|
||||||
@@ -69,12 +79,12 @@ ask_question:
|
|||||||
max_tokens:
|
max_tokens:
|
||||||
name: Max Tokens
|
name: Max Tokens
|
||||||
description: >-
|
description: >-
|
||||||
Maximum length of the response. Higher values allow longer responses but use more API tokens.
|
Maximum length of the response (1-4096 tokens).
|
||||||
Recommended ranges:
|
Recommended ranges:
|
||||||
- Short responses (256-512): Quick answers, status updates
|
- Short (256-512): Quick answers, status updates
|
||||||
- Medium responses (512-1024): Detailed explanations, instructions
|
- Medium (512-1024): Detailed explanations
|
||||||
- Long responses (1024-4096): Complex analysis, multiple examples
|
- Long (1024-4096): Complex analysis
|
||||||
Note: Actual response length may be shorter based on content.
|
Note: Token limits vary by model. Actual length may be shorter.
|
||||||
required: false
|
required: false
|
||||||
default: 1000
|
default: 1000
|
||||||
selector:
|
selector:
|
||||||
@@ -84,19 +94,35 @@ ask_question:
|
|||||||
step: 1
|
step: 1
|
||||||
mode: box
|
mode: box
|
||||||
|
|
||||||
|
system_prompt:
|
||||||
|
name: System Prompt
|
||||||
|
description: >-
|
||||||
|
Optional system prompt to set context for this specific question.
|
||||||
|
This will temporarily override the default system prompt.
|
||||||
|
Use this to specify expertise areas, response format, or special instructions.
|
||||||
|
required: false
|
||||||
|
example: "You are a home automation expert focused on energy efficiency and security"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
multiline: true
|
||||||
|
|
||||||
clear_history:
|
clear_history:
|
||||||
name: Clear History
|
name: Clear History
|
||||||
description: >-
|
description: >-
|
||||||
Delete all stored questions and responses from the conversation history.
|
Delete all stored questions and responses from the conversation history.
|
||||||
This action cannot be undone. Consider using 'get_history' first if you need to backup the data.
|
This action cannot be undone. Consider using 'get_history' first if you need to backup the data.
|
||||||
System prompt settings will be preserved.
|
System prompt settings and configuration will be preserved.
|
||||||
fields: {}
|
fields: {}
|
||||||
|
|
||||||
get_history:
|
get_history:
|
||||||
name: Get History
|
name: Get History
|
||||||
description: >-
|
description: >-
|
||||||
Retrieve recent conversation history, including questions, responses, and timestamps.
|
Retrieve recent conversation history, including:
|
||||||
Results are ordered from newest to oldest and include metadata like model used and response times.
|
- Questions and responses
|
||||||
|
- Timestamps and response times
|
||||||
|
- Models used and token counts
|
||||||
|
- Temperature and other settings
|
||||||
|
Results are ordered from newest to oldest.
|
||||||
fields:
|
fields:
|
||||||
limit:
|
limit:
|
||||||
name: Limit
|
name: Limit
|
||||||
@@ -121,6 +147,7 @@ get_history:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
select:
|
select:
|
||||||
|
custom_value: true
|
||||||
options:
|
options:
|
||||||
- label: "All Models"
|
- label: "All Models"
|
||||||
value: ""
|
value: ""
|
||||||
@@ -128,21 +155,41 @@ get_history:
|
|||||||
value: "gpt-3.5-turbo"
|
value: "gpt-3.5-turbo"
|
||||||
- label: "GPT-4"
|
- label: "GPT-4"
|
||||||
value: "gpt-4"
|
value: "gpt-4"
|
||||||
|
- label: "GPT-4 Turbo"
|
||||||
|
value: "gpt-4-1106-preview"
|
||||||
|
- label: "Claude-3 Sonnet"
|
||||||
|
value: "claude-3-sonnet"
|
||||||
|
- label: "Claude-3 Opus"
|
||||||
|
value: "claude-3-opus"
|
||||||
mode: dropdown
|
mode: dropdown
|
||||||
|
|
||||||
|
start_date:
|
||||||
|
name: Start Date
|
||||||
|
description: >-
|
||||||
|
Optional start date for filtering history.
|
||||||
|
Format: YYYY-MM-DD
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
type: date
|
||||||
|
|
||||||
set_system_prompt:
|
set_system_prompt:
|
||||||
name: Set System Prompt
|
name: Set System Prompt
|
||||||
description: >-
|
description: >-
|
||||||
Configure the AI's behavior by setting a system prompt.
|
Set default system behavior instructions for all future conversations.
|
||||||
This affects how the AI interprets and responds to all future questions.
|
This defines how the AI should behave and respond to questions.
|
||||||
The prompt will persist until changed or cleared.
|
The prompt will be used for all models unless overridden per question.
|
||||||
fields:
|
fields:
|
||||||
prompt:
|
prompt:
|
||||||
name: System Prompt
|
name: System Prompt
|
||||||
description: >-
|
description: >-
|
||||||
Instructions that define how the AI should behave and respond.
|
Instructions that define how the AI should behave and respond.
|
||||||
Be specific about the desired expertise, tone, and format of responses.
|
Be specific about:
|
||||||
Maximum length: 1000 characters.
|
- Desired expertise and knowledge areas
|
||||||
|
- Response tone and style
|
||||||
|
- Output format preferences
|
||||||
|
- Special handling instructions
|
||||||
|
Maximum length: 1000 characters
|
||||||
required: true
|
required: true
|
||||||
example: |
|
example: |
|
||||||
You are a home automation expert assistant. Focus on:
|
You are a home automation expert assistant. Focus on:
|
||||||
@@ -156,15 +203,3 @@ set_system_prompt:
|
|||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
type: text
|
|
||||||
max_length: 1000
|
|
||||||
|
|
||||||
clear_prompt:
|
|
||||||
name: Clear Existing Prompt
|
|
||||||
description: >-
|
|
||||||
Set to true to remove the current system prompt before applying the new one.
|
|
||||||
This ensures no conflicting instructions remain.
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
selector:
|
|
||||||
boolean: {}
|
|
||||||
|
|||||||
@@ -1,61 +1,147 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"option": {
|
"step": {
|
||||||
"api_key": {
|
"user": {
|
||||||
"name": "API Key",
|
"title": "Set up HA Text AI",
|
||||||
"description": "Your OpenAI API key"
|
"description": "Configure your AI integration for various providers (OpenAI, Anthropic, etc.). Different models have different capabilities and pricing.",
|
||||||
},
|
"data": {
|
||||||
"model": {
|
"api_key": "API key for authentication (required)",
|
||||||
"name": "Model",
|
"model": "AI model to use (e.g., gpt-3.5-turbo, gpt-4, claude-3-sonnet, claude-3-opus)",
|
||||||
"description": "AI model to use for responses"
|
"temperature": "Response creativity (0-2, lower = more focused and consistent)",
|
||||||
},
|
"max_tokens": "Maximum response length (1-4096 tokens)",
|
||||||
"temperature": {
|
"api_endpoint": "Custom API endpoint URL (default varies by provider)",
|
||||||
"name": "Temperature",
|
"request_interval": "Minimum time between requests in seconds (min: 0.1)",
|
||||||
"description": "Temperature for response generation (0-2)"
|
"system_prompt": "Default instructions for AI behavior and expertise"
|
||||||
},
|
}
|
||||||
"max_tokens": {
|
|
||||||
"name": "Max Tokens",
|
|
||||||
"description": "Maximum tokens in response (1-4096)"
|
|
||||||
},
|
|
||||||
"api_endpoint": {
|
|
||||||
"name": "API Endpoint",
|
|
||||||
"description": "API endpoint URL"
|
|
||||||
},
|
|
||||||
"request_interval": {
|
|
||||||
"name": "Request Interval",
|
|
||||||
"description": "Minimum time between API requests (seconds)"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_auth": "Authentication failed - check your API key",
|
||||||
|
"invalid_api_key": "Invalid API key - please verify your credentials",
|
||||||
|
"cannot_connect": "Connection failed - check endpoint and network status",
|
||||||
|
"invalid_model": "Model unavailable or not supported by the API",
|
||||||
|
"rate_limit": "Rate limit exceeded - please reduce request frequency",
|
||||||
|
"context_length": "Input exceeds maximum context length for model",
|
||||||
|
"api_error": "API service error - check provider status",
|
||||||
|
"timeout": "Request timeout - server not responding",
|
||||||
|
"queue_full": "Request queue full - try again later",
|
||||||
|
"invalid_prompt": "Invalid system prompt format or length",
|
||||||
|
"invalid_url_format": "Invalid API endpoint URL format",
|
||||||
|
"invalid_input": "Invalid configuration parameters",
|
||||||
|
"unknown": "Unexpected error - check logs for details"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": "Temperature",
|
"step": {
|
||||||
"max_tokens": "Max Tokens",
|
"init": {
|
||||||
"request_interval": "Request Interval"
|
"title": "HA Text AI Settings",
|
||||||
},
|
"description": "Adjust your AI integration parameters and behavior",
|
||||||
"error": {
|
"data": {
|
||||||
"invalid_api_key": "Invalid API key",
|
"model": "Select AI model for responses (capabilities vary)",
|
||||||
"cannot_connect": "Cannot connect to the API",
|
"temperature": "Response creativity (0-2, affects variation)",
|
||||||
"unknown_error": "Unknown error",
|
"max_tokens": "Maximum response length in tokens",
|
||||||
"invalid_model": "Invalid model",
|
"request_interval": "Minimum seconds between requests (rate limiting)",
|
||||||
"rate_limit_exceeded": "Rate limit exceeded",
|
"system_prompt": "Default AI behavior and expertise instructions",
|
||||||
"context_length_exceeded": "Context length exceeded",
|
"timeout": "Request timeout in seconds (default: 30)",
|
||||||
"api_error": "API error",
|
"retry_count": "Number of retry attempts for failed requests",
|
||||||
"timeout_error": "Timeout error",
|
"queue_size": "Maximum pending requests in queue"
|
||||||
"queue_full": "Queue full",
|
}
|
||||||
"invalid_prompt": "Invalid prompt"
|
}
|
||||||
},
|
}
|
||||||
"state": {
|
|
||||||
"ready": "Ready",
|
|
||||||
"processing": "Processing",
|
|
||||||
"error": "Error",
|
|
||||||
"disconnected": "Disconnected",
|
|
||||||
"rate_limited": "Rate limited",
|
|
||||||
"initializing": "Initializing"
|
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"ask_question": "Ask Question",
|
"ask_question": {
|
||||||
"clear_history": "Clear History",
|
"name": "Ask Question",
|
||||||
"get_history": "Get History",
|
"description": "Send a question to the AI model and get a detailed response. Supports context awareness for follow-up questions.",
|
||||||
"set_system_prompt": "Set System Prompt"
|
"fields": {
|
||||||
|
"question": {
|
||||||
|
"name": "Question",
|
||||||
|
"description": "Your question or prompt for the AI model. Be specific for better results."
|
||||||
|
},
|
||||||
|
"system_prompt": {
|
||||||
|
"name": "System Prompt",
|
||||||
|
"description": "Optional behavior instructions for this specific question"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "Model",
|
||||||
|
"description": "Optional specific AI model for this request (overrides default)"
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"name": "Temperature",
|
||||||
|
"description": "Optional creativity setting (0-2, affects response variation)"
|
||||||
|
},
|
||||||
|
"max_tokens": {
|
||||||
|
"name": "Max Tokens",
|
||||||
|
"description": "Optional maximum response length (1-4096 tokens)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clear_history": {
|
||||||
|
"name": "Clear History",
|
||||||
|
"description": "Delete all stored conversation history, responses, and metadata"
|
||||||
|
},
|
||||||
|
"get_history": {
|
||||||
|
"name": "Get History",
|
||||||
|
"description": "Retrieve conversation history with detailed metadata including timestamps",
|
||||||
|
"fields": {
|
||||||
|
"limit": {
|
||||||
|
"name": "Limit",
|
||||||
|
"description": "Maximum number of conversations to return (1-100)"
|
||||||
|
},
|
||||||
|
"filter_model": {
|
||||||
|
"name": "Filter Model",
|
||||||
|
"description": "Show only responses from a specific model"
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"name": "Start Date",
|
||||||
|
"description": "Filter conversations from this date (YYYY-MM-DD)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"set_system_prompt": {
|
||||||
|
"name": "Set System Prompt",
|
||||||
|
"description": "Update default AI behavior and expertise instructions",
|
||||||
|
"fields": {
|
||||||
|
"prompt": {
|
||||||
|
"name": "System Prompt",
|
||||||
|
"description": "Instructions for AI behavior, expertise, and response style"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready for requests",
|
||||||
|
"processing": "Processing request",
|
||||||
|
"error": "Error occurred",
|
||||||
|
"disconnected": "API disconnected",
|
||||||
|
"rate_limited": "Rate limit reached",
|
||||||
|
"initializing": "Starting up",
|
||||||
|
"retrying": "Retrying request",
|
||||||
|
"queued": "Request queued"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"status": {
|
||||||
|
"name": "AI Status",
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready",
|
||||||
|
"processing": "Processing",
|
||||||
|
"error": "Error",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"rate_limited": "Rate Limited",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"retrying": "Retrying",
|
||||||
|
"queued": "Queued"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_response": {
|
||||||
|
"name": "Last Response",
|
||||||
|
"state": {
|
||||||
|
"success": "Success",
|
||||||
|
"error": "Error",
|
||||||
|
"timeout": "Timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"option": {
|
|
||||||
"api_key": {
|
|
||||||
"name": "API ключ",
|
|
||||||
"description": "Ваш API ключ OpenAI"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"name": "Модель",
|
|
||||||
"description": "Модель AI для генерации ответов"
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"name": "Температура",
|
|
||||||
"description": "Температура для генерации ответов (0-2)"
|
|
||||||
},
|
|
||||||
"max_tokens": {
|
|
||||||
"name": "Максимальное количество токенов",
|
|
||||||
"description": "Максимальное количество токенов в ответе (1-4096)"
|
|
||||||
},
|
|
||||||
"api_endpoint": {
|
|
||||||
"name": "Конечная точка API",
|
|
||||||
"description": "URL конечной точки API"
|
|
||||||
},
|
|
||||||
"request_interval": {
|
|
||||||
"name": "Интервал запросов",
|
|
||||||
"description": "Минимальное время между запросами к API в секундах"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"temperature": "Температура",
|
|
||||||
"max_tokens": "Максимальное количество токенов",
|
|
||||||
"request_interval": "Интервал запросов"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"invalid_api_key": "Неверный API ключ",
|
|
||||||
"cannot_connect": "Не удается подключиться к API",
|
|
||||||
"unknown_error": "Неизвестная ошибка",
|
|
||||||
"invalid_model": "Неверная модель",
|
|
||||||
"rate_limit_exceeded": "Превышен лимит запросов",
|
|
||||||
"context_length_exceeded": "Превышена длина контекста",
|
|
||||||
"api_error": "Ошибка API",
|
|
||||||
"timeout_error": "Время ожидания истекло",
|
|
||||||
"queue_full": "Очередь полна",
|
|
||||||
"invalid_prompt": "Неверный запрос"
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"ready": "Готово",
|
|
||||||
"processing": "Обработка",
|
|
||||||
"error": "Ошибка",
|
|
||||||
"disconnected": "Отключено",
|
|
||||||
"rate_limited": "Ограниченный по скорости",
|
|
||||||
"initializing": "Инициализация"
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"ask_question": "Задать вопрос",
|
|
||||||
"clear_history": "Очистить историю",
|
|
||||||
"get_history": "Получить историю",
|
|
||||||
"set_system_prompt": "Установить системный запрос"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -4,6 +4,6 @@
|
|||||||
"domains": ["sensor"],
|
"domains": ["sensor"],
|
||||||
"homeassistant": "2024.11.0",
|
"homeassistant": "2024.11.0",
|
||||||
"icon": "mdi:brain",
|
"icon": "mdi:brain",
|
||||||
"version": "1.0.7",
|
"version": "2.0.0",
|
||||||
"documentation": "https://github.com/smkrv/ha-text-ai"
|
"documentation": "https://github.com/smkrv/ha-text-ai"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
```
|
||||||
|
ha-text-ai/
|
||||||
|
│
|
||||||
|
├── custom_components/
|
||||||
|
│ └── ha_text_ai/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config_flow.py
|
||||||
|
│ ├── coordinator.py
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ ├── sensor.py
|
||||||
|
│ ├── services.yaml
|
||||||
|
│ └── const.py
|
||||||
|
│
|
||||||
|
└── strings/
|
||||||
|
├── en.json
|
||||||
|
└── ru.json
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user