Voice assistants pipeline UI (#16167)

This commit is contained in:
Paul Bottein 2023-04-13 14:52:10 +02:00 committed by GitHub
parent 639c120b56
commit 5e352f6194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 499 additions and 3 deletions

View File

@ -71,6 +71,7 @@ export class HaFormExpendable extends LitElement implements HaFormElement {
display: block;
--expansion-panel-content-padding: 0;
border-radius: 6px;
--ha-card-border-radius: 6px;
}
ha-svg-icon,
ha-icon {

View File

@ -3,6 +3,23 @@ import type { ConversationResult } from "./conversation";
import type { ResolvedMediaSource } from "./media_source";
import type { SpeechMetadata } from "./stt";
export interface VoiceAssistantPipeline {
id: string;
conversation_engine: string;
language: string;
name: string;
stt_engine: string;
tts_engine: string;
}
export interface VoiceAssistantPipelineMutableParams {
conversation_engine: string;
language: string;
name: string;
stt_engine: string;
tts_engine: string;
}
interface PipelineEventBase {
timestamp: string;
}
@ -202,3 +219,37 @@ export const runVoiceAssistantPipeline = (
return unsubProm;
};
export const fetchVoiceAssistantPipelines = (hass: HomeAssistant) =>
hass.callWS<VoiceAssistantPipeline[]>({
type: "voice_assistant/pipeline/list",
});
export const createVoiceAssistantPipeline = (
hass: HomeAssistant,
pipeline: VoiceAssistantPipelineMutableParams
) =>
hass.callWS<VoiceAssistantPipeline>({
type: "voice_assistant/pipeline/create",
...pipeline,
});
export const updateVoiceAssistantPipeline = (
hass: HomeAssistant,
pipelineId: string,
pipeline: Partial<VoiceAssistantPipelineMutableParams>
) =>
hass.callWS<VoiceAssistantPipeline>({
type: "voice_assistant/pipeline/update",
pipeline_id: pipelineId,
...pipeline,
});
export const deleteVoiceAssistantPipeline = (
hass: HomeAssistant,
pipelineId: string
) =>
hass.callWS<void>({
type: "voice_assistant/pipeline/delete",
pipeline_id: pipelineId,
});

View File

@ -0,0 +1,188 @@
import "@material/mwc-list/mwc-list";
import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { property, state } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-list-item";
import "../../../components/ha-switch";
import "../../../components/ha-button";
import {
createVoiceAssistantPipeline,
deleteVoiceAssistantPipeline,
fetchVoiceAssistantPipelines,
updateVoiceAssistantPipeline,
VoiceAssistantPipeline,
} from "../../../data/voice_assistant";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { showVoiceAssistantPipelineDetailDialog } from "./show-dialog-voice-assistant-pipeline-detail";
export class AssistPref extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _pipelines: VoiceAssistantPipeline[] = [];
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
fetchVoiceAssistantPipelines(this.hass).then((pipelines) => {
this._pipelines = pipelines;
});
}
protected render() {
return html`
<ha-card outlined>
<h1 class="card-header">Assist</h1>
<div class="header-actions">
<a
href="https://www.home-assistant.io/docs/assist/"
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
label="Learn how it works"
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
</div>
<div class="card-content">
<mwc-list>
${this._pipelines.map(
(pipeline) => html`
<ha-list-item
twoline
hasMeta
role="button"
@click=${this._editPipeline}
.id=${pipeline.id}
>
${pipeline.name}
<span slot="secondary">${pipeline.language}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}
</mwc-list>
<ha-button @click=${this._addPipeline}>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.add_assistant"
)}
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-button>
</div>
<div class="card-actions">
<a
href="/config/voice-assistants/expose?assistants=conversation&historyBack"
>
<ha-button>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.manage_entities"
)}
</ha-button>
</a>
</div>
</ha-card>
`;
}
private _editPipeline(ev) {
const id = ev.currentTarget.id as string;
const pipeline = this._pipelines.find((res) => res.id === id);
this._openDialog(pipeline);
}
private _addPipeline() {
this._openDialog();
}
private async _openDialog(pipeline?: VoiceAssistantPipeline): Promise<void> {
showVoiceAssistantPipelineDetailDialog(this, {
pipeline,
createPipeline: async (values) => {
const created = await createVoiceAssistantPipeline(this.hass!, values);
this._pipelines = this._pipelines!.concat(created);
},
updatePipeline: async (values) => {
const updated = await updateVoiceAssistantPipeline(
this.hass!,
pipeline!.id,
values
);
this._pipelines = this._pipelines!.map((res) =>
res === pipeline ? updated : res
);
},
deletePipeline: async () => {
if (
!(await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.delete.confirm_title",
{ name: pipeline!.name }
),
text: this.hass!.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.delete.confirm_text",
{ name: pipeline!.name }
),
confirmText: this.hass!.localize("ui.common.delete"),
destructive: true,
}))
) {
return false;
}
try {
await deleteVoiceAssistantPipeline(this.hass!, pipeline!.id);
this._pipelines = this._pipelines!.filter((res) => res !== pipeline);
return true;
} catch (err: any) {
return false;
}
},
});
}
static get styles(): CSSResultGroup {
return css`
a {
color: var(--primary-color);
}
.header-actions {
position: absolute;
right: 0px;
top: 24px;
display: flex;
flex-direction: row;
}
.header-actions .icon-link {
margin-top: -16px;
margin-inline-end: 8px;
margin-right: 8px;
direction: var(--direction);
color: var(--secondary-text-color);
}
.card-actions {
display: flex;
}
.card-actions a {
text-decoration: none;
}
.card-header {
display: flex;
align-items: center;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"assist-pref": AssistPref;
}
}
customElements.define("assist-pref", AssistPref);

View File

@ -0,0 +1,204 @@
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import { SchemaUnion } from "../../../components/ha-form/types";
import {
VoiceAssistantPipeline,
VoiceAssistantPipelineMutableParams,
} from "../../../data/voice_assistant";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { VoiceAssistantPipelineDetailsDialogParams } from "./show-dialog-voice-assistant-pipeline-detail";
@customElement("dialog-voice-assistant-pipeline-detail")
export class DialogVoiceAssistantPipelineDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: VoiceAssistantPipelineDetailsDialogParams;
@state() private _data?: Partial<VoiceAssistantPipeline>;
@state() private _error?: Record<string, string>;
@state() private _submitting = false;
public showDialog(params: VoiceAssistantPipelineDetailsDialogParams): void {
this._params = params;
this._error = undefined;
if (this._params.pipeline) {
this._data = this._params.pipeline;
} else {
this._data = {};
}
}
public closeDialog(): void {
this._params = undefined;
this._data = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._data) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.pipeline?.id
? this._params.pipeline.name
: this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.detail.add_assistant_title"
)
)}
>
<div>
<ha-form
.schema=${this._schema()}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
@value-changed=${this._valueChanged}
></ha-form>
</div>
${this._params.pipeline?.id
? html`
<ha-button
slot="secondaryAction"
class="warning"
@click=${this._deletePipeline}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.delete")}
</ha-button>
`
: nothing}
<ha-button
slot="primaryAction"
@click=${this._updatePipeline}
.disabled=${Boolean(this._error) || this._submitting}
dialogInitialFocus
>
${this._params.pipeline?.id
? this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.detail.update_assistant_action"
)
: this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.detail.add_assistant_action"
)}
</ha-button>
</ha-dialog>
`;
}
private _schema = memoizeOne(
() =>
[
{
name: "name",
required: true,
selector: {
text: {},
},
},
{
name: "conversation_engine",
required: true,
selector: {
text: {},
},
},
{
name: "language",
required: true,
selector: {
text: {},
},
},
{
name: "stt_engine",
required: true,
selector: {
text: {},
},
},
{
name: "tts_engine",
required: true,
selector: {
text: {},
},
},
] as const
);
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.voice_assistants.assistants.pipeline.detail.form.${schema.name}`
);
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
const value = ev.detail.value;
this._data = value;
}
private async _updatePipeline() {
this._submitting = true;
try {
if (this._params!.pipeline?.id) {
const values: Partial<VoiceAssistantPipelineMutableParams> = {
name: this._data!.name,
conversation_engine: this._data!.conversation_engine,
language: this._data!.language,
stt_engine: this._data!.stt_engine,
tts_engine: this._data!.tts_engine,
};
await this._params!.updatePipeline(values);
} else {
await this._params!.createPipeline(
this._data as VoiceAssistantPipelineMutableParams
);
}
this.closeDialog();
} catch (err: any) {
this._error = { base: err?.message || "Unknown error" };
} finally {
this._submitting = false;
}
}
private async _deletePipeline() {
this._submitting = true;
try {
if (await this._params!.deletePipeline()) {
this.closeDialog();
}
} finally {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return [haStyleDialog, css``];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-voice-assistant-pipeline-detail": DialogVoiceAssistantPipelineDetail;
}
}

View File

@ -1,14 +1,15 @@
import "@polymer/paper-item/paper-item";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeRTLDirection } from "../../../common/util/compute_rtl";
import { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-tabs-subpage";
import { HomeAssistant, Route } from "../../../types";
import "./assist-pref";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
import { voiceAssistantTabs } from "./ha-config-voice-assistants";
import "@polymer/paper-item/paper-item";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@customElement("ha-config-voice-assistants-assistants")
export class HaConfigVoiceAssistantsAssistants extends LitElement {
@ -36,6 +37,7 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
.tabs=${voiceAssistantTabs}
>
<div class="content">
<assist-pref .hass=${this.hass}> </assist-pref>
${this.cloudStatus?.logged_in
? html`<cloud-alexa-pref
.hass=${this.hass}

View File

@ -0,0 +1,30 @@
import { fireEvent } from "../../../common/dom/fire_event";
import {
VoiceAssistantPipeline,
VoiceAssistantPipelineMutableParams,
} from "../../../data/voice_assistant";
export interface VoiceAssistantPipelineDetailsDialogParams {
pipeline?: VoiceAssistantPipeline;
createPipeline: (
values: VoiceAssistantPipelineMutableParams
) => Promise<unknown>;
updatePipeline: (
updates: Partial<VoiceAssistantPipelineMutableParams>
) => Promise<unknown>;
deletePipeline: () => Promise<boolean>;
}
export const loadVoiceAssistantPipelineDetailDialog = () =>
import("./dialog-voice-assistant-pipeline-detail");
export const showVoiceAssistantPipelineDetailDialog = (
element: HTMLElement,
dialogParams: VoiceAssistantPipelineDetailsDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-voice-assistant-pipeline-detail",
dialogImport: loadVoiceAssistantPipelineDetailDialog,
dialogParams,
});
};

View File

@ -2001,7 +2001,27 @@
},
"voice_assistants": {
"assistants": {
"caption": "Assistants"
"caption": "Assistants",
"pipeline": {
"add_assistant": "Add assistant",
"manage_entities": "[%key:ui::panel::config::cloud::account::google::manage_entities%]",
"delete": {
"confirm_title": "Delete {name}?",
"confirm_text": "{name} will be permanently deleted."
},
"detail": {
"update_assistant_action": "Update",
"add_assistant_title": "Add assistant",
"add_assistant_action": "Create",
"form": {
"name": "Name",
"conversation_engine": "Conversation agent",
"language": "Language",
"stt_engine": "Speech to text",
"tts_engine": "Text to speech"
}
}
}
},
"expose": {
"caption": "Expose",