feat(web): add chat image paste and drag-and-drop upload
This commit is contained in:
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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
170
web/frontend/src/features/chat/image-input.ts
Normal file
170
web/frontend/src/features/chat/image-input.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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}}\".",
|
||||||
|
|
|
||||||
|
|
@ -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}}\".",
|
||||||
|
|
|
||||||
|
|
@ -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}}”失败。",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue