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;
max-width: 85%;
margin-top: 1rem;
background: var(--bg-user-message);
overflow-x: auto;
}
.message :is(textarea) {
display: block;
}
.user-message {
margin-left: auto;
}
.user-message > div {
color: var(--text-user);
background: var(--bg-user-message);
margin-left: auto;
white-space: pre-wrap;
}
@@ -69,7 +79,6 @@
padding: 0.5rem 2.5rem 0.5rem 0.5rem;
background: var(--bg-code);
border-radius: 0.4rem;
overflow-x: auto;
white-space: pre;
}
.copy-btn {
@@ -118,13 +127,22 @@
.submit.busy {
animation: both pulse 1s infinite;
}
.model-opt__label:has(input[checked]) {
font-weight: 700;
}
#model-selector,
#history-panel {
font-size: 0.8em;
position: absolute;
border-radius: 0.5rem;
top: 1rem;
opacity: 0.3;
padding: 0.5rem;
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 {
left: 1rem;
@@ -180,12 +198,11 @@
<div x-text="msg.content"></div>
</template>
<template x-if="msg.role === 'assistant'">
<div x-html="renderMd(msg.content)"></div>
<div x-html="renderMarkdown(msg.content)"></div>
</template>
</div>
</template>
<!-- Streaming element (target for smd parser) -->
<div class="message system-message" x-show="isBusy" x-ref="streamBox"></div>
<div class="message system-message" x-show="isBusy" x-ref="incomingMessage"></div>
</div>
<!-- Textarea -->
@@ -214,7 +231,7 @@
</details>
<!-- History -->
<details id="history-panel">
<details id="history-panel" x-ref="historyPanel">
<summary>History</summary>
<div class="history-list">
<template x-if="historyItems.length === 0">
@@ -231,9 +248,12 @@
</div>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<script type="module">
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 ??= {};
const systemPrompt = `You are a helpful AI assistant.
@@ -242,7 +262,7 @@
Keep a critical mindset,
do not assume blindly.
Challenge my views when appropriate.
Answer in valid markdown!.`;
Answer in valid markdown only, no exceptions.`;
const chatApp = () => ({
messages: [{ role: "system", content: systemPrompt }],
@@ -266,7 +286,7 @@
this.$refs.msgBox.addEventListener("click", (e) => {
if (e.target.classList.contains("copy-btn")) {
const code = e.target.closest("pre").querySelector("code").innerText;
navigator.clipboard.writeText(code);
copyToClipboard(code);
const original = e.target.textContent;
e.target.textContent = "Copied!";
setTimeout(() => (e.target.textContent = original), 1200);
@@ -296,23 +316,13 @@
}
},
renderMd(markdown) {
const container = document.createElement("div");
const renderer = smd.default_renderer(container);
const parser = smd.parser(renderer);
smd.parser_write(parser, markdown);
smd.parser_end(parser);
this.attachCopyButtons(container);
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);
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" }),
getExcerpt: (msgs) => msgs.find((m) => m.role === "user")?.content.slice(0, 30) || "Empty chat",
renderMarkdown,
async handleSubmit() {
const text = this.promptText.trim();
if (!text || this.isBusy) return;
@@ -330,10 +341,7 @@
this.abortController = new AbortController();
// Streaming setup
const target = this.$refs.streamBox;
target.innerHTML = "";
const renderer = smd.default_renderer(target);
const parser = smd.parser(renderer);
const target = this.$refs.incomingMessage;
let fullResponse = "";
this.scrollToBottom();
@@ -347,12 +355,15 @@
signal: this.abortController.signal,
onContent: (chunk) => {
fullResponse += chunk;
smd.parser_write(parser, chunk);
target.innerHTML = renderMarkdown(fullResponse);
this.attachCopyButtons(target);
this.scrollToBottom();
},
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}]`;
this.messages.push({ role: "assistant", content: fullResponse + note });
} finally {
smd.parser_end(parser);
this.isBusy = false;
this.abortController = null;
this.historyItems = saveHistory([...this.messages]);
@@ -377,10 +387,18 @@
restoreConversation(msgs) {
this.messages = JSON.parse(JSON.stringify(msgs));
this.userHasScrolledUp = false;
this.$refs.historyPanel.removeAttribute("open");
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 }) {
const response = await fetch(`${baseUrl}/chat/completions`, {
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 todayKey = () => new Date().toISOString().slice(0, 10);
const loadHistory = () => {
@@ -489,7 +526,7 @@
const saveHistory = (messages) => {
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
const today = todayKey();
all[today] ||= [];
all[today] ??= [];
all[today].unshift({ time: new Date().toISOString(), messages });
all[today] = all[today].slice(0, 5);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));