import { mdiClose, mdiPlus } from "@mdi/js"; import { dump } from "js-yaml"; import type { CSSResultGroup } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/chips/ha-assist-chip"; import "../../../../components/chips/ha-chip-set"; import "../../../../components/ha-alert"; import "../../../../components/ha-area-picker"; import "../../../../components/ha-domain-icon"; import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-icon-picker"; import "../../../../components/ha-labels-picker"; import "../../../../components/ha-suggest-with-ai-button"; import type { SuggestWithAIGenerateTask } from "../../../../components/ha-suggest-with-ai-button"; import "../../../../components/ha-svg-icon"; import "../../../../components/ha-textarea"; import "../../../../components/ha-textfield"; import "../../category/ha-category-picker"; import { computeStateDomain } from "../../../../common/entity/compute_state_domain"; import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support"; import { subscribeOne } from "../../../../common/util/subscribe-one"; import type { GenDataTaskResult } from "../../../../data/ai_task"; import { fetchCategoryRegistry } from "../../../../data/category_registry"; import { subscribeEntityRegistry } from "../../../../data/entity_registry"; import { subscribeLabelRegistry } from "../../../../data/label_registry"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import { haStyle, haStyleDialog } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; import type { EntityRegistryUpdate, SaveDialogParams, } from "./show-dialog-automation-save"; @customElement("ha-dialog-automation-save") class DialogAutomationSave extends LitElement implements HassDialog { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _opened = false; @state() private _error?: string; @state() private _visibleOptionals: string[] = []; @state() private _entryUpdates!: EntityRegistryUpdate; private _params!: SaveDialogParams; @state() private _newName?: string; private _newIcon?: string; private _newDescription?: string; public showDialog(params: SaveDialogParams): void { this._opened = true; this._params = params; this._newIcon = "icon" in params.config ? params.config.icon : undefined; this._newName = params.config.alias || this.hass.localize( `ui.panel.config.${this._params.domain}.editor.default_name` ); this._newDescription = params.config.description || ""; this._entryUpdates = params.entityRegistryUpdate || { area: params.entityRegistryEntry?.area_id || "", labels: params.entityRegistryEntry?.labels || [], category: params.entityRegistryEntry?.categories[params.domain] || "", }; this._visibleOptionals = [ this._newDescription ? "description" : "", this._newIcon ? "icon" : "", this._entryUpdates.category ? "category" : "", this._entryUpdates.labels.length > 0 ? "labels" : "", this._entryUpdates.area ? "area" : "", ].filter(Boolean); } public closeDialog() { this._params.onClose(); if (this._opened) { fireEvent(this, "dialog-closed", { dialog: this.localName }); } this._opened = false; this._visibleOptionals = []; return true; } protected _renderOptionalChip(id: string, label: string) { if (this._visibleOptionals.includes(id)) { return nothing; } return html` `; } protected _renderDiscard() { if (!this._params.onDiscard) { return nothing; } return html` ${this.hass.localize("ui.common.dont_save")} `; } protected _renderInputs() { if (this._params.hideInputs) { return nothing; } return html` ${this._params.domain === "script" && this._visibleOptionals.includes("icon") ? html` ` : nothing} ${this._visibleOptionals.includes("description") ? html` ` : nothing} ${this._visibleOptionals.includes("category") ? html` ` : nothing} ${this._visibleOptionals.includes("labels") ? html` ` : nothing} ${this._visibleOptionals.includes("area") ? html` ` : nothing} ${this._renderOptionalChip( "description", this.hass.localize( "ui.panel.config.automation.editor.dialog.add_description" ) )} ${this._params.domain === "script" ? this._renderOptionalChip( "icon", this.hass.localize( "ui.panel.config.automation.editor.dialog.add_icon" ) ) : nothing} ${this._renderOptionalChip( "area", this.hass.localize( "ui.panel.config.automation.editor.dialog.add_area" ) )} ${this._renderOptionalChip( "category", this.hass.localize( "ui.panel.config.automation.editor.dialog.add_category" ) )} ${this._renderOptionalChip( "labels", this.hass.localize( "ui.panel.config.automation.editor.dialog.add_labels" ) )} `; } protected render() { if (!this._opened) { return nothing; } const title = this.hass.localize( this._params.config.alias ? "ui.panel.config.automation.editor.rename" : "ui.common.save" ); return html` ${this._params.title || title} ${this._params.hideInputs ? nothing : html` `} ${this._error ? html`${this.hass.localize( "ui.panel.config.automation.editor.missing_name" )}` : ""} ${this._params.description ? html`

${this._params.description}

` : nothing} ${this._renderInputs()} ${this._renderDiscard()}
${this.hass.localize("ui.common.cancel")} ${this.hass.localize( this._params.config.alias && !this._params.onDiscard ? "ui.panel.config.automation.editor.rename" : "ui.common.save" )}
`; } private _addOptional(ev) { ev.stopPropagation(); const option: string = ev.target.id; this._visibleOptionals = [...this._visibleOptionals, option]; } private _registryEntryChanged(ev) { ev.stopPropagation(); const id: string = ev.target.id; const value = ev.detail.value; this._entryUpdates = { ...this._entryUpdates, [id]: value }; } private _iconChanged(ev: CustomEvent) { ev.stopPropagation(); this._newIcon = ev.detail.value || undefined; } private _valueChanged(ev: CustomEvent) { ev.stopPropagation(); const target = ev.target as any; if (target.name === "description") { this._newDescription = target.value; } else { this._newName = target.value; } } private _handleDiscard() { this._params.onDiscard?.(); this.closeDialog(); } 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])) ), ]); } 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(entity) !== domain || entity.attributes.restored || !entity.attributes.friendly_name || !entityEntry ) { continue; } 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 .map((label) => labels[label]) .join(", ")})`; } inspirations.push(inspiration); } const term = this._params.domain === "script" ? "script" : "automation"; 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. ${ 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 ${term} contains 5+ steps, include a short description. For inspiration, here are existing ${term}s: ${inspirations.join("\n")} The ${term} configuration is as follows: ${dump(this._params.config)} `, structure: { name: { description: "The name of the automation", required: true, 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, }, }, }, 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; if (!this._visibleOptionals.includes("description")) { 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( result.data.labels.map((name) => [name, undefined]) ); let toFind = result.data.labels.length; for (const [labelId, labelName] of Object.entries(labels)) { if (labelName in newLabels && newLabels[labelName] === undefined) { newLabels[labelName] = labelId; toFind--; if (toFind === 0) { break; } } } const foundLabels = Object.values(newLabels).filter( (labelId) => labelId !== undefined ); if (foundLabels.length) { this._entryUpdates = { ...this._entryUpdates, labels: foundLabels, }; if (!this._visibleOptionals.includes("labels")) { this._visibleOptionals = [...this._visibleOptionals, "labels"]; } } } } private async _save(): Promise { if (!this._newName) { this._error = "Name is required"; return; } if (this._params.domain === "script") { await this._params.updateConfig( { ...this._params.config, alias: this._newName, description: this._newDescription, icon: this._newIcon, }, this._entryUpdates ); } else { await this._params.updateConfig( { ...this._params.config, alias: this._newName, description: this._newDescription, }, this._entryUpdates ); } this.closeDialog(); } static get styles(): CSSResultGroup { return [ haStyle, haStyleDialog, css` ha-dialog { --dialog-content-padding: 0 24px 24px 24px; } @media all and (min-width: 500px) { ha-dialog { --mdc-dialog-min-width: min(500px, 95vw); --mdc-dialog-max-width: min(500px, 95vw); } } ha-textfield, ha-textarea, ha-icon-picker, ha-category-picker, ha-labels-picker, ha-area-picker { display: block; } ha-icon-picker, ha-category-picker, ha-labels-picker, ha-area-picker, ha-chip-set { margin-top: 16px; } ha-alert { display: block; margin-bottom: 16px; } ha-suggest-with-ai-button { margin: 8px 16px; } `, ]; } } declare global { interface HTMLElementTagNameMap { "ha-dialog-automation-save": DialogAutomationSave; } }