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:
Dorian
2026-03-03 23:25:26 +00:00
co-authored by Claude Opus 4.6
parent 0ae497f1db
commit 27aeb2b188
5 changed files with 213 additions and 16 deletions
+33 -5
View File
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
import { getApiKey } from '@/utils/key-vault'
import type { ImageAttachment } from '@aiui/core/types/message'
type Provider = 'claude' | 'openrouter' | 'mock'
@@ -110,6 +111,23 @@ function setModel(model: string) {
interface ChatMessage {
role: 'user' | 'assistant'
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(
@@ -145,13 +163,19 @@ async function streamClaude(
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, {
method: 'POST',
headers,
body: JSON.stringify({
model: activeModel.value,
system: systemPrompt,
messages,
messages: apiMessages,
stream: true,
webSearch,
}),
@@ -363,7 +387,7 @@ export function useAI() {
chatStore.isStreaming = false
}
async function sendMessage(userText: string) {
async function sendMessage(userText: string, images?: ImageAttachment[]) {
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
@@ -374,7 +398,11 @@ export function useAI() {
}
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: '' })
if (!assistantMsg) return
@@ -399,7 +427,7 @@ export function useAI() {
const history: ChatMessage[] = chatStore.messages
.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 onError = (err: string) => {
@@ -510,7 +538,7 @@ export function useAI() {
const history: ChatMessage[] = chatStore.messages
.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 onError = (err: string) => {