Add GET /enhance/prompts: recent distinct Enhance prompts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-31 12:43:27 -04:00
1 parent c6d04589d1
commit cdc339021b
7 files changed
+243 -3

No files matched your search

+4
View File
@@ -507,6 +507,10 @@ GET /enhance/templates
POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job
GET /enhance/jobs/{id} -> job GET /enhance/jobs/{id} -> job
GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden 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) 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}/approve {"n"} -> a /image/versions `versions[]` entry
POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched) POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched)
+108
View File
@@ -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, \ reference_path, comfy_prompt_id, status, error, queue_position, pending, saved, \
discarded, created_at, updated_at, run_ms"; 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. /// What the poller / approve changes on an existing job.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobUpdate { pub struct JobUpdate {
@@ -107,6 +122,14 @@ pub trait EnhanceJobDao: Sync + Send {
/// Jobs the poller still has to advance (`queued` / `running`). /// Jobs the poller still has to advance (`queued` / `running`).
fn list_active(&mut self) -> Result<Vec<EnhanceJobRow>, DbError>; fn list_active(&mut self) -> Result<Vec<EnhanceJobRow>, DbError>;
fn update(&mut self, id: &str, update: &JobUpdate) -> 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<Vec<RecentPrompt>, DbError>;
/// Hide a job from `list_for_hash`. /// Hide a job from `list_for_hash`.
fn discard(&mut self, id: &str) -> Result<(), DbError>; fn discard(&mut self, id: &str) -> Result<(), DbError>;
/// Delete jobs last touched before `cutoff` (unix seconds) that are not /// 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)) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
} }
fn recent_prompts(
&mut self,
templates: Option<&[String]>,
limit: i64,
) -> Result<Vec<RecentPrompt>, 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::<Integer, _>(i32::from(templates.is_some()))
.bind::<Text, _>(names)
.bind::<BigInt, _>(limit)
.load::<RecentPrompt>(&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> { fn discard(&mut self, id: &str) -> Result<(), DbError> {
trace_db_call("update", "enhance_job_discard", |_span| { trace_db_call("update", "enhance_job_discard", |_span| {
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao"); 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("c").unwrap().is_some());
assert!(d.get("b").unwrap().is_none()); 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);
}
} }
+52
View File
@@ -3,6 +3,7 @@
//! //!
//! - `GET /enhance/templates` //! - `GET /enhance/templates`
//! - `POST /enhance` //! - `POST /enhance`
//! - `GET /enhance/prompts?templates=&limit=`
//! - `GET /enhance/jobs?path=` //! - `GET /enhance/jobs?path=`
//! - `GET /enhance/jobs/{id}` //! - `GET /enhance/jobs/{id}`
//! - `GET /enhance/jobs/{id}/preview/{n}` //! - `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<String>,
pub limit: Option<i64>,
}
#[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<PromptsQuery>,
svc: web::Data<EnhanceService>,
) -> impl Responder {
let names: Option<Vec<String>> = 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<PromptView> = 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}")] #[get("/enhance/jobs/{id}/preview/{n}")]
pub async fn preview( pub async fn preview(
_: Claims, _: Claims,
+60
View File
@@ -263,6 +263,7 @@ impl Fx {
.service(super::get_templates) .service(super::get_templates)
.service(super::submit) .service(super::submit)
.service(super::list_jobs) .service(super::list_jobs)
.service(super::recent_prompts)
.service(super::get_job) .service(super::get_job)
.service(super::preview) .service(super::preview)
.service(super::approve) .service(super::approve)
@@ -991,3 +992,62 @@ async fn busy_queue_blocks_the_free() {
fx.svc.idle_check().await; fx.svc.idle_check().await;
assert_eq!(fx.mock.free_calls.load(Ordering::SeqCst), 0); 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::<i64>(), 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": []})));
}
+3 -1
View File
@@ -21,5 +21,7 @@ pub mod template;
#[cfg(test)] #[cfg(test)]
mod integration_tests; 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; pub use service::EnhanceService;
+15 -2
View File
@@ -18,8 +18,8 @@ use super::config::{EnhanceConfig, Loaded, PromptMode};
use super::path_map::PathMap; use super::path_map::PathMap;
use super::template::Template; use super::template::Template;
use crate::database::enhance_dao::{ use crate::database::enhance_dao::{
EnhanceJobDao, EnhanceJobRow, JobUpdate, STATUS_APPROVED, STATUS_EXPIRED, STATUS_FAILED, EnhanceJobDao, EnhanceJobRow, JobUpdate, RecentPrompt, STATUS_APPROVED, STATUS_EXPIRED,
STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE, STATUS_FAILED, STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE,
}; };
/// Short timeout for the `available` probe, so the templates call stays snappy /// 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())) .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<Vec<RecentPrompt>, 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: /// Hide a job from the per-photo list. Nothing is deleted on ComfyUI:
/// staged results vanish on its restart anyway. /// staged results vanish on its restart anyway.
pub fn discard(&self, id: &str) -> Result<EnhanceJobRow, EnhanceError> { pub fn discard(&self, id: &str) -> Result<EnhanceJobRow, EnhanceError> {
+1
View File
@@ -344,6 +344,7 @@ fn main() -> std::io::Result<()> {
.service(enhance::get_templates) .service(enhance::get_templates)
.service(enhance::submit) .service(enhance::submit)
.service(enhance::list_jobs) .service(enhance::list_jobs)
.service(enhance::recent_prompts)
.service(enhance::get_job) .service(enhance::get_job)
.service(enhance::preview) .service(enhance::preview)
.service(enhance::approve) .service(enhance::approve)