refactor(chat): Replace markdown parser

This commit is contained in:
2025-12-23 18:42:14 +01:00
parent 348c5874be
commit 5df03146ba
+71 -34
View File
@@ -53,10 +53,20 @@
border-radius: 0.8rem; border-radius: 0.8rem;
max-width: 85%; max-width: 85%;
margin-top: 1rem; margin-top: 1rem;
background: var(--bg-user-message);
overflow-x: auto;
} }
.message :is(textarea) {
display: block;
}
.user-message {
margin-left: auto;
}
.user-message > div { .user-message > div {
color: var(--text-user); color: var(--text-user);
background: var(--bg-user-message);
margin-left: auto; margin-left: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -69,7 +79,6 @@
padding: 0.5rem 2.5rem 0.5rem 0.5rem; padding: 0.5rem 2.5rem 0.5rem 0.5rem;
background: var(--bg-code); background: var(--bg-code);
border-radius: 0.4rem; border-radius: 0.4rem;
overflow-x: auto;
white-space: pre; white-space: pre;
} }
.copy-btn { .copy-btn {
@@ -118,13 +127,22 @@
.submit.busy { .submit.busy {
animation: both pulse 1s infinite; animation: both pulse 1s infinite;
} }
.model-opt__label:has(input[checked]) {
font-weight: 700;
}
#model-selector, #model-selector,
#history-panel { #history-panel {
font-size: 0.8em;
position: absolute; position: absolute;
border-radius: 0.5rem;
top: 1rem; top: 1rem;
opacity: 0.3; opacity: 0.3;
padding: 0.5rem; padding: 0.5rem;
cursor: pointer; cursor: pointer;
background-color: color-mix(in srgb, var(--bg-secondary) 30%, transparent);
}
:is(#model-selector, #history-panel):hover {
background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
} }
#model-selector { #model-selector {
left: 1rem; left: 1rem;
@@ -180,12 +198,11 @@
<div x-text="msg.content"></div> <div x-text="msg.content"></div>
</template> </template>
<template x-if="msg.role === 'assistant'"> <template x-if="msg.role === 'assistant'">
<div x-html="renderMd(msg.content)"></div> <div x-html="renderMarkdown(msg.content)"></div>
</template> </template>
</div> </div>
</template> </template>
<!-- Streaming element (target for smd parser) --> <div class="message system-message" x-show="isBusy" x-ref="incomingMessage"></div>
<div class="message system-message" x-show="isBusy" x-ref="streamBox"></div>
</div> </div>
<!-- Textarea --> <!-- Textarea -->
@@ -214,7 +231,7 @@
</details> </details>
<!-- History --> <!-- History -->
<details id="history-panel"> <details id="history-panel" x-ref="historyPanel">
<summary>History</summary> <summary>History</summary>
<div class="history-list"> <div class="history-list">
<template x-if="historyItems.length === 0"> <template x-if="historyItems.length === 0">
@@ -231,9 +248,12 @@
</div> </div>
</div> </div>
<script defer src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<script type="module"> <script type="module">
import Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/module.esm.js"; import Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/module.esm.js";
import * as smd from "https://cdn.jsdelivr.net/npm/streaming-markdown@latest/smd.min.js";
const md = markdownit({ html: false });
window.env ??= {}; window.env ??= {};
const systemPrompt = `You are a helpful AI assistant. const systemPrompt = `You are a helpful AI assistant.
@@ -242,7 +262,7 @@
Keep a critical mindset, Keep a critical mindset,
do not assume blindly. do not assume blindly.
Challenge my views when appropriate. Challenge my views when appropriate.
Answer in valid markdown!.`; Answer in valid markdown only, no exceptions.`;
const chatApp = () => ({ const chatApp = () => ({
messages: [{ role: "system", content: systemPrompt }], messages: [{ role: "system", content: systemPrompt }],
@@ -266,7 +286,7 @@
this.$refs.msgBox.addEventListener("click", (e) => { this.$refs.msgBox.addEventListener("click", (e) => {
if (e.target.classList.contains("copy-btn")) { if (e.target.classList.contains("copy-btn")) {
const code = e.target.closest("pre").querySelector("code").innerText; const code = e.target.closest("pre").querySelector("code").innerText;
navigator.clipboard.writeText(code); copyToClipboard(code);
const original = e.target.textContent; const original = e.target.textContent;
e.target.textContent = "Copied!"; e.target.textContent = "Copied!";
setTimeout(() => (e.target.textContent = original), 1200); setTimeout(() => (e.target.textContent = original), 1200);
@@ -296,23 +316,13 @@
} }
}, },
renderMd(markdown) { attachCopyButtons($el) {
const container = document.createElement("div"); $el.querySelectorAll("pre").forEach(($pre) => {
const renderer = smd.default_renderer(container); if (!$pre.querySelector(".copy-btn")) {
const parser = smd.parser(renderer); const $btn = document.createElement("button");
smd.parser_write(parser, markdown); $btn.className = "copy-btn";
smd.parser_end(parser); $btn.textContent = "Copy";
this.attachCopyButtons(container); $pre.appendChild($btn);
return container.innerHTML;
},
attachCopyButtons(el) {
el.querySelectorAll("pre").forEach((pre) => {
if (!pre.querySelector(".copy-btn")) {
const btn = document.createElement("button");
btn.className = "copy-btn";
btn.textContent = "Copy";
pre.appendChild(btn);
} }
}); });
}, },
@@ -320,6 +330,7 @@
formatTime: (iso) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), formatTime: (iso) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
getExcerpt: (msgs) => msgs.find((m) => m.role === "user")?.content.slice(0, 30) || "Empty chat", getExcerpt: (msgs) => msgs.find((m) => m.role === "user")?.content.slice(0, 30) || "Empty chat",
renderMarkdown,
async handleSubmit() { async handleSubmit() {
const text = this.promptText.trim(); const text = this.promptText.trim();
if (!text || this.isBusy) return; if (!text || this.isBusy) return;
@@ -330,10 +341,7 @@
this.abortController = new AbortController(); this.abortController = new AbortController();
// Streaming setup // Streaming setup
const target = this.$refs.streamBox; const target = this.$refs.incomingMessage;
target.innerHTML = "";
const renderer = smd.default_renderer(target);
const parser = smd.parser(renderer);
let fullResponse = ""; let fullResponse = "";
this.scrollToBottom(); this.scrollToBottom();
@@ -347,12 +355,15 @@
signal: this.abortController.signal, signal: this.abortController.signal,
onContent: (chunk) => { onContent: (chunk) => {
fullResponse += chunk; fullResponse += chunk;
smd.parser_write(parser, chunk); target.innerHTML = renderMarkdown(fullResponse);
this.attachCopyButtons(target); this.attachCopyButtons(target);
this.scrollToBottom(); this.scrollToBottom();
}, },
onError: (e) => { onError: (e) => {
smd.parser_write(parser, `\n\n**Error:** ${e.message}`); fullResponse += `\n\n[Error: ${e.message}]`;
target.innerHTML = renderMarkdown(fullResponse);
this.attachCopyButtons(target);
this.scrollToBottom();
}, },
}); });
@@ -363,7 +374,6 @@
const note = err.name === "AbortError" ? "\n\n[Cancelled]" : `\n\n[Error: ${err.message}]`; const note = err.name === "AbortError" ? "\n\n[Cancelled]" : `\n\n[Error: ${err.message}]`;
this.messages.push({ role: "assistant", content: fullResponse + note }); this.messages.push({ role: "assistant", content: fullResponse + note });
} finally { } finally {
smd.parser_end(parser);
this.isBusy = false; this.isBusy = false;
this.abortController = null; this.abortController = null;
this.historyItems = saveHistory([...this.messages]); this.historyItems = saveHistory([...this.messages]);
@@ -377,10 +387,18 @@
restoreConversation(msgs) { restoreConversation(msgs) {
this.messages = JSON.parse(JSON.stringify(msgs)); this.messages = JSON.parse(JSON.stringify(msgs));
this.userHasScrolledUp = false; this.userHasScrolledUp = false;
this.$refs.historyPanel.removeAttribute("open");
this.scrollToBottom(); this.scrollToBottom();
}, },
}); });
function renderMarkdown(markdown) {
const $container = document.createElement("template");
$container.innerHTML = md.render(markdown);
this.attachCopyButtons($container);
return $container.innerHTML;
}
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, onError, messages, signal, apiKey, model, extraHeaders = {}, ...rest }) { async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, onError, messages, signal, apiKey, model, extraHeaders = {}, ...rest }) {
const response = await fetch(`${baseUrl}/chat/completions`, { const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST", method: "POST",
@@ -477,6 +495,25 @@
} }
} }
function copyToClipboard(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
console.log("Copied!");
} catch (err) {
console.error("Failed to copy:", err);
}
document.body.removeChild(textarea);
}
const STORAGE_KEY = "chat_conversations"; const STORAGE_KEY = "chat_conversations";
const todayKey = () => new Date().toISOString().slice(0, 10); const todayKey = () => new Date().toISOString().slice(0, 10);
const loadHistory = () => { const loadHistory = () => {
@@ -489,7 +526,7 @@
const saveHistory = (messages) => { const saveHistory = (messages) => {
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
const today = todayKey(); const today = todayKey();
all[today] ||= []; all[today] ??= [];
all[today].unshift({ time: new Date().toISOString(), messages }); all[today].unshift({ time: new Date().toISOString(), messages });
all[today] = all[today].slice(0, 5); all[today] = all[today].slice(0, 5);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); localStorage.setItem(STORAGE_KEY, JSON.stringify(all));