feat(chat): Add image paste support

Paste images into the textarea to attach them to a message. Pending
images show as thumbnails above the input with a × remove button.
Images are sent to all three providers (OpenAI image_url, Anthropic
image_url via compat endpoint, Gemini inline_data).
This commit is contained in:
2026-04-09 22:48:23 +02:00
parent db89164aeb
commit fbbd0a4763
+53 -5
View File
@@ -129,8 +129,14 @@
.copy-btn:hover { .copy-btn:hover {
opacity: 1; opacity: 1;
} }
.prompt { .prompt-wrapper {
grid-area: prompt; grid-area: prompt;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.prompt {
min-height: 40px; min-height: 40px;
padding: 0.8rem; padding: 0.8rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -272,7 +278,12 @@
<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'">
<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 x-text="msg.content"></div> <div x-text="msg.content"></div>
</div>
</template> </template>
<template x-if="msg.role === 'assistant'"> <template x-if="msg.role === 'assistant'">
<div x-html="renderMarkdown(msg.content)"></div> <div x-html="renderMarkdown(msg.content)"></div>
@@ -283,6 +294,15 @@
</div> </div>
<!-- Textarea --> <!-- Textarea -->
<div class="prompt-wrapper">
<div x-show="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 <textarea
class="prompt" class="prompt"
placeholder="Type your message..." placeholder="Type your message..."
@@ -291,7 +311,9 @@
:rows="inputRows" :rows="inputRows"
@keydown.ctrl.enter="handleSubmit" @keydown.ctrl.enter="handleSubmit"
@keydown.meta.enter="handleSubmit" @keydown.meta.enter="handleSubmit"
@paste="handlePaste"
></textarea> ></textarea>
</div>
<!-- Action Button --> <!-- Action Button -->
<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>
@@ -326,7 +348,7 @@
</div> </div>
<script defer src="./alpine@3.x.x.js"></script> <script defer src="./alpine@3.x.x.js"></script>
<script defer src="./markdown-it@14.1.0.min.js"></script> <script src="./markdown-it@14.1.0.min.js"></script>
<script> <script>
window.env ??= {}; window.env ??= {};
const systemPrompt = ` const systemPrompt = `
@@ -343,6 +365,7 @@
const chatApp = () => ({ const chatApp = () => ({
messages: [{ role: "system", content: systemPrompt }], messages: [{ role: "system", content: systemPrompt }],
pendingImages: [],
promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "", promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "",
activeModelName: "gemini-3-flash-preview", activeModelName: "gemini-3-flash-preview",
historyItems: [], historyItems: [],
@@ -403,9 +426,10 @@
renderMarkdown, renderMarkdown,
async handleSubmit() { async handleSubmit() {
const text = this.promptText.trim(); const text = this.promptText.trim();
if (!text || this.isBusy) return; if ((!text && !this.pendingImages.length) || this.isBusy) return;
this.messages.push({ role: "user", content: text }); const images = this.pendingImages.splice(0);
this.messages.push({ role: "user", content: text, images });
this.promptText = ""; this.promptText = "";
this.isBusy = true; this.isBusy = true;
this.abortController = new AbortController(); this.abortController = new AbortController();
@@ -454,6 +478,21 @@
this.abortController?.abort(); this.abortController?.abort();
}, },
handlePaste(e) {
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());
});
},
restoreConversation(msgs) { restoreConversation(msgs) {
this.messages = JSON.parse(JSON.stringify(msgs)); this.messages = JSON.parse(JSON.stringify(msgs));
this.userHasScrolledUp = false; this.userHasScrolledUp = false;
@@ -485,7 +524,11 @@
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, stream: true, ...rest }), body: JSON.stringify({ model, messages: messages.map(({ role, content, images }) =>
images?.length
? { role, content: [{ type: "text", text: content }, ...images.map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } }))] }
: { role, content }
), stream: true, ...rest }),
signal, signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -540,6 +583,11 @@
parts = [...parts, ...fileParts]; 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 { return {
role, role,
parts, parts,