KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module

This commit is contained in:
k
2026-09-21 11:00:32 +02:00
commit 739c14324a
47 changed files with 8263 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts";
import type { ChatMessage, Conversation, DriverModel, User } from "./lib/types.ts";
import { Login } from "./components/Login.tsx";
import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx";
import { ConvEditDialog, SettingsModal, ShareModal } from "./components/dialogs.tsx";
import { AdminPanel } from "./components/AdminPanel.tsx";
import { SharePage } from "./components/SharePage.tsx";
import { ConfirmDialog } from "./components/ui.tsx";
function shareIdFromPath(): string | null {
const m = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/);
return m ? m[1]! : null;
}
// Module-level: the path never changes without a full reload, so hook order stays stable.
const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null;
function applyTheme(theme: string) {
const root = document.documentElement;
const dark =
theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
}
export function App() {
const [user, setUser] = useState<User | null>(null);
const [checking, setChecking] = useState(!SHARE_ID && !!getToken());
const [models, setModels] = useState<DriverModel[]>([]);
const [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streaming, setStreaming] = useState("");
const [sending, setSending] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [editConv, setEditConv] = useState<Conversation | null>(null);
const [shareConv, setShareConv] = useState<Conversation | null>(null);
const [deleteConv, setDeleteConv] = useState<Conversation | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [adminOpen, setAdminOpen] = useState(false);
// Public share route: no auth needed. SHARE_ID is constant for the page lifetime.
if (SHARE_ID) {
return <SharePage publicId={SHARE_ID} />;
}
const refreshConvs = useCallback(async (select?: string) => {
try {
const res = await api.convs();
setConvs(res.conversations);
if (select) setActiveId(select);
else if (res.conversations.length > 0) {
setActiveId((cur) => (cur && res.conversations.some((c) => c.id === cur) ? cur : res.conversations[0]!.id));
}
} catch {
// ignore (session errors reload the page via api layer)
}
}, []);
// Session restore
useEffect(() => {
if (!getToken()) {
setChecking(false);
return;
}
api.verify()
.then(async (res) => {
setUser(res.user);
applyTheme(res.user.theme);
const [m, c] = await Promise.all([api.models().catch(() => ({ models: [] })), api.convs().catch(() => ({ conversations: [] }))]);
setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id);
setConvs(c.conversations);
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
})
.catch(() => {})
.finally(() => setChecking(false));
}, []);
// Load messages for active conversation
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
api.getConv(activeId).then((res) => setMessages(res.messages)).catch(() => setMessages([]));
}, [activeId]);
// Follow OS theme when account theme is auto
useEffect(() => {
if (user?.theme !== "auto") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const h = () => applyTheme("auto");
mq.addEventListener("change", h);
return () => mq.removeEventListener("change", h);
}, [user?.theme]);
function handleLogin(u: User) {
setUser(u);
applyTheme(u.theme);
api.models().then((m) => {
setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id);
}).catch(() => {});
refreshConvs();
}
function logout() {
api.logout().finally(() => {});
clearToken();
setUser(null);
setConvs([]);
setMessages([]);
setActiveId(null);
}
async function newChat() {
try {
const res = await api.createConv({ model });
await refreshConvs(res.conversation.id);
} catch {
// fall back to local-only pending state
}
}
async function send(text: string) {
if (sending || !model) return;
let convId = activeId;
if (!convId) {
try {
const res = await api.createConv({ model });
convId = res.conversation.id;
await refreshConvs(convId);
} catch (err) {
return;
}
}
const userMsg: ChatMessage = { role: "user", content: text };
const history = [...messages, userMsg];
setMessages(history);
setSending(true);
setStreaming("");
let full = "";
try {
await api.chatStream({ model, messages: history, conversationId: convId! }, (t) => {
full += t;
setStreaming(full);
});
setMessages([...history, { role: "assistant", content: full }]);
setStreaming("");
await refreshConvs();
} catch {
setMessages([...history, { role: "assistant", content: "**Error:** the model did not respond. Try again." }]);
setStreaming("");
} finally {
setSending(false);
}
}
async function removeConv(c: Conversation) {
try {
await api.deleteConv(c.id);
if (activeId === c.id) {
setActiveId(null);
setMessages([]);
}
await refreshConvs();
} catch {
// ignore
}
}
if (checking) {
return <div className="grid min-h-full place-items-center text-sm opacity-60">Loading…</div>;
}
if (!user) {
return <Login onLogin={handleLogin} />;
}
const active = convs.find((c) => c.id === activeId) ?? null;
return (
<div className="flex h-full">
<Sidebar
user={user}
convs={convs}
activeId={activeId}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(true)}
onNew={newChat}
onSelect={setActiveId}
onRename={setEditConv}
onTopic={setEditConv}
onShare={setShareConv}
onDelete={setDeleteConv}
onOpenSettings={() => setSettingsOpen(true)}
onOpenAdmin={() => setAdminOpen(true)}
onLogout={logout}
/>
<Chat
user={user}
conv={active}
messages={messages}
streaming={streaming}
sending={sending}
models={models}
model={model}
onModelChange={setModel}
onSend={send}
onShare={() => active && setShareConv(active)}
sidebarCollapsed={sidebarCollapsed}
onExpandSidebar={() => setSidebarCollapsed(false)}
/>
<ConvEditDialog
conv={editConv}
onClose={() => setEditConv(null)}
onSaved={(c) => {
setConvs((prev) => prev.map((x) => (x.id === c.id ? c : x)));
}}
/>
<ShareModal conv={shareConv} onClose={() => setShareConv(null)} />
<ConfirmDialog
open={deleteConv !== null}
onClose={() => setDeleteConv(null)}
title="Delete conversation?"
message={`"${deleteConv?.title}" and all its messages will be permanently removed.`}
onConfirm={() => deleteConv && removeConv(deleteConv)}
/>
<SettingsModal
user={settingsOpen ? user : null}
onClose={() => setSettingsOpen(false)}
onSaved={(u) => {
setUser(u);
applyTheme(u.theme);
}}
/>
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} />
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useState } from "react";
import { KeyRound, Pencil, RefreshCw, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import type { User } from "../lib/types.ts";
import { Avatar, Button, ConfirmDialog, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
const [users, setUsers] = useState<User[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null);
// create form
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
async function refresh() {
setBusy(true);
setError("");
try {
const res = await api.adminList();
setUsers(res.users);
} catch (err) {
setError(err instanceof Error ? err.message : "Load failed");
} finally {
setBusy(false);
}
}
useEffect(() => {
if (open) {
refresh();
setShowCreate(false);
setFreshKey(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open ]);
async function create(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined });
setFreshKey({ username: res.user.username, key: res.key });
setUsername("");
setDisplayName("");
setShowCreate(false);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Create failed");
}
}
async function regenerate(u: User) {
setError("");
try {
const res = await api.adminRegenerate(u.id);
setFreshKey({ username: u.username, key: res.key });
} catch (err) {
setError(err instanceof Error ? err.message : "Regenerate failed");
}
}
async function remove(u: User) {
try {
await api.adminDelete(u.id);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
}
}
return (
<>
<Modal open={open} onClose={onClose} title="Accounts" icon={Users} wide>
<p className="mb-4 text-sm opacity-70">
No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.
</p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
{freshKey && (
<div className="mb-4 rounded-2xl border border-emerald-500/40 bg-emerald-50 p-4 dark:bg-emerald-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> Key for @{freshKey.username} — shown once, copy it now:</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey.key}</code>
<CopyButton text={freshKey.key} />
</div>
</div>
)}
{!showCreate ? (
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)} className="mb-3">
<UserPlus size={15} /> New account
</Button>
) : (
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-40 flex-1">
<Field label="Username"><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
</div>
<div className="min-w-40 flex-1">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
</div>
<Button size="sm" type="submit">Create</Button>
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
</form>
)}
{busy ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading…</p>
) : (
<ul className="space-y-2">
{users.map((u) => (
<li key={u.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
<Avatar name={u.displayName} url={u.avatarUrl} size={36} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p>
<p className="text-xs opacity-50">theme: {u.theme} · since {new Date(u.createdAt).toLocaleDateString()}</p>
</div>
{!u.isAdmin && (
<div className="flex shrink-0 gap-1">
<button title="Edit profile" onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title="Regenerate key (revokes old sessions)" onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title="Delete account" onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
</div>
)}
</li>
))}
</ul>
)}
</Modal>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={`Delete @${deleteTarget?.username}?`}
message="This permanently removes the account, all its conversations and its shares."
onConfirm={() => deleteTarget && remove(deleteTarget)}
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
</>
);
}
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [theme, setTheme] = useState("auto");
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
setTheme(user.theme);
setError("");
}
}, [user]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!user) return;
try {
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme });
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
}
}
return (
<Modal open={user !== null} onClose={onClose} title={`Edit @${user?.username}`} icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
<Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
<Field label="Theme">
<Picker
ariaLabel="Theme"
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
<Button size="sm" type="submit">Save</Button>
</div>
</form>
</Modal>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useRef, useState } from "react";
import { Bot, Cpu, PanelLeftOpen, SendHorizontal, Share2, Sparkles, User as UserIcon } from "lucide-react";
import type { ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { Avatar, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts";
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
const isUser = msg.role === "user";
return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
<Avatar name={authorName} url={isUser ? authorAvatar : undefined} size={32} />
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
{isUser ? "You" : "KisAssistant"}
</div>
{isUser ? (
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-emerald-600 px-4 py-2.5 text-[15px] text-white dark:bg-emerald-500 dark:text-zinc-950">
{msg.content}
</div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<Markdown text={msg.content} />
</div>
)}
</div>
</div>
);
}
export function Chat({
user,
conv,
messages,
streaming,
sending,
models,
model,
onModelChange,
onSend,
onShare,
sidebarCollapsed,
onExpandSidebar,
}: {
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;
sidebarCollapsed: boolean;
onExpandSidebar: () => void;
}) {
const [draft, setDraft] = useState("");
const boxRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const modelGroups: PickerGroup[] = (() => {
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
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();
const text = draft.trim();
if (!text || sending) return;
setDraft("");
requestAnimationFrame(autoGrow);
onSend(text);
}
return (
<div className="flex h-full min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
{sidebarCollapsed && (
<IconButton title="Expand sidebar" onClick={onExpandSidebar}>
<PanelLeftOpen size={18} />
</IconButton>
)}
<span className="hidden items-center gap-2 font-semibold sm:flex">
<Sparkles size={16} className="text-emerald-600 dark:text-emerald-400" />
{conv?.title || "New chat"}
</span>
{conv?.topic && (
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
)}
<div className="ml-auto flex items-center gap-2">
<Picker
ariaLabel="Model"
icon={Cpu}
value={model}
onChange={onModelChange}
groups={modelGroups}
placeholder={models.length === 0 ? "No models" : "Select model…"}
disabled={models.length === 0}
/>
{conv && (
<IconButton title="Share publicly" onClick={onShare}>
<Share2 size={17} />
</IconButton>
)}
</div>
</header>
<div ref={boxRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-6 sm:px-8">
<div className="mx-auto w-full max-w-3xl space-y-5">
{messages.length === 0 && !streaming && (
<div className="py-16 text-center">
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-3xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
<Sparkles size={24} />
</span>
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
</div>
)}
{messages.filter((m) => m.role !== "system").map((m, i) => (
<MessageBubble key={i} msg={m} authorAvatar={user.avatarUrl} authorName={user.displayName} />
))}
{streaming !== "" && (
<div className="flex gap-3">
<Avatar name="KisAssistant" size={32} />
<div className="min-w-0 max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="typing-cursor"><Markdown text={streaming} /></div>
</div>
</div>
</div>
)}
{sending && streaming === "" && (
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">Thinking…</span></div>
)}
</div>
</div>
<div className="px-4 pb-2 sm:px-8">
<form onSubmit={submit} className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 pl-5 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<textarea
ref={taRef}
rows={1}
value={draft}
onChange={(e) => { setDraft(e.target.value); autoGrow(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder="Message KisAssistant…"
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
style={{ maxHeight: 200 }}
/>
<button
type="submit"
disabled={!draft.trim() || sending}
aria-label="Send"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-emerald-600 text-white shadow transition hover:bg-emerald-500 active:scale-95 disabled:opacity-40 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-400"
>
<SendHorizontal size={17} />
</button>
</form>
<p className="py-2 text-center text-xs opacity-50">No cookies — a Bearer key is stored in this browser only.</p>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
import { KeyRound, Sparkles, User } from "lucide-react";
import { api, setToken } from "../lib/api.ts";
import type { User as UserType } from "../lib/types.ts";
import { Button, Field, Input, Spinner } from "./ui.tsx";
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [username, setUsername] = useState("admin");
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setError("");
setBusy(true);
try {
const res = await api.login(username.trim().toLowerCase(), key);
setToken(res.token);
onLogin(res.user);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setBusy(false);
}
}
return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-emerald-50 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-950/30">
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200/70 bg-white/90 p-8 shadow-2xl backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
<div className="mb-6 flex items-center gap-3">
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
<Sparkles size={22} />
</span>
<div>
<h1 className="text-xl font-bold tracking-tight">KisAssistant</h1>
<p className="text-sm opacity-60">Private assistant</p>
</div>
</div>
<form onSubmit={submit} className="space-y-4">
<Field label="Username">
<div className="relative">
<User size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
<Input className="pl-10" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</div>
</Field>
<Field label="Access key" hint="Admin key lives in the server .env; user keys are issued by the admin.">
<div className="relative">
<KeyRound size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
<Input
className="pl-10"
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
autoComplete="current-password"
required
/>
</div>
</Field>
{error && <p className="rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
<Button type="submit" size="lg" className="w-full" disabled={busy}>
{busy ? <Spinner /> : "Unlock"}
</Button>
</form>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "./ui.tsx";
function CodeBlock({ code, lang }: { code: string; lang: string }) {
return (
<div className="relative">
{lang && (
<span className="absolute left-4 top-2.5 text-[11px] font-medium uppercase tracking-wide text-stone-400">{lang}</span>
)}
<div className="absolute right-2.5 top-2">
<CopyButton text={code} />
</div>
<pre className={lang ? "pt-9" : ""}>
<code>{code}</code>
</pre>
</div>
);
}
export function Markdown({ text }: { text: string }) {
return (
<div className="md space-y-3 text-[15px] leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre({ children }) {
// react-markdown nests <code> inside <pre>; render our block instead.
const child = Array.isArray(children) ? children[0] : children;
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
const className = props?.className || "";
const lang = className.replace("language-", "");
const code = String(props?.children ?? "").replace(/\n$/, "");
return <CodeBlock code={code} lang={lang} />;
},
code({ children }) {
return <code>{children}</code>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { Bot, Globe, Sparkles, User as UserIcon } from "lucide-react";
import { api } from "../lib/api.ts";
import type { SharedChat } from "../lib/types.ts";
import { Avatar } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
export function SharePage({ publicId }: { publicId: string }) {
const [chat, setChat] = useState<SharedChat | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api.publicShare(publicId)
.then(setChat)
.catch((err) => setError(err instanceof Error ? err.message : "Not found"));
}, [publicId]);
return (
<div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3">
<span className="grid h-9 w-9 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white">
<Sparkles size={17} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
{chat && (
<p className="flex items-center gap-1.5 text-xs opacity-60">
<Globe size={11} /> by {chat.authorName} · {new Date(chat.sharedAt).toLocaleDateString()}
{chat.model && <> · {chat.model.split(":").pop()}</>}
</p>
)}
</div>
<a href="/" className="rounded-full bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 dark:bg-emerald-500 dark:text-zinc-950">
Open KisAssistant
</a>
</div>
</header>
<main className="mx-auto w-full max-w-3xl space-y-5 px-4 py-6">
{error && <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>}
{chat?.messages.map((m, i) => (
<div key={i} className="flex gap-3">
<Avatar name={m.role === "user" ? chat.authorName : "KisAssistant"} size={32} />
<div className="min-w-0 max-w-[90%] flex-1">
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
{m.role === "user" ? chat.authorName : "KisAssistant"}
</div>
{m.role === "user" ? (
<div className="whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900">{m.content}</div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<Markdown text={m.content} />
</div>
)}
</div>
</div>
))}
</main>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import {
MessageSquarePlus,
MoreVertical,
PanelLeftClose,
Pencil,
Settings,
Share2,
Tag,
Trash2,
Users,
} from "lucide-react";
import type { Conversation, User } from "../lib/types.ts";
import { Avatar, Button, ContextMenu, IconButton } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
interface MenuState {
x: number;
y: number;
conv: Conversation;
}
export function Sidebar({
user,
convs,
activeId,
collapsed,
onToggle,
onNew,
onSelect,
onRename,
onTopic,
onShare,
onDelete,
onOpenSettings,
onOpenAdmin,
onLogout,
}: {
user: User;
convs: Conversation[];
activeId: string | null;
collapsed: boolean;
onToggle: () => void;
onNew: () => void;
onSelect: (id: string) => void;
onRename: (conv: Conversation) => void;
onTopic: (conv: Conversation) => void;
onShare: (conv: Conversation) => void;
onDelete: (conv: Conversation) => void;
onOpenSettings: () => void;
onOpenAdmin: () => void;
onLogout: () => void;
}) {
const [menu, setMenu] = useState<MenuState | null>(null);
function openMenu(e: React.MouseEvent, conv: Conversation) {
e.preventDefault();
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY, conv });
}
if (collapsed) return null;
return (
<aside className="flex h-full w-72 shrink-0 flex-col gap-2 border-r border-stone-200/70 bg-white/70 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="flex items-center gap-1">
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
<MessageSquarePlus size={16} />
New chat
</Button>
<IconButton title="Collapse sidebar" onClick={onToggle}>
<PanelLeftClose size={18} />
</IconButton>
</div>
<div className="flex-1 space-y-1 overflow-y-auto py-1">
{convs.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p>
)}
{convs.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => onSelect(c.id)}
onKeyDown={(e) => e.key === "Enter" && onSelect(c.id)}
onContextMenu={(e) => openMenu(e, c)}
className={cn(
"group flex cursor-pointer items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition",
c.id === activeId
? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{c.title}</span>
{c.topic && (
<span className="mt-0.5 flex items-center gap-1 text-xs opacity-60">
<Tag size={11} />
<span className="truncate">{c.topic}</span>
</span>
)}
</span>
<button
aria-label="Conversation options"
onClick={(e) => openMenu(e, c)}
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
>
<MoreVertical size={15} />
</button>
</div>
))}
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
items={[
{ icon: Pencil, label: "Rename", onClick: () => onRename(menu.conv) },
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) },
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) },
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) },
]}
/>
)}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800">
<Avatar name={user.displayName} url={user.avatarUrl} size={34} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.displayName}</span>
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? " · admin" : ""}</span>
</span>
<Settings size={16} className="opacity-50" />
</button>
<div className="flex gap-1.5">
{user.isAdmin && (
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title="Manage accounts">
<Users size={15} />
Accounts
</Button>
)}
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
Log out
</Button>
</div>
</div>
</aside>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from "react";
import { Globe, Link2, Pencil, Settings2, Tag, Unplug } from "lucide-react";
import { api } from "../lib/api.ts";
import type { Conversation, User } from "../lib/types.ts";
import { Avatar, Button, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
/* ---------- Rename + topic ---------- */
export function ConvEditDialog({
conv,
onClose,
onSaved,
}: {
conv: Conversation | null;
onClose: () => void;
onSaved: (c: Conversation) => void;
}) {
const [title, setTitle] = useState("");
const [topic, setTopic] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (conv) {
setTitle(conv.title);
setTopic(conv.topic);
setError("");
}
}, [conv]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!conv || busy) return;
setBusy(true);
setError("");
try {
const res = await api.patchConv(conv.id, { title: title.trim(), topic: topic.trim() });
onSaved(res.conversation);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={conv !== null} onClose={onClose} title="Edit conversation" icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label="Title">
<Input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} required />
</Field>
<Field label="Topic" hint="A short theme tag, e.g. cooking, rust, travel.">
<div className="relative">
<Tag size={15} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
<Input className="pl-10" value={topic} onChange={(e) => setTopic(e.target.value)} maxLength={120} placeholder="No topic" />
</div>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
</div>
</form>
</Modal>
);
}
/* ---------- Share ---------- */
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
const [url, setUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!conv) {
setUrl(null);
return;
}
setBusy(true);
setError("");
api.share(conv.id)
.then((r) => setUrl(window.location.origin + r.url))
.catch((err) => setError(err instanceof Error ? err.message : "Share failed"))
.finally(() => setBusy(false));
}, [conv]);
async function unshare() {
if (!conv) return;
await api.unshare(conv.id);
onClose();
}
return (
<Modal open={conv !== null} onClose={onClose} title="Share publicly" icon={Globe}>
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> Creating public link…</p>}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{url && (
<div className="space-y-4">
<p className="text-sm opacity-70">Anyone with this link can read <strong>{conv?.title}</strong>. No login required.</p>
<div className="flex items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm dark:border-zinc-700 dark:bg-zinc-800">
<Link2 size={15} className="shrink-0 opacity-50" />
<span className="min-w-0 flex-1 truncate">{url}</span>
<CopyButton text={url} />
</div>
<div className="flex justify-between">
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> Unshare</Button>
<Button size="sm" onClick={onClose}>Done</Button>
</div>
</div>
)}
</Modal>
);
}
/* ---------- Settings (profile) ---------- */
export function SettingsModal({
user,
onClose,
onSaved,
}: {
user: User | null;
onClose: () => void;
onSaved: (u: User) => void;
}) {
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [theme, setTheme] = useState("auto");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
setTheme(user.theme);
setError("");
}
}, [user]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!user || busy) return;
setBusy(true);
setError("");
try {
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme });
onSaved(res.user);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={user !== null} onClose={onClose} title="Profile & appearance" icon={Settings2}>
<form onSubmit={save} className="space-y-4">
<div className="flex items-center gap-3">
<Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} />
<p className="text-sm opacity-60">@{user?.username}</p>
</div>
<Field label="Display name">
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required />
</Field>
<Field label="Avatar URL" hint="Host an image on catbox.moe (or anywhere) and paste the direct link here. Empty = initials.">
<Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" inputMode="url" />
</Field>
<Field label="Theme">
<Picker
ariaLabel="Theme"
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: "Auto (follows system)" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
</div>
</form>
</Modal>
);
}
+367
View File
@@ -0,0 +1,367 @@
import React, { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
/* ---------- Button ---------- */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg" | "icon";
};
export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" && "px-3.5 py-1.5 text-sm",
size === "md" && "px-5 py-2.5 text-sm",
size === "lg" && "px-6 py-3 text-base",
size === "icon" && "h-9 w-9",
variant === "primary" && "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:text-zinc-950",
variant === "secondary" && "border border-stone-200 bg-white hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800",
variant === "ghost" && "hover:bg-stone-200/60 dark:hover:bg-zinc-800",
variant === "danger" && "bg-red-600 text-white hover:bg-red-500",
className,
)}
{...props}
/>
);
}
export function IconButton({ className, title, ...props }: ButtonProps & { title: string }) {
return <Button variant="ghost" size="icon" title={title} aria-label={title} className={cn("rounded-full", className)} {...props} />;
}
/* ---------- Inputs ---------- */
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium opacity-80">{label}</span>
{children}
{hint && <span className="mt-1 block text-xs opacity-60">{hint}</span>}
</label>
);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"w-full rounded-2xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none transition",
"placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20",
"dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500",
)}
{...props}
/>
);
}
/* ---------- Picker (framework-styled dropdown, replaces native <select>) ---------- */
export interface PickerOption {
value: string;
label: string;
hint?: string;
}
export interface PickerGroup {
group: string;
options: PickerOption[];
}
export function Picker({
value,
onChange,
options,
groups,
placeholder = "Select…",
ariaLabel,
disabled,
icon: Icon,
className,
align = "right",
}: {
value: string;
onChange: (v: string) => void;
options?: PickerOption[];
groups?: PickerGroup[];
placeholder?: string;
ariaLabel: string;
disabled?: boolean;
icon?: LucideIcon;
className?: string;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open ]);
return (
<div className={cn("relative", className)}>
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={cn(
"flex max-w-56 items-center gap-2 rounded-full border border-stone-200 bg-white py-2 pl-3.5 pr-3 text-sm font-medium shadow-sm transition",
"hover:border-emerald-500/60 hover:shadow disabled:cursor-not-allowed disabled:opacity-50",
"dark:border-zinc-700 dark:bg-zinc-900",
open && "border-emerald-500 ring-2 ring-emerald-500/20",
)}
>
{Icon && <Icon size={15} className="shrink-0 text-emerald-600 dark:text-emerald-400" />}
<span className="truncate">{selected?.label ?? placeholder}</span>
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
</button>
{open && (
<>
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
<div
role="listbox"
aria-label={ariaLabel}
className={cn(
"absolute top-full z-50 mt-2 max-h-72 min-w-52 max-w-72 overflow-y-auto rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900",
align === "right" ? "right-0" : "left-0",
)}
>
{all.map((g) => (
<div key={g.group || "default"}>
{g.group && (
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider opacity-50">{g.group}</p>
)}
{g.options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
role="option"
aria-selected={active}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition",
active
? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-100"
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{o.label}</span>
{o.hint && <span className="block truncate text-xs opacity-50">{o.hint}</span>}
</span>
{active && <Check size={15} className="shrink-0 text-emerald-600 dark:text-emerald-400" />}
</button>
);
})}
</div>
))}
</div>
</>
)}
</div>
);
}
/* ---------- Modal / Dialog ---------- */
export function Modal({
open,
onClose,
title,
icon,
children,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
icon?: LucideIcon;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open, onClose]);
if (!open) return null;
const Icon = icon;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
className={cn(
"relative max-h-[85vh] w-full overflow-y-auto rounded-3xl border border-stone-200 bg-white p-6 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900",
wide ? "max-w-2xl" : "max-w-md",
)}
>
<div className="mb-4 flex items-center gap-2.5">
{Icon && (
<span className="grid h-9 w-9 place-items-center rounded-full bg-emerald-600/10 text-emerald-600 dark:text-emerald-400">
<Icon size={18} />
</span>
)}
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} aria-label="Close" className="ml-auto rounded-full p-1.5 hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<X size={18} />
</button>
</div>
{children}
</div>
</div>
);
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = "Delete",
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
}) {
return (
<Modal open={open} onClose={onClose} title={title}>
<p className="text-sm opacity-80">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel}</Button>
</div>
</Modal>
);
}
/* ---------- Context menu ---------- */
export interface MenuItem {
icon: LucideIcon;
label: string;
danger?: boolean;
onClick: () => void;
}
export function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: MenuItem[]; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ x, y });
useEffect(() => {
const el = ref.current;
if (el) {
const r = el.getBoundingClientRect();
setPos({
x: Math.min(x, window.innerWidth - r.width - 8),
y: Math.min(y, window.innerHeight - r.height - 8),
});
}
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [x, y, onClose]);
return (
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose(); }}>
<div
ref={ref}
className="absolute w-52 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
style={{ left: pos.x, top: pos.y }}
onClick={(e) => e.stopPropagation()}
>
{items.map((it) => (
<button
key={it.label}
onClick={() => { it.onClick(); onClose(); }}
className={cn(
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-sm transition",
it.danger
? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
)}
>
<it.icon size={16} />
{it.label}
</button>
))}
</div>
</div>
);
}
/* ---------- Avatar ---------- */
const GRADIENTS = [
"from-emerald-500 to-teal-600",
"from-violet-500 to-purple-600",
"from-amber-500 to-orange-600",
"from-sky-500 to-cyan-600",
"from-rose-500 to-pink-600",
];
export function Avatar({ name, url, size = 36 }: { name: string; url?: string; size?: number }) {
if (url) {
return <img src={url} alt={name} width={size} height={size} className="rounded-full object-cover" style={{ width: size, height: size }} />;
}
const initial = (name.trim()[0] || "?").toUpperCase();
const g = GRADIENTS[(name.charCodeAt(0) || 0) % GRADIENTS.length];
return (
<span
className={cn("grid shrink-0 place-items-center rounded-full bg-gradient-to-br font-semibold text-white", g)}
style={{ width: size, height: size, fontSize: size * 0.42 }}
aria-label={name}
>
{initial}
</span>
);
}
/* ---------- Misc ---------- */
export function Spinner({ size = 18 }: { size?: number }) {
return <Loader2 size={size} className="animate-spin" />;
}
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1500);
});
}}
className="inline-flex items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium transition hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800"
>
{done ? <Check size={13} className="text-emerald-500" /> : <Copy size={13} />}
{done ? "Copied" : label}
</button>
);
}
+66
View File
@@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
height: 100%;
}
body {
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
}
/* slim scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: rgb(0 0 0 / 0.2) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 0.2);
border-radius: 999px;
}
/* markdown content */
.md p:first-child { margin-top: 0; }
.md p:last-child { margin-bottom: 0; }
.md pre {
@apply relative overflow-x-auto rounded-2xl border border-stone-200 bg-stone-900 p-4 text-[13.5px] leading-relaxed text-stone-100 dark:border-zinc-700;
}
.md pre code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: none !important;
padding: 0 !important;
}
.md :not(pre) > code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@apply rounded-md bg-stone-200/70 px-1.5 py-0.5 text-[13px] dark:bg-zinc-800;
}
.md table {
@apply w-full border-collapse overflow-hidden rounded-xl text-sm;
}
.md th, .md td {
@apply border border-stone-200 px-3 py-1.5 dark:border-zinc-700;
}
.md th {
@apply bg-stone-200/50 dark:bg-zinc-800;
}
.md a {
@apply text-emerald-600 underline dark:text-emerald-400;
}
.md ul { @apply list-disc pl-5; }
.md ol { @apply list-decimal pl-5; }
.md blockquote {
@apply border-l-4 border-emerald-500/50 pl-3 italic opacity-80;
}
.typing-cursor::after {
content: "▍";
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
+120
View File
@@ -0,0 +1,120 @@
import type { ChatMessage, Conversation, DriverModel, SharedChat, User } from "./types.ts";
const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(t: string): void {
localStorage.setItem(TOKEN_KEY, t);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, opts: RequestInit = {}, auth = true): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (auth) {
const t = getToken();
if (t) headers["Authorization"] = `Bearer ${t}`;
}
const res = await fetch(path, { ...opts, headers: { ...headers, ...(opts.headers as Record<string, string> | undefined) } });
if (res.status === 401 && auth) {
clearToken();
if (!window.location.pathname.startsWith("/share/")) window.location.reload();
throw new ApiError(401, "Session expired, please log in again.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error || `Request failed (${res.status})`);
return data as T;
}
export const api = {
login: (username: string, key: string) =>
req<{ token: string; expiresAt: number; user: User }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, key }),
}, false),
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
me: () => req<{ user: User }>("/api/me"),
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
adminList: () => req<{ users: User[] }>("/api/admin/users"),
adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string }) =>
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"),
createConv: (c: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }),
getConv: (id: string) => req<{ conversation: Conversation; messages: ChatMessage[] }>(`/api/conversations/${id}`),
patchConv: (id: string, patch: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>(`/api/conversations/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteConv: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}`, { method: "DELETE" }),
share: (id: string) => req<{ publicId: string; url: string }>(`/api/conversations/${id}/share`, { method: "POST" }),
getShare: (id: string) => req<{ publicId: string; url: string } | { shared: false }>(`/api/conversations/${id}/share`),
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
publicShare: (publicId: string) => req<SharedChat>(`/api/share/${publicId}`, {}, false),
models: () => req<{ models: DriverModel[] }>("/api/models"),
/** Streaming chat. Calls onToken per token; resolves with the full text. */
chatStream: async (
args: { model: string; messages: ChatMessage[]; conversationId?: string },
onToken: (t: string) => void,
): Promise<string> => {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ ...args, stream: true }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok || !res.body) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, err.error || `Chat failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let full = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop() ?? "";
for (const ev of events) {
const m = ev.match(/^event: (\w+)\ndata: ([\s\S]*)$/m);
if (!m) continue;
const [, event, data] = m as [string, string, string];
if (event === "token") {
const token = (JSON.parse(data) as { token: string }).token;
full += token;
onToken(token);
} else if (event === "error") {
throw new ApiError(502, (JSON.parse(data) as { error?: string }).error || "stream error");
}
}
}
return full;
},
};
+3
View File
@@ -0,0 +1,3 @@
export function cn(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
+40
View File
@@ -0,0 +1,40 @@
export interface User {
id: string;
username: string;
displayName: string;
avatarUrl: string;
theme: string;
createdAt: number;
isAdmin: boolean;
}
export interface Conversation {
id: string;
user_id: string;
title: string;
topic: string;
model: string;
created_at: number;
updated_at: number;
}
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface DriverModel {
id: string;
name: string;
driver: string;
label: string;
}
export interface SharedChat {
title: string;
topic: string;
model: string;
authorName: string;
sharedAt: number;
messages: ChatMessage[];
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource-variable/inter";
import "./index.css";
import { App } from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);