Add GET /enhance/prompts (recent Enhance prompts) #199
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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<Vec<EnhanceJobRow>, 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`.
|
||||
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<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> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<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}")]
|
||||
pub async fn preview(
|
||||
_: Claims,
|
||||
|
||||
@@ -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::<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
@@ -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;
|
||||
+15
-2
@@ -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<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:
|
||||
/// staged results vanish on its restart anyway.
|
||||
pub fn discard(&self, id: &str) -> Result<EnhanceJobRow, EnhanceError> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user