mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): add chat image paste and drag-and-drop upload
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}\".",
|
||||
|
||||
@@ -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}}\".",
|
||||
|
||||
@@ -97,8 +97,9 @@
|
||||
"contextTitle": "上下文",
|
||||
"contextDetail": "查看详情",
|
||||
"attachImage": "添加图片",
|
||||
"dropImagesActive": "松开以添加图片",
|
||||
"removeImage": "移除图片",
|
||||
"uploadedImage": "已上传图片",
|
||||
"uploadedImage": "已添加图片",
|
||||
"invalidImage": "“{{name}}”不是支持的图片文件。",
|
||||
"imageTooLarge": "“{{name}}”超过了 {{size}} 限制。",
|
||||
"imageReadFailed": "读取“{{name}}”失败。",
|
||||
|
||||
Reference in New Issue
Block a user