Commit 17ddd732 authored by AGLANPC\aglan's avatar AGLANPC\aglan

ghvntumyutj mtyj mtyjthy nv

parent f8727239
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -109,18 +109,12 @@ def update_settings(body: SettingsBody, admin: User = Depends(require_superadmin
@router.post("/test-connection")
async def test_connection(body: SettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
url = body.gitlab_url.rstrip("/")
if not body.gitlab_token or body.gitlab_token == "UNCHANGED":
s = db.query(GitLabSettings).first()
token = s.gitlab_token if s else ""
else:
token = body.gitlab_token
if not url or not token:
raise HTTPException(400, "GitLab URL and token not provided")
async def test_connection(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
s = db.query(GitLabSettings).first()
if not s or not s.gitlab_url or not s.gitlab_token:
raise HTTPException(400, "GitLab URL and token not configured")
try:
result = await gitlab_service.test_connection(url, token)
result = await gitlab_service.test_connection(s.gitlab_url, s.gitlab_token)
return result
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Connection failed: {e.detail}")
......
......@@ -10,49 +10,46 @@ function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
function extractError(err, defaultMsg) {
let msg = err.detail || err.message || defaultMsg;
if (Array.isArray(msg)) return msg.map(m => m.msg || JSON.stringify(m)).join(", ");
if (typeof msg === "object" && msg !== null) return msg.message || JSON.stringify(msg);
return String(msg);
}
async function request(method, path, token, body) {
const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || err.message || "Request failed");
throw new Error(extractError(err, "Request failed"));
}
return res.json();
}
export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password });
// ═══════════ Auth ═══════════
export const login = (username, password) => request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) => request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token);
// ═══════════ Chats ═══════════
export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) => request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) => updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) => request("GET", `/chats/${chatId}/generating`, token);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
// ═══════════ Streaming ═══════════
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token),
body: JSON.stringify(body), signal,
method: "POST", headers: headers(token), body: JSON.stringify(body), signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Stream failed");
throw new Error(extractError(err, "Stream failed"));
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
......@@ -66,81 +63,57 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { }
try { yield JSON.parse(line.slice(6)); } catch { /* skip */ }
}
}
}
if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
try { yield JSON.parse(buffer.trim().slice(6)); } catch { /* skip */ }
}
}
// ═══════════ Attachments ═══════════
export async function uploadAttachments(token, chatId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); }
return res.json();
}
export function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token);
// ═══════════ Knowledge ═══════════
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(token), body: form });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); }
return res.json();
}
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
// ═══════════ Admin ═══════════
export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token);
export async function downloadZip(token, markdown) {
// ═══════════ Code Download ═══════════
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown }),
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }),
});
if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || "";
......@@ -149,7 +122,9 @@ export async function downloadZip(token, markdown) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "son-of-anton-code.zip";
const raw = (chatTitle || "").trim();
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
a.download = `${safeName}.zip`;
a.click();
URL.revokeObjectURL(url);
} else {
......@@ -159,47 +134,30 @@ export async function downloadZip(token, markdown) {
}
// ═══════════════════════════════════════════════════
// GitLab Repository API — Son of Anton v4.1.0
// GitLab CE Integration — v4.0.0
// ═══════════════════════════════════════════════════
export function gitlabGetTree(token, repoId, ref = "", path = "") {
const p = new URLSearchParams();
if (ref) p.set("ref", ref);
if (path) p.set("path", path);
return request("GET", `/gitlab/repos/${repoId}/tree?${p}`, token);
}
export function gitlabGetFile(token, repoId, filePath, ref = "") {
const p = new URLSearchParams({ path: filePath });
if (ref) p.set("ref", ref);
return request("GET", `/gitlab/repos/${repoId}/file?${p}`, token);
}
export async function gitlabFileExists(token, repoId, filePath, ref = "") {
try {
await gitlabGetFile(token, repoId, filePath, ref);
return true;
} catch {
return false;
}
}
export function gitlabCommitSingle(token, repoId, data) {
return request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
}
export function gitlabCommitMulti(token, repoId, data) {
return request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
}
export function gitlabGetBranches(token, repoId) {
return request("GET", `/gitlab/repos/${repoId}/branches`, token);
}
export function gitlabCreateBranch(token, repoId, data) {
return request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
}
export function gitlabListRepos(token) {
return request("GET", `/gitlab/repos`, token);
}
\ No newline at end of file
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
export const gitlabSearchProjects = (token, search, owned) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data);
export const gitlabListRepos = (token) => request("GET", "/gitlab/repos", token);
export const gitlabLinkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId });
export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token);
export const gitlabGetTree = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetFile = (token, repoId, path, ref) =>
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token);
export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
export const gitlabCommit = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
export const gitlabAnalyzeProject = (token, repoId, ref) =>
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data);
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token);
export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token);
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import RepoFilePanel from "./RepoFilePanel";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, GitBranch } from "lucide-react";
import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch,
} from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
];
function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(f) {
const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || "";
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 === "application/pdf" || ext === "pdf") return "document";
return "text";
}
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId);
const currentChat = state.chats.find(c => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const linkedRepo = currentChat?.linked_repo || null;
const isSuperadmin = state.user?.role === "superadmin";
const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false);
const [showRepoPanel, setShowRepoPanel] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null);
......@@ -50,18 +60,9 @@ export default function ChatView({ chatId }) {
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
rafRef.current = null;
});
rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; });
}, []);
useEffect(() => {
......@@ -70,201 +71,194 @@ export default function ChatView({ chatId }) {
const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
if (isSuperadmin) { try { setRepos(await gitlabListRepos(state.token)); } catch { } }
} catch { }
})();
}, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [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]);
function onScroll() { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }
async function saveSettings() {
try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, 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() {
if (showSettings) saveSettings();
setShowSettings(!showSettings);
}
function handleFileSelect(e) {
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 }))]);
e.target.value = "";
}
function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
}
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); }
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
async function handleSend() {
const content = input.trim();
if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = [];
if (pendingFiles.length) {
setUploading(true);
try {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; }
try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch { setUploading(false); return; }
setUploading(false);
}
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
if (inputRef.current) inputRef.current.style.height = "auto";
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 handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
}
const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo;
function handleDrop(e) {
e.preventDefault();
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 }))]);
async function handleCommitFromChat(filePath, code, action) {
if (!linkedRepo) return;
const branch = linkedRepo.default_branch;
const msg = prompt("Commit message:", `Update ${filePath} via Son of Anton`);
if (!msg) return;
try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
alert(`✅ Committed to ${branch}`);
} catch (e) { alert(`❌ ${e.message}`); }
}
const streaming = streamData.streaming;
return (
<div className="flex-1 flex 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">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} />
))}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble
message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }}
isStreaming isThinking={streamData.isThinking} token={state.token} linkedRepo={linkedRepo}
/>
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div>
)}
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
{dragOver && (
<div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none">
<div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div>
</div>
)}
{/* Input Area */}
<div className="border-t border-anton-border bg-anton-surface p-4">
{showSettings && (
<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">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div>
<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">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</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>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</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>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
{/* Repo banner */}
{linkedRepo && (
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs">
<GitBranch size={12} className="text-orange-400" />
<span className="text-orange-300 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300/40 ml-auto">Project-aware mode</span>
</div>
)}
{/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} />)}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div>
<span className="text-anton-muted text-sm">Thinking…</span>
</div>
)}
</div>
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
{showSettings && (
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div>
<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.5 text-white focus:outline-none focus:border-anton-accent">
{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1.5"><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))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1.5"><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))} />
</div>
<div>
<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.5 text-white focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</div>
{isSuperadmin && repos.length > 0 && (
<div>
<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">
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> 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.5 text-white focus:outline-none focus:border-orange-400">
<option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
{repos.map(r => <option key={r.id} value={r.id}>{r.name} ({r.default_branch})</option>)}
</select>
</div>
</div>
)}
)}
</div>
)}
{pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
{pendingFiles.map((pf, i) => (
<div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
{pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
) : (
<div className="w-16 h-16 flex flex-col items-center justify-center px-1">
<FileText size={20} className="text-anton-muted mb-1" />
<span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
{pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => {
const Icon = TYPE_ICONS[pf.type] || FileText;
return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" /> : (
<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1">
<Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} />
<span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span>
</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>
<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>
<button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
</div>
))}
</div>
)}
<div 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={() => 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>
{linkedRepo && (
<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>
)}
<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">
<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…"}
rows={1} style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
</div>
{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={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">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
);
})}
</div>
)}
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-green-400">🔗 {linkedRepo.name}</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") && (
<button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch { } }}
className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
)}
<div className="flex items-end gap-1.5">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
<div className="flex-1 min-w-0">
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"}
rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
</div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
</div>
{/* Repo File Panel (slides in from right) */}
{showRepoPanel && linkedRepo && (
<RepoFilePanel linkedRepo={linkedRepo} token={state.token} onClose={() => setShowRepoPanel(false)} />
)}
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find(m => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{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, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>
)}
</div>
</div>
</div>
);
}
\ No newline at end of file
import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitBranch, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { gitlabFileExists, gitlabCommitSingle } from "../api";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitCommitVertical } from "lucide-react";
const COMMIT_STATES = { idle: "idle", checking: "checking", committing: "committing", success: "success", error: "error" };
export default function CodeBlock({ language, filename, code, linkedRepo, token }) {
export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
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() {
navigator.clipboard.writeText(code);
......@@ -29,119 +22,43 @@ export default function CodeBlock({ language, filename, code, linkedRepo, token
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");
}
function handleCommit() {
if (!onCommit || !filename) return;
onCommit(filename, code, "update");
}
const canCommit = linkedRepo && filename && token;
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 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} />
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border">
<div className="flex items-center gap-2 min-w-0">
{language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>}
{filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{linkedRepo && filename && (
<button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition">
<GitCommitVertical size={11} /> Commit
</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 onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} />
</button>
<button onClick={handleCopy} className="p-1.5 text-anton-muted hover:text-white transition" title="Copy">
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</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">
<button onClick={handleCommit}
disabled={commitState === COMMIT_STATES.checking || commitState === COMMIT_STATES.committing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50 disabled:cursor-not-allowed">
{commitState === COMMIT_STATES.checking && <><Loader2 size={12} className="animate-spin" /> Checking...</>}
{commitState === COMMIT_STATES.committing && <><Loader2 size={12} className="animate-spin" /> Committing...</>}
{commitState === COMMIT_STATES.success && <><CheckCircle2 size={12} /> Committed!</>}
{commitState === COMMIT_STATES.error && <><XCircle size={12} /> Retry</>}
{commitState === COMMIT_STATES.idle && <><GitBranch size={12} /> Commit</>}
</button>
<button onClick={toggleCommitForm} className="px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition">
Cancel
</button>
{commitState === COMMIT_STATES.success && (
<span className="text-[11px] text-green-400">✓ Pushed to {linkedRepo.default_branch}</span>
)}
</div>
{commitError && (
<div className="text-[11px] text-red-400 bg-red-500/10 rounded px-2 py-1">{commitError}</div>
)}
</div>
)}
{/* Code */}
<SyntaxHighlighter
language={language || "text"}
style={vscDarkPlus}
customStyle={{ margin: 0, padding: "1rem", fontSize: "0.8rem", background: "#1a1a2e", maxHeight: "500px" }}
showLineNumbers
lineNumberStyle={{ minWidth: "2.5em", paddingRight: "1em", color: "#555" }}
style={oneDark}
customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
showLineNumbers={code.split("\n").length > 3}
lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
wrapLongLines
>
{code}
</SyntaxHighlighter>
......
......@@ -8,22 +8,16 @@ import {
Image, Film, FileText, ExternalLink,
} from "lucide-react";
const FILE_TYPE_ICONS = {
image: Image, video: Film, document: FileText, text: FileText,
};
const FILE_TYPE_ICONS = { 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, linkedRepo, onCommit }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false);
const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null);
function handleCopy() {
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleCopy() { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); }
const hasAttachments = attachments && attachments.length > 0;
......@@ -40,16 +34,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{thinking_content && (
<div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
<button onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
<Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button>
{(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
{thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
{thinking_content}{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div>
)}
</div>
......@@ -57,7 +49,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att) => {
{attachments.map(att => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id);
if (att.file_type === "image") {
......@@ -65,18 +57,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} />
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onError={e => { e.target.style.display = "none"; }} />
{expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg" />
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer" onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" />
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
{att.original_filename}
</div>
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div>
</div>
);
}
......@@ -95,8 +82,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</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 ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : (
......@@ -107,28 +93,14 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return (
<CodeBlock
language={lang}
filename={filename}
code={String(children).replace(/\n$/, "")}
linkedRepo={linkedRepo}
token={token}
/>
);
if (rawLang.includes(":")) { const idx = rawLang.indexOf(":"); lang = rawLang.slice(0, idx); filename = rawLang.slice(idx + 1); }
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />;
},
pre({ children }) { return <>{children}</>; },
}}>
{content || ""}
</ReactMarkdown>
{isStreaming && !isThinking && (
<span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
)}
{isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />}
</div>
)}
</div>
......@@ -136,13 +108,10 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{!isUser && !isStreaming && content && (
<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">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
</span>
<span className="text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span>
)}
</div>
)}
......
import React, { useState, useEffect, useCallback } from "react";
import {
X, FolderOpen, FileCode, ChevronRight, ChevronDown, GitBranch,
RefreshCw, Plus, Trash2, Save, Edit3, Eye, Loader2, FileText,
AlertCircle, CheckCircle2, FolderClosed,
} from "lucide-react";
import { gitlabGetTree, gitlabGetFile, gitlabCommitSingle, gitlabCommitMulti, gitlabGetBranches } from "../api";
const EXT_LANGS = {
py: "Python", js: "JavaScript", ts: "TypeScript", jsx: "React", tsx: "React TS",
cs: "C#", java: "Java", cpp: "C++", c: "C", go: "Go", rs: "Rust", rb: "Ruby",
php: "PHP", swift: "Swift", kt: "Kotlin", lua: "Lua", html: "HTML", css: "CSS",
json: "JSON", yaml: "YAML", yml: "YAML", xml: "XML", sql: "SQL", sh: "Shell",
md: "Markdown", txt: "Text", toml: "TOML", dart: "Dart", vue: "Vue",
};
function getFileExt(name) {
const parts = name.split(".");
return parts.length > 1 ? parts.pop().toLowerCase() : "";
}
function buildFileTree(items) {
const root = { name: "/", type: "tree", children: {}, path: "" };
for (const item of items) {
const parts = item.path.split("/");
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i < parts.length - 1) {
if (!node.children[part]) {
node.children[part] = { name: part, type: "tree", children: {}, path: parts.slice(0, i + 1).join("/") };
}
node = node.children[part];
} else {
node.children[part] = { ...item, children: item.type === "tree" ? {} : undefined };
}
}
}
return root;
}
function TreeNode({ node, depth, onSelect, selectedPath }) {
const [open, setOpen] = useState(depth < 2);
const isDir = node.type === "tree";
const isSelected = node.path === selectedPath;
const ext = !isDir ? getFileExt(node.name) : "";
const children = isDir ? Object.values(node.children).sort((a, b) => {
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
}) : [];
return (
<div>
<button
onClick={() => { if (isDir) setOpen(!open); else onSelect(node.path); }}
className={`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs hover:bg-white/5 transition rounded ${isSelected ? "bg-anton-accent/15 text-anton-accent" : "text-anton-text"
}`}
style={{ paddingLeft: `${depth * 14 + 8}px` }}
>
{isDir ? (
open ? <ChevronDown size={12} className="text-anton-muted shrink-0" /> : <ChevronRight size={12} className="text-anton-muted shrink-0" />
) : <span className="w-3 shrink-0" />}
{isDir ? (
open ? <FolderOpen size={13} className="text-yellow-400 shrink-0" /> : <FolderClosed size={13} className="text-yellow-500/70 shrink-0" />
) : (
<FileCode size={13} className="text-blue-400 shrink-0" />
)}
<span className="truncate">{node.name}</span>
{ext && <span className="text-[9px] text-anton-muted ml-auto shrink-0">{EXT_LANGS[ext] || ext}</span>}
</button>
{isDir && open && children.map((child) => (
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} selectedPath={selectedPath} />
))}
</div>
);
}
export default function RepoFilePanel({ linkedRepo, token, onClose }) {
const [treeData, setTreeData] = useState(null);
const [branches, setBranches] = useState([]);
const [branch, setBranch] = useState(linkedRepo?.default_branch || "main");
const [selectedFile, setSelectedFile] = useState(null);
const [fileContent, setFileContent] = useState("");
const [editContent, setEditContent] = useState("");
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [fileLoading, setFileLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [commitMsg, setCommitMsg] = useState("");
const [status, setStatus] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [newFilePath, setNewFilePath] = useState("");
const [newFileContent, setNewFileContent] = useState("");
const [showDelete, setShowDelete] = useState(false);
const loadTree = useCallback(async () => {
if (!linkedRepo) return;
setLoading(true);
try {
const data = await gitlabGetTree(token, linkedRepo.id, branch);
setTreeData(buildFileTree(data.items || []));
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setLoading(false);
}
}, [linkedRepo, token, branch]);
const loadBranches = useCallback(async () => {
if (!linkedRepo) return;
try {
const data = await gitlabGetBranches(token, linkedRepo.id);
setBranches(data);
} catch { }
}, [linkedRepo, token]);
useEffect(() => { loadTree(); loadBranches(); }, [loadTree, loadBranches]);
async function handleSelectFile(path) {
setSelectedFile(path);
setEditing(false);
setFileLoading(true);
setStatus(null);
try {
const data = await gitlabGetFile(token, linkedRepo.id, path, branch);
setFileContent(data.content || "");
setEditContent(data.content || "");
} catch (err) {
setFileContent(`[Error loading file: ${err.message}]`);
} finally {
setFileLoading(false);
}
}
async function handleSave() {
if (!selectedFile || !linkedRepo) return;
setSaving(true);
setStatus(null);
try {
await gitlabCommitSingle(token, linkedRepo.id, {
branch,
file_path: selectedFile,
content: editContent,
commit_message: commitMsg || `Update ${selectedFile} via Son of Anton`,
action: "update",
});
setFileContent(editContent);
setEditing(false);
setCommitMsg("");
setStatus({ type: "success", text: `Committed to ${branch}` });
setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setSaving(false);
}
}
async function handleCreate() {
if (!newFilePath.trim() || !linkedRepo) return;
setSaving(true);
setStatus(null);
try {
await gitlabCommitSingle(token, linkedRepo.id, {
branch,
file_path: newFilePath.trim(),
content: newFileContent || "",
commit_message: `Create ${newFilePath.trim()} via Son of Anton`,
action: "create",
});
setShowCreate(false);
setNewFilePath("");
setNewFileContent("");
setStatus({ type: "success", text: `Created ${newFilePath.trim()}` });
loadTree();
setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!selectedFile || !linkedRepo) return;
setSaving(true);
setStatus(null);
try {
await gitlabCommitMulti(token, linkedRepo.id, {
branch,
commit_message: `Delete ${selectedFile} via Son of Anton`,
actions: [{ action: "delete", file_path: selectedFile }],
});
setShowDelete(false);
setSelectedFile(null);
setFileContent("");
setStatus({ type: "success", text: `Deleted ${selectedFile}` });
loadTree();
setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setSaving(false);
}
}
if (!linkedRepo) return null;
return (
<div className="w-[420px] shrink-0 border-l border-anton-border bg-anton-surface flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="p-3 border-b border-anton-border space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<GitBranch size={14} className="text-green-400 shrink-0" />
<span className="text-sm font-semibold text-white truncate">{linkedRepo.name}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={loadTree} title="Refresh" className="p-1.5 rounded hover:bg-white/5 text-anton-muted hover:text-white transition">
<RefreshCw size={13} className={loading ? "animate-spin" : ""} />
</button>
<button onClick={onClose} className="p-1.5 rounded hover:bg-white/5 text-anton-muted hover:text-white transition">
<X size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<select value={branch} onChange={(e) => setBranch(e.target.value)}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-green-500/50">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}{b.default ? " ★" : ""}</option>)}
{!branches.length && <option value={branch}>{branch}</option>}
</select>
<button onClick={() => { setShowCreate(!showCreate); setShowDelete(false); }} title="New file"
className={`p-1.5 rounded transition ${showCreate ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-green-400 hover:bg-green-500/10"}`}>
<Plus size={14} />
</button>
</div>
{status && (
<div className={`flex items-center gap-1.5 text-[11px] px-2 py-1 rounded ${status.type === "success" ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
}`}>
{status.type === "success" ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
{status.text}
</div>
)}
</div>
{/* Create New File Form */}
{showCreate && (
<div className="p-3 border-b border-anton-border space-y-2 bg-green-500/5 animate-fade-in">
<div className="text-xs font-medium text-green-400">Create New File</div>
<input type="text" value={newFilePath} onChange={(e) => setNewFilePath(e.target.value)}
placeholder="path/to/file.ext"
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 font-mono" />
<textarea value={newFileContent} onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content (optional)..." rows={4}
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 font-mono resize-none" />
<div className="flex items-center gap-2">
<button onClick={handleCreate} disabled={!newFilePath.trim() || saving}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50">
{saving ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />} Create
</button>
<button onClick={() => setShowCreate(false)} className="px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition">Cancel</button>
</div>
</div>
)}
{/* File Tree */}
<div className="flex-1 overflow-y-auto min-h-0">
{loading && !treeData ? (
<div className="flex items-center justify-center py-8 text-anton-muted text-xs"><Loader2 size={16} className="animate-spin mr-2" /> Loading tree...</div>
) : treeData ? (
<div className="py-1">
{Object.values(treeData.children).sort((a, b) => {
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
}).map((node) => (
<TreeNode key={node.path} node={node} depth={0} onSelect={handleSelectFile} selectedPath={selectedFile} />
))}
</div>
) : (
<div className="text-center py-8 text-anton-muted text-xs">No files loaded</div>
)}
</div>
{/* File Viewer / Editor */}
{selectedFile && (
<div className="border-t border-anton-border flex flex-col" style={{ height: "45%" }}>
<div className="flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-card">
<div className="flex items-center gap-1.5 min-w-0">
<FileText size={12} className="text-blue-400 shrink-0" />
<span className="text-[11px] text-white font-mono truncate">{selectedFile}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{!editing ? (
<button onClick={() => { setEditing(true); setEditContent(fileContent); setCommitMsg(`Update ${selectedFile}`); }}
title="Edit" className="p-1 rounded text-anton-muted hover:text-yellow-400 hover:bg-yellow-500/10 transition">
<Edit3 size={12} />
</button>
) : (
<button onClick={() => setEditing(false)} title="View"
className="p-1 rounded text-yellow-400 bg-yellow-500/10 transition">
<Eye size={12} />
</button>
)}
<button onClick={() => { setShowDelete(true); setShowCreate(false); }} title="Delete"
className="p-1 rounded text-anton-muted hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={12} />
</button>
<button onClick={() => setSelectedFile(null)} className="p-1 rounded text-anton-muted hover:text-white transition">
<X size={12} />
</button>
</div>
</div>
{/* Delete Confirmation */}
{showDelete && (
<div className="px-3 py-2 bg-red-500/10 border-b border-red-500/20 animate-fade-in">
<div className="text-[11px] text-red-400 mb-2">Delete <strong>{selectedFile}</strong> from {branch}?</div>
<div className="flex items-center gap-2">
<button onClick={handleDelete} disabled={saving}
className="flex items-center gap-1 px-2.5 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-[11px] font-medium transition disabled:opacity-50">
{saving ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />} Delete
</button>
<button onClick={() => setShowDelete(false)} className="px-2.5 py-1 rounded text-[11px] text-anton-muted hover:text-white transition">Cancel</button>
</div>
</div>
)}
{/* Save bar */}
{editing && (
<div className="px-3 py-2 bg-yellow-500/5 border-b border-anton-border flex items-center gap-2">
<input type="text" value={commitMsg} onChange={(e) => setCommitMsg(e.target.value)} placeholder="Commit message..."
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-[11px] text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50" />
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1 px-2.5 py-1 rounded bg-green-600 hover:bg-green-500 text-white text-[11px] font-medium transition disabled:opacity-50">
{saving ? <Loader2 size={11} className="animate-spin" /> : <Save size={11} />} Save
</button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-auto min-h-0">
{fileLoading ? (
<div className="flex items-center justify-center py-8 text-anton-muted text-xs"><Loader2 size={14} className="animate-spin mr-2" /> Loading...</div>
) : editing ? (
<textarea value={editContent} onChange={(e) => setEditContent(e.target.value)}
className="w-full h-full bg-[#1a1a2e] text-green-300 font-mono text-xs p-3 resize-none focus:outline-none" spellCheck={false} />
) : (
<pre className="text-xs text-anton-text font-mono p-3 whitespace-pre-wrap break-words">{fileContent}</pre>
)}
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
......@@ -70,7 +70,7 @@ export default function GitLabPage() {
async function handleTest() {
setTesting(true); setTestResult(null);
try {
const r = await gitlabTestConnection(t, { gitlab_url: url, gitlab_token: token || "UNCHANGED" });
const r = await gitlabTestConnection(t);
setTestResult({ ok: true, msg: `Connected as ${r.name} (@${r.username})` });
} catch (e) { setTestResult({ ok: false, msg: e.message }); }
setTesting(false);
......
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