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

+237
View File
@@ -0,0 +1,237 @@
//! Image generation via fal.ai – as an agent tool and for the manual image page.
use crate::app::App;
use crate::config::{ImageModel, expand_env};
use crate::store::{ImageRecord, UsageRecord};
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::{ImageData, new_id, now_ms};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use base64::Engine;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Size presets offered in the UI and to the model (FLUX-style names).
pub const SIZES: &[&str] = &["square_hd", "square", "landscape_4_3", "landscape_16_9", "portrait_4_3", "portrait_16_9"];
fn aspect_ratio(size: &str) -> &'static str {
match size {
"landscape_4_3" => "4:3",
"landscape_16_9" => "16:9",
"portrait_4_3" => "3:4",
"portrait_16_9" => "9:16",
_ => "1:1",
}
}
pub struct GenerateRequest {
pub prompt: String,
pub model: Option<String>,
pub size: Option<String>,
pub count: u32,
/// Directory the files are written to.
pub dir: PathBuf,
pub user_id: Option<String>,
pub session_id: Option<String>,
}
fn slug(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
while out.contains("--") {
out = out.replace("--", "-");
}
out.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string()
}
fn model_config(app: &App, id: &str) -> ImageModel {
app.config()
.images
.models
.iter()
.find(|m| m.id == id)
.cloned()
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
}
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
if let Some(rest) = url.strip_prefix("data:") {
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
return Ok((base64::engine::general_purpose::STANDARD.decode(data)?, ctype));
}
let resp = crate::provider::http_client().get(url).timeout(Duration::from_secs(120)).send().await?.error_for_status()?;
let ctype = resp.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("image/png").to_string();
Ok((resp.bytes().await?.to_vec(), ctype))
}
/// Generate images, store them in `req.dir` and record them (history + cost).
pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord>> {
let cfg = app.config();
let ic = &cfg.images;
if !ic.enabled {
bail!("image generation is disabled – enable [images] in the settings");
}
let key = expand_env(&ic.api_key);
if key.is_empty() {
bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})");
}
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
let model = model_config(app, &model_id);
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
let count = req.count.clamp(1, 4);
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
match model.size_param.as_deref() {
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
Some("none") => {}
_ => body["image_size"] = json!(size),
}
for (k, v) in &model.options {
body[k] = v.clone();
}
let url = format!("{}/{}", expand_env(&ic.base_url).trim_end_matches('/'), model_id.trim_matches('/'));
let resp = crate::provider::http_client()
.post(&url)
.header("Authorization", format!("Key {key}"))
.timeout(Duration::from_secs(300))
.json(&body)
.send()
.await
.context("fal.ai request failed")?;
if !resp.status().is_success() {
return Err(crate::provider::http_error(resp).await);
}
let out: Value = resp.json().await?;
let images = out["images"].as_array().cloned().or_else(|| out["image"].as_object().map(|o| vec![Value::Object(o.clone())])).unwrap_or_default();
if images.is_empty() {
bail!("fal.ai returned no images: {}", out.to_string().chars().take(300).collect::<String>());
}
tokio::fs::create_dir_all(&req.dir).await?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let base = slug(&req.prompt);
let mut records = Vec::new();
for (i, img) in images.iter().enumerate() {
let src = img["url"].as_str().context("image without url")?;
let (bytes, ctype) = download(src).await?;
let ext = match ctype.as_str() {
"image/jpeg" => "jpg",
"image/webp" => "webp",
_ => "png",
};
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
let path = req.dir.join(name);
tokio::fs::write(&path, &bytes).await?;
let rec = ImageRecord {
id: new_id(),
user_id: req.user_id.clone(),
session_id: req.session_id.clone(),
prompt: req.prompt.clone(),
model: model_id.clone(),
path: path.display().to_string(),
width: img["width"].as_i64().unwrap_or(0),
height: img["height"].as_i64().unwrap_or(0),
created_at: now_ms(),
};
app.store.add_image(&rec)?;
records.push(rec);
}
// Costs go into the normal statistics (provider "fal").
let cost = model.price.unwrap_or(0.0) * records.len() as f64;
let session = req.session_id.clone().unwrap_or_default();
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session.clone(),
root_session_id: if session.is_empty() { String::new() } else { app.root_session_id(&session) },
provider: "fal".into(),
model: model_id,
agent: "image".into(),
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
ttft_ms: 0,
duration_ms: 0,
cost,
error: false,
});
Ok(records)
}
/// Downscaled copy for the model's context.
fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = image::open(path).ok()?;
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
pub struct GenerateImage;
#[async_trait]
impl Tool for GenerateImage {
fn name(&self) -> &str {
"generate_image"
}
fn description(&self) -> String {
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \
visual prompt in English (subject, style, composition, colours, lighting). Each image \
costs money – generate only what is needed. Returns the file paths and a preview."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"prompt": {"type": "string"},
"size": {"type": "string", "enum": SIZES},
"count": {"type": "integer", "minimum": 1, "maximum": 4},
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
"model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"}
},
"required": ["prompt"]
})
}
fn title(&self, input: &Value) -> String {
format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let prompt = arg_str(&input, "prompt")?.to_string();
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
let dir = ctx.resolve(&dir_arg)?;
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
let records = generate(
&ctx.app,
GenerateRequest {
prompt,
model: input["model"].as_str().map(String::from),
size: input["size"].as_str().map(String::from),
count: input["count"].as_u64().unwrap_or(1) as u32,
dir,
user_id: owner,
session_id: Some(ctx.session_id.clone()),
},
)
.await
.map_err(|e| anyhow!("{e:#}"))?;
let mut text = String::from("Generated:\n");
let mut images = Vec::new();
for r in &records {
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
text.push_str(&format!("- {rel} ({}×{})\n", r.width, r.height));
if let Some(p) = preview_for_model(Path::new(&r.path)) {
images.push(p);
}
}
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
}
}