1000 lines
43 KiB
HTML
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;">×</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>
|