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:
+63
-15
@@ -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 x-text="msg.content"></div>
|
<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>
|
||||||
<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,15 +294,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea -->
|
<!-- Textarea -->
|
||||||
<textarea
|
<div class="prompt-wrapper">
|
||||||
class="prompt"
|
<div x-show="pendingImages.length > 0" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start;">
|
||||||
placeholder="Type your message..."
|
<template x-for="(img, i) in pendingImages" :key="img.dataUrl">
|
||||||
x-model="promptText"
|
<div style="position: relative; display: inline-block;">
|
||||||
x-ref="promptInput"
|
<img :src="img.dataUrl" style="max-height: 72px; max-width: 120px; object-fit: cover; border-radius: 0.3rem; display: block;" />
|
||||||
:rows="inputRows"
|
<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>
|
||||||
@keydown.ctrl.enter="handleSubmit"
|
</div>
|
||||||
@keydown.meta.enter="handleSubmit"
|
</template>
|
||||||
></textarea>
|
</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 -->
|
<!-- 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user