From ed8c9f5ce5853e0f51bbd4a21dc0291f06cddd96 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 16 Jul 2025 07:20:37 +0200 Subject: [PATCH] AI Task automation save improvements (#26140) * also assign category * Extract Suggest AI button * Add sick animation * Show AI Task pref but disabled if not done loading * Lint * Update progress wording * Define better interface * Add My panel * Adjust instructions to params.domain * Mention sentence capitalization * Update label when failure * Keep width during suggestion --- src/components/ha-suggest-with-ai-button.ts | 201 +++++++++++++++++ src/data/ai_task.ts | 14 +- .../dialog-automation-save.ts | 211 +++++++++++------- src/panels/config/core/ai-task-pref.ts | 14 +- .../config/core/ha-config-section-general.ts | 22 +- src/panels/my/ha-panel-my.ts | 3 + src/translations/en.json | 11 +- 7 files changed, 369 insertions(+), 107 deletions(-) create mode 100644 src/components/ha-suggest-with-ai-button.ts diff --git a/src/components/ha-suggest-with-ai-button.ts b/src/components/ha-suggest-with-ai-button.ts new file mode 100644 index 0000000000..f1bef06fd3 --- /dev/null +++ b/src/components/ha-suggest-with-ai-button.ts @@ -0,0 +1,201 @@ +import type { PropertyValues } from "lit"; +import { html, css, LitElement, nothing } from "lit"; +import { mdiStarFourPoints } from "@mdi/js"; + +import { customElement, state, property } from "lit/decorators"; +import type { + AITaskPreferences, + GenDataTask, + GenDataTaskResult, +} from "../data/ai_task"; +import { fetchAITaskPreferences, generateDataAITask } from "../data/ai_task"; +import "./chips/ha-assist-chip"; +import "./ha-svg-icon"; +import type { HomeAssistant } from "../types"; +import { fireEvent } from "../common/dom/fire_event"; +import { isComponentLoaded } from "../common/config/is_component_loaded"; + +declare global { + interface HASSDomEvents { + suggestion: GenDataTaskResult; + } +} + +export interface SuggestWithAIGenerateTask { + type: "data"; + task: GenDataTask; +} + +@customElement("ha-suggest-with-ai-button") +export class HaSuggestWithAIButton extends LitElement { + @property({ attribute: false }) + public hass!: HomeAssistant; + + @property({ attribute: "task-type" }) + public taskType!: "data"; + + @property({ attribute: false }) + generateTask!: () => SuggestWithAIGenerateTask; + + @state() + private _aiPrefs?: AITaskPreferences; + + @state() + private _state: { + status: "idle" | "suggesting" | "error" | "done"; + suggestionIndex: 1 | 2 | 3; + } = { + status: "idle", + suggestionIndex: 1, + }; + + @state() + private _minWidth?: string; + + private _intervalId?: number; + + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + if (!this.hass || !isComponentLoaded(this.hass, "ai_task")) { + return; + } + fetchAITaskPreferences(this.hass).then((prefs) => { + this._aiPrefs = prefs; + }); + } + + render() { + if (!this._aiPrefs || !this._aiPrefs.gen_data_entity_id) { + return nothing; + } + + let label: string; + switch (this._state.status) { + case "error": + label = this.hass.localize("ui.components.suggest_with_ai.error"); + break; + case "done": + label = this.hass.localize("ui.components.suggest_with_ai.done"); + break; + case "suggesting": + label = this.hass.localize( + `ui.components.suggest_with_ai.suggesting_${this._state.suggestionIndex}` + ); + break; + default: + label = this.hass.localize("ui.components.suggest_with_ai.label"); + } + + return html` + + + + `; + } + + private async _suggest() { + if (!this.generateTask || this._state.status === "suggesting") { + return; + } + + // Capture current width before changing state + const chip = this.shadowRoot?.querySelector("ha-assist-chip"); + if (chip) { + this._minWidth = `${chip.offsetWidth}px`; + } + + // Reset to suggesting state + this._state = { + status: "suggesting", + suggestionIndex: 1, + }; + + try { + // Start cycling through suggestion texts + this._intervalId = window.setInterval(() => { + this._state = { + ...this._state, + suggestionIndex: ((this._state.suggestionIndex % 3) + 1) as 1 | 2 | 3, + }; + }, 3000); + + const info = await this.generateTask(); + let result: GenDataTaskResult; + if (info.type === "data") { + result = await generateDataAITask(this.hass, info.task); + } else { + throw new Error("Unsupported task type"); + } + + fireEvent(this, "suggestion", result); + + // Show success state + this._state = { + ...this._state, + status: "done", + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error generating AI suggestion:", error); + + this._state = { + ...this._state, + status: "error", + }; + } finally { + if (this._intervalId) { + clearInterval(this._intervalId); + this._intervalId = undefined; + } + setTimeout(() => { + this._state = { + ...this._state, + status: "idle", + }; + this._minWidth = undefined; + }, 3000); + } + } + + static styles = css` + ha-assist-chip[active] { + animation: pulse-glow 1.5s ease-in-out infinite; + } + + ha-assist-chip.error { + box-shadow: 0 0 12px 4px rgba(var(--rgb-error-color), 0.8); + } + + ha-assist-chip.done { + box-shadow: 0 0 12px 4px rgba(var(--rgb-primary-color), 0.8); + } + + @keyframes pulse-glow { + 0% { + box-shadow: 0 0 0 0 rgba(var(--rgb-primary-color), 0); + } + 50% { + box-shadow: 0 0 8px 2px rgba(var(--rgb-primary-color), 0.6); + } + 100% { + box-shadow: 0 0 0 0 rgba(var(--rgb-primary-color), 0); + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-suggest-with-ai-button": HaSuggestWithAIButton; + } +} diff --git a/src/data/ai_task.ts b/src/data/ai_task.ts index c89f6607fe..83f0293e7a 100644 --- a/src/data/ai_task.ts +++ b/src/data/ai_task.ts @@ -5,6 +5,13 @@ export interface AITaskPreferences { gen_data_entity_id: string | null; } +export interface GenDataTask { + task_name: string; + entity_id?: string; + instructions: string; + structure?: AITaskStructure; +} + export interface GenDataTaskResult { conversation_id: string; data: T; @@ -34,12 +41,7 @@ export const saveAITaskPreferences = ( export const generateDataAITask = async ( hass: HomeAssistant, - task: { - task_name: string; - entity_id?: string; - instructions: string; - structure?: AITaskStructure; - } + task: GenDataTask ): Promise> => { const result = await hass.callService>( "ai_task", diff --git a/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts b/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts index 6500646071..f901e428ce 100644 --- a/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts +++ b/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts @@ -1,16 +1,19 @@ import "@material/mwc-button"; -import type { CSSResultGroup, PropertyValues } from "lit"; +import type { CSSResultGroup } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { mdiClose, mdiPlus, mdiStarFourPoints } from "@mdi/js"; +import { mdiClose, mdiPlus } from "@mdi/js"; import { dump } from "js-yaml"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-alert"; import "../../../../components/ha-domain-icon"; import "../../../../components/ha-icon-picker"; +import "../../../../components/ha-svg-icon"; import "../../../../components/ha-textarea"; import "../../../../components/ha-textfield"; import "../../../../components/ha-labels-picker"; +import "../../../../components/ha-suggest-with-ai-button"; +import type { SuggestWithAIGenerateTask } from "../../../../components/ha-suggest-with-ai-button"; import "../../category/ha-category-picker"; import "../../../../components/ha-expansion-panel"; import "../../../../components/chips/ha-chip-set"; @@ -25,14 +28,12 @@ import type { SaveDialogParams, } from "./show-dialog-automation-save"; import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support"; -import { - fetchAITaskPreferences, - generateDataAITask, -} from "../../../../data/ai_task"; -import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; +import type { GenDataTaskResult } from "../../../../data/ai_task"; import { computeStateDomain } from "../../../../common/entity/compute_state_domain"; import { subscribeOne } from "../../../../common/util/subscribe-one"; import { subscribeLabelRegistry } from "../../../../data/label_registry"; +import { subscribeEntityRegistry } from "../../../../data/entity_registry"; +import { fetchCategoryRegistry } from "../../../../data/category_registry"; @customElement("ha-dialog-automation-save") class DialogAutomationSave extends LitElement implements HassDialog { @@ -46,8 +47,6 @@ class DialogAutomationSave extends LitElement implements HassDialog { @state() private _entryUpdates!: EntityRegistryUpdate; - @state() private _canSuggest = false; - private _params!: SaveDialogParams; @state() private _newName?: string; @@ -92,15 +91,6 @@ class DialogAutomationSave extends LitElement implements HassDialog { return true; } - protected firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - if (isComponentLoaded(this.hass, "ai_task")) { - fetchAITaskPreferences(this.hass).then((prefs) => { - this._canSuggest = prefs.gen_data_entity_id !== null; - }); - } - } - protected _renderOptionalChip(id: string, label: string) { if (this._visibleOptionals.includes(id)) { return nothing; @@ -270,21 +260,12 @@ class DialogAutomationSave extends LitElement implements HassDialog { .path=${mdiClose} > ${this._params.title || title} - ${this._canSuggest - ? html` - - - - ` - : nothing} + ${this._error ? html` - Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name])) - ); - const automationInspiration: string[] = []; + private _getSuggestData() { + return Promise.all([ + subscribeOne(this.hass.connection, subscribeLabelRegistry).then((labs) => + Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name])) + ), + subscribeOne(this.hass.connection, subscribeEntityRegistry).then((ents) => + Object.fromEntries(ents.map((ent) => [ent.entity_id, ent])) + ), + fetchCategoryRegistry(this.hass.connection, "automation").then((cats) => + Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name])) + ), + ]); + } - for (const automation of Object.values(this.hass.states)) { - const entityEntry = this.hass.entities[automation.entity_id]; + private _generateTask = async (): Promise => { + const [labels, entities, categories] = await this._getSuggestData(); + const inspirations: string[] = []; + + const domain = this._params.domain; + + for (const entity of Object.values(this.hass.states)) { + const entityEntry = entities[entity.entity_id]; if ( - computeStateDomain(automation) !== "automation" || - automation.attributes.restored || - !automation.attributes.friendly_name || + computeStateDomain(entity) !== domain || + entity.attributes.restored || + !entity.attributes.friendly_name || !entityEntry ) { continue; } - let inspiration = `- ${automation.attributes.friendly_name}`; + let inspiration = `- ${entity.attributes.friendly_name}`; + + const category = categories[entityEntry.categories.automation]; + if (category) { + inspiration += ` (category: ${category})`; + } if (entityEntry.labels.length) { inspiration += ` (labels: ${entityEntry.labels @@ -376,59 +373,88 @@ class DialogAutomationSave extends LitElement implements HassDialog { .join(", ")})`; } - automationInspiration.push(inspiration); + inspirations.push(inspiration); } - const result = await generateDataAITask<{ - name: string; - description: string | undefined; - labels: string[] | undefined; - }>(this.hass, { - task_name: "frontend:automation:save", - instructions: `Suggest in language "${this.hass.language}" a name, description, and labels for the following Home Assistant automation. + const term = this._params.domain === "script" ? "script" : "automation"; -The name should be relevant to the automation's purpose. + return { + type: "data", + task: { + task_name: `frontend:${term}:save`, + instructions: `Suggest in language "${this.hass.language}" a name, description, category and labels for the following Home Assistant ${term}. + +The name should be relevant to the ${term}'s purpose. ${ - automationInspiration.length - ? `The name should be in same style as existing automations. -Suggest labels if relevant to the automation's purpose. -Only suggest labels that are already used by existing automations.` + inspirations.length + ? `The name should be in same style and sentence capitalization as existing ${term}s. +Suggest a category and labels if relevant to the ${term}'s purpose. +Only suggest category and labels that are already used by existing ${term}s.` : `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.` } -If the automation contains 5+ steps, include a short description. +If the ${term} contains 5+ steps, include a short description. -For inspiration, here are existing automations: -${automationInspiration.join("\n")} +For inspiration, here are existing ${term}s: +${inspirations.join("\n")} + +The ${term} configuration is as follows: -The automation configuration is as follows: ${dump(this._params.config)} `, - structure: { - name: { - description: "The name of the automation", - required: true, - selector: { - text: {}, + structure: { + name: { + description: "The name of the automation", + required: true, + selector: { + text: {}, + }, }, - }, - description: { - description: "A short description of the automation", - required: false, - selector: { - text: {}, + description: { + description: "A short description of the automation", + required: false, + selector: { + text: {}, + }, }, - }, - labels: { - description: "Labels for the automation", - required: false, - selector: { - text: { - multiple: true, + labels: { + description: "Labels for the automation", + required: false, + selector: { + text: { + multiple: true, + }, + }, + }, + category: { + description: "The category of the automation", + required: false, + selector: { + select: { + options: Object.entries(categories).map(([id, name]) => ({ + value: id, + label: name, + })), + }, }, }, }, }, - }); + }; + }; + + private async _handleSuggestion( + event: CustomEvent< + GenDataTaskResult<{ + name: string; + description?: string; + category?: string; + labels?: string[]; + }> + > + ) { + const result = event.detail; + const [labels, _entities, categories] = await this._getSuggestData(); + this._newName = result.data.name; if (result.data.description) { this._newDescription = result.data.description; @@ -436,6 +462,21 @@ ${dump(this._params.config)} this._visibleOptionals = [...this._visibleOptionals, "description"]; } } + if (result.data.category) { + // We get back category name, convert it to ID + const categoryId = Object.entries(categories).find( + ([, name]) => name === result.data.category + )?.[0]; + if (categoryId) { + this._entryUpdates = { + ...this._entryUpdates, + category: categoryId, + }; + if (!this._visibleOptionals.includes("category")) { + this._visibleOptionals = [...this._visibleOptionals, "category"]; + } + } + } if (result.data.labels?.length) { // We get back label names, convert them to IDs const newLabels: Record = Object.fromEntries( @@ -535,7 +576,7 @@ ${dump(this._params.config)} --mdc-theme-primary: var(--error-color); } - #suggest { + ha-suggest-with-ai-button { margin: 8px 16px; } `, diff --git a/src/panels/config/core/ai-task-pref.ts b/src/panels/config/core/ai-task-pref.ts index 2547f8a4ad..ec99cfa907 100644 --- a/src/panels/config/core/ai-task-pref.ts +++ b/src/panels/config/core/ai-task-pref.ts @@ -1,6 +1,6 @@ import "@material/mwc-button"; import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js"; -import { css, html, LitElement, nothing } from "lit"; +import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import "../../../components/ha-card"; import "../../../components/ha-settings-row"; @@ -14,6 +14,7 @@ import { type AITaskPreferences, } from "../../../data/ai_task"; import { documentationUrl } from "../../../util/documentation-url"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; @customElement("ai-task-pref") export class AITaskPref extends LitElement { @@ -25,16 +26,15 @@ export class AITaskPref extends LitElement { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); + if (!this.hass || !isComponentLoaded(this.hass, "ai_task")) { + return; + } fetchAITaskPreferences(this.hass).then((prefs) => { this._prefs = prefs; }); } protected render() { - if (!this._prefs) { - return nothing; - } - return html`

@@ -84,7 +84,9 @@ export class AITaskPref extends LitElement { diff --git a/src/panels/config/core/ha-config-section-general.ts b/src/panels/config/core/ha-config-section-general.ts index 1d014dfd2b..0636f01a5a 100644 --- a/src/panels/config/core/ha-config-section-general.ts +++ b/src/panels/config/core/ha-config-section-general.ts @@ -1,6 +1,6 @@ import type { TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property, state, query } from "lit/decorators"; import { UNIT_C } from "../../../common/const"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { navigate } from "../../../common/navigate"; @@ -26,9 +26,9 @@ import { saveCoreConfig } from "../../../data/core"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-subpage"; import "./ai-task-pref"; +import type { AITaskPref } from "./ai-task-pref"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, ValueChangedEvent } from "../../../types"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; @customElement("ha-config-section-general") class HaConfigSectionGeneral extends LitElement { @@ -58,6 +58,8 @@ class HaConfigSectionGeneral extends LitElement { @state() private _updateUnits?: boolean; + @query("ai-task-pref") private _aiTaskPref!: AITaskPref; + protected render(): TemplateResult { const canEdit = ["storage", "default"].includes( this.hass.config.config_source @@ -267,12 +269,10 @@ class HaConfigSectionGeneral extends LitElement { - ${isComponentLoaded(this.hass, "ai_task") - ? html`` - : nothing} + `; @@ -293,6 +293,12 @@ class HaConfigSectionGeneral extends LitElement { this._timeZone = this.hass.config.time_zone || "Etc/GMT"; this._name = this.hass.config.location_name; this._updateUnits = true; + + if (window.location.hash === "#ai-task") { + this._aiTaskPref.updateComplete.then(() => { + this._aiTaskPref.scrollIntoView(); + }); + } } private _handleValueChanged(ev: ValueChangedEvent) { diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index c1e091aaa1..ed1e21d7df 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -123,6 +123,9 @@ export const getMyRedirects = (): Redirects => ({ component: "bluetooth", redirect: "/config/bluetooth/visualization", }, + config_ai_task: { + redirect: "/config/general/#ai-task", + }, config_bluetooth: { component: "bluetooth", redirect: "/config/bluetooth", diff --git a/src/translations/en.json b/src/translations/en.json index 847f27f3cf..ccc28368a6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -436,8 +436,7 @@ "replace": "Replace", "append": "Append", "supports_markdown": "Supports {markdown_help_link}", - "markdown": "Markdown", - "suggest_ai": "Suggest with AI" + "markdown": "Markdown" }, "components": { @@ -1219,6 +1218,14 @@ }, "combo-box": { "no_match": "No matching items found" + }, + "suggest_with_ai": { + "label": "Suggest with AI", + "suggesting_1": "Analyzing…", + "suggesting_2": "Suggesting…", + "suggesting_3": "Enchanting…", + "done": "Done!", + "error": "Fail!" } }, "dialogs": {