import { useEffect, useRef, useState } from "react"; import { Archive, ArchiveRestore, BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx"; import { Markdown } from "./Markdown.tsx"; import { cn } from "../lib/cn.ts"; import { useFeatures } from "../lib/features.ts"; import { useT } from "../lib/i18n.ts"; function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) { const { t } = useT(); const isUser = msg.role === "user"; return (
{isUser ? : }
{isUser ? : } {isUser ? t("chat.you") : "KisAssistant"}
{isUser ? (
{msg.content}
) : (
)}
); } export function Chat({ user, conv, messages, streaming, sending, models, model, onModelChange, onSend, onShare, onUnarchive, readOnly, sidebarCollapsed, onExpandSidebar, onOpenNav, attachments, attachError, ocrAvailable, onRemoveAttachment, onPickFile, }: { user: User; conv: Conversation | null; messages: ChatMessage[]; streaming: string; sending: boolean; models: DriverModel[]; model: string; onModelChange: (m: string) => void; onSend: (text: string) => void; onShare: () => void; /** Archived chats render read-only; the banner offers to unarchive. */ readOnly?: boolean; onUnarchive?: () => void; sidebarCollapsed: boolean; onExpandSidebar: () => void; onOpenNav: () => void; attachments: Attachment[]; attachError: string | null; ocrAvailable: boolean; onRemoveAttachment: (id: string) => void; onPickFile: (kind: "ocr" | "text", file: File) => void; }) { const [draft, setDraft] = useState(""); const [attachOpen, setAttachOpen] = useState(false); const { t } = useT(); const features = useFeatures(); const boxRef = useRef(null); const taRef = useRef(null); const imgRef = useRef(null); const txtRef = useRef(null); const modelGroups: PickerGroup[] = (() => { const byDriver = new Map(); for (const m of models) { const list = byDriver.get(m.driver) ?? []; list.push({ value: m.id, label: m.label, hint: m.id }); byDriver.set(m.driver, list); } return [...byDriver.entries()].map(([driver, options]) => ({ group: driver, options })); })(); useEffect(() => { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, streaming]); function autoGrow() { const ta = taRef.current; if (ta) { ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 200) + "px"; } } function submit(e?: React.FormEvent) { e?.preventDefault(); if (readOnly) return; const text = draft.trim(); if (!text || sending) return; setDraft(""); requestAnimationFrame(autoGrow); onSend(text); } return (
{sidebarCollapsed && ( )} {conv?.title || t("sidebar.newChat")} {conv?.topic && ( {conv.topic} )}
{conv && !readOnly && ( )}
{messages.length === 0 && !streaming && (

{t("chat.greet", { name: user.displayName.split(" ")[0]! })}

{t("chat.greetSub")}

)} {messages.filter((m) => m.role !== "system").map((m, i) => ( ))} {streaming !== "" && (
KisAssistant
)} {sending && streaming === "" && (
{t("chat.thinking")}
)}
{attachError && (

{attachError}

)} {attachments.length > 0 && (
{attachments.map((a) => ( {a.kind === "ocr" ? : } {a.name} ))}
)}
{attachOpen && ( <>
setAttachOpen(false)} />

{t("chat.attachNote")}

)}
{readOnly ? (

{t("archived.readonly")}

) : (
{features.attachments && ( )} { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} /> { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />