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