656 lines
31 KiB
TypeScript
656 lines
31 KiB
TypeScript
"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<ChatMessage[]>([])
|
|
const [input, setInput] = React.useState("")
|
|
const [image, setImage] = React.useState<string | undefined>()
|
|
const [isStreaming, setIsStreaming] = React.useState(false)
|
|
const [lightboxHit, setLightboxHit] = React.useState<Hit | null>(null)
|
|
const [lightboxHits, setLightboxHits] = React.useState<Hit[]>([])
|
|
const messagesEndRef = React.useRef<HTMLDivElement>(null)
|
|
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
const abortRef = React.useRef<AbortController | null>(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<string, unknown>) {
|
|
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 (
|
|
<div className="chat-page flex h-[calc(100vh-3.5rem)] flex-col">
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isEmpty ? (
|
|
<EmptyState onExample={handleSend} onSearchMode={() => router.push("/")} />
|
|
) : (
|
|
<div className="mx-auto max-w-[720px] px-5 py-8">
|
|
<AnimatePresence initial={false}>
|
|
{messages.map((msg) => (
|
|
<motion.div key={msg.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
|
|
{msg.role === "user" ? <UserMessage content={msg.content} image={msg.image} /> : <AssistantMessage message={msg} isStreaming={isStreaming && msg.id === messages[messages.length - 1]?.id} onTileClick={handleTileClick} />}
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="chat-input-area border-t border-[var(--chat-border)]">
|
|
<div className="mx-auto flex max-w-[720px] items-end gap-2 px-5 py-3.5">
|
|
{messages.length > 0 && (
|
|
<button onClick={handleReset} className="mb-1.5 rounded-lg p-2 text-[var(--chat-muted)] transition-colors hover:text-[var(--chat-fg)]" title="New conversation">
|
|
<RotateCcw className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
<div
|
|
className="chat-input flex min-h-[50px] flex-1 flex-col rounded-2xl border border-[var(--chat-border)] bg-[var(--chat-input-bg)] px-4 py-1.5 transition-all focus-within:border-[var(--chat-accent)] focus-within:shadow-[0_0_0_3px_var(--chat-accent-glow)]"
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
>
|
|
{image && (
|
|
<div className="relative mt-1.5 w-fit">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={image} alt="attachment" className="max-h-24 rounded-lg border border-[var(--chat-border)] object-contain" />
|
|
<button
|
|
onClick={() => setImage(undefined)}
|
|
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--chat-fg)] text-[var(--chat-bg)] shadow"
|
|
title="Remove image"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="flex items-end">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = "" }}
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isStreaming}
|
|
className="mb-1.5 mr-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl text-[var(--chat-muted)] transition-colors hover:bg-[var(--chat-card-hover)] hover:text-[var(--chat-fg)] disabled:opacity-30"
|
|
title="Attach image"
|
|
>
|
|
<ImagePlus className="h-4 w-4" />
|
|
</button>
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => { setInput(e.target.value); e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px" }}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder="Ask anything, or paste an image…"
|
|
rows={1}
|
|
className="flex-1 resize-none bg-transparent py-2 text-[14px] leading-relaxed text-[var(--chat-fg)] outline-none placeholder:text-[var(--chat-muted)]"
|
|
disabled={isStreaming}
|
|
/>
|
|
<button
|
|
onClick={() => handleSend()}
|
|
disabled={(!input.trim() && !image) || isStreaming}
|
|
className="mb-1.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--chat-accent)] text-white transition-all hover:brightness-110 disabled:opacity-20"
|
|
>
|
|
{isStreaming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lightbox */}
|
|
{lightboxHit && (
|
|
<Lightbox
|
|
key={lightboxHit.vector_id}
|
|
hit={lightboxHit}
|
|
allHits={lightboxHits}
|
|
onClose={() => setLightboxHit(null)}
|
|
onNavigate={(hit) => setLightboxHit(hit)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ChatPage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<ChatPageInner />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
/* ─── Empty State ─── */
|
|
|
|
function EmptyState({ onExample, onSearchMode }: { onExample: (q: string) => void; onSearchMode: () => void }) {
|
|
const [recent, setRecent] = React.useState<string[]>([])
|
|
React.useEffect(() => setRecent(getHistory("ask")), [])
|
|
return (
|
|
<div className="relative flex h-full flex-col items-center justify-center px-6">
|
|
{/* Background mesh */}
|
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
<div className="absolute left-1/4 top-1/4 h-[400px] w-[400px] rounded-full bg-[var(--chat-accent)] opacity-[0.03] blur-[120px]" />
|
|
<div className="absolute bottom-1/4 right-1/4 h-[300px] w-[300px] rounded-full bg-[var(--chat-warm)] opacity-[0.04] blur-[100px]" />
|
|
{/* Grid overlay */}
|
|
<div className="absolute inset-0 opacity-[0.015]" style={{ backgroundImage: "linear-gradient(var(--chat-fg) 1px, transparent 1px), linear-gradient(90deg, var(--chat-fg) 1px, transparent 1px)", backgroundSize: "60px 60px" }} />
|
|
</div>
|
|
|
|
<div className="relative z-10 text-center">
|
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
|
|
<div className="mb-8 inline-flex items-center gap-2 rounded-full border border-[var(--chat-border)] px-5 py-2 text-[11px] font-medium tracking-[0.15em] text-[var(--chat-muted)]" style={{ fontFamily: "var(--font-mono)" }}>
|
|
<span className="relative flex h-2 w-2"><span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50" /><span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" /></span>
|
|
PIXELRAG AGENT
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.h1 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.1 }} className="font-display text-5xl font-light leading-[1.15] tracking-tight text-[var(--chat-fg)] sm:text-6xl">
|
|
Search Wikipedia
|
|
<br />
|
|
<span className="bg-gradient-to-r from-[var(--chat-accent)] to-[var(--chat-warm)] bg-clip-text text-transparent">
|
|
visually.
|
|
</span>
|
|
</motion.h1>
|
|
|
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="mx-auto mt-5 max-w-md text-[14px] leading-relaxed text-[var(--chat-secondary)]">
|
|
Ask a question. I'll search 28.1M Wikipedia screenshot tiles,
|
|
read the visual results, and synthesize an answer.
|
|
</motion.p>
|
|
</div>
|
|
|
|
{/* Mode toggle */}
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.35 }} className="relative z-10 mt-8">
|
|
<ModeToggle mode="ask" onChange={(m) => { if (m === "search") onSearchMode() }} />
|
|
</motion.div>
|
|
|
|
{/* Examples */}
|
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4 }} className="relative z-10 mt-8 grid w-full max-w-lg grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{EXAMPLES.map(({ q, icon }) => (
|
|
<button
|
|
key={q}
|
|
onClick={() => onExample(q)}
|
|
className="group flex items-start gap-3 rounded-xl border border-[var(--chat-border)] bg-[var(--chat-card)] p-4 text-left transition-all hover:border-[var(--chat-accent-dim)] hover:bg-[var(--chat-card-hover)]"
|
|
>
|
|
<span className="mt-0.5 shrink-0 font-mono text-[11px] text-[var(--chat-accent)] opacity-50 group-hover:opacity-100">{icon}</span>
|
|
<span className="text-[13px] leading-snug text-[var(--chat-secondary)] group-hover:text-[var(--chat-fg)]">
|
|
{q}
|
|
</span>
|
|
<ArrowRight className="ml-auto mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--chat-muted)] opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-60" />
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
|
|
{/* Your recent questions (saved locally on this device) */}
|
|
{recent.length > 0 && (
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }} className="relative z-10 mt-7 w-full max-w-lg">
|
|
<div className="mb-2.5 flex items-center justify-between px-1">
|
|
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.12em] text-[var(--chat-muted)]">
|
|
<Clock className="h-3 w-3" /> Recent
|
|
</span>
|
|
<button
|
|
onClick={() => { clearHistory("ask"); setRecent([]) }}
|
|
className="text-[11px] text-[var(--chat-muted)] transition-colors hover:text-[var(--chat-secondary)]"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{recent.map((q) => (
|
|
<button
|
|
key={q}
|
|
onClick={() => onExample(q)}
|
|
title={q}
|
|
className="max-w-[15rem] truncate rounded-full border border-[var(--chat-border)] bg-[var(--chat-card)] px-3 py-1.5 text-[12px] text-[var(--chat-secondary)] transition-all hover:border-[var(--chat-accent-dim)] hover:text-[var(--chat-fg)]"
|
|
>
|
|
{q}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ─── Messages ─── */
|
|
|
|
function UserMessage({ content, image }: { content: string; image?: string }) {
|
|
return (
|
|
<div className="mb-5 flex flex-col items-end gap-1.5">
|
|
{image && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={image} alt="attached" className="max-h-48 max-w-[80%] rounded-2xl rounded-br-md border border-[var(--chat-border)] object-contain" />
|
|
)}
|
|
{content && (
|
|
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-[var(--chat-accent)] px-4 py-2.5 text-[14px] leading-relaxed text-white shadow-lg shadow-[var(--chat-accent-glow)]">
|
|
{content}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AssistantMessage({ message, isStreaming, onTileClick }: { message: ChatMessage; isStreaming: boolean; onTileClick: (hit: Hit, allHits: Hit[]) => void }) {
|
|
return (
|
|
<div className="mb-8">
|
|
{message.thinking && (
|
|
<ThinkingTrace text={message.thinking} active={isStreaming && !message.content} />
|
|
)}
|
|
{message.searches?.map((s, i) => <SearchCard key={i} result={s} onTileClick={onTileClick} />)}
|
|
{message.tiles && message.tiles.length > 0 && <TileGallery tiles={message.tiles} loading={message.viewingTile} onTileClick={onTileClick} />}
|
|
|
|
{message.searching && (
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mb-4 flex items-center gap-2.5 rounded-lg border border-[var(--chat-border)] bg-[var(--chat-card)] px-3.5 py-2.5">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--chat-accent)]" />
|
|
<span className="text-[12px] text-[var(--chat-secondary)]">Searching <span className="font-medium text-[var(--chat-fg)]">“{message.searching}”</span></span>
|
|
</motion.div>
|
|
)}
|
|
|
|
{message.content && (
|
|
<div className="chat-prose prose prose-sm max-w-none text-[14px] leading-[1.8] text-[var(--chat-secondary)] prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-headings:text-[var(--chat-fg)] prose-h2:mt-6 prose-h2:text-lg prose-h3:mt-4 prose-h3:text-[15px] prose-p:text-[var(--chat-secondary)] prose-a:text-[var(--chat-accent)] prose-a:no-underline hover:prose-a:underline prose-strong:font-semibold prose-strong:text-[var(--chat-fg)] prose-code:rounded-md prose-code:bg-[var(--chat-card)] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[12px] prose-code:text-[var(--chat-secondary)] prose-code:before:content-none prose-code:after:content-none prose-pre:border prose-pre:border-[var(--chat-border)] prose-pre:bg-[var(--chat-card)] prose-pre:rounded-xl prose-li:marker:text-[var(--chat-muted)] prose-hr:border-[var(--chat-border)] prose-blockquote:border-l-[var(--chat-accent-dim)] prose-blockquote:text-[var(--chat-muted)] prose-th:font-mono prose-th:text-[11px] prose-th:font-medium prose-th:uppercase prose-th:tracking-wider prose-th:text-[var(--chat-muted)] prose-td:text-[var(--chat-secondary)] prose-table:text-[13px]">
|
|
<Markdown remarkPlugins={[remarkGfm]} components={{ a: ({ href, children }) => (<a href={href} target="_blank" rel="noopener noreferrer">{href?.includes("wikipedia.org") ? decodeURIComponent(href.split("/wiki/").pop() || String(children)).replace(/_/g, " ") : children}</a>) }}>
|
|
{message.content}
|
|
</Markdown>
|
|
</div>
|
|
)}
|
|
|
|
{isStreaming && !message.content && !message.searching && (
|
|
<div className="flex items-center gap-2.5 py-3">
|
|
<div className="flex gap-1.5">
|
|
{[0, 1, 2].map((i) => (<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-[var(--chat-accent)]" animate={{ opacity: [0.2, 0.8, 0.2] }} transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.15 }} />))}
|
|
</div>
|
|
{message.tiles && message.tiles.length > 0 && !message.viewingTile && (
|
|
<span className="text-[12px] text-[var(--chat-muted)]">Reading the screenshots and writing your answer…</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ─── Search Card ─── */
|
|
|
|
function SearchCard({ result, onTileClick }: { result: SearchResult; onTileClick: (hit: Hit, allHits: Hit[]) => void }) {
|
|
const [expanded, setExpanded] = React.useState(false)
|
|
const shown = expanded ? result.hits : result.hits.slice(0, 5)
|
|
const hits = result.hits.map((h) => ({
|
|
score: h.score,
|
|
article_id: h.article_id,
|
|
tile_index: h.tile_index,
|
|
chunk_index: h.chunk_index,
|
|
url: h.url,
|
|
tile_height: h.tile_height,
|
|
vector_id: h.article_id * 10000 + h.tile_index * 100 + h.chunk_index,
|
|
y_offset: 0,
|
|
path: "",
|
|
} as Hit))
|
|
|
|
return (
|
|
<motion.div initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} className="mb-4 overflow-hidden rounded-xl border border-[var(--chat-border)] bg-[var(--chat-card)]">
|
|
<button onClick={() => setExpanded(!expanded)} className="flex w-full items-center gap-2 border-b border-[var(--chat-border)] px-4 py-2.5 text-left">
|
|
<Search className="h-3.5 w-3.5 text-[var(--chat-accent)]" />
|
|
<span className="text-[12px] text-[var(--chat-secondary)]">“{result.query}”</span>
|
|
<span className="ml-auto rounded-full bg-[var(--chat-accent)] bg-opacity-10 px-2 py-0.5 font-mono text-[10px] tabular-nums text-[var(--chat-accent)]">{result.hits.length}</span>
|
|
</button>
|
|
<div className="flex gap-1 overflow-x-auto p-2 scrollbar-thin">
|
|
{shown.map((hit, i) => {
|
|
const slug = hit.url.includes("/wiki/") ? hit.url.split("/wiki/").pop() : hit.url
|
|
const title = decodeURIComponent(slug || "").replace(/_/g, " ")
|
|
return (
|
|
<button key={i} onClick={() => onTileClick(hits[i], hits)} className="group relative shrink-0 overflow-hidden rounded-lg transition-transform hover:scale-[1.02]" title="Click to view full-size">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={tileUrl(hit)} alt={title} className="h-24 w-36 object-cover object-top" loading="lazy" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
|
<div className="absolute inset-x-0 bottom-0 translate-y-1 px-2 pb-1.5 opacity-0 transition-all group-hover:translate-y-0 group-hover:opacity-100">
|
|
<span className="flex items-center gap-1 text-[10px] font-medium text-white"><span className="truncate">{title}</span><ExternalLink className="h-2.5 w-2.5 shrink-0 opacity-60" /></span>
|
|
</div>
|
|
<div className="absolute right-1 top-1 rounded bg-black/60 px-1 py-0.5 font-mono text-[8px] tabular-nums text-white/60 backdrop-blur-sm">{hit.score.toFixed(2)}</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{!expanded && result.hits.length > 5 && (
|
|
<button onClick={() => setExpanded(true)} className="w-full border-t border-[var(--chat-border)] py-2 text-center text-[11px] text-[var(--chat-muted)] transition-colors hover:text-[var(--chat-accent)]">
|
|
Show {result.hits.length - 5} more results
|
|
</button>
|
|
)}
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
/* ─── Thinking Trace ─── */
|
|
|
|
function ThinkingTrace({ text, active }: { text: string; active: boolean }) {
|
|
// Auto-expanded while reasoning streams; user can collapse/expand anytime.
|
|
const [open, setOpen] = React.useState(true)
|
|
const [userToggled, setUserToggled] = React.useState(false)
|
|
const bodyRef = React.useRef<HTMLDivElement>(null)
|
|
|
|
// Once the answer starts (active=false), auto-collapse unless user touched it.
|
|
React.useEffect(() => {
|
|
if (!active && !userToggled) setOpen(false)
|
|
}, [active, userToggled])
|
|
|
|
// Keep the streaming trace scrolled to the latest line.
|
|
React.useEffect(() => {
|
|
if (open && active && bodyRef.current) {
|
|
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
|
|
}
|
|
}, [text, open, active])
|
|
|
|
return (
|
|
<div className="mb-3 overflow-hidden rounded-xl border border-[var(--chat-border)] bg-[var(--chat-card)]">
|
|
<button
|
|
onClick={() => { setOpen((o) => !o); setUserToggled(true) }}
|
|
className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left"
|
|
>
|
|
<Brain className={`h-3.5 w-3.5 ${active ? "text-[var(--chat-accent)]" : "text-[var(--chat-muted)]"}`} />
|
|
<span className="text-[12px] font-medium text-[var(--chat-secondary)]">
|
|
{active ? "Thinking…" : "Thought process"}
|
|
</span>
|
|
{active && (
|
|
<span className="flex gap-1">
|
|
{[0, 1, 2].map((i) => (
|
|
<motion.span key={i} className="h-1 w-1 rounded-full bg-[var(--chat-accent)]" animate={{ opacity: [0.2, 0.8, 0.2] }} transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.15 }} />
|
|
))}
|
|
</span>
|
|
)}
|
|
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-[var(--chat-muted)] transition-transform ${open ? "rotate-90" : ""}`} />
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{open && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div
|
|
ref={bodyRef}
|
|
className="max-h-56 overflow-y-auto border-t border-[var(--chat-border)] px-3.5 py-3 scrollbar-thin"
|
|
>
|
|
<p className="whitespace-pre-wrap font-mono text-[11.5px] leading-relaxed text-[var(--chat-muted)]">
|
|
{text}
|
|
{active && <span className="ml-0.5 inline-block h-3 w-1.5 animate-pulse bg-[var(--chat-accent)] align-middle" />}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ─── Tile Gallery ─── */
|
|
|
|
// Vivid, varied phrasing for "Claude is reading the screenshots with its eyes".
|
|
const READING_VERBS = [
|
|
"Looking at",
|
|
"Reading",
|
|
"Studying",
|
|
"Examining",
|
|
"Poring over",
|
|
"Peering at",
|
|
"Taking in",
|
|
"Scanning",
|
|
]
|
|
|
|
function TileGallery({ tiles, loading, onTileClick }: { tiles: TileView[]; loading?: boolean; onTileClick: (hit: Hit, allHits: Hit[]) => void }) {
|
|
// Belt-and-braces: if a tile image 404s anyway, drop it from the gallery
|
|
// instead of rendering a broken image.
|
|
const [failed, setFailed] = React.useState<Set<string>>(new Set())
|
|
const key = (t: TileView) => `${t.article_id}/${t.tile_index}/${t.chunk_index}`
|
|
const shown = tiles.filter((t) => !failed.has(key(t)))
|
|
const n = shown.length
|
|
// Seed by the first tile so the verb stays put as more tiles stream in,
|
|
// but differs from answer to answer.
|
|
const verb = READING_VERBS[(tiles[0]?.article_id ?? n) % READING_VERBS.length]
|
|
|
|
function tileToHit(t: TileView): Hit {
|
|
return {
|
|
score: 0,
|
|
article_id: t.article_id,
|
|
tile_index: t.tile_index,
|
|
chunk_index: t.chunk_index,
|
|
url: `https://en.wikipedia.org/?curid=${t.article_id}`,
|
|
tile_height: 0,
|
|
vector_id: t.article_id * 10000 + t.tile_index * 100 + t.chunk_index,
|
|
y_offset: 0,
|
|
path: "",
|
|
}
|
|
}
|
|
|
|
const hits = shown.map(tileToHit)
|
|
|
|
return (
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mb-5">
|
|
<div className="mb-2.5 flex items-center gap-2">
|
|
<Eye className="h-3.5 w-3.5 text-[var(--chat-warm)]" />
|
|
<span className="text-[12px] font-medium text-[var(--chat-secondary)]">
|
|
{verb} {n} screenshot{n > 1 ? "s" : ""}
|
|
</span>
|
|
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--chat-warm)]" />}
|
|
</div>
|
|
<div className="flex gap-2.5 overflow-x-auto pb-1 scrollbar-thin">
|
|
{shown.map((t, i) => (
|
|
<motion.button key={i} onClick={() => onTileClick(hits[i], hits)}
|
|
title="Click to view full-size"
|
|
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: i * 0.08 }}
|
|
className="group/tile relative block shrink-0 overflow-hidden rounded-xl border-2 border-[var(--chat-warm)] border-opacity-20 shadow-lg shadow-[var(--chat-warm)]/5 text-left">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={tileUrl(t)} alt={`Tile ${t.article_id}/${t.tile_index}/${t.chunk_index}`} className="h-48 w-80 object-cover object-top transition-transform duration-300 group-hover/tile:scale-[1.02]" loading="lazy" onError={() => setFailed((prev) => new Set(prev).add(key(t)))} />
|
|
<div className="pointer-events-none absolute right-2 top-2 flex items-center gap-1 rounded-md bg-black/55 px-2 py-1 text-[10px] font-medium text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/tile:opacity-100">
|
|
<Maximize2 className="h-2.5 w-2.5" /> Open
|
|
</div>
|
|
<div className="flex items-center gap-2 bg-[var(--chat-card)] px-3 py-1.5">
|
|
<span className="relative flex h-1.5 w-1.5"><span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--chat-warm)] opacity-40" /><span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[var(--chat-warm)]" /></span>
|
|
<span className="font-mono text-[10px] tabular-nums text-[var(--chat-muted)]">{t.article_id}:{t.tile_index}:{t.chunk_index}</span>
|
|
</div>
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|