fix(chat): Streaming markdown support
- Better auto scroll support
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cet ci pi ti</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
/* Light theme variables */
|
||||
@@ -40,6 +39,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: "JetBrains Mono", Consolas, Menlo, monospace;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
background-color: var(--bg-primary);
|
||||
@@ -161,7 +161,7 @@
|
||||
anchor-name: --submit;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
#model-selector {
|
||||
position: absolute;
|
||||
position-anchor: --submit;
|
||||
bottom: anchor(top);
|
||||
@@ -193,6 +193,17 @@
|
||||
ul {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:nth-child(odd) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -201,46 +212,65 @@
|
||||
<div id="messages" class="messages"></div>
|
||||
<textarea rows="5" id="prompt" class="prompt" placeholder="Type your message..."></textarea>
|
||||
<button id="submit" class="submit">Send</button>
|
||||
<details class="model-selector">
|
||||
<details id="model-selector" popover>
|
||||
<summary>Model</summary>
|
||||
<label class="model-opt__label">
|
||||
<input type="radio" name="model" value="gemini-2.5-flash" />
|
||||
Gemini 2.5 Flash
|
||||
</label>
|
||||
|
||||
<label class="model-opt__label">
|
||||
<input type="radio" name="model" value="gpt-4.1" checked />
|
||||
GPT-4.1
|
||||
</label>
|
||||
|
||||
<label class="model-opt__label">
|
||||
<input type="radio" name="model" value="gpt-4.1-mini" />
|
||||
GPT-4.1 mini
|
||||
</label>
|
||||
|
||||
<label class="model-opt__label">
|
||||
<input type="radio" name="model" value="claude-3-7-sonnet-20250219" />
|
||||
Claude 3.7 Sonnet
|
||||
</label>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
<script type="module">
|
||||
import * as smd from "https://cdn.jsdelivr.net/npm/streaming-markdown@latest/smd.min.js";
|
||||
window.env ??= {};
|
||||
let abortController = null;
|
||||
const messages = [{ role: "system", content: "You are a helpful AI assistant. Do not preach about implications. Answer in valid markdown." }];
|
||||
|
||||
const $messages = document.getElementById("messages");
|
||||
const $prompt = document.getElementById("prompt");
|
||||
const $submit = document.getElementById("submit");
|
||||
const models = [
|
||||
{ name: "gpt-4.1", chat: chatWithOpenAI },
|
||||
{ name: "gpt-4.1-mini", chat: chatWithOpenAI },
|
||||
{ name: "gemini-2.5-flash", chat: chatWithGemini },
|
||||
{ name: "claude-3-7-sonnet-20250219", chat: chatWithClaude },
|
||||
];
|
||||
window.env.MODEL ||= "gemini-2.5-flash";
|
||||
|
||||
window.env.MODEL ||= "gpt-4.1-mini";
|
||||
document.querySelectorAll("[name=model]").forEach((modelOpt) => {
|
||||
if (modelOpt.value === window.env.MODEL) {
|
||||
modelOpt.checked = true;
|
||||
const systemPrompt = `You are a helpful AI assistant.
|
||||
Do not preach about implications,
|
||||
do not praise excessively.
|
||||
Keep a critical mindset,
|
||||
do not assume blindly.
|
||||
Answer in valid markdown.`;
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
];
|
||||
|
||||
const $messages = document.querySelector("#messages");
|
||||
const $prompt = document.querySelector("#prompt");
|
||||
const $submit = document.querySelector("#submit");
|
||||
const $modelSelector = document.querySelector("#model-selector");
|
||||
|
||||
$modelSelector.append(
|
||||
...models.map((model) => {
|
||||
return Object.assign(document.createElement("label"), {
|
||||
className: "model-opt__label",
|
||||
innerHTML: `<div><input type="radio" name="model" value="${model.name}" />${model.name}</div>`,
|
||||
});
|
||||
})
|
||||
);
|
||||
$modelSelector.addEventListener("mouseover", (e) => {
|
||||
const open = $modelSelector.hasAttribute("open");
|
||||
if (!open) {
|
||||
$modelSelector.setAttribute("open", "");
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[name=model]").forEach(($option) => {
|
||||
if ($option.value === window.env.MODEL) {
|
||||
$option.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
/** @type {AbortController} */
|
||||
let abortController = null;
|
||||
$submit.addEventListener("click", () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
@@ -268,6 +298,8 @@
|
||||
const prompt = $prompt.value.trim();
|
||||
if (!prompt) return;
|
||||
|
||||
$modelSelector.removeAttribute("open");
|
||||
|
||||
// Add user message
|
||||
const userMessage = createMessageElement(prompt, true);
|
||||
$messages.appendChild(userMessage);
|
||||
@@ -275,36 +307,31 @@
|
||||
|
||||
// Prepare for system response
|
||||
const systemMessage = createMessageElement("", false);
|
||||
const md = new StreamingMarkdown(systemMessage);
|
||||
$messages.appendChild(systemMessage);
|
||||
let fullResponse = "";
|
||||
|
||||
// Clear input and scroll
|
||||
$prompt.value = "";
|
||||
scrollToBottom($messages);
|
||||
|
||||
// Update button state
|
||||
$submit.textContent = "Cancel";
|
||||
abortController = new AbortController();
|
||||
|
||||
const pickedModel = document.querySelector("[name=model]:checked")?.value;
|
||||
|
||||
const chat = {
|
||||
"gpt-4.1": (params) => chatWithOpenAI({ ...params, apiKey: window.env.OPENAI_API_KEY, model: "gpt-4.1" }),
|
||||
"gpt-4.1-mini": (params) => chatWithOpenAI({ ...params, apiKey: window.env.OPENAI_API_KEY, model: "gpt-4.1-mini" }),
|
||||
"gemini-2.5-flash": (params) => chatWithGemini({ ...params, apiKey: window.env.GEMINI_API_KEY, model: "gemini-2.5-flash" }),
|
||||
"claude-3-7-sonnet-20250219": (params) => chatWithClaude({ ...params, apiKey: window.env.ANTHROPIC_API_KEY, model: "claude-3-7-sonnet-20250219" }),
|
||||
}[pickedModel];
|
||||
const chat = models.find((m) => m.name === pickedModel)?.chat;
|
||||
|
||||
if (!chat) {
|
||||
console.error(`Unknown model: ${window.env.MODEL}`);
|
||||
return;
|
||||
}
|
||||
|
||||
$submit.textContent = "Cancel";
|
||||
try {
|
||||
await chat({
|
||||
model: pickedModel,
|
||||
onContent: (content) => {
|
||||
fullResponse += content;
|
||||
updateMessageContent(systemMessage, fullResponse);
|
||||
smd.parser_write(parser, content);
|
||||
scrollToBottom($messages);
|
||||
},
|
||||
messages,
|
||||
@@ -315,11 +342,12 @@
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
updateMessageContent(systemMessage, fullResponse + "\n[Cancelled]");
|
||||
md.append("\n\n[Cancelled]");
|
||||
} else {
|
||||
updateMessageContent(systemMessage, fullResponse + "\n[Error: " + err.message + "]");
|
||||
md.append("\n\n[Error: " + err.message + "]");
|
||||
}
|
||||
} finally {
|
||||
md.finish();
|
||||
$submit.textContent = "Send";
|
||||
abortController = null;
|
||||
}
|
||||
@@ -330,65 +358,56 @@
|
||||
$el.className = `message ${isUser ? "user-message" : "system-message"}`;
|
||||
if (isUser) {
|
||||
$el.innerText = content;
|
||||
} else {
|
||||
$el.innerHTML = marked.parse(content, { sanitize: true });
|
||||
}
|
||||
return $el;
|
||||
}
|
||||
|
||||
function updateMessageContent($el, markdown) {
|
||||
$el.innerHTML = marked.parse(markdown, { sanitize: true });
|
||||
// Auto scroll functionality
|
||||
let userHasScrolledUp = false;
|
||||
|
||||
// Function to check if user is at the bottom
|
||||
function isAtBottom($el) {
|
||||
const threshold = 50; // Allow some tolerance (50px from bottom)
|
||||
return $el.scrollHeight - $el.clientHeight - $el.scrollTop <= threshold;
|
||||
}
|
||||
|
||||
function throttle(func, ms) {
|
||||
let lastCall = 0;
|
||||
let timeoutId = null;
|
||||
let lastArgs = null;
|
||||
return function throttled(...args) {
|
||||
const now = Date.now();
|
||||
const remaining = ms - (now - lastCall);
|
||||
if (remaining <= 0) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
lastCall = now;
|
||||
func.apply(this, args);
|
||||
} else {
|
||||
lastArgs = args;
|
||||
if (!timeoutId) {
|
||||
timeoutId = setTimeout(() => {
|
||||
lastCall = Date.now();
|
||||
timeoutId = null;
|
||||
func.apply(this, lastArgs);
|
||||
}, remaining);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Throttled version to use in your code
|
||||
const scrollToBottom = throttle(($el) => {
|
||||
const isScrolledUp = $el.scrollHeight - $el.clientHeight - $el.scrollTop > 100;
|
||||
if (!isScrolledUp) {
|
||||
// Function to scroll to bottom (only called when new content arrives)
|
||||
function scrollToBottom($el) {
|
||||
if (!userHasScrolledUp) {
|
||||
$el.scrollTop = $el.scrollHeight;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, messages, signal, apiKey, model }) {
|
||||
const payload = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
};
|
||||
// Listen for user scroll events to detect if they scrolled up
|
||||
$messages.addEventListener("scroll", () => {
|
||||
userHasScrolledUp = !isAtBottom($messages);
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* onContent: (content: string) => Promise<void>,
|
||||
* messages: Array<{ role: string, content: string }>,
|
||||
* extraHeaders: Record<string, string>,
|
||||
* signal: AbortSignal,
|
||||
* apiKey: string,
|
||||
* model: string
|
||||
* }} ChatArgs
|
||||
*/
|
||||
|
||||
/** @param {ChatArgs} args - The arguments for the chatWithClaude function. */
|
||||
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, messages, signal, apiKey = window.env.OPENAI_API_KEY, model, extraHeaders = {} }) {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -418,71 +437,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function chatWithClaude({ onContent, messages, signal, apiKey, model }) {
|
||||
const payload = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
max_tokens: 2048,
|
||||
};
|
||||
const systemPrompt = messages.find((message) => message.role === "system")?.content;
|
||||
if (systemPrompt) {
|
||||
payload.messages = messages.filter((message) => message.role !== "system");
|
||||
payload.system = systemPrompt;
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
/*** @param {ChatArgs} args - The arguments for the chatWithClaude function. */
|
||||
async function chatWithClaude(args) {
|
||||
return chatWithOpenAI({
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
extraHeaders: {
|
||||
"x-api-key": args.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
apiKey: window.env.ANTHROPIC_API_KEY,
|
||||
...args,
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace("data: ", ""));
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === "" || line === "[DONE]") continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.type === "content_block_delta") {
|
||||
const content = parsed.delta?.text;
|
||||
if (content) {
|
||||
await onContent(content);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing line:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function chatWithGemini({ onContent, messages, signal, apiKey, model = "gemini-2.5-flash" }) {
|
||||
/*** @param {ChatArgs} args - The arguments for the chatWithGemini function. */
|
||||
async function chatWithGemini(args) {
|
||||
return chatWithOpenAI({
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
onContent,
|
||||
messages,
|
||||
signal,
|
||||
apiKey,
|
||||
model,
|
||||
apiKey: window.env.GEMINI_API_KEY,
|
||||
...args,
|
||||
});
|
||||
}
|
||||
|
||||
class StreamingMarkdown {
|
||||
constructor($el) {
|
||||
this.$el = $el;
|
||||
this.renderer = smd.default_renderer(systemMessage);
|
||||
this.parser = smd.parser(renderer);
|
||||
}
|
||||
|
||||
append(markdown) {
|
||||
smd.parser_write(this.parser, markdown);
|
||||
}
|
||||
|
||||
finish() {
|
||||
smd.parser_end(this.parser);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user