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 ( return (
<AuthGate>
<Routes> <Routes>
<Route path="/" element={<ChatPage />} />
<Route path="/chat/:chatId" element={<ChatPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} /> <Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} /> <Route path="/gitlab" element={<GitLabPage />} />
<Route path="/*" element={<ChatPage />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</AuthGate>
);
}
export default function App() {
return (
<AppProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AppProvider>
); );
} }
\ No newline at end of file
...@@ -21,29 +21,27 @@ async function request(method, path, token, body) { ...@@ -21,29 +21,27 @@ async function request(method, path, token, body) {
return res.json(); return res.json();
} }
// ═══ Auth ═══
export const login = (username, password) => export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password }); request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) => export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password }); request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token); export const getMe = (token) => request("GET", "/auth/me", token);
// ═══ Chats ═══
export const listChats = (token) => request("GET", "/chats", token); export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data); export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const getChat = (token, chatId) => request("GET", `/chats/${chatId}`, token);
export const updateChat = (token, chatId, data) => export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data); request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) => export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title }); updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token); request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token); request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) =>
request("GET", `/chats/${chatId}/generating`, token);
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
...@@ -66,15 +64,39 @@ export async function* streamMessage(token, chatId, body, signal) { ...@@ -66,15 +64,39 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) { for (const part of parts) {
const line = part.trim(); const line = part.trim();
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { } try { yield JSON.parse(line.slice(6)); } catch {}
} }
} }
} }
if (buffer.trim().startsWith("data: ")) { if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { } try { yield JSON.parse(buffer.trim().slice(6)); } catch {}
} }
} }
export async function* reconnectStream(token, chatId, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/stream`, {
method: "GET", headers: headers(token), signal,
});
if (!res.ok) return;
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 { yield JSON.parse(line.slice(6)); } catch {}
}
}
}
}
// ═══ Attachments ═══
export async function uploadAttachments(token, chatId, files) { export async function uploadAttachments(token, chatId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
...@@ -87,34 +109,22 @@ export async function uploadAttachments(token, chatId, files) { ...@@ -87,34 +109,22 @@ export async function uploadAttachments(token, chatId, files) {
} }
return res.json(); return res.json();
} }
export function getAttachmentUrl(attachmentId) { export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`; return `${BASE}/attachments/${attachmentId}/file`;
} }
export const deleteAttachment = (token, attachmentId) => export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token); request("DELETE", `/attachments/${attachmentId}`, token);
// ═══ Knowledge Bases ═══
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token); export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") => export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description }); request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) => export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token); request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) =>
request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) => export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token); request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) =>
request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) => export const deleteKnowledgeDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token); request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export async function uploadDocuments(token, kbId, files) { export async function uploadDocuments(token, kbId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
...@@ -127,29 +137,25 @@ export async function uploadDocuments(token, kbId, files) { ...@@ -127,29 +137,25 @@ export async function uploadDocuments(token, kbId, files) {
} }
return res.json(); return res.json();
} }
export const uploadDocument = (token, kbId, file) => export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]); uploadDocuments(token, kbId, [file]);
// ═══ Admin ═══
export const adminStats = (token) => request("GET", "/admin/stats", token); export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token); export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) => export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data); request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) => export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data); request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) => export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token); request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token); export const adminListChats = (token) => request("GET", "/admin/chats", token);
export async function downloadZip(token, markdown) { // ═══ Files / Code Download ═══
export async function downloadZip(token, markdown, title) {
const res = await fetch(`${BASE}/files/download-zip`, { const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token), method: "POST", headers: headers(token),
body: JSON.stringify({ markdown }), body: JSON.stringify({ markdown, title }),
}); });
if (!res.ok) throw new Error("Download failed"); if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || ""; const ct = res.headers.get("content-type") || "";
...@@ -158,7 +164,9 @@ export async function downloadZip(token, markdown) { ...@@ -158,7 +164,9 @@ export async function downloadZip(token, markdown) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = "son-of-anton-code.zip"; const disp = res.headers.get("content-disposition") || "";
const match = disp.match(/filename="?([^"]+)"?/);
a.download = match ? match[1] : "son-of-anton-code.zip";
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
...@@ -167,61 +175,59 @@ export async function downloadZip(token, markdown) { ...@@ -167,61 +175,59 @@ export async function downloadZip(token, markdown) {
} }
} }
// ═══════════════════════════════════════════════════ // ═══ GitLab ═══
// GitLab Repository API — Son of Anton v4.1.0 export const getGitLabSettings = (token) =>
// ═══════════════════════════════════════════════════ request("GET", "/gitlab/settings", token);
export const updateGitLabSettings = (token, data) =>
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token); request("PUT", "/gitlab/settings", token, data);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data); export const testGitLabConnection = (token, data) =>
export const gitlabTestConnection = (token, data) => request("POST", "/gitlab/test-connection", token, data); request("POST", "/gitlab/test-connection", token, data);
export const gitlabSearchProjects = (token, search, owned) => export const searchGitLabProjects = (token, search = "", owned = false) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token); request("GET", `/gitlab/projects?search=${encodeURIComponent(search)}&owned=${owned}`, token);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data); export const createGitLabProject = (token, data) =>
export const gitlabLinkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId }); request("POST", "/gitlab/projects", token, data);
export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token);
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token); // Linked repos
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token); export const listLinkedRepos = (token) =>
export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token); request("GET", "/gitlab/repos", token);
export const gitlabAnalyzeProject = (token, repoId, ref) => request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token); export const linkRepo = (token, gitlabProjectId) =>
request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId });
export function gitlabGetTree(token, repoId, ref = "", path = "") { export const unlinkRepo = (token, repoId) =>
const p = new URLSearchParams(); request("DELETE", `/gitlab/repos/${repoId}`, token);
if (ref) p.set("ref", ref);
if (path) p.set("path", path); // Repo operations
return request("GET", `/gitlab/repos/${repoId}/tree?${p}`, token); export const getRepoTree = (token, repoId, path = "", ref = null) => {
} let url = `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path)}`;
if (ref) url += `&ref=${encodeURIComponent(ref)}`;
export function gitlabGetFile(token, repoId, filePath, ref = "") { return request("GET", url, token);
const p = new URLSearchParams({ path: filePath }); };
if (ref) p.set("ref", ref); export const getRepoFile = (token, repoId, path, ref = null) => {
return request("GET", `/gitlab/repos/${repoId}/file?${p}`, token); let url = `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}`;
} if (ref) url += `&ref=${encodeURIComponent(ref)}`;
return request("GET", url, token);
export async function gitlabFileExists(token, repoId, filePath, ref = "") { };
try { export const getRepoBranches = (token, repoId) =>
await gitlabGetFile(token, repoId, filePath, ref); request("GET", `/gitlab/repos/${repoId}/branches`, token);
return true; export const createRepoBranch = (token, repoId, branchName, ref = "main") =>
} catch { request("POST", `/gitlab/repos/${repoId}/branches`, token, { branch_name: branchName, ref });
return false; export const commitToRepo = (token, repoId, data) =>
} request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
} export const commitSingleFile = (token, repoId, data) =>
request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
export function gitlabCommitSingle(token, repoId, data) { export const createMergeRequest = (token, repoId, data) =>
return request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data); request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
} export const analyzeProject = (token, repoId, ref = null) => {
let url = `/gitlab/repos/${repoId}/analyze`;
export function gitlabCommitMulti(token, repoId, data) { if (ref) url += `?ref=${encodeURIComponent(ref)}`;
return request("POST", `/gitlab/repos/${repoId}/commit`, token, data); return request("GET", url, token);
} };
export function gitlabGetBranches(token, repoId) { // Pending actions
return request("GET", `/gitlab/repos/${repoId}/branches`, token); export const listPendingActions = (token, status = "pending") =>
} request("GET", `/gitlab/actions?status=${status}`, token);
export const createAction = (token, data) =>
export function gitlabCreateBranch(token, repoId, data) { request("POST", "/gitlab/actions", token, data);
return request("POST", `/gitlab/repos/${repoId}/branches`, token, data); export const approveAction = (token, actionId) =>
} request("POST", `/gitlab/actions/${actionId}/approve`, token);
export const rejectAction = (token, actionId) =>
export function gitlabListRepos(token) { request("POST", `/gitlab/actions/${actionId}/reject`, token);
return request("GET", `/gitlab/repos`, token); \ No newline at end of file
}
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api"; import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
uploadAttachments, listLinkedRepos, checkGenerating,
} from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import RepoFilePanel from "./RepoFilePanel"; import {
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, GitBranch } from "lucide-react"; Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, GitBranch,
} from "lucide-react";
const MODELS = [ const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" }, { id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
...@@ -14,8 +19,8 @@ const MODELS = [ ...@@ -14,8 +19,8 @@ const MODELS = [
function classifyFile(file) { function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase(); const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || ""; const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].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";
} }
...@@ -25,16 +30,16 @@ export default function ChatView({ chatId }) { ...@@ -25,16 +30,16 @@ export default function ChatView({ chatId }) {
const currentChat = state.chats.find((c) => c.id === chatId); const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || []; const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId]; const isStreamingGlobal = !!state.activeStreams[chatId];
const linkedRepo = currentChat?.linked_repo || null;
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showRepoPanel, setShowRepoPanel] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id); const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
...@@ -50,6 +55,16 @@ export default function ChatView({ chatId }) { ...@@ -50,6 +55,16 @@ export default function ChatView({ chatId }) {
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]); }, [chatId]);
useEffect(() => {
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
setSelectedRepoId(currentChat.linked_repo_id || null);
}
}, [chatId, currentChat?.id]);
function onScroll() { function onScroll() {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
...@@ -67,21 +82,50 @@ export default function ChatView({ chatId }) { ...@@ -67,21 +82,50 @@ export default function ChatView({ chatId }) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]); const [msgs, kbData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData); setKbs(kbData);
} catch { }
// Load linked repos if superadmin
if (state.user?.role === "superadmin") {
try {
const repoData = await listLinkedRepos(state.token);
setRepos(repoData);
dispatch({ type: "SET_LINKED_REPOS", repos: repoData });
} catch {}
}
// Check if there's an active generation we should reconnect to
try {
const { active } = await checkGenerating(state.token, chatId);
if (active && !streamManager.isStreaming(chatId)) {
streamManager.reconnectToStream({ token: state.token, chatId });
}
} catch {}
} catch {}
})(); })();
}, [chatId, state.token, dispatch]); }, [chatId, state.token, dispatch, state.user?.role]);
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]);
async function saveSettings() { async function saveSettings() {
try { try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" }); await updateChat(state.token, chatId, {
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } }); model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
} catch { } knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "",
});
dispatch({
type: "UPDATE_CHAT",
chat: {
id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId,
},
});
} catch {}
} }
function toggleSettings() { function toggleSettings() {
...@@ -91,12 +135,21 @@ export default function ChatView({ chatId }) { ...@@ -91,12 +135,21 @@ export default function ChatView({ chatId }) {
function handleFileSelect(e) { function handleFileSelect(e) {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.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,
})),
]);
e.target.value = ""; e.target.value = "";
} }
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() {
...@@ -115,14 +168,29 @@ export default function ChatView({ chatId }) { ...@@ -115,14 +168,29 @@ 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;
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } }); dispatch({
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } }); type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId },
});
streamManager.startStream({
token: state.token, 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(); } }
...@@ -131,29 +199,32 @@ export default function ChatView({ chatId }) { ...@@ -131,29 +199,32 @@ export default function ChatView({ chatId }) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/")); const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return; if (!imgs.length) return;
e.preventDefault(); e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]); setPendingFiles((prev) => [
...prev,
...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; }),
]);
} }
function handleDrop(e) { function handleDrop(e) {
e.preventDefault(); e.preventDefault();
const files = Array.from(e.dataTransfer?.files || []); const files = Array.from(e.dataTransfer?.files || []);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); if (files.length) setPendingFiles((prev) => [
...prev,
...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null })),
]);
} }
const streaming = streamData.streaming; const streaming = streamData.streaming;
const selectedRepo = repos.find((r) => r.id === selectedRepoId);
return ( return (
<div className="flex-1 flex min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}> <div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{/* Main Chat Column */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4"> <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => ( {messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
<MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} />
))}
{streaming && (streamData.thinking || streamData.text) && ( {streaming && (streamData.thinking || streamData.text) && (
<MessageBubble <MessageBubble
message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }}
isStreaming isThinking={streamData.isThinking} token={state.token} linkedRepo={linkedRepo} isStreaming isThinking={streamData.isThinking} token={state.token}
/> />
)} )}
{streaming && !streamData.text && !streamData.thinking && ( {streaming && !streamData.text && !streamData.thinking && (
...@@ -168,35 +239,61 @@ export default function ChatView({ chatId }) { ...@@ -168,35 +239,61 @@ export default function ChatView({ chatId }) {
)} )}
</div> </div>
{/* Input Area */}
<div className="border-t border-anton-border bg-anton-surface p-4"> <div className="border-t border-anton-border bg-anton-surface p-4">
{showSettings && ( {showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"> <div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<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">
<Settings2 size={14} className="text-anton-accent" /> Chat Settings
</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button> <button onClick={toggleSettings} className="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 text-white text-sm 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">
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} /> <span className="text-anton-muted">Max Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" />
</div> </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">
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} /> <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>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} className="w-full" />
</div> </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"><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 text-white text-sm focus:outline-none focus:border-anton-accent"> <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">
<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>
</div> </div>
{state.user?.role === "superadmin" && repos.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} /> Linked Repository</label>
<select value={selectedRepoId || ""} onChange={(e) => setSelectedRepoId(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">
<option value="">None</option>
{repos.map((r) => <option key={r.id} value={r.id}>{r.name} ({r.path_with_namespace})</option>)}
</select>
{selectedRepo && (
<div className="mt-1.5 text-[10px] text-blue-400 flex items-center gap-1">
<GitBranch size={10} />
<span>{selectedRepo.path_with_namespace}</span>
<span className="text-anton-muted">{selectedRepo.default_branch}</span>
</div>
)}
</div>
)}
</div> </div>
)} )}
...@@ -212,23 +309,31 @@ export default function ChatView({ chatId }) { ...@@ -212,23 +309,31 @@ export default function ChatView({ chatId }) {
<span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span> <span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
</div> </div>
)} )}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button> <button onClick={() => removePending(i)}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div> className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity">
<X size={10} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">
{(pf.file.size / 1024).toFixed(0)}KB
</div>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<button onClick={toggleSettings} className={`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> <button onClick={toggleSettings}
<button onClick={() => fileRef.current?.click()} className={`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> className={`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"}`}>
{linkedRepo && ( <Settings2 size={18} />
<button onClick={() => setShowRepoPanel(!showRepoPanel)} title={`${linkedRepo.name} — File Browser`}
className={`p-2.5 rounded-xl transition shrink-0 ${showRepoPanel ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-green-400 hover:bg-anton-card"}`}>
<GitBranch size={18} />
</button> </button>
)} <button onClick={() => fileRef.current?.click()}
<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,.dart,.vue,.svelte,.log" onChange={handleFileSelect} /> className={`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>
<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,.dart,.vue,.svelte,.log"
onChange={handleFileSelect} />
<div className="flex-1 relative"> <div className="flex-1 relative">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"} placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
...@@ -237,7 +342,10 @@ export default function ChatView({ chatId }) { ...@@ -237,7 +342,10 @@ export default function ChatView({ chatId }) {
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} /> onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
</div> </div>
{streaming ? ( {streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="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">
<Square size={18} />
</button>
) : ( ) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading} <button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"> className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
...@@ -246,25 +354,21 @@ export default function ChatView({ chatId }) { ...@@ -246,25 +354,21 @@ export default function ChatView({ chatId }) {
)} )}
</div> </div>
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted"> <div className="flex items-center gap-3 mt-2 text-[11px] 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()} tokens</span> <span></span><span>{maxTokens.toLocaleString()} tokens</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></>}
{linkedRepo && <><span></span><span className="text-green-400">🔗 {linkedRepo.name}</span></>} {selectedRepoId && <><span></span><span className="text-blue-400">🔗 {selectedRepo?.name || "Repo"}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</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 { } }} <button onClick={async () => {
className="ml-auto hover:text-anton-accent transition">⬇ Download code</button> 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, currentChat?.title); } catch {}
}} className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Repo File Panel (slides in from right) */}
{showRepoPanel && linkedRepo && (
<RepoFilePanel linkedRepo={linkedRepo} token={state.token} onClose={() => setShowRepoPanel(false)} />
)}
</div>
); );
} }
\ No newline at end of file
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">
<span className="text-xs text-anton-muted font-mono truncate min-w-0">
{filename || language || "code"}
</span>
<div className="flex items-center gap-1 shrink-0">
{canCommit && (
<button onClick={toggleCommitForm} title="Commit to repo"
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition ${showCommitForm
? "bg-green-500/20 text-green-400"
: "text-anton-muted hover:text-green-400 hover:bg-green-500/10"
}`}>
<GitBranch size={12} />
<span className="hidden sm:inline">Push</span>
</button>
)}
{filename && (
<button onClick={handleDownload} title="Download file"
className="p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition">
<Download size={13} />
</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>
{/* 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"> <div className="flex items-center gap-2">
<button onClick={handleCommit} <FileCode size={13} className="text-anton-accent" />
disabled={commitState === COMMIT_STATES.checking || commitState === COMMIT_STATES.committing} {filename && <span className="text-xs text-white font-mono">{filename}</span>}
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"> {language && !filename && <span className="text-xs text-anton-muted font-mono">{language}</span>}
{commitState === COMMIT_STATES.checking && <><Loader2 size={12} className="animate-spin" /> Checking...</>} </div>
{commitState === COMMIT_STATES.committing && <><Loader2 size={12} className="animate-spin" /> Committing...</>} <div className="flex items-center gap-1">
{commitState === COMMIT_STATES.success && <><CheckCircle2 size={12} /> Committed!</>} <button onClick={handleCopy}
{commitState === COMMIT_STATES.error && <><XCircle size={12} /> Retry</>} className="flex items-center gap-1 px-2 py-1 text-[10px] text-anton-muted hover:text-white rounded transition">
{commitState === COMMIT_STATES.idle && <><GitBranch size={12} /> Commit</>} {copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button> </button>
<button onClick={toggleCommitForm} className="px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition"> {filename && (
Cancel <button onClick={handleDownload}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-anton-muted hover:text-white rounded transition">
<Download size={11} /> Save
</button> </button>
{commitState === COMMIT_STATES.success && (
<span className="text-[11px] text-green-400">✓ Pushed to {linkedRepo.default_branch}</span>
)} )}
</div> </div>
{commitError && (
<div className="text-[11px] text-red-400 bg-red-500/10 rounded px-2 py-1">{commitError}</div>
)}
</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">
......
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { listChats, createChat, deleteChat, renameChat } from "../api"; import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import { import {
Plus, Trash2, MessageSquare, Flame, LogOut, Shield, BookOpen, Plus, MessageSquare, Trash2, Pencil, Check, X, LogOut,
Edit3, Check, X, GitBranch, Shield, BookOpen, GitBranch, Flame,
} from "lucide-react"; } from "lucide-react";
export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose }) { export default function Sidebar({ activeChatId, onSelectChat }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const nav = useNavigate(); const navigate = useNavigate();
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
useEffect(() => { async function handleNewChat() {
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { }
})();
}, [state.token, dispatch]);
async function handleNew() {
try { try {
const chat = await createChat(state.token); const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat }); dispatch({ type: "ADD_CHAT", chat });
onSelectChat(chat.id); onSelectChat(chat.id);
} catch { } } catch {}
} }
async function handleDelete(e, chatId) { async function handleDelete(e, chatId) {
...@@ -37,60 +28,66 @@ export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose }) ...@@ -37,60 +28,66 @@ export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose })
await deleteChat(state.token, chatId); await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId }); dispatch({ type: "REMOVE_CHAT", chatId });
if (activeChatId === chatId) onSelectChat(null); if (activeChatId === chatId) onSelectChat(null);
} catch { } } catch {}
}
function startEdit(e, chat) {
e.stopPropagation();
setEditId(chat.id);
setEditTitle(chat.title);
} }
async function handleRename(chatId) { async function saveEdit(e) {
if (!editTitle.trim()) { setEditId(null); return; } e.stopPropagation();
if (!editTitle.trim()) return;
try { try {
await renameChat(state.token, chatId, editTitle.trim()); await renameChat(state.token, editId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } }); dispatch({ type: "UPDATE_CHAT", chat: { id: editId, title: editTitle.trim() } });
} catch { } } catch {}
setEditId(null); setEditId(null);
} }
const isSuperadmin = state.user?.role === "superadmin"; const isSuperadmin = state.user?.role === "superadmin";
return ( return (
<> <div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col h-full shrink-0">
{isOpen && <div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onClose} />} <div className="p-4 border-b border-anton-border">
<div className={`fixed md:static z-50 inset-y-0 left-0 w-72 bg-anton-surface border-r border-anton-border flex flex-col transition-transform duration-200 ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`}> <div className="flex items-center gap-2 mb-4">
{/* Header */}
<div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
<Flame size={16} className="text-white" /> <Flame size={16} className="text-white" />
</div> </div>
<div> <span className="font-bold text-white text-sm">Son of Anton</span>
<h1 className="text-sm font-bold text-white">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">v4.0.0 — The Architect</p>
</div> </div>
</div> <button onClick={handleNewChat}
<button onClick={handleNew} className="w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-80 transition"> className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition">
<Plus size={16} /> New Chat <Plus size={16} /> New Chat
</button> </button>
</div> </div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5"> <div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{state.chats.map((c) => ( {state.chats.map((chat) => (
<div key={c.id} onClick={() => { onSelectChat(c.id); onClose?.(); }} <div key={chat.id} onClick={() => onSelectChat(chat.id)}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition text-sm ${activeChatId === c.id ? "bg-anton-accent/15 text-white" : "text-anton-muted hover:bg-anton-card hover:text-white"}`}> className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${
activeChatId === chat.id
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white"
}`}>
<MessageSquare size={14} className="shrink-0" /> <MessageSquare size={14} className="shrink-0" />
{editId === c.id ? ( {editId === chat.id ? (
<div className="flex-1 flex items-center gap-1"> <div className="flex-1 flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)} <input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white" autoFocus /> onKeyDown={(e) => e.key === "Enter" && saveEdit(e)}
<button onClick={() => handleRename(c.id)} className="text-green-400"><Check size={12} /></button> 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" />
<button onClick={() => setEditId(null)} className="text-red-400"><X size={12} /></button> <button onClick={saveEdit}><Check size={12} className="text-green-400" /></button>
<button onClick={() => setEditId(null)}><X size={12} className="text-red-400" /></button>
</div> </div>
) : ( ) : (
<> <>
<span className="flex-1 truncate text-xs">{c.title}</span> <span className="flex-1 truncate">{chat.title}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> {chat.linked_repo_id && <GitBranch size={11} className="text-blue-400 shrink-0" />}
{c.linked_repo_id && <GitBranch size={11} className="text-orange-400" />} <div className="hidden group-hover:flex items-center gap-0.5">
<button onClick={(e) => { e.stopPropagation(); setEditId(c.id); setEditTitle(c.title); }} className="p-0.5 hover:text-anton-accent"><Edit3 size={11} /></button> <button onClick={(e) => startEdit(e, chat)} className="p-0.5 hover:text-anton-accent"><Pencil size={11} /></button>
<button onClick={(e) => handleDelete(e, c.id)} className="p-0.5 hover:text-red-400"><Trash2 size={11} /></button> <button onClick={(e) => handleDelete(e, chat.id)} className="p-0.5 hover:text-anton-danger"><Trash2 size={11} /></button>
</div> </div>
</> </>
)} )}
...@@ -98,27 +95,30 @@ export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose }) ...@@ -98,27 +95,30 @@ export default function Sidebar({ activeChatId, onSelectChat, isOpen, onClose })
))} ))}
</div> </div>
{/* Footer */} <div className="p-3 border-t border-anton-border space-y-1">
<div className="p-2 border-t border-anton-border space-y-0.5">
{isSuperadmin && ( {isSuperadmin && (
<> <>
<button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition"> <button onClick={() => navigate("/gitlab")}
<GitBranch size={14} /> GitLab Center className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<GitBranch size={14} /> GitLab
</button> </button>
<button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"> <button onClick={() => navigate("/admin")}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<Shield size={14} /> Admin <Shield size={14} /> Admin
</button> </button>
</> </>
)} )}
<button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition"> <button onClick={() => navigate("/knowledge")}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<BookOpen size={14} /> Knowledge <BookOpen size={14} /> Knowledge
</button> </button>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-red-400 transition"> <div className="flex items-center justify-between px-3 py-2">
<LogOut size={14} /> Logout <span className="text-xs text-anton-muted truncate">{state.user?.username}</span>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="text-anton-muted hover:text-anton-danger transition">
<LogOut size={14} />
</button> </button>
<div className="px-3 py-1 text-[10px] text-anton-muted">{state.user?.username}{state.user?.role}</div>
</div> </div>
</div> </div>
</> </div>
); );
} }
\ No newline at end of file
...@@ -2,284 +2,105 @@ ...@@ -2,284 +2,105 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ═══════════════════════════════════════════════════ * {
ROOT VARIABLES & BASE scrollbar-width: thin;
═══════════════════════════════════════════════════ */ scrollbar-color: #1e2030 transparent;
: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;
} }
/* ═══════════════════════════════════════════════════ *::-webkit-scrollbar {
GLOBAL RESETS FOR MOBILE width: 6px;
═══════════════════════════════════════════════════ */ height: 6px;
*, *::before, *::after {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
} }
*::-webkit-scrollbar-track {
html { background: transparent;
overflow: hidden;
height: 100%;
height: 100dvh;
} }
*::-webkit-scrollbar-thumb {
body { background: #1e2030;
overflow: hidden; border-radius: 3px;
height: 100%; }
height: 100dvh; *::-webkit-scrollbar-thumb:hover {
overscroll-behavior: none; background: #2a2d3e;
-webkit-overflow-scrolling: touch;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
position: fixed;
width: 100%;
top: 0;
left: 0;
} }
#root { html, body, #root {
height: 100%; height: 100%;
height: 100dvh;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
} }
/* ═══════════════════════════════════════════════════ body {
SAFE AREA UTILITIES -webkit-font-smoothing: antialiased;
═══════════════════════════════════════════════════ */ -moz-osx-font-smoothing: grayscale;
.safe-top { padding-top: var(--sat); }
.safe-bottom { padding-bottom: max(var(--sab), 8px); }
.safe-left { padding-left: var(--sal); }
.safe-right { padding-right: var(--sar); }
/* ═══════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════ */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-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); }
/* ═══════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes slideOutLeft {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
@keyframes fadeOverlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOverlayOut {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-fade-in { animation: fadeIn 0.2s ease-out both; }
.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 */
}
@media (min-width: 640px) {
textarea, input, select {
font-size: 14px !important;
}
}
textarea {
-webkit-appearance: none;
appearance: none;
}
select {
-webkit-appearance: none;
appearance: none;
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;
} }
/* ═══════════════════════════════════════════════════
TOUCH-FRIENDLY RANGE SLIDER
═══════════════════════════════════════════════════ */
input[type="range"] { input[type="range"] {
-webkit-appearance: none; @apply w-full h-1.5 bg-anton-border rounded-lg appearance-none cursor-pointer;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255,255,255,0.08);
outline: none;
cursor: pointer;
} }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; @apply appearance-none w-4 h-4 bg-anton-accent rounded-full cursor-pointer;
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);
} }
input[type="range"]::-moz-range-thumb { input[type="range"]::-moz-range-thumb {
width: 22px; @apply w-4 h-4 bg-anton-accent rounded-full cursor-pointer border-0;
height: 22px;
border-radius: 50%;
background: #e53e3e;
border: 2px solid #1a1a2e;
cursor: pointer;
} }
/* ═══════════════════════════════════════════════════
MARKDOWN PROSE
═══════════════════════════════════════════════════ */
.prose-anton { .prose-anton {
color: #e2e2ea; @apply text-anton-text leading-relaxed;
line-height: 1.65;
word-break: break-word;
overflow-wrap: anywhere;
} }
.prose-anton p {
.prose-anton h1, .prose-anton h2, .prose-anton h3, @apply mb-3;
.prose-anton h4, .prose-anton h5, .prose-anton h6 {
color: #fff;
font-weight: 600;
margin-top: 1.2em;
margin-bottom: 0.5em;
} }
.prose-anton p:last-child {
.prose-anton h1 { font-size: 1.4em; } @apply mb-0;
.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 h1, .prose-anton h2, .prose-anton h3, .prose-anton h4 {
.prose-anton li { margin-bottom: 0.25em; } @apply font-bold text-white mt-4 mb-2;
.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 h1 { @apply text-xl; }
.prose-anton a { .prose-anton h2 { @apply text-lg; }
color: #e53e3e; .prose-anton h3 { @apply text-base; }
text-decoration: underline; .prose-anton ul, .prose-anton ol {
text-underline-offset: 2px; @apply mb-3 pl-6;
}
.prose-anton ul { @apply list-disc; }
.prose-anton ol { @apply list-decimal; }
.prose-anton li {
@apply mb-1;
} }
.prose-anton blockquote { .prose-anton blockquote {
border-left: 3px solid #e53e3e; @apply border-l-2 border-anton-accent pl-4 italic text-anton-muted my-3;
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 a {
.prose-anton hr { @apply text-anton-accent underline hover:opacity-80;
border: none; }
border-top: 1px solid rgba(255,255,255,0.08); .prose-anton code:not(pre code) {
margin: 1.5em 0; @apply bg-anton-card border border-anton-border rounded px-1.5 py-0.5 text-xs font-mono text-white;
} }
.prose-anton table { .prose-anton table {
width: 100%; @apply w-full border-collapse mb-3;
border-collapse: collapse;
font-size: 0.85em;
margin: 0.75em 0;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
} }
.prose-anton th, .prose-anton td { .prose-anton th, .prose-anton td {
border: 1px solid rgba(255,255,255,0.08); @apply border border-anton-border px-3 py-1.5 text-sm;
padding: 0.4em 0.7em;
text-align: left;
white-space: nowrap;
} }
.prose-anton th { .prose-anton th {
background: rgba(255,255,255,0.04); @apply bg-anton-card font-semibold text-white;
font-weight: 600;
} }
.prose-anton hr {
.prose-anton strong { color: #fff; font-weight: 600; } @apply border-anton-border my-4;
.prose-anton em { font-style: italic; } }
.prose-anton strong {
/* ═══════════════════════════════════════════════════ @apply font-semibold text-white;
THINKING PULSE }
═══════════════════════════════════════════════════ */ .prose-anton em {
@apply italic;
@keyframes thinkPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }
.thinking-pulse { animation: thinkPulse 1.5s ease-in-out infinite; } @keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
/* ═══════════════════════════════════════════════════ to { opacity: 1; transform: translateY(0); }
MOBILE-SPECIFIC OVERRIDES }
═══════════════════════════════════════════════════ */ .animate-fade-in {
animation: fade-in 0.2s ease-out;
@media (max-width: 639px) {
.prose-anton { font-size: 0.9rem; line-height: 1.6; }
.prose-anton h1 { font-size: 1.25em; }
.prose-anton h2 { font-size: 1.15em; }
} }
/* Prevent body scroll when modal/drawer is open */ .thinking-pulse {
body.drawer-open { animation: pulse 1.5s ease-in-out infinite;
touch-action: none;
} }
\ No newline at end of file
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>
<AppProvider>
<App /> <App />
</AppProvider>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
\ No newline at end of file
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { import { adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser } from "../api";
adminStats, import { ArrowLeft, Shield, Users, MessageSquare, Coins, Plus, Trash2, ToggleLeft, ToggleRight } from "lucide-react";
adminListUsers,
adminCreateUser,
adminUpdateUser,
adminDeleteUser,
} from "../api";
import {
ArrowLeft, Users, MessageSquare, Database, Zap,
UserPlus, Trash2, Shield, ShieldOff, Save, X,
} from "lucide-react";
export default function AdminPage() { export default function AdminPage() {
const { state } = useApp(); const { state } = useApp();
...@@ -19,27 +10,17 @@ export default function AdminPage() { ...@@ -19,27 +10,17 @@ export default function AdminPage() {
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [editId, setEditId] = useState(null); const [newUser, setNewUser] = useState({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
const [editData, setEditData] = useState({});
const [newUser, setNewUser] = useState({
username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000,
});
const [error, setError] = useState("");
const load = useCallback(async () => { useEffect(() => { load(); }, []);
async function load() {
try { try {
const [s, u] = await Promise.all([ const [s, u] = await Promise.all([adminStats(state.token), adminListUsers(state.token)]);
adminStats(state.token),
adminListUsers(state.token),
]);
setStats(s); setStats(s);
setUsers(u); setUsers(u);
} catch (err) { } catch {}
setError(err.message);
} }
}, [state.token]);
useEffect(() => { load(); }, [load]);
async function handleCreate(e) { async function handleCreate(e) {
e.preventDefault(); e.preventDefault();
...@@ -48,212 +29,82 @@ export default function AdminPage() { ...@@ -48,212 +29,82 @@ export default function AdminPage() {
setShowCreate(false); setShowCreate(false);
setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 }); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
load(); load();
} catch (err) { setError(err.message); } } catch {}
}
async function handleSaveEdit(userId) {
try {
await adminUpdateUser(state.token, userId, editData);
setEditId(null);
load();
} catch (err) { setError(err.message); }
} }
async function handleDelete(userId, username) { async function toggleActive(u) {
if (!confirm(`Delete user "${username}"? This is permanent.`)) return;
try { try {
await adminDeleteUser(state.token, userId); await adminUpdateUser(state.token, u.id, { is_active: !u.is_active });
load(); load();
} catch (err) { setError(err.message); } } catch {}
} }
function formatNum(n) { async function handleDelete(u) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; if (!confirm(`Delete ${u.username}?`)) return;
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K"; try { await adminDeleteUser(state.token, u.id); load(); } catch {}
return String(n);
} }
if (state.user?.role !== "superadmin") {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-screen flex flex-col bg-anton-bg">
<p className="text-anton-danger text-lg">⛔ Access Denied</p> <div className="border-b border-anton-border bg-anton-surface px-6 py-4 flex items-center gap-4">
</div> <button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition"><ArrowLeft size={20} /></button>
); <Shield size={20} className="text-anton-accent" />
} <h1 className="text-lg font-bold text-white">Admin Dashboard</h1>
return (
<div className="h-full overflow-y-auto bg-anton-bg p-6">
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={() => navigate("/")} className="p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition">
<ArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield size={24} className="text-anton-accent" /> Admin Panel
</h1>
<p className="text-anton-muted text-sm">Manage everything</p>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
{error}
<button onClick={() => setError("")} className="ml-2 text-red-300 hover:text-white"></button>
</div> </div>
)} <div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Stats */}
{stats && ( {stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[ {[
{ label: "Users", value: stats.total_users, icon: Users, color: "text-blue-400" }, { label: "Users", val: stats.total_users, icon: Users },
{ label: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-green-400" }, { label: "Chats", val: stats.total_chats, icon: MessageSquare },
{ label: "Messages", value: formatNum(stats.total_messages), icon: Zap, color: "text-anton-accent" }, { label: "Messages", val: stats.total_messages, icon: MessageSquare },
{ label: "Tokens Used", value: formatNum(stats.total_tokens_used), icon: Database, color: "text-purple-400" }, { label: "Tokens Used", val: (stats.total_tokens_used || 0).toLocaleString(), icon: Coins },
].map((s) => ( ].map((s) => (
<div key={s.label} className="bg-anton-surface border border-anton-border rounded-xl p-4"> <div key={s.label} className="bg-anton-card border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 text-anton-muted text-xs mb-1"><s.icon size={12} />{s.label}</div>
<s.icon size={16} className={s.color} /> <div className="text-xl font-bold text-white">{s.val}</div>
<span className="text-anton-muted text-sm">{s.label}</span>
</div>
<p className="text-2xl font-bold text-white">{s.value}</p>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="flex items-center justify-between">
{/* User Management */} <h2 className="text-sm font-semibold text-white">Users</h2>
<div className="bg-anton-surface border border-anton-border rounded-xl overflow-hidden"> <button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-1 text-xs text-anton-accent hover:text-white transition">
<div className="px-5 py-4 border-b border-anton-border flex items-center justify-between"> <Plus size={12} /> Create User
<h2 className="text-lg font-semibold text-white">Users</h2>
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-accent text-white rounded-lg text-sm font-medium hover:opacity-90 transition"
>
{showCreate ? <X size={14} /> : <UserPlus size={14} />}
{showCreate ? "Cancel" : "New User"}
</button> </button>
</div> </div>
{showCreate && ( {showCreate && (
<form onSubmit={handleCreate} className="px-5 py-4 border-b border-anton-border bg-anton-card grid grid-cols-1 md:grid-cols-3 gap-3"> <form onSubmit={handleCreate} className="bg-anton-card border border-anton-border rounded-xl p-4 grid grid-cols-2 gap-3">
<input placeholder="Username" required value={newUser.username} <input placeholder="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} required
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" <input placeholder="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} required
/> className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Email" type="email" required value={newUser.email} <input placeholder="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} required
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
className="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={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
/> className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<input placeholder="Password" required value={newUser.password} <option value="user">User</option><option value="admin">Admin</option>
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="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={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="superadmin">Superadmin</option>
</select> </select>
<input placeholder="Monthly quota" type="number" value={newUser.quota_tokens_monthly} <button type="submit" className="col-span-2 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-90 transition">Create</button>
onChange={(e) => setNewUser({ ...newUser, quota_tokens_monthly: Number(e.target.value) })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<button type="submit" className="bg-anton-success text-white rounded-lg px-3 py-2 text-sm font-medium hover:opacity-90">
Create
</button>
</form> </form>
)} )}
<div className="space-y-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-anton-muted border-b border-anton-border">
<th className="px-5 py-3">User</th>
<th className="px-5 py-3">Role</th>
<th className="px-5 py-3">Quota</th>
<th className="px-5 py-3">Used</th>
<th className="px-5 py-3">Chats</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => ( {users.map((u) => (
<tr key={u.id} className="border-b border-anton-border/50 hover:bg-anton-card/50 transition"> <div key={u.id} className="bg-anton-card border border-anton-border rounded-xl px-4 py-3 flex items-center justify-between">
<td className="px-5 py-3"> <div>
<div className="text-white font-medium">{u.username}</div> <div className="text-sm text-white font-medium">{u.username} <span className="text-xs text-anton-muted">({u.role})</span></div>
<div className="text-anton-muted text-xs">{u.email}</div> <div className="text-xs text-anton-muted">{u.email}{u.chat_count} chats • {(u.tokens_used_this_month || 0).toLocaleString()} tokens</div>
</td> </div>
<td className="px-5 py-3"> <div className="flex items-center gap-2">
{editId === u.id ? ( <button onClick={() => toggleActive(u)} className={`${u.is_active ? "text-green-400" : "text-red-400"} transition`}>
<select value={editData.role ?? u.role} {u.is_active ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
onChange={(e) => setEditData({ ...editData, role: e.target.value })}
className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs"
>
<option value="user">user</option>
<option value="admin">admin</option>
<option value="superadmin">superadmin</option>
</select>
) : (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
u.role === "superadmin" ? "bg-anton-accent/20 text-anton-accent"
: u.role === "admin" ? "bg-blue-500/20 text-blue-400"
: "bg-anton-border text-anton-muted"
}`}>
{u.role}
</span>
)}
</td>
<td className="px-5 py-3 text-anton-muted">
{editId === u.id ? (
<input type="number" value={editData.quota_tokens_monthly ?? u.quota_tokens_monthly}
onChange={(e) => setEditData({ ...editData, quota_tokens_monthly: Number(e.target.value) })}
className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs w-28"
/>
) : formatNum(u.quota_tokens_monthly)}
</td>
<td className="px-5 py-3 text-anton-muted">
{formatNum(u.tokens_used_this_month)}
</td>
<td className="px-5 py-3 text-anton-muted">{u.chat_count}</td>
<td className="px-5 py-3">
<span className={`w-2 h-2 inline-block rounded-full mr-1 ${u.is_active ? "bg-anton-success" : "bg-anton-danger"}`} />
<span className="text-xs text-anton-muted">{u.is_active ? "Active" : "Disabled"}</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-1">
{editId === u.id ? (
<>
<button onClick={() => handleSaveEdit(u.id)} className="p-1 rounded hover:bg-anton-success/20 text-anton-success"><Save size={14} /></button>
<button onClick={() => setEditId(null)} className="p-1 rounded hover:bg-anton-border text-anton-muted"><X size={14} /></button>
</>
) : (
<>
<button onClick={() => { setEditId(u.id); setEditData({}); }}
className="p-1 rounded hover:bg-anton-accent/20 text-anton-accent text-xs">Edit</button>
<button onClick={() => adminUpdateUser(state.token, u.id, { is_active: !u.is_active }).then(load)}
className="p-1 rounded hover:bg-anton-border text-anton-muted"
title={u.is_active ? "Disable" : "Enable"}>
{u.is_active ? <ShieldOff size={14} /> : <Shield size={14} />}
</button> </button>
{u.role !== "superadmin" && ( {u.role !== "superadmin" && (
<button onClick={() => handleDelete(u.id, u.username)} <button onClick={() => handleDelete(u)} className="text-anton-muted hover:text-red-400 transition"><Trash2 size={14} /></button>
className="p-1 rounded hover:bg-red-500/20 text-anton-danger"><Trash2 size={14} /></button>
)}
</>
)} )}
</div> </div>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
))}
</div> </div>
</div> </div>
</div> </div>
......
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>
)} )}
......
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { import {
gitlabGetSettings, gitlabUpdateSettings, gitlabTestConnection, getGitLabSettings, updateGitLabSettings, testGitLabConnection,
gitlabSearchProjects, gitlabCreateProject, gitlabListRepos, searchGitLabProjects, listLinkedRepos, linkRepo, unlinkRepo,
gitlabLinkRepo, gitlabUnlinkRepo, gitlabGetTree, gitlabGetFile,
gitlabListActions, gitlabApproveAction, gitlabRejectAction,
} from "../api"; } from "../api";
import { import {
ArrowLeft, Settings2, Plug, Check, X, Search, Plus, Link, Unlink, ArrowLeft, GitBranch, Settings, Search, Link, Unlink, Check,
FolderTree, GitBranch, Eye, Shield, Clock, CheckCircle, XCircle, X, Loader2, ExternalLink, RefreshCw,
Loader2, ExternalLink, FileText, Folder, RefreshCw,
} from "lucide-react"; } from "lucide-react";
export default function GitLabPage() { export default function GitLabPage() {
const { state } = useApp(); const { state } = useApp();
const nav = useNavigate(); const navigate = useNavigate();
const t = state.token; const [tab, setTab] = useState("repos");
const [settings, setSettings] = useState({ gitlab_url: "", gitlab_token: "" });
const [tab, setTab] = useState("connection"); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [settings, setSettings] = useState({ gitlab_url: "", gitlab_token: "", is_active: false });
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [testResult, setTestResult] = useState(null); const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [repos, setRepos] = useState([]);
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [searchQ, setSearchQ] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [repos, setRepos] = useState([]);
const [linking, setLinking] = useState(null); const [linking, setLinking] = useState(null);
const [actions, setActions] = useState([]);
const [actionsTab, setActionsTab] = useState("pending");
const [processingAction, setProcessingAction] = useState(null);
const [browseRepo, setBrowseRepo] = useState(null);
const [tree, setTree] = useState([]);
const [fileContent, setFileContent] = useState(null);
useEffect(() => { useEffect(() => {
if (state.user?.role !== "superadmin") { nav("/"); return; }
loadSettings(); loadSettings();
loadRepos(); loadRepos();
}, []); }, []);
async function loadSettings() { async function loadSettings() {
try { const s = await gitlabGetSettings(t); setSettings(s); setUrl(s.gitlab_url || ""); } catch { } try {
const s = await getGitLabSettings(state.token);
setSettings({ gitlab_url: s.gitlab_url || "", gitlab_token: s.gitlab_token_set ? "UNCHANGED" : "" });
setSettingsLoaded(true);
} catch {}
} }
async function loadRepos() { async function loadRepos() {
try { setRepos(await gitlabListRepos(t)); } catch { } try {
} const r = await listLinkedRepos(state.token);
async function loadActions() { setRepos(r);
try { setActions(await gitlabListActions(t, actionsTab)); } catch { } } catch {}
} }
useEffect(() => { if (tab === "actions") loadActions(); }, [tab, actionsTab]);
async function handleSave() { async function handleSaveSettings() {
setSaving(true); setSaving(true);
try { try {
await gitlabUpdateSettings(t, { gitlab_url: url, gitlab_token: token || "UNCHANGED" }); await updateGitLabSettings(state.token, {
await loadSettings(); gitlab_url: settings.gitlab_url,
setTestResult(null); gitlab_token: settings.gitlab_token === "UNCHANGED" ? "UNCHANGED" : settings.gitlab_token,
} catch (e) { alert(e.message); } });
setTestResult({ ok: true, message: "Settings saved!" });
} catch (err) {
setTestResult({ ok: false, message: err.message });
}
setSaving(false); setSaving(false);
} }
async function handleTest() { async function handleTest() {
setTesting(true); setTestResult(null); setTesting(true);
setTestResult(null);
try { try {
const r = await gitlabTestConnection(t, { gitlab_url: url, gitlab_token: token || "UNCHANGED" }); const r = await testGitLabConnection(state.token, {
setTestResult({ ok: true, msg: `Connected as ${r.name} (@${r.username})` }); gitlab_url: settings.gitlab_url,
} catch (e) { setTestResult({ ok: false, msg: e.message }); } gitlab_token: settings.gitlab_token === "UNCHANGED" ? "UNCHANGED" : settings.gitlab_token,
});
setTestResult({ ok: true, message: `Connected as ${r.name} (@${r.username})` });
} catch (err) {
setTestResult({ ok: false, message: err.message });
}
setTesting(false); setTesting(false);
} }
async function handleSearch() { async function handleSearch() {
setSearching(true); setSearching(true);
try { setProjects(await gitlabSearchProjects(t, searchQ, false)); } catch { } try {
const p = await searchGitLabProjects(state.token, searchQuery, false);
setProjects(p);
} catch {}
setSearching(false); setSearching(false);
} }
async function handleLink(projectId) { async function handleLink(projectId) {
setLinking(projectId); setLinking(projectId);
try { await gitlabLinkRepo(t, projectId); await loadRepos(); } catch (e) { alert(e.message); } try {
await linkRepo(state.token, projectId);
await loadRepos();
} catch {}
setLinking(null); setLinking(null);
} }
async function handleUnlink(repoId) { async function handleUnlink(repoId) {
if (!confirm("Unlink this repo?")) return; if (!confirm("Unlink this repository?")) return;
try { await gitlabUnlinkRepo(t, repoId); await loadRepos(); } catch { }
}
async function handleBrowse(repo) {
setBrowseRepo(repo); setFileContent(null);
try { try {
const r = await gitlabGetTree(t, repo.id, "", null); await unlinkRepo(state.token, repoId);
setTree(r.items || []); await loadRepos();
} catch { setTree([]); } } catch {}
}
async function handleViewFile(path) {
if (!browseRepo) return;
try {
const f = await gitlabGetFile(t, browseRepo.id, path, null);
setFileContent(f);
} catch (e) { setFileContent({ file_path: path, content: `Error: ${e.message}` }); }
}
async function handleApprove(id) {
setProcessingAction(id);
try { await gitlabApproveAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
setProcessingAction(null);
}
async function handleReject(id) {
setProcessingAction(id);
try { await gitlabRejectAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
setProcessingAction(null);
} }
const linked = new Set(repos.map(r => r.gitlab_project_id)); const linkedProjectIds = new Set(repos.map((r) => r.gitlab_project_id));
return ( return (
<div className="h-dvh flex flex-col bg-anton-bg text-anton-text"> <div className="h-screen flex flex-col bg-anton-bg">
{/* Header */} <div className="border-b border-anton-border bg-anton-surface px-6 py-4 flex items-center gap-4">
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3"> <button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition">
<button onClick={() => nav("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button> <ArrowLeft size={20} />
<div className="flex items-center gap-2"> </button>
<GitBranch size={20} className="text-orange-400" /> <GitBranch size={20} className="text-anton-accent" />
<h1 className="text-lg font-bold text-white">GitLab Command Center</h1> <h1 className="text-lg font-bold text-white">GitLab Command Center</h1>
</div> </div>
<div className={`ml-auto flex items-center gap-1.5 text-xs ${settings.is_active ? "text-green-400" : "text-red-400"}`}>
<div className={`w-2 h-2 rounded-full ${settings.is_active ? "bg-green-400" : "bg-red-400"}`} />
{settings.is_active ? "Connected" : "Disconnected"}
</div>
</div>
{/* Tabs */} <div className="flex border-b border-anton-border bg-anton-surface">
<div className="border-b border-anton-border bg-anton-surface flex gap-0.5 px-4"> {["repos", "browse", "settings"].map((t) => (
{[["connection", "Connection", Plug], ["repos", "Repositories", FolderTree], ["actions", "Actions", Shield]].map(([key, label, Icon]) => ( <button key={t} onClick={() => setTab(t)}
<button key={key} onClick={() => setTab(key)} className={`px-6 py-3 text-sm font-medium transition border-b-2 ${
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm border-b-2 transition ${tab === key ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"}`}> tab === t ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"
<Icon size={14} />{label} }`}>
{t === "repos" ? "Linked Repos" : t === "browse" ? "Browse Projects" : "Connection"}
</button> </button>
))} ))}
</div> </div>
{/* Content */} <div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto p-4 space-y-4"> {tab === "settings" && (
{/* ── CONNECTION TAB ── */} <div className="max-w-xl space-y-4">
{tab === "connection" && (
<div className="max-w-2xl mx-auto space-y-4">
<div className="bg-anton-card border border-anton-border rounded-xl p-5 space-y-4">
<h2 className="text-white font-semibold flex items-center gap-2"><Settings2 size={16} className="text-anton-accent" /> Connection Settings</h2>
<div> <div>
<label className="text-xs text-anton-muted block mb-1">GitLab URL</label> <label className="text-xs text-anton-muted mb-1 block">GitLab URL</label>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://gitlab.example.com" <input value={settings.gitlab_url} onChange={(e) => setSettings({ ...settings, gitlab_url: 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" /> placeholder="https://gitlab.example.com"
className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent" />
</div> </div>
<div> <div>
<label className="text-xs text-anton-muted block mb-1">Personal Access Token</label> <label className="text-xs text-anton-muted mb-1 block">Access Token</label>
<input type="password" value={token} onChange={e => setToken(e.target.value)} placeholder={settings.gitlab_token_set ? "••••••• (saved)" : "glpat-..."} <input type="password" value={settings.gitlab_token}
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" /> onChange={(e) => setSettings({ ...settings, gitlab_token: e.target.value })}
<p className="text-[10px] text-anton-muted mt-1">Needs api, read_repository, write_repository scopes</p> placeholder="glpat-..."
</div> className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent" />
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving} className="px-4 py-2 bg-anton-accent text-white rounded-lg hover:opacity-80 transition disabled:opacity-50 flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Save
</button>
<button onClick={handleTest} disabled={testing} className="px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg hover:border-anton-accent transition disabled:opacity-50 flex items-center gap-1.5">
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />} Test
</button>
</div> </div>
{testResult && ( {testResult && (
<div className={`p-3 rounded-lg text-sm ${testResult.ok ? "bg-green-500/10 text-green-400 border border-green-500/30" : "bg-red-500/10 text-red-400 border border-red-500/30"}`}> <div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
{testResult.ok ? <CheckCircle size={14} className="inline mr-1" /> : <XCircle size={14} className="inline mr-1" />} testResult.ok ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
{testResult.msg} }`}>
</div> {testResult.ok ? <Check size={14} /> : <X size={14} />}
)} {testResult.message}
</div>
</div> </div>
)} )}
{/* ── REPOS TAB ── */}
{tab === "repos" && (
<div className="max-w-4xl mx-auto space-y-4">
{/* Search & Link */}
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<h2 className="text-white font-semibold flex items-center gap-2"><Search size={16} className="text-anton-accent" /> Find & Link Projects</h2>
<div className="flex gap-2"> <div className="flex gap-2">
<input value={searchQ} onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === "Enter" && handleSearch()} placeholder="Search GitLab projects..." <button onClick={handleTest} disabled={testing}
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" /> className="flex items-center gap-2 px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm text-white hover:border-anton-accent transition disabled:opacity-50">
<button onClick={handleSearch} disabled={searching} className="px-4 py-2 bg-anton-accent text-white rounded-lg text-sm hover:opacity-80 disabled:opacity-50"> {testing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{searching ? <Loader2 size={14} className="animate-spin" /> : "Search"} Test Connection
</button> </button>
</div> <button onClick={handleSaveSettings} disabled={saving}
{projects.length > 0 && ( className="flex items-center gap-2 px-4 py-2 bg-anton-accent rounded-lg text-sm text-white hover:opacity-90 transition disabled:opacity-50">
<div className="max-h-60 overflow-y-auto space-y-1"> {saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{projects.map(p => ( Save
<div key={p.id} className="flex items-center justify-between bg-anton-bg rounded-lg px-3 py-2">
<div className="min-w-0">
<div className="text-sm text-white truncate">{p.path_with_namespace}</div>
<div className="text-[10px] text-anton-muted truncate">{p.description || "No description"}</div>
</div>
{linked.has(p.id) ? (
<span className="text-xs text-green-400 shrink-0 ml-2">✓ Linked</span>
) : (
<button onClick={() => handleLink(p.id)} disabled={linking === p.id} className="text-xs bg-anton-accent/20 text-anton-accent px-2.5 py-1 rounded hover:bg-anton-accent/30 shrink-0 ml-2">
{linking === p.id ? <Loader2 size={12} className="animate-spin" /> : <><Link size={12} className="inline mr-1" />Link</>}
</button> </button>
)}
</div> </div>
))}
</div> </div>
)} )}
</div>
{/* Linked Repos */} {tab === "repos" && (
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> {repos.length === 0 && (
<h2 className="text-white font-semibold flex items-center gap-2"><FolderTree size={16} className="text-green-400" /> Linked Repositories ({repos.length})</h2> <div className="text-center py-12 text-anton-muted">
<button onClick={loadRepos} className="text-anton-muted hover:text-white"><RefreshCw size={14} /></button> <GitBranch size={40} className="mx-auto mb-3 opacity-30" />
<p>No linked repositories yet.</p>
<p className="text-xs mt-1">Go to "Browse Projects" to link one.</p>
</div> </div>
{repos.length === 0 && <p className="text-anton-muted text-sm">No repos linked yet. Search above.</p>} )}
{repos.map(r => ( {repos.map((r) => (
<div key={r.id} className="bg-anton-bg rounded-xl p-3 space-y-2"> <div key={r.id} className="bg-anton-card border border-anton-border rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center justify-between">
<div> <div>
<div className="text-sm text-white font-medium">{r.name}</div> <div className="text-sm font-medium text-white flex items-center gap-2">
<div className="text-[10px] text-anton-muted">{r.path_with_namespace}{r.default_branch}</div> <GitBranch size={14} className="text-anton-accent" />
</div> {r.name}
<div className="flex gap-1.5">
{r.web_url && <a href={r.web_url} target="_blank" rel="noopener noreferrer" className="p-1.5 text-anton-muted hover:text-white"><ExternalLink size={14} /></a>}
<button onClick={() => handleBrowse(r)} className="p-1.5 text-anton-muted hover:text-green-400"><Eye size={14} /></button>
<button onClick={() => handleUnlink(r.id)} className="p-1.5 text-anton-muted hover:text-red-400"><Unlink size={14} /></button>
</div>
</div>
</div>
))}
</div>
{/* File Browser */}
{browseRepo && (
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-white font-semibold text-sm">📂 {browseRepo.name} / {browseRepo.default_branch}</h2>
<button onClick={() => { setBrowseRepo(null); setFileContent(null); }} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[60vh]">
<div className="overflow-y-auto border border-anton-border rounded-lg p-2 space-y-0.5 max-h-[55vh]">
{tree.map(item => (
<button key={item.path} onClick={() => item.type === "blob" && handleViewFile(item.path)}
className={`w-full text-left px-2 py-1 rounded text-xs flex items-center gap-1.5 ${item.type === "blob" ? "hover:bg-anton-accent/10 text-white cursor-pointer" : "text-anton-muted cursor-default"}`}>
{item.type === "tree" ? <Folder size={12} className="text-blue-400 shrink-0" /> : <FileText size={12} className="text-anton-muted shrink-0" />}
<span className="truncate">{item.path}</span>
</button>
))}
</div> </div>
<div className="overflow-y-auto border border-anton-border rounded-lg max-h-[55vh]"> <div className="text-xs text-anton-muted mt-0.5">{r.path_with_namespace}{r.default_branch}</div>
{fileContent ? ( {r.description && <div className="text-xs text-anton-muted mt-1 line-clamp-1">{r.description}</div>}
<div>
<div className="sticky top-0 bg-anton-surface px-3 py-1.5 border-b border-anton-border text-xs text-anton-muted">{fileContent.file_path}</div>
<pre className="p-3 text-[11px] text-white whitespace-pre-wrap font-mono leading-relaxed">{fileContent.content}</pre>
</div> </div>
) : ( <div className="flex items-center gap-2">
<div className="flex items-center justify-center h-full text-anton-muted text-sm p-4">Click a file to view</div> {r.web_url && (
<a href={r.web_url} target="_blank" rel="noopener noreferrer"
className="p-2 text-anton-muted hover:text-white transition">
<ExternalLink size={14} />
</a>
)} )}
<button onClick={() => handleUnlink(r.id)}
className="p-2 text-anton-muted hover:text-red-400 transition" title="Unlink">
<Unlink size={14} />
</button>
</div> </div>
</div> </div>
</div> ))}
)}
</div> </div>
)} )}
{/* ── ACTIONS TAB ── */} {tab === "browse" && (
{tab === "actions" && ( <div className="space-y-4">
<div className="max-w-3xl mx-auto space-y-4"> <div className="flex gap-2">
<div className="flex gap-2 mb-2"> <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
{["pending", "approved", "rejected"].map(s => ( onKeyDown={(e) => e.key === "Enter" && handleSearch()}
<button key={s} onClick={() => setActionsTab(s)} placeholder="Search GitLab projects..."
className={`px-3 py-1.5 rounded-lg text-xs capitalize ${actionsTab === s ? "bg-anton-accent text-white" : "bg-anton-card text-anton-muted border border-anton-border hover:text-white"}`}> className="flex-1 bg-anton-card border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent" />
{s} <button onClick={handleSearch} disabled={searching}
className="flex items-center gap-2 px-4 py-2.5 bg-anton-accent rounded-lg text-sm text-white hover:opacity-90 transition disabled:opacity-50">
{searching ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
Search
</button> </button>
))}
<button onClick={loadActions} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
</div> </div>
{actions.length === 0 && <p className="text-anton-muted text-sm text-center py-8">No {actionsTab} actions</p>} {projects.map((p) => {
{actions.map(a => ( const isLinked = linkedProjectIds.has(p.id);
<div key={a.id} className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-2"> return (
<div className="flex items-center justify-between"> <div key={p.id} className="bg-anton-card border border-anton-border rounded-xl p-4 flex items-center justify-between">
<div> <div>
<span className="text-xs bg-anton-accent/20 text-anton-accent px-2 py-0.5 rounded mr-2">{a.action_type}</span> <div className="text-sm font-medium text-white">{p.name}</div>
<span className="text-sm text-white">{a.title || "Untitled"}</span> <div className="text-xs text-anton-muted mt-0.5">{p.path_with_namespace}</div>
</div> {p.description && <div className="text-xs text-anton-muted mt-1 line-clamp-1">{p.description}</div>}
<span className="text-[10px] text-anton-muted">{a.repo_name}</span> </div>
</div> {isLinked ? (
<pre className="text-[10px] text-anton-muted bg-anton-bg rounded p-2 max-h-32 overflow-y-auto">{a.payload}</pre> <span className="flex items-center gap-1 text-xs text-green-400 px-3 py-1.5 bg-green-500/10 rounded-lg">
{a.status === "pending" && ( <Check size={12} /> Linked
<div className="flex gap-2"> </span>
<button onClick={() => handleApprove(a.id)} disabled={processingAction === a.id} ) : (
className="px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-500 disabled:opacity-50 flex items-center gap-1"> <button onClick={() => handleLink(p.id)} disabled={linking === p.id}
{processingAction === a.id ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle size={12} />} Approve className="flex items-center gap-1 text-xs text-white px-3 py-1.5 bg-anton-accent rounded-lg hover:opacity-90 transition disabled:opacity-50">
</button> {linking === p.id ? <Loader2 size={12} className="animate-spin" /> : <Link size={12} />}
<button onClick={() => handleReject(a.id)} disabled={processingAction === a.id} Link
className="px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:bg-red-500 disabled:opacity-50 flex items-center gap-1">
<XCircle size={12} /> Reject
</button> </button>
</div>
)} )}
{a.result_message && <p className="text-[10px] text-anton-muted">{a.result_message}</p>}
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>
......
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { import {
listKnowledgeBases, listKnowledgeBases, createKnowledgeBase, getKnowledgeBase,
createKnowledgeBase, deleteKnowledgeBase, uploadDocuments, deleteKnowledgeDocument,
getKnowledgeBase,
updateKnowledgeBase,
deleteKnowledgeBase,
deleteKnowledgeDocument,
uploadDocuments,
} from "../api"; } from "../api";
import { import {
BookOpen, Plus, Trash2, FileText, Upload, ArrowLeft, Edit3, ArrowLeft, BookOpen, Plus, Trash2, Upload, FileText, Loader2, X,
Check, X, ChevronRight, Database, Hash, Type, Calendar,
AlertTriangle, Loader2, Search, Flame, RefreshCw,
} from "lucide-react"; } from "lucide-react";
function fmtSize(b) {
if (!b) return "0 B";
if (b < 1024) return b + " B";
if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
return (b / 1048576).toFixed(1) + " MB";
}
function fmtDate(s) {
if (!s) return "";
try {
return new Date(s).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch {
return s;
}
}
export default function KnowledgePage() { export default function KnowledgePage() {
const { state } = useApp(); const { state } = useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const token = state.token;
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [selectedKb, setSelectedKb] = useState(null); const [selectedKb, setSelectedKb] = useState(null);
const [loading, setLoading] = useState(true); const [kbDetail, setKbDetail] = useState(null);
const [error, setError] = useState("");
// Create KB state
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState(""); const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false); const [showCreate, setShowCreate] = useState(false);
// Edit KB state
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [saving, setSaving] = useState(false);
// Upload state
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState(null);
const fileRef = useRef(null);
// Delete doc state
const [deletingDocId, setDeletingDocId] = useState(null);
const [confirmDeleteKb, setConfirmDeleteKb] = useState(false);
// Search useEffect(() => { loadKbs(); }, []);
const [docSearch, setDocSearch] = useState("");
useEffect(() => {
loadKbs();
}, []);
async function loadKbs() { async function loadKbs() {
setLoading(true);
setError("");
try { try {
const data = await listKnowledgeBases(token); const data = await listKnowledgeBases(state.token);
setKbs(data); setKbs(data);
} catch (e) { } catch {}
setError(e.message);
} finally {
setLoading(false);
}
} }
async function loadKbDetail(kbId) { async function loadDetail(kbId) {
setError("");
try { try {
const data = await getKnowledgeBase(token, kbId); const data = await getKnowledgeBase(state.token, kbId);
setSelectedKb(data); setKbDetail(data);
} catch (e) { } catch {}
setError(e.message);
}
} }
async function handleCreate() { async function handleCreate(e) {
e.preventDefault();
if (!newName.trim()) return; if (!newName.trim()) return;
setCreating(true);
setError("");
try { try {
await createKnowledgeBase(token, newName.trim(), newDesc.trim()); await createKnowledgeBase(state.token, newName.trim(), newDesc.trim());
setNewName(""); setNewName("");
setNewDesc(""); setNewDesc("");
setShowCreate(false); setShowCreate(false);
await loadKbs(); loadKbs();
} catch (e) { } catch {}
setError(e.message);
} finally {
setCreating(false);
}
}
async function handleDeleteKb() {
if (!selectedKb) return;
setError("");
try {
await deleteKnowledgeBase(token, selectedKb.id);
setSelectedKb(null);
setConfirmDeleteKb(false);
await loadKbs();
} catch (e) {
setError(e.message);
}
} }
async function handleSaveEdit() { async function handleDeleteKb(kbId) {
if (!selectedKb) return; if (!confirm("Delete this knowledge base and all its documents?")) return;
setSaving(true);
setError("");
try { try {
await updateKnowledgeBase(token, selectedKb.id, { await deleteKnowledgeBase(state.token, kbId);
name: editName.trim() || selectedKb.name, if (selectedKb === kbId) { setSelectedKb(null); setKbDetail(null); }
description: editDesc.trim(), loadKbs();
}); } catch {}
setEditing(false);
await loadKbDetail(selectedKb.id);
await loadKbs();
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
} }
async function handleUpload(e) { async function handleUpload(e) {
if (!selectedKb) return;
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (!files.length || !selectedKb) return; if (!files.length) return;
e.target.value = "";
setUploading(true); setUploading(true);
setUploadResult(null);
setError("");
try { try {
const result = await uploadDocuments(token, selectedKb.id, files); await uploadDocuments(state.token, selectedKb, files);
setUploadResult(result); loadDetail(selectedKb);
await loadKbDetail(selectedKb.id); loadKbs();
await loadKbs(); } catch {}
} catch (err) {
setError(err.message);
} finally {
setUploading(false); setUploading(false);
} e.target.value = "";
} }
async function handleDeleteDoc(docId) { async function handleDeleteDoc(docId) {
if (!selectedKb) return; if (!selectedKb || !confirm("Delete this document?")) return;
setDeletingDocId(docId);
setError("");
try { try {
await deleteKnowledgeDocument(token, selectedKb.id, docId); await deleteKnowledgeDocument(state.token, selectedKb, docId);
await loadKbDetail(selectedKb.id); loadDetail(selectedKb);
await loadKbs(); loadKbs();
} catch (e) { } catch {}
setError(e.message);
} finally {
setDeletingDocId(null);
}
}
function openKb(kb) {
setSelectedKb(null);
setEditing(false);
setUploadResult(null);
setDocSearch("");
setConfirmDeleteKb(false);
loadKbDetail(kb.id);
} }
function goBack() { function selectKb(kbId) {
setSelectedKb(null); setSelectedKb(kbId);
setEditing(false); loadDetail(kbId);
setUploadResult(null);
setDocSearch("");
setConfirmDeleteKb(false);
} }
const filteredDocs = (selectedKb?.documents || []).filter((d) =>
d.filename.toLowerCase().includes(docSearch.toLowerCase())
);
// ─── KB Detail View ───
if (selectedKb) {
return ( return (
<div className="h-dvh flex flex-col bg-anton-bg text-anton-text"> <div className="h-screen flex flex-col bg-anton-bg">
{/* Header */} <div className="border-b border-anton-border bg-anton-surface px-6 py-4 flex items-center gap-4">
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3"> <button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition"><ArrowLeft size={20} /></button>
<button onClick={goBack} className="text-anton-muted hover:text-white transition"><ArrowLeft size={20} /></button> <BookOpen size={20} className="text-anton-accent" />
<div className="flex-1 min-w-0"> <h1 className="text-lg font-bold text-white">Knowledge Bases</h1>
{editing ? (
<div className="flex items-center gap-2">
<input value={editName} onChange={(e) => setEditName(e.target.value)} className="bg-anton-card border border-anton-border rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-anton-accent flex-1" placeholder="Name" />
<button onClick={handleSaveEdit} disabled={saving} className="p-1.5 rounded-lg bg-anton-accent text-white hover:opacity-80 transition disabled:opacity-50">{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}</button>
<button onClick={() => setEditing(false)} className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"><X size={14} /></button>
</div>
) : (
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-white truncate">{selectedKb.name}</h1>
<button onClick={() => { setEditName(selectedKb.name); setEditDesc(selectedKb.description || ""); setEditing(true); }} className="text-anton-muted hover:text-anton-accent transition"><Edit3 size={14} /></button>
</div>
)}
{!editing && selectedKb.description && <p className="text-xs text-anton-muted truncate mt-0.5">{selectedKb.description}</p>}
</div>
<button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition text-sm">← Chat</button>
</div>
{/* Edit description row */}
{editing && (
<div className="px-4 py-2 border-b border-anton-border bg-anton-surface">
<textarea value={editDesc} onChange={(e) => setEditDesc(e.target.value)} placeholder="Description (optional)" rows={2} className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-anton-accent" />
</div>
)}
{error && <div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-red-400 text-sm flex items-center gap-2"><AlertTriangle size={14} />{error}</div>}
{/* Stats bar */}
<div className="px-4 py-2 border-b border-anton-border bg-anton-surface/50 flex items-center gap-4 text-xs text-anton-muted flex-wrap">
<span className="flex items-center gap-1"><FileText size={12} />{selectedKb.document_count} docs</span>
<span className="flex items-center gap-1"><Hash size={12} />{selectedKb.chunk_count} chunks</span>
<span className="flex items-center gap-1"><Type size={12} />{(selectedKb.estimated_tokens || 0).toLocaleString()} est. tokens</span>
<span className="flex items-center gap-1"><Calendar size={12} />{fmtDate(selectedKb.created_at)}</span>
</div> </div>
<div className="flex-1 flex overflow-hidden">
{/* Actions bar */} <div className="w-80 border-r border-anton-border overflow-y-auto p-4 space-y-2">
<div className="px-4 py-2 border-b border-anton-border bg-anton-surface/30 flex items-center gap-2 flex-wrap"> <button onClick={() => setShowCreate(!showCreate)}
<button onClick={() => fileRef.current?.click()} disabled={uploading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-anton-accent text-white text-sm hover:opacity-80 transition disabled:opacity-50"> className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition">
{uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />} <Plus size={16} /> New Knowledge Base
{uploading ? "Uploading…" : "Upload Files"}
</button> </button>
<input ref={fileRef} type="file" multiple className="hidden" accept=".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,.doc,.docx" onChange={handleUpload} /> {showCreate && (
<button onClick={() => loadKbDetail(selectedKb.id)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-anton-border text-anton-muted text-sm hover:text-white hover:bg-anton-card transition"><RefreshCw size={14} /> Refresh</button> <form onSubmit={handleCreate} className="bg-anton-card border border-anton-border rounded-xl p-3 space-y-2">
<div className="flex-1" /> <input placeholder="Name" value={newName} onChange={(e) => setNewName(e.target.value)} required
{confirmDeleteKb ? ( 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" />
<div className="flex items-center gap-2"> <input placeholder="Description (optional)" value={newDesc} onChange={(e) => setNewDesc(e.target.value)}
<span className="text-red-400 text-xs">Delete entire KB?</span> 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" />
<button onClick={handleDeleteKb} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm hover:opacity-80 transition">Yes, Delete</button> <button type="submit" className="w-full bg-anton-accent text-white rounded-lg py-2 text-xs hover:opacity-90 transition">Create</button>
<button onClick={() => setConfirmDeleteKb(false)} className="px-3 py-1.5 rounded-lg border border-anton-border text-anton-muted text-sm hover:text-white transition">Cancel</button> </form>
</div>
) : (
<button onClick={() => setConfirmDeleteKb(true)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-500/30 text-red-400 text-sm hover:bg-red-500/10 transition"><Trash2 size={14} /> Delete KB</button>
)} )}
{kbs.map((kb) => (
<div key={kb.id} onClick={() => selectKb(kb.id)}
className={`p-3 rounded-xl cursor-pointer transition ${
selectedKb === kb.id ? "bg-anton-accent/15 border border-anton-accent/30" : "bg-anton-card border border-anton-border hover:border-anton-accent/50"
}`}>
<div className="text-sm text-white font-medium truncate">{kb.name}</div>
<div className="text-xs text-anton-muted mt-0.5">{kb.document_count} docs • {kb.chunk_count} chunks</div>
</div> </div>
{/* Upload result */}
{uploadResult && (
<div className="px-4 py-2 border-b border-anton-border bg-green-500/5">
<p className="text-xs text-green-400 font-semibold mb-1">Upload complete: {uploadResult.total_chunks_added} chunks from {uploadResult.total_files} file(s)</p>
{uploadResult.files?.map((f, i) => (
<p key={i} className={`text-xs ${f.error ? "text-red-400" : "text-anton-muted"}`}>
{f.filename}: {f.error ? `Error: ${f.error}` : `${f.chunks_added} chunks, ~${f.estimated_tokens?.toLocaleString()} tokens`}
</p>
))} ))}
</div> </div>
)} <div className="flex-1 overflow-y-auto p-6">
{kbDetail ? (
{/* Search */} <div className="space-y-4">
{(selectedKb.documents || []).length > 3 && ( <div className="flex items-center justify-between">
<div className="px-4 py-2 border-b border-anton-border"> <div>
<div className="relative"> <h2 className="text-lg font-bold text-white">{kbDetail.name}</h2>
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-anton-muted" /> {kbDetail.description && <p className="text-sm text-anton-muted mt-1">{kbDetail.description}</p>}
<input value={docSearch} onChange={(e) => setDocSearch(e.target.value)} placeholder="Search documents…" className="w-full bg-anton-card border border-anton-border rounded-lg pl-9 pr-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" /> <div className="text-xs text-anton-muted mt-2">
</div> {kbDetail.document_count} documents • {kbDetail.chunk_count} chunks • ~{kbDetail.estimated_tokens?.toLocaleString()} tokens
</div>
)}
{/* Document list */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{filteredDocs.length === 0 ? (
<div className="text-center py-16">
<Database size={40} className="mx-auto text-anton-muted mb-3 opacity-50" />
<p className="text-anton-muted text-sm">{docSearch ? "No documents match your search." : "No documents yet. Upload some files!"}</p>
</div> </div>
) : (
<div className="space-y-1.5">
{filteredDocs.map((doc) => (
<div key={doc.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-anton-border bg-anton-card hover:border-anton-accent/30 transition group">
<FileText size={16} className="text-anton-accent shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate font-medium">{doc.filename}</p>
<p className="text-[11px] text-anton-muted">{fmtSize(doc.file_size)} · {doc.chunk_count} chunks · {fmtDate(doc.created_at)}</p>
</div> </div>
<button <div className="flex gap-2">
onClick={() => handleDeleteDoc(doc.id)} <label className="flex items-center gap-2 px-3 py-2 bg-anton-accent rounded-lg text-sm text-white cursor-pointer hover:opacity-90 transition">
disabled={deletingDocId === doc.id} {uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
className="p-1.5 rounded-lg text-anton-muted hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 disabled:opacity-50" Upload
title="Remove document and its vector chunks" <input type="file" multiple className="hidden" onChange={handleUpload} accept=".txt,.md,.pdf,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.go,.rs,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv" />
> </label>
{deletingDocId === doc.id ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} <button onClick={() => handleDeleteKb(kbDetail.id)}
className="px-3 py-2 bg-anton-danger rounded-lg text-sm text-white hover:opacity-90 transition">
<Trash2 size={14} />
</button> </button>
</div> </div>
))}
</div>
)}
</div> </div>
<div className="space-y-2">
{(kbDetail.documents || []).map((doc) => (
<div key={doc.id} className="bg-anton-card border border-anton-border rounded-xl px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText size={16} className="text-anton-muted" />
<div>
<div className="text-sm text-white">{doc.filename}</div>
<div className="text-xs text-anton-muted">{doc.chunk_count} chunks • {(doc.file_size / 1024).toFixed(0)}KB</div>
</div> </div>
);
}
// ─── KB List View ───
return (
<div className="h-dvh flex flex-col bg-anton-bg text-anton-text">
{/* Header */}
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition"><ArrowLeft size={20} /></button>
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
<Flame size={18} className="text-white" />
</div>
<div className="flex-1">
<h1 className="text-lg font-bold text-white">Knowledge Bases</h1>
<p className="text-xs text-anton-muted">Manage your RAG document collections</p>
</div> </div>
<button onClick={() => setShowCreate(!showCreate)} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${showCreate ? "bg-anton-card text-anton-muted" : "bg-anton-accent text-white hover:opacity-80"}`}> <button onClick={() => handleDeleteDoc(doc.id)}
{showCreate ? <X size={14} /> : <Plus size={14} />} className="text-anton-muted hover:text-red-400 transition"><Trash2 size={14} /></button>
{showCreate ? "Cancel" : "New KB"}
</button>
</div> </div>
))}
{error && <div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-red-400 text-sm flex items-center gap-2"><AlertTriangle size={14} />{error}</div>}
{/* Create form */}
{showCreate && (
<div className="px-4 py-3 border-b border-anton-border bg-anton-surface/50 space-y-2 animate-fade-in">
<input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Knowledge base name" className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" onKeyDown={(e) => e.key === "Enter" && handleCreate()} />
<textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" rows={2} className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-anton-accent" />
<button onClick={handleCreate} disabled={!newName.trim() || creating} className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-anton-accent text-white text-sm hover:opacity-80 transition disabled:opacity-50">
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Create
</button>
</div> </div>
)}
{/* KB list */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{loading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="text-anton-accent animate-spin" /></div>
) : kbs.length === 0 ? (
<div className="text-center py-20">
<BookOpen size={48} className="mx-auto text-anton-muted mb-4 opacity-50" />
<p className="text-anton-muted text-sm">No knowledge bases yet.</p>
<p className="text-anton-muted text-xs mt-1">Create one to start uploading documents for RAG.</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="flex items-center justify-center h-full text-anton-muted text-sm">
{kbs.map((kb) => ( Select a knowledge base to manage
<button
key={kb.id}
onClick={() => openKb(kb)}
className="w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border border-anton-border bg-anton-card hover:border-anton-accent/40 hover:bg-anton-card/80 transition group"
>
<div className="w-10 h-10 rounded-lg bg-anton-accent/10 flex items-center justify-center shrink-0">
<BookOpen size={18} className="text-anton-accent" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-semibold truncate">{kb.name}</p>
{kb.description && <p className="text-xs text-anton-muted truncate">{kb.description}</p>}
<p className="text-[11px] text-anton-muted mt-0.5">
{kb.document_count} docs · {kb.chunk_count} chunks · ~{(kb.estimated_tokens || 0).toLocaleString()} tokens
</p>
</div>
<ChevronRight size={16} className="text-anton-muted group-hover:text-anton-accent transition shrink-0" />
</button>
))}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
\ No newline at end of file
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>
{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> </div>
<button type="submit" disabled={loading}
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">
{loading ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : isRegister ? (
<><UserPlus size={16} /> Register</>
) : (
<><LogIn size={16} /> Login</>
)} )}
<button
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 ac = new AbortController();
_streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId);
(async () => {
const s = _streams.get(chatId); const s = _streams.get(chatId);
if (!s) return; if (!s) return;
let usage = {};
let msgId = ""; (async () => {
let usage = {}, 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(); export function reconnectToStream({ token, chatId }) {
const decoder = new TextDecoder(); if (_streams.has(chatId)) return;
let buffer = ""; const ac = new AbortController();
while (true) { _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
const { done, value } = await reader.read(); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
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); _notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
})();
}
function _handleEvent(chatId, s, evt, setUsage, setMsgId) { const iter = reconnectStream(token, chatId, ac.signal);
switch (evt.type) { _processEvents(chatId, iter, ac);
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": 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;
case "done": setMsgId(evt.message_id); break;
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: {
// Content-hash all chunks so browsers fetch new versions
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash].[ext]",
},
}, },
// Generate a manifest for cache-busting verification build: {
manifest: true, outDir: "dist",
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