feat(web): support image messages in pico chat (#2299)

This commit is contained in:
wenjie
2026-04-03 14:15:20 +08:00
committed by GitHub
parent f3ad5d9305
commit f2a19ab947
21 changed files with 1009 additions and 79 deletions
@@ -43,7 +43,7 @@ export function AssistantMessage({
</div>
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed [overflow-wrap:anywhere] break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
@@ -1,25 +1,34 @@
import { IconArrowUp } from "@tabler/icons-react"
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
import type { KeyboardEvent } from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
interface ChatComposerProps {
input: string
attachments: ChatAttachment[]
onInputChange: (value: string) => void
onAddImages: () => void
onRemoveAttachment: (index: number) => void
onSend: () => void
isConnected: boolean
hasDefaultModel: boolean
canSend: boolean
}
export function ChatComposer({
input,
attachments,
onInputChange,
onAddImages,
onRemoveAttachment,
onSend,
isConnected,
hasDefaultModel,
canSend,
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = isConnected && hasDefaultModel
@@ -35,6 +44,32 @@ export function ChatComposer({
return (
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
<div
key={`${attachment.url}-${index}`}
className="bg-background relative h-20 w-20 overflow-hidden rounded-xl border"
>
<img
src={attachment.url}
alt={attachment.filename || t("chat.uploadedImage")}
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => onRemoveAttachment(index)}
className="bg-background/85 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm transition hover:bg-white"
aria-label={t("chat.removeImage")}
title={t("chat.removeImage")}
>
<IconX className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<TextareaAutosize
value={input}
onChange={(e) => onInputChange(e.target.value)}
@@ -42,7 +77,7 @@ export function ChatComposer({
placeholder={t("chat.placeholder")}
disabled={!canInput}
className={cn(
"placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1}
@@ -50,13 +85,27 @@ export function ChatComposer({
/>
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">{/* action buttons */}</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
onClick={onAddImages}
disabled={!canInput}
aria-label={t("chat.attachImage")}
title={t("chat.attachImage")}
>
<IconPhotoPlus className="size-4" />
</Button>
</div>
<Button
type="button"
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!input.trim() || !canInput}
disabled={!canSend}
>
<IconArrowUp className="size-4" />
</Button>
+116 -5
View File
@@ -1,6 +1,7 @@
import { IconPlus } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react"
import { type ChangeEvent, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { AssistantMessage } from "@/components/chat/assistant-message"
import { ChatComposer } from "@/components/chat/chat-composer"
@@ -15,13 +16,42 @@ import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history"
import type { ChatAttachment } from "@/store/chat"
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)
})
}
export function ChatPage() {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const {
messages,
@@ -80,18 +110,84 @@ export function ChatPage() {
}, [messages, isTyping, isAtBottom])
const handleSend = () => {
if (!input.trim() || !canSend) return
if (sendMessage(input.trim())) {
if ((!input.trim() && attachments.length === 0) || !canSend) return
if (
sendMessage({
content: input,
attachments,
})
) {
setInput("")
setAttachments([])
}
}
const handleAddImages = () => {
if (!canSend) return
fileInputRef.current?.click()
}
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index))
}
const handleImageSelection = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? [])
event.target.value = ""
if (files.length === 0) {
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
}
if (file.size > MAX_IMAGE_SIZE_BYTES) {
toast.error(
t("chat.imageTooLarge", {
name: file.name,
size: MAX_IMAGE_SIZE_LABEL,
}),
)
continue
}
try {
nextAttachments.push({
type: "image",
filename: file.name,
url: await readFileAsDataUrl(file),
})
} catch {
toast.error(
t("chat.imageReadFailed", {
name: file.name,
}),
)
}
}
if (nextAttachments.length > 0) {
setAttachments(nextAttachments.slice(0, 1))
}
}
const canSubmit = canSend && (Boolean(input.trim()) || attachments.length > 0)
return (
<div className="bg-background/95 flex h-full flex-col">
<PageHeader
title={t("navigation.chat")}
className={`transition-shadow ${
hasScrolled ? "shadow-sm" : "shadow-none"
hasScrolled ? "shadow-xs" : "shadow-none"
}`}
titleExtra={
hasAvailableModels && (
@@ -154,7 +250,10 @@ export function ChatPage() {
timestamp={msg.timestamp}
/>
) : (
<UserMessage content={msg.content} />
<UserMessage
content={msg.content}
attachments={msg.attachments}
/>
)}
</div>
))}
@@ -163,12 +262,24 @@ export function ChatPage() {
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
className="hidden"
onChange={handleImageSelection}
/>
<ChatComposer
input={input}
attachments={attachments}
onInputChange={setInput}
onAddImages={handleAddImages}
onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend}
isConnected={isChatConnected}
hasDefaultModel={Boolean(defaultModelName)}
canSend={canSubmit}
/>
</div>
)
@@ -71,7 +71,7 @@ export function SessionHistoryMenu({
onClick={() => onSwitchSession(session.id)}
>
<span className="line-clamp-1 text-sm font-medium">
{session.title || session.preview}
{session.title}
</span>
<span className="text-muted-foreground text-xs">
{t("chat.messagesCount", {
@@ -1,13 +1,36 @@
import type { ChatAttachment } from "@/store/chat"
interface UserMessageProps {
content: string
attachments?: ChatAttachment[]
}
export function UserMessage({ content }: UserMessageProps) {
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
const hasText = content.trim().length > 0
const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image",
)
return (
<div className="flex w-full flex-col items-end gap-1.5">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed whitespace-pre-wrap text-white shadow-sm break-words">
{content}
</div>
{imageAttachments.length > 0 && (
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
{imageAttachments.map((attachment, index) => (
<img
key={`${attachment.url}-${index}`}
src={attachment.url}
alt={attachment.filename || "Uploaded image"}
className="max-h-72 max-w-full object-cover"
/>
))}
</div>
)}
{hasText && (
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed wrap-break-word whitespace-pre-wrap text-white shadow-sm">
{content}
</div>
)}
</div>
)
}