diff --git a/CHANGELOG.md b/CHANGELOG.md index 2162d58..4f6aa4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Fixed + +- **Generated images missing on other devices:** when an agent moved or renamed a generated + image afterwards (e.g. sorted it into subfolders), the gallery pointed to the old file – only + the device that had it in its browser cache still showed it. Now: + - generated images are also kept in the image store and shown from there when the file is gone + (existing images get their copy on the first start); + - their entries follow moves and renames: after an agent's `mv`/`rename`, when moved with the + file manager, and on the next view (found by file name, or by content when renamed); + - in the chat, an image that cannot be found falls back to the preview the model saw. + ## [v202611-00014] – 2026-11-10 ### Added diff --git a/server/src/api.rs b/server/src/api.rs index 035d91c..eb0d17b 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -1563,13 +1563,26 @@ fn own_image(app: &App, user: &User, id: &str) -> Result, State(app): State, Path(id): Path) -> Result { let img = own_image(&app, &user, &id)?; - let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?; - let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) { + let ctype_of = |p: &str| match std::path::Path::new(p).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { Some("jpg") | Some("jpeg") => "image/jpeg", Some("webp") => "image/webp", _ => "image/png", }; - Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response()) + let respond = |ctype: String, bytes: Vec| ([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400".to_string())], bytes).into_response(); + if let Ok(bytes) = tokio::fs::read(&img.path).await { + return Ok(respond(ctype_of(&img.path).into(), bytes)); + } + // The file was moved or renamed (agents tidy up folders): with a copy in the image store, + // answer from there and look for the file in the background; otherwise look now. + if let Some((mime, bytes)) = img.blob.as_deref().map(|h| app.store.blob(h)).transpose()?.flatten() { + let a = app.clone(); + tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img)); + return Ok(respond(mime, bytes)); + } + let a = app.clone(); + let found = tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img)).await.ok().flatten().ok_or_else(|| not_found("image file"))?; + let bytes = tokio::fs::read(&found).await.map_err(|_| not_found("image file"))?; + Ok(respond(ctype_of(&found.display().to_string()).into(), bytes)) } async fn delete_image(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { diff --git a/server/src/app.rs b/server/src/app.rs index 217a70c..01249a4 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -144,6 +144,11 @@ impl App { Ok(n) => tracing::info!("moved {n} images of older chats to the image store"), Err(e) => tracing::warn!("moving images to the image store: {e:#}"), } + match crate::images::backfill_copies(&self) { + Ok(0) => {} + Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"), + Err(e) => tracing::warn!("copies of generated images: {e:#}"), + } match self.store.gc_blobs() { Ok(0) => {} Ok(n) => tracing::info!("removed {n} unused images"), diff --git a/server/src/blobs.rs b/server/src/blobs.rs index ff5fc6b..3250558 100644 --- a/server/src/blobs.rs +++ b/server/src/blobs.rs @@ -56,7 +56,8 @@ pub fn is_hash(s: &str) -> bool { s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } -fn hash(bytes: &[u8]) -> String { +/// Address of `bytes` in the image store (SHA-256, hex). +pub fn hash(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } diff --git a/server/src/files.rs b/server/src/files.rs index df47fa4..e50f7de 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -353,6 +353,10 @@ pub async fn move_or_copy(Extension(user): Extension, State(app): State Result 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") }; let path = req.dir.join(name); tokio::fs::write(&path, &bytes).await?; + // A copy in the image store keeps it viewable when the file is moved or deleted. + let blob = app.store.put_blob(&ctype, &bytes).map_err(|e| tracing::warn!("image copy: {e:#}")).ok(); let rec = ImageRecord { id: new_id(), user_id: req.user_id.clone(), @@ -223,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result Result Option { + let name = path.file_name()?; + search_near(path, roots, |p, _| p.file_name() == Some(name)) +} + +/// Like `find_moved`, but by content: an image file with the same bytes (renamed). +pub fn find_by_content(path: &Path, roots: &[PathBuf], hash: &str, size: u64) -> Option { + search_near(path, roots, |p, len| { + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + len == size && matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "webp")) && std::fs::read(p).is_ok_and(|b| crate::blobs::hash(&b) == hash) + }) +} + +/// Files below the folder of `path` and the folder above it (3 levels, hidden folders +/// skipped, inside `roots`) for which `matches(path, size)` holds. +fn search_near(path: &Path, roots: &[PathBuf], matches: impl Fn(&Path, u64) -> bool) -> Option { + let dir = path.parent()?; + let mut budget = 5000usize; + for base in [Some(dir), dir.parent()].into_iter().flatten() { + if !roots.iter().any(|r| base.starts_with(r)) { + continue; + } + let mut stack = vec![(base.to_path_buf(), 0usize)]; + while let Some((d, depth)) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { continue }; + for e in entries.flatten() { + if budget == 0 { + return None; + } + budget -= 1; + let p = e.path(); + let Ok(ft) = e.file_type() else { continue }; + if ft.is_file() && p != path && matches(&p, e.metadata().map(|m| m.len()).unwrap_or(0)) { + return Some(p); + } + let hidden = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') || n == "node_modules"); + if ft.is_dir() && depth < 3 && !hidden { + stack.push((p, depth + 1)); + } + } + } + } + None +} + +/// Where the file of a generated image is now: by name, then (with a copy in the image +/// store) by content. Corrects the stored path when found. +pub fn relocate(app: &App, rec: &ImageRecord) -> Option { + let path = Path::new(&rec.path); + let roots = app.user_roots(rec.user_id.as_deref()); + let found = find_moved(path, &roots).or_else(|| { + let hash = rec.blob.as_deref()?; + let (_, bytes) = app.store.blob(hash).ok()??; + find_by_content(path, &roots, hash, bytes.len() as u64) + })?; + let _ = app.store.set_image_path(&rec.id, &found.display().to_string()); + Some(found) +} + +/// After an agent moved or renamed files: find the generated images of `owner` from the +/// last two weeks whose files are gone and correct their paths. Returns how many. +pub fn relocate_missing(app: &App, owner: Option<&str>) -> usize { + let since = now_ms() - 14 * 86_400_000; + let Ok(list) = app.store.images(owner, 500) else { return 0 }; + list.iter().filter(|r| r.created_at >= since && !Path::new(&r.path).exists()).filter(|r| relocate(app, r).is_some()).count() +} + +/// Copies in the image store for generated images from before they were kept (once per +/// image, files up to 25 MB): later moves and renames are then found by content. +pub fn backfill_copies(app: &App) -> Result { + let mut n = 0; + for (id, path) in app.store.images_without_copy()? { + let Ok(meta) = std::fs::metadata(&path) else { continue }; + if !meta.is_file() || meta.len() > 25 * 1024 * 1024 { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { continue }; + let mime = match Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { + Some("jpg" | "jpeg") => "image/jpeg", + Some("webp") => "image/webp", + _ => "image/png", + }; + let hash = app.store.put_blob(mime, &bytes)?; + app.store.set_image_blob(&id, &hash)?; + n += 1; + } + Ok(n) +} + /// Downscaled copy for the model's context. fn preview_for_model(path: &Path) -> Option { let img = image::open(path).ok()?; @@ -363,6 +458,37 @@ impl Tool for GenerateImage { mod tests { use super::*; + #[test] + fn finds_moved_files_and_keeps_their_copies() { + let dir = std::env::temp_dir().join(format!("cc-img-moved-{}", std::process::id())); + let art = dir.join("art"); + std::fs::create_dir_all(art.join("older")).unwrap(); + std::fs::write(art.join("older/20261110-a.png"), b"x").unwrap(); + // Moved into a subfolder: found; renamed or outside the roots: not. + assert_eq!(find_moved(&art.join("20261110-a.png"), std::slice::from_ref(&dir)), Some(art.join("older/20261110-a.png"))); + assert_eq!(find_moved(&art.join("20261110-b.png"), std::slice::from_ref(&dir)), None); + assert_eq!(find_moved(&art.join("20261110-a.png"), &[dir.join("elsewhere")]), None); + // Renamed: found by content. + std::fs::write(art.join("older/level-5.png"), b"renamed bytes").unwrap(); + let h = crate::blobs::hash(b"renamed bytes"); + assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &h, 13), Some(art.join("older/level-5.png"))); + assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &crate::blobs::hash(b"other"), 13), None); + // The image store keeps the copy of a generated image. + let store = crate::store::Store::open(&dir.join("t.db")).unwrap(); + let hash = store.put_blob("image/png", b"png bytes").unwrap(); + store + .add_image(&ImageRecord { id: "i".into(), user_id: None, session_id: None, prompt: "p".into(), model: "m".into(), path: "/gone.png".into(), width: 1, height: 1, created_at: 0, blob: Some(hash.clone()) }) + .unwrap(); + assert_eq!(store.gc_blobs().unwrap(), 0); + // Moved with the file manager: the entry follows (file or folder, not look-alikes). + assert_eq!(store.move_image_paths("/gone.png", "/art/new.png").unwrap(), 1); + assert_eq!(store.move_image_paths("/art", "/bilder/").unwrap(), 1); + assert_eq!(store.move_image_paths("/bild", "/x").unwrap(), 0); + assert_eq!(store.image("i").unwrap().unwrap().path, "/bilder/new.png"); + assert_eq!(store.blob(&hash).unwrap().unwrap().1, b"png bytes"); + std::fs::remove_dir_all(&dir).unwrap(); + } + fn m(id: &str) -> ImageModel { with_defaults(ImageModel { id: id.into(), ..Default::default() }) } diff --git a/server/src/runner.rs b/server/src/runner.rs index 1604cdf..d502a37 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -563,7 +563,15 @@ async fn execute_call( // Pass depth through the agent name channel: subagents run with depth+1. run_task(app, &ctx, input, depth).await } else { - tool.run(input, &ctx).await + let moves = name == "bash" && input["command"].as_str().is_some_and(moves_files); + let out = tool.run(input, &ctx).await; + if moves { + // Generated images the command moved or renamed keep their entries. + let a = app.clone(); + let owner = session.owner_id.clone(); + tokio::task::spawn_blocking(move || crate::images::relocate_missing(&a, owner.as_deref())); + } + out }; // Diagnostics: report new errors of the changed file right away. let res = match res { @@ -609,6 +617,11 @@ async fn execute_call( } } +/// A shell command that may move or rename files (`mv`, `git mv`, `rename`). +fn moves_files(cmd: &str) -> bool { + cmd.split(|c: char| c.is_whitespace() || ";&|()`$".contains(c)).any(|w| w == "mv" || w == "rename") +} + async fn run_task(app: &Arc, ctx: &ToolCtx, input: Value, depth: usize) -> Result { let prompt = tools::arg_str(&input, "prompt")?.to_string(); let description = input["description"].as_str().unwrap_or("subagent").to_string(); @@ -1301,6 +1314,14 @@ async fn compact_inner(app: Arc, session_id: &str, cancel: CancellationToke mod tests { use super::*; + #[test] + fn detects_commands_that_move_files() { + assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/")); + assert!(moves_files("git mv a.png b.png")); + assert!(moves_files("cd art;mv x y")); + assert!(!moves_files("ls -la movies && cat remove.txt")); + } + fn call(id: &str) -> Part { Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None } } diff --git a/server/src/store.rs b/server/src/store.rs index 7e95745..d292968 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -170,6 +170,7 @@ impl Store { ("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"), ("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"), ("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"), + ("images", "blob", "ALTER TABLE images ADD COLUMN blob TEXT"), ("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"), ] { let exists: bool = conn @@ -394,6 +395,10 @@ impl Store { } drop(rows); drop(stmt); + // Copies of generated images (see ImageRecord::blob). + for h in conn.prepare("SELECT blob FROM images WHERE blob IS NOT NULL")?.query_map([], |r| r.get::<_, String>(0))? { + used.insert(h?); + } let all: Vec = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::>()?; let mut removed = 0; for h in all.iter().filter(|h| !used.contains(*h)) { @@ -966,6 +971,10 @@ pub struct ImageRecord { pub width: i64, pub height: i64, pub created_at: i64, + /// Copy of the bytes in the image store: the file may be moved or deleted later + /// (agents tidy up folders), the image stays viewable. + #[serde(skip)] + pub blob: Option, } fn image_row(r: &rusqlite::Row) -> rusqlite::Result { @@ -979,21 +988,62 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result { width: r.get(6)?, height: r.get(7)?, created_at: r.get(8)?, + blob: r.get(9)?, }) } -const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at"; +const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at, blob"; impl Store { pub fn add_image(&self, img: &ImageRecord) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( - &format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"), - params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at], + &format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"), + params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at, img.blob], )?; Ok(()) } + /// Store bytes in the image store; returns their hash. + pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result { + let hash = crate::blobs::hash(bytes); + self.conn.lock().unwrap().execute( + "INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)", + params![hash, media_type, bytes, now_ms()], + )?; + Ok(hash) + } + + /// Generated images from before the copies in the image store (id, path). + pub fn images_without_copy(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, path FROM images WHERE blob IS NULL")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::>>()?; + Ok(rows) + } + + pub fn set_image_blob(&self, id: &str, hash: &str) -> Result<()> { + self.conn.lock().unwrap().execute("UPDATE images SET blob = ?2 WHERE id = ?1", params![id, hash])?; + Ok(()) + } + + /// A file or folder was moved: generated images in it follow. + pub fn move_image_paths(&self, from: &str, to: &str) -> Result { + let from = from.trim_end_matches('/'); + let to = to.trim_end_matches('/'); + Ok(self.conn.lock().unwrap().execute( + "UPDATE images SET path = ?2 || substr(path, length(?1) + 1) + WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'", + params![from, to], + )?) + } + + /// The file of a generated image was found somewhere else. + pub fn set_image_path(&self, id: &str, path: &str) -> Result<()> { + self.conn.lock().unwrap().execute("UPDATE images SET path = ?2 WHERE id = ?1", params![id, path])?; + Ok(()) + } + /// Latest images; `user = None` lists everybody's (admins). pub fn images(&self, user: Option<&str>, limit: i64) -> Result> { let conn = self.conn.lock().unwrap(); diff --git a/web/src/components/ToolCard.tsx b/web/src/components/ToolCard.tsx index 6e8bbc2..eed8873 100644 --- a/web/src/components/ToolCard.tsx +++ b/web/src/components/ToolCard.tsx @@ -158,11 +158,25 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r )} {Array.isArray(meta?.image_ids) ? (
- {meta.image_ids.map((id: string) => ( - - - - ))} + {meta.image_ids.map((id: string, i: number) => { + // The file may be gone (moved or deleted later): fall back to the preview the model saw. + const preview = result?.images?.[i]; + return ( + + { + const img = e.currentTarget; + if (preview && !img.dataset.fallback) { + img.dataset.fallback = "1"; + img.src = imageSrc(preview); + } + }} + /> + + ); + })}
) : ( result?.images?.map((img, i) => (