frontend/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-pipeline.ts
Bram Kragten a2f70f682f Take lang into account when search existing pipeline (#24866)
* Take lang into account when search existing pipeline

* Simplify logic
2025-03-31 20:42:36 +02:00

485 lines
14 KiB
TypeScript

import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatLanguageCode } from "../../common/language/format_language";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-select-box";
import type { SelectBoxOption } from "../../components/ha-select-box";
import {
createAssistPipeline,
listAssistPipelines,
} from "../../data/assist_pipeline";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import { fetchCloudStatus } from "../../data/cloud";
import type { LanguageScore, LanguageScores } from "../../data/conversation";
import { getLanguageScores, listAgents } from "../../data/conversation";
import { listSTTEngines } from "../../data/stt";
import { listTTSEngines, listTTSVoices } from "../../data/tts";
import type { HomeAssistant } from "../../types";
import { AssistantSetupStyles } from "./styles";
import { STEP } from "./voice-assistant-setup-dialog";
import { documentationUrl } from "../../util/documentation-url";
const OPTIONS = ["cloud", "focused_local", "full_local"] as const;
const EMPTY_SCORE: LanguageScore = {
cloud: 0,
focused_local: 0,
full_local: 0,
};
@customElement("ha-voice-assistant-setup-step-pipeline")
export class HaVoiceAssistantSetupStepPipeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public assistConfiguration?: AssistSatelliteConfiguration;
@property({ attribute: false }) public deviceId!: string;
@property({ attribute: false }) public assistEntityId?: string;
@property({ attribute: false }) public language?: string;
@property({ attribute: false }) public languages: string[] = [];
@state() private _cloudChecked = false;
@state() private _value?: (typeof OPTIONS)[number];
@state() private _languageScores?: LanguageScores;
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (!this.hasUpdated) {
this._fetchData();
}
if (
(changedProperties.has("language") ||
changedProperties.has("_languageScores")) &&
this.language &&
this._languageScores
) {
const lang = this.language;
if (this._value && this._languageScores[lang]?.[this._value] === 0) {
this._value = undefined;
}
if (!this._value) {
this._value = this._getOptions(
this._languageScores[lang] || EMPTY_SCORE,
this.hass.localize
).supportedOptions[0]?.value as
| "cloud"
| "focused_local"
| "full_local"
| undefined;
}
}
}
private _getOptions = memoizeOne((score, localize: LocalizeFunc) => {
const supportedOptions: SelectBoxOption[] = [];
const unsupportedOptions: SelectBoxOption[] = [];
OPTIONS.forEach((option) => {
if (score[option] > 0) {
supportedOptions.push({
label: localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.options.${option}.label`
),
description: localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.options.${option}.description`
),
value: option,
});
} else {
unsupportedOptions.push({
label: localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.options.${option}.label`
),
value: option,
});
}
});
return { supportedOptions, unsupportedOptions };
});
protected override render() {
if (!this._cloudChecked || !this._languageScores) {
return nothing;
}
if (!this.language) {
const language = formatLanguageCode(
this.hass.config.language,
this.hass.locale
);
return html`<div class="content">
<h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.header"
)}
</h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.secondary",
{ language }
)}
<ha-language-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.language_picker"
)}
.languages=${this.languages}
@value-changed=${this._languageChanged}
></ha-language-picker>
<a
href=${documentationUrl(
this.hass,
"/voice_control/contribute-voice/"
)}
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.contribute",
{ language }
)}</a
>
</div>`;
}
const score = this._languageScores[this.language] || EMPTY_SCORE;
const options = this._getOptions(score, this.hass.localize);
const performance = !this._value
? ""
: this._value === "full_local"
? "low"
: "high";
const commands = !this._value
? ""
: score[this._value] > 2
? "high"
: score[this._value] > 1
? "ready"
: score[this._value] > 0
? "low"
: "";
return html`<div class="content">
<h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.title"
)}
</h1>
<div class="bar-header">
<span
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.performance.header"
)}</span
><span
>${!performance
? ""
: this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.performance.${performance}`
)}</span
>
</div>
<div class="perf-bar ${performance}">
<div class="segment"></div>
<div class="segment"></div>
<div class="segment"></div>
</div>
<div class="bar-header">
<span
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.commands.header"
)}</span
><span
>${!commands
? ""
: this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.commands.${commands}`
)}</span
>
</div>
<div class="perf-bar ${commands}">
<div class="segment"></div>
<div class="segment"></div>
<div class="segment"></div>
</div>
<ha-select-box
max_columns="1"
.options=${options.supportedOptions}
.value=${this._value}
@value-changed=${this._valueChanged}
></ha-select-box>
${options.unsupportedOptions.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported"
)}
</h3>
<ha-select-box
max_columns="1"
.options=${options.unsupportedOptions}
disabled
></ha-select-box>`
: nothing}
</div>
<div class="footer">
<ha-button
@click=${this._createPipeline}
unelevated
.disabled=${!this._value}
>${this.hass.localize("ui.common.next")}</ha-button
>
</div>`;
}
private async _fetchData() {
const cloud =
(await this._hasCloud()) && (await this._createCloudPipeline(false));
if (!cloud) {
this._cloudChecked = true;
this._languageScores = (await getLanguageScores(this.hass)).languages;
}
}
private async _hasCloud(): Promise<boolean> {
if (!isComponentLoaded(this.hass, "cloud")) {
return false;
}
const cloudStatus = await fetchCloudStatus(this.hass);
if (!cloudStatus.logged_in || !cloudStatus.active_subscription) {
return false;
}
return true;
}
private async _createCloudPipeline(useLanguage: boolean): Promise<boolean> {
let cloudTtsEntityId;
let cloudSttEntityId;
for (const entity of Object.values(this.hass.entities)) {
if (entity.platform === "cloud") {
const domain = computeDomain(entity.entity_id);
if (domain === "tts") {
cloudTtsEntityId = entity.entity_id;
} else if (domain === "stt") {
cloudSttEntityId = entity.entity_id;
} else {
continue;
}
if (cloudTtsEntityId && cloudSttEntityId) {
break;
}
}
}
try {
const pipelines = await listAssistPipelines(this.hass);
if (pipelines.preferred_pipeline) {
pipelines.pipelines.sort((a) =>
a.id === pipelines.preferred_pipeline ? -1 : 0
);
}
let cloudPipeline = pipelines.pipelines.find(
(pipeline) =>
pipeline.conversation_engine === "conversation.home_assistant" &&
pipeline.tts_engine === cloudTtsEntityId &&
pipeline.stt_engine === cloudSttEntityId &&
(!useLanguage ||
pipeline.language.split("-")[0] === this.language!.split("-")[0])
);
if (!cloudPipeline) {
const agent = (
await listAgents(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).agents.find((agnt) => agnt.id === "conversation.home_assistant");
if (!agent?.supported_languages.length) {
return false;
}
const ttsEngine = (
await listTTSEngines(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === cloudTtsEntityId);
if (!ttsEngine?.supported_languages?.length) {
return false;
}
const ttsVoices = await listTTSVoices(
this.hass,
cloudTtsEntityId,
ttsEngine.supported_languages[0]
);
const sttEngine = (
await listSTTEngines(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === cloudSttEntityId);
if (!sttEngine?.supported_languages?.length) {
return false;
}
let pipelineName = "Home Assistant Cloud";
let i = 1;
while (
pipelines.pipelines.find(
// eslint-disable-next-line no-loop-func
(pipeline) => pipeline.name === pipelineName
)
) {
pipelineName = `Home Assistant Cloud ${i}`;
i++;
}
cloudPipeline = await createAssistPipeline(this.hass, {
name: pipelineName,
language: (this.language || this.hass.config.language).split("-")[0],
conversation_engine: "conversation.home_assistant",
conversation_language: agent.supported_languages[0],
stt_engine: cloudSttEntityId,
stt_language: sttEngine.supported_languages[0],
tts_engine: cloudTtsEntityId,
tts_language: ttsEngine.supported_languages[0],
tts_voice: ttsVoices.voices![0].voice_id,
wake_word_entity: null,
wake_word_id: null,
});
}
await this.hass.callService(
"select",
"select_option",
{ option: cloudPipeline.name },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
fireEvent(this, "next-step", { step: STEP.SUCCESS, noPrevious: true });
return true;
} catch (_e) {
return false;
}
}
private _valueChanged(ev: CustomEvent) {
this._value = ev.detail.value;
}
private async _setupCloud() {
if (await this._hasCloud()) {
this._createCloudPipeline(true);
return;
}
fireEvent(this, "next-step", { step: STEP.CLOUD });
}
private _createPipeline() {
if (this._value === "cloud") {
this._setupCloud();
} else if (this._value === "focused_local") {
this._setupLocalFocused();
} else {
this._setupLocalFull();
}
}
private _setupLocalFocused() {
fireEvent(this, "next-step", { step: STEP.LOCAL, option: this._value });
}
private _setupLocalFull() {
fireEvent(this, "next-step", { step: STEP.LOCAL, option: this._value });
}
private _languageChanged(ev: CustomEvent) {
if (!ev.detail.value) {
return;
}
fireEvent(this, "language-changed", { value: ev.detail.value });
}
static styles = [
AssistantSetupStyles,
css`
:host {
text-align: left;
}
.perf-bar {
width: 100%;
height: 10px;
display: flex;
gap: 4px;
margin: 8px 0;
}
.segment {
flex-grow: 1;
background-color: var(--disabled-color);
transition: background-color 0.3s;
}
.segment:first-child {
border-radius: 4px 0 0 4px;
}
.segment:last-child {
border-radius: 0 4px 4px 0;
}
.perf-bar.high .segment {
background-color: var(--success-color);
}
.perf-bar.ready .segment:nth-child(-n + 2) {
background-color: var(--warning-color);
}
.perf-bar.low .segment:nth-child(1) {
background-color: var(--error-color);
}
.bar-header {
display: flex;
justify-content: space-between;
margin: 8px 0;
margin-top: 16px;
}
ha-select-box {
display: block;
}
ha-select-box:first-of-type {
margin-top: 32px;
}
.footer {
margin-top: 16px;
}
ha-language-picker {
display: block;
margin-top: 16px;
margin-bottom: 16px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-voice-assistant-setup-step-pipeline": HaVoiceAssistantSetupStepPipeline;
}
}