Commit 459309cd authored by AGLANPC\aglan's avatar AGLANPC\aglan

fgg dfjdfghj dfkj df

parent e636c018
...@@ -9,8 +9,8 @@ import { ...@@ -9,8 +9,8 @@ import {
} from "lucide-react"; } from "lucide-react";
const MODELS = [ const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6" }, { id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5" }, { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
]; ];
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode }; const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
...@@ -20,7 +20,7 @@ const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", doc ...@@ -20,7 +20,7 @@ const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", doc
function classifyFile(f) { function classifyFile(f) {
const ext = (f.name || "").split(".").pop().toLowerCase(); const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || ""; const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(ext)) return "image"; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document"; if (mime === "application/pdf" || ext === "pdf") return "document";
return "text"; return "text";
...@@ -64,7 +64,7 @@ export default function ChatView({ chatId }) { ...@@ -64,7 +64,7 @@ export default function ChatView({ chatId }) {
const scrollBottom = useCallback(() => { const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return; if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
rafRef.current = null; rafRef.current = null;
}); });
}, []); }, []);
...@@ -85,7 +85,6 @@ export default function ChatView({ chatId }) { ...@@ -85,7 +85,6 @@ export default function ChatView({ chatId }) {
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]); useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); useEffect(() => { inputRef.current?.focus(); }, [chatId]);
// Sync settings when chat changes
useEffect(() => { useEffect(() => {
if (currentChat) { if (currentChat) {
setModel(currentChat.model || MODELS[0].id); setModel(currentChat.model || MODELS[0].id);
...@@ -114,11 +113,21 @@ export default function ChatView({ chatId }) { ...@@ -114,11 +113,21 @@ export default function ChatView({ chatId }) {
} }
function addFiles(files) { function addFiles(files) {
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); setPendingFiles((prev) => [
...prev,
...files.map((f) => ({
file: f,
type: classifyFile(f),
preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
})),
]);
} }
function removePending(i) { function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
} }
async function handleSend() { async function handleSend() {
...@@ -137,19 +146,44 @@ export default function ChatView({ chatId }) { ...@@ -137,19 +146,44 @@ export default function ChatView({ chatId }) {
setUploading(false); setUploading(false);
} }
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } }); dispatch({
type: "ADD_MESSAGE",
chatId,
message: {
id: `tmp-${Date.now()}`,
role: "user",
content: text,
created_at: new Date().toISOString(),
attachments: uploaded,
},
});
setInput(""); setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); }); pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]); setPendingFiles([]);
autoScroll.current = true; autoScroll.current = true;
// Reset textarea height
if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({ streamManager.startStream({
token: state.token, chatId, token: state.token,
body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds }, chatId,
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
}); });
} }
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handlePaste(e) { function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file"); const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file");
...@@ -159,7 +193,8 @@ export default function ChatView({ chatId }) { ...@@ -159,7 +193,8 @@ export default function ChatView({ chatId }) {
} }
function handleDrop(e) { function handleDrop(e) {
e.preventDefault(); setDragOver(false); e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []); const files = Array.from(e.dataTransfer?.files || []);
if (files.length) addFiles(files); if (files.length) addFiles(files);
} }
...@@ -167,26 +202,46 @@ export default function ChatView({ chatId }) { ...@@ -167,26 +202,46 @@ export default function ChatView({ chatId }) {
const streaming = streamData.streaming; const streaming = streamData.streaming;
return ( return (
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}> <div
className="flex-1 flex flex-col min-h-0 relative"
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
>
{/* Drag overlay */}
{dragOver && ( {dragOver && (
<div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none">
<div className="text-center"> <div className="text-center">
<Upload size={40} className="text-anton-accent mx-auto mb-2 animate-bounce" /> <Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" />
<p className="text-white font-semibold">Drop files here</p> <p className="text-white font-semibold text-sm">Drop files here</p>
</div> </div>
</div> </div>
)} )}
{/* Messages */} {/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-3 sm:px-4 py-4 space-y-3 sm:space-y-4"> <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)} {messages.map((m) => (
<MessageBubble key={m.id} message={m} token={state.token} />
))}
{streaming && (streamData.thinking || streamData.text) && ( {streaming && (streamData.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} /> <MessageBubble
message={{
id: "streaming", role: "assistant", content: streamData.text,
thinking_content: streamData.thinking || null, attachments: [],
}}
isStreaming
isThinking={streamData.isThinking}
token={state.token}
/>
)} )}
{streaming && !streamData.text && !streamData.thinking && ( {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in"> <div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1"> <div className="flex gap-1">
{[0, 150, 300].map((d) => <span key={d} className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)} {[0, 150, 300].map((d) => (
<span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />
))}
</div> </div>
<span className="text-anton-muted text-sm">Thinking…</span> <span className="text-anton-muted text-sm">Thinking…</span>
</div> </div>
...@@ -194,90 +249,170 @@ export default function ChatView({ chatId }) { ...@@ -194,90 +249,170 @@ export default function ChatView({ chatId }) {
</div> </div>
{/* Input area */} {/* Input area */}
<div className="border-t border-anton-border bg-anton-surface p-3 sm:p-4 safe-bottom"> <div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{/* Settings panel */}
{showSettings && ( {showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-3 sm:p-4 space-y-3 animate-fade-in"> <div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button> <Settings2 size={14} className="text-anton-accent" /> Settings
</h3>
<button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white">
<X size={14} />
</button>
</div> </div>
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Model</label> <label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"> <select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)} {MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select> </select>
</div> </div>
<div> <div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div> <div className="flex justify-between text-xs mb-1.5">
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" /> <span className="text-anton-muted">Max Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div> </div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div> <div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div> <div className="flex justify-between text-xs mb-1.5">
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} className="w-full" /> <span className="text-anton-muted flex items-center gap-1">
<Brain size={12} className="text-purple-400" /> Reasoning
</span>
<span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span>
</div> </div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
<div> <div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label> <label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"> <BookOpen size={12} /> Knowledge Base
</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
<option value="">None</option> <option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)} {kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select> </select>
{kbs.length === 0 && <p className="text-[10px] text-anton-muted mt-1">Create knowledge bases in the sidebar → Knowledge tab</p>}
</div> </div>
</div> </div>
)} )}
{/* Pending files */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-1.5 animate-fade-in"> <div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => { {pendingFiles.map((pf, i) => {
const Icon = TYPE_ICONS[pf.type] || FileText; const Icon = TYPE_ICONS[pf.type] || FileText;
return ( return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}> <div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? ( {pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-16 h-16 sm:w-20 sm:h-20 object-cover" /> <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" />
) : ( ) : (
<div className="w-16 h-16 sm:w-20 sm:h-20 flex flex-col items-center justify-center px-1"> <div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1">
<Icon size={18} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} /> <Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} />
<span className="text-[8px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span> <span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span>
</div> </div>
)} )}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-4 h-4 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity shadow"><X size={8} /></button> <button
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div> onClick={() => removePending(i)}
className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
>
<X size={10} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">
{fmtSize(pf.file.size)}
</div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
<div className="flex items-end gap-1.5 sm:gap-2"> {/* Input row */}
<button onClick={toggleSettings} className={`p-2 sm:p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button> <div className="flex items-end gap-1.5">
<button onClick={() => fileRef.current?.click()} className={`p-2 sm:p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button> <button
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} /> onClick={toggleSettings}
<div className="flex-1 relative"> className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
}`}
>
<Settings2 size={18} />
</button>
<button
onClick={() => fileRef.current?.click()}
className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
}`}
title="Attach files"
>
<Paperclip size={18} />
</button>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
/>
<div className="flex-1 min-w-0">
<textarea <textarea
ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} ref={inputRef}
placeholder={pendingFiles.length ? "Add a message or send…" : "Ask anything…"} value={input}
rows={1} style={{ maxHeight: "160px" }} onChange={(e) => setInput(e.target.value)}
className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 sm:px-4 sm:py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition" onKeyDown={handleKeyDown}
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; }} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
rows={1}
style={{ maxHeight: "120px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
}}
/> />
</div> </div>
{streaming ? ( {streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2 sm:p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button> <button
onClick={() => streamManager.abortStream(chatId)}
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
>
<Square size={18} />
</button>
) : ( ) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2 sm:p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30"> <button
onClick={handleSend}
disabled={(!input.trim() && !pendingFiles.length) || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-anton-muted flex-wrap"> {/* Status bar */}
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span> <span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span> <span></span>
<span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{messages.some((m) => m.role === "assistant") && ( {messages.some((m) => m.role === "assistant") && (
<button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button> <button
onClick={async () => {
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
if (all) try { await downloadZip(state.token, all); } catch { /* */ }
}}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Code
</button>
)} )}
</div> </div>
</div> </div>
......
...@@ -3,22 +3,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; ...@@ -3,22 +3,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, FileCode } from "lucide-react"; import { Copy, Check, Download, FileCode } from "lucide-react";
// Map common aliases for syntax highlighting
const LANG_MAP = { const LANG_MAP = {
cs: "csharp", cs: "csharp", sh: "bash", shell: "bash", yml: "yaml",
sh: "bash", dockerfile: "docker", jsx: "jsx", tsx: "tsx", py: "python",
shell: "bash", js: "javascript", ts: "typescript", rb: "ruby", rs: "rust",
yml: "yaml", kt: "kotlin", gd: "gdscript",
dockerfile: "docker",
jsx: "jsx",
tsx: "tsx",
py: "python",
js: "javascript",
ts: "typescript",
rb: "ruby",
rs: "rust",
kt: "kotlin",
gd: "gdscript",
}; };
const customStyle = { const customStyle = {
...@@ -28,19 +17,18 @@ const customStyle = { ...@@ -28,19 +17,18 @@ const customStyle = {
background: "#0d0d14", background: "#0d0d14",
margin: 0, margin: 0,
borderRadius: 0, borderRadius: 0,
fontSize: "0.82rem", fontSize: "0.78rem",
lineHeight: "1.6", lineHeight: "1.55",
}, },
'code[class*="language-"]': { 'code[class*="language-"]': {
...oneDark['code[class*="language-"]'], ...oneDark['code[class*="language-"]'],
background: "none", background: "none",
fontSize: "0.82rem", fontSize: "0.78rem",
}, },
}; };
export default function CodeBlock({ language, filename, code }) { export default function CodeBlock({ language, filename, code }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const hlLang = LANG_MAP[language] || language || "text"; const hlLang = LANG_MAP[language] || language || "text";
function handleCopy() { function handleCopy() {
...@@ -61,46 +49,44 @@ export default function CodeBlock({ language, filename, code }) { ...@@ -61,46 +49,44 @@ export default function CodeBlock({ language, filename, code }) {
} }
return ( return (
<div className="my-3 rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]"> <div className="my-2.5 rounded-lg overflow-hidden border border-anton-border bg-[#0d0d14]">
{/* Header bar */} {/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-border/30"> <div className="flex items-center justify-between px-2.5 sm:px-3 py-1.5 bg-anton-border/30 gap-2">
<div className="flex items-center gap-2 text-xs text-anton-muted"> <div className="flex items-center gap-1.5 text-xs text-anton-muted min-w-0">
<FileCode size={12} className="text-anton-accent" /> <FileCode size={11} className="text-anton-accent shrink-0" />
{filename ? ( {filename ? (
<span className="text-anton-text font-mono">{filename}</span> <span className="text-anton-text font-mono truncate text-[11px]">{filename}</span>
) : ( ) : (
<span>{hlLang}</span> <span className="text-[11px]">{hlLang}</span>
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-0.5 shrink-0">
<button onClick={handleCopy} <button
className="flex items-center gap-1 px-2 py-0.5 rounded text-[11px] text-anton-muted hover:text-white hover:bg-anton-card transition" onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[28px]"
> >
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />}
{copied ? "Copied" : "Copy"} <span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
</button> </button>
<button onClick={handleDownload} <button
className="flex items-center gap-1 px-2 py-0.5 rounded text-[11px] text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition" onClick={handleDownload}
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition min-h-[28px]"
> >
<Download size={11} /> <Download size={10} />
Download <span className="hidden sm:inline">Download</span>
</button> </button>
</div> </div>
</div> </div>
{/* Code */} {/* Code with horizontal scroll */}
<div className="overflow-x-auto"> <div className="overflow-x-auto overscroll-x-contain -webkit-overflow-scrolling-touch">
<SyntaxHighlighter <SyntaxHighlighter
language={hlLang} language={hlLang}
style={customStyle} style={customStyle}
showLineNumbers showLineNumbers={code.split("\n").length > 3}
lineNumberStyle={{ lineNumberStyle={{ color: "#333", fontSize: "0.7rem", minWidth: "2em", paddingRight: "0.5em" }}
minWidth: "2.5em", customStyle={{ padding: "0.75rem", minWidth: "fit-content" }}
paddingRight: "1em", wrapLongLines={false}
color: "#3a3a4a",
userSelect: "none",
}}
wrapLines
> >
{code} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
......
...@@ -5,10 +5,12 @@ import CodeBlock from "./CodeBlock"; ...@@ -5,10 +5,12 @@ import CodeBlock from "./CodeBlock";
import { getAttachmentUrl } from "../api"; import { getAttachmentUrl } from "../api";
import { import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check, User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink, FileCode, File, Image, Film, FileText, ExternalLink,
} from "lucide-react"; } from "lucide-react";
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileCode }; const FILE_TYPE_ICONS = {
image: Image, video: Film, document: FileText, text: FileText,
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) { const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message; const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
...@@ -35,20 +37,20 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -35,20 +37,20 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div> </div>
)} )}
<div className={`max-w-[92%] sm:max-w-[80%] min-w-0 ${isUser ? "order-first" : ""}`}> <div className={`min-w-0 ${isUser ? "max-w-[85%] sm:max-w-[75%]" : "max-w-[90%] sm:max-w-[80%]"}`}>
{/* Thinking block */} {/* Thinking block */}
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
<button <button
onClick={() => setShowThinking(!showThinking)} onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1" className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1 min-h-[32px]"
> >
<Brain size={12} /> <Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button> </button>
{(showThinking || isThinking) && ( {(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto"> <div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto overscroll-contain break-words">
{thinking_content} {thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />} {isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
...@@ -60,22 +62,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -60,22 +62,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-1.5"> <div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => { {attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || File; const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
if (att.file_type === "image") { if (att.file_type === "image") {
return ( return (
<div key={att.id} className="relative group"> <div key={att.id} className="relative">
<img <img
src={`${url}?token=${token}`} src={`${url}?token=${token}`}
alt={att.original_filename} alt={att.original_filename}
className="max-w-[180px] sm:max-w-[260px] max-h-[140px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-md" className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
/> />
{expandedImage === att.id && ( {expandedImage === att.id && (
<div <div
className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4 cursor-pointer" className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8 cursor-pointer"
onClick={() => setExpandedImage(null)} onClick={() => setExpandedImage(null)}
> >
<img <img
...@@ -85,7 +87,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -85,7 +87,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
/> />
</div> </div>
)} )}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded max-w-[90%] truncate"> <div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1 py-0.5 rounded">
{att.original_filename} {att.original_filename}
</div> </div>
</div> </div>
...@@ -98,14 +100,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -98,14 +100,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
href={`${url}?token=${token}`} href={`${url}?token=${token}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-2 hover:border-anton-accent transition group max-w-[200px]" className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-2 hover:border-anton-accent transition group min-h-[44px]"
> >
<Icon size={15} className="shrink-0 text-blue-400" /> <Icon size={14} className="shrink-0 text-blue-400" />
<div className="min-w-0 flex-1"> <div className="min-w-0">
<div className="text-[11px] text-white truncate">{att.original_filename}</div> <div className="text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div> <div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div> </div>
<ExternalLink size={11} className="text-anton-muted group-hover:text-anton-accent shrink-0" /> <ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a> </a>
); );
})} })}
...@@ -113,14 +115,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -113,14 +115,15 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
{/* Message bubble */} {/* Message bubble */}
<div className={`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${isUser <div className={`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${
isUser
? "bg-anton-accent text-white rounded-br-md" ? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}> }`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap break-words">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap break-words leading-relaxed">{_stripPrefixes(content)}</div>
) : ( ) : (
<div className="prose-anton text-sm break-words"> <div className="prose-anton text-sm">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
...@@ -148,16 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -148,16 +151,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </div>
{/* Meta info */} {/* Actions */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1 flex-wrap"> <div className="flex items-center gap-3 mt-1 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition"> <button
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} onClick={handleCopy}
className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition min-h-[28px]"
>
{copied ? <Check size={10} className="text-anton-success" /> : <Copy size={10} />}
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[10px] sm:text-[11px] text-anton-muted"> <span className="text-[10px] text-anton-muted">
{input_tokens?.toLocaleString()}/ {output_tokens?.toLocaleString()} {input_tokens?.toLocaleString()}{output_tokens?.toLocaleString()}
</span> </span>
)} )}
</div> </div>
......
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import { import {
listChats, deleteChat, renameChat, Flame, Plus, MessageSquare, Trash2, Edit3, Check, X,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments, LogOut, Shield, BookOpen, ChevronRight,
} from "../api";
import {
Plus, Trash2, Edit3, Check, X, Flame, LogOut, Shield, MessageSquare,
BookOpen, Upload, FolderPlus, ChevronRight, FileText, Loader2,
} from "lucide-react"; } from "lucide-react";
export default function Sidebar({ activeChatId, onSelectChat, onNewChat }) { export default function Sidebar({ mobile, onClose }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate();
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
const [kbs, setKbs] = useState([]);
const [kbName, setKbName] = useState("");
const [expandedKb, setExpandedKb] = useState(null);
const [kbDetail, setKbDetail] = useState(null);
const [kbUploading, setKbUploading] = useState(false);
const [kbCreating, setKbCreating] = useState(false);
const tab = state.sidebarTab || "chats";
useEffect(() => {
if (tab === "knowledge") loadKbs();
}, [tab, state.token]);
async function loadKbs() { async function handleNew() {
try { try {
const data = await listKnowledgeBases(state.token); const chat = await createChat(state.token);
setKbs(data); dispatch({ type: "ADD_CHAT", chat });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
async function handleDeleteChat(e, id) { async function handleDelete(e, chatId) {
e.stopPropagation(); e.stopPropagation();
if (!confirm("Delete this chat?")) return; if (!confirm("Delete this chat?")) return;
try { try {
await deleteChat(state.token, id); await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId: id }); dispatch({ type: "REMOVE_CHAT", chatId });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function startRename(e, chat) { function startEdit(e, chat) {
e.stopPropagation(); e.stopPropagation();
setEditId(chat.id); setEditId(chat.id);
setEditTitle(chat.title); setEditTitle(chat.title);
} }
async function saveRename(id) { async function confirmEdit(e) {
if (editTitle.trim()) { e.stopPropagation();
if (editTitle.trim() && editId) {
try { try {
await renameChat(state.token, id, editTitle.trim()); await renameChat(state.token, editId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id, title: editTitle.trim() } }); dispatch({ type: "UPDATE_CHAT", chat: { id: editId, title: editTitle.trim() } });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
setEditId(null); setEditId(null);
} }
async function handleCreateKb() { function cancelEdit(e) {
const name = kbName.trim();
if (!name) return;
setKbCreating(true);
try {
await createKnowledgeBase(state.token, name);
setKbName("");
await loadKbs();
} catch { /* ignore */ }
setKbCreating(false);
}
async function handleDeleteKb(e, kbId) {
e.stopPropagation(); e.stopPropagation();
if (!confirm("Delete this knowledge base and all its documents?")) return; setEditId(null);
try {
await deleteKnowledgeBase(state.token, kbId);
await loadKbs();
if (expandedKb === kbId) setExpandedKb(null);
} catch { /* ignore */ }
} }
async function handleUploadDocs(kbId, files) { function selectChat(chatId) {
if (!files.length) return; dispatch({ type: "SET_ACTIVE_CHAT", chatId });
setKbUploading(true);
try {
await uploadDocuments(state.token, kbId, files);
await loadKbs();
} catch (err) {
alert("Upload failed: " + err.message);
} }
setKbUploading(false);
function handleLogout() {
dispatch({ type: "LOGOUT" });
} }
const isSuperadmin = state.user?.role === "superadmin";
return ( return (
<div className="h-full flex flex-col bg-anton-surface border-r border-anton-border"> <div className={`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`}>
{/* Logo */} {/* Header */}
<div className="p-4 pb-3"> <div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5 mb-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 shrink-0">
<Flame size={18} className="text-white" /> <Flame size={18} className="text-white" />
</div> </div>
<div> <div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-white leading-tight">Son of Anton</h1> <h1 className="text-sm font-bold text-white truncate">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">Avatar of All Elements of Code</p> <p className="text-[10px] text-anton-muted truncate">{state.user?.username || ""}</p>
</div>
</div>
</div> </div>
{mobile && (
{/* Tab switcher */} <button onClick={onClose} className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition">
<div className="px-3 pb-2 flex gap-1"> <X size={18} />
<button
onClick={() => dispatch({ type: "SET_SIDEBAR_TAB", tab: "chats" })}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition ${tab === "chats" ? "bg-anton-accent/15 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
>
<MessageSquare size={13} /> Chats
</button>
<button
onClick={() => dispatch({ type: "SET_SIDEBAR_TAB", tab: "knowledge" })}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition ${tab === "knowledge" ? "bg-green-500/15 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
>
<BookOpen size={13} /> Knowledge
</button> </button>
)}
</div> </div>
{/* Content based on tab */}
<div className="flex-1 overflow-y-auto px-3 pb-2">
{tab === "chats" ? (
<>
<button <button
onClick={onNewChat} onClick={handleNew}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-dashed border-anton-border text-anton-muted hover:text-white hover:border-anton-accent hover:bg-anton-accent/5 transition text-sm mb-2" className="w-full flex items-center justify-center gap-2 py-2.5 px-3 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition active:scale-[0.98]"
> >
<Plus size={16} /> New Chat <Plus size={16} /> New Chat
</button> </button>
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-1.5 px-1.5 space-y-0.5">
{state.chats.length === 0 && (
<p className="text-center text-anton-muted text-xs py-8 px-4">
No chats yet. Start a new conversation!
</p>
)}
{state.chats.map((chat) => {
const active = chat.id === state.activeChatId;
const editing = editId === chat.id;
<div className="space-y-0.5"> return (
{state.chats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
onClick={() => onSelectChat(chat.id)} onClick={() => !editing && selectChat(chat.id)}
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${chat.id === activeChatId className={`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
? "bg-anton-accent/15 text-white" active
: "text-anton-muted hover:bg-anton-card hover:text-white" ? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`} }`}
> >
<MessageSquare size={14} className="shrink-0 opacity-50" /> <MessageSquare size={14} className="shrink-0 opacity-50" />
{editId === chat.id ? (
<div className="flex-1 flex items-center gap-1" onClick={(e) => e.stopPropagation()}> {editing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input <input
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveRename(chat.id)} onKeyDown={(e) => { if (e.key === "Enter") confirmEdit(e); if (e.key === "Escape") cancelEdit(e); }}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-anton-accent" className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
autoFocus autoFocus
/> />
<button onClick={() => saveRename(chat.id)} className="text-anton-success"><Check size={12} /></button> <button onClick={confirmEdit} className="p-1 text-anton-success"><Check size={14} /></button>
<button onClick={() => setEditId(null)} className="text-anton-muted"><X size={12} /></button> <button onClick={cancelEdit} className="p-1 text-anton-muted"><X size={14} /></button>
</div> </div>
) : ( ) : (
<> <>
<span className="flex-1 truncate text-xs">{chat.title}</span> <span className="flex-1 text-sm truncate">{chat.title}</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => startRename(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={11} /></button> <button onClick={(e) => startEdit(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={12} /></button>
<button onClick={(e) => handleDeleteChat(e, chat.id)} className="p-1 rounded hover:bg-anton-bg text-anton-danger transition"><Trash2 size={11} /></button> <button onClick={(e) => handleDelete(e, chat.id)} className="p-1 rounded hover:bg-anton-danger/20 text-anton-danger transition"><Trash2 size={12} /></button>
</div> </div>
</> </>
)} )}
</div> </div>
))} );
})}
</div> </div>
</>
) : ( {/* Footer nav */}
/* Knowledge Base tab */ <div className="p-2 border-t border-anton-border space-y-0.5">
<>
{/* Create new KB */}
<div className="flex gap-1.5 mb-3">
<input
value={kbName}
onChange={(e) => setKbName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
placeholder="New knowledge base name…"
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-green-500 placeholder:text-anton-muted/50"
/>
<button <button
onClick={handleCreateKb} onClick={() => { navigate("/knowledge"); onClose?.(); }}
disabled={!kbName.trim() || kbCreating} className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
className="px-3 py-2 rounded-lg bg-green-600 text-white text-xs hover:bg-green-500 transition disabled:opacity-40"
> >
{kbCreating ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />} <BookOpen size={16} /> Knowledge Bases <ChevronRight size={14} className="ml-auto opacity-40" />
</button> </button>
</div> {isSuperadmin && (
<button
{kbs.length === 0 ? ( onClick={() => { navigate("/admin"); onClose?.(); }}
<div className="text-center py-8 text-anton-muted text-xs"> className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
<BookOpen size={24} className="mx-auto mb-2 opacity-30" />
<p>No knowledge bases yet.</p>
<p className="mt-1 text-[10px]">Create one above, then upload documents.</p>
</div>
) : (
<div className="space-y-1.5">
{kbs.map((kb) => (
<div key={kb.id} className="bg-anton-card border border-anton-border rounded-xl overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-anton-bg/50 transition"
onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)}
> >
<ChevronRight size={12} className={`text-anton-muted transition-transform ${expandedKb === kb.id ? "rotate-90" : ""}`} /> <Shield size={16} /> Admin Panel <ChevronRight size={14} className="ml-auto opacity-40" />
<BookOpen size={13} className="text-green-400 shrink-0" /> </button>
<div className="flex-1 min-w-0"> )}
<div className="text-xs text-white truncate">{kb.name}</div>
<div className="text-[10px] text-anton-muted">{kb.document_count} docs · {kb.chunk_count} chunks</div>
</div>
<button <button
onClick={(e) => handleDeleteKb(e, kb.id)} onClick={handleLogout}
className="p-1 rounded hover:bg-anton-bg text-anton-muted hover:text-anton-danger transition opacity-0 group-hover:opacity-100" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition min-h-[44px]"
> >
<Trash2 size={11} /> <LogOut size={16} /> Sign Out
</button> </button>
</div>
{expandedKb === kb.id && ( {/* Quota display */}
<div className="px-3 pb-3 pt-1 border-t border-anton-border/50 space-y-2 animate-fade-in"> {state.user && (
<div className="text-[10px] text-anton-muted space-y-0.5"> <div className="px-3 py-2 text-[10px] text-anton-muted">
<p>~{kb.estimated_tokens?.toLocaleString() || 0} tokens</p> <div className="flex justify-between mb-1">
{kb.description && <p>{kb.description}</p>} <span>Tokens used</span>
<span>{((state.user.tokens_used_this_month || 0) / 1000).toFixed(0)}K / {((state.user.quota_tokens_monthly || 0) / 1000).toFixed(0)}K</span>
</div> </div>
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-green-500/30 text-green-400 text-xs cursor-pointer hover:bg-green-500/5 transition"> <div className="h-1 bg-anton-border rounded-full overflow-hidden">
{kbUploading ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />} <div
{kbUploading ? "Uploading…" : "Upload documents"} className="h-full bg-anton-accent rounded-full transition-all"
<input style={{ width: `${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%` }}
type="file" multiple className="hidden"
accept=".txt,.md,.pdf,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql"
onChange={(e) => {
const files = Array.from(e.target.files || []);
if (files.length) handleUploadDocs(kb.id, files);
e.target.value = "";
}}
disabled={kbUploading}
/> />
</label>
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-anton-border">
<div className="flex items-center gap-2 text-xs text-anton-muted">
<div className="flex-1 min-w-0">
<div className="text-white font-medium truncate">{state.user?.username}</div>
<div className="text-[10px] truncate">
{((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}k / {((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}k tokens
</div> </div>
</div> </div>
{state.user?.role === "superadmin" && (
<a href="/admin" className="p-1.5 rounded-lg hover:bg-anton-card transition" title="Admin">
<Shield size={14} />
</a>
)} )}
<button
onClick={() => dispatch({ type: "LOGOUT" })}
className="p-1.5 rounded-lg hover:bg-anton-card transition"
title="Logout"
>
<LogOut size={14} />
</button>
</div>
</div> </div>
</div> </div>
); );
......
...@@ -2,251 +2,284 @@ ...@@ -2,251 +2,284 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ─── Base ─────────────────────────────────────── */ /* ═══════════════════════════════════════════════════
* { ROOT VARIABLES & BASE
═══════════════════════════════════════════════════ */
:root {
--sat: env(safe-area-inset-top, 0px);
--sar: env(safe-area-inset-right, 0px);
--sab: env(safe-area-inset-bottom, 0px);
--sal: env(safe-area-inset-left, 0px);
--header-h: 3.25rem;
color-scheme: dark;
}
/* ═══════════════════════════════════════════════════
GLOBAL RESETS FOR MOBILE
═══════════════════════════════════════════════════ */
*, *::before, *::after {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
} }
html { html {
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
height: 100dvh;
} }
body { body {
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
height: 100dvh;
overscroll-behavior: none; overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
position: fixed;
width: 100%;
top: 0;
left: 0;
} }
#root { #root {
height: 100%; height: 100%;
height: 100dvh;
overflow: hidden;
display: flex;
flex-direction: column;
} }
/* ─── Scrollbar ────────────────────────────────── */ /* ═══════════════════════════════════════════════════
::-webkit-scrollbar { SAFE AREA UTILITIES
width: 6px; ═══════════════════════════════════════════════════ */
height: 6px;
}
::-webkit-scrollbar-track { .safe-top { padding-top: var(--sat); }
background: transparent; .safe-bottom { padding-bottom: max(var(--sab), 8px); }
} .safe-left { padding-left: var(--sal); }
.safe-right { padding-right: var(--sar); }
::-webkit-scrollbar-thumb { /* ═══════════════════════════════════════════════════
background: #2a2a3a; SCROLLBAR
border-radius: 3px; ═══════════════════════════════════════════════════ */
}
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar { width: 4px; height: 4px; }
background: #3a3a4a; ::-webkit-scrollbar-track { background: transparent; }
} ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
/* ─── Prose for AI messages ────────────────────── */ /* ═══════════════════════════════════════════════════
.prose-anton { ANIMATIONS
color: #e0e0e0; ═══════════════════════════════════════════════════ */
word-break: break-word;
overflow-wrap: break-word; @keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
} }
.prose-anton p { @keyframes slideInLeft {
margin: 0.5em 0; from { transform: translateX(-100%); }
line-height: 1.7; to { transform: translateX(0); }
} }
.prose-anton p:first-child { @keyframes slideOutLeft {
margin-top: 0; from { transform: translateX(0); }
to { transform: translateX(-100%); }
} }
.prose-anton p:last-child { @keyframes fadeOverlayIn {
margin-bottom: 0; from { opacity: 0; }
to { opacity: 1; }
} }
.prose-anton strong { @keyframes fadeOverlayOut {
color: #fff; from { opacity: 1; }
font-weight: 600; to { opacity: 0; }
} }
.prose-anton em { .animate-fade-in { animation: fadeIn 0.2s ease-out both; }
color: #c0c0d0; .animate-slide-in { animation: slideInLeft 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; }
.animate-slide-out { animation: slideOutLeft 0.2s ease-in both; }
.animate-overlay-in { animation: fadeOverlayIn 0.2s ease-out both; }
.animate-overlay-out { animation: fadeOverlayOut 0.15s ease-in both; }
/* ═══════════════════════════════════════════════════
MOBILE INPUT FIXES
═══════════════════════════════════════════════════ */
textarea, input, select {
font-size: 16px !important; /* Prevents iOS zoom on focus */
} }
.prose-anton a { @media (min-width: 640px) {
color: #ff4444; textarea, input, select {
text-decoration: underline; font-size: 14px !important;
text-underline-offset: 2px; }
} }
.prose-anton a:hover { textarea {
color: #ff6666; -webkit-appearance: none;
appearance: none;
} }
.prose-anton ul, select {
.prose-anton ol { -webkit-appearance: none;
margin: 0.5em 0; appearance: none;
padding-left: 1.5em; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
} }
.prose-anton li { /* ═══════════════════════════════════════════════════
margin: 0.25em 0; TOUCH-FRIENDLY RANGE SLIDER
line-height: 1.6; ═══════════════════════════════════════════════════ */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255,255,255,0.08);
outline: none;
cursor: pointer;
} }
.prose-anton li::marker { input[type="range"]::-webkit-slider-thumb {
color: #ff4444; -webkit-appearance: none;
appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: #e53e3e;
border: 2px solid #1a1a2e;
cursor: pointer;
box-shadow: 0 0 8px rgba(229,62,62,0.3);
} }
.prose-anton code:not(pre code) { input[type="range"]::-moz-range-thumb {
background: #1a1a2e; width: 22px;
color: #ff6b6b; height: 22px;
padding: 0.15em 0.4em; border-radius: 50%;
border-radius: 4px; background: #e53e3e;
font-size: 0.88em; border: 2px solid #1a1a2e;
font-family: 'JetBrains Mono', monospace; cursor: pointer;
} }
.prose-anton blockquote { /* ═══════════════════════════════════════════════════
border-left: 3px solid #ff4444; MARKDOWN PROSE
padding-left: 1em; ═══════════════════════════════════════════════════ */
margin: 0.75em 0;
color: #a0a0b0; .prose-anton {
font-style: italic; color: #e2e2ea;
line-height: 1.65;
word-break: break-word;
overflow-wrap: anywhere;
} }
.prose-anton h1, .prose-anton h1, .prose-anton h2, .prose-anton h3,
.prose-anton h2, .prose-anton h4, .prose-anton h5, .prose-anton h6 {
.prose-anton h3,
.prose-anton h4 {
color: #fff; color: #fff;
font-weight: 700; font-weight: 600;
margin: 1em 0 0.5em; margin-top: 1.2em;
margin-bottom: 0.5em;
} }
.prose-anton h1 { .prose-anton h1 { font-size: 1.4em; }
font-size: 1.4em; .prose-anton h2 { font-size: 1.2em; }
.prose-anton h3 { font-size: 1.05em; }
.prose-anton p { margin-bottom: 0.75em; }
.prose-anton ul, .prose-anton ol {
padding-left: 1.4em;
margin-bottom: 0.75em;
} }
.prose-anton h2 { .prose-anton li { margin-bottom: 0.25em; }
font-size: 1.2em; .prose-anton li::marker { color: #555; }
.prose-anton code:not(pre code) {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 4px;
padding: 0.15em 0.35em;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
color: #ff6b6b;
word-break: break-all;
} }
.prose-anton h3 { .prose-anton a {
font-size: 1.1em; color: #e53e3e;
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-anton blockquote {
border-left: 3px solid #e53e3e;
padding: 0.5em 1em;
margin: 0.75em 0;
background: rgba(229,62,62,0.04);
border-radius: 0 6px 6px 0;
color: #aaa;
} }
.prose-anton hr { .prose-anton hr {
border-color: #2a2a3a; border: none;
border-top: 1px solid rgba(255,255,255,0.08);
margin: 1.5em 0; margin: 1.5em 0;
} }
.prose-anton table { .prose-anton table {
width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85em;
margin: 0.75em 0; margin: 0.75em 0;
width: 100%; display: block;
font-size: 0.9em; overflow-x: auto;
-webkit-overflow-scrolling: touch;
} }
.prose-anton th, .prose-anton th, .prose-anton td {
.prose-anton td { border: 1px solid rgba(255,255,255,0.08);
border: 1px solid #2a2a3a; padding: 0.4em 0.7em;
padding: 0.4em 0.75em;
text-align: left; text-align: left;
white-space: nowrap;
} }
.prose-anton th { .prose-anton th {
background: #12121c; background: rgba(255,255,255,0.04);
color: #fff;
font-weight: 600; font-weight: 600;
} }
.prose-anton img { .prose-anton strong { color: #fff; font-weight: 600; }
max-width: 100%; .prose-anton em { font-style: italic; }
border-radius: 8px;
}
/* ─── Animations ───────────────────────────────── */ /* ═══════════════════════════════════════════════════
@keyframes fade-in { THINKING PULSE
from { ═══════════════════════════════════════════════════ */
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.animate-fade-in { @keyframes thinkPulse {
animation: fade-in 0.2s ease-out; 0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }
@keyframes thinking-pulse { .thinking-pulse { animation: thinkPulse 1.5s ease-in-out infinite; }
0%, /* ═══════════════════════════════════════════════════
100% { MOBILE-SPECIFIC OVERRIDES
opacity: 1; ═══════════════════════════════════════════════════ */
}
50% {
opacity: 0.5;
}
}
.thinking-pulse { @media (max-width: 639px) {
animation: thinking-pulse 1.5s ease-in-out infinite; .prose-anton { font-size: 0.9rem; line-height: 1.6; }
.prose-anton h1 { font-size: 1.25em; }
.prose-anton h2 { font-size: 1.15em; }
} }
/* ─── Range slider ─────────────────────────────── */ /* Prevent body scroll when modal/drawer is open */
input[type="range"] { body.drawer-open {
-webkit-appearance: none; touch-action: none;
appearance: none;
width: 100%;
height: 6px;
background: #1a1a2e;
border-radius: 3px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #ff4444;
border-radius: 50%;
cursor: pointer;
border: 2px solid #0d0d14;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: #ff4444;
border-radius: 50%;
cursor: pointer;
border: 2px solid #0d0d14;
}
/* ─── Safe area for mobile ─────────────────────── */
.safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom, 0px), 0.75rem);
}
/* ─── Selection ────────────────────────────────── */
::selection {
background: rgba(255, 68, 68, 0.3);
color: #fff;
}
/* ─── Mobile keyboard fix ──────────────────────── */
@supports (height: 100dvh) {
.h-dvh {
height: 100dvh;
}
}
@supports not (height: 100dvh) {
.h-dvh {
height: 100vh;
}
} }
\ No newline at end of file
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { listChats, createChat } from "../api"; import { listChats, createChat } from "../api";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView"; import ChatView from "../components/ChatView";
import { Flame, BookOpen, Shield } from "lucide-react"; import { Flame, Menu, Plus, MessageSquare } from "lucide-react";
import { useNavigate } from "react-router-dom";
export default function ChatPage() { export default function ChatPage() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate();
const [activeChatId, setActiveChatId] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const chats = await listChats(state.token); const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats }); dispatch({ type: "SET_CHATS", chats });
if (chats.length > 0 && !activeChatId) { if (!state.activeChatId && chats.length > 0) {
setActiveChatId(chats[0].id); dispatch({ type: "SET_ACTIVE_CHAT", chatId: chats[0].id });
} }
} catch { } } catch { /* ignore */ }
})(); })();
}, [state.token, dispatch]); }, [state.token]);
async function handleNewChat() { async function handleNewChat() {
try { try {
const chat = await createChat(state.token); const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat }); dispatch({ type: "ADD_CHAT", chat });
setActiveChatId(chat.id); } catch { /* ignore */ }
setSidebarOpen(false);
} catch { }
}
function handleSelectChat(chatId) {
setActiveChatId(chatId);
setSidebarOpen(false);
} }
return ( return (
<div className="h-dvh flex bg-anton-bg text-anton-text overflow-hidden"> <div className="h-full h-dvh flex overflow-hidden bg-anton-bg">
{/* Sidebar */} {/* Desktop sidebar */}
<Sidebar <div className="hidden sm:flex">
activeChatId={activeChatId} <Sidebar />
onSelectChat={handleSelectChat} </div>
onNewChat={handleNewChat}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
{/* Main */} {/* Mobile sidebar overlay */}
<div className="flex-1 flex flex-col min-h-0 min-w-0"> {state.sidebarOpen && (
{/* Top bar */} <>
<div className="border-b border-anton-border bg-anton-surface px-3 py-2 flex items-center gap-2"> <div
<button onClick={() => setSidebarOpen(true)} className="sm:hidden p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"> className="sm:hidden fixed inset-0 z-40 bg-black/60 animate-overlay-in"
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg> onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })}
</button> />
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"> <div className="sm:hidden fixed inset-y-0 left-0 z-50 w-[280px] animate-slide-in safe-top safe-bottom">
<Flame size={14} className="text-white" /> <Sidebar mobile onClose={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })} />
</div> </div>
<span className="text-sm font-semibold text-white truncate flex-1"> </>
{state.chats.find((c) => c.id === activeChatId)?.title || "Son of Anton"} )}
</span>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile header */}
<div className="sm:hidden flex items-center gap-2 px-3 py-2.5 border-b border-anton-border bg-anton-surface safe-top">
<button <button
onClick={() => navigate("/knowledge")} onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-anton-muted hover:text-green-400 hover:bg-green-500/10 transition" className="p-2 -ml-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
title="Knowledge Bases"
> >
<BookOpen size={14} /> <Menu size={20} />
<span className="hidden sm:inline">Knowledge</span>
</button> </button>
{state.user?.role === "superadmin" && ( <div className="flex-1 min-w-0 flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shrink-0">
<Flame size={12} className="text-white" />
</div>
<span className="text-sm font-medium text-white truncate">
{state.chats.find((c) => c.id === state.activeChatId)?.title || "Son of Anton"}
</span>
</div>
<button <button
onClick={() => navigate("/admin")} onClick={handleNewChat}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-anton-muted hover:text-anton-accent hover:bg-anton-accent/10 transition" className="p-2 -mr-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
title="Admin Panel"
> >
<Shield size={14} /> <Plus size={20} />
<span className="hidden sm:inline">Admin</span>
</button> </button>
)}
</div> </div>
{/* Chat area */} {/* Chat or empty state */}
{activeChatId ? ( {state.activeChatId ? (
<ChatView chatId={activeChatId} /> <ChatView chatId={state.activeChatId} />
) : ( ) : (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center p-6">
<div className="text-center"> <div className="text-center max-w-sm">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-anton-accent/20"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={32} className="text-white" /> <Flame size={32} className="text-white" />
</div> </div>
<h2 className="text-xl font-bold text-white mb-2">Son of Anton</h2> <h2 className="text-xl font-bold text-white mb-2">Son of Anton</h2>
<p className="text-anton-muted text-sm mb-6">Avatar of All Elements of Code</p> <p className="text-anton-muted text-sm mb-6">
<button onClick={handleNewChat} className="px-6 py-2.5 rounded-xl bg-anton-accent text-white font-medium hover:opacity-80 transition"> Avatar of All Elements of Code
Start a Chat </p>
<button
onClick={handleNewChat}
className="inline-flex items-center gap-2 px-5 py-3 bg-anton-accent text-white rounded-xl font-medium hover:opacity-90 transition active:scale-95"
>
<MessageSquare size={18} /> Start a conversation
</button> </button>
</div> </div>
</div> </div>
......
import React, { useState } from "react"; import React, { useState } from "react";
import { Flame, LogIn, UserPlus, Eye, EyeOff } from "lucide-react";
import { login, register } from "../api";
import { useApp } from "../store"; import { useApp } from "../store";
import { login, register } from "../api";
import { Flame, Eye, EyeOff, Loader2 } from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
const { dispatch } = useApp(); const { dispatch } = useApp();
...@@ -18,132 +18,104 @@ export default function LoginPage() { ...@@ -18,132 +18,104 @@ export default function LoginPage() {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
let res; const res = isRegister
if (isRegister) { ? await register(username, email, password)
res = await register(username, email, password); : await login(username, password);
} else {
res = await login(username, password);
}
dispatch({ type: "LOGIN", token: res.token, user: res.user }); dispatch({ type: "LOGIN", token: res.token, user: res.user });
} catch (err) { } catch (err) {
setError(err.message); setError(err.message || "Authentication failed");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
return ( return (
<div className="h-full flex items-center justify-center bg-anton-bg p-4"> <div className="h-full h-dvh flex items-center justify-center bg-anton-bg px-4 safe-top safe-bottom">
{/* Glow effect */} <div className="w-full max-w-sm">
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-anton-accent/5 rounded-full blur-[120px] pointer-events-none" /> {/* Logo */}
<div className="relative w-full max-w-md animate-fade-in">
{/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 mb-4 shadow-lg shadow-anton-accent/20"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={40} className="text-white" /> <Flame size={32} className="text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white tracking-tight"> <h1 className="text-2xl font-bold text-white">Son of Anton</h1>
Son of Anton <p className="text-anton-muted text-sm mt-1">Avatar of All Elements of Code</p>
</h1>
<p className="text-anton-muted mt-2 text-sm">
Avatar of All Elements of Code
</p>
</div> </div>
{/* Form Card */} {/* Form */}
<form <form onSubmit={handleSubmit} className="space-y-4">
onSubmit={handleSubmit}
className="bg-anton-surface border border-anton-border rounded-2xl p-8 space-y-5 shadow-2xl"
>
<h2 className="text-xl font-semibold text-white text-center">
{isRegister ? "Create Account" : "Welcome Back"}
</h2>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
{error}
</div>
)}
<div> <div>
<label className="block text-sm text-anton-muted mb-1.5">Username</label> <label className="text-xs text-anton-muted mb-1.5 block">Username</label>
<input <input
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
className="w-full bg-anton-bg border border-anton-border rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-anton-accent transition"
placeholder="Enter username" placeholder="Enter username"
required
autoComplete="username"
autoCapitalize="off"
/> />
</div> </div>
{isRegister && ( {isRegister && (
<div> <div>
<label className="block text-sm text-anton-muted mb-1.5">Email</label> <label className="text-xs text-anton-muted mb-1.5 block">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
placeholder="your@email.com"
required required
className="w-full bg-anton-bg border border-anton-border rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-anton-accent transition" autoComplete="email"
placeholder="you@example.com"
/> />
</div> </div>
)} )}
<div> <div>
<label className="block text-sm text-anton-muted mb-1.5">Password</label> <label className="text-xs text-anton-muted mb-1.5 block">Password</label>
<div className="relative"> <div className="relative">
<input <input
type={showPw ? "text" : "password"} type={showPw ? "text" : "password"}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-white focus:outline-none focus:border-anton-accent transition"
className="w-full bg-anton-bg border border-anton-border rounded-lg px-4 py-2.5 pr-10 text-white focus:outline-none focus:border-anton-accent transition"
placeholder="••••••••" placeholder="••••••••"
required
autoComplete={isRegister ? "new-password" : "current-password"}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPw(!showPw)} onClick={() => setShowPw(!showPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition" className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition p-1"
> >
{showPw ? <EyeOff size={16} /> : <Eye size={16} />} {showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button> </button>
</div> </div>
</div> </div>
{error && (
<div className="bg-anton-danger/10 border border-anton-danger/30 text-anton-danger text-sm rounded-lg px-3 py-2.5">
{error}
</div>
)}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-gradient-to-r from-anton-accent to-orange-600 text-white font-semibold rounded-lg py-2.5 hover:opacity-90 transition disabled:opacity-50 flex items-center justify-center gap-2" className="w-full py-3.5 bg-anton-accent text-white rounded-xl font-semibold hover:opacity-90 transition disabled:opacity-50 active:scale-[0.98] flex items-center justify-center gap-2"
> >
{loading ? ( {loading && <Loader2 size={18} className="animate-spin" />}
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> {isRegister ? "Create Account" : "Sign In"}
) : isRegister ? (
<>
<UserPlus size={18} /> Create Account
</>
) : (
<>
<LogIn size={18} /> Sign In
</>
)}
</button> </button>
<p className="text-center text-sm text-anton-muted">
{isRegister ? "Already have an account?" : "Don't have an account?"}{" "}
<button <button
type="button" type="button"
onClick={() => { onClick={() => { setIsRegister(!isRegister); setError(""); }}
setIsRegister(!isRegister); className="w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
setError("");
}}
className="text-anton-accent hover:underline"
> >
{isRegister ? "Sign in" : "Register"} {isRegister ? "Already have an account? Sign in" : "Need an account? Register"}
</button> </button>
</p>
</form> </form>
</div> </div>
</div> </div>
......
import React, { createContext, useContext, useReducer, useEffect } from "react"; import React, { createContext, useContext, useReducer, useCallback } from "react";
const AppContext = createContext(null); const AppContext = createContext(null);
...@@ -6,10 +6,10 @@ const initialState = { ...@@ -6,10 +6,10 @@ const initialState = {
token: localStorage.getItem("token") || null, token: localStorage.getItem("token") || null,
user: null, user: null,
chats: [], chats: [],
activeChatId: null,
chatMessages: {}, chatMessages: {},
activeStreams: {}, activeStreams: {},
sidebarOpen: false, sidebarOpen: false,
sidebarTab: "chats", // "chats" | "knowledge"
}; };
function reducer(state, action) { function reducer(state, action) {
...@@ -17,18 +17,23 @@ function reducer(state, action) { ...@@ -17,18 +17,23 @@ function reducer(state, action) {
case "LOGIN": case "LOGIN":
localStorage.setItem("token", action.token); localStorage.setItem("token", action.token);
return { ...state, token: action.token, user: action.user }; return { ...state, token: action.token, user: action.user };
case "SET_TOKEN":
localStorage.setItem("token", action.token);
return { ...state, token: action.token };
case "SET_USER":
return { ...state, user: action.user };
case "LOGOUT": case "LOGOUT":
localStorage.removeItem("token"); localStorage.removeItem("token");
return { ...initialState, token: null }; return { ...initialState, token: null };
case "SET_USER":
return { ...state, user: action.user };
case "SET_CHATS": case "SET_CHATS":
return { ...state, chats: action.chats }; return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT": case "ADD_CHAT":
return { ...state, chats: [action.chat, ...state.chats] }; return {
...state,
chats: [action.chat, ...state.chats],
activeChatId: action.chat.id,
sidebarOpen: false,
};
case "UPDATE_CHAT": case "UPDATE_CHAT":
return { return {
...state, ...state,
...@@ -36,47 +41,49 @@ function reducer(state, action) { ...@@ -36,47 +41,49 @@ function reducer(state, action) {
c.id === action.chat.id ? { ...c, ...action.chat } : c c.id === action.chat.id ? { ...c, ...action.chat } : c
), ),
}; };
case "REMOVE_CHAT": case "REMOVE_CHAT": {
const remaining = state.chats.filter((c) => c.id !== action.chatId);
return { return {
...state, ...state,
chats: state.chats.filter((c) => c.id !== action.chatId), chats: remaining,
chatMessages: (() => { activeChatId:
const m = { ...state.chatMessages }; state.activeChatId === action.chatId
delete m[action.chatId]; ? remaining[0]?.id || null
return m; : state.activeChatId,
})(),
}; };
}
case "SET_MESSAGES": case "SET_MESSAGES":
return { return {
...state, ...state,
chatMessages: { ...state.chatMessages, [action.chatId]: action.messages }, chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
}; };
case "ADD_MESSAGE": case "ADD_MESSAGE": {
const prev = state.chatMessages[action.chatId] || [];
return { return {
...state, ...state,
chatMessages: { chatMessages: {
...state.chatMessages, ...state.chatMessages,
[action.chatId]: [ [action.chatId]: [...prev, action.message],
...(state.chatMessages[action.chatId] || []),
action.message,
],
}, },
}; };
}
case "SET_STREAMING": case "SET_STREAMING":
return { return {
...state, ...state,
activeStreams: action.streaming activeStreams: action.streaming
? { ...state.activeStreams, [action.chatId]: true } ? { ...state.activeStreams, [action.chatId]: true }
: (() => { : Object.fromEntries(
const s = { ...state.activeStreams }; Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId)
delete s[action.chatId]; ),
return s;
})(),
}; };
case "SET_SIDEBAR_OPEN": case "SET_SIDEBAR_OPEN":
return { ...state, sidebarOpen: action.open }; return { ...state, sidebarOpen: action.open };
case "SET_SIDEBAR_TAB": case "TOGGLE_SIDEBAR":
return { ...state, sidebarTab: action.tab }; return { ...state, sidebarOpen: !state.sidebarOpen };
default: default:
return state; return state;
} }
...@@ -93,6 +100,6 @@ export function AppProvider({ children }) { ...@@ -93,6 +100,6 @@ export function AppProvider({ children }) {
export function useApp() { export function useApp() {
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used within AppProvider"); if (!ctx) throw new Error("useApp must be inside AppProvider");
return ctx; return ctx;
} }
\ No newline at end of file
...@@ -5,18 +5,21 @@ export default { ...@@ -5,18 +5,21 @@ export default {
extend: { extend: {
colors: { colors: {
"anton-bg": "#09090f", "anton-bg": "#09090f",
"anton-surface": "#0d0d14", "anton-surface": "#0f0f1a",
"anton-card": "#12121c", "anton-card": "#16162a",
"anton-border": "#1e1e2e", "anton-border": "#1e1e3a",
"anton-text": "#e0e0e0", "anton-text": "#e2e2ea",
"anton-muted": "#6b6b80", "anton-muted": "#6b6b8a",
"anton-accent": "#ff4444", "anton-accent": "#e53e3e",
"anton-success": "#22c55e", "anton-success": "#48bb78",
"anton-danger": "#ef4444", "anton-danger": "#e53e3e",
}, },
fontFamily: { fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"], sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
mono: ["JetBrains Mono", "Consolas", "monospace"], mono: ["JetBrains Mono", "Fira Code", "monospace"],
},
screens: {
"xs": "400px",
}, },
}, },
}, },
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment