Files
playground/chat.html
T
2025-12-23 10:52:43 +01:00

506 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cet ci pi ti</title>
<style>
:root {
color-scheme: light dark;
--bg-primary: Canvas;
--bg-secondary: #ccc;
--bg-input: #f0f0f0;
--bg-user-message: Highlight;
--bg-code: Field;
--text-primary: CanvasText;
--text-user: HighlightText;
--border-color: #aaa;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-secondary: #555;
--bg-input: #444;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
font-family: inherit;
}
body {
margin: 0;
font-family: "JetBrains Mono", Consolas, Menlo, monospace;
line-height: 1.6;
font-size: 16px;
}
.chat-container {
height: 100vh;
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: 1fr min-content;
gap: 1rem;
padding: 1rem;
grid-template-areas: "messages messages" "prompt submit";
}
.messages {
grid-area: messages;
overflow-y: auto;
padding-right: 0.5rem;
}
.message {
padding: 0.8rem 1.2rem;
border-radius: 0.8rem;
max-width: 85%;
margin-top: 1rem;
}
.user-message > div {
color: var(--text-user);
background: var(--bg-user-message);
margin-left: auto;
white-space: pre-wrap;
}
.system-message {
background: var(--bg-secondary);
margin-right: auto;
}
.system-message pre {
position: relative;
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 {
position: absolute;
top: 0.4rem;
right: 0.6rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: none;
border-radius: 0.3rem;
padding: 0.2rem 0.6rem;
font-size: 0.9em;
cursor: pointer;
opacity: 0.6;
}
.copy-btn:hover {
opacity: 1;
}
.prompt {
grid-area: prompt;
min-height: 40px;
padding: 0.8rem;
border: 1px solid var(--border-color);
border-radius: 0.4rem;
resize: vertical;
font-size: 1rem;
}
.submit {
font-size: 1rem;
grid-area: submit;
cursor: pointer;
padding: 0.8rem 2rem;
background: var(--bg-secondary);
border: none;
border-radius: 0.4rem;
transition: background-color 0.2s;
}
.submit:hover {
background: var(--bg-user-message);
}
@keyframes pulse {
20% {
opacity: 0.8;
}
}
.submit.busy {
animation: both pulse 1s infinite;
}
#model-selector,
#history-panel {
position: absolute;
top: 1rem;
opacity: 0.3;
padding: 0.5rem;
cursor: pointer;
}
#model-selector {
left: 1rem;
}
#history-panel {
right: 1rem;
}
#model-selector:is(:hover, [open]),
#history-panel:is(:hover, [open]) {
opacity: 1;
}
.history-list {
min-width: 220px;
max-width: 320px;
padding-top: 0.5rem;
}
.history-item {
cursor: pointer;
padding: 0.25rem 0.2rem;
border-radius: 0.2rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.history-item:hover {
background: rgba(0, 0, 0, 0.05);
}
.history-time {
font-weight: 700;
}
.history-time::after {
content: " - ";
}
.history-excerpt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
[x-cloak] {
display: none !important;
}
</style>
</head>
<body>
<div id="app" x-data="chatApp()" x-cloak>
<div class="chat-container">
<!-- Messages -->
<div class="messages" x-ref="msgBox" @scroll="handleScroll">
<template x-for="(msg, idx) in visibleMessages" :key="idx">
<div :class="msg.role === 'user' ? 'message user-message' : 'message system-message'">
<template x-if="msg.role === 'user'">
<div x-text="msg.content"></div>
</template>
<template x-if="msg.role === 'assistant'">
<div x-html="renderMd(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>
<!-- Textarea -->
<textarea
class="prompt"
placeholder="Type your message..."
x-model="promptText"
x-ref="promptInput"
:rows="inputRows"
@keydown.ctrl.enter="handleSubmit"
@keydown.meta.enter="handleSubmit"
></textarea>
<!-- Action Button -->
<button class="submit" :class="isBusy && 'busy'" @click="isBusy ? abort() : handleSubmit()" x-text="isBusy ? 'Cancel' : 'Send'"></button>
<!-- Model Selector -->
<details id="model-selector">
<summary x-text="'Model: ' + activeModelName"></summary>
<template x-for="m in models" :key="m.name">
<label class="model-opt__label" style="display: block">
<input type="radio" :value="m.name" x-model="activeModelName" />
<span x-text="m.name"></span>
</label>
</template>
</details>
<!-- History -->
<details id="history-panel">
<summary>History</summary>
<div class="history-list">
<template x-if="historyItems.length === 0">
<div style="opacity: 0.7">No conversations today</div>
</template>
<template x-for="(item, idx) in historyItems" :key="idx">
<div class="history-item" @click="restoreConversation(item.messages)">
<span class="history-time" x-text="formatTime(item.time)"></span>
<span class="history-excerpt" x-text="getExcerpt(item.messages)"></span>
</div>
</template>
</div>
</details>
</div>
</div>
<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";
window.env ??= {};
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.
Challenge my views when appropriate.
Answer in valid markdown!.`;
const chatApp = () => ({
messages: [{ role: "system", content: systemPrompt }],
promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "",
activeModelName: "gemini-3-flash-preview",
historyItems: [],
isBusy: false,
abortController: null,
userHasScrolledUp: false,
models: [
{ name: "gpt-4.1", chat: chatWithOpenAI, key: "OPENAI_API_KEY" },
{ name: "gpt-4.1-mini", chat: chatWithOpenAI, key: "OPENAI_API_KEY" },
{ name: "gemini-3-flash-preview", chat: chatWithGemini, key: "GEMINI_API_KEY" },
{ name: "claude-haiku-4-5-20251001", chat: chatWithClaude, key: "ANTHROPIC_API_KEY" },
],
async init() {
this.historyItems = loadHistory();
// Delegate copy button clicks
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);
const original = e.target.textContent;
e.target.textContent = "Copied!";
setTimeout(() => (e.target.textContent = original), 1200);
}
});
setTimeout(() => this.$refs.promptInput.focus(), 0);
},
get visibleMessages() {
return this.messages.filter((m) => m.role !== "system");
},
get inputRows() {
return Math.min(10, Math.max(2, this.promptText.split("\n").length));
},
handleScroll() {
const el = this.$refs.msgBox;
this.userHasScrolledUp = el.scrollHeight - el.clientHeight - el.scrollTop > 50;
},
scrollToBottom() {
if (!this.userHasScrolledUp) {
this.$nextTick(() => {
this.$refs.msgBox.scrollTop = this.$refs.msgBox.scrollHeight;
});
}
},
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);
}
});
},
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",
async handleSubmit() {
const text = this.promptText.trim();
if (!text || this.isBusy) return;
this.messages.push({ role: "user", content: text });
this.promptText = "";
this.isBusy = true;
this.abortController = new AbortController();
// Streaming setup
const target = this.$refs.streamBox;
target.innerHTML = "";
const renderer = smd.default_renderer(target);
const parser = smd.parser(renderer);
let fullResponse = "";
this.scrollToBottom();
try {
const modelObj = this.models.find((m) => m.name === this.activeModelName);
await modelObj.chat({
model: this.activeModelName,
apiKey: window.env[modelObj.key],
messages: this.messages,
signal: this.abortController.signal,
onContent: (chunk) => {
fullResponse += chunk;
smd.parser_write(parser, chunk);
this.attachCopyButtons(target);
this.scrollToBottom();
},
onError: (e) => {
smd.parser_write(parser, `\n\n**Error:** ${e.message}`);
},
});
if (fullResponse) {
this.messages.push({ role: "assistant", content: fullResponse });
}
} catch (err) {
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]);
}
},
abort() {
this.abortController?.abort();
},
restoreConversation(msgs) {
this.messages = JSON.parse(JSON.stringify(msgs));
this.userHasScrolledUp = false;
this.scrollToBottom();
},
});
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",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, ...extraHeaders },
body: JSON.stringify({ model, messages, stream: true, ...rest }),
signal,
});
if (!response.ok) {
const text = await response.text();
await onError?.(new Error(`HTTP ${response.status}:\n${text.trim()}`));
return;
}
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\n")
.filter((l) => !!l.trim())
.map((l) => l.trim().replace("data: ", ""));
for (const line of lines) {
if (line === "[DONE]") return;
try {
const content = JSON.parse(line).choices[0].delta.content;
if (content) await onContent(content);
} catch (e) {}
}
}
}
async function chatWithClaude(args) {
return chatWithOpenAI({
...args,
baseUrl: "https://api.anthropic.com/v1",
extraHeaders: {
"x-api-key": args.apiKey,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
apiKey: window.env.ANTHROPIC_API_KEY,
});
}
async function chatWithGemini({ onContent, onError, messages, signal, apiKey = window.env.GEMINI_API_KEY, model, ...rest }) {
const contents = messages.map((msg) => ({
role: msg.role === "assistant" ? "model" : "user",
parts: [{ text: msg.content }],
}));
try {
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents, generationConfig: { thinkingConfig: { thinkingLevel: "low" }, ...rest } }),
signal,
});
if (!response.ok) {
const text = await response.text();
await onError?.(new Error(`Gemini API Error ${response.status}: ${text}`));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let startIdx;
while ((startIdx = buffer.indexOf("{")) !== -1) {
let endIdx = -1,
depth = 0;
for (let i = startIdx; i < buffer.length; i++) {
if (buffer[i] === "{") depth++;
else if (buffer[i] === "}") depth--;
if (depth === 0) {
endIdx = i;
break;
}
}
if (endIdx === -1) break;
const jsonStr = buffer.substring(startIdx, endIdx + 1);
buffer = buffer.substring(endIdx + 1);
try {
const parsed = JSON.parse(jsonStr);
const content = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
if (content) await onContent(content);
} catch (e) {}
}
}
} catch (err) {
if (err.name !== "AbortError") await onError?.(err);
}
}
const STORAGE_KEY = "chat_conversations";
const todayKey = () => new Date().toISOString().slice(0, 10);
const loadHistory = () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}")[todayKey()] || [];
} catch {
return [];
}
};
const saveHistory = (messages) => {
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
const today = todayKey();
all[today] ||= [];
all[today].unshift({ time: new Date().toISOString(), messages });
all[today] = all[today].slice(0, 5);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
return all[today];
};
document.addEventListener("alpine:init", () => {
Alpine.data("chatApp", chatApp);
});
Alpine.start();
</script>
</body>
</html>