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>
This commit is contained in:
1 parent
90d65ec654
commit
b99f71526a
9 files changed
+260
-13
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
## [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
|
## [v202611-00014] – 2026-11-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+16
-3
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
Reference in new issue
Block a user