Extract Suggest AI button

This commit is contained in:
Paulus Schoutsen 2025-07-11 13:03:38 +00:00
parent 9819c31882
commit 517a0a6223
4 changed files with 130 additions and 43 deletions

View File

@ -0,0 +1,86 @@
import type { PropertyValues } from "lit";
import { html, 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 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;
}
}
@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!: () => GenDataTask;
@state()
private _aiPrefs?: AITaskPreferences;
@state()
private _suggesting = false;
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;
}
return html`
<ha-assist-chip
@click=${this._suggest}
label=${this.hass.localize(
this._suggesting ? "ui.common.suggesting_ai" : "ui.common.suggest_ai"
)}
.active=${this._suggesting}
>
<ha-svg-icon slot="icon" .path=${mdiStarFourPoints}></ha-svg-icon>
</ha-assist-chip>
`;
}
private async _suggest() {
if (!this.generateTask || this._suggesting) {
return;
}
try {
this._suggesting = true;
const task = await this.generateTask();
const result = await generateDataAITask(this.hass, task);
fireEvent(this, "suggestion", result);
} finally {
this._suggesting = false;
}
}
}
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

@ -11,6 +11,7 @@ import "../../../../components/ha-icon-picker";
import "../../../../components/ha-textarea";
import "../../../../components/ha-textfield";
import "../../../../components/ha-labels-picker";
import "../../../../components/ha-suggest-with-ai-button";
import "../../category/ha-category-picker";
import "../../../../components/ha-expansion-panel";
import "../../../../components/chips/ha-chip-set";
@ -27,6 +28,8 @@ import type {
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
import {
fetchAITaskPreferences,
GenDataTask,
GenDataTaskResult,
generateDataAITask,
} from "../../../../data/ai_task";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
@ -48,8 +51,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
@state() private _entryUpdates!: EntityRegistryUpdate;
@state() private _canSuggest = false;
private _params!: SaveDialogParams;
@state() private _newName?: string;
@ -94,15 +95,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;
@ -272,21 +264,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"
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}
<ha-suggest-with-ai-button
slot="actionItems"
.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"
@ -350,8 +333,8 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this.closeDialog();
}
private async _suggest() {
const [labels, entities, categories] = await Promise.all([
private _getSuggestData() {
return Promise.all([
subscribeOne(this.hass.connection, subscribeLabelRegistry).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
),
@ -362,6 +345,10 @@ class DialogAutomationSave extends LitElement implements HassDialog {
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
),
]);
}
private _generateTask = async (): Promise<GenDataTask> => {
const [labels, entities, categories] = await this._getSuggestData();
const automationInspiration: string[] = [];
for (const automation of Object.values(this.hass.states)) {
@ -391,12 +378,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
automationInspiration.push(inspiration);
}
const result = await generateDataAITask<{
name: string;
description: string | undefined;
labels: string[] | undefined;
category: string | undefined;
}>(this.hass, {
return {
task_name: "frontend:automation:save",
instructions: `Suggest in language "${this.hass.language}" a name, description, category and labels for the following Home Assistant automation.
@ -454,7 +436,22 @@ ${dump(this._params.config)}
},
},
},
});
};
};
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;
@ -463,6 +460,7 @@ ${dump(this._params.config)}
}
}
if (result.data.category) {
// TODO search up category ID
this._entryUpdates = {
...this._entryUpdates,
category: result.data.category,
@ -570,7 +568,7 @@ ${dump(this._params.config)}
--mdc-theme-primary: var(--error-color);
}
#suggest {
ha-suggest-with-ai-button {
margin: 8px 16px;
}
`,

View File

@ -437,7 +437,8 @@
"append": "Append",
"supports_markdown": "Supports {markdown_help_link}",
"markdown": "Markdown",
"suggest_ai": "Suggest with AI"
"suggest_ai": "Suggest with AI",
"suggesting_ai": "Suggesting…"
},
"components": {