nsarrazin commited on
Commit
be6d225
·
unverified ·
1 Parent(s): ddbe419

refactor: improve rendering performance on low-end hardware (#1825)

Browse files

refactor: improve rendering perf. on low-end hardware

- Reintroduced MarkdownWorker for asynchronous processing of markdown content.

- Debounce message content updates dynamically based on render time

src/lib/components/chat/MarkdownRenderer.svelte CHANGED
@@ -1,40 +1,45 @@
1
  <script lang="ts">
2
  import type { WebSearchSource } from "$lib/types/WebSearch";
3
  import { processTokens, processTokensSync, type Token } from "$lib/utils/marked";
4
- // import MarkdownWorker from "$lib/workers/markdownWorker?worker";
5
  import CodeBlock from "../CodeBlock.svelte";
6
- // import type { IncomingMessage, OutgoingMessage } from "$lib/workers/markdownWorker";
7
  import { browser } from "$app/environment";
8
 
9
  import DOMPurify from "isomorphic-dompurify";
 
 
10
 
11
  interface Props {
12
  content: string;
13
  sources?: WebSearchSource[];
14
  }
15
 
16
- // const worker = browser && window.Worker ? new MarkdownWorker() : null;
17
 
18
  let { content, sources = [] }: Props = $props();
19
 
20
  let tokens: Token[] = $state(processTokensSync(content, sources));
21
 
22
  async function processContent(content: string, sources: WebSearchSource[]): Promise<Token[]> {
23
- // if (worker) {
24
- // return new Promise((resolve) => {
25
- // worker.onmessage = (event: MessageEvent<OutgoingMessage>) => {
26
- // if (event.data.type !== "processed") {
27
- // throw new Error("Invalid message type");
28
- // }
29
- // resolve(event.data.tokens);
30
- // };
31
- // worker.postMessage(
32
- // JSON.parse(JSON.stringify({ content, sources, type: "process" })) as IncomingMessage
33
- // );
34
- // });
35
- // } else {
36
- return processTokens(content, sources);
37
- // }
 
 
 
38
  }
39
 
40
  $effect(() => {
@@ -42,25 +47,40 @@
42
  tokens = processTokensSync(content, sources);
43
  } else {
44
  (async () => {
45
- tokens = await processContent(content, sources);
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  })();
47
  }
48
  });
49
 
50
- DOMPurify.addHook("afterSanitizeAttributes", (node) => {
51
- if (node.tagName === "A") {
52
- node.setAttribute("target", "_blank");
53
- node.setAttribute("rel", "noreferrer");
54
- }
 
 
 
 
55
  });
56
  </script>
57
 
58
  {#each tokens as token}
59
  {#if token.type === "text"}
60
- {#await token.html then html}
61
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
62
- {@html DOMPurify.sanitize(html)}
63
- {/await}
64
  {:else if token.type === "code"}
65
  <CodeBlock code={token.code} rawCode={token.rawCode} />
66
  {/if}
 
1
  <script lang="ts">
2
  import type { WebSearchSource } from "$lib/types/WebSearch";
3
  import { processTokens, processTokensSync, type Token } from "$lib/utils/marked";
4
+ import MarkdownWorker from "$lib/workers/markdownWorker?worker";
5
  import CodeBlock from "../CodeBlock.svelte";
6
+ import type { IncomingMessage, OutgoingMessage } from "$lib/workers/markdownWorker";
7
  import { browser } from "$app/environment";
8
 
9
  import DOMPurify from "isomorphic-dompurify";
10
+ import { onMount } from "svelte";
11
+ import { updateDebouncer } from "$lib/utils/updates";
12
 
13
  interface Props {
14
  content: string;
15
  sources?: WebSearchSource[];
16
  }
17
 
18
+ let worker: Worker | null = null;
19
 
20
  let { content, sources = [] }: Props = $props();
21
 
22
  let tokens: Token[] = $state(processTokensSync(content, sources));
23
 
24
  async function processContent(content: string, sources: WebSearchSource[]): Promise<Token[]> {
25
+ if (worker) {
26
+ return new Promise((resolve) => {
27
+ if (!worker) {
28
+ throw new Error("Worker not initialized");
29
+ }
30
+ worker.onmessage = (event: MessageEvent<OutgoingMessage>) => {
31
+ if (event.data.type !== "processed") {
32
+ throw new Error("Invalid message type");
33
+ }
34
+ resolve(event.data.tokens);
35
+ };
36
+ worker.postMessage(
37
+ JSON.parse(JSON.stringify({ content, sources, type: "process" })) as IncomingMessage
38
+ );
39
+ });
40
+ } else {
41
+ return processTokens(content, sources);
42
+ }
43
  }
44
 
45
  $effect(() => {
 
47
  tokens = processTokensSync(content, sources);
48
  } else {
49
  (async () => {
50
+ updateDebouncer.startRender();
51
+ tokens = await processContent(content, sources).then(
52
+ async (tokens) =>
53
+ await Promise.all(
54
+ tokens.map(async (token) => {
55
+ if (token.type === "text") {
56
+ token.html = DOMPurify.sanitize(await token.html);
57
+ }
58
+ return token;
59
+ })
60
+ )
61
+ );
62
+
63
+ updateDebouncer.endRender();
64
  })();
65
  }
66
  });
67
 
68
+ onMount(() => {
69
+ worker = browser && window.Worker ? new MarkdownWorker() : null;
70
+
71
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
72
+ if (node.tagName === "A") {
73
+ node.setAttribute("target", "_blank");
74
+ node.setAttribute("rel", "noreferrer");
75
+ }
76
+ });
77
  });
78
  </script>
79
 
80
  {#each tokens as token}
81
  {#if token.type === "text"}
82
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
83
+ {@html token.html}
 
 
84
  {:else if token.type === "code"}
85
  <CodeBlock code={token.code} rawCode={token.rawCode} />
86
  {/if}
src/lib/utils/updates.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // This is a debouncer for the updates from the server to the client
2
+ // It is used to prevent the client from being overloaded with too many updates
3
+ // It works by keeping track of the time it takes to render the updates
4
+ // and adding a safety margin to it, to find the debounce time.
5
+
6
+ class UpdateDebouncer {
7
+ private renderStartedAt: Date | null = null;
8
+ private lastRenderTimes: number[] = [];
9
+
10
+ get maxUpdateTime() {
11
+ if (this.lastRenderTimes.length === 0) {
12
+ return 50;
13
+ }
14
+ return Math.max(...this.lastRenderTimes) * 3;
15
+ }
16
+
17
+ public startRender() {
18
+ this.renderStartedAt = new Date();
19
+ }
20
+
21
+ public endRender() {
22
+ if (!this.renderStartedAt) {
23
+ return;
24
+ }
25
+
26
+ const timeSinceRenderStarted = new Date().getTime() - this.renderStartedAt.getTime();
27
+ this.lastRenderTimes.push(timeSinceRenderStarted);
28
+ if (this.lastRenderTimes.length > 10) {
29
+ this.lastRenderTimes.shift();
30
+ }
31
+ this.renderStartedAt = null;
32
+ }
33
+ }
34
+
35
+ export const updateDebouncer = new UpdateDebouncer();
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -26,6 +26,7 @@
26
  import { browser } from "$app/environment";
27
 
28
  import "katex/dist/katex.min.css";
 
29
 
30
  let { data = $bindable() } = $props();
31
 
@@ -270,6 +271,12 @@
270
  if (messageUpdatesIterator === undefined) return;
271
 
272
  files = [];
 
 
 
 
 
 
273
 
274
  for await (const update of messageUpdatesIterator) {
275
  if ($isAborted) {
@@ -283,10 +290,6 @@
283
  update.token = update.token.replaceAll("\0", "");
284
  }
285
 
286
- // dont write updates for reasoning stream and normal stream to reduce render load
287
- // but handle the rest
288
-
289
- // Skip storing high-frequency updates to reduce render load
290
  const isHighFrequencyUpdate =
291
  (update.type === MessageUpdateType.Reasoning &&
292
  update.subtype === MessageReasoningUpdateType.Stream) ||
@@ -297,9 +300,16 @@
297
  if (!isHighFrequencyUpdate) {
298
  messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
299
  }
 
300
 
301
  if (update.type === MessageUpdateType.Stream && !$settings.disableStream) {
302
- messageToWriteTo.content += update.token;
 
 
 
 
 
 
303
  pending = false;
304
  } else if (
305
  update.type === MessageUpdateType.Status &&
@@ -326,7 +336,15 @@
326
  messageToWriteTo.reasoning = "";
327
  }
328
  if (update.subtype === MessageReasoningUpdateType.Stream) {
329
- messageToWriteTo.reasoning += update.token;
 
 
 
 
 
 
 
 
330
  }
331
  }
332
  }
 
26
  import { browser } from "$app/environment";
27
 
28
  import "katex/dist/katex.min.css";
29
+ import { updateDebouncer } from "$lib/utils/updates.js";
30
 
31
  let { data = $bindable() } = $props();
32
 
 
271
  if (messageUpdatesIterator === undefined) return;
272
 
273
  files = [];
274
+ let buffer = "";
275
+ // Initialize lastUpdateTime outside the loop to persist between updates
276
+ let lastUpdateTime = new Date();
277
+
278
+ let reasoningBuffer = "";
279
+ let reasoningLastUpdate = new Date();
280
 
281
  for await (const update of messageUpdatesIterator) {
282
  if ($isAborted) {
 
290
  update.token = update.token.replaceAll("\0", "");
291
  }
292
 
 
 
 
 
293
  const isHighFrequencyUpdate =
294
  (update.type === MessageUpdateType.Reasoning &&
295
  update.subtype === MessageReasoningUpdateType.Stream) ||
 
300
  if (!isHighFrequencyUpdate) {
301
  messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
302
  }
303
+ const currentTime = new Date();
304
 
305
  if (update.type === MessageUpdateType.Stream && !$settings.disableStream) {
306
+ buffer += update.token;
307
+ // Check if this is the first update or if enough time has passed
308
+ if (currentTime.getTime() - lastUpdateTime.getTime() > updateDebouncer.maxUpdateTime) {
309
+ messageToWriteTo.content += buffer;
310
+ buffer = "";
311
+ lastUpdateTime = currentTime;
312
+ }
313
  pending = false;
314
  } else if (
315
  update.type === MessageUpdateType.Status &&
 
336
  messageToWriteTo.reasoning = "";
337
  }
338
  if (update.subtype === MessageReasoningUpdateType.Stream) {
339
+ reasoningBuffer += update.token;
340
+ if (
341
+ currentTime.getTime() - reasoningLastUpdate.getTime() >
342
+ updateDebouncer.maxUpdateTime
343
+ ) {
344
+ messageToWriteTo.reasoning += reasoningBuffer;
345
+ reasoningBuffer = "";
346
+ reasoningLastUpdate = currentTime;
347
+ }
348
  }
349
  }
350
  }