mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): support image messages in pico chat (#2299)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user