feat(chat): Support Youtube transcripts

This commit is contained in:
2026-08-11 09:51:30 +02:00
parent 3cba11f1df
commit d5cf81a919
+219 -25
View File
@@ -74,6 +74,49 @@
overflow-y: auto; overflow-y: auto;
padding-right: 0.5rem; 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 { .message {
padding: 0.8rem 1.2rem; padding: 0.8rem 1.2rem;
border-radius: 0.8rem; border-radius: 0.8rem;
@@ -308,6 +351,21 @@
<div class="chat-container"> <div class="chat-container">
<!-- Messages --> <!-- Messages -->
<div class="messages" x-ref="msgBox" @click="onClickMessage" @scroll="handleScroll"> <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"> <template x-for="(msg, idx) in visibleMessages" :key="idx">
<div :class="msg.role === 'user' ? 'message user-message' : 'message system-message'"> <div :class="msg.role === 'user' ? 'message user-message' : 'message system-message'">
<template x-if="msg.role === 'user'"> <template x-if="msg.role === 'user'">
@@ -329,7 +387,7 @@
<!-- Textarea --> <!-- Textarea -->
<div class="prompt-wrapper"> <div class="prompt-wrapper">
<div x-show="pendingImages.length > 0" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start;"> <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"> <template x-for="(img, i) in pendingImages" :key="img.dataUrl">
<div style="position: relative; display: inline-block;"> <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;" /> <img :src="img.dataUrl" style="max-height: 72px; max-width: 120px; object-fit: cover; border-radius: 0.3rem; display: block;" />
@@ -353,7 +411,7 @@
<button class="submit" :class="isBusy && 'busy'" @click="isBusy ? abort() : handleSubmit()" x-text="isBusy ? 'Cancel' : 'Send'"></button> <button class="submit" :class="isBusy && 'busy'" @click="isBusy ? abort() : handleSubmit()" x-text="isBusy ? 'Cancel' : 'Send'"></button>
<!-- Model Selector --> <!-- Model Selector -->
<details id="model-selector"> <details id="model-selector" x-show="!attachment">
<summary x-text="'Model: ' + activeModelName"></summary> <summary x-text="'Model: ' + activeModelName"></summary>
<template x-for="m in models" :key="m.name"> <template x-for="m in models" :key="m.name">
<label class="model-opt__label" style="display: block"> <label class="model-opt__label" style="display: block">
@@ -364,7 +422,7 @@
</details> </details>
<!-- History --> <!-- History -->
<details id="history-panel" x-ref="historyPanel"> <details id="history-panel" x-ref="historyPanel" x-show="!attachment">
<summary>History</summary> <summary>History</summary>
<div class="history-list"> <div class="history-list">
<template x-if="historyItems.length === 0"> <template x-if="historyItems.length === 0">
@@ -399,10 +457,60 @@
.replace(/^\s+/gm, "") .replace(/^\s+/gm, "")
.trim(); .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 = () => ({ const chatApp = () => ({
messages: [{ role: "system", content: systemPrompt }], messages: [{ role: "system", content: systemPrompt }],
pendingImages: [], pendingImages: [],
promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "", attachment: initialAttachment,
promptText: initialPromptText,
activeModelName: "gemini-3.5-flash-lite", activeModelName: "gemini-3.5-flash-lite",
historyItems: [], historyItems: [],
isBusy: false, isBusy: false,
@@ -418,8 +526,11 @@
async init() { async init() {
this.sessionId = +new Date(); this.sessionId = +new Date();
this.historyItems = loadHistory(); this.historyItems = this.attachment ? [] : loadHistory();
setTimeout(() => this.$refs.promptInput.focus(), 0); 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) { async onClickMessage(e) {
@@ -459,6 +570,12 @@
getExcerpt: (msgs) => msgs.find((m) => m.role === "user")?.content.slice(0, 30) || "Empty chat", getExcerpt: (msgs) => msgs.find((m) => m.role === "user")?.content.slice(0, 30) || "Empty chat",
renderMarkdown, renderMarkdown,
useSuggestedQuestion(question, submit = false) {
if (this.isBusy) return;
this.promptText = question;
this.$nextTick(() => this.$refs.promptInput.focus());
if (submit) this.handleSubmit();
},
async handleSubmit() { async handleSubmit() {
const text = this.promptText.trim(); const text = this.promptText.trim();
if ((!text && !this.pendingImages.length) || this.isBusy) return; if ((!text && !this.pendingImages.length) || this.isBusy) return;
@@ -477,11 +594,13 @@
this.scrollToBottom(); this.scrollToBottom();
try { try {
const modelObj = this.models.find((m) => m.name === this.activeModelName); const modelObj = this.models.find((m) => m.name === (this.attachment ? "gemini-3.5-flash-lite" : this.activeModelName));
await modelObj.chat({ await modelObj.chat({
model: this.activeModelName, model: modelObj.name,
apiKey: modelObj.key, apiKey: modelObj.key,
messages: this.messages, messages: this.messages,
attachment: this.attachment,
systemInstruction: getSystemInstruction(this.attachment),
signal: this.abortController.signal, signal: this.abortController.signal,
onContent: (chunk) => { onContent: (chunk) => {
fullResponse += chunk; fullResponse += chunk;
@@ -505,7 +624,7 @@
} finally { } finally {
this.isBusy = false; this.isBusy = false;
this.abortController = null; this.abortController = null;
updateSessionHistory(this.sessionId, this.messages); if (!this.attachment) updateSessionHistory(this.sessionId, this.messages);
} }
}, },
@@ -514,6 +633,7 @@
}, },
handlePaste(e) { handlePaste(e) {
if (this.attachment) return;
const items = [...(e.clipboardData?.items || [])]; const items = [...(e.clipboardData?.items || [])];
const imageItems = items.filter((item) => item.type.startsWith("image/")); const imageItems = items.filter((item) => item.type.startsWith("image/"));
if (!imageItems.length) return; if (!imageItems.length) return;
@@ -533,13 +653,14 @@
const idx = this.messages.indexOf(msg); const idx = this.messages.indexOf(msg);
if (idx === -1) return; if (idx === -1) return;
this.messages = this.messages.slice(0, idx); this.messages = this.messages.slice(0, idx);
updateSessionHistory(this.sessionId, this.messages); if (!this.attachment) updateSessionHistory(this.sessionId, this.messages);
this.promptText = msg.content; this.promptText = msg.content;
this.pendingImages = msg.images ? JSON.parse(JSON.stringify(msg.images)) : []; this.pendingImages = msg.images ? JSON.parse(JSON.stringify(msg.images)) : [];
this.$nextTick(() => this.$refs.promptInput.focus()); this.$nextTick(() => this.$refs.promptInput.focus());
}, },
restoreConversation(msgs) { restoreConversation(msgs) {
if (this.attachment) return;
this.messages = JSON.parse(JSON.stringify(msgs)); this.messages = JSON.parse(JSON.stringify(msgs));
this.userHasScrolledUp = false; this.userHasScrolledUp = false;
this.$refs.historyPanel.removeAttribute("open"); this.$refs.historyPanel.removeAttribute("open");
@@ -547,6 +668,7 @@
}, },
clearHistory() { clearHistory() {
if (this.attachment) return;
if (!confirm("Clear all saved conversations?")) return; if (!confirm("Clear all saved conversations?")) return;
this.historyItems = clearAllHistory(); this.historyItems = clearAllHistory();
this.$refs.historyPanel.removeAttribute("open"); this.$refs.historyPanel.removeAttribute("open");
@@ -572,15 +694,28 @@
return $placeholder.innerHTML; return $placeholder.innerHTML;
} }
async function chatWithOpenAI({ baseUrl = "https://api.openai.com/v1", onContent, onError, messages, signal, apiKey, model, extraHeaders = {}, ...rest }) { 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`, { const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, ...extraHeaders }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, ...extraHeaders },
body: JSON.stringify({ model, messages: messages.map(({ role, content, images }) => body: JSON.stringify({
images?.length model,
? { role, content: [{ type: "text", text: content }, ...images.map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } }))] } messages: apiMessages,
: { role, content } prompt_cache_key: "chat:index:v1",
), stream: true, ...rest }), prompt_cache_options: { mode: "explicit" },
stream: true,
...rest,
}),
signal, signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -609,25 +744,83 @@
} }
async function chatWithClaude(args) { async function chatWithClaude(args) {
return chatWithOpenAI({ const { onContent, onError, messages, signal, apiKey, model } = args;
...args, const system = messages
baseUrl: "https://api.anthropic.com/v1", .filter((message) => message.role === "system")
extraHeaders: { .map((message) => ({ type: "text", text: message.content }))
"x-api-key": args.apiKey, .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-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true", "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, ...rest }) { async function chatWithGemini({ onContent, onError, messages, signal, apiKey, model, systemInstruction, attachment, ...rest }) {
const contents = messages.map((msg, i, arr) => { 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"; const role = msg.role === "assistant" ? "model" : "user";
let parts = [{ text: msg.content }]; let parts = [{ text: msg.content }];
// Add YouTube URLs as file data for the final user message // Preserve the existing ability to send a YouTube URL in a normal chat message.
const isLastMessage = i === arr.length - 1; const isLastMessage = i === conversation.length - 1;
if (isLastMessage && role === "user") { if (!attachment && isLastMessage && role === "user") {
const youtubeUrlPattern = /https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+/g; const youtubeUrlPattern = /https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+/g;
const youtubeUrls = msg.content.match(youtubeUrlPattern) || []; const youtubeUrls = msg.content.match(youtubeUrlPattern) || [];
const fileParts = youtubeUrls.map((url) => ({ file_data: { file_uri: url } })); const fileParts = youtubeUrls.map((url) => ({ file_data: { file_uri: url } }));
@@ -650,6 +843,7 @@
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
...(cachedContent ? { cachedContent } : systemInstruction ? { system_instruction: { parts: [{ text: systemInstruction }] } } : {}),
contents, contents,
generationConfig: rest, generationConfig: rest,
}), }),