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

+112 -20
View File
@@ -5,7 +5,12 @@ import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
import { navigate } from "../router";
import { dismissError, loadSession, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
import { ActivityLine, Dots, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
import { Markdown } from "./Markdown";
import { TodoList, ToolCard } from "./ToolCard";
@@ -22,12 +27,29 @@ interface SessionStats {
function Reasoning({ text, live }: { text: string; live: boolean }) {
const t = useT();
if (!text.trim()) return null;
const mode = useReasoningMode();
const [toggled, setToggled] = useState<boolean | null>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const open = toggled ?? (mode === "expanded" || (mode === "live" && live));
// Follow the stream while the model is thinking.
useLayoutEffect(() => {
if (live && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}, [text, live]);
if (mode === "hidden" || !text.trim()) return null;
const words = text.trim().split(/\s+/).length;
return (
<details className="reasoning" open={live}>
<summary>{t("chat.reasoning")}</summary>
<div className="body">{text}</div>
</details>
<div className={`reasoning ${live ? "live" : ""}`}>
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
{live ? <Dots /> : <span aria-hidden="true">💭</span>}
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && (
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
{text}
</div>
)}
</div>
);
}
@@ -37,7 +59,11 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
{parts.map((p, i) => {
switch (p.type) {
case "text":
return <Markdown key={i} text={p.text} />;
return (
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
<Markdown text={p.text} />
</div>
);
case "reasoning":
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
case "tool_call":
@@ -50,6 +76,32 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
);
}
const BG_RE = /^<background-task-result agent="([^"]*)" session="([^"]*)" description="([^"]*)">\n?([\s\S]*?)\n?<\/background-task-result>$/;
/** Result of a background subagent, delivered as a message. */
function BackgroundResult({ text }: { text: string }) {
const t = useT();
const m = text.match(BG_RE);
if (!m) return null;
const [, agent, session, description, body] = m;
return (
<details className="tool" style={{ borderColor: "var(--aqua)" }}>
<summary>
<span className="tool-name">{agent}</span>
<span className="tool-title">
{t("chat.backgroundDone")}: {description}
</span>
<a href={`#/s/${session}`} onClick={(e) => e.stopPropagation()}>
{t("tool.openSubagent")}
</a>
</summary>
<div className="tool-body">
<Markdown text={body} />
</div>
</details>
);
}
function StatsPanel({ stats }: { stats: SessionStats }) {
const t = useT();
const a = stats.total;
@@ -99,6 +151,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const errors = useStore((s) => s.errors[sessionId]);
const allPermissions = useStore((s) => s.permissions);
const allQuestions = useStore((s) => s.questions);
const waiting = useStore((s) => s.waiting[sessionId]);
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
const isAdmin = useStore((s) => s.me?.role === "admin");
const [sharing, setSharing] = useState(false);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState("");
@@ -210,24 +266,39 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<div className="path">{session.cwd}</div>
</div>
{ctxShare !== undefined && (
<span className={`chip ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
<span className={`chip hide-mobile ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
{t("chat.context")} {fmtPercent(ctxShare)}
</span>
)}
{stats && (
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
{fmtCompact(stats.total.output_tokens)}↓ · {fmtCost(stats.total.cost, stats.currency)}
<span className="hide-mobile">
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
{fmtCompact(stats.total.output_tokens)}↓ ·{" "}
</span>
{fmtCost(stats.total.cost, stats.currency)}
</button>
)}
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
⇊
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
🗑
</button>
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
{!readOnly && (
<>
{!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
⇄
</button>
)}
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
⇊
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
🗑
</button>
</>
)}
</div>
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
className="chat-scroll"
ref={scrollRef}
@@ -256,13 +327,26 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</details>
);
}
const texts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => BG_RE.test(p.text));
const texts = allTexts.filter((p) => !BG_RE.test(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
if (texts.length === 0 && imgs.length === 0) return null;
if (texts.length === 0 && imgs.length === 0) {
return background.length ? (
<div key={m.id} className="msg">
{background.map((p, i) => (
<BackgroundResult key={i} text={p.text} />
))}
</div>
) : null;
}
return (
<div key={m.id} className="msg">
{background.map((p, i) => (
<BackgroundResult key={i} text={p.text} />
))}
<div className="msg-user">
{!busy && (
{!busy && !readOnly && (
<div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
✎
@@ -286,8 +370,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<AssistantParts parts={draft.parts} results={results} running={busy} live />
</div>
)}
{busy && !draft && <div className="msg-meta">{t("chat.thinking")}</div>}
{openTodos.length > 0 && (
{(busy || waiting) && (
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
@@ -322,6 +408,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
)}
</div>
</div>
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
) : (
<Composer
session={session}
busy={busy}
@@ -331,6 +422,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
onAbort={() => post(`/sessions/${sessionId}/abort`)}
onUpdate={update}
/>
)}
</>
);
}