diff --git a/packages/app/src/components/chat/ChatInput.vue b/packages/app/src/components/chat/ChatInput.vue
index faa03135..ff84cf9c 100644
--- a/packages/app/src/components/chat/ChatInput.vue
+++ b/packages/app/src/components/chat/ChatInput.vue
@@ -1,5 +1,10 @@
-
+
+
+
+
+
![]()
+
+
+
+ Max {{ MAX_IMAGES }} images
+
+
+
+
+
+
+
+
+
@@ -90,6 +152,10 @@
import { ref, computed, nextTick, watch } from 'vue'
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
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(
defineProps<{
@@ -107,7 +173,7 @@ const props = withDefaults(
)
const emit = defineEmits<{
- send: [text: string]
+ send: [text: string, images: ImageAttachment[]]
extract: [text: string]
stop: []
clearReply: []
@@ -116,14 +182,18 @@ const emit = defineEmits<{
const text = ref('')
const focused = ref(false)
const hasPasted = ref(false)
+const isDragging = ref(false)
const textareaRef = ref(null)
+const fileInputRef = ref(null)
+const images = ref([])
-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() {
if (!canSend.value) return
- emit('send', text.value.trim())
+ emit('send', text.value.trim(), [...images.value])
text.value = ''
+ images.value = []
hasPasted.value = false
nextTick(autoResize)
}
@@ -136,8 +206,82 @@ function extract() {
nextTick(autoResize)
}
-function onPaste() {
- hasPasted.value = true
+function onPaste(e: ClipboardEvent) {
+ 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() {
diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue
index 7fde5ed7..6731c729 100644
--- a/packages/app/src/components/chat/ChatMessage.vue
+++ b/packages/app/src/components/chat/ChatMessage.vue
@@ -74,13 +74,23 @@
+
+
+
![]()
+
{{ displayText }}
diff --git a/packages/app/src/components/chat/ChatWindow.vue b/packages/app/src/components/chat/ChatWindow.vue
index 5527db30..61f43929 100644
--- a/packages/app/src/components/chat/ChatWindow.vue
+++ b/packages/app/src/components/chat/ChatWindow.vue
@@ -117,7 +117,7 @@ import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.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(
defineProps<{
@@ -250,7 +250,7 @@ function handleExtract(text: string) {
updatePanelFromText(text, '', [])
}
-async function handleSend(text: string) {
+async function handleSend(text: string, images: ImageAttachment[] = []) {
// Command handling
const trimmed = text.trim().toLowerCase()
if (trimmed === '/code') {
@@ -316,13 +316,13 @@ async function handleSend(text: string) {
// Comparison mode: stream to both models simultaneously
if (comparison.isComparing.value) {
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 }))
await comparison.streamBothModels(streamWithModel, history)
return
}
- await sendMessage(finalText)
+ await sendMessage(finalText, images.length > 0 ? images : undefined)
}
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts
index c8b36f87..1eba480d 100644
--- a/packages/app/src/composables/useAI.ts
+++ b/packages/app/src/composables/useAI.ts
@@ -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> {
+ if (!msg.images || msg.images.length === 0) return msg.content
+ const blocks: Array> = []
+ 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) => {
diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts
index d8160fd5..63602e32 100644
--- a/packages/core/src/types/message.ts
+++ b/packages/core/src/types/message.ts
@@ -8,6 +8,13 @@ export interface WebSearchResult {
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 {
id: string
role: 'user' | 'assistant' | 'system'
@@ -23,6 +30,8 @@ export interface Message {
webResults?: WebSearchResult[]
/** Timestamp of last edit (set when user edits a sent message) */
editedAt?: number
+ /** Attached images (base64, max 4) for vision-capable models */
+ images?: ImageAttachment[]
}
export interface Reaction {
@@ -39,4 +48,10 @@ export interface Conversation {
updatedAt: number
model?: 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[]
}