diff --git a/admin/src/api/chat.ts b/admin/src/api/chat.ts index d2258f3..60d4c3f 100644 --- a/admin/src/api/chat.ts +++ b/admin/src/api/chat.ts @@ -1,5 +1,17 @@ import { api, createSSE, getAuthHeaders } from './client' +export interface ChatImage { + id: string + url: string + thumb_url: string + ocr_text?: string | null + width: number + height: number + original_name: string + size?: number + mime_type?: string +} + export interface ChatMessage { id: string role: 'user' | 'assistant' | 'system' @@ -8,6 +20,9 @@ export interface ChatMessage { edited?: boolean parent_id?: string | null is_active?: boolean + metadata?: { + images?: ChatImage[] + } | null } export interface SiblingInfo { @@ -179,7 +194,8 @@ export const chatApi = { content: string, onChunk: (data: { type: string; content?: string; message?: ChatMessage; token_usage?: TokenUsage; query?: string; name?: string; found?: boolean }) => void, llmOverride?: { llm_backend?: string; system_prompt?: string }, - widgetInstanceId?: string + widgetInstanceId?: string, + imageIds?: string[] ) => { const controller = new AbortController() @@ -190,6 +206,9 @@ export const chatApi = { if (widgetInstanceId) { body.widget_instance_id = widgetInstanceId } + if (imageIds?.length) { + body.image_ids = imageIds + } fetch(`/admin/chat/sessions/${sessionId}/stream`, { method: 'POST', @@ -278,4 +297,8 @@ export const chatApi = { getShareableUsers: () => api.get<{ users: ShareableUser[] }>('/admin/chat/shareable-users'), + + // Image upload + uploadImage: (sessionId: string, file: File) => + api.upload<{ image: ChatImage }>(`/admin/chat/sessions/${sessionId}/upload-image`, file), } diff --git a/admin/src/plugins/i18n.ts b/admin/src/plugins/i18n.ts index c00200f..5323071 100644 --- a/admin/src/plugins/i18n.ts +++ b/admin/src/plugins/i18n.ts @@ -370,6 +370,8 @@ const messages = { zenSettings: "Настройки чата", pastedLines: "{count} строк", removePasted: "Удалить вложение", + uploadImage: "Загрузить изображение", + removeImage: "Удалить изображение", claudeCode: { enable: "Включить Claude Code", disable: "Выключить Claude Code", @@ -1703,6 +1705,8 @@ const messages = { zenSettings: "Chat settings", pastedLines: "{count} lines", removePasted: "Remove attachment", + uploadImage: "Upload image", + removeImage: "Remove image", claudeCode: { enable: "Enable Claude Code", disable: "Disable Claude Code", @@ -3036,6 +3040,8 @@ const messages = { zenSettings: "Чат параметрлері", pastedLines: "{count} жол", removePasted: "Тіркемені жою", + uploadImage: "Сурет жүктеу", + removeImage: "Суретті жою", claudeCode: { enable: "Claude Code қосу", disable: "Claude Code өшіру", diff --git a/admin/src/views/ChatView.vue b/admin/src/views/ChatView.vue index 81a743f..66c192d 100644 --- a/admin/src/views/ChatView.vue +++ b/admin/src/views/ChatView.vue @@ -23,7 +23,7 @@ import 'prismjs/components/prism-diff' import 'prismjs/components/prism-markdown' import 'prismjs/components/prism-jsx' import 'prismjs/components/prism-tsx' -import { chatApi, ttsApi, llmApi, sttApi, wikiRagApi, type ChatSession, type ChatMessage, type ChatSessionSummary, type CloudProvider, type BranchNode, type SiblingInfo, type TokenUsage } from '@/api' +import { chatApi, ttsApi, llmApi, sttApi, wikiRagApi, type ChatSession, type ChatMessage, type ChatImage, type ChatSessionSummary, type CloudProvider, type BranchNode, type SiblingInfo, type TokenUsage } from '@/api' import BranchTree from '@/components/BranchTree.vue' import ChatShareDialog from '@/components/ChatShareDialog.vue' import ArtifactPanel, { type Artifact } from '@/components/ArtifactPanel.vue' @@ -76,7 +76,8 @@ import { Minimize2, Globe, Server, - Search + Search, + ImagePlus } from 'lucide-vue-next' import { useSidebarCollapse } from '@/composables/useSidebarCollapse' import { useClaudeCode } from '@/composables/useClaudeCode' @@ -159,6 +160,25 @@ const { width: artifactWidth, startResize: startArtifactResize, startTouchResize const pastedBlocks = ref([]) function onPaste(e: ClipboardEvent) { + // Handle pasted images + const items = e.clipboardData?.items + if (items && currentSessionId.value) { + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (file) { + isUploadingImage.value = true + chatApi.uploadImage(currentSessionId.value, file) + .then(({ image }) => pendingImages.value.push(image)) + .catch(err => toastStore.error(String(err))) + .finally(() => { isUploadingImage.value = false }) + } + return + } + } + } + // Handle pasted text const text = e.clipboardData?.getData('text/plain') if (!text || !shouldTreatAsPaste(text)) return e.preventDefault() @@ -169,6 +189,38 @@ function removePastedBlock(id: string) { pastedBlocks.value = pastedBlocks.value.filter(b => b.id !== id) } +// Pending image uploads +const pendingImages = ref([]) +const isUploadingImage = ref(false) +const imageInputRef = ref(null) + +async function handleImageUpload(e: Event) { + const input = e.target as HTMLInputElement + const files = input.files + if (!files?.length || !currentSessionId.value) return + + isUploadingImage.value = true + try { + for (const file of Array.from(files)) { + if (!file.type.startsWith('image/')) continue + const { image } = await chatApi.uploadImage(currentSessionId.value, file) + pendingImages.value.push(image) + } + } catch (err) { + toastStore.error(String(err)) + } finally { + isUploadingImage.value = false + input.value = '' + } +} + +function removePendingImage(id: string) { + pendingImages.value = pendingImages.value.filter(i => i.id !== id) +} + +// Fullscreen image viewer +const fullscreenImage = ref(null) + // Artifact viewer state const showArtifact = ref(false) const activeArtifact = ref(null) @@ -991,11 +1043,14 @@ async function deleteCcSession(ccSessionId: string, title: string, event: Event) function sendMessage() { const hasText = inputMessage.value.trim().length > 0 const hasPaste = pastedBlocks.value.length > 0 - if ((!hasText && !hasPaste) || !currentSessionId.value || isStreaming.value) return + const hasImages = pendingImages.value.length > 0 + if ((!hasText && !hasPaste && !hasImages) || !currentSessionId.value || isStreaming.value) return const content = buildMessageContent(inputMessage.value.trim(), pastedBlocks.value) + const imageIds = pendingImages.value.map(i => i.id) inputMessage.value = '' pastedBlocks.value = [] + pendingImages.value = [] if (messageInputRef.value) messageInputRef.value.style.height = 'auto' // Show user message immediately (optimistic) @@ -1063,7 +1118,7 @@ function sendMessage() { refetchSessions() nextTick(() => messageInputRef.value?.focus()) } - }, llmOverride) + }, llmOverride, undefined, imageIds.length ? imageIds : undefined) } function ccSendMessage() { @@ -2860,6 +2915,30 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => { inputPosition === 'bottom' ? 'border-t border-border order-last' + (fullscreenStore.isFullscreen ? '' : ' pb-24') : 'border-b border-border' ]" > + +
+
+ + +
+ OCR +
+
+
cc.isProcessing.value, (processing, wasProcesing) => { @input="onInputAutoResize" @paste="onPaste" /> + + +