import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { api, ApiError, type ReportReason } from "../lib/api.ts";
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { UserMessageContent } from "./MessageContent.tsx";
import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) {
const { t } = useT();
const isUser = msg.role === "user";
const [copied, setCopied] = useState(false);
function copy() {
navigator.clipboard.writeText(msg.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}).catch(() => {});
}
function searchWeb() {
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
}
return (
{isUser
?
:
}
{isUser ? : }
{isUser ? t("chat.you") : "KisAssistant"}
{isUser ? (
) : (
)}
{!isUser && (onReport || (isFailed && onRetry)) && (
{onReport && (
<>
{copied ? : }
onReport(index)}>
>
)}
{isFailed && onRetry && (
)}
)}
);
}
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
open: boolean;
onClose: () => void;
conversationId: string | null;
messageIndex: number;
content: string;
prompt: string;
model: string;
}) {
const { t } = useT();
const [reason, setReason] = useState("");
const [details, setDetails] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [sent, setSent] = useState(false);
const [duplicate, setDuplicate] = useState(false);
useEffect(() => {
if (open) {
setReason("");
setDetails("");
setError("");
setSent(false);
setDuplicate(false);
}
}, [open, messageIndex]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!reason || busy) return;
setBusy(true);
setError("");
try {
await api.createReport({
conversationId: conversationId ?? undefined,
messageIndex,
reason: reason as ReportReason,
details: details.trim(),
content,
prompt,
model,
});
setSent(true);
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setDuplicate(true);
} else {
setError(err instanceof Error ? err.message : t("report.failed"));
}
} finally {
setBusy(false);
}
}
return (
{sent || duplicate ? (
{duplicate ? t("report.alreadyReported") : t("report.sent")}
) : (
)}
);
}
export function Chat({
user,
conv,
messages,
streaming,
sending,
models,
model,
onModelChange,
onSend,
onShare,
onUnarchive,
readOnly,
sidebarCollapsed,
onExpandSidebar,
onOpenNav,
attachments,
attachError,
ocrAvailable,
onRemoveAttachment,
onPickFile,
failedIndex,
retryCount,
maxRetries,
onRetry,
}: {
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;
/** Trailing model-error bubble index (retry arrow), if any. */
failedIndex: number | null;
retryCount: number;
maxRetries: number;
onRetry?: () => void;
}) {
const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false);
const [reportIndex, setReportIndex] = useState(null);
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 group = m.group ?? m.driver;
const list = byDriver.get(group) ?? [];
list.push({ value: m.id, label: m.label, hint: m.id });
byDriver.set(group, list);
}
return [...byDriver.entries()].map(([group, options]) => ({ group, 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 !== "" && (
)}
{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")}
) : (
)}
{t("chat.wiki")}
{(() => {
const shown = messages.filter((m) => m.role !== "system");
const target = reportIndex !== null ? shown[reportIndex] : undefined;
const prev = reportIndex !== null && reportIndex > 0 ? shown[reportIndex - 1] : undefined;
return (
setReportIndex(null)}
conversationId={conv?.id ?? null}
messageIndex={reportIndex ?? 0}
content={target?.content ?? ""}
prompt={prev && prev.role === "user" ? prev.content : ""}
model={conv?.model || model}
/>
);
})()}
);
}