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
8 changed files with 320 additions and 71 deletions
@@ -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<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
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 (
<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="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="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={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 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
@@ -115,6 +149,7 @@ export function ChatComposer({
onCompositionEnd={() => {
composingRef.current = false
}}
onPaste={onPaste}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
+103 -63
View File
@@ -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<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({
hasDefaultModel,
connectionState,
@@ -118,10 +104,12 @@ export function ChatPage() {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
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<HTMLInputElement>) => {
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<HTMLTextAreaElement>,
) => {
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<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 =
@@ -398,7 +431,8 @@ export function ChatPage() {
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
accept={CHAT_IMAGE_ACCEPT}
multiple
className="hidden"
onChange={handleImageSelection}
/>
@@ -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}
/>
</div>
@@ -39,7 +39,7 @@ export function UserMessage({
<img
key={`${attachment.url}-${index}`}
src={attachment.url}
alt={attachment.filename || "Uploaded image"}
alt={attachment.filename || t("chat.uploadedImage")}
className="max-h-72 max-w-full object-cover"
/>
))}
@@ -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
}
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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}}\".",
+2 -1
View File
@@ -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}}\".",
+2 -1
View File
@@ -97,8 +97,9 @@
"contextTitle": "上下文",
"contextDetail": "查看详情",
"attachImage": "添加图片",
"dropImagesActive": "松开以添加图片",
"removeImage": "移除图片",
"uploadedImage": "已上传图片",
"uploadedImage": "已添加图片",
"invalidImage": "“{{name}}”不是支持的图片文件。",
"imageTooLarge": "“{{name}}”超过了 {{size}} 限制。",
"imageReadFailed": "读取“{{name}}”失败。",