feat(chat): add vision input with drag-and-drop & paste images (M9.4)
Drag-and-drop or paste images into chat input. Thumbnail preview above input, max 4 images per message. Images encoded as base64 and sent in Claude vision format (multimodal content arrays). Image attach button in chat input toolbar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0ae497f1db
commit
27aeb2b188
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-3 md:p-4 relative">
|
<div
|
||||||
|
class="p-3 md:p-4 relative"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
<SearchResults
|
<SearchResults
|
||||||
v-if="isSearchMode"
|
v-if="isSearchMode"
|
||||||
:results="searchResults"
|
:results="searchResults"
|
||||||
@@ -27,10 +32,57 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Image thumbnails -->
|
||||||
|
<div v-if="images.length > 0" class="mb-2 flex gap-2 flex-wrap animate-fade-up-fast">
|
||||||
|
<div
|
||||||
|
v-for="(img, i) in images"
|
||||||
|
:key="i"
|
||||||
|
class="relative group w-16 h-16 rounded-lg overflow-hidden border border-white/10 bg-white/5"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`data:${img.mediaType};base64,${img.data}`"
|
||||||
|
:alt="`Attached image ${i + 1}`"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Remove image"
|
||||||
|
@click="removeImage(i)"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-if="images.length >= MAX_IMAGES" class="self-center text-[10px] text-white/30">
|
||||||
|
Max {{ MAX_IMAGES }} images
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drag overlay -->
|
||||||
|
<div
|
||||||
|
v-if="isDragging"
|
||||||
|
class="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-accent/50 bg-accent/5 backdrop-blur-sm pointer-events-none"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-accent/80 font-medium">Drop image here</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||||
:class="focused ? 'border-white/30' : ''"
|
:class="focused ? 'border-white/30' : ''"
|
||||||
>
|
>
|
||||||
|
<!-- Image attach button -->
|
||||||
|
<button
|
||||||
|
v-if="!streaming && images.length < MAX_IMAGES"
|
||||||
|
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
|
||||||
|
aria-label="Attach image"
|
||||||
|
@click="openFilePicker"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="text"
|
v-model="text"
|
||||||
@@ -83,6 +135,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -90,6 +152,10 @@
|
|||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
|
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
|
||||||
import SearchResults from '@/components/ui/SearchResults.vue'
|
import SearchResults from '@/components/ui/SearchResults.vue'
|
||||||
|
import type { ImageAttachment } from '@aiui/core/types/message'
|
||||||
|
|
||||||
|
const MAX_IMAGES = 4
|
||||||
|
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -107,7 +173,7 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
send: [text: string]
|
send: [text: string, images: ImageAttachment[]]
|
||||||
extract: [text: string]
|
extract: [text: string]
|
||||||
stop: []
|
stop: []
|
||||||
clearReply: []
|
clearReply: []
|
||||||
@@ -116,14 +182,18 @@ const emit = defineEmits<{
|
|||||||
const text = ref('')
|
const text = ref('')
|
||||||
const focused = ref(false)
|
const focused = ref(false)
|
||||||
const hasPasted = ref(false)
|
const hasPasted = ref(false)
|
||||||
|
const isDragging = ref(false)
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const images = ref<ImageAttachment[]>([])
|
||||||
|
|
||||||
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
|
const canSend = computed(() => (text.value.trim().length > 0 || images.value.length > 0) && !props.disabled)
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
if (!canSend.value) return
|
if (!canSend.value) return
|
||||||
emit('send', text.value.trim())
|
emit('send', text.value.trim(), [...images.value])
|
||||||
text.value = ''
|
text.value = ''
|
||||||
|
images.value = []
|
||||||
hasPasted.value = false
|
hasPasted.value = false
|
||||||
nextTick(autoResize)
|
nextTick(autoResize)
|
||||||
}
|
}
|
||||||
@@ -136,8 +206,82 @@ function extract() {
|
|||||||
nextTick(autoResize)
|
nextTick(autoResize)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPaste() {
|
function onPaste(e: ClipboardEvent) {
|
||||||
hasPasted.value = true
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) {
|
||||||
|
hasPasted.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasImage = false
|
||||||
|
for (const item of items) {
|
||||||
|
if (ACCEPTED_TYPES.includes(item.type)) {
|
||||||
|
hasImage = true
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) addImageFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasImage) {
|
||||||
|
hasPasted.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
if (e.dataTransfer?.types.includes('Files')) {
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
isDragging.value = false
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
if (!files) return
|
||||||
|
for (const file of files) {
|
||||||
|
if (ACCEPTED_TYPES.includes(file.type)) {
|
||||||
|
addImageFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const files = input.files
|
||||||
|
if (!files) return
|
||||||
|
for (const file of files) {
|
||||||
|
if (ACCEPTED_TYPES.includes(file.type)) {
|
||||||
|
addImageFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset input so same file can be re-selected
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageFile(file: File) {
|
||||||
|
if (images.value.length >= MAX_IMAGES) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
// Strip the data:...;base64, prefix
|
||||||
|
const base64 = result.split(',')[1]
|
||||||
|
if (base64) {
|
||||||
|
images.value.push({ data: base64, mediaType: file.type })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index: number) {
|
||||||
|
images.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoResize() {
|
function autoResize() {
|
||||||
|
|||||||
@@ -74,13 +74,23 @@
|
|||||||
|
|
||||||
<!-- Normal display -->
|
<!-- Normal display -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- Attached images -->
|
||||||
|
<div v-if="message.images && message.images.length > 0" class="flex gap-2 flex-wrap mb-2">
|
||||||
|
<img
|
||||||
|
v-for="(img, i) in message.images"
|
||||||
|
:key="i"
|
||||||
|
:src="`data:${img.mediaType};base64,${img.data}`"
|
||||||
|
:alt="`Attached image ${i + 1}`"
|
||||||
|
class="rounded-lg max-w-[200px] max-h-[200px] object-cover border border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!isUser"
|
v-if="!isUser"
|
||||||
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
|
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
|
||||||
v-html="renderedMarkdown"
|
v-html="renderedMarkdown"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-else
|
v-else-if="message.content"
|
||||||
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
|
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
|
||||||
>{{ displayText }}</p>
|
>{{ displayText }}</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ import ChatSearch from './ChatSearch.vue'
|
|||||||
import ContextBar from './ContextBar.vue'
|
import ContextBar from './ContextBar.vue'
|
||||||
import ComparisonView from './ComparisonView.vue'
|
import ComparisonView from './ComparisonView.vue'
|
||||||
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
||||||
import type { Message } from '@aiui/core/types/message'
|
import type { Message, ImageAttachment } from '@aiui/core/types/message'
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -250,7 +250,7 @@ function handleExtract(text: string) {
|
|||||||
updatePanelFromText(text, '', [])
|
updatePanelFromText(text, '', [])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend(text: string) {
|
async function handleSend(text: string, images: ImageAttachment[] = []) {
|
||||||
// Command handling
|
// Command handling
|
||||||
const trimmed = text.trim().toLowerCase()
|
const trimmed = text.trim().toLowerCase()
|
||||||
if (trimmed === '/code') {
|
if (trimmed === '/code') {
|
||||||
@@ -316,13 +316,13 @@ async function handleSend(text: string) {
|
|||||||
// Comparison mode: stream to both models simultaneously
|
// Comparison mode: stream to both models simultaneously
|
||||||
if (comparison.isComparing.value) {
|
if (comparison.isComparing.value) {
|
||||||
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
|
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
|
||||||
chatStore.addMessage(convId, { role: 'user', content: finalText })
|
chatStore.addMessage(convId, { role: 'user', content: finalText, images: images.length > 0 ? images : undefined })
|
||||||
const history = chatStore.messages.map(m => ({ role: m.role, content: m.content }))
|
const history = chatStore.messages.map(m => ({ role: m.role, content: m.content }))
|
||||||
await comparison.streamBothModels(streamWithModel, history)
|
await comparison.streamBothModels(streamWithModel, history)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessage(finalText)
|
await sendMessage(finalText, images.length > 0 ? images : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
|
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
|
|||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { searchWeb } from '@/composables/useWebSearch'
|
import { searchWeb } from '@/composables/useWebSearch'
|
||||||
import { getApiKey } from '@/utils/key-vault'
|
import { getApiKey } from '@/utils/key-vault'
|
||||||
|
import type { ImageAttachment } from '@aiui/core/types/message'
|
||||||
|
|
||||||
type Provider = 'claude' | 'openrouter' | 'mock'
|
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||||
|
|
||||||
@@ -110,6 +111,23 @@ function setModel(model: string) {
|
|||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
|
images?: ImageAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Claude API content array for a message (multimodal when images present) */
|
||||||
|
function buildClaudeContent(msg: ChatMessage): string | Array<Record<string, unknown>> {
|
||||||
|
if (!msg.images || msg.images.length === 0) return msg.content
|
||||||
|
const blocks: Array<Record<string, unknown>> = []
|
||||||
|
for (const img of msg.images) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (msg.content) {
|
||||||
|
blocks.push({ type: 'text', text: msg.content })
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamMock(
|
async function streamMock(
|
||||||
@@ -145,13 +163,19 @@ async function streamClaude(
|
|||||||
headers['x-api-key'] = vaultKey
|
headers['x-api-key'] = vaultKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build API messages with multimodal content arrays when images present
|
||||||
|
const apiMessages = messages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: buildClaudeContent(m),
|
||||||
|
}))
|
||||||
|
|
||||||
const res = await fetch(CLAUDE_PATH, {
|
const res = await fetch(CLAUDE_PATH, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: activeModel.value,
|
model: activeModel.value,
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
messages,
|
messages: apiMessages,
|
||||||
stream: true,
|
stream: true,
|
||||||
webSearch,
|
webSearch,
|
||||||
}),
|
}),
|
||||||
@@ -363,7 +387,7 @@ export function useAI() {
|
|||||||
chatStore.isStreaming = false
|
chatStore.isStreaming = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(userText: string) {
|
async function sendMessage(userText: string, images?: ImageAttachment[]) {
|
||||||
const provider = activeProvider.value
|
const provider = activeProvider.value
|
||||||
currentAbort = new AbortController()
|
currentAbort = new AbortController()
|
||||||
const signal = currentAbort.signal
|
const signal = currentAbort.signal
|
||||||
@@ -374,7 +398,11 @@ export function useAI() {
|
|||||||
}
|
}
|
||||||
const cid = convId
|
const cid = convId
|
||||||
|
|
||||||
chatStore.addMessage(cid, { role: 'user', content: userText })
|
chatStore.addMessage(cid, {
|
||||||
|
role: 'user',
|
||||||
|
content: userText,
|
||||||
|
images: images && images.length > 0 ? images : undefined,
|
||||||
|
})
|
||||||
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
||||||
if (!assistantMsg) return
|
if (!assistantMsg) return
|
||||||
|
|
||||||
@@ -399,7 +427,7 @@ export function useAI() {
|
|||||||
|
|
||||||
const history: ChatMessage[] = chatStore.messages
|
const history: ChatMessage[] = chatStore.messages
|
||||||
.filter((m) => m.id !== assistantMsg.id)
|
.filter((m) => m.id !== assistantMsg.id)
|
||||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
|
||||||
|
|
||||||
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||||||
const onError = (err: string) => {
|
const onError = (err: string) => {
|
||||||
@@ -510,7 +538,7 @@ export function useAI() {
|
|||||||
|
|
||||||
const history: ChatMessage[] = chatStore.messages
|
const history: ChatMessage[] = chatStore.messages
|
||||||
.filter((m) => m.id !== assistantMsg.id)
|
.filter((m) => m.id !== assistantMsg.id)
|
||||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
|
||||||
|
|
||||||
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||||||
const onError = (err: string) => {
|
const onError = (err: string) => {
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export interface WebSearchResult {
|
|||||||
imgSrc?: string
|
imgSrc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageAttachment {
|
||||||
|
/** Base64-encoded image data (no data: prefix) */
|
||||||
|
data: string
|
||||||
|
/** MIME type e.g. image/jpeg, image/png, image/gif, image/webp */
|
||||||
|
mediaType: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant' | 'system'
|
role: 'user' | 'assistant' | 'system'
|
||||||
@@ -23,6 +30,8 @@ export interface Message {
|
|||||||
webResults?: WebSearchResult[]
|
webResults?: WebSearchResult[]
|
||||||
/** Timestamp of last edit (set when user edits a sent message) */
|
/** Timestamp of last edit (set when user edits a sent message) */
|
||||||
editedAt?: number
|
editedAt?: number
|
||||||
|
/** Attached images (base64, max 4) for vision-capable models */
|
||||||
|
images?: ImageAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Reaction {
|
export interface Reaction {
|
||||||
@@ -39,4 +48,10 @@ export interface Conversation {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
model?: string
|
model?: string
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
|
/** ID of parent conversation this was branched from */
|
||||||
|
parentConversationId?: string
|
||||||
|
/** Message ID in parent where this branch forked */
|
||||||
|
branchPoint?: string
|
||||||
|
/** IDs of child branches forked from this conversation */
|
||||||
|
childBranchIds?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user