Spaces:
Paused
Paused
feat(front): use webworker for markdown parsing (#1733)
Browse files* feat(front): move markdown parsing to web worker
* feat: use webworker for markdown parsing
* feat(markdown-worker): implement message buffering and processing
* fix(markdown): import KaTeX CSS locally instead of via CDN
* fix(markdown): make sure messages are serializable
* feat(markdown): make sure links have target blank
* refactor(markdown): improve HTML escaping function formatting
src/lib/components/chat/MarkdownRenderer.svelte
CHANGED
|
@@ -1,199 +1,71 @@
|
|
| 1 |
<script lang="ts">
|
| 2 |
import type { WebSearchSource } from "$lib/types/WebSearch";
|
| 3 |
-
import
|
| 4 |
-
import "
|
| 5 |
-
import DOMPurify from "isomorphic-dompurify";
|
| 6 |
-
import { Marked } from "marked";
|
| 7 |
-
import type { Tokens, TokenizerExtension, RendererExtension } from "marked";
|
| 8 |
import CodeBlock from "../CodeBlock.svelte";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
interface Props {
|
| 11 |
content: string;
|
| 12 |
sources?: WebSearchSource[];
|
| 13 |
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
interface katexBlockToken extends Tokens.Generic {
|
| 18 |
-
type: "katexBlock";
|
| 19 |
-
raw: string;
|
| 20 |
-
text: string;
|
| 21 |
-
displayMode: true;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
interface katexInlineToken extends Tokens.Generic {
|
| 25 |
-
type: "katexInline";
|
| 26 |
-
raw: string;
|
| 27 |
-
text: string;
|
| 28 |
-
displayMode: false;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
export const katexBlockExtension: TokenizerExtension & RendererExtension = {
|
| 32 |
-
name: "katexBlock",
|
| 33 |
-
level: "block",
|
| 34 |
-
|
| 35 |
-
start(src: string): number | undefined {
|
| 36 |
-
const match = src.match(/(\${2}|\\\[)/);
|
| 37 |
-
return match ? match.index : -1;
|
| 38 |
-
},
|
| 39 |
-
|
| 40 |
-
tokenizer(src: string): katexBlockToken | undefined {
|
| 41 |
-
// 1) $$ ... $$
|
| 42 |
-
const rule1 = /^\${2}([\s\S]+?)\${2}/;
|
| 43 |
-
const match1 = rule1.exec(src);
|
| 44 |
-
if (match1) {
|
| 45 |
-
const token: katexBlockToken = {
|
| 46 |
-
type: "katexBlock",
|
| 47 |
-
raw: match1[0],
|
| 48 |
-
text: match1[1].trim(),
|
| 49 |
-
displayMode: true,
|
| 50 |
-
};
|
| 51 |
-
return token;
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
// 2) \[ ... \]
|
| 55 |
-
const rule2 = /^\\\[([\s\S]+?)\\\]/;
|
| 56 |
-
const match2 = rule2.exec(src);
|
| 57 |
-
if (match2) {
|
| 58 |
-
const token: katexBlockToken = {
|
| 59 |
-
type: "katexBlock",
|
| 60 |
-
raw: match2[0],
|
| 61 |
-
text: match2[1].trim(),
|
| 62 |
-
displayMode: true,
|
| 63 |
-
};
|
| 64 |
-
return token;
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
return undefined;
|
| 68 |
-
},
|
| 69 |
-
|
| 70 |
-
renderer(token) {
|
| 71 |
-
if (token.type === "katexBlock") {
|
| 72 |
-
return katex.renderToString(token.text, {
|
| 73 |
-
throwOnError: false,
|
| 74 |
-
displayMode: token.displayMode,
|
| 75 |
-
});
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
return undefined;
|
| 79 |
-
},
|
| 80 |
-
};
|
| 81 |
|
| 82 |
-
|
| 83 |
-
name: "katexInline",
|
| 84 |
-
level: "inline",
|
| 85 |
-
|
| 86 |
-
start(src: string): number | undefined {
|
| 87 |
-
const match = src.match(/(\$|\\\()/);
|
| 88 |
-
return match ? match.index : -1;
|
| 89 |
-
},
|
| 90 |
-
|
| 91 |
-
tokenizer(src: string): katexInlineToken | undefined {
|
| 92 |
-
// 1) $...$
|
| 93 |
-
const rule1 = /^\$([^$]+?)\$/;
|
| 94 |
-
const match1 = rule1.exec(src);
|
| 95 |
-
if (match1) {
|
| 96 |
-
const token: katexInlineToken = {
|
| 97 |
-
type: "katexInline",
|
| 98 |
-
raw: match1[0],
|
| 99 |
-
text: match1[1].trim(),
|
| 100 |
-
displayMode: false,
|
| 101 |
-
};
|
| 102 |
-
return token;
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
// 2) \(...\)
|
| 106 |
-
const rule2 = /^\\\(([\s\S]+?)\\\)/;
|
| 107 |
-
const match2 = rule2.exec(src);
|
| 108 |
-
if (match2) {
|
| 109 |
-
const token: katexInlineToken = {
|
| 110 |
-
type: "katexInline",
|
| 111 |
-
raw: match2[0],
|
| 112 |
-
text: match2[1].trim(),
|
| 113 |
-
displayMode: false,
|
| 114 |
-
};
|
| 115 |
-
return token;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
return undefined;
|
| 119 |
-
},
|
| 120 |
-
|
| 121 |
-
renderer(token) {
|
| 122 |
-
if (token.type === "katexInline") {
|
| 123 |
-
return katex.renderToString(token.text, {
|
| 124 |
-
throwOnError: false,
|
| 125 |
-
displayMode: token.displayMode,
|
| 126 |
-
});
|
| 127 |
-
}
|
| 128 |
-
return undefined;
|
| 129 |
-
},
|
| 130 |
-
};
|
| 131 |
-
|
| 132 |
-
function escapeHTML(content: string) {
|
| 133 |
-
return content.replace(
|
| 134 |
-
/[<>&"']/g,
|
| 135 |
-
(x) =>
|
| 136 |
-
({
|
| 137 |
-
"<": "<",
|
| 138 |
-
">": ">",
|
| 139 |
-
"&": "&",
|
| 140 |
-
"'": "'",
|
| 141 |
-
'"': """,
|
| 142 |
-
})[x] || x
|
| 143 |
-
);
|
| 144 |
-
}
|
| 145 |
|
| 146 |
-
|
| 147 |
-
const linkStyle =
|
| 148 |
-
"color: rgb(59, 130, 246); text-decoration: none; hover:text-decoration: underline;";
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
.
|
| 154 |
-
if (
|
| 155 |
-
|
| 156 |
-
if (source) {
|
| 157 |
-
return `<a href="${source.link}" target="_blank" rel="noreferrer" style="${linkStyle}">${index}</a>`;
|
| 158 |
}
|
| 159 |
-
|
| 160 |
-
}
|
| 161 |
-
.
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
}
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
});
|
| 181 |
|
| 182 |
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
| 183 |
if (node.tagName === "A") {
|
| 184 |
-
node.setAttribute("rel", "noreferrer");
|
| 185 |
node.setAttribute("target", "_blank");
|
|
|
|
| 186 |
}
|
| 187 |
});
|
| 188 |
</script>
|
| 189 |
|
| 190 |
-
{#each
|
| 191 |
-
{#if token.type === "
|
| 192 |
-
|
| 193 |
-
{:else}
|
| 194 |
-
{#await marked.parse(token.raw) then parsed}
|
| 195 |
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
| 196 |
-
{@html
|
| 197 |
{/await}
|
|
|
|
|
|
|
| 198 |
{/if}
|
| 199 |
{/each}
|
|
|
|
| 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(() => {
|
| 41 |
+
if (!browser) {
|
| 42 |
+
tokens = processTokensSync(content, sources);
|
| 43 |
+
} else {
|
| 44 |
+
(async () => {
|
| 45 |
+
if (!browser) {
|
| 46 |
+
tokens = processTokensSync(content, sources);
|
| 47 |
+
} else {
|
| 48 |
+
tokens = await processContent(content, sources);
|
| 49 |
+
}
|
| 50 |
+
})();
|
| 51 |
+
}
|
| 52 |
});
|
| 53 |
|
| 54 |
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
| 55 |
if (node.tagName === "A") {
|
|
|
|
| 56 |
node.setAttribute("target", "_blank");
|
| 57 |
+
node.setAttribute("rel", "noreferrer");
|
| 58 |
}
|
| 59 |
});
|
| 60 |
</script>
|
| 61 |
|
| 62 |
+
{#each tokens as token}
|
| 63 |
+
{#if token.type === "text"}
|
| 64 |
+
{#await token.html then html}
|
|
|
|
|
|
|
| 65 |
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
| 66 |
+
{@html DOMPurify.sanitize(html)}
|
| 67 |
{/await}
|
| 68 |
+
{:else if token.type === "code"}
|
| 69 |
+
<CodeBlock lang={token.lang} code={token.code} />
|
| 70 |
{/if}
|
| 71 |
{/each}
|
src/lib/utils/marked.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import katex from "katex";
|
| 2 |
+
import "katex/dist/contrib/mhchem.mjs";
|
| 3 |
+
import { Marked } from "marked";
|
| 4 |
+
import type { Tokens, TokenizerExtension, RendererExtension } from "marked";
|
| 5 |
+
import type { WebSearchSource } from "$lib/types/WebSearch";
|
| 6 |
+
|
| 7 |
+
interface katexBlockToken extends Tokens.Generic {
|
| 8 |
+
type: "katexBlock";
|
| 9 |
+
raw: string;
|
| 10 |
+
text: string;
|
| 11 |
+
displayMode: true;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface katexInlineToken extends Tokens.Generic {
|
| 15 |
+
type: "katexInline";
|
| 16 |
+
raw: string;
|
| 17 |
+
text: string;
|
| 18 |
+
displayMode: false;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export const katexBlockExtension: TokenizerExtension & RendererExtension = {
|
| 22 |
+
name: "katexBlock",
|
| 23 |
+
level: "block",
|
| 24 |
+
|
| 25 |
+
start(src: string): number | undefined {
|
| 26 |
+
const match = src.match(/(\${2}|\\\[)/);
|
| 27 |
+
return match ? match.index : -1;
|
| 28 |
+
},
|
| 29 |
+
|
| 30 |
+
tokenizer(src: string): katexBlockToken | undefined {
|
| 31 |
+
// 1) $$ ... $$
|
| 32 |
+
const rule1 = /^\${2}([\s\S]+?)\${2}/;
|
| 33 |
+
const match1 = rule1.exec(src);
|
| 34 |
+
if (match1) {
|
| 35 |
+
const token: katexBlockToken = {
|
| 36 |
+
type: "katexBlock",
|
| 37 |
+
raw: match1[0],
|
| 38 |
+
text: match1[1].trim(),
|
| 39 |
+
displayMode: true,
|
| 40 |
+
};
|
| 41 |
+
return token;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// 2) \[ ... \]
|
| 45 |
+
const rule2 = /^\\\[([\s\S]+?)\\\]/;
|
| 46 |
+
const match2 = rule2.exec(src);
|
| 47 |
+
if (match2) {
|
| 48 |
+
const token: katexBlockToken = {
|
| 49 |
+
type: "katexBlock",
|
| 50 |
+
raw: match2[0],
|
| 51 |
+
text: match2[1].trim(),
|
| 52 |
+
displayMode: true,
|
| 53 |
+
};
|
| 54 |
+
return token;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return undefined;
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
renderer(token) {
|
| 61 |
+
if (token.type === "katexBlock") {
|
| 62 |
+
return katex.renderToString(token.text, {
|
| 63 |
+
throwOnError: false,
|
| 64 |
+
displayMode: token.displayMode,
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
return undefined;
|
| 68 |
+
},
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const katexInlineExtension: TokenizerExtension & RendererExtension = {
|
| 72 |
+
name: "katexInline",
|
| 73 |
+
level: "inline",
|
| 74 |
+
|
| 75 |
+
start(src: string): number | undefined {
|
| 76 |
+
const match = src.match(/(\$|\\\()/);
|
| 77 |
+
return match ? match.index : -1;
|
| 78 |
+
},
|
| 79 |
+
|
| 80 |
+
tokenizer(src: string): katexInlineToken | undefined {
|
| 81 |
+
// 1) $...$
|
| 82 |
+
const rule1 = /^\$([^$]+?)\$/;
|
| 83 |
+
const match1 = rule1.exec(src);
|
| 84 |
+
if (match1) {
|
| 85 |
+
const token: katexInlineToken = {
|
| 86 |
+
type: "katexInline",
|
| 87 |
+
raw: match1[0],
|
| 88 |
+
text: match1[1].trim(),
|
| 89 |
+
displayMode: false,
|
| 90 |
+
};
|
| 91 |
+
return token;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// 2) \(...\)
|
| 95 |
+
const rule2 = /^\\\(([\s\S]+?)\\\)/;
|
| 96 |
+
const match2 = rule2.exec(src);
|
| 97 |
+
if (match2) {
|
| 98 |
+
const token: katexInlineToken = {
|
| 99 |
+
type: "katexInline",
|
| 100 |
+
raw: match2[0],
|
| 101 |
+
text: match2[1].trim(),
|
| 102 |
+
displayMode: false,
|
| 103 |
+
};
|
| 104 |
+
return token;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
return undefined;
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
renderer(token) {
|
| 111 |
+
if (token.type === "katexInline") {
|
| 112 |
+
return katex.renderToString(token.text, {
|
| 113 |
+
throwOnError: false,
|
| 114 |
+
displayMode: token.displayMode,
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
return undefined;
|
| 118 |
+
},
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
function escapeHTML(content: string) {
|
| 122 |
+
return content.replace(
|
| 123 |
+
/[<>&"']/g,
|
| 124 |
+
(x) =>
|
| 125 |
+
({
|
| 126 |
+
"<": "<",
|
| 127 |
+
">": ">",
|
| 128 |
+
"&": "&",
|
| 129 |
+
"'": "'",
|
| 130 |
+
'"': """,
|
| 131 |
+
})[x] || x
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function addInlineCitations(md: string, webSearchSources: WebSearchSource[] = []): string {
|
| 136 |
+
const linkStyle =
|
| 137 |
+
"color: rgb(59, 130, 246); text-decoration: none; hover:text-decoration: underline;";
|
| 138 |
+
return md.replace(/\[(\d+)\]/g, (match: string) => {
|
| 139 |
+
const indices: number[] = (match.match(/\d+/g) || []).map(Number);
|
| 140 |
+
const links: string = indices
|
| 141 |
+
.map((index: number) => {
|
| 142 |
+
if (index === 0) return false;
|
| 143 |
+
const source = webSearchSources[index - 1];
|
| 144 |
+
if (source) {
|
| 145 |
+
return `<a href="${source.link}" target="_blank" rel="noreferrer" style="${linkStyle}">${index}</a>`;
|
| 146 |
+
}
|
| 147 |
+
return "";
|
| 148 |
+
})
|
| 149 |
+
.filter(Boolean)
|
| 150 |
+
.join(", ");
|
| 151 |
+
return links ? ` <sup>${links}</sup>` : match;
|
| 152 |
+
});
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function createMarkedInstance(sources: WebSearchSource[]): Marked {
|
| 156 |
+
return new Marked({
|
| 157 |
+
hooks: {
|
| 158 |
+
postprocess: (html) => addInlineCitations(html, sources),
|
| 159 |
+
},
|
| 160 |
+
extensions: [katexBlockExtension, katexInlineExtension],
|
| 161 |
+
renderer: {
|
| 162 |
+
link: (href, title, text) =>
|
| 163 |
+
`<a href="${href?.replace(/>$/, "")}" target="_blank" rel="noreferrer">${text}</a>`,
|
| 164 |
+
html: (html) => escapeHTML(html),
|
| 165 |
+
},
|
| 166 |
+
gfm: true,
|
| 167 |
+
breaks: true,
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
type CodeToken = {
|
| 171 |
+
type: "code";
|
| 172 |
+
lang: string;
|
| 173 |
+
code: string;
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
type TextToken = {
|
| 177 |
+
type: "text";
|
| 178 |
+
html: string | Promise<string>;
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
export async function processTokens(content: string, sources: WebSearchSource[]): Promise<Token[]> {
|
| 182 |
+
const marked = createMarkedInstance(sources);
|
| 183 |
+
const tokens = marked.lexer(content);
|
| 184 |
+
|
| 185 |
+
const processedTokens = await Promise.all(
|
| 186 |
+
tokens.map(async (token) => {
|
| 187 |
+
if (token.type === "code") {
|
| 188 |
+
return {
|
| 189 |
+
type: "code" as const,
|
| 190 |
+
lang: token.lang,
|
| 191 |
+
code: token.text,
|
| 192 |
+
};
|
| 193 |
+
} else {
|
| 194 |
+
return {
|
| 195 |
+
type: "text" as const,
|
| 196 |
+
html: marked.parse(token.raw),
|
| 197 |
+
};
|
| 198 |
+
}
|
| 199 |
+
})
|
| 200 |
+
);
|
| 201 |
+
|
| 202 |
+
return processedTokens;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export function processTokensSync(content: string, sources: WebSearchSource[]): Token[] {
|
| 206 |
+
const marked = createMarkedInstance(sources);
|
| 207 |
+
const tokens = marked.lexer(content);
|
| 208 |
+
return tokens.map((token) => {
|
| 209 |
+
if (token.type === "code") {
|
| 210 |
+
return { type: "code" as const, lang: token.lang, code: token.text };
|
| 211 |
+
}
|
| 212 |
+
return { type: "text" as const, html: marked.parse(token.raw) };
|
| 213 |
+
});
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
export type Token = CodeToken | TextToken;
|
src/lib/workers/markdownWorker.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { WebSearchSource } from "$lib/types/WebSearch";
|
| 2 |
+
import { processTokens, type Token } from "$lib/utils/marked";
|
| 3 |
+
|
| 4 |
+
export type IncomingMessage = {
|
| 5 |
+
type: "process";
|
| 6 |
+
content: string;
|
| 7 |
+
sources: WebSearchSource[];
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export type OutgoingMessage = {
|
| 11 |
+
type: "processed";
|
| 12 |
+
tokens: Token[];
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
// Flag to track if the worker is currently processing a message
|
| 16 |
+
let isProcessing = false;
|
| 17 |
+
|
| 18 |
+
// Buffer to store the latest incoming message
|
| 19 |
+
let latestMessage: IncomingMessage | null = null;
|
| 20 |
+
|
| 21 |
+
// Helper function to safely handle the latest message
|
| 22 |
+
async function processMessage() {
|
| 23 |
+
if (latestMessage) {
|
| 24 |
+
const nextMessage = latestMessage;
|
| 25 |
+
|
| 26 |
+
latestMessage = null;
|
| 27 |
+
isProcessing = true;
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
const { content, sources } = nextMessage;
|
| 31 |
+
const processedTokens = await processTokens(content, sources);
|
| 32 |
+
postMessage(JSON.parse(JSON.stringify({ type: "processed", tokens: processedTokens })));
|
| 33 |
+
} finally {
|
| 34 |
+
isProcessing = false;
|
| 35 |
+
|
| 36 |
+
// After processing, check if a new message was buffered
|
| 37 |
+
processMessage();
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
onmessage = (event) => {
|
| 43 |
+
if (event.data.type !== "process") {
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
latestMessage = event.data as IncomingMessage;
|
| 48 |
+
|
| 49 |
+
if (!isProcessing && latestMessage) {
|
| 50 |
+
processMessage();
|
| 51 |
+
}
|
| 52 |
+
};
|
src/routes/conversation/[id]/+page.svelte
CHANGED
|
@@ -27,6 +27,8 @@
|
|
| 27 |
import { useSettingsStore } from "$lib/stores/settings.js";
|
| 28 |
import { browser } from "$app/environment";
|
| 29 |
|
|
|
|
|
|
|
| 30 |
let { data = $bindable() } = $props();
|
| 31 |
|
| 32 |
let loading = $state(false);
|
|
@@ -472,12 +474,6 @@
|
|
| 472 |
|
| 473 |
<svelte:head>
|
| 474 |
<title>{title}</title>
|
| 475 |
-
<link
|
| 476 |
-
rel="stylesheet"
|
| 477 |
-
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
|
| 478 |
-
integrity="sha384-GvrOXuhMATgEsSwCs4smul74iXGOixntILdUW9XmUC6+HX0sLNAK3q71HotJqlAn"
|
| 479 |
-
crossorigin="anonymous"
|
| 480 |
-
/>
|
| 481 |
</svelte:head>
|
| 482 |
|
| 483 |
<ChatWindow
|
|
|
|
| 27 |
import { useSettingsStore } from "$lib/stores/settings.js";
|
| 28 |
import { browser } from "$app/environment";
|
| 29 |
|
| 30 |
+
import "katex/dist/katex.min.css";
|
| 31 |
+
|
| 32 |
let { data = $bindable() } = $props();
|
| 33 |
|
| 34 |
let loading = $state(false);
|
|
|
|
| 474 |
|
| 475 |
<svelte:head>
|
| 476 |
<title>{title}</title>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
</svelte:head>
|
| 478 |
|
| 479 |
<ChatWindow
|