From 1edb873acebb8c361412f4bb5e0d6a6f7d5e2586 Mon Sep 17 00:00:00 2001 From: lc6464 Date: Sat, 30 May 2026 18:21:40 +0800 Subject: [PATCH] feat(web): add chat image paste and drag-and-drop upload --- .../src/components/chat/chat-composer.tsx | 41 ++++- .../src/components/chat/chat-page.tsx | 166 ++++++++++------- .../src/components/chat/user-message.tsx | 2 +- web/frontend/src/features/chat/image-input.ts | 170 ++++++++++++++++++ web/frontend/src/i18n/locales/cs.json | 3 +- web/frontend/src/i18n/locales/en.json | 3 +- web/frontend/src/i18n/locales/pt-br.json | 3 +- web/frontend/src/i18n/locales/zh.json | 3 +- 8 files changed, 320 insertions(+), 71 deletions(-) create mode 100644 web/frontend/src/features/chat/image-input.ts diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 43bc8a463..cae4d2f6d 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -1,5 +1,10 @@ import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" -import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react" +import { + type ClipboardEvent as ReactClipboardEvent, + type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, + useRef, +} from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" @@ -30,11 +35,17 @@ interface ChatComposerProps { attachments: ChatAttachment[] onInputChange: (value: string) => void onAddImages: () => void + onPaste: (event: ReactClipboardEvent) => void + onDragEnter: (event: ReactDragEvent) => void + onDragLeave: (event: ReactDragEvent) => void + onDragOver: (event: ReactDragEvent) => void + onDrop: (event: ReactDragEvent) => void onRemoveAttachment: (index: number) => void onSend: () => void onContextDetail?: () => void inputDisabledReason: ChatInputDisabledReason | null canSend: boolean + isDragActive: boolean contextUsage?: ContextUsage } @@ -43,11 +54,17 @@ export function ChatComposer({ attachments, onInputChange, onAddImages, + onPaste, + onDragEnter, + onDragLeave, + onDragOver, + onDrop, onRemoveAttachment, onSend, onContextDetail, inputDisabledReason, canSend, + isDragActive, contextUsage, }: ChatComposerProps) { const { t } = useTranslation() @@ -78,8 +95,25 @@ export function ChatComposer({ } return ( -
-
+
+
+ {isDragActive && ( +
+
+ {t("chat.dropImagesActive")} +
+
+ )} + {attachments.length > 0 && (
{attachments.map((attachment, index) => ( @@ -115,6 +149,7 @@ export function ChatComposer({ onCompositionEnd={() => { composingRef.current = false }} + onPaste={onPaste} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={!canInput} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 3b158843c..98766bfd5 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -1,8 +1,14 @@ import { IconPlus } from "@tabler/icons-react" import { useAtom } from "jotai" -import { type ChangeEvent, useEffect, useRef, useState } from "react" +import { + type ChangeEvent, + type ClipboardEvent, + type DragEvent, + useEffect, + useRef, + useState, +} from "react" import { useTranslation } from "react-i18next" -import { toast } from "sonner" import { AssistantMessage } from "@/components/chat/assistant-message" import { @@ -23,6 +29,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + CHAT_IMAGE_ACCEPT, + buildChatImageAttachments, + getTransferredFiles, + hasFileTransfer, +} from "@/features/chat/image-input" import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" @@ -36,32 +48,6 @@ import { } from "@/store/chat" import type { GatewayState } from "@/store/gateway" -const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 -const MAX_IMAGE_SIZE_LABEL = "7 MB" -const ALLOWED_IMAGE_TYPES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/bmp", -]) - -function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result) - return - } - reject(new Error("Failed to read file")) - } - reader.onerror = () => - reject(reader.error || new Error("Failed to read file")) - reader.readAsDataURL(file) - }) -} - function resolveChatInputDisabledReason({ hasDefaultModel, connectionState, @@ -118,10 +104,12 @@ export function ChatPage() { const { t } = useTranslation() const scrollRef = useRef(null) const fileInputRef = useRef(null) + const dragDepthRef = useRef(0) const [isAtBottom, setIsAtBottom] = useState(true) const [hasScrolled, setHasScrolled] = useState(false) const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) + const [isDragActive, setIsDragActive] = useState(false) const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom( assistantDetailVisibilityAtom, ) @@ -223,6 +211,19 @@ export function ChatPage() { setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index)) } + const appendImageFiles = async (files: readonly File[]) => { + if (!canInput || files.length === 0) { + return + } + + const nextAttachments = await buildChatImageAttachments(files, t) + if (nextAttachments.length === 0) { + return + } + + setAttachments((prev) => [...prev, ...nextAttachments]) + } + const handleImageSelection = async (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []) event.target.value = "" @@ -231,45 +232,77 @@ export function ChatPage() { return } - const nextAttachments: ChatAttachment[] = [] - for (const file of files) { - if (!ALLOWED_IMAGE_TYPES.has(file.type)) { - toast.error( - t("chat.invalidImage", { - name: file.name, - }), - ) - continue - } + await appendImageFiles(files) + } - if (file.size > MAX_IMAGE_SIZE_BYTES) { - toast.error( - t("chat.imageTooLarge", { - name: file.name, - size: MAX_IMAGE_SIZE_LABEL, - }), - ) - continue - } + const resetDragState = () => { + dragDepthRef.current = 0 + setIsDragActive(false) + } - try { - nextAttachments.push({ - type: "image", - filename: file.name, - url: await readFileAsDataUrl(file), - }) - } catch { - toast.error( - t("chat.imageReadFailed", { - name: file.name, - }), - ) - } + const handleComposerPaste = async ( + event: ClipboardEvent, + ) => { + const files = getTransferredFiles(event.clipboardData) + if (files.length === 0) { + return } - if (nextAttachments.length > 0) { - setAttachments(nextAttachments.slice(0, 1)) + await appendImageFiles(files) + } + + const handleComposerDragEnter = (event: DragEvent) => { + if (!hasFileTransfer(event.dataTransfer)) { + return } + + event.preventDefault() + if (!canInput) { + return + } + dragDepthRef.current += 1 + setIsDragActive(true) + } + + const handleComposerDragLeave = (event: DragEvent) => { + if (!hasFileTransfer(event.dataTransfer)) { + return + } + + event.preventDefault() + if (!canInput) { + resetDragState() + return + } + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) { + setIsDragActive(false) + } + } + + const handleComposerDragOver = (event: DragEvent) => { + if (!hasFileTransfer(event.dataTransfer)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = canInput ? "copy" : "none" + } + + const handleComposerDrop = async (event: DragEvent) => { + if (!hasFileTransfer(event.dataTransfer)) { + return + } + + event.preventDefault() + const files = getTransferredFiles(event.dataTransfer) + resetDragState() + + if (!canInput || files.length === 0) { + return + } + + await appendImageFiles(files) } const canSubmit = @@ -398,7 +431,8 @@ export function ChatPage() { @@ -408,6 +442,11 @@ export function ChatPage() { attachments={attachments} onInputChange={setInput} onAddImages={handleAddImages} + onPaste={handleComposerPaste} + onDragEnter={handleComposerDragEnter} + onDragLeave={handleComposerDragLeave} + onDragOver={handleComposerDragOver} + onDrop={handleComposerDrop} onRemoveAttachment={handleRemoveAttachment} onSend={handleSend} onContextDetail={() => { @@ -417,6 +456,7 @@ export function ChatPage() { }} inputDisabledReason={inputDisabledReason} canSend={canSubmit} + isDragActive={isDragActive} contextUsage={contextUsage} />
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 80ebc7ca0..9a05c961d 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -39,7 +39,7 @@ export function UserMessage({ {attachment.filename ))} diff --git a/web/frontend/src/features/chat/image-input.ts b/web/frontend/src/features/chat/image-input.ts new file mode 100644 index 000000000..ed424f084 --- /dev/null +++ b/web/frontend/src/features/chat/image-input.ts @@ -0,0 +1,170 @@ +import type { TFunction } from "i18next" +import { toast } from "sonner" + +import type { ChatAttachment } from "@/store/chat" + +const CHAT_IMAGE_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", +] as const + +const CHAT_IMAGE_MIME_TYPE_SET = new Set(CHAT_IMAGE_MIME_TYPES) +const CHAT_IMAGE_EXTENSION_BY_MIME: Record = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", +} +const CHAT_IMAGE_MIME_BY_EXTENSION: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +} + +export const CHAT_IMAGE_ACCEPT = CHAT_IMAGE_MIME_TYPES.join(",") + +const MAX_CHAT_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 +const MAX_CHAT_IMAGE_SIZE_LABEL = "7 MB" + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result) + return + } + reject(new Error("Failed to read file")) + } + reader.onerror = () => + reject(reader.error || new Error("Failed to read file")) + reader.readAsDataURL(file) + }) +} + +function getFileExtension(fileName: string): string { + const lastDotIndex = fileName.lastIndexOf(".") + if (lastDotIndex === -1) { + return "" + } + return fileName.slice(lastDotIndex).toLowerCase() +} + +function getSupportedImageMimeType(file: File): string | null { + const normalizedType = file.type.trim().toLowerCase() + if (normalizedType && CHAT_IMAGE_MIME_TYPE_SET.has(normalizedType)) { + return normalizedType + } + + const extension = getFileExtension(file.name) + return CHAT_IMAGE_MIME_BY_EXTENSION[extension] ?? null +} + +function normalizeImageFileForDataUrl(file: File, filename: string): File { + const mimeType = getSupportedImageMimeType(file) + if (!mimeType || file.type.trim().toLowerCase() === mimeType) { + return file + } + + const normalizedName = file.name.trim() || filename + return new File([file], normalizedName, { type: mimeType }) +} + +function getAttachmentFilename(file: File, index: number): string { + const trimmedName = file.name.trim() + if (trimmedName) { + return trimmedName + } + + const mimeType = getSupportedImageMimeType(file) + const extension = mimeType ? CHAT_IMAGE_EXTENSION_BY_MIME[mimeType] : ".png" + return `image-${index + 1}${extension}` +} + +function getTransferItemFiles(dataTransfer: DataTransfer | null): File[] { + if (!dataTransfer) { + return [] + } + + const files = Array.from(dataTransfer.files) + if (files.length > 0) { + return files + } + + return Array.from(dataTransfer.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) +} + +export function hasFileTransfer(dataTransfer: DataTransfer | null): boolean { + if (!dataTransfer) { + return false + } + + if (dataTransfer.files.length > 0) { + return true + } + + return Array.from(dataTransfer.items).some((item) => item.kind === "file") +} + +export function getTransferredFiles(dataTransfer: DataTransfer | null) { + return getTransferItemFiles(dataTransfer) +} + +export async function buildChatImageAttachments( + files: readonly File[], + t: TFunction, +): Promise { + const nextAttachments: ChatAttachment[] = [] + + for (const [index, file] of files.entries()) { + const filename = getAttachmentFilename(file, index) + + const mimeType = getSupportedImageMimeType(file) + if (!mimeType) { + toast.error( + t("chat.invalidImage", { + name: filename, + }), + ) + continue + } + + if (file.size > MAX_CHAT_IMAGE_SIZE_BYTES) { + toast.error( + t("chat.imageTooLarge", { + name: filename, + size: MAX_CHAT_IMAGE_SIZE_LABEL, + }), + ) + continue + } + + try { + const normalizedFile = normalizeImageFileForDataUrl(file, filename) + nextAttachments.push({ + type: "image", + filename, + url: await readFileAsDataUrl(normalizedFile), + contentType: mimeType, + }) + } catch { + toast.error( + t("chat.imageReadFailed", { + name: filename, + }), + ) + } + } + + return nextAttachments +} diff --git a/web/frontend/src/i18n/locales/cs.json b/web/frontend/src/i18n/locales/cs.json index 8f3fa9a25..30139c0b9 100644 --- a/web/frontend/src/i18n/locales/cs.json +++ b/web/frontend/src/i18n/locales/cs.json @@ -95,8 +95,9 @@ "contextTitle": "Kontext", "contextDetail": "Zobrazit detail", "attachImage": "Přidat obrázky", + "dropImagesActive": "Uvolněním přidáte obrázky", "removeImage": "Odebrat obrázek", - "uploadedImage": "Nahraný obrázek", + "uploadedImage": "Přiložený obrázek", "invalidImage": "\"{{name}}\" není podporovaný formát obrázku.", "imageTooLarge": "\"{{name}}\" překračuje limit {{size}}.", "imageReadFailed": "Čtení souboru \"{{name}}\" selhalo.", diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index cb97f0a5e..11ea2cabf 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -97,8 +97,9 @@ "contextTitle": "Context", "contextDetail": "View Details", "attachImage": "Add images", + "dropImagesActive": "Release to add images", "removeImage": "Remove image", - "uploadedImage": "Uploaded image", + "uploadedImage": "Attached image", "invalidImage": "\"{{name}}\" is not a supported image file.", "imageTooLarge": "\"{{name}}\" exceeds the {{size}} limit.", "imageReadFailed": "Failed to read \"{{name}}\".", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index ca1f9ed32..28df583ba 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -97,8 +97,9 @@ "contextTitle": "Contexto", "contextDetail": "Ver Detalhes", "attachImage": "Adicionar imagens", + "dropImagesActive": "Solte para adicionar imagens", "removeImage": "Remover imagem", - "uploadedImage": "Imagem enviada", + "uploadedImage": "Imagem anexada", "invalidImage": "\"{{name}}\" não é um arquivo de imagem suportado.", "imageTooLarge": "\"{{name}}\" excede o limite de {{size}}.", "imageReadFailed": "Falha ao ler \"{{name}}\".", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 5590adab2..d5491e8f0 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -97,8 +97,9 @@ "contextTitle": "上下文", "contextDetail": "查看详情", "attachImage": "添加图片", + "dropImagesActive": "松开以添加图片", "removeImage": "移除图片", - "uploadedImage": "已上传图片", + "uploadedImage": "已添加图片", "invalidImage": "“{{name}}”不是支持的图片文件。", "imageTooLarge": "“{{name}}”超过了 {{size}} 限制。", "imageReadFailed": "读取“{{name}}”失败。",