refactor(chat): Replace markdown parser
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user