Compare commits
2
Commits
90d65ec654
...
9fd92d5b39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd92d5b39 | ||
|
|
b99f71526a |
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.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
|
||||||
|
const preview = result?.images?.[i];
|
||||||
|
return (
|
||||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||||
<img src={`/api/images/${id}/file`} alt="" />
|
<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>
|
</FileFigure>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
result?.images?.map((img, i) => (
|
result?.images?.map((img, i) => (
|
||||||
|
|||||||
Reference in new issue
Block a user