From cdc339021b6e8128df8ecfd069a92be88d9f9575 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 31 Oct 2026 12:43:27 -0400 Subject: [PATCH] Add GET /enhance/prompts: recent distinct Enhance prompts Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++ src/database/enhance_dao.rs | 108 +++++++++++++++++++++++++++++++ src/enhance/handlers.rs | 52 +++++++++++++++ src/enhance/integration_tests.rs | 60 +++++++++++++++++ src/enhance/mod.rs | 4 +- src/enhance/service.rs | 17 ++++- src/main.rs | 1 + 7 files changed, 243 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26931c0..9020328 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -507,6 +507,10 @@ GET /enhance/templates POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job GET /enhance/jobs/{id} -> job GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden +GET /enhance/prompts?templates=a,b&limit=10 (templates optional = all; limit 1..50, default 10) + -> {"prompts": [{"prompt","template","last_used","uses"}]} distinct trimmed prompts, newest + use first; template/last_used from the newest job; discarded + failed jobs count; + DB only (no library scope, answers even when ComfyUI is down or Enhance is off) GET /enhance/jobs/{id}/preview/{n} -> the staged PNG (relayed from ComfyUI /view) POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` entry POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched) diff --git a/src/database/enhance_dao.rs b/src/database/enhance_dao.rs index 1ddbf57..aeb5e30 100644 --- a/src/database/enhance_dao.rs +++ b/src/database/enhance_dao.rs @@ -68,6 +68,21 @@ const COLUMNS: &str = "id, original_hash, library_id, rel_path, template, prompt reference_path, comfy_prompt_id, status, error, queue_position, pending, saved, \ discarded, created_at, updated_at, run_ms"; +/// One distinct prompt used across jobs, newest use first. +#[derive(Debug, Clone, PartialEq, Eq, QueryableByName)] +pub struct RecentPrompt { + #[diesel(sql_type = Text)] + pub prompt: String, + /// Template of the newest job that used this prompt. + #[diesel(sql_type = Text)] + pub template: String, + /// `created_at` of the newest job that used it. + #[diesel(sql_type = BigInt)] + pub last_used: i64, + #[diesel(sql_type = BigInt)] + pub uses: i64, +} + /// What the poller / approve changes on an existing job. #[derive(Debug, Clone, PartialEq, Eq)] pub struct JobUpdate { @@ -107,6 +122,14 @@ pub trait EnhanceJobDao: Sync + Send { /// Jobs the poller still has to advance (`queued` / `running`). fn list_active(&mut self) -> Result, DbError>; fn update(&mut self, id: &str, update: &JobUpdate) -> Result<(), DbError>; + /// Distinct non-empty prompts (compared trimmed), newest use first. + /// Counts every job, discarded and failed included. `templates` limits + /// to those template names; `None` means all. + fn recent_prompts( + &mut self, + templates: Option<&[String]>, + limit: i64, + ) -> Result, DbError>; /// Hide a job from `list_for_hash`. fn discard(&mut self, id: &str) -> Result<(), DbError>; /// Delete jobs last touched before `cutoff` (unix seconds) that are not @@ -238,6 +261,36 @@ impl EnhanceJobDao for SqliteEnhanceJobDao { .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) } + fn recent_prompts( + &mut self, + templates: Option<&[String]>, + limit: i64, + ) -> Result, DbError> { + trace_db_call("query", "enhance_job_recent_prompts", |_span| { + let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao"); + let names = serde_json::to_string(templates.unwrap_or(&[])) + .map_err(|e| anyhow::anyhow!("Encode error: {}", e))?; + // `template` and `MAX(created_at)` are bare/aggregate in the same + // group, so SQLite takes `template` from the newest row. + sql_query( + "SELECT TRIM(prompt, ' ' || char(9) || char(10) || char(13)) AS prompt, \ + template, MAX(created_at) AS last_used, COUNT(*) AS uses \ + FROM enhance_jobs \ + WHERE prompt IS NOT NULL \ + AND TRIM(prompt, ' ' || char(9) || char(10) || char(13)) <> '' \ + AND (? = 0 OR template IN (SELECT value FROM json_each(?))) \ + GROUP BY TRIM(prompt, ' ' || char(9) || char(10) || char(13)) \ + ORDER BY last_used DESC, MAX(rowid) DESC LIMIT ?", + ) + .bind::(i32::from(templates.is_some())) + .bind::(names) + .bind::(limit) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + fn discard(&mut self, id: &str) -> Result<(), DbError> { trace_db_call("update", "enhance_job_discard", |_span| { let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao"); @@ -358,4 +411,59 @@ mod tests { assert!(d.get("c").unwrap().is_some()); assert!(d.get("b").unwrap().is_none()); } + + fn prompt_job(id: &str, template: &str, prompt: Option<&str>, created: i64) -> EnhanceJobRow { + EnhanceJobRow { + template: template.into(), + prompt: prompt.map(str::to_string), + ..job(id, "h", "ready", created) + } + } + + #[test] + fn recent_prompts_groups_trimmed_newest_first() { + let mut d = dao(); + d.insert(&prompt_job("a", "edit", Some("warmer"), 100)) + .unwrap(); + d.insert(&prompt_job("b", "edit-fast", Some(" warmer\n"), 300)) + .unwrap(); + d.insert(&prompt_job("c", "edit", Some("remove the sign"), 200)) + .unwrap(); + d.insert(&prompt_job("d", "edit", Some(" "), 400)) + .unwrap(); + d.insert(&prompt_job("e", "edit", None, 500)).unwrap(); + d.insert(&prompt_job("f", "restore", Some("older"), 50)) + .unwrap(); + // Failed and discarded jobs still count. + d.update("b", &JobUpdate::status("failed")).unwrap(); + d.discard("a").unwrap(); + + let all = d.recent_prompts(None, 10).unwrap(); + let view: Vec<_> = all + .iter() + .map(|p| (p.prompt.as_str(), p.template.as_str(), p.last_used, p.uses)) + .collect(); + assert_eq!( + view, + [ + ("warmer", "edit-fast", 300, 2), + ("remove the sign", "edit", 200, 1), + ("older", "restore", 50, 1), + ] + ); + + let edit = d + .recent_prompts(Some(&["edit".into(), "nope".into()]), 10) + .unwrap(); + let view: Vec<_> = edit.iter().map(|p| (p.prompt.as_str(), p.uses)).collect(); + assert_eq!(view, [("remove the sign", 1), ("warmer", 1)]); + + assert!( + d.recent_prompts(Some(&["nope".into()]), 10) + .unwrap() + .is_empty() + ); + assert!(d.recent_prompts(Some(&[]), 10).unwrap().is_empty()); + assert_eq!(d.recent_prompts(None, 1).unwrap().len(), 1); + } } diff --git a/src/enhance/handlers.rs b/src/enhance/handlers.rs index 4714a36..44ae37e 100644 --- a/src/enhance/handlers.rs +++ b/src/enhance/handlers.rs @@ -3,6 +3,7 @@ //! //! - `GET /enhance/templates` //! - `POST /enhance` +//! - `GET /enhance/prompts?templates=&limit=` //! - `GET /enhance/jobs?path=` //! - `GET /enhance/jobs/{id}` //! - `GET /enhance/jobs/{id}/preview/{n}` @@ -363,6 +364,57 @@ pub async fn list_jobs( } } +#[derive(Debug, Deserialize)] +pub struct PromptsQuery { + /// Comma-separated template names; omitted = all templates. + pub templates: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +struct PromptView { + prompt: String, + template: String, + last_used: i64, + uses: i64, +} + +/// Prompts used before, newest first, so the app can offer them again for a +/// series of similar photos. DB only: not library-scoped, and answered even +/// when ComfyUI is down or Enhance is off (then simply from old rows). +#[get("/enhance/prompts")] +pub async fn recent_prompts( + _: Claims, + q: web::Query, + svc: web::Data, +) -> impl Responder { + let names: Option> = q.templates.as_deref().map(|t| { + t.split(',') + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(str::to_string) + .collect() + }); + // `templates=` with nothing in it means "no filter", like omitting it. + let names = names.filter(|n| !n.is_empty()); + let limit = q.limit.unwrap_or(10).clamp(1, 50); + match svc.recent_prompts(names.as_deref(), limit) { + Ok(rows) => { + let prompts: Vec = rows + .into_iter() + .map(|r| PromptView { + prompt: r.prompt, + template: r.template, + last_used: r.last_used, + uses: r.uses, + }) + .collect(); + HttpResponse::Ok().json(json!({ "prompts": prompts })) + } + Err(e) => error_response(&e), + } +} + #[get("/enhance/jobs/{id}/preview/{n}")] pub async fn preview( _: Claims, diff --git a/src/enhance/integration_tests.rs b/src/enhance/integration_tests.rs index 66008b9..7afb8ea 100644 --- a/src/enhance/integration_tests.rs +++ b/src/enhance/integration_tests.rs @@ -263,6 +263,7 @@ impl Fx { .service(super::get_templates) .service(super::submit) .service(super::list_jobs) + .service(super::recent_prompts) .service(super::get_job) .service(super::preview) .service(super::approve) @@ -991,3 +992,62 @@ async fn busy_queue_blocks_the_free() { fx.svc.idle_check().await; assert_eq!(fx.mock.free_calls.load(Ordering::SeqCst), 0); } + +#[actix_rt::test] +async fn recent_prompts_lists_distinct_trimmed_prompts_even_when_discarded() { + let fx = fx().await; + fx.put(PHOTO, b"photo"); + let mut ids = Vec::new(); + for prompt in ["warmer", " warmer ", "less grain"] { + let (st, job, _) = call( + &fx, + "POST", + "/enhance", + Some(json!({"path": PHOTO, "template": "restore", "prompt": prompt})), + ) + .await; + assert_eq!(st, 200); + ids.push(job["id"].as_str().unwrap().to_string()); + } + call( + &fx, + "POST", + &format!("/enhance/jobs/{}/discard", ids[0]), + None, + ) + .await; + + let (st, body, _) = call(&fx, "GET", "/enhance/prompts", None).await; + assert_eq!(st, 200); + let rows = body["prompts"].as_array().unwrap(); + let texts: Vec<(&str, i64)> = rows + .iter() + .map(|r| (r["prompt"].as_str().unwrap(), r["uses"].as_i64().unwrap())) + .collect(); + assert_eq!(texts.iter().map(|t| t.1).sum::(), 3); + assert!(texts.contains(&("warmer", 2)) && texts.contains(&("less grain", 1))); + assert_eq!(rows[0]["template"], "restore"); + assert!(rows[0]["last_used"].as_i64().unwrap() > 0); + + let (_, body, _) = call(&fx, "GET", "/enhance/prompts?templates=nope", None).await; + assert_eq!(body, json!({"prompts": []})); + let (_, body, _) = call( + &fx, + "GET", + "/enhance/prompts?templates=nope,restore&limit=0", + None, + ) + .await; + assert_eq!( + body["prompts"].as_array().unwrap().len(), + 1, + "limit clamps to 1" + ); +} + +#[actix_rt::test] +async fn recent_prompts_answers_when_enhance_is_off() { + let fx = fx_with(None, |c| c.url = None).await; + let (st, body, _) = call(&fx, "GET", "/enhance/prompts", None).await; + assert_eq!((st, body), (200, json!({"prompts": []}))); +} diff --git a/src/enhance/mod.rs b/src/enhance/mod.rs index 2e31846..1f8c885 100644 --- a/src/enhance/mod.rs +++ b/src/enhance/mod.rs @@ -21,5 +21,7 @@ pub mod template; #[cfg(test)] mod integration_tests; -pub use handlers::{approve, discard, get_job, get_templates, list_jobs, preview, submit}; +pub use handlers::{ + approve, discard, get_job, get_templates, list_jobs, preview, recent_prompts, submit, +}; pub use service::EnhanceService; diff --git a/src/enhance/service.rs b/src/enhance/service.rs index 253836a..cda4202 100644 --- a/src/enhance/service.rs +++ b/src/enhance/service.rs @@ -18,8 +18,8 @@ use super::config::{EnhanceConfig, Loaded, PromptMode}; use super::path_map::PathMap; use super::template::Template; use crate::database::enhance_dao::{ - EnhanceJobDao, EnhanceJobRow, JobUpdate, STATUS_APPROVED, STATUS_EXPIRED, STATUS_FAILED, - STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE, + EnhanceJobDao, EnhanceJobRow, JobUpdate, RecentPrompt, STATUS_APPROVED, STATUS_EXPIRED, + STATUS_FAILED, STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE, }; /// Short timeout for the `available` probe, so the templates call stays snappy @@ -392,6 +392,19 @@ impl EnhanceService { .map_err(|e| EnhanceError::Internal(e.to_string())) } + /// Distinct recent prompts; DB only, so it works with ComfyUI down. + pub fn recent_prompts( + &self, + templates: Option<&[String]>, + limit: i64, + ) -> Result, EnhanceError> { + self.dao + .lock() + .unwrap() + .recent_prompts(templates, limit) + .map_err(|e| EnhanceError::Internal(e.to_string())) + } + /// Hide a job from the per-photo list. Nothing is deleted on ComfyUI: /// staged results vanish on its restart anyway. pub fn discard(&self, id: &str) -> Result { diff --git a/src/main.rs b/src/main.rs index 25fcc4c..411a096 100644 --- a/src/main.rs +++ b/src/main.rs @@ -344,6 +344,7 @@ fn main() -> std::io::Result<()> { .service(enhance::get_templates) .service(enhance::submit) .service(enhance::list_jobs) + .service(enhance::recent_prompts) .service(enhance::get_job) .service(enhance::preview) .service(enhance::approve)