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

+26 -13
View File
@@ -14,16 +14,21 @@ const BUILTIN: { name: string; key: Key }[] = [
{ name: "new", key: "command.new" },
];
function readImage(file: File): Promise<ImageData> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const url = String(r.result);
resolve({ media_type: file.type || "image/png", data: url.slice(url.indexOf(",") + 1) });
};
r.onerror = reject;
r.readAsDataURL(file);
});
const MAX_SIDE = 2000;
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
async function readImage(file: File): Promise<ImageData> {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
// PNG keeps screenshots crisp; photos become JPEG.
const type = file.type === "image/png" && file.size < 1_500_000 ? "image/png" : "image/jpeg";
const url = canvas.toDataURL(type, 0.85);
return { media_type: type, data: url.slice(url.indexOf(",") + 1) };
}
export function Composer({
@@ -97,8 +102,12 @@ export function Composer({
};
const addFiles = async (files: FileList | File[]) => {
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
try {
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
} catch (e: any) {
alert(e.message ?? String(e));
}
};
return (
@@ -163,6 +172,7 @@ export function Composer({
aria-label={t("composer.placeholder")}
/>
<div className="composer-bar">
<div className="composer-options">
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
{agents.map((a) => (
<button
@@ -182,6 +192,7 @@ export function Composer({
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
title={t("composer.permissionMode")}
>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="default">{t("permissionMode.default")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
@@ -191,7 +202,8 @@ export function Composer({
📎
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
</label>
<span className="grow" />
</div>
<div className="composer-actions">
{busy && (
<button className="danger" onClick={onAbort} title="Esc">
■ {t("composer.stop")}
@@ -200,6 +212,7 @@ export function Composer({
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
{busy ? t("composer.queue") : t("composer.send")}
</button>
</div>
</div>
</div>
</div>