Skip to content

Commit 4a90331

Browse files
krsilasMindFreeze
andauthored
Add markdown support for assist messages (#27957)
* Add markdown support for assist messages * Improve styles * Refactor code * Fix white space * Move code * Make css compiler happy * Wait for render to complete before scrolling * Revert changes * Refactor ha-markdown to render in chunks * Refactor and adapt scroll logic * Fix imports * Update styles * Render into renderRoot * Fix query selector * Fix broken image style * Implement PR feedback * Remove unnecessary css * Fix cache issue * Apply suggestion from @MindFreeze --------- Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
1 parent 7b264ae commit 4a90331

File tree

4 files changed

+177
-80
lines changed

4 files changed

+177
-80
lines changed

src/components/ha-assist-chat.ts

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { HomeAssistant } from "../types";
1717
import { AudioRecorder } from "../util/audio-recorder";
1818
import { documentationUrl } from "../util/documentation-url";
1919
import "./ha-alert";
20+
import "./ha-markdown";
2021
import "./ha-textfield";
2122
import type { HaTextField } from "./ha-textfield";
2223

@@ -40,7 +41,11 @@ export class HaAssistChat extends LitElement {
4041

4142
@query("#message-input") private _messageInput!: HaTextField;
4243

43-
@query("#scroll-container") private _scrollContainer!: HTMLDivElement;
44+
@query(".message:last-child")
45+
private _lastChatMessage!: LitElement;
46+
47+
@query(".message:last-child img:last-of-type")
48+
private _lastChatMessageImage: HTMLImageElement | undefined;
4449

4550
@state() private _conversation: AssistMessage[] = [];
4651

@@ -92,10 +97,7 @@ export class HaAssistChat extends LitElement {
9297
public disconnectedCallback() {
9398
super.disconnectedCallback();
9499
this._audioRecorder?.close();
95-
this._audioRecorder = undefined;
96100
this._unloadAudio();
97-
this._conversation = [];
98-
this._conversationId = null;
99101
}
100102

101103
protected render(): TemplateResult {
@@ -112,7 +114,7 @@ export class HaAssistChat extends LitElement {
112114
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
113115

114116
return html`
115-
<div class="messages" id="scroll-container">
117+
<div class="messages">
116118
${controlHA
117119
? nothing
118120
: html`
@@ -124,11 +126,18 @@ export class HaAssistChat extends LitElement {
124126
`}
125127
<div class="spacer"></div>
126128
${this._conversation!.map(
127-
// New lines matter for messages
128-
// prettier-ignore
129129
(message) => html`
130-
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
131-
`
130+
<ha-markdown
131+
class="message ${classMap({
132+
error: !!message.error,
133+
[message.who]: true,
134+
})}"
135+
breaks
136+
cache
137+
.content=${message.text}
138+
>
139+
</ha-markdown>
140+
`
132141
)}
133142
</div>
134143
<div class="input" slot="primaryAction">
@@ -189,12 +198,28 @@ export class HaAssistChat extends LitElement {
189198
`;
190199
}
191200

192-
private _scrollMessagesBottom() {
193-
const scrollContainer = this._scrollContainer;
194-
if (!scrollContainer) {
195-
return;
201+
private async _scrollMessagesBottom() {
202+
const lastChatMessage = this._lastChatMessage;
203+
if (!lastChatMessage.hasUpdated) {
204+
await lastChatMessage.updateComplete;
205+
}
206+
if (
207+
this._lastChatMessageImage &&
208+
!this._lastChatMessageImage.naturalHeight
209+
) {
210+
try {
211+
await this._lastChatMessageImage.decode();
212+
} catch (err: any) {
213+
// eslint-disable-next-line no-console
214+
console.warn("Failed to decode image:", err);
215+
}
216+
}
217+
const isLastMessageFullyVisible =
218+
lastChatMessage.getBoundingClientRect().y <
219+
this.getBoundingClientRect().top + 24;
220+
if (!isLastMessageFullyVisible) {
221+
lastChatMessage.scrollIntoView({ behavior: "smooth", block: "start" });
196222
}
197-
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
198223
}
199224

200225
private _handleKeyUp(ev: KeyboardEvent) {
@@ -586,42 +611,31 @@ export class HaAssistChat extends LitElement {
586611
flex: 1;
587612
}
588613
.message {
589-
white-space: pre-line;
590614
font-size: var(--ha-font-size-l);
591615
clear: both;
616+
max-width: -webkit-fill-available;
617+
overflow-wrap: break-word;
618+
scroll-margin-top: 24px;
592619
margin: 8px 0;
593620
padding: 8px;
594621
border-radius: var(--ha-border-radius-xl);
595622
}
596-
.message:last-child {
597-
margin-bottom: 0;
598-
}
599-
600623
@media all and (max-width: 450px), all and (max-height: 500px) {
601624
.message {
602625
font-size: var(--ha-font-size-l);
603626
}
604627
}
605-
606-
.message p {
607-
margin: 0;
608-
}
609-
.message p:not(:last-child) {
610-
margin-bottom: 8px;
611-
}
612-
613628
.message.user {
614629
margin-left: 24px;
615630
margin-inline-start: 24px;
616631
margin-inline-end: initial;
617632
align-self: flex-end;
618-
text-align: right;
619633
border-bottom-right-radius: 0px;
634+
--markdown-link-color: var(--text-primary-color);
620635
background-color: var(--chat-background-color-user, var(--primary-color));
621636
color: var(--text-primary-color);
622637
direction: var(--direction);
623638
}
624-
625639
.message.hass {
626640
margin-right: 24px;
627641
margin-inline-end: 24px;
@@ -636,20 +650,21 @@ export class HaAssistChat extends LitElement {
636650
color: var(--primary-text-color);
637651
direction: var(--direction);
638652
}
639-
640-
.message.user a {
641-
color: var(--text-primary-color);
642-
}
643-
644-
.message.hass a {
645-
color: var(--primary-text-color);
646-
}
647-
648653
.message.error {
649654
background-color: var(--error-color);
650655
color: var(--text-primary-color);
651656
}
652-
657+
ha-markdown {
658+
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
659+
--markdown-table-border-color: var(--divider-color);
660+
--markdown-code-background-color: var(--primary-background-color);
661+
--markdown-code-text-color: var(--primary-text-color);
662+
&:not(:has(ha-markdown-element)) {
663+
min-height: 1lh;
664+
min-width: 1lh;
665+
flex-shrink: 0;
666+
}
667+
}
653668
.bouncer {
654669
width: 48px;
655670
height: 48px;

src/components/ha-markdown-element.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { PropertyValues } from "lit";
2-
import { ReactiveElement } from "lit";
2+
import { ReactiveElement, render, html } from "lit";
33
import { customElement, property } from "lit/decorators";
4+
// eslint-disable-next-line import/extensions
5+
import { unsafeHTML } from "lit/directives/unsafe-html.js";
46
import hash from "object-hash";
57
import { fireEvent } from "../common/dom/fire_event";
68
import { renderMarkdown } from "../resources/render-markdown";
79
import { CacheManager } from "../util/cache-manager";
810

11+
const h = (template: ReturnType<typeof unsafeHTML>) => html`${template}`;
12+
913
const markdownCache = new CacheManager<string>(1000);
1014

1115
const _gitHubMarkdownAlerts = {
@@ -48,18 +52,26 @@ class HaMarkdownElement extends ReactiveElement {
4852
return this;
4953
}
5054

55+
private _renderPromise: ReturnType<typeof this._render> = Promise.resolve();
56+
5157
protected update(changedProps) {
5258
super.update(changedProps);
5359
if (this.content !== undefined) {
54-
this._render();
60+
this._renderPromise = this._render();
5561
}
5662
}
5763

64+
protected async getUpdateComplete(): Promise<boolean> {
65+
await super.getUpdateComplete();
66+
await this._renderPromise;
67+
return true;
68+
}
69+
5870
protected willUpdate(_changedProperties: PropertyValues): void {
5971
if (!this.innerHTML && this.cache) {
6072
const key = this._computeCacheKey();
6173
if (markdownCache.has(key)) {
62-
this.innerHTML = markdownCache.get(key)!;
74+
render(markdownCache.get(key)!, this.renderRoot);
6375
this._resize();
6476
}
6577
}
@@ -75,7 +87,7 @@ class HaMarkdownElement extends ReactiveElement {
7587
}
7688

7789
private async _render() {
78-
this.innerHTML = await renderMarkdown(
90+
const elements = await renderMarkdown(
7991
String(this.content),
8092
{
8193
breaks: this.breaks,
@@ -87,6 +99,11 @@ class HaMarkdownElement extends ReactiveElement {
8799
}
88100
);
89101

102+
render(
103+
elements.map((e) => h(unsafeHTML(e))),
104+
this.renderRoot
105+
);
106+
90107
this._resize();
91108

92109
const walker = document.createTreeWalker(

src/components/ha-markdown.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
2-
import { customElement, property } from "lit/decorators";
1+
import {
2+
css,
3+
html,
4+
LitElement,
5+
nothing,
6+
type ReactiveElement,
7+
type CSSResultGroup,
8+
} from "lit";
9+
import { customElement, property, query } from "lit/decorators";
310
import "./ha-markdown-element";
411

512
@customElement("ha-markdown")
@@ -18,6 +25,14 @@ export class HaMarkdown extends LitElement {
1825

1926
@property({ type: Boolean }) public cache = false;
2027

28+
@query("ha-markdown-element") private _markdownElement!: ReactiveElement;
29+
30+
protected async getUpdateComplete() {
31+
const result = await super.getUpdateComplete();
32+
await this._markdownElement.updateComplete;
33+
return result;
34+
}
35+
2136
protected render() {
2237
if (!this.content) {
2338
return nothing;
@@ -53,19 +68,46 @@ export class HaMarkdown extends LitElement {
5368
margin: var(--ha-space-1) 0;
5469
}
5570
a {
56-
color: var(--primary-color);
71+
color: var(--markdown-link-color, var(--primary-color));
5772
}
5873
img {
74+
background-color: rgba(10, 10, 10, 0.15);
75+
border-radius: var(--markdown-image-border-radius);
5976
max-width: 100%;
77+
min-height: 2lh;
78+
height: auto;
79+
width: auto;
80+
text-indent: 4px;
81+
transition: height 0.2s ease-in-out;
82+
}
83+
p:first-child > img:first-child {
84+
vertical-align: top;
85+
}
86+
p:first-child > img:last-child {
87+
vertical-align: top;
88+
}
89+
ol,
90+
ul {
91+
list-style-position: inside;
92+
padding-inline-start: 0;
93+
}
94+
li {
95+
&:has(input[type="checkbox"]) {
96+
list-style: none;
97+
& > input[type="checkbox"] {
98+
margin-left: 0;
99+
}
100+
}
101+
}
102+
svg {
103+
background-color: var(--markdown-svg-background-color, none);
104+
color: var(--markdown-svg-color, none);
60105
}
61106
code,
62107
pre {
63108
background-color: var(--markdown-code-background-color, none);
64109
border-radius: var(--ha-border-radius-sm);
65-
}
66-
svg {
67-
background-color: var(--markdown-svg-background-color, none);
68-
color: var(--markdown-svg-color, none);
110+
color: var(--markdown-code-text-color, inherit);
69111
}
70112
code {
71113
font-size: var(--ha-font-size-s);
@@ -97,6 +139,24 @@ export class HaMarkdown extends LitElement {
97139
border-bottom: none;
98140
margin: var(--ha-space-4) 0;
99141
}
142+
table {
143+
border-collapse: collapse;
144+
display: block;
145+
overflow-x: auto;
146+
}
147+
th {
148+
text-align: start;
149+
}
150+
td,
151+
th {
152+
border: 1px solid var(--markdown-table-border-color, transparent);
153+
padding: 0.25em 0.5em;
154+
}
155+
blockquote {
156+
border-left: 4px solid var(--divider-color);
157+
margin-inline: 0;
158+
padding-inline: 1em;
159+
}
100160
` as CSSResultGroup;
101161
}
102162

0 commit comments

Comments
 (0)