Spaces:
Sleeping
Sleeping
refactor: improve rendering performance on low-end hardware (#1825)
Browse filesrefactor: 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 |
-
|
| 5 |
import CodeBlock from "../CodeBlock.svelte";
|
| 6 |
-
|
| 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 |
-
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
$effect(() => {
|
|
@@ -42,25 +47,40 @@
|
|
| 42 |
tokens = processTokensSync(content, sources);
|
| 43 |
} else {
|
| 44 |
(async () => {
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
})();
|
| 47 |
}
|
| 48 |
});
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
});
|
| 56 |
</script>
|
| 57 |
|
| 58 |
{#each tokens as token}
|
| 59 |
{#if token.type === "text"}
|
| 60 |
-
|
| 61 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|