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:
+53
-5
@@ -129,8 +129,14 @@
|
||||
.copy-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.prompt {
|
||||
.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);
|
||||
@@ -272,7 +278,12 @@
|
||||
<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 x-text="msg.content"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="msg.role === 'assistant'">
|
||||
<div x-html="renderMarkdown(msg.content)"></div>
|
||||
@@ -283,6 +294,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 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;">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<textarea
|
||||
class="prompt"
|
||||
placeholder="Type your message..."
|
||||
@@ -291,7 +311,9 @@
|
||||
: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>
|
||||
@@ -326,7 +348,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
window.env ??= {};
|
||||
const systemPrompt = `
|
||||
@@ -343,6 +365,7 @@
|
||||
|
||||
const chatApp = () => ({
|
||||
messages: [{ role: "system", content: systemPrompt }],
|
||||
pendingImages: [],
|
||||
promptText: window.env.PROMPT ? `${window.env.PROMPT}\n\n` : "",
|
||||
activeModelName: "gemini-3-flash-preview",
|
||||
historyItems: [],
|
||||
@@ -403,9 +426,10 @@
|
||||
renderMarkdown,
|
||||
async handleSubmit() {
|
||||
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.isBusy = true;
|
||||
this.abortController = new AbortController();
|
||||
@@ -454,6 +478,21 @@
|
||||
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) {
|
||||
this.messages = JSON.parse(JSON.stringify(msgs));
|
||||
this.userHasScrolledUp = false;
|
||||
@@ -485,7 +524,11 @@
|
||||
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 }),
|
||||
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,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -540,6 +583,11 @@
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user