Files
playground/chat/index.html
T

1000 lines
43 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 {
/* Light theme */
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--bg-input: #ffffff;
--bg-user-message: #3b82f6;
--bg-code: #f0f0f0;
--text-primary: #1f2937;
--text-user: #ffffff;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--hover-bg: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-input: #374151;
--bg-user-message: #2563eb;
--bg-code: #242f48;
--text-primary: #f3f4f6;
--text-user: #ffffff;
--text-secondary: #d1d5db;
--border-color: #4b5563;
--hover-bg: #374151;
}
}
*,
*::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: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition:
background-color 0.2s,
color 0.2s;
}
input {
line-height: inherit;
font-family: inherit;
}
td + td,
th + td {
padding-left: 0.5rem;
}
.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";
background: var(--bg-primary);
}
.messages {
grid-area: messages;
overflow-y: auto;
padding-right: 0.5rem;
}
.attachment-card {
max-width: 85%;
margin: 1rem 0;
padding: 0.8rem 1.2rem;
border: 1px solid var(--border-color);
border-radius: 0.8rem;
background: var(--bg-secondary);
}
.attachment-card__title {
font-weight: 700;
}
.attachment-card__meta {
color: var(--text-secondary);
font-size: 0.9em;
}
.attachment-card summary {
cursor: pointer;
margin-top: 0.5rem;
}
.attachment-card pre {
max-height: 14rem;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 0.6rem;
background: var(--bg-code);
border: 1px solid var(--border-color);
border-radius: 0.3rem;
}
.suggested-questions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.75rem 0;
}
.suggested-questions button {
cursor: pointer;
padding: 0.35rem 0.6rem;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.3rem;
}
.message {
padding: 0.8rem 1.2rem;
border-radius: 0.8rem;
max-width: 85%;
margin-top: 1rem;
overflow-x: auto;
}
.message :is(textarea) {
display: block;
}
.user-message {
margin-left: auto;
background: var(--bg-user-message);
color: var(--text-user);
}
.user-message .text {
color: var(--text-user);
margin-left: auto;
white-space: pre-wrap;
}
.undo-btn {
display: block;
margin-left: auto;
margin-top: 0.4rem;
background: rgba(255, 255, 255, 0.15);
color: var(--text-user);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 0.3rem;
padding: 0.15rem 0.5rem;
font-size: 0.8em;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.undo-btn:hover {
opacity: 1;
}
.system-message {
background: var(--bg-secondary);
margin-right: auto;
color: var(--text-primary);
}
.system-message pre {
position: relative;
padding: 0.5rem 2.5rem 0.5rem 0.5rem;
background: var(--bg-code);
border-radius: 0.4rem;
white-space: pre;
color: var(--text-primary);
border: 1px solid var(--border-color);
overflow-x: auto;
}
.copy-btn {
position: absolute;
top: 0.4rem;
right: 0.6rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.3rem;
padding: 0.2rem 0.6rem;
font-size: 0.9em;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.copy-btn:hover {
opacity: 1;
}
.prompt-wrapper {
grid-area: prompt;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.prompt {
min-height: 40px;
padding: 0.8rem;
border: 1px solid var(--border-color);
border-radius: 0.4rem;
resize: vertical;
font-size: inherit;
background: var(--bg-input);
color: var(--text-primary);
transition:
background-color 0.2s,
color 0.2s;
}
.prompt::placeholder {
color: var(--text-secondary);
}
.submit {
font-size: inherit;
grid-area: submit;
cursor: pointer;
padding: 0.8rem 2rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.4rem;
transition:
background-color 0.2s,
color 0.2s;
}
.submit:hover {
background: var(--hover-bg);
}
@keyframes pulse {
20% {
opacity: 0.8;
}
}
.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: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
transition:
opacity 0.2s,
background-color 0.2s;
}
:is(#model-selector, #history-panel):hover {
opacity: 0.7;
background-color: var(--hover-bg);
}
#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;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.4rem;
padding: 0.5rem;
}
.history-item {
cursor: pointer;
padding: 0.25rem 0.2rem;
border-radius: 0.2rem;
display: flex;
gap: 0.5rem;
align-items: center;
transition: background-color 0.2s;
}
.history-item:hover {
background: var(--hover-bg);
}
.clear-history-btn {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.3rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.3rem;
font-size: 0.9em;
cursor: pointer;
transition: background-color 0.2s;
}
.clear-history-btn:hover {
background: var(--hover-bg);
}
.history-time {
font-weight: 700;
color: var(--text-secondary);
}
.history-time::after {
content: " - ";
}
.history-excerpt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
color: var(--text-secondary);
}
[x-cloak] {
display: none !important;
}
.spinner {
width: 1.5rem;
height: 1.5rem;
background: #888;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: grow 1s ease-in infinite;
}
@keyframes grow {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
</style>
</head>
<body>
<div id="app" x-data="chatApp()" x-cloak>
<div class="chat-container">
<!-- Messages -->
<div class="messages" x-ref="msgBox" @click="onClickMessage" @scroll="handleScroll">
<section class="attachment-card" x-show="attachment">
<div class="attachment-card__title">Transcript attached</div>
<div class="attachment-card__meta" x-text="`${attachment?.length.toLocaleString()} characters`"></div>
<details>
<summary>Show transcript</summary>
<pre x-text="attachment?.value"></pre>
</details>
<template x-if="visibleMessages.length === 0 && !isBusy">
<div class="suggested-questions">
<button @click="useSuggestedQuestion('What are the key points?')">Key points</button>
<button @click="useSuggestedQuestion('What are the most important quotes?')">Notable quotes</button>
<button @click="useSuggestedQuestion('What should I challenge or verify?')">What to verify</button>
</div>
</template>
</section>
<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>
<template x-for="img in (msg.images || [])" :key="img.dataUrl">
<img :src="img.dataUrl" style="max-width: 100%; border-radius: 0.3rem; display: block; margin-bottom: 0.3rem;" />
</template>
<div class="text" x-text="msg.content"></div>
<button class="undo-btn" title="Trim conversation back to before this message" @click="trimTo(msg)">Undo</button>
</div>
</template>
<template x-if="msg.role === 'assistant'">
<div x-html="renderMarkdown(msg.content)"></div>
</template>
</div>
</template>
<div class="message system-message" x-show="isBusy" x-ref="incomingMessage"><span class="spinner"></span></div>
</div>
<!-- Textarea -->
<div class="prompt-wrapper">
<div x-show="!attachment && pendingImages.length > 0" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start;">
<template x-for="(img, i) in pendingImages" :key="img.dataUrl">
<div style="position: relative; display: inline-block;">
<img :src="img.dataUrl" style="max-height: 72px; max-width: 120px; object-fit: cover; border-radius: 0.3rem; display: block;" />
<button @click.prevent="pendingImages.splice(i, 1)" title="Remove" style="position: absolute; top: -0.35rem; right: -0.35rem; width: 1.1rem; height: 1.1rem; border-radius: 50%; background: #444; color: #fff; border: none; cursor: pointer; font-size: 0.65rem; line-height: 1; padding: 0; display: flex; align-items: center; justify-content: center;">&times;</button>
</div>
</template>
</div>
<textarea
class="prompt"
placeholder="Type your message..."
x-model="promptText"
x-ref="promptInput"
:rows="inputRows"
@keydown.ctrl.enter="handleSubmit"
@keydown.meta.enter="handleSubmit"
@paste="handlePaste"
></textarea>
</div>
<!-- Action Button -->
<button class="submit" :class="isBusy && 'busy'" @click="isBusy ? abort() : handleSubmit()" x-text="isBusy ? 'Cancel' : 'Send'"></button>
<!-- Model Selector -->
<details id="model-selector" x-show="!attachment">
<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" x-ref="historyPanel" x-show="!attachment">
<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>
<template x-if="historyItems.length > 0">
<button class="clear-history-btn" @click="clearHistory">Clear</button>
</template>
</div>
</details>
</div>
</div>
<script defer src="./alpine@3.x.x.js"></script>
<script src="./markdown-it@14.1.0.min.js"></script>
<script>
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.
- When I ask something about code, or software development, keep in mind that I am a senior backend developer (but do not mention that explicitly), and do not explain excessively, but and challenge my statements where needed with better alternatives if I don't sound right, especially in coding. Do not bring it up otherwise.
- Do not give me code if the topic is not about programming or software development.
`
.replace(/^\s+/gm, "")
.trim();
const defaultVideoSummaryPrompt = "Summarize this video or transcript. Cover the key points, necessary context, and notable quotes. Skip promotions.";
function getTranscriptAttachment(transcript) {
const value = typeof transcript === "string" ? transcript.trim() : "";
if (value) return { kind: "text", value, length: value.length };
return null;
}
function getSystemInstruction(attachment) {
if (!attachment) return systemPrompt;
const attachmentRules = `
You are answering questions about the attached transcript.
Base your answers on that source. State when the source does not establish an answer, do not invent details, and preserve exact wording when the user asks for quotes.
`
.replace(/^\s+/gm, "")
.trim();
return `${attachmentRules}\n\nTranscript:\n${attachment.value}`;
}
const initialAttachment = getTranscriptAttachment(window.env.YOUTUBE_TRANSCRIPT);
const initialPromptText = window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "";
const geminiCachePromises = new Map();
async function getGeminiAttachmentCache({ model, apiKey, systemInstruction, attachment }) {
if (!attachment) return null;
const key = `${model}:${attachment.kind}:${attachment.value}`;
if (!geminiCachePromises.has(key)) {
geminiCachePromises.set(
key,
fetch(`https://generativelanguage.googleapis.com/v1beta/cachedContents?key=${apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: `models/${model}`,
systemInstruction: { parts: [{ text: systemInstruction }] },
contents: [],
ttl: "3600s",
}),
})
.then(async (response) => (response.ok ? response.json() : null))
.then((cache) => cache?.name || null)
.catch(() => null)
);
}
return geminiCachePromises.get(key);
}
const chatApp = () => ({
messages: [{ role: "system", content: systemPrompt }],
pendingImages: [],
attachment: initialAttachment,
promptText: initialPromptText,
activeModelName: "gemini-3.5-flash-lite",
historyItems: [],
isBusy: false,
abortController: null,
userHasScrolledUp: false,
sessionId: null,
models: [
{ name: "gpt-5.6-luna", chat: chatWithOpenAI, key: window.env.OPENAI_API_KEY },
{ name: "gemini-3.5-flash-lite", chat: chatWithGemini, key: window.env.GEMINI_API_KEY },
{ name: "claude-haiku-4-5-20251001", chat: chatWithClaude, key: window.env.ANTHROPIC_API_KEY },
],
async init() {
this.sessionId = +new Date();
this.historyItems = this.attachment ? [] : loadHistory();
setTimeout(() => this.$refs.promptInput.focus(), 0);
if (this.attachment && window.env.AUTO_SUBMIT === true) {
setTimeout(() => this.useSuggestedQuestion(this.promptText.trim() || defaultVideoSummaryPrompt, true), 0);
}
},
async onClickMessage(e) {
if (e.target.classList.contains("copy-btn")) {
const $btn = e.target;
const code = $btn.closest("pre").querySelector("code").innerText;
copyToClipboard(code);
const buttonText = $btn.textContent;
$btn.textContent = "Copied!";
setTimeout(() => ($btn.textContent = buttonText), 1200);
}
},
get visibleMessages() {
return this.messages.filter((m) => m.role !== "system");
},
get inputRows() {
return Math.min(10, Math.max(2, this.promptText.split("\n").length)) + 1;
},
handleScroll() {
const el = this.$refs.msgBox;
this.userHasScrolledUp = el.scrollHeight - el.clientHeight - el.scrollTop > 100;
},
scrollToBottom() {
if (!this.userHasScrolledUp) {
this.$nextTick(() => {
this.$refs.msgBox.scrollTop = this.$refs.msgBox.scrollHeight;
});
}
},
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,
useSuggestedQuestion(question, submit = false) {
if (this.isBusy) return;
this.promptText = question;
this.$nextTick(() => this.$refs.promptInput.focus());
if (submit) this.handleSubmit();
},
async handleSubmit() {
const text = this.promptText.trim();
if ((!text && !this.pendingImages.length) || this.isBusy) return;
const images = this.pendingImages.splice(0);
this.messages.push({ role: "user", content: text, images });
this.promptText = "";
this.isBusy = true;
this.abortController = new AbortController();
// Streaming setup
const target = this.$refs.incomingMessage;
this.$refs.incomingMessage.innerHTML = "";
let fullResponse = "";
this.scrollToBottom();
try {
const modelObj = this.models.find((m) => m.name === (this.attachment ? "gemini-3.5-flash-lite" : this.activeModelName));
await modelObj.chat({
model: modelObj.name,
apiKey: modelObj.key,
messages: this.messages,
attachment: this.attachment,
systemInstruction: getSystemInstruction(this.attachment),
signal: this.abortController.signal,
onContent: (chunk) => {
fullResponse += chunk;
target.innerHTML = renderMarkdown(fullResponse);
this.scrollToBottom();
},
onError: (e) => {
fullResponse += `\n\n[Error: ${e.message}]`;
target.innerHTML = renderMarkdown(fullResponse);
this.scrollToBottom();
},
});
if (fullResponse) {
this.messages.push({ role: "assistant", content: fullResponse });
window.lastResponse = 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 {
this.isBusy = false;
this.abortController = null;
if (!this.attachment) updateSessionHistory(this.sessionId, this.messages);
}
},
abort() {
this.abortController?.abort();
},
handlePaste(e) {
if (this.attachment) return;
const items = [...(e.clipboardData?.items || [])];
const imageItems = items.filter((item) => item.type.startsWith("image/"));
if (!imageItems.length) return;
e.preventDefault();
imageItems.forEach((item) => {
const reader = new FileReader();
reader.onload = (ev) => {
const dataUrl = ev.target.result;
this.pendingImages.push({ dataUrl, base64: dataUrl.split(",")[1], mimeType: item.type });
};
reader.readAsDataURL(item.getAsFile());
});
},
trimTo(msg) {
if (this.isBusy) return;
const idx = this.messages.indexOf(msg);
if (idx === -1) return;
this.messages = this.messages.slice(0, idx);
if (!this.attachment) updateSessionHistory(this.sessionId, this.messages);
this.promptText = msg.content;
this.pendingImages = msg.images ? JSON.parse(JSON.stringify(msg.images)) : [];
this.$nextTick(() => this.$refs.promptInput.focus());
},
restoreConversation(msgs) {
if (this.attachment) return;
this.messages = JSON.parse(JSON.stringify(msgs));
this.userHasScrolledUp = false;
this.$refs.historyPanel.removeAttribute("open");
this.scrollToBottom();
},
clearHistory() {
if (this.attachment) return;
if (!confirm("Clear all saved conversations?")) return;
this.historyItems = clearAllHistory();
this.$refs.historyPanel.removeAttribute("open");
},
});
function 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);
}
});
}
const md = markdownit({ html: false });
function renderMarkdown(markdown) {
const $placeholder = document.createElement("div");
$placeholder.innerHTML = md.render(markdown);
attachCopyButtons($placeholder);
return $placeholder.innerHTML;
}
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, onError, messages, signal, apiKey, model, extraHeaders = {}, systemInstruction, attachment, ...rest }) {
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
const apiMessages = messages.map(({ role, content, images }, index) => {
const textPart = {
type: "text",
text: content,
...(index === lastUserIndex ? { prompt_cache_breakpoint: { mode: "explicit" } } : {}),
};
const parts = [textPart, ...(images || []).map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } }))];
return role === "system" || images?.length || index === lastUserIndex ? { role, content: parts } : { role, content };
});
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, ...extraHeaders },
body: JSON.stringify({
model,
messages: apiMessages,
prompt_cache_key: "chat:index:v1",
prompt_cache_options: { mode: "explicit" },
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) {
const { onContent, onError, messages, signal, apiKey, model } = args;
const system = messages
.filter((message) => message.role === "system")
.map((message) => ({ type: "text", text: message.content }))
.filter((part) => part.text);
const conversation = messages
.filter((message) => message.role !== "system")
.map((message) => {
const images = (message.images || []).map((image) => ({
type: "image",
source: { type: "base64", media_type: image.mimeType, data: image.base64 },
}));
return {
role: message.role,
content: images.length ? [{ type: "text", text: message.content }, ...images] : message.content,
};
});
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-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model,
max_tokens: 4096,
system,
messages: conversation,
cache_control: { type: "ephemeral" },
stream: true,
}),
signal,
});
if (!response.ok) {
const text = await response.text();
await onError?.(new Error(`Anthropic API Error ${response.status}: ${text.trim()}`));
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 });
const events = buffer.split("\n\n");
buffer = events.pop();
for (const event of events) {
const data = event
.split("\n")
.find((line) => line.startsWith("data:"))
?.slice(5)
.trim();
if (!data) continue;
try {
const parsed = JSON.parse(data);
if (parsed.type === "content_block_delta" && parsed.delta?.type === "text_delta") await onContent(parsed.delta.text);
} catch (error) {}
}
}
}
async function chatWithGemini({ onContent, onError, messages, signal, apiKey, model, systemInstruction, attachment, ...rest }) {
const conversation = messages.filter((msg) => msg.role !== "system");
const cachedContent = await getGeminiAttachmentCache({ model, apiKey, systemInstruction, attachment });
const contents = conversation.map((msg, i) => {
const role = msg.role === "assistant" ? "model" : "user";
let parts = [{ text: msg.content }];
// Preserve the existing ability to send a YouTube URL in a normal chat message.
const isLastMessage = i === conversation.length - 1;
if (!attachment && isLastMessage && role === "user") {
const youtubeUrlPattern = /https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+/g;
const youtubeUrls = msg.content.match(youtubeUrlPattern) || [];
const fileParts = youtubeUrls.map((url) => ({ file_data: { file_uri: url } }));
parts = [...parts, ...fileParts];
}
// Add pasted images
if (role === "user" && msg.images?.length) {
parts = [...parts, ...msg.images.map((img) => ({ inline_data: { mime_type: img.mimeType, data: img.base64 } }))];
}
return {
role,
parts,
};
});
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({
...(cachedContent ? { cachedContent } : systemInstruction ? { system_instruction: { parts: [{ text: systemInstruction }] } } : {}),
contents,
generationConfig: 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 pos = 0;
while (pos < buffer.length) {
// Find start of JSON object
const start = buffer.indexOf("{", pos);
if (start === -1) break;
// Find matching closing brace
let depth = 0;
let inString = false;
let escaped = false;
let end = -1;
for (let i = start; i < buffer.length; i++) {
const ch = buffer[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
}
if (end === -1) {
// Incomplete JSON, keep it in buffer
buffer = buffer.slice(start);
break;
}
// Parse and process JSON
try {
const json = JSON.parse(buffer.slice(start, end + 1));
const parts = json.candidates?.[0]?.content?.parts || [];
const text = parts
.filter((p) => p.text)
.map((p) => p.text)
.join("");
if (text) await onContent(text);
} catch (e) {
// Ignore parse errors
}
pos = end + 1;
}
// Keep only unparsed remainder
buffer = buffer.slice(pos);
}
} catch (err) {
if (err.name !== "AbortError") await onError?.(err);
}
}
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 = () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}")[todayKey()] || [];
} catch {
return [];
}
};
const updateSessionHistory = (sessionId, messages) => {
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
const today = todayKey();
all[today] ??= [];
const idx = all[today].findIndex((item) => item.sessionId === sessionId);
if (idx >= 0) {
all[today][idx] = { sessionId, time: all[today][idx].time, messages };
} else {
all[today].unshift({ sessionId, time: new Date().toISOString(), messages });
}
all[today] = all[today].slice(0, 5);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
return all[today];
};
const clearAllHistory = () => {
localStorage.removeItem(STORAGE_KEY);
return [];
};
document.addEventListener("alpine:init", () => {
Alpine.data("chatApp", chatApp);
});
</script>
</body>
</html>