Users, file manager, image generation, budgets and agent workflow

- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+36 -19
View File
@@ -1,39 +1,48 @@
import { useEffect, useState } from "react";
import { ApiError, connectEvents, get, getToken } from "./api";
import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n";
import { navigate, useRoute } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store";
import type { Info } from "./types";
import { Sidebar } from "./components/Sidebar";
import { Login } from "./components/Login";
import { ChatView } from "./components/ChatView";
import { NewSession } from "./components/NewSession";
import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats";
import { Images } from "./components/Images";
import { Spinner, useTitleActivity } from "./components/Activity";
type AuthState = "checking" | "login" | "ok";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
export function App() {
const t = useT();
const route = useRoute();
const [auth, setAuth] = useState<AuthState>("checking");
const [info, setInfo] = useState<Info>();
const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false);
useTitleActivity();
const refresh = async () => {
try {
const i = await get<Info>("/info");
setState(() => ({ me: i.user }));
if (i.user) await loadSessions();
setInfo(i);
} catch (e: any) {
setError(e.message);
}
};
// Check whether a token is needed and whether ours works.
useEffect(() => {
(async () => {
try {
const info = await get<{ auth_required: boolean }>("/info");
if (info.auth_required && !getToken()) return setAuth("login");
await loadSessions();
setAuth("ok");
} catch (e) {
setAuth(e instanceof ApiError && e.status === 401 ? "login" : "ok");
}
})();
setUnauthorizedHandler(() => setInfo((i) => (i ? { ...i, user: null } : i)));
void refresh();
}, []);
const signedIn = !!info?.user;
useEffect(() => {
if (auth !== "ok") return;
if (!signedIn) return;
return connectEvents({
onEvent: applyEvent,
onOpen: () => {
@@ -42,19 +51,27 @@ export function App() {
},
onClose: () => setState(() => ({ connected: false })),
});
}, [auth]);
}, [signedIn]);
// Close the mobile menu on navigation.
useEffect(() => setMenuOpen(false), [route.join("/")]);
if (auth === "checking") return <div className="empty">{t("common.loading")}</div>;
if (auth === "login") return <Login onSuccess={() => setAuth("ok")} />;
if (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user) return <Login info={info} onSuccess={refresh} />;
const [section, arg] = route;
let content;
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "files")
content = (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<Files onMenu={() => setMenuOpen(true)} />
</Suspense>
);
else content = <NewSession onMenu={() => setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />;
return (