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`
- ${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": {