fix(chat): Streaming markdown support
- Better auto scroll support
This commit is contained in:
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Cet ci pi ti</title>
|
<title>Cet ci pi ti</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Light theme variables */
|
/* Light theme variables */
|
||||||
@@ -40,6 +39,7 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-family: "JetBrains Mono", Consolas, Menlo, monospace;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
anchor-name: --submit;
|
anchor-name: --submit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-selector {
|
#model-selector {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
position-anchor: --submit;
|
position-anchor: --submit;
|
||||||
bottom: anchor(top);
|
bottom: anchor(top);
|
||||||
@@ -193,6 +193,17 @@
|
|||||||
ul {
|
ul {
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(odd) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -201,46 +212,65 @@
|
|||||||
<div id="messages" class="messages"></div>
|
<div id="messages" class="messages"></div>
|
||||||
<textarea rows="5" id="prompt" class="prompt" placeholder="Type your message..."></textarea>
|
<textarea rows="5" id="prompt" class="prompt" placeholder="Type your message..."></textarea>
|
||||||
<button id="submit" class="submit">Send</button>
|
<button id="submit" class="submit">Send</button>
|
||||||
<details class="model-selector">
|
<details id="model-selector" popover>
|
||||||
<summary>Model</summary>
|
<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>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script type="module">
|
||||||
|
import * as smd from "https://cdn.jsdelivr.net/npm/streaming-markdown@latest/smd.min.js";
|
||||||
window.env ??= {};
|
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 models = [
|
||||||
const $prompt = document.getElementById("prompt");
|
{ name: "gpt-4.1", chat: chatWithOpenAI },
|
||||||
const $submit = document.getElementById("submit");
|
{ 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";
|
const systemPrompt = `You are a helpful AI assistant.
|
||||||
document.querySelectorAll("[name=model]").forEach((modelOpt) => {
|
Do not preach about implications,
|
||||||
if (modelOpt.value === window.env.MODEL) {
|
do not praise excessively.
|
||||||
modelOpt.checked = true;
|
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", () => {
|
$submit.addEventListener("click", () => {
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
@@ -268,6 +298,8 @@
|
|||||||
const prompt = $prompt.value.trim();
|
const prompt = $prompt.value.trim();
|
||||||
if (!prompt) return;
|
if (!prompt) return;
|
||||||
|
|
||||||
|
$modelSelector.removeAttribute("open");
|
||||||
|
|
||||||
// Add user message
|
// Add user message
|
||||||
const userMessage = createMessageElement(prompt, true);
|
const userMessage = createMessageElement(prompt, true);
|
||||||
$messages.appendChild(userMessage);
|
$messages.appendChild(userMessage);
|
||||||
@@ -275,36 +307,31 @@
|
|||||||
|
|
||||||
// Prepare for system response
|
// Prepare for system response
|
||||||
const systemMessage = createMessageElement("", false);
|
const systemMessage = createMessageElement("", false);
|
||||||
|
const md = new StreamingMarkdown(systemMessage);
|
||||||
$messages.appendChild(systemMessage);
|
$messages.appendChild(systemMessage);
|
||||||
let fullResponse = "";
|
let fullResponse = "";
|
||||||
|
|
||||||
// Clear input and scroll
|
|
||||||
$prompt.value = "";
|
$prompt.value = "";
|
||||||
scrollToBottom($messages);
|
scrollToBottom($messages);
|
||||||
|
|
||||||
// Update button state
|
|
||||||
$submit.textContent = "Cancel";
|
|
||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
|
|
||||||
const pickedModel = document.querySelector("[name=model]:checked")?.value;
|
const pickedModel = document.querySelector("[name=model]:checked")?.value;
|
||||||
|
|
||||||
const chat = {
|
const chat = models.find((m) => m.name === pickedModel)?.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];
|
|
||||||
|
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
console.error(`Unknown model: ${window.env.MODEL}`);
|
console.error(`Unknown model: ${window.env.MODEL}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$submit.textContent = "Cancel";
|
||||||
try {
|
try {
|
||||||
await chat({
|
await chat({
|
||||||
|
model: pickedModel,
|
||||||
onContent: (content) => {
|
onContent: (content) => {
|
||||||
fullResponse += content;
|
fullResponse += content;
|
||||||
updateMessageContent(systemMessage, fullResponse);
|
smd.parser_write(parser, content);
|
||||||
scrollToBottom($messages);
|
scrollToBottom($messages);
|
||||||
},
|
},
|
||||||
messages,
|
messages,
|
||||||
@@ -315,11 +342,12 @@
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === "AbortError") {
|
if (err.name === "AbortError") {
|
||||||
updateMessageContent(systemMessage, fullResponse + "\n[Cancelled]");
|
md.append("\n\n[Cancelled]");
|
||||||
} else {
|
} else {
|
||||||
updateMessageContent(systemMessage, fullResponse + "\n[Error: " + err.message + "]");
|
md.append("\n\n[Error: " + err.message + "]");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
md.finish();
|
||||||
$submit.textContent = "Send";
|
$submit.textContent = "Send";
|
||||||
abortController = null;
|
abortController = null;
|
||||||
}
|
}
|
||||||
@@ -330,65 +358,56 @@
|
|||||||
$el.className = `message ${isUser ? "user-message" : "system-message"}`;
|
$el.className = `message ${isUser ? "user-message" : "system-message"}`;
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
$el.innerText = content;
|
$el.innerText = content;
|
||||||
} else {
|
|
||||||
$el.innerHTML = marked.parse(content, { sanitize: true });
|
|
||||||
}
|
}
|
||||||
return $el;
|
return $el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMessageContent($el, markdown) {
|
// Auto scroll functionality
|
||||||
$el.innerHTML = marked.parse(markdown, { sanitize: true });
|
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) {
|
// Function to scroll to bottom (only called when new content arrives)
|
||||||
let lastCall = 0;
|
function scrollToBottom($el) {
|
||||||
let timeoutId = null;
|
if (!userHasScrolledUp) {
|
||||||
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) {
|
|
||||||
$el.scrollTop = $el.scrollHeight;
|
$el.scrollTop = $el.scrollHeight;
|
||||||
}
|
}
|
||||||
}, 200);
|
}
|
||||||
|
|
||||||
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, messages, signal, apiKey, model }) {
|
// Listen for user scroll events to detect if they scrolled up
|
||||||
const payload = {
|
$messages.addEventListener("scroll", () => {
|
||||||
model,
|
userHasScrolledUp = !isAtBottom($messages);
|
||||||
messages,
|
});
|
||||||
stream: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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`, {
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -418,71 +437,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chatWithClaude({ onContent, messages, signal, apiKey, model }) {
|
/*** @param {ChatArgs} args - The arguments for the chatWithClaude function. */
|
||||||
const payload = {
|
async function chatWithClaude(args) {
|
||||||
model,
|
return chatWithOpenAI({
|
||||||
messages,
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
stream: true,
|
extraHeaders: {
|
||||||
max_tokens: 2048,
|
"x-api-key": args.apiKey,
|
||||||
};
|
|
||||||
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,
|
|
||||||
"anthropic-version": "2023-06-01",
|
"anthropic-version": "2023-06-01",
|
||||||
"anthropic-dangerous-direct-browser-access": "true",
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
apiKey: window.env.ANTHROPIC_API_KEY,
|
||||||
signal,
|
...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({
|
return chatWithOpenAI({
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
onContent,
|
apiKey: window.env.GEMINI_API_KEY,
|
||||||
messages,
|
...args,
|
||||||
signal,
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user