import { LitElement, html, css, } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module"; console.log("AI Agent HA Panel loading..."); // Debug log const PROVIDERS = { openai: "OpenAI", llama: "Llama", gemini: "Google Gemini", openrouter: "OpenRouter", anthropic: "Anthropic", alter: "Alter", zai: "z.ai", local: "Local Model", local_ollama: "Local Ollama", openai_compatible: "OpenAI-Compatible", }; class AiAgentHaPanel extends LitElement { static get properties() { return { hass: { type: Object, reflect: false, attribute: false }, narrow: { type: Boolean, reflect: false, attribute: false }, panel: { type: Object, reflect: false, attribute: false }, _messages: { type: Array, reflect: false, attribute: false }, _isLoading: { type: Boolean, reflect: false, attribute: false }, _error: { type: String, reflect: false, attribute: false }, _pendingAutomation: { type: Object, reflect: false, attribute: false }, _promptHistory: { type: Array, reflect: false, attribute: false }, _showPredefinedPrompts: { type: Boolean, reflect: false, attribute: false }, _showPromptHistory: { type: Boolean, reflect: false, attribute: false }, _selectedPrompts: { type: Array, reflect: false, attribute: false }, _selectedProvider: { type: String, reflect: false, attribute: false }, _availableProviders: { type: Array, reflect: false, attribute: false }, _showProviderDropdown: { type: Boolean, reflect: false, attribute: false }, _showThinking: { type: Boolean, reflect: false, attribute: false }, _thinkingExpanded: { type: Boolean, reflect: false, attribute: false }, _debugInfo: { type: Object, reflect: false, attribute: false } }; } static get styles() { return css` :host { background: var(--primary-background-color); -webkit-font-smoothing: antialiased; display: flex; flex-direction: column; height: 100vh; } .header { background: var(--app-header-background-color); color: var(--app-header-text-color); padding: 16px 24px; display: flex; align-items: center; gap: 12px; font-size: 20px; font-weight: 500; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); position: relative; z-index: 100; } .clear-button { margin-left: auto; border: none; border-radius: 16px; background: var(--error-color); color: #fff; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; padding: 8px 16px; font-weight: 500; font-size: 13px; box-shadow: 0 1px 2px rgba(0,0,0,0.08); min-width: unset; width: auto; height: 36px; flex-shrink: 0; position: relative; z-index: 101; font-family: inherit; } .clear-button:hover { background: var(--error-color); opacity: 0.92; transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0,0,0,0.13); } .clear-button:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(0,0,0,0.08); } .clear-button ha-icon { --mdc-icon-size: 16px; margin-right: 2px; color: #fff; } .clear-button span { color: #fff; font-weight: 500; } .content { flex-grow: 1; padding: 24px; overflow-y: auto; display: flex; flex-direction: column; justify-content: flex-end; } .chat-container { width: 100%; padding: 0; display: flex; flex-direction: column; flex-grow: 1; height: 100%; } .messages { overflow-y: auto; border: 1px solid var(--divider-color); border-radius: 12px; margin-bottom: 24px; padding: 0; background: var(--primary-background-color); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); flex-grow: 1; width: 100%; } .prompts-section { margin-bottom: 12px; padding: 12px 16px; background: var(--secondary-background-color); border-radius: 16px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); border: 1px solid var(--divider-color); } .prompts-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 14px; font-weight: 500; color: var(--secondary-text-color); } .prompts-toggle { display: flex; align-items: center; gap: 4px; cursor: pointer; color: var(--primary-color); font-size: 12px; font-weight: 500; padding: 2px 6px; border-radius: 4px; transition: background-color 0.2s ease; } .prompts-toggle:hover { background: var(--primary-color); color: var(--text-primary-color); } .prompts-toggle ha-icon { --mdc-icon-size: 14px; } .prompt-bubbles { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } .prompt-bubble { background: var(--primary-background-color); border: 1px solid var(--divider-color); border-radius: 20px; padding: 6px 12px; cursor: pointer; transition: all 0.2s ease; font-size: 12px; line-height: 1.3; color: var(--primary-text-color); white-space: nowrap; max-width: 200px; overflow: hidden; text-overflow: ellipsis; } .prompt-bubble:hover { border-color: var(--primary-color); background: var(--primary-color); color: var(--text-primary-color); transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .prompt-bubble:active { transform: translateY(0); } .history-bubble { background: var(--primary-background-color); border: 1px solid var(--accent-color); border-radius: 20px; padding: 6px 12px; cursor: pointer; transition: all 0.2s ease; font-size: 12px; line-height: 1.3; color: var(--accent-color); white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 6px; } .history-bubble:hover { background: var(--accent-color); color: var(--text-primary-color); transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .history-delete { opacity: 0; transition: opacity 0.2s ease; color: var(--error-color); cursor: pointer; --mdc-icon-size: 14px; } .history-bubble:hover .history-delete { opacity: 1; color: var(--text-primary-color); } .message { margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; max-width: 80%; line-height: 1.5; animation: fadeIn 0.3s ease-out; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); word-wrap: break-word; } .user-message { background: var(--primary-color); color: var(--text-primary-color); margin-left: auto; border-bottom-right-radius: 4px; } .assistant-message { background: var(--secondary-background-color); margin-right: auto; border-bottom-left-radius: 4px; } .input-container { position: relative; width: 100%; background: var(--card-background-color); border: 1px solid var(--divider-color); border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); margin-bottom: 24px; transition: border-color 0.2s ease, box-shadow 0.2s ease; } .input-container:focus-within { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.1); } .input-main { display: flex; align-items: flex-end; padding: 12px; gap: 12px; } .input-wrapper { flex-grow: 1; position: relative; border: 1px solid var(--divider-color); } textarea { width: 100%; min-height: 24px; max-height: 200px; padding: 12px 16px 12px 16px; border: none; outline: none; resize: none; font-size: 16px; line-height: 1.5; background: transparent; color: var(--primary-text-color); font-family: inherit; } textarea::placeholder { color: var(--secondary-text-color); } .input-footer { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px 12px 16px; border-top: 1px solid var(--divider-color); background: var(--card-background-color); border-radius: 0 0 12px 12px; } .provider-selector { position: relative; display: flex; align-items: center; gap: 8px; } .provider-button { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; color: var(--primary-text-color); transition: all 0.2s ease; min-width: 150px; -webkit-appearance: none; -moz-appearance: none; appearance: none; background-image: url('data:image/svg+xml;charset=US-ASCII,'); background-repeat: no-repeat; background-position: right 8px center; padding-right: 30px; } .provider-button:hover { background-color: var(--primary-background-color); border-color: var(--primary-color); } .provider-button:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.2); } .provider-label { font-size: 12px; color: var(--secondary-text-color); margin-right: 8px; } .thinking-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--secondary-text-color); cursor: pointer; user-select: none; } .thinking-toggle input { margin: 0; } .thinking-panel { border: 1px dashed var(--divider-color); border-radius: 10px; padding: 10px 12px; margin: 12px 0; background: var(--secondary-background-color); } .thinking-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; gap: 10px; } .thinking-title { font-weight: 600; color: var(--primary-text-color); font-size: 14px; } .thinking-subtitle { display: block; font-size: 12px; color: var(--secondary-text-color); margin-top: 2px; } .thinking-body { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; max-height: 240px; overflow-y: auto; } .thinking-entry { border: 1px solid var(--divider-color); border-radius: 8px; padding: 8px; background: var(--primary-background-color); } .thinking-entry .badge { display: inline-block; background: var(--secondary-background-color); color: var(--secondary-text-color); font-size: 11px; padding: 2px 6px; border-radius: 6px; margin-bottom: 6px; } .thinking-entry pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 12px; } .thinking-empty { color: var(--secondary-text-color); font-size: 12px; } .send-button { --mdc-theme-primary: var(--primary-color); --mdc-theme-on-primary: var(--text-primary-color); --mdc-typography-button-font-size: 14px; --mdc-typography-button-text-transform: none; --mdc-typography-button-letter-spacing: 0; --mdc-typography-button-font-weight: 500; --mdc-button-height: 36px; --mdc-button-padding: 0 16px; border-radius: 8px; transition: all 0.2s ease; min-width: 80px; } .send-button:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .send-button:active { transform: translateY(0); } .send-button:disabled { opacity: 0.5; cursor: not-allowed; } .loading { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; background: var(--secondary-background-color); margin-right: auto; max-width: 80%; animation: fadeIn 0.3s ease-out; } .loading-dots { display: flex; gap: 4px; } .dot { width: 8px; height: 8px; background: var(--primary-color); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; } .dot:nth-child(1) { animation-delay: -0.32s; } .dot:nth-child(2) { animation-delay: -0.16s; } @keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1.0); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .error { color: var(--error-color); padding: 16px; margin: 8px 0; border-radius: 12px; background: var(--error-background-color); border: 1px solid var(--error-color); animation: fadeIn 0.3s ease-out; } .automation-suggestion { background: var(--secondary-background-color); border: 1px solid var(--primary-color); border-radius: 12px; padding: 16px; margin: 8px 0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); position: relative; z-index: 10; } .automation-title { font-weight: 500; margin-bottom: 8px; color: var(--primary-color); font-size: 16px; } .automation-description { margin-bottom: 16px; color: var(--secondary-text-color); line-height: 1.4; } .automation-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; } .automation-actions ha-button { --mdc-button-height: 40px; --mdc-button-padding: 0 20px; --mdc-typography-button-font-size: 14px; --mdc-typography-button-font-weight: 600; border-radius: 20px; } .automation-actions ha-button:first-child { --mdc-theme-primary: var(--success-color, #4caf50); --mdc-theme-on-primary: #fff; } .automation-actions ha-button:last-child { --mdc-theme-primary: var(--error-color); --mdc-theme-on-primary: #fff; } .automation-details { margin-top: 8px; padding: 8px; background: var(--primary-background-color); border-radius: 8px; font-family: monospace; font-size: 12px; white-space: pre-wrap; overflow-x: auto; max-height: 200px; overflow-y: auto; border: 1px solid var(--divider-color); } .dashboard-suggestion { background: var(--secondary-background-color); border: 1px solid var(--info-color, #2196f3); border-radius: 12px; padding: 16px; margin: 8px 0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); position: relative; z-index: 10; } .dashboard-title { font-weight: 500; margin-bottom: 8px; color: var(--info-color, #2196f3); font-size: 16px; } .dashboard-description { margin-bottom: 16px; color: var(--secondary-text-color); line-height: 1.4; } .dashboard-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; } .dashboard-actions ha-button { --mdc-button-height: 40px; --mdc-button-padding: 0 20px; --mdc-typography-button-font-size: 14px; --mdc-typography-button-font-weight: 600; border-radius: 20px; } .dashboard-actions ha-button:first-child { --mdc-theme-primary: var(--info-color, #2196f3); --mdc-theme-on-primary: #fff; } .dashboard-actions ha-button:last-child { --mdc-theme-primary: var(--error-color); --mdc-theme-on-primary: #fff; } .dashboard-details { margin-top: 8px; padding: 8px; background: var(--primary-background-color); border-radius: 8px; font-family: monospace; font-size: 12px; white-space: pre-wrap; overflow-x: auto; max-height: 200px; overflow-y: auto; border: 1px solid var(--divider-color); } .no-providers { color: var(--error-color); font-size: 14px; padding: 8px; } `; } constructor() { super(); this._messages = []; this._isLoading = false; this._error = null; this._pendingAutomation = null; this._promptHistory = []; this._promptHistoryLoaded = false; this._showPredefinedPrompts = true; this._showPromptHistory = true; this._predefinedPrompts = [ "Build a new automation to turn off all lights at 10:00 PM every day", "What's the current temperature inside and outside?", "Turn on all the lights in the living room", "Show me today's weather forecast", "What devices are currently on?", "Show me the energy usage for today", "Are all the doors and windows locked?", "Turn on movie mode in the living room", "What's the status of my security system?", "Show me who's currently home", "Turn off all devices when I leave home" ]; this._selectedPrompts = this._getRandomPrompts(); this._selectedProvider = null; this._availableProviders = []; this._showProviderDropdown = false; this.providersLoaded = false; this._eventSubscriptionSetup = false; this._serviceCallTimeout = null; this._showThinking = false; this._thinkingExpanded = false; this._debugInfo = null; console.debug("AI Agent HA Panel constructor called"); } _getRandomPrompts() { // Shuffle array and take first 3 items const shuffled = [...this._predefinedPrompts].sort(() => 0.5 - Math.random()); return shuffled.slice(0, 3); } async connectedCallback() { super.connectedCallback(); console.debug("AI Agent HA Panel connected"); if (this.hass && !this._eventSubscriptionSetup) { this._eventSubscriptionSetup = true; this.hass.connection.subscribeEvents( (event) => this._handleLlamaResponse(event), 'ai_agent_ha_response' ); console.debug("Event subscription set up in connectedCallback()"); // Load prompt history from Home Assistant storage await this._loadPromptHistory(); } // Close dropdown when clicking outside document.addEventListener('click', (e) => { if (!this.shadowRoot.querySelector('.provider-selector')?.contains(e.target)) { this._showProviderDropdown = false; } }); } async updated(changedProps) { console.debug("Updated called with:", changedProps); // Set up event subscription when hass becomes available if (changedProps.has('hass') && this.hass && !this._eventSubscriptionSetup) { this._eventSubscriptionSetup = true; this.hass.connection.subscribeEvents( (event) => this._handleLlamaResponse(event), 'ai_agent_ha_response' ); console.debug("Event subscription set up in updated()"); } // Load providers when hass becomes available if (changedProps.has('hass') && this.hass && !this.providersLoaded) { this.providersLoaded = true; try { // Uses the WebSocket API to get all entries with their complete data const allEntries = await this.hass.callWS({ type: 'config_entries/get' }); const aiAgentEntries = allEntries.filter( entry => entry.domain === 'ai_agent_ha' ); if (aiAgentEntries.length > 0) { const providers = aiAgentEntries .map(entry => { const provider = this._resolveProviderFromEntry(entry); if (!provider) return null; return { value: provider, label: PROVIDERS[provider] || provider }; }) .filter(Boolean); this._availableProviders = providers; if ( (!this._selectedProvider || !providers.find(p => p.value === this._selectedProvider)) && providers.length > 0 ) { this._selectedProvider = providers[0].value; } } else { console.debug("No 'ai_agent_ha' config entries found via WebSocket."); this._availableProviders = []; } } catch (error) { console.error("Error fetching config entries via WebSocket:", error); this._error = error.message || 'Failed to load AI provider configurations.'; this._availableProviders = []; } this.requestUpdate(); } // Load prompt history when hass becomes available and we haven't loaded it yet if (changedProps.has('hass') && this.hass && !this._promptHistoryLoaded) { this._promptHistoryLoaded = true; await this._loadPromptHistory(); } // Load prompt history when provider changes if (changedProps.has('_selectedProvider') && this._selectedProvider && this.hass) { await this._loadPromptHistory(); } if (changedProps.has('_messages') || changedProps.has('_isLoading')) { this._scrollToBottom(); } } _renderPromptsSection() { return html`
Quick Actions
this._togglePredefinedPrompts()}> Suggestions
${this._promptHistory.length > 0 ? html`
this._togglePromptHistory()}> Recent
` : ''}
${this._showPredefinedPrompts ? html`
${this._selectedPrompts.map(prompt => html`
this._usePrompt(prompt)}> ${prompt}
`)}
` : ''} ${this._showPromptHistory && this._promptHistory.length > 0 ? html`
${this._promptHistory.slice(-3).reverse().map((prompt, index) => html`
this._useHistoryPrompt(e, prompt)}> ${prompt} this._deleteHistoryItem(e, prompt)} >
`)}
` : ''}
`; } _togglePredefinedPrompts() { this._showPredefinedPrompts = !this._showPredefinedPrompts; // Refresh random selection when toggling on if (this._showPredefinedPrompts) { this._selectedPrompts = this._getRandomPrompts(); } } _togglePromptHistory() { this._showPromptHistory = !this._showPromptHistory; } _usePrompt(prompt) { if (this._isLoading) return; const promptEl = this.shadowRoot.querySelector('#prompt'); if (promptEl) { promptEl.value = prompt; promptEl.focus(); } } _useHistoryPrompt(event, prompt) { event.stopPropagation(); if (this._isLoading) return; const promptEl = this.shadowRoot.querySelector('#prompt'); if (promptEl) { promptEl.value = prompt; promptEl.focus(); } } async _deleteHistoryItem(event, prompt) { event.stopPropagation(); this._promptHistory = this._promptHistory.filter(p => p !== prompt); await this._savePromptHistory(); this.requestUpdate(); } async _addToHistory(prompt) { if (!prompt || prompt.trim().length === 0) return; // Remove duplicates and add to front this._promptHistory = this._promptHistory.filter(p => p !== prompt); this._promptHistory.push(prompt); // Keep only last 20 prompts if (this._promptHistory.length > 20) { this._promptHistory = this._promptHistory.slice(-20); } await this._savePromptHistory(); this.requestUpdate(); } async _loadPromptHistory() { if (!this.hass) { console.debug('Hass not available, skipping prompt history load'); return; } console.debug('Loading prompt history...'); try { const result = await this.hass.callService('ai_agent_ha', 'load_prompt_history', { provider: this._selectedProvider }); console.debug('Prompt history service result:', result); if (result && result.response && result.response.history) { this._promptHistory = result.response.history; console.debug('Loaded prompt history from service:', this._promptHistory); this.requestUpdate(); } else if (result && result.history) { this._promptHistory = result.history; console.debug('Loaded prompt history from service (direct):', this._promptHistory); this.requestUpdate(); } else { console.debug('No prompt history returned from service, checking localStorage'); // Fallback to localStorage if service returns no data this._loadFromLocalStorage(); } } catch (error) { console.error('Error loading prompt history from service:', error); // Fallback to localStorage if service fails this._loadFromLocalStorage(); } } _loadFromLocalStorage() { try { const savedList = localStorage.getItem('ai_agent_ha_prompt_history'); if (savedList) { const parsedList = JSON.parse(savedList); const saved = parsedList.history && parsedList.provider === this._selectedProvider ? parsedList.history : null; if (saved) { this._promptHistory = JSON.parse(saved); console.debug('Loaded prompt history from localStorage:', this._promptHistory); this.requestUpdate(); } else { console.debug('No prompt history in localStorage'); this._promptHistory = []; } } } catch (e) { console.error('Error loading from localStorage:', e); this._promptHistory = []; } } async _savePromptHistory() { if (!this.hass) { console.debug('Hass not available, saving to localStorage only'); this._saveToLocalStorage(); return; } console.debug('Saving prompt history:', this._promptHistory); try { const result = await this.hass.callService('ai_agent_ha', 'save_prompt_history', { history: this._promptHistory, provider: this._selectedProvider }); console.debug('Save prompt history result:', result); // Also save to localStorage as backup this._saveToLocalStorage(); } catch (error) { console.error('Error saving prompt history to service:', error); // Fallback to localStorage if service fails this._saveToLocalStorage(); } } _saveToLocalStorage() { try { const data = { provider: this._selectedProvider, history: JSON.stringify(this._promptHistory) } localStorage.setItem('ai_agent_ha_prompt_history', JSON.stringify(data)); console.debug('Saved prompt history to localStorage'); } catch (e) { console.error('Error saving to localStorage:', e); } } render() { console.debug("Rendering with state:", { messages: this._messages, isLoading: this._isLoading, error: this._error }); console.debug("Messages array:", this._messages); return html`
AI Agent HA
${this._messages.map(msg => html`
${msg.text} ${msg.automation ? html`
${msg.automation.alias}
${msg.automation.description}
${JSON.stringify(msg.automation, null, 2)}
this._approveAutomation(msg.automation)} .disabled=${this._isLoading} >Approve this._rejectAutomation()} .disabled=${this._isLoading} >Reject
` : ''} ${msg.dashboard ? html`
${msg.dashboard.title}
Dashboard with ${msg.dashboard.views ? msg.dashboard.views.length : 0} view(s)
${JSON.stringify(msg.dashboard, null, 2)}
this._approveDashboard(msg.dashboard)} .disabled=${this._isLoading} >Create Dashboard this._rejectDashboard()} .disabled=${this._isLoading} >Cancel
` : ''}
`)} ${this._isLoading ? html`
AI Agent is thinking
` : ''} ${this._error ? html`
${this._error}
` : ''} ${this._showThinking ? this._renderThinkingPanel() : ''}
${this._renderPromptsSection()}
`; } _scrollToBottom() { const messages = this.shadowRoot.querySelector('#messages'); if (messages) { messages.scrollTop = messages.scrollHeight; } } _autoResize(e) { const textarea = e.target; textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; } _handleKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey && !this._isLoading) { e.preventDefault(); this._sendMessage(); } } _toggleProviderDropdown() { this._showProviderDropdown = !this._showProviderDropdown; console.log("Toggling provider dropdown:", this._showProviderDropdown); this.requestUpdate(); // Añade esta línea para forzar la actualización } async _selectProvider(provider) { this._selectedProvider = provider; console.debug("Provider changed to:", provider); await this._loadPromptHistory(); this.requestUpdate(); } _getSelectedProviderLabel() { const provider = this._availableProviders.find(p => p.value === this._selectedProvider); return provider ? provider.label : 'Select Model'; } async _sendMessage() { const promptEl = this.shadowRoot.querySelector('#prompt'); const prompt = promptEl.value.trim(); if (!prompt || this._isLoading) return; console.debug("Sending message:", prompt); console.debug("Sending message with provider:", this._selectedProvider); // Add to history await this._addToHistory(prompt); // Add user message this._messages = [...this._messages, { type: 'user', text: prompt }]; promptEl.value = ''; promptEl.style.height = 'auto'; this._isLoading = true; this._error = null; this._debugInfo = null; this._thinkingExpanded = false; // keep collapsed until a trace arrives // Clear any existing timeout if (this._serviceCallTimeout) { clearTimeout(this._serviceCallTimeout); } // Set timeout to clear loading state after 10 minutes (for slower/local models) this._serviceCallTimeout = setTimeout(() => { if (this._isLoading) { console.warn("Service call timeout - clearing loading state"); this._isLoading = false; this._error = 'Request timed out. Please try again.'; this._messages = [...this._messages, { type: 'assistant', text: 'Sorry, the request timed out. Please try again.' }]; this.requestUpdate(); } }, 600000); // 10 minute timeout try { console.debug("Calling ai_agent_ha service"); await this.hass.callService('ai_agent_ha', 'query', { prompt: prompt, provider: this._selectedProvider, debug: this._showThinking }); } catch (error) { console.error("Error calling service:", error); this._clearLoadingState(); this._error = error.message || 'An error occurred while processing your request'; this._messages = [...this._messages, { type: 'assistant', text: `Error: ${this._error}` }]; } } _clearLoadingState() { this._isLoading = false; if (this._serviceCallTimeout) { clearTimeout(this._serviceCallTimeout); this._serviceCallTimeout = null; } } _handleLlamaResponse(event) { console.debug("Received llama response:", event); try { this._clearLoadingState(); this._debugInfo = this._showThinking ? (event.data.debug || null) : null; if (this._showThinking && this._debugInfo) { this._thinkingExpanded = true; } if (event.data.success) { // Check if the answer is empty if (!event.data.answer || event.data.answer.trim() === '') { console.warn("AI agent returned empty response"); this._messages = [ ...this._messages, { type: 'assistant', text: 'I received your message but I\'m not sure how to respond. Could you please try rephrasing your question?' } ]; return; } let message = { type: 'assistant', text: event.data.answer }; // Check if the response contains an automation or dashboard suggestion try { console.debug("Attempting to parse response as JSON:", event.data.answer); let jsonText = event.data.answer; // Try to extract JSON from mixed text+JSON responses const jsonMatch = jsonText.match(/\{[\s\S]*\}/); if (jsonMatch && jsonMatch[0] !== jsonText.trim()) { console.debug("Found JSON within mixed response, extracting:", jsonMatch[0]); jsonText = jsonMatch[0]; } const response = JSON.parse(jsonText); console.debug("Parsed JSON response:", response); if (response.request_type === 'automation_suggestion') { console.debug("Found automation suggestion"); message.automation = response.automation; message.text = response.message || 'I found an automation that might help you. Would you like me to create it?'; } else if (response.request_type === 'dashboard_suggestion') { console.debug("Found dashboard suggestion:", response.dashboard); message.dashboard = response.dashboard; message.text = response.message || 'I created a dashboard configuration for you. Would you like me to create it?'; } else if (response.request_type === 'final_response') { // If it's a final response, use the response field message.text = response.response || response.message || event.data.answer; } else if (response.message) { // If there's a message field, use it message.text = response.message; } else if (response.response) { // If there's a response field, use it message.text = response.response; } // If none of the above, keep the original event.data.answer as message.text } catch (e) { // Not a JSON response, treat as normal message console.debug("Response is not JSON, using as-is:", event.data.answer); console.debug("JSON parse error:", e); // message.text is already set to event.data.answer } console.debug("Adding message to UI:", message); this._messages = [...this._messages, message]; } else { this._error = event.data.error || 'An error occurred'; this._messages = [ ...this._messages, { type: 'assistant', text: `Error: ${this._error}` } ]; } } catch (error) { console.error("Error in _handleLlamaResponse:", error); this._clearLoadingState(); this._error = 'An error occurred while processing the response'; this._messages = [...this._messages, { type: 'assistant', text: 'Sorry, an error occurred while processing the response. Please try again.' }]; this.requestUpdate(); } } async _approveAutomation(automation) { if (this._isLoading) return; this._isLoading = true; try { const result = await this.hass.callService('ai_agent_ha', 'create_automation', { automation: automation }); console.debug("Automation creation result:", result); // The result should be an object with a message property if (result && result.message) { this._messages = [...this._messages, { type: 'assistant', text: result.message }]; } else { // Fallback success message if no message is provided this._messages = [...this._messages, { type: 'assistant', text: `Automation "${automation.alias}" has been created successfully!` }]; } } catch (error) { console.error("Error creating automation:", error); this._error = error.message || 'An error occurred while creating the automation'; this._messages = [...this._messages, { type: 'assistant', text: `Error: ${this._error}` }]; } finally { this._clearLoadingState(); } } _rejectAutomation() { this._messages = [...this._messages, { type: 'assistant', text: 'Automation creation cancelled. Would you like to try something else?' }]; } async _approveDashboard(dashboard) { if (this._isLoading) return; this._isLoading = true; try { const result = await this.hass.callService('ai_agent_ha', 'create_dashboard', { dashboard_config: dashboard }); console.debug("Dashboard creation result:", result); // The result should be an object with a message property if (result && result.message) { this._messages = [...this._messages, { type: 'assistant', text: result.message }]; } else { // Fallback success message if no message is provided this._messages = [...this._messages, { type: 'assistant', text: `Dashboard "${dashboard.title}" has been created successfully!` }]; } } catch (error) { console.error("Error creating dashboard:", error); this._error = error.message || 'An error occurred while creating the dashboard'; this._messages = [...this._messages, { type: 'assistant', text: `Error: ${this._error}` }]; } finally { this._clearLoadingState(); } } _rejectDashboard() { this._messages = [...this._messages, { type: 'assistant', text: 'Dashboard creation cancelled. Would you like me to create a different dashboard?' }]; } shouldUpdate(changedProps) { // Only update if internal state changes, not on every hass update return changedProps.has('_messages') || changedProps.has('_isLoading') || changedProps.has('_error') || changedProps.has('_promptHistory') || changedProps.has('_showPredefinedPrompts') || changedProps.has('_showPromptHistory') || changedProps.has('_availableProviders') || changedProps.has('_selectedProvider') || changedProps.has('_showProviderDropdown'); } _clearChat() { this._messages = []; this._clearLoadingState(); this._error = null; this._pendingAutomation = null; this._debugInfo = null; // Don't clear prompt history - users might want to keep it } _resolveProviderFromEntry(entry) { if (!entry) return null; const providerFromData = entry.data?.ai_provider || entry.options?.ai_provider; if (providerFromData && PROVIDERS[providerFromData]) { return providerFromData; } const uniqueId = entry.unique_id || entry.uniqueId; if (uniqueId && uniqueId.startsWith("ai_agent_ha_")) { const fromUniqueId = uniqueId.replace("ai_agent_ha_", ""); if (PROVIDERS[fromUniqueId]) { return fromUniqueId; } } // Fallback: try to match from title (case-insensitive, partial match) if (entry.title) { const lowerTitle = entry.title.toLowerCase(); // Direct keyword match for known providers const keywordMap = [ { key: "openai_compatible", keywords: ["openai-compatible", "openai compatible"] }, { key: "local_ollama", keywords: ["local ollama", "ollama"] }, { key: "openrouter", keywords: ["openrouter"] }, { key: "gemini", keywords: ["google gemini", "gemini"] }, { key: "openai", keywords: ["openai"] }, { key: "llama", keywords: ["llama"] }, { key: "anthropic", keywords: ["anthropic", "claude"] }, { key: "alter", keywords: ["alter"] }, { key: "zai", keywords: ["z.ai"] }, { key: "local", keywords: ["local model"] }, ]; for (const { key, keywords } of keywordMap) { if (PROVIDERS[key] && keywords.some(k => lowerTitle.includes(k))) { return key; } } } return null; } _getProviderInfo(providerId) { return this._availableProviders.find(p => p.value === providerId); } _hasProviders() { return this._availableProviders && this._availableProviders.length > 0; } _toggleThinkingPanel() { this._thinkingExpanded = !this._thinkingExpanded; } _toggleShowThinking(e) { this._showThinking = e.target.checked; if (!this._showThinking) { this._thinkingExpanded = false; } } _renderThinkingPanel() { if (!this._debugInfo) { return ''; } const subtitleParts = []; if (this._debugInfo.provider) subtitleParts.push(this._debugInfo.provider); if (this._debugInfo.model) subtitleParts.push(this._debugInfo.model); if (this._debugInfo.endpoint_type) subtitleParts.push(this._debugInfo.endpoint_type); const subtitle = subtitleParts.join(" · "); const conversation = this._debugInfo.conversation || []; return html`
this._toggleThinkingPanel()}>
Thinking trace ${subtitle ? html`${subtitle}` : ''}
${this._thinkingExpanded ? html`
${conversation.length === 0 ? html`
No trace captured.
` : conversation.map((entry, index) => html`
${entry.role || 'unknown'}
${entry.content || ''}
`)}
` : ''}
`; } } customElements.define("ai_agent_ha-panel", AiAgentHaPanel); console.log("AI Agent HA Panel registered");