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
This commit is contained in:
Paulus Schoutsen 2025-07-16 07:20:37 +02:00 committed by GitHub
parent c3bf1d8770
commit ed8c9f5ce5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 369 additions and 107 deletions

View File

@ -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`
<ha-assist-chip
@click=${this._suggest}
.label=${label}
?active=${this._state.status === "suggesting"}
class=${this._state.status === "error"
? "error"
: this._state.status === "done"
? "done"
: ""}
style=${this._minWidth ? `min-width: ${this._minWidth}` : ""}
>
<ha-svg-icon slot="icon" .path=${mdiStarFourPoints}></ha-svg-icon>
</ha-assist-chip>
`;
}
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;
}
}

View File

@ -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<T = string> {
conversation_id: string;
data: T;
@ -34,12 +41,7 @@ export const saveAITaskPreferences = (
export const generateDataAITask = async <T = string>(
hass: HomeAssistant,
task: {
task_name: string;
entity_id?: string;
instructions: string;
structure?: AITaskStructure;
}
task: GenDataTask
): Promise<GenDataTaskResult<T>> => {
const result = await hass.callService<GenDataTaskResult<T>>(
"ai_task",

View File

@ -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}
></ha-icon-button>
<span slot="title">${this._params.title || title}</span>
${this._canSuggest
? html`
<ha-assist-chip
id="suggest"
<ha-suggest-with-ai-button
slot="actionItems"
@click=${this._suggest}
label=${this.hass.localize("ui.common.suggest_ai")}
>
<ha-svg-icon
slot="icon"
.path=${mdiStarFourPoints}
></ha-svg-icon>
</ha-assist-chip>
`
: nothing}
.hass=${this.hass}
.generateTask=${this._generateTask}
@suggestion=${this._handleSuggestion}
></ha-suggest-with-ai-button>
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error"
@ -348,27 +329,43 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this.closeDialog();
}
private async _suggest() {
const labels = await subscribeOne(
this.hass.connection,
subscribeLabelRegistry
).then((labs) =>
private _getSuggestData() {
return Promise.all([
subscribeOne(this.hass.connection, subscribeLabelRegistry).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
);
const automationInspiration: string[] = [];
),
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<SuggestWithAIGenerateTask> => {
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,31 +373,32 @@ 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: {
@ -427,8 +425,36 @@ ${dump(this._params.config)}
},
},
},
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<string, undefined | string> = 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;
}
`,

View File

@ -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`
<ha-card outlined>
<h1 class="card-header">
@ -84,7 +84,9 @@ export class AITaskPref extends LitElement {
<ha-entity-picker
data-name="gen_data_entity_id"
.hass=${this.hass}
.value=${this._prefs.gen_data_entity_id}
.disabled=${this._prefs === undefined &&
isComponentLoaded(this.hass, "ai_task")}
.value=${this._prefs?.gen_data_entity_id}
.includeDomains=${["ai_task"]}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>

View File

@ -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 {
</ha-progress-button>
</div>
</ha-card>
${isComponentLoaded(this.hass, "ai_task")
? html`<ai-task-pref
<ai-task-pref
.hass=${this.hass}
.narrow=${this.narrow}
></ai-task-pref>`
: nothing}
></ai-task-pref>
</div>
</hass-subpage>
`;
@ -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<string>) {

View File

@ -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",

View File

@ -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": {