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:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -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() })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user