Compare commits

..
Author SHA1 Message Date
Richard Zink 9fd92d5b39 Release v202611-00015 2026-11-10 12:37:14 +01:00
Richard ZinkandClaude Opus 5.5 b99f71526a Generated images survive moves and renames
Agents tidy up folders after generating images (sort into subfolders, rename
revisions); the image records kept the old path, so /api/images/<id>/file
answered 404 and only the requesting device still showed them from its cache.

- Generated images get a copy in the image store (images.blob, kept by the
  blob GC); existing ones are copied once on start-up.
- Records follow: after bash commands with mv/rename (missing images of the
  owner from the last two weeks, found by name or by content hash), when moved
  with the file manager (file or folder), and on access (served from the copy,
  relocated in the background; without a copy found by file name).
- Chat gallery falls back to the preview the model saw when the file is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:37:13 +01:00
10 changed files with 261 additions and 14 deletions

No files matched your search

+13
View File
@@ -3,6 +3,19 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00015] – 2026-11-10
### 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 ## [v202611-00014] – 2026-11-10
### Added ### Added
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00014" src="https://img.shields.io/badge/version-v202611--00014-d69e2e?style=flat-square"> <img alt="Version v202611-00015" src="https://img.shields.io/badge/version-v202611--00015-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+16 -3
View File
@@ -1563,13 +1563,26 @@ fn own_image(app: &App, user: &User, id: &str) -> Result<crate::store::ImageReco
async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> { async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
let img = own_image(&app, &user, &id)?; let img = own_image(&app, &user, &id)?;
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?; 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() {
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg", Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp", Some("webp") => "image/webp",
_ => "image/png", _ => "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<u8>| ([(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<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
+5
View File
@@ -144,6 +144,11 @@ impl App {
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"), 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:#}"), 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() { match self.store.gc_blobs() {
Ok(0) => {} Ok(0) => {}
Ok(n) => tracing::info!("removed {n} unused images"), Ok(n) => tracing::info!("removed {n} unused images"),
+2 -1
View File
@@ -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)) 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() Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
} }
+4
View File
@@ -353,6 +353,10 @@ pub async fn move_or_copy(Extension(user): Extension<User>, State(app): State<Ap
} }
let (f, t) = (from.clone(), to.clone()); let (f, t) = (from.clone(), to.clone());
tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??; tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??;
if !b.copy {
// Generated images in the moved file or folder keep their entries.
app.store.move_image_paths(&from.display().to_string(), &to.display().to_string())?;
}
Ok(Json(json!({"ok": true, "path": to}))) Ok(Json(json!({"ok": true, "path": to})))
} }
+126
View File
@@ -213,6 +213,8 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") }; let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
let path = req.dir.join(name); let path = req.dir.join(name);
tokio::fs::write(&path, &bytes).await?; 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 { let rec = ImageRecord {
id: new_id(), id: new_id(),
user_id: req.user_id.clone(), user_id: req.user_id.clone(),
@@ -223,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
width: img["width"].as_i64().unwrap_or(0), width: img["width"].as_i64().unwrap_or(0),
height: img["height"].as_i64().unwrap_or(0), height: img["height"].as_i64().unwrap_or(0),
created_at: now_ms(), created_at: now_ms(),
blob,
}; };
app.store.add_image(&rec)?; app.store.add_image(&rec)?;
records.push(rec); records.push(rec);
@@ -252,6 +255,98 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
Ok(records) Ok(records)
} }
/// A generated image whose file is gone: the same file name below its original folder
/// (or the folder above it), e.g. after an agent sorted the images into subfolders.
/// Only inside `roots`; looks at a few thousand entries at most.
pub fn find_moved(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<usize> {
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. /// Downscaled copy for the model's context.
fn preview_for_model(path: &Path) -> Option<ImageData> { fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = image::open(path).ok()?; let img = image::open(path).ok()?;
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
mod tests { mod tests {
use super::*; 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 { fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() }) with_defaults(ImageModel { id: id.into(), ..Default::default() })
} }
+22 -1
View File
@@ -563,7 +563,15 @@ async fn execute_call(
// Pass depth through the agent name channel: subagents run with depth+1. // Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await run_task(app, &ctx, input, depth).await
} else { } 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. // Diagnostics: report new errors of the changed file right away.
let res = match res { 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<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> { async fn run_task(app: &Arc<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
let prompt = tools::arg_str(&input, "prompt")?.to_string(); let prompt = tools::arg_str(&input, "prompt")?.to_string();
let description = input["description"].as_str().unwrap_or("subagent").to_string(); let description = input["description"].as_str().unwrap_or("subagent").to_string();
@@ -1301,6 +1314,14 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
mod tests { mod tests {
use super::*; 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 { fn call(id: &str) -> Part {
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None } Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
} }
+53 -3
View File
@@ -170,6 +170,7 @@ impl Store {
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"), ("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"), ("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings 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"), ("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] { ] {
let exists: bool = conn let exists: bool = conn
@@ -394,6 +395,10 @@ impl Store {
} }
drop(rows); drop(rows);
drop(stmt); 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<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?; let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
let mut removed = 0; let mut removed = 0;
for h in all.iter().filter(|h| !used.contains(*h)) { for h in all.iter().filter(|h| !used.contains(*h)) {
@@ -966,6 +971,10 @@ pub struct ImageRecord {
pub width: i64, pub width: i64,
pub height: i64, pub height: i64,
pub created_at: 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<String>,
} }
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> { fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
@@ -979,21 +988,62 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
width: r.get(6)?, width: r.get(6)?,
height: r.get(7)?, height: r.get(7)?,
created_at: r.get(8)?, 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 { impl Store {
pub fn add_image(&self, img: &ImageRecord) -> Result<()> { pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"), &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], 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(()) Ok(())
} }
/// Store bytes in the image store; returns their hash.
pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result<String> {
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<Vec<(String, String)>> {
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::<rusqlite::Result<Vec<_>>>()?;
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<usize> {
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). /// Latest images; `user = None` lists everybody's (admins).
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> { pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
+19 -5
View File
@@ -158,11 +158,25 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
)} )}
{Array.isArray(meta?.image_ids) ? ( {Array.isArray(meta?.image_ids) ? (
<div className="gallery small"> <div className="gallery small">
{meta.image_ids.map((id: string) => ( {meta.image_ids.map((id: string, i: number) => {
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item"> // The file may be gone (moved or deleted later): fall back to the preview the model saw.
<img src={`/api/images/${id}/file`} alt="" /> const preview = result?.images?.[i];
</FileFigure> return (
))} <FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img
src={`/api/images/${id}/file`}
alt=""
onError={(e) => {
const img = e.currentTarget;
if (preview && !img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = imageSrc(preview);
}
}}
/>
</FileFigure>
);
})}
</div> </div>
) : ( ) : (
result?.images?.map((img, i) => ( result?.images?.map((img, i) => (