"use client" import * as React from "react" import { Send, Search, ExternalLink, Loader2, RotateCcw, Eye, ArrowRight, Maximize2, Brain, ChevronRight, ImagePlus, X, Clock, } from "lucide-react" import { tileUrl } from "@/lib/api" import { getHistory, addHistory, clearHistory } from "@/lib/history" import { motion, AnimatePresence } from "framer-motion" import Markdown from "react-markdown" import remarkGfm from "remark-gfm" import { Suspense } from "react" import { useRouter, useSearchParams } from "next/navigation" import { ModeToggle } from "@/components/ModeToggle" import { Lightbox } from "@/components/Lightbox" import type { Hit } from "@/lib/types" interface SearchResult { query: string hits: { score: number article_id: number tile_index: number chunk_index: number url: string tile_height: number }[] } interface TileView { article_id: number tile_index: number chunk_index: number } interface ChatMessage { id: string role: "user" | "assistant" content: string image?: string thinking?: string searches?: SearchResult[] searching?: string tiles?: TileView[] viewingTile?: boolean } const EXAMPLES = [ { q: "How many shots on target did Inter have in the 2010 Champions League final?", icon: "01" }, { q: "Which district in Nagaland has the RTO code NL-03?", icon: "02" }, { q: "Explain Van Gogh's The Starry Night", icon: "03" }, { q: "介绍一下兵马俑", icon: "04" }, ] function ChatPageInner() { const router = useRouter() const searchParams = useSearchParams() const [messages, setMessages] = React.useState([]) const [input, setInput] = React.useState("") const [image, setImage] = React.useState() const [isStreaming, setIsStreaming] = React.useState(false) const [lightboxHit, setLightboxHit] = React.useState(null) const [lightboxHits, setLightboxHits] = React.useState([]) const messagesEndRef = React.useRef(null) const inputRef = React.useRef(null) const fileInputRef = React.useRef(null) const abortRef = React.useRef(null) const handleSendRef = React.useRef<((text?: string, imageOverride?: string) => void) | null>(null) const didInitRef = React.useRef(false) function scrollToBottom() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) } React.useEffect(() => { scrollToBottom() }, [messages]) React.useEffect(() => { handleSendRef.current = handleSend }) // Auto-send query handed off from the Search page (Ask mode) React.useEffect(() => { if (didInitRef.current) return const q = searchParams.get("q") // The attached image (if any) is passed via sessionStorage, since a // base64 image can't fit in a URL query param. let pendingImage: string | undefined if (searchParams.get("img")) { try { pendingImage = sessionStorage.getItem("pixelrag:pending-image") || undefined sessionStorage.removeItem("pixelrag:pending-image") } catch { pendingImage = undefined } } if (q || pendingImage) { didInitRef.current = true router.replace("/chat", { scroll: false }) handleSendRef.current?.(q || "", pendingImage) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) async function handleSend(text?: string, imageOverride?: string) { const query = (text ?? input).trim() const img = imageOverride ?? image if ((!query && !img) || isStreaming) return if (query) addHistory(query, "ask") const id1 = crypto.randomUUID?.() ?? "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16) }) const id2 = crypto.randomUUID?.() ?? "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16) }) const userMsg: ChatMessage = { id: id1, role: "user", content: query, image: img } const assistantMsg: ChatMessage = { id: id2, role: "assistant", content: "", searches: [] } setMessages((prev) => [...prev, userMsg, assistantMsg]) setInput("") setImage(undefined) setIsStreaming(true) const allMessages = [ ...messages.filter((m) => m.content).map((m) => ({ role: m.role, content: m.content })), { role: "user" as const, content: query, ...(img ? { image: img } : {}) }, ] const abort = new AbortController() abortRef.current = abort try { const resp = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: allMessages }), signal: abort.signal, }) if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || `HTTP ${resp.status}`) } const reader = resp.body!.getReader() const decoder = new TextDecoder() let buffer = "" while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split("\n") buffer = lines.pop() || "" let eventType = "" for (const line of lines) { if (line.startsWith("event: ")) eventType = line.slice(7) else if (line.startsWith("data: ")) handleSSEEvent(assistantMsg.id, eventType, JSON.parse(line.slice(6))) } } } catch (err) { if ((err as Error).name !== "AbortError") { setMessages((prev) => prev.map((m) => m.id === assistantMsg.id ? { ...m, content: m.content || `Error: ${err}`, searching: undefined } : m)) } } finally { setIsStreaming(false); abortRef.current = null } } function handleSSEEvent(msgId: string, event: string, data: Record) { setMessages((prev) => prev.map((m) => { if (m.id !== msgId) return m switch (event) { case "thinking": return { ...m, thinking: (m.thinking || "") + (data.text as string) } case "text": return { ...m, content: m.content + (data.text as string) } case "searching": return { ...m, searching: data.query as string } case "search_results": return { ...m, searching: undefined, searches: [...(m.searches || []), { query: data.query as string, hits: data.hits as SearchResult["hits"] }] } case "viewing_tile": return { ...m, viewingTile: true, tiles: [...(m.tiles || []), { article_id: data.article_id as number, tile_index: data.tile_index as number, chunk_index: data.chunk_index as number }] } case "done": return { ...m, searching: undefined, viewingTile: false } case "error": return { ...m, content: m.content || `Error: ${data.message}`, searching: undefined } default: return m } })) } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend() } } function handleReset() { if (abortRef.current) abortRef.current.abort() setMessages([]); setIsStreaming(false); setInput(""); setImage(undefined); inputRef.current?.focus() } function handleTileClick(hit: Hit, allHits: Hit[]) { setLightboxHit(hit) setLightboxHits(allHits) } function handleFile(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => setImage(e.target?.result as string) reader.readAsDataURL(file) } function handlePaste(e: React.ClipboardEvent) { const file = Array.from(e.clipboardData.items) .find((it) => it.type.startsWith("image/")) ?.getAsFile() if (file) { e.preventDefault(); handleFile(file) } } function handleDrop(e: React.DragEvent) { e.preventDefault() const file = e.dataTransfer.files[0] if (file) handleFile(file) } const isEmpty = messages.length === 0 return (
{isEmpty ? ( router.push("/")} /> ) : (
{messages.map((msg) => ( {msg.role === "user" ? : } ))}
)}
{/* Input */}
{messages.length > 0 && ( )}
e.preventDefault()} > {image && (
{/* eslint-disable-next-line @next/next/no-img-element */} attachment
)}
{ const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = "" }} />