feat(web): add chat image paste and drag-and-drop upload

This commit is contained in:
lc6464 2026-05-30 18:21:40 +08:00
parent e81d37108b
commit 1edb873ace
No known key found for this signature in database
GPG key ID: 123B3EDA032064FF
8 changed files with 320 additions and 71 deletions

View file

@ -1,5 +1,10 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" 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 { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from "react-textarea-autosize"
@ -30,11 +35,17 @@ interface ChatComposerProps {
attachments: ChatAttachment[] attachments: ChatAttachment[]
onInputChange: (value: string) => void onInputChange: (value: string) => void
onAddImages: () => void onAddImages: () => void
onPaste: (event: ReactClipboardEvent<HTMLTextAreaElement>) => void
onDragEnter: (event: ReactDragEvent<HTMLDivElement>) => void
onDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void
onDragOver: (event: ReactDragEvent<HTMLDivElement>) => void
onDrop: (event: ReactDragEvent<HTMLDivElement>) => void
onRemoveAttachment: (index: number) => void onRemoveAttachment: (index: number) => void
onSend: () => void onSend: () => void
onContextDetail?: () => void onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean canSend: boolean
isDragActive: boolean
contextUsage?: ContextUsage contextUsage?: ContextUsage
} }
@ -43,11 +54,17 @@ export function ChatComposer({
attachments, attachments,
onInputChange, onInputChange,
onAddImages, onAddImages,
onPaste,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onRemoveAttachment, onRemoveAttachment,
onSend, onSend,
onContextDetail, onContextDetail,
inputDisabledReason, inputDisabledReason,
canSend, canSend,
isDragActive,
contextUsage, contextUsage,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -78,8 +95,25 @@ export function ChatComposer({
} }
return ( return (
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48"> <div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 [scrollbar-gutter:stable] overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm"> <div
className={cn(
"bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm transition-colors",
isDragActive && "border-violet-400/70 bg-violet-500/5",
)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{isDragActive && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-violet-400/70 bg-violet-500/10">
<div className="bg-background/95 text-foreground rounded-full px-4 py-2 text-sm font-medium shadow-sm">
{t("chat.dropImagesActive")}
</div>
</div>
)}
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2"> <div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (
@ -115,6 +149,7 @@ export function ChatComposer({
onCompositionEnd={() => { onCompositionEnd={() => {
composingRef.current = false composingRef.current = false
}} }}
onPaste={onPaste}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
disabled={!canInput} disabled={!canInput}

View file

@ -1,8 +1,14 @@
import { IconPlus } from "@tabler/icons-react" import { IconPlus } from "@tabler/icons-react"
import { useAtom } from "jotai" 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 { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { AssistantMessage } from "@/components/chat/assistant-message" import { AssistantMessage } from "@/components/chat/assistant-message"
import { import {
@ -23,6 +29,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import {
CHAT_IMAGE_ACCEPT,
buildChatImageAttachments,
getTransferredFiles,
hasFileTransfer,
} from "@/features/chat/image-input"
import { useChatModels } from "@/hooks/use-chat-models" import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway" import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat" import { usePicoChat } from "@/hooks/use-pico-chat"
@ -36,32 +48,6 @@ import {
} from "@/store/chat" } from "@/store/chat"
import type { GatewayState } from "@/store/gateway" 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<string> {
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({ function resolveChatInputDisabledReason({
hasDefaultModel, hasDefaultModel,
connectionState, connectionState,
@ -118,10 +104,12 @@ export function ChatPage() {
const { t } = useTranslation() const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const [isAtBottom, setIsAtBottom] = useState(true) const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false) const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([]) const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [isDragActive, setIsDragActive] = useState(false)
const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom( const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom(
assistantDetailVisibilityAtom, assistantDetailVisibilityAtom,
) )
@ -223,6 +211,19 @@ export function ChatPage() {
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index)) 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<HTMLInputElement>) => { const handleImageSelection = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []) const files = Array.from(event.target.files ?? [])
event.target.value = "" event.target.value = ""
@ -231,45 +232,77 @@ export function ChatPage() {
return return
} }
const nextAttachments: ChatAttachment[] = [] await appendImageFiles(files)
for (const file of files) { }
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
toast.error(
t("chat.invalidImage", {
name: file.name,
}),
)
continue
}
if (file.size > MAX_IMAGE_SIZE_BYTES) { const resetDragState = () => {
toast.error( dragDepthRef.current = 0
t("chat.imageTooLarge", { setIsDragActive(false)
name: file.name, }
size: MAX_IMAGE_SIZE_LABEL,
}),
)
continue
}
try { const handleComposerPaste = async (
nextAttachments.push({ event: ClipboardEvent<HTMLTextAreaElement>,
type: "image", ) => {
filename: file.name, const files = getTransferredFiles(event.clipboardData)
url: await readFileAsDataUrl(file), if (files.length === 0) {
}) return
} catch {
toast.error(
t("chat.imageReadFailed", {
name: file.name,
}),
)
}
} }
if (nextAttachments.length > 0) { await appendImageFiles(files)
setAttachments(nextAttachments.slice(0, 1)) }
const handleComposerDragEnter = (event: DragEvent<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
} }
event.preventDefault()
if (!canInput) {
return
}
dragDepthRef.current += 1
setIsDragActive(true)
}
const handleComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = canInput ? "copy" : "none"
}
const handleComposerDrop = async (event: DragEvent<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
const files = getTransferredFiles(event.dataTransfer)
resetDragState()
if (!canInput || files.length === 0) {
return
}
await appendImageFiles(files)
} }
const canSubmit = const canSubmit =
@ -398,7 +431,8 @@ export function ChatPage() {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp" accept={CHAT_IMAGE_ACCEPT}
multiple
className="hidden" className="hidden"
onChange={handleImageSelection} onChange={handleImageSelection}
/> />
@ -408,6 +442,11 @@ export function ChatPage() {
attachments={attachments} attachments={attachments}
onInputChange={setInput} onInputChange={setInput}
onAddImages={handleAddImages} onAddImages={handleAddImages}
onPaste={handleComposerPaste}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
onRemoveAttachment={handleRemoveAttachment} onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend} onSend={handleSend}
onContextDetail={() => { onContextDetail={() => {
@ -417,6 +456,7 @@ export function ChatPage() {
}} }}
inputDisabledReason={inputDisabledReason} inputDisabledReason={inputDisabledReason}
canSend={canSubmit} canSend={canSubmit}
isDragActive={isDragActive}
contextUsage={contextUsage} contextUsage={contextUsage}
/> />
</div> </div>

View file

@ -39,7 +39,7 @@ export function UserMessage({
<img <img
key={`${attachment.url}-${index}`} key={`${attachment.url}-${index}`}
src={attachment.url} src={attachment.url}
alt={attachment.filename || "Uploaded image"} alt={attachment.filename || t("chat.uploadedImage")}
className="max-h-72 max-w-full object-cover" className="max-h-72 max-w-full object-cover"
/> />
))} ))}

View file

@ -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<string>(CHAT_IMAGE_MIME_TYPES)
const CHAT_IMAGE_EXTENSION_BY_MIME: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
}
const CHAT_IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".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<string> {
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<ChatAttachment[]> {
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
}

View file

@ -95,8 +95,9 @@
"contextTitle": "Kontext", "contextTitle": "Kontext",
"contextDetail": "Zobrazit detail", "contextDetail": "Zobrazit detail",
"attachImage": "Přidat obrázky", "attachImage": "Přidat obrázky",
"dropImagesActive": "Uvolněním přidáte obrázky",
"removeImage": "Odebrat obrázek", "removeImage": "Odebrat obrázek",
"uploadedImage": "Nahraný obrázek", "uploadedImage": "Přiložený obrázek",
"invalidImage": "\"{{name}}\" není podporovaný formát obrázku.", "invalidImage": "\"{{name}}\" není podporovaný formát obrázku.",
"imageTooLarge": "\"{{name}}\" překračuje limit {{size}}.", "imageTooLarge": "\"{{name}}\" překračuje limit {{size}}.",
"imageReadFailed": "Čtení souboru \"{{name}}\" selhalo.", "imageReadFailed": "Čtení souboru \"{{name}}\" selhalo.",

View file

@ -97,8 +97,9 @@
"contextTitle": "Context", "contextTitle": "Context",
"contextDetail": "View Details", "contextDetail": "View Details",
"attachImage": "Add images", "attachImage": "Add images",
"dropImagesActive": "Release to add images",
"removeImage": "Remove image", "removeImage": "Remove image",
"uploadedImage": "Uploaded image", "uploadedImage": "Attached image",
"invalidImage": "\"{{name}}\" is not a supported image file.", "invalidImage": "\"{{name}}\" is not a supported image file.",
"imageTooLarge": "\"{{name}}\" exceeds the {{size}} limit.", "imageTooLarge": "\"{{name}}\" exceeds the {{size}} limit.",
"imageReadFailed": "Failed to read \"{{name}}\".", "imageReadFailed": "Failed to read \"{{name}}\".",

View file

@ -97,8 +97,9 @@
"contextTitle": "Contexto", "contextTitle": "Contexto",
"contextDetail": "Ver Detalhes", "contextDetail": "Ver Detalhes",
"attachImage": "Adicionar imagens", "attachImage": "Adicionar imagens",
"dropImagesActive": "Solte para adicionar imagens",
"removeImage": "Remover imagem", "removeImage": "Remover imagem",
"uploadedImage": "Imagem enviada", "uploadedImage": "Imagem anexada",
"invalidImage": "\"{{name}}\" não é um arquivo de imagem suportado.", "invalidImage": "\"{{name}}\" não é um arquivo de imagem suportado.",
"imageTooLarge": "\"{{name}}\" excede o limite de {{size}}.", "imageTooLarge": "\"{{name}}\" excede o limite de {{size}}.",
"imageReadFailed": "Falha ao ler \"{{name}}\".", "imageReadFailed": "Falha ao ler \"{{name}}\".",

View file

@ -97,8 +97,9 @@
"contextTitle": "上下文", "contextTitle": "上下文",
"contextDetail": "查看详情", "contextDetail": "查看详情",
"attachImage": "添加图片", "attachImage": "添加图片",
"dropImagesActive": "松开以添加图片",
"removeImage": "移除图片", "removeImage": "移除图片",
"uploadedImage": "已上传图片", "uploadedImage": "已添加图片",
"invalidImage": "“{{name}}”不是支持的图片文件。", "invalidImage": "“{{name}}”不是支持的图片文件。",
"imageTooLarge": "“{{name}}”超过了 {{size}} 限制。", "imageTooLarge": "“{{name}}”超过了 {{size}} 限制。",
"imageReadFailed": "读取“{{name}}”失败。", "imageReadFailed": "读取“{{name}}”失败。",