diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts
new file mode 100644
index 0000000000..21bad3eefa
--- /dev/null
+++ b/src/components/ha-assist-chat.ts
@@ -0,0 +1,639 @@
+import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
+import { css, LitElement, html, nothing } from "lit";
+import { mdiAlertCircle, mdiMicrophone, mdiSend } from "@mdi/js";
+import { customElement, property, query, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import type { HomeAssistant } from "../types";
+import {
+ runAssistPipeline,
+ type AssistPipeline,
+} from "../data/assist_pipeline";
+import { supportsFeature } from "../common/entity/supports-feature";
+import { ConversationEntityFeature } from "../data/conversation";
+import { AudioRecorder } from "../util/audio-recorder";
+import "./ha-alert";
+import "./ha-textfield";
+import type { HaTextField } from "./ha-textfield";
+import { documentationUrl } from "../util/documentation-url";
+import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
+
+interface AssistMessage {
+ who: string;
+ text?: string | TemplateResult;
+ error?: boolean;
+}
+
+@customElement("ha-assist-chat")
+export class HaAssistChat extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public pipeline?: AssistPipeline;
+
+ @property({ type: Boolean, attribute: false })
+ public startListening?: boolean;
+
+ @query("#message-input") private _messageInput!: HaTextField;
+
+ @query("#scroll-container") private _scrollContainer!: HTMLDivElement;
+
+ @state() private _conversation: AssistMessage[] = [];
+
+ @state() private _showSendButton = false;
+
+ @state() private _processing = false;
+
+ private _conversationId: string | null = null;
+
+ private _audioRecorder?: AudioRecorder;
+
+ private _audioBuffer?: Int16Array[];
+
+ private _audio?: HTMLAudioElement;
+
+ private _stt_binary_handler_id?: number | null;
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ if (!this.hasUpdated || changedProperties.has("pipeline")) {
+ this._conversation = [
+ {
+ who: "hass",
+ text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
+ },
+ ];
+ }
+ }
+
+ protected firstUpdated(changedProperties: PropertyValues): void {
+ super.firstUpdated(changedProperties);
+ if (
+ this.startListening &&
+ this.pipeline &&
+ this.pipeline.stt_engine &&
+ AudioRecorder.isSupported
+ ) {
+ this._toggleListening();
+ }
+ setTimeout(() => this._messageInput.focus(), 0);
+ }
+
+ protected updated(changedProps: PropertyValues) {
+ super.updated(changedProps);
+ if (changedProps.has("_conversation")) {
+ this._scrollMessagesBottom();
+ }
+ }
+
+ public disconnectedCallback() {
+ super.disconnectedCallback();
+ this._audioRecorder?.close();
+ this._audioRecorder = undefined;
+ this._audio?.pause();
+ this._conversation = [];
+ this._conversationId = null;
+ }
+
+ protected render(): TemplateResult {
+ const controlHA = !this.pipeline
+ ? false
+ : this.pipeline.prefer_local_intents ||
+ (this.hass.states[this.pipeline.conversation_engine]
+ ? supportsFeature(
+ this.hass.states[this.pipeline.conversation_engine],
+ ConversationEntityFeature.CONTROL
+ )
+ : true);
+ const supportsMicrophone = AudioRecorder.isSupported;
+ const supportsSTT = this.pipeline?.stt_engine;
+
+ return html`
+ ${controlHA
+ ? nothing
+ : html`
+
+ ${this.hass.localize(
+ "ui.dialogs.voice_command.conversation_no_control"
+ )}
+
+ `}
+
+
+ `;
+ }
+
+ private _scrollMessagesBottom() {
+ const scrollContainer = this._scrollContainer;
+ if (!scrollContainer) {
+ return;
+ }
+ scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
+ }
+
+ private _handleKeyUp(ev: KeyboardEvent) {
+ const input = ev.target as HaTextField;
+ if (!this._processing && ev.key === "Enter" && input.value) {
+ this._processText(input.value);
+ input.value = "";
+ this._showSendButton = false;
+ }
+ }
+
+ private _handleInput(ev: InputEvent) {
+ const value = (ev.target as HaTextField).value;
+ if (value && !this._showSendButton) {
+ this._showSendButton = true;
+ } else if (!value && this._showSendButton) {
+ this._showSendButton = false;
+ }
+ }
+
+ private _handleSendMessage() {
+ if (this._messageInput.value) {
+ this._processText(this._messageInput.value.trim());
+ this._messageInput.value = "";
+ this._showSendButton = false;
+ }
+ }
+
+ private _handleListeningButton(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this._toggleListening();
+ }
+
+ private async _toggleListening() {
+ const supportsMicrophone = AudioRecorder.isSupported;
+ if (!supportsMicrophone) {
+ this._showNotSupportedMessage();
+ return;
+ }
+ if (!this._audioRecorder?.active) {
+ this._startListening();
+ } else {
+ this._stopListening();
+ }
+ }
+
+ private _addMessage(message: AssistMessage) {
+ this._conversation = [...this._conversation!, message];
+ }
+
+ private async _showNotSupportedMessage() {
+ this._addMessage({
+ who: "hass",
+ text:
+ // New lines matter for messages
+ // prettier-ignore
+ html`${this.hass.localize(
+ "ui.dialogs.voice_command.not_supported_microphone_browser"
+ )}
+
+ ${this.hass.localize(
+ "ui.dialogs.voice_command.not_supported_microphone_documentation",
+ {
+ documentation_link: html`${this.hass.localize(
+ "ui.dialogs.voice_command.not_supported_microphone_documentation_link"
+ )}`,
+ }
+ )}`,
+ });
+ }
+
+ private async _startListening() {
+ this._processing = true;
+ this._audio?.pause();
+ if (!this._audioRecorder) {
+ this._audioRecorder = new AudioRecorder((audio) => {
+ if (this._audioBuffer) {
+ this._audioBuffer.push(audio);
+ } else {
+ this._sendAudioChunk(audio);
+ }
+ });
+ }
+ this._stt_binary_handler_id = undefined;
+ this._audioBuffer = [];
+ const userMessage: AssistMessage = {
+ who: "user",
+ text: "…",
+ };
+ await this._audioRecorder.start();
+
+ this._addMessage(userMessage);
+ this.requestUpdate("_audioRecorder");
+
+ const hassMessage: AssistMessage = {
+ who: "hass",
+ text: "…",
+ };
+ // To make sure the answer is placed at the right user text, we add it before we process it
+ try {
+ const unsub = await runAssistPipeline(
+ this.hass,
+ (event) => {
+ if (event.type === "run-start") {
+ this._stt_binary_handler_id =
+ event.data.runner_data.stt_binary_handler_id;
+ }
+
+ // When we start STT stage, the WS has a binary handler
+ if (event.type === "stt-start" && this._audioBuffer) {
+ // Send the buffer over the WS to the STT engine.
+ for (const buffer of this._audioBuffer) {
+ this._sendAudioChunk(buffer);
+ }
+ this._audioBuffer = undefined;
+ }
+
+ // Stop recording if the server is done with STT stage
+ if (event.type === "stt-end") {
+ this._stt_binary_handler_id = undefined;
+ this._stopListening();
+ userMessage.text = event.data.stt_output.text;
+ this.requestUpdate("_conversation");
+ // To make sure the answer is placed at the right user text, we add it before we process it
+ this._addMessage(hassMessage);
+ }
+
+ if (event.type === "intent-end") {
+ this._conversationId = event.data.intent_output.conversation_id;
+ const plain = event.data.intent_output.response.speech?.plain;
+ if (plain) {
+ hassMessage.text = plain.speech;
+ }
+ this.requestUpdate("_conversation");
+ }
+
+ if (event.type === "tts-end") {
+ const url = event.data.tts_output.url;
+ this._audio = new Audio(url);
+ this._audio.play();
+ this._audio.addEventListener("ended", this._unloadAudio);
+ this._audio.addEventListener("pause", this._unloadAudio);
+ this._audio.addEventListener("canplaythrough", this._playAudio);
+ this._audio.addEventListener("error", this._audioError);
+ }
+
+ if (event.type === "run-end") {
+ this._stt_binary_handler_id = undefined;
+ unsub();
+ }
+
+ if (event.type === "error") {
+ this._stt_binary_handler_id = undefined;
+ if (userMessage.text === "…") {
+ userMessage.text = event.data.message;
+ userMessage.error = true;
+ } else {
+ hassMessage.text = event.data.message;
+ hassMessage.error = true;
+ }
+ this._stopListening();
+ this.requestUpdate("_conversation");
+ unsub();
+ }
+ },
+ {
+ start_stage: "stt",
+ end_stage: this.pipeline?.tts_engine ? "tts" : "intent",
+ input: { sample_rate: this._audioRecorder.sampleRate! },
+ pipeline: this.pipeline?.id,
+ conversation_id: this._conversationId,
+ }
+ );
+ } catch (err: any) {
+ await showAlertDialog(this, {
+ title: "Error starting pipeline",
+ text: err.message || err,
+ });
+ this._stopListening();
+ } finally {
+ this._processing = false;
+ }
+ }
+
+ private _stopListening() {
+ this._audioRecorder?.stop();
+ this.requestUpdate("_audioRecorder");
+ // We're currently STTing, so finish audio
+ if (this._stt_binary_handler_id) {
+ if (this._audioBuffer) {
+ for (const chunk of this._audioBuffer) {
+ this._sendAudioChunk(chunk);
+ }
+ }
+ // Send empty message to indicate we're done streaming.
+ this._sendAudioChunk(new Int16Array());
+ this._stt_binary_handler_id = undefined;
+ }
+ this._audioBuffer = undefined;
+ }
+
+ private _sendAudioChunk(chunk: Int16Array) {
+ this.hass.connection.socket!.binaryType = "arraybuffer";
+
+ // eslint-disable-next-line eqeqeq
+ if (this._stt_binary_handler_id == undefined) {
+ return;
+ }
+ // Turn into 8 bit so we can prefix our handler ID.
+ const data = new Uint8Array(1 + chunk.length * 2);
+ data[0] = this._stt_binary_handler_id;
+ data.set(new Uint8Array(chunk.buffer), 1);
+
+ this.hass.connection.socket!.send(data);
+ }
+
+ private _playAudio = () => {
+ this._audio?.play();
+ };
+
+ private _audioError = () => {
+ showAlertDialog(this, { title: "Error playing audio." });
+ this._audio?.removeAttribute("src");
+ };
+
+ private _unloadAudio = () => {
+ this._audio?.removeAttribute("src");
+ this._audio = undefined;
+ };
+
+ private async _processText(text: string) {
+ this._processing = true;
+ this._audio?.pause();
+ this._addMessage({ who: "user", text });
+ const message: AssistMessage = {
+ who: "hass",
+ text: "…",
+ };
+ // To make sure the answer is placed at the right user text, we add it before we process it
+ this._addMessage(message);
+ try {
+ const unsub = await runAssistPipeline(
+ this.hass,
+ (event) => {
+ if (event.type === "intent-end") {
+ this._conversationId = event.data.intent_output.conversation_id;
+ const plain = event.data.intent_output.response.speech?.plain;
+ if (plain) {
+ message.text = plain.speech;
+ }
+ this.requestUpdate("_conversation");
+ unsub();
+ }
+ if (event.type === "error") {
+ message.text = event.data.message;
+ message.error = true;
+ this.requestUpdate("_conversation");
+ unsub();
+ }
+ },
+ {
+ start_stage: "intent",
+ input: { text },
+ end_stage: "intent",
+ pipeline: this.pipeline?.id,
+ conversation_id: this._conversationId,
+ }
+ );
+ } catch {
+ message.text = this.hass.localize("ui.dialogs.voice_command.error");
+ message.error = true;
+ this.requestUpdate("_conversation");
+ } finally {
+ this._processing = false;
+ }
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: var(--ha-assist-chat-min-height, 415px);
+ }
+ ha-textfield {
+ display: block;
+ margin: 0 24px 16px;
+ }
+ .messages {
+ flex: 1;
+ display: block;
+ box-sizing: border-box;
+ position: relative;
+ }
+ .messages-container {
+ position: absolute;
+ bottom: 0px;
+ right: 0px;
+ left: 0px;
+ padding: 24px;
+ box-sizing: border-box;
+ overflow-y: auto;
+ max-height: 100%;
+ }
+ .message {
+ white-space: pre-line;
+ font-size: 18px;
+ clear: both;
+ margin: 8px 0;
+ padding: 8px;
+ border-radius: 15px;
+ }
+
+ @media all and (max-width: 450px), all and (max-height: 500px) {
+ .message {
+ font-size: 16px;
+ }
+ }
+
+ .message p {
+ margin: 0;
+ }
+ .message p:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .message.user {
+ margin-left: 24px;
+ margin-inline-start: 24px;
+ margin-inline-end: initial;
+ float: var(--float-end);
+ text-align: right;
+ border-bottom-right-radius: 0px;
+ background-color: var(--primary-color);
+ color: var(--text-primary-color);
+ direction: var(--direction);
+ }
+
+ .message.hass {
+ margin-right: 24px;
+ margin-inline-end: 24px;
+ margin-inline-start: initial;
+ float: var(--float-start);
+ border-bottom-left-radius: 0px;
+ background-color: var(--secondary-background-color);
+
+ color: var(--primary-text-color);
+ direction: var(--direction);
+ }
+
+ .message.user a {
+ color: var(--text-primary-color);
+ }
+
+ .message.hass a {
+ color: var(--primary-text-color);
+ }
+
+ .message.error {
+ background-color: var(--error-color);
+ color: var(--text-primary-color);
+ }
+
+ .bouncer {
+ width: 48px;
+ height: 48px;
+ position: absolute;
+ }
+ .double-bounce1,
+ .double-bounce2 {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background-color: var(--primary-color);
+ opacity: 0.2;
+ position: absolute;
+ top: 0;
+ left: 0;
+ -webkit-animation: sk-bounce 2s infinite ease-in-out;
+ animation: sk-bounce 2s infinite ease-in-out;
+ }
+ .double-bounce2 {
+ -webkit-animation-delay: -1s;
+ animation-delay: -1s;
+ }
+ @-webkit-keyframes sk-bounce {
+ 0%,
+ 100% {
+ -webkit-transform: scale(0);
+ }
+ 50% {
+ -webkit-transform: scale(1);
+ }
+ }
+ @keyframes sk-bounce {
+ 0%,
+ 100% {
+ transform: scale(0);
+ -webkit-transform: scale(0);
+ }
+ 50% {
+ transform: scale(1);
+ -webkit-transform: scale(1);
+ }
+ }
+
+ .listening-icon {
+ position: relative;
+ color: var(--secondary-text-color);
+ margin-right: -24px;
+ margin-inline-end: -24px;
+ margin-inline-start: initial;
+ direction: var(--direction);
+ transform: scaleX(var(--scale-direction));
+ }
+
+ .listening-icon[active] {
+ color: var(--primary-color);
+ }
+
+ .unsupported {
+ color: var(--error-color);
+ position: absolute;
+ --mdc-icon-size: 16px;
+ right: 5px;
+ inset-inline-end: 5px;
+ inset-inline-start: initial;
+ top: 0px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-assist-chat": HaAssistChat;
+ }
+}
diff --git a/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts b/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts
index 42c311bef2..1bfa890e95 100644
--- a/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts
+++ b/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts
@@ -1,16 +1,13 @@
import "@material/mwc-button/mwc-button";
import {
- mdiAlertCircle,
mdiChevronDown,
mdiClose,
mdiHelpCircleOutline,
- mdiMicrophone,
- mdiSend,
mdiStar,
} from "@mdi/js";
-import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
+import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
-import { customElement, property, query, state } from "lit/decorators";
+import { customElement, property, state } from "lit/decorators";
import { storage } from "../../common/decorators/storage";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -20,36 +17,23 @@ import "../../components/ha-dialog";
import "../../components/ha-dialog-header";
import "../../components/ha-icon-button";
import "../../components/ha-list-item";
-import "../../components/ha-textfield";
import "../../components/ha-alert";
-import type { HaTextField } from "../../components/ha-textfield";
+import "../../components/ha-assist-chat";
+import "../../components/ha-circular-progress";
import type { AssistPipeline } from "../../data/assist_pipeline";
import {
getAssistPipeline,
listAssistPipelines,
- runAssistPipeline,
} from "../../data/assist_pipeline";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
-import { AudioRecorder } from "../../util/audio-recorder";
import { documentationUrl } from "../../util/documentation-url";
-import { showAlertDialog } from "../generic/show-dialog-box";
import type { VoiceCommandDialogParams } from "./show-ha-voice-command-dialog";
-import { supportsFeature } from "../../common/entity/supports-feature";
-import { ConversationEntityFeature } from "../../data/conversation";
-
-interface Message {
- who: string;
- text?: string | TemplateResult;
- error?: boolean;
-}
@customElement("ha-voice-command-dialog")
export class HaVoiceCommandDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
- @state() private _conversation?: Message[];
-
@state() private _opened = false;
@storage({
@@ -61,69 +45,34 @@ export class HaVoiceCommandDialog extends LitElement {
@state() private _pipeline?: AssistPipeline;
- @state() private _showSendButton = false;
-
@state() private _pipelines?: AssistPipeline[];
@state() private _preferredPipeline?: string;
- @query("#scroll-container") private _scrollContainer!: HTMLDivElement;
+ @state() private _errorLoadAssist?: "not_found" | "unknown";
- @query("#message-input") private _messageInput!: HaTextField;
-
- private _conversationId: string | null = null;
-
- private _audioRecorder?: AudioRecorder;
-
- private _audioBuffer?: Int16Array[];
-
- private _audio?: HTMLAudioElement;
-
- private _stt_binary_handler_id?: number | null;
-
- private _pipelinePromise?: Promise;
+ private _startListening = false;
public async showDialog(
params: Required
): Promise {
- if (params.pipeline_id === "last_used") {
- // Do not set pipeline id (retrieve from storage)
- } else if (params.pipeline_id === "preferred") {
+ if (
+ params.pipeline_id === "preferred" ||
+ (params.pipeline_id === "last_used" && !this._pipelineId)
+ ) {
await this._loadPipelines();
this._pipelineId = this._preferredPipeline;
- } else {
+ } else if (!["last_used", "preferred"].includes(params.pipeline_id)) {
this._pipelineId = params.pipeline_id;
}
- this._conversation = [
- {
- who: "hass",
- text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
- },
- ];
+ this._startListening = params.start_listening;
this._opened = true;
- await this.updateComplete;
- this._scrollMessagesBottom();
-
- await this._pipelinePromise;
- if (
- params?.start_listening &&
- this._pipeline?.stt_engine &&
- AudioRecorder.isSupported
- ) {
- this._toggleListening();
- }
}
public async closeDialog(): Promise {
this._opened = false;
- this._pipeline = undefined;
this._pipelines = undefined;
- this._conversation = undefined;
- this._conversationId = null;
- this._audioRecorder?.close();
- this._audioRecorder = undefined;
- this._audio?.pause();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -132,24 +81,13 @@ export class HaVoiceCommandDialog extends LitElement {
return nothing;
}
- const controlHA = !this._pipeline
- ? false
- : this._pipeline.prefer_local_intents ||
- (this.hass.states[this._pipeline.conversation_engine]
- ? supportsFeature(
- this.hass.states[this._pipeline.conversation_engine],
- ConversationEntityFeature.CONTROL
- )
- : true);
- const supportsMicrophone = AudioRecorder.isSupported;
- const supportsSTT = this._pipeline?.stt_engine;
-
return html`
- ${this._pipelines?.map(
- (pipeline) =>
- html`
- ${pipeline.name}${pipeline.id === this._preferredPipeline
- ? html`
-
- `
- : nothing}
- `
- )}
+ ${!this._pipelines
+ ? html`
+
+
`
+ : this._pipelines?.map(
+ (pipeline) =>
+ html`
+ ${pipeline.name}${pipeline.id ===
+ this._preferredPipeline
+ ? html`
+
+ `
+ : nothing}
+ `
+ )}
${this.hass.user?.is_admin
? html`
- ${controlHA
- ? nothing
- : html`
-
- ${this.hass.localize(
- "ui.dialogs.voice_command.conversation_no_control"
- )}
-
- `}
-
-
+ ${this._pipeline
+ ? html`
+
+
+ `
+ : html`
+
+
`}
+ ${this._errorLoadAssist
+ ? html`
+ ${this.hass.localize(
+ `ui.dialogs.voice_command.${this._errorLoadAssist}_error_load_assist`
+ )}
+ `
+ : nothing}
`;
}
@@ -298,23 +193,14 @@ export class HaVoiceCommandDialog extends LitElement {
protected willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has("_pipelineId") ||
- (changedProperties.has("_opened") && this._opened === true)
+ (changedProperties.has("_opened") &&
+ this._opened === true &&
+ this._pipelineId)
) {
this._getPipeline();
}
}
- private async _getPipeline() {
- try {
- this._pipelinePromise = getAssistPipeline(this.hass, this._pipelineId);
- this._pipeline = await this._pipelinePromise;
- } catch (e: any) {
- if (e.code === "not_found") {
- this._pipelineId = undefined;
- }
- }
- }
-
private async _loadPipelines() {
if (this._pipelines) {
return;
@@ -328,343 +214,28 @@ export class HaVoiceCommandDialog extends LitElement {
private async _selectPipeline(ev: CustomEvent) {
this._pipelineId = (ev.currentTarget as any).pipeline;
- this._conversation = [
- {
- who: "hass",
- text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
- },
- ];
await this.updateComplete;
- this._scrollMessagesBottom();
}
- protected updated(changedProps: PropertyValues) {
- super.updated(changedProps);
- if (changedProps.has("_conversation") || changedProps.has("results")) {
- this._scrollMessagesBottom();
- }
- }
-
- private _addMessage(message: Message) {
- this._conversation = [...this._conversation!, message];
- }
-
- private _handleKeyUp(ev: KeyboardEvent) {
- const input = ev.target as HaTextField;
- if (ev.key === "Enter" && input.value) {
- this._processText(input.value);
- input.value = "";
- this._showSendButton = false;
- }
- }
-
- private _handleInput(ev: InputEvent) {
- const value = (ev.target as HaTextField).value;
- if (value && !this._showSendButton) {
- this._showSendButton = true;
- } else if (!value && this._showSendButton) {
- this._showSendButton = false;
- }
- }
-
- private _handleSendMessage() {
- if (this._messageInput.value) {
- this._processText(this._messageInput.value.trim());
- this._messageInput.value = "";
- this._showSendButton = false;
- }
- }
-
- private async _processText(text: string) {
- this._audio?.pause();
- this._addMessage({ who: "user", text });
- const message: Message = {
- who: "hass",
- text: "…",
- };
- // To make sure the answer is placed at the right user text, we add it before we process it
- this._addMessage(message);
+ private async _getPipeline() {
try {
- const unsub = await runAssistPipeline(
- this.hass,
- (event) => {
- if (event.type === "intent-end") {
- this._conversationId = event.data.intent_output.conversation_id;
- const plain = event.data.intent_output.response.speech?.plain;
- if (plain) {
- message.text = plain.speech;
- }
- this.requestUpdate("_conversation");
- unsub();
- }
- if (event.type === "error") {
- message.text = event.data.message;
- message.error = true;
- this.requestUpdate("_conversation");
- unsub();
- }
- },
- {
- start_stage: "intent",
- input: { text },
- end_stage: "intent",
- pipeline: this._pipeline?.id,
- conversation_id: this._conversationId,
- }
- );
- } catch {
- message.text = this.hass.localize("ui.dialogs.voice_command.error");
- message.error = true;
- this.requestUpdate("_conversation");
- }
- }
-
- private _handleListeningButton(ev) {
- ev.stopPropagation();
- ev.preventDefault();
- this._toggleListening();
- }
-
- private _toggleListening() {
- const supportsMicrophone = AudioRecorder.isSupported;
- if (!supportsMicrophone) {
- this._showNotSupportedMessage();
- return;
- }
- if (!this._audioRecorder?.active) {
- this._startListening();
- } else {
- this._stopListening();
- }
- }
-
- private async _showNotSupportedMessage() {
- this._addMessage({
- who: "hass",
- text:
- // New lines matter for messages
- // prettier-ignore
- html`${this.hass.localize(
- "ui.dialogs.voice_command.not_supported_microphone_browser"
- )}
-
- ${this.hass.localize(
- "ui.dialogs.voice_command.not_supported_microphone_documentation",
- {
- documentation_link: html`${this.hass.localize(
- "ui.dialogs.voice_command.not_supported_microphone_documentation_link"
- )}`,
- }
- )}`,
- });
- }
-
- private async _startListening() {
- this._audio?.pause();
- if (!this._audioRecorder) {
- this._audioRecorder = new AudioRecorder((audio) => {
- if (this._audioBuffer) {
- this._audioBuffer.push(audio);
- } else {
- this._sendAudioChunk(audio);
- }
- });
- }
- this._stt_binary_handler_id = undefined;
- this._audioBuffer = [];
- const userMessage: Message = {
- who: "user",
- text: "…",
- };
- await this._audioRecorder.start();
-
- this._addMessage(userMessage);
- this.requestUpdate("_audioRecorder");
-
- const hassMessage: Message = {
- who: "hass",
- text: "…",
- };
- // To make sure the answer is placed at the right user text, we add it before we process it
- try {
- const unsub = await runAssistPipeline(
- this.hass,
- (event) => {
- if (event.type === "run-start") {
- this._stt_binary_handler_id =
- event.data.runner_data.stt_binary_handler_id;
- }
-
- // When we start STT stage, the WS has a binary handler
- if (event.type === "stt-start" && this._audioBuffer) {
- // Send the buffer over the WS to the STT engine.
- for (const buffer of this._audioBuffer) {
- this._sendAudioChunk(buffer);
- }
- this._audioBuffer = undefined;
- }
-
- // Stop recording if the server is done with STT stage
- if (event.type === "stt-end") {
- this._stt_binary_handler_id = undefined;
- this._stopListening();
- userMessage.text = event.data.stt_output.text;
- this.requestUpdate("_conversation");
- // To make sure the answer is placed at the right user text, we add it before we process it
- this._addMessage(hassMessage);
- }
-
- if (event.type === "intent-end") {
- this._conversationId = event.data.intent_output.conversation_id;
- const plain = event.data.intent_output.response.speech?.plain;
- if (plain) {
- hassMessage.text = plain.speech;
- }
- this.requestUpdate("_conversation");
- }
-
- if (event.type === "tts-end") {
- const url = event.data.tts_output.url;
- this._audio = new Audio(url);
- this._audio.play();
- this._audio.addEventListener("ended", this._unloadAudio);
- this._audio.addEventListener("pause", this._unloadAudio);
- this._audio.addEventListener("canplaythrough", this._playAudio);
- this._audio.addEventListener("error", this._audioError);
- }
-
- if (event.type === "run-end") {
- this._stt_binary_handler_id = undefined;
- unsub();
- }
-
- if (event.type === "error") {
- this._stt_binary_handler_id = undefined;
- if (userMessage.text === "…") {
- userMessage.text = event.data.message;
- userMessage.error = true;
- } else {
- hassMessage.text = event.data.message;
- hassMessage.error = true;
- }
- this._stopListening();
- this.requestUpdate("_conversation");
- unsub();
- }
- },
- {
- start_stage: "stt",
- end_stage: this._pipeline?.tts_engine ? "tts" : "intent",
- input: { sample_rate: this._audioRecorder.sampleRate! },
- pipeline: this._pipeline?.id,
- conversation_id: this._conversationId,
- }
- );
- } catch (err: any) {
- await showAlertDialog(this, {
- title: "Error starting pipeline",
- text: err.message || err,
- });
- this._stopListening();
- }
- }
-
- private _stopListening() {
- this._audioRecorder?.stop();
- this.requestUpdate("_audioRecorder");
- // We're currently STTing, so finish audio
- if (this._stt_binary_handler_id) {
- if (this._audioBuffer) {
- for (const chunk of this._audioBuffer) {
- this._sendAudioChunk(chunk);
- }
+ this._pipeline = await getAssistPipeline(this.hass, this._pipelineId);
+ } catch (e: any) {
+ if (e.code === "not_found") {
+ this._errorLoadAssist = "not_found";
+ } else {
+ this._errorLoadAssist = "unknown";
+ // eslint-disable-next-line no-console
+ console.error(e);
}
- // Send empty message to indicate we're done streaming.
- this._sendAudioChunk(new Int16Array());
- this._stt_binary_handler_id = undefined;
}
- this._audioBuffer = undefined;
- }
-
- private _sendAudioChunk(chunk: Int16Array) {
- this.hass.connection.socket!.binaryType = "arraybuffer";
-
- // eslint-disable-next-line eqeqeq
- if (this._stt_binary_handler_id == undefined) {
- return;
- }
- // Turn into 8 bit so we can prefix our handler ID.
- const data = new Uint8Array(1 + chunk.length * 2);
- data[0] = this._stt_binary_handler_id;
- data.set(new Uint8Array(chunk.buffer), 1);
-
- this.hass.connection.socket!.send(data);
- }
-
- private _playAudio = () => {
- this._audio?.play();
- };
-
- private _audioError = () => {
- showAlertDialog(this, { title: "Error playing audio." });
- this._audio?.removeAttribute("src");
- };
-
- private _unloadAudio = () => {
- this._audio?.removeAttribute("src");
- this._audio = undefined;
- };
-
- private _scrollMessagesBottom() {
- const scrollContainer = this._scrollContainer;
- if (!scrollContainer) {
- return;
- }
- scrollContainer.scrollTo(0, 99999);
- }
-
- private _computeMessageClasses(message: Message) {
- return `message ${message.who} ${message.error ? " error" : ""}`;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
- .listening-icon {
- position: relative;
- color: var(--secondary-text-color);
- margin-right: -24px;
- margin-inline-end: -24px;
- margin-inline-start: initial;
- direction: var(--direction);
- transform: scaleX(var(--scale-direction));
- }
-
- .listening-icon[active] {
- color: var(--primary-color);
- }
-
- .unsupported {
- color: var(--error-color);
- position: absolute;
- --mdc-icon-size: 16px;
- right: 5px;
- inset-inline-end: 5px;
- inset-inline-start: initial;
- top: 0px;
- }
-
ha-dialog {
- --primary-action-button-flex: 1;
- --secondary-action-button-flex: 0;
--mdc-dialog-max-width: 500px;
--mdc-dialog-max-height: 500px;
--dialog-content-padding: 0;
@@ -722,157 +293,10 @@ export class HaVoiceCommandDialog extends LitElement {
ha-button-menu a {
text-decoration: none;
}
- ha-textfield {
- display: block;
- overflow: hidden;
- }
- a.button {
- text-decoration: none;
- }
- a.button > mwc-button {
- width: 100%;
- }
- .side-by-side {
+
+ .pipelines-loading {
display: flex;
- margin: 8px 0;
- }
- .side-by-side > * {
- flex: 1 0;
- padding: 4px;
- }
- .messages {
- display: block;
- height: 400px;
- box-sizing: border-box;
- position: relative;
- }
- @media all and (max-width: 450px), all and (max-height: 500px) {
- ha-dialog {
- --mdc-dialog-max-width: 100%;
- }
- .messages {
- height: 100%;
- flex: 1;
- }
- }
- .messages-container {
- position: absolute;
- bottom: 0px;
- right: 0px;
- left: 0px;
- padding: 24px;
- box-sizing: border-box;
- overflow-y: auto;
- max-height: 100%;
- }
- .message {
- white-space: pre-line;
- font-size: 18px;
- clear: both;
- margin: 8px 0;
- padding: 8px;
- border-radius: 15px;
- }
- .message p {
- margin: 0;
- }
- .message p:not(:last-child) {
- margin-bottom: 8px;
- }
-
- .message.user {
- margin-left: 24px;
- margin-inline-start: 24px;
- margin-inline-end: initial;
- float: var(--float-end);
- text-align: right;
- border-bottom-right-radius: 0px;
- background-color: var(--primary-color);
- color: var(--text-primary-color);
- direction: var(--direction);
- }
-
- .message.hass {
- margin-right: 24px;
- margin-inline-end: 24px;
- margin-inline-start: initial;
- float: var(--float-start);
- border-bottom-left-radius: 0px;
- background-color: var(--secondary-background-color);
- color: var(--primary-text-color);
- direction: var(--direction);
- }
-
- .message.user a {
- color: var(--text-primary-color);
- }
-
- .message.hass a {
- color: var(--primary-text-color);
- }
-
- .message img {
- width: 100%;
- border-radius: 10px;
- }
-
- .message.error {
- background-color: var(--error-color);
- color: var(--text-primary-color);
- }
-
- .input {
- margin-left: 0;
- margin-right: 0;
- }
-
- .bouncer {
- width: 48px;
- height: 48px;
- position: absolute;
- }
- .double-bounce1,
- .double-bounce2 {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background-color: var(--primary-color);
- opacity: 0.2;
- position: absolute;
- top: 0;
- left: 0;
- -webkit-animation: sk-bounce 2s infinite ease-in-out;
- animation: sk-bounce 2s infinite ease-in-out;
- }
- .double-bounce2 {
- -webkit-animation-delay: -1s;
- animation-delay: -1s;
- }
- @-webkit-keyframes sk-bounce {
- 0%,
- 100% {
- -webkit-transform: scale(0);
- }
- 50% {
- -webkit-transform: scale(1);
- }
- }
- @keyframes sk-bounce {
- 0%,
- 100% {
- transform: scale(0);
- -webkit-transform: scale(0);
- }
- 50% {
- transform: scale(1);
- -webkit-transform: scale(1);
- }
- }
-
- @media all and (max-width: 450px), all and (max-height: 500px) {
- .message {
- font-size: 16px;
- }
+ justify-content: center;
}
`,
];
diff --git a/src/translations/en.json b/src/translations/en.json
index 89611846f3..9f685eca89 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -1215,7 +1215,9 @@
"manage_assistants": "Manage assistants",
"not_supported_microphone_browser": "Your connection to Home Assistant is not secured using HTTPS. This causes browsers to block Home Assistant from accessing the microphone.",
"not_supported_microphone_documentation": "Use the Home Assistant app or visit {documentation_link} to learn how to use a secure URL",
- "not_supported_microphone_documentation_link": "the documentation"
+ "not_supported_microphone_documentation_link": "the documentation",
+ "unknown_error_load_assist": "Loading the assist pipeline failed",
+ "not_found_error_load_assist": "Cannot find the assist pipeline"
},
"generic": {
"cancel": "Cancel",