KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...parts: (string | false | null | undefined)[]): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
Reference in New Issue
Block a user