Commit faa34a43 authored by AGLANPC\aglan's avatar AGLANPC\aglan

im gonna

parent 6b9a6730
#!/bin/bash
# Run from project root
rm -f backend/routes/attachment_routes_15.py
rm -f backend/routes/attachment_routes_16.py
rm -f backend/routes/attachments.py
rm -f backend/routes/messages_patch.py
echo "Stale files removed."
\ No newline at end of file
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useApp } from "./store"; import { AppProvider, useApp } from "./store";
import { getMe } from "./api"; import { getMe } from "./api";
import * as streamManager from "./streamManager";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage"; import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
import KnowledgePage from "./pages/KnowledgePage"; import KnowledgePage from "./pages/KnowledgePage";
import GitLabPage from "./pages/GitLabPage"; import GitLabPage from "./pages/GitLabPage";
import { Flame } from "lucide-react";
export default function App() { function AuthGate({ children }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token);
useEffect(() => { streamManager.setDispatch(dispatch); }, [dispatch]);
useEffect(() => { useEffect(() => {
if (!state.token) { setAuthChecked(true); return; } if (state.token && !state.user) {
if (state.user) { setAuthChecked(true); return; } getMe(state.token)
(async () => { .then((user) => dispatch({ type: "SET_USER", user }))
try { .catch(() => dispatch({ type: "LOGOUT" }));
const user = await getMe(state.token); }
dispatch({ type: "SET_USER", user });
} catch { dispatch({ type: "LOGOUT" }); }
finally { setAuthChecked(true); }
})();
}, [state.token, state.user, dispatch]); }, [state.token, state.user, dispatch]);
if (!authChecked) { if (!state.token) return <LoginPage />;
if (!state.user) {
return ( return (
<div className="h-dvh flex items-center justify-center bg-anton-bg"> <div className="h-screen flex items-center justify-center bg-anton-bg">
<div className="flex flex-col items-center gap-4 animate-fade-in"> <div className="flex items-center gap-3 text-anton-muted">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20"> <div className="w-5 h-5 border-2 border-anton-accent border-t-transparent rounded-full animate-spin" />
<Flame size={32} className="text-white animate-pulse" /> Loading...
</div>
<p className="text-anton-muted text-sm">Loading...</p>
</div> </div>
</div> </div>
); );
} }
return children;
}
if (!state.token) return <LoginPage />; function AppRoutes() {
return (
<AuthGate>
<Routes>
<Route path="/" element={<ChatPage />} />
<Route path="/chat/:chatId" element={<ChatPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthGate>
);
}
export default function App() {
return ( return (
<Routes> <AppProvider>
<Route path="/admin" element={<AdminPage />} /> <BrowserRouter>
<Route path="/knowledge" element={<KnowledgePage />} /> <AppRoutes />
<Route path="/gitlab" element={<GitLabPage />} /> </BrowserRouter>
<Route path="/*" element={<ChatPage />} /> </AppProvider>
</Routes>
); );
} }
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
import React, { useState } from "react"; import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitBranch, Loader2, CheckCircle2, XCircle } from "lucide-react"; import { Copy, Check, Download, FileCode } from "lucide-react";
import { gitlabFileExists, gitlabCommitSingle } from "../api";
const COMMIT_STATES = { idle: "idle", checking: "checking", committing: "committing", success: "success", error: "error" }; export default function CodeBlock({ language, filename, code }) {
export default function CodeBlock({ language, filename, code, linkedRepo, token }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [commitState, setCommitState] = useState(COMMIT_STATES.idle);
const [commitMsg, setCommitMsg] = useState("");
const [commitError, setCommitError] = useState("");
const [showCommitForm, setShowCommitForm] = useState(false);
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
...@@ -29,120 +22,33 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token ...@@ -29,120 +22,33 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function toggleCommitForm() {
if (!showCommitForm) {
setCommitMsg(`Update ${filename || "file"} via Son of Anton`);
setCommitError("");
setCommitState(COMMIT_STATES.idle);
}
setShowCommitForm(!showCommitForm);
}
async function handleCommit() {
if (!linkedRepo || !token || !filename) return;
setCommitState(COMMIT_STATES.checking);
setCommitError("");
try {
const exists = await gitlabFileExists(token, linkedRepo.id, filename, linkedRepo.default_branch);
const action = exists ? "update" : "create";
setCommitState(COMMIT_STATES.committing);
await gitlabCommitSingle(token, linkedRepo.id, {
branch: linkedRepo.default_branch,
file_path: filename,
content: code,
commit_message: commitMsg || `${action === "create" ? "Create" : "Update"} ${filename} via Son of Anton`,
action,
});
setCommitState(COMMIT_STATES.success);
setTimeout(() => { setCommitState(COMMIT_STATES.idle); setShowCommitForm(false); }, 2500);
} catch (err) {
setCommitState(COMMIT_STATES.error);
setCommitError(err.message || "Commit failed");
}
}
const canCommit = linkedRepo && filename && token;
return ( return (
<div className="rounded-lg overflow-hidden border border-anton-border my-3"> <div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
{/* Header */} <div className="flex items-center justify-between px-4 py-2 bg-anton-card border-b border-anton-border">
<div className="flex items-center justify-between bg-[#1e1e2e] px-3 py-2 gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-anton-muted font-mono truncate min-w-0"> <FileCode size={13} className="text-anton-accent" />
{filename || language || "code"} {filename && <span className="text-xs text-white font-mono">{filename}</span>}
</span> {language && !filename && <span className="text-xs text-anton-muted font-mono">{language}</span>}
<div className="flex items-center gap-1 shrink-0"> </div>
{canCommit && ( <div className="flex items-center gap-1">
<button onClick={toggleCommitForm} title="Commit to repo" <button onClick={handleCopy}
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition ${showCommitForm className="flex items-center gap-1 px-2 py-1 text-[10px] text-anton-muted hover:text-white rounded transition">
? "bg-green-500/20 text-green-400" {copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
: "text-anton-muted hover:text-green-400 hover:bg-green-500/10" {copied ? "Copied" : "Copy"}
}`}> </button>
<GitBranch size={12} />
<span className="hidden sm:inline">Push</span>
</button>
)}
{filename && ( {filename && (
<button onClick={handleDownload} title="Download file" <button onClick={handleDownload}
className="p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition"> className="flex items-center gap-1 px-2 py-1 text-[10px] text-anton-muted hover:text-white rounded transition">
<Download size={13} /> <Download size={11} /> Save
</button> </button>
)} )}
<button onClick={handleCopy} title="Copy code"
className="p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition">
{copied ? <Check size={13} className="text-green-400" /> : <Copy size={13} />}
</button>
</div> </div>
</div> </div>
{/* Commit Form */}
{showCommitForm && canCommit && (
<div className="bg-[#16162a] border-b border-anton-border px-3 py-2.5 space-y-2 animate-fade-in">
<div className="flex items-center gap-2 text-[11px]">
<span className="text-anton-muted">Repo:</span>
<span className="text-green-400 font-mono">{linkedRepo.name}</span>
<span className="text-anton-muted"></span>
<span className="text-blue-400 font-mono">{linkedRepo.default_branch}</span>
</div>
<input
type="text" value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
placeholder="Commit message..."
className="w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-xs text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50"
/>
<div className="flex items-center gap-2">
<button onClick={handleCommit}
disabled={commitState === COMMIT_STATES.checking || commitState === COMMIT_STATES.committing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50 disabled:cursor-not-allowed">
{commitState === COMMIT_STATES.checking && <><Loader2 size={12} className="animate-spin" /> Checking...</>}
{commitState === COMMIT_STATES.committing && <><Loader2 size={12} className="animate-spin" /> Committing...</>}
{commitState === COMMIT_STATES.success && <><CheckCircle2 size={12} /> Committed!</>}
{commitState === COMMIT_STATES.error && <><XCircle size={12} /> Retry</>}
{commitState === COMMIT_STATES.idle && <><GitBranch size={12} /> Commit</>}
</button>
<button onClick={toggleCommitForm} className="px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition">
Cancel
</button>
{commitState === COMMIT_STATES.success && (
<span className="text-[11px] text-green-400">✓ Pushed to {linkedRepo.default_branch}</span>
)}
</div>
{commitError && (
<div className="text-[11px] text-red-400 bg-red-500/10 rounded px-2 py-1">{commitError}</div>
)}
</div>
)}
{/* Code */}
<SyntaxHighlighter <SyntaxHighlighter
language={language || "text"} language={language || "text"}
style={vscDarkPlus} style={oneDark}
customStyle={{ margin: 0, padding: "1rem", fontSize: "0.8rem", background: "#1a1a2e", maxHeight: "500px" }} customStyle={{ margin: 0, padding: "1rem", background: "transparent", fontSize: "0.8rem" }}
showLineNumbers wrapLongLines>
lineNumberStyle={{ minWidth: "2.5em", paddingRight: "1em", color: "#555" }}
>
{code} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
......
...@@ -8,11 +8,9 @@ import { ...@@ -8,11 +8,9 @@ import {
Image, Film, FileText, ExternalLink, Image, Film, FileText, ExternalLink,
} from "lucide-react"; } from "lucide-react";
const FILE_TYPE_ICONS = { const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
image: Image, video: Film, document: FileText, text: FileText,
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo }) { 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;
const isUser = role === "user"; const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false); const [showThinking, setShowThinking] = useState(false);
...@@ -36,7 +34,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -36,7 +34,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div> </div>
</div> </div>
)} )}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}> <div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
...@@ -54,7 +51,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -54,7 +51,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </div>
)} )}
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att) => { {attachments.map((att) => {
...@@ -94,9 +90,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -94,9 +90,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
})} })}
</div> </div>
)} )}
<div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"}`}>
<div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : ( ) : (
...@@ -112,15 +106,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -112,15 +106,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
lang = rawLang.slice(0, idx); lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1); filename = rawLang.slice(idx + 1);
} }
return ( return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
<CodeBlock
language={lang}
filename={filename}
code={String(children).replace(/\n$/, "")}
linkedRepo={linkedRepo}
token={token}
/>
);
}, },
pre({ children }) { return <>{children}</>; }, pre({ children }) { return <>{children}</>; },
}}> }}>
...@@ -132,7 +118,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -132,7 +118,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div> </div>
)} )}
</div> </div>
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1"> <div className="flex items-center gap-3 mt-1.5 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"> <button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition">
...@@ -147,7 +132,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -147,7 +132,6 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div> </div>
)} )}
</div> </div>
{isUser && ( {isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
......
This diff is collapsed.
This diff is collapsed.
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AppProvider } from "./store";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <App />
<AppProvider>
<App />
</AppProvider>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
\ No newline at end of file
This diff is collapsed.
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { listChats, createChat } from "../api"; import { listChats } from "../api";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView"; import ChatView from "../components/ChatView";
import { Flame, Menu, Plus, MessageSquare } from "lucide-react"; import { Flame } from "lucide-react";
export default function ChatPage() { export default function ChatPage() {
const { chatId } = useParams();
const navigate = useNavigate();
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
useEffect(() => { useEffect(() => {
(async () => { listChats(state.token)
try { .then((chats) => dispatch({ type: "SET_CHATS", chats }))
const chats = await listChats(state.token); .catch(() => {});
dispatch({ type: "SET_CHATS", chats }); }, [state.token, dispatch]);
if (!state.activeChatId && chats.length > 0) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId: chats[0].id });
}
} catch { /* ignore */ }
})();
}, [state.token]);
async function handleNewChat() { function onSelectChat(id) {
try { if (id) {
const chat = await createChat(state.token); navigate(`/chat/${id}`);
dispatch({ type: "ADD_CHAT", chat }); } else {
} catch { /* ignore */ } navigate("/");
}
} }
return ( return (
<div className="h-full h-dvh flex overflow-hidden bg-anton-bg"> <div className="h-screen flex overflow-hidden bg-anton-bg">
{/* Desktop sidebar */} <Sidebar activeChatId={chatId} onSelectChat={onSelectChat} />
<div className="hidden sm:flex"> <div className="flex-1 flex flex-col min-h-0">
<Sidebar /> {chatId ? (
</div> <ChatView chatId={chatId} />
{/* Mobile sidebar overlay */}
{state.sidebarOpen && (
<>
<div
className="sm:hidden fixed inset-0 z-40 bg-black/60 animate-overlay-in"
onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })}
/>
<div className="sm:hidden fixed inset-y-0 left-0 z-50 w-[280px] animate-slide-in safe-top safe-bottom">
<Sidebar mobile onClose={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })} />
</div>
</>
)}
{/* 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
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-2 -ml-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<Menu size={20} />
</button>
<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
onClick={handleNewChat}
className="p-2 -mr-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<Plus size={20} />
</button>
</div>
{/* Chat or empty state */}
{state.activeChatId ? (
<ChatView chatId={state.activeChatId} />
) : ( ) : (
<div className="flex-1 flex items-center justify-center p-6"> <div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-sm"> <div className="text-center">
<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"> <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">
<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"> <p className="text-anton-muted text-sm">Select a chat or create a new one</p>
Avatar of All Elements of Code
</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>
</div> </div>
</div> </div>
)} )}
......
This diff is collapsed.
This diff is collapsed.
import React, { useState } from "react"; import React, { useState } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { login, register } from "../api"; import { login, register } from "../api";
import { Flame, Eye, EyeOff, Loader2 } from "lucide-react"; import { Flame, LogIn, UserPlus } from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
const { dispatch } = useApp(); const { dispatch } = useApp();
...@@ -9,7 +9,6 @@ export default function LoginPage() { ...@@ -9,7 +9,6 @@ export default function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
...@@ -21,100 +20,55 @@ export default function LoginPage() { ...@@ -21,100 +20,55 @@ export default function LoginPage() {
const res = isRegister const res = isRegister
? await register(username, email, password) ? await register(username, email, password)
: await login(username, password); : await login(username, password);
dispatch({ type: "LOGIN", token: res.token, user: res.user }); dispatch({ type: "SET_AUTH", token: res.token, user: res.user });
} catch (err) { } catch (err) {
setError(err.message || "Authentication failed"); setError(err.message);
} finally {
setLoading(false);
} }
setLoading(false);
} }
return ( return (
<div className="h-full h-dvh flex items-center justify-center bg-anton-bg px-4 safe-top safe-bottom"> <div className="h-screen flex items-center justify-center bg-anton-bg">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm mx-4">
{/* Logo */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<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"> <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">
<Flame size={32} className="text-white" /> <Flame size={32} className="text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Son of Anton</h1> <h1 className="text-2xl font-bold text-white">Son of Anton</h1>
<p className="text-anton-muted text-sm mt-1">Avatar of All Elements of Code</p> <p className="text-anton-muted text-sm mt-1">Avatar of All Elements of Code</p>
</div> </div>
<form onSubmit={handleSubmit} className="bg-anton-surface border border-anton-border rounded-2xl p-6 space-y-4">
{/* Form */} {error && <div className="bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 text-red-400 text-xs">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-anton-muted mb-1.5 block">Username</label> <label className="text-xs text-anton-muted mb-1 block">Username</label>
<input <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus
type="text" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent transition" />
value={username}
onChange={(e) => setUsername(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="Enter username"
required
autoComplete="username"
autoCapitalize="off"
/>
</div> </div>
{isRegister && ( {isRegister && (
<div> <div>
<label className="text-xs text-anton-muted mb-1.5 block">Email</label> <label className="text-xs text-anton-muted mb-1 block">Email</label>
<input <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
type="email" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent transition" />
value={email}
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
autoComplete="email"
/>
</div> </div>
)} )}
<div> <div>
<label className="text-xs text-anton-muted mb-1.5 block">Password</label> <label className="text-xs text-anton-muted mb-1 block">Password</label>
<div className="relative"> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required
<input className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent transition" />
type={showPw ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
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"
placeholder="••••••••"
required
autoComplete={isRegister ? "new-password" : "current-password"}
/>
<button
type="button"
onClick={() => setShowPw(!showPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition p-1"
>
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div> </div>
<button type="submit" disabled={loading}
{error && ( className="w-full flex items-center justify-center gap-2 bg-anton-accent text-white rounded-xl py-2.5 text-sm font-medium hover:opacity-90 transition disabled:opacity-50">
<div className="bg-anton-danger/10 border border-anton-danger/30 text-anton-danger text-sm rounded-lg px-3 py-2.5"> {loading ? (
{error} <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div> ) : isRegister ? (
)} <><UserPlus size={16} /> Register</>
) : (
<button <><LogIn size={16} /> Login</>
type="submit" )}
disabled={loading}
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 && <Loader2 size={18} className="animate-spin" />}
{isRegister ? "Create Account" : "Sign In"}
</button> </button>
<button type="button" onClick={() => { setIsRegister(!isRegister); setError(""); }}
<button className="w-full text-xs text-anton-muted hover:text-white transition text-center">
type="button" {isRegister ? "Already have an account? Login" : "Need an account? Register"}
onClick={() => { setIsRegister(!isRegister); setError(""); }}
className="w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
>
{isRegister ? "Already have an account? Sign in" : "Need an account? Register"}
</button> </button>
</form> </form>
</div> </div>
......
import React, { createContext, useContext, useReducer, useCallback } from "react"; import React, { createContext, useContext, useReducer, useEffect } from "react";
import { setDispatch } from "./streamManager";
const AppContext = createContext(null); const AppContext = createContext(null);
...@@ -9,31 +10,24 @@ const initialState = { ...@@ -9,31 +10,24 @@ const initialState = {
activeChatId: null, activeChatId: null,
chatMessages: {}, chatMessages: {},
activeStreams: {}, activeStreams: {},
sidebarOpen: false, linkedRepos: [],
}; };
function reducer(state, action) { function reducer(state, action) {
switch (action.type) { switch (action.type) {
case "LOGIN": case "SET_AUTH":
localStorage.setItem("token", action.token);
return { ...state, token: action.token, user: action.user }; return { ...state, token: action.token, user: action.user };
case "LOGOUT": case "LOGOUT":
localStorage.removeItem("token"); localStorage.removeItem("token");
return { ...initialState, token: null }; return { ...initialState, token: null };
case "SET_USER": case "SET_USER":
return { ...state, user: action.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": case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false }; return { ...state, activeChatId: action.chatId };
case "ADD_CHAT": case "ADD_CHAT":
return { return { ...state, chats: [action.chat, ...state.chats] };
...state,
chats: [action.chat, ...state.chats],
activeChatId: action.chat.id,
sidebarOpen: false,
};
case "UPDATE_CHAT": case "UPDATE_CHAT":
return { return {
...state, ...state,
...@@ -41,49 +35,49 @@ function reducer(state, action) { ...@@ -41,49 +35,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: remaining, chats: state.chats.filter((c) => c.id !== action.chatId),
activeChatId: activeChatId:
state.activeChatId === action.chatId state.activeChatId === action.chatId ? null : state.activeChatId,
? remaining[0]?.id || null chatMessages: (() => {
: state.activeChatId, const m = { ...state.chatMessages };
delete m[action.chatId];
return m;
})(),
}; };
}
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]: [...prev, action.message], [action.chatId]: [
...(state.chatMessages[action.chatId] || []).filter(
(m) => !m.id.startsWith("tmp-") && !m.id.startsWith("err-") && !m.id.startsWith("gen-")
),
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( : (() => {
Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId) const s = { ...state.activeStreams };
), delete s[action.chatId];
return s;
})(),
}; };
case "SET_LINKED_REPOS":
case "SET_SIDEBAR_OPEN": return { ...state, linkedRepos: action.repos };
return { ...state, sidebarOpen: action.open };
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
default: default:
return state; return state;
} }
...@@ -91,6 +85,15 @@ function reducer(state, action) { ...@@ -91,6 +85,15 @@ function reducer(state, action) {
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
setDispatch(dispatch);
}, [dispatch]);
useEffect(() => {
if (state.token) localStorage.setItem("token", state.token);
}, [state.token]);
return ( return (
<AppContext.Provider value={{ state, dispatch }}> <AppContext.Provider value={{ state, dispatch }}>
{children} {children}
...@@ -99,7 +102,5 @@ export function AppProvider({ children }) { ...@@ -99,7 +102,5 @@ export function AppProvider({ children }) {
} }
export function useApp() { export function useApp() {
const ctx = useContext(AppContext); return useContext(AppContext);
if (!ctx) throw new Error("useApp must be inside AppProvider");
return ctx;
} }
\ No newline at end of file
import { streamMessage } from "./api"; import { streamMessage, reconnectStream } from "./api";
const _streams = new Map(); const _streams = new Map();
const _listeners = new Map(); const _listeners = new Map();
let _dispatch = null; let _dispatch = null;
export function setDispatch(dispatch) { export function setDispatch(dispatch) { _dispatch = dispatch; }
_dispatch = dispatch;
}
export function getStreamData(chatId) { export function getStreamData(chatId) {
const s = _streams.get(chatId); const s = _streams.get(chatId);
...@@ -14,9 +12,7 @@ export function getStreamData(chatId) { ...@@ -14,9 +12,7 @@ export function getStreamData(chatId) {
return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking }; return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking };
} }
export function isStreaming(chatId) { export function isStreaming(chatId) { return _streams.has(chatId); }
return _streams.has(chatId);
}
export function subscribe(chatId, cb) { export function subscribe(chatId, cb) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set()); if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
...@@ -42,24 +38,32 @@ export function abortStream(chatId) { ...@@ -42,24 +38,32 @@ export function abortStream(chatId) {
} }
} }
export function startStream({ token, chatId, body }) { function _processEvents(chatId, eventIterator, ac) {
if (_streams.has(chatId)) return; const s = _streams.get(chatId);
const ac = new AbortController(); if (!s) return;
_streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId);
(async () => { (async () => {
const s = _streams.get(chatId); let usage = {}, msgId = "";
if (!s) return;
let usage = {};
let msgId = "";
try { try {
for await (const evt of streamMessage(token, chatId, body, ac.signal)) { for await (const evt of eventIterator) {
if (ac.signal.aborted || !_streams.has(chatId)) break; if (ac.signal.aborted || !_streams.has(chatId)) break;
_handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; }); switch (evt.type) {
case "thinking_start": s.isThinking = true; _notify(chatId); break;
case "thinking_delta": s.thinking += evt.content; _notify(chatId); break;
case "thinking_end": s.isThinking = false; _notify(chatId); break;
case "text_delta": s.text += evt.content; _notify(chatId); break;
case "usage": usage = { input_tokens: evt.input_tokens, output_tokens: evt.output_tokens }; break;
case "title_update":
if (_dispatch) _dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } });
break;
case "done": msgId = evt.message_id; break;
case "error":
s.text += `\n\n**Error:** ${evt.message}`;
_notify(chatId);
break;
}
} }
if (!ac.signal.aborted && _dispatch) { if (!ac.signal.aborted && _dispatch && (s.text || s.thinking)) {
_dispatch({ _dispatch({
type: "ADD_MESSAGE", chatId, message: { type: "ADD_MESSAGE", chatId, message: {
id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text, id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text,
...@@ -85,75 +89,24 @@ export function startStream({ token, chatId, body }) { ...@@ -85,75 +89,24 @@ export function startStream({ token, chatId, body }) {
})(); })();
} }
/** export function startStream({ token, chatId, body }) {
* Reconnect to an ongoing background generation via GET /stream endpoint.
*/
export function reconnectStream({ token, chatId }) {
if (_streams.has(chatId)) return; if (_streams.has(chatId)) return;
const ac = new AbortController(); const ac = new AbortController();
_streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac }); _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true }); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId); _notify(chatId);
(async () => { const iter = streamMessage(token, chatId, body, ac.signal);
const s = _streams.get(chatId); _processEvents(chatId, iter, ac);
if (!s) return;
let usage = {};
let msgId = "";
try {
const res = await fetch(`/api/chats/${chatId}/stream`, {
headers: { Authorization: `Bearer ${token}` },
signal: ac.signal,
});
if (!res.ok) throw new Error("Reconnect failed");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try {
const evt = JSON.parse(line.slice(6));
if (ac.signal.aborted || !_streams.has(chatId)) break;
_handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; });
} catch { /* skip */ }
}
}
}
if (!ac.signal.aborted && s.text && _dispatch) {
_dispatch({
type: "ADD_MESSAGE", chatId, message: {
id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text,
thinking_content: s.thinking || null, input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0, created_at: new Date().toISOString(), attachments: [],
}
});
}
} catch { /* reconnect failed, generation may be done */ }
finally {
_streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
})();
} }
function _handleEvent(chatId, s, evt, setUsage, setMsgId) { export function reconnectToStream({ token, chatId }) {
switch (evt.type) { if (_streams.has(chatId)) return;
case "thinking_start": s.isThinking = true; _notify(chatId); break; const ac = new AbortController();
case "thinking_delta": s.thinking += evt.content; _notify(chatId); break; _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
case "thinking_end": s.isThinking = false; _notify(chatId); break; if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
case "text_delta": s.text += evt.content; _notify(chatId); break; _notify(chatId);
case "usage": setUsage({ input_tokens: evt.input_tokens, output_tokens: evt.output_tokens }); break;
case "title_update": if (_dispatch) _dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } }); break; const iter = reconnectStream(token, chatId, ac.signal);
case "done": setMsgId(evt.message_id); break; _processEvents(chatId, iter, ac);
case "error": s.text += `\n\n**Error:** ${evt.message}`; _notify(chatId); break;
}
} }
\ No newline at end of file
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: { theme: {
extend: { extend: {
colors: { colors: {
"anton-bg": "#09090f", "anton-bg": "#09090f",
"anton-surface": "#0f0f1a", "anton-surface": "#0f1017",
"anton-card": "#16162a", "anton-card": "#161822",
"anton-border": "#1e1e3a", "anton-border": "#1e2030",
"anton-text": "#e2e2ea", "anton-text": "#e4e4e7",
"anton-muted": "#6b6b8a", "anton-muted": "#71717a",
"anton-accent": "#e53e3e", "anton-accent": "#e53e3e",
"anton-success": "#48bb78", "anton-danger": "#dc2626",
"anton-danger": "#e53e3e", "anton-success": "#22c55e",
}, },
fontFamily: { fontFamily: {
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"], sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"], mono: ["JetBrains Mono", "monospace"],
},
screens: {
"xs": "400px",
}, },
}, },
}, },
......
...@@ -5,20 +5,14 @@ export default defineConfig({ ...@@ -5,20 +5,14 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
"/api": "http://localhost:80", "/api": {
target: "http://localhost:80",
changeOrigin: true,
},
}, },
}, },
build: { build: {
// Content-hash all chunks so browsers fetch new versions outDir: "dist",
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash].[ext]",
},
},
// Generate a manifest for cache-busting verification
manifest: true,
sourcemap: false, sourcemap: false,
}, },
}); });
\ No newline at end of file
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