Add web frontend
This commit is contained in:
521
web/src/App.tsx
Normal file
521
web/src/App.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { LogOut, MessageSquare, Plus, SendHorizontal, ShieldCheck } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
createChat,
|
||||
getChat,
|
||||
getConfiguredToken,
|
||||
listChats,
|
||||
runCompletion,
|
||||
setAuthToken,
|
||||
verifySession,
|
||||
type ChatDetail,
|
||||
type ChatSummary,
|
||||
type CompletionRequestMessage,
|
||||
} from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Provider = "openai" | "anthropic" | "xai";
|
||||
type AuthMode = "open" | "token";
|
||||
|
||||
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
|
||||
openai: "gpt-4.1-mini",
|
||||
anthropic: "claude-3-5-sonnet-latest",
|
||||
xai: "grok-3-mini",
|
||||
};
|
||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
|
||||
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
|
||||
if (chat.title?.trim()) return chat.title.trim();
|
||||
const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim();
|
||||
if (firstUserMessage) return firstUserMessage.slice(0, 48);
|
||||
return "New chat";
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function readStoredToken() {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || null;
|
||||
}
|
||||
|
||||
function persistToken(token: string | null) {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function normalizeAuthError(message: string) {
|
||||
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
|
||||
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
|
||||
|
||||
const [authTokenInput, setAuthTokenInput] = useState(initialToken);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
|
||||
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
||||
const [isLoadingChat, setIsLoadingChat] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [composer, setComposer] = useState("");
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const completeSessionCheck = async (tokenCandidate: string | null) => {
|
||||
setAuthToken(tokenCandidate);
|
||||
const session = await verifySession();
|
||||
setIsAuthenticated(true);
|
||||
setAuthMode(session.mode);
|
||||
setAuthError(null);
|
||||
persistToken(tokenCandidate);
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string) => {
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(normalizeAuthError(message));
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setChats([]);
|
||||
setSelectedChatId(null);
|
||||
setSelectedChat(null);
|
||||
};
|
||||
|
||||
const refreshChats = async (preferredChatId?: string) => {
|
||||
setIsLoadingChats(true);
|
||||
try {
|
||||
const nextChats = await listChats();
|
||||
setChats(nextChats);
|
||||
|
||||
setSelectedChatId((current) => {
|
||||
if (preferredChatId && nextChats.some((chat) => chat.id === preferredChatId)) {
|
||||
return preferredChatId;
|
||||
}
|
||||
if (current && nextChats.some((chat) => chat.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextChats[0]?.id ?? null;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingChats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshChat = async (chatId: string) => {
|
||||
setIsLoadingChat(true);
|
||||
try {
|
||||
const chat = await getChat(chatId);
|
||||
setSelectedChat(chat);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingChat(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredToken() ?? getConfiguredToken();
|
||||
void (async () => {
|
||||
try {
|
||||
await completeSessionCheck(token);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
handleAuthFailure(message);
|
||||
} finally {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
void refreshChats();
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setSelectedChat(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedChatId) {
|
||||
setSelectedChat(null);
|
||||
return;
|
||||
}
|
||||
void refreshChat(selectedChatId);
|
||||
}, [isAuthenticated, selectedChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [selectedChat?.messages.length, isSending]);
|
||||
|
||||
const messages = selectedChat?.messages ?? [];
|
||||
|
||||
const selectedChatTitle = useMemo(() => {
|
||||
if (!selectedChat) return "Sybil";
|
||||
return getChatTitle(selectedChat, selectedChat.messages);
|
||||
}, [selectedChat]);
|
||||
|
||||
const handleCreateChat = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const chat = await createChat();
|
||||
setSelectedChatId(chat.id);
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
messages: [],
|
||||
});
|
||||
await refreshChats(chat.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const content = composer.trim();
|
||||
if (!content || isSending) return;
|
||||
|
||||
setComposer("");
|
||||
setError(null);
|
||||
setIsSending(true);
|
||||
|
||||
let chatId = selectedChatId;
|
||||
|
||||
try {
|
||||
if (!chatId) {
|
||||
const chat = await createChat();
|
||||
chatId = chat.id;
|
||||
setSelectedChatId(chatId);
|
||||
}
|
||||
|
||||
if (!chatId) {
|
||||
throw new Error("Unable to initialize chat");
|
||||
}
|
||||
|
||||
let baseChat = selectedChat;
|
||||
if (!baseChat || baseChat.id !== chatId) {
|
||||
baseChat = await getChat(chatId);
|
||||
}
|
||||
|
||||
const optimisticUserMessage = {
|
||||
id: `temp-user-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
role: "user" as const,
|
||||
content,
|
||||
name: null,
|
||||
};
|
||||
|
||||
const optimisticAssistantMessage = {
|
||||
id: `temp-assistant-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
role: "assistant" as const,
|
||||
content: "",
|
||||
name: null,
|
||||
};
|
||||
|
||||
setSelectedChat((current) => {
|
||||
if (!current || current.id !== chatId) return current;
|
||||
return {
|
||||
...current,
|
||||
messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage],
|
||||
};
|
||||
});
|
||||
|
||||
const requestMessages: CompletionRequestMessage[] = [
|
||||
...baseChat.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
})),
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
];
|
||||
|
||||
await runCompletion({
|
||||
chatId,
|
||||
provider,
|
||||
model: model.trim(),
|
||||
messages: requestMessages,
|
||||
});
|
||||
|
||||
await Promise.all([refreshChats(chatId), refreshChat(chatId)]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
if (chatId) {
|
||||
await refreshChat(chatId);
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = async (tokenCandidate: string | null) => {
|
||||
setIsSigningIn(true);
|
||||
setAuthError(null);
|
||||
try {
|
||||
await completeSessionCheck(tokenCandidate);
|
||||
await refreshChats();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setAuthError(normalizeAuthError(message));
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(null);
|
||||
setChats([]);
|
||||
setSelectedChatId(null);
|
||||
setSelectedChat(null);
|
||||
setComposer("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (isCheckingSession) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-muted/70">
|
||||
<p className="text-sm text-muted-foreground">Checking session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#f8fafc_0%,#eef2f7_45%,#e2e8f0_100%)] p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border bg-background p-6 shadow-xl">
|
||||
<div className="mb-5 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-slate-900 p-2 text-slate-50">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Sign in to Sybil</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSignIn(authTokenInput.trim() || null);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="ADMIN_TOKEN"
|
||||
value={authTokenInput}
|
||||
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
||||
disabled={isSigningIn}
|
||||
/>
|
||||
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isSigningIn}
|
||||
onClick={() => void handleSignIn(null)}
|
||||
>
|
||||
Continue without token
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
||||
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full p-3 md:p-5">
|
||||
<div className="mx-auto flex h-full w-full max-w-[1560px] overflow-hidden rounded-2xl border bg-background shadow-xl">
|
||||
<aside className="flex w-80 shrink-0 flex-col border-r bg-slate-50">
|
||||
<div className="p-3">
|
||||
<Button className="w-full justify-start gap-2" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoadingChats && chats.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading chats...</p> : null}
|
||||
{!isLoadingChats && chats.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Start your first conversation.
|
||||
</div>
|
||||
) : null}
|
||||
{chats.map((chat) => {
|
||||
const active = chat.id === selectedChatId;
|
||||
return (
|
||||
<button
|
||||
key={chat.id}
|
||||
className={cn(
|
||||
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
||||
active ? "bg-slate-900 text-slate-50" : "hover:bg-slate-200"
|
||||
)}
|
||||
onClick={() => setSelectedChatId(chat.id)}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-sm font-medium">{getChatTitle(chat)}</p>
|
||||
<p className={cn("mt-1 text-xs", active ? "text-slate-300" : "text-slate-500")}>{formatDate(chat.updatedAt)}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold md:text-base">{selectedChatTitle}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={provider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setProvider(nextProvider);
|
||||
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
|
||||
}}
|
||||
disabled={isSending}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="xai">xAI</option>
|
||||
</select>
|
||||
<Input
|
||||
value={model}
|
||||
onInput={(event) => setModel(event.currentTarget.value)}
|
||||
placeholder="Model"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="mr-1 h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
||||
{isLoadingChat && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
{!isLoadingChat && messages.length === 0 ? (
|
||||
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 rounded-xl border border-dashed p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">How can I help today?</h2>
|
||||
<p className="text-sm text-muted-foreground">Ask a question to begin this conversation.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{messages.map((message) => {
|
||||
const isUser = message.role === "user";
|
||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending;
|
||||
return (
|
||||
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-6",
|
||||
isUser ? "bg-slate-900 text-slate-50" : "bg-slate-100 text-slate-900"
|
||||
)}
|
||||
>
|
||||
{isPendingAssistant ? "Thinking..." : message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
|
||||
<footer className="border-t p-3 md:p-4">
|
||||
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={composer}
|
||||
onInput={(event) => setComposer(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Message Sybil"
|
||||
className="resize-none border-0 shadow-none focus-visible:ring-0"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : <span className="text-xs text-muted-foreground">Enter to send</span>}
|
||||
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user