diff --git a/CHANGELOG.md b/CHANGELOG.md index 9571c9b..a90892a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **Server-side Provider Credentials.** Provider keys are now masked even for same-origin Settings responses, are preserved when masked/empty fields are saved, and model/health probes resolve configured keys inside the server. Browser localStorage is no longer used as a credential store. +- **Image Job Deduplication.** Consistent portrait/reference generation accepts an `Idempotency-Key` and returns the + existing job for repeated submissions; completed job mappings are evicted with the job cache. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index 824e3e8..bb36175 100644 --- a/TODO.md +++ b/TODO.md @@ -51,8 +51,8 @@ the first block before investing in architecture or polish. - [ ] **M — Make profile portraits and character sheets use one canonical identity.** Share age, hair, eyes, face, build, skin tone, marks and era; version the prompt and detect stale or inconsistent images. -- [ ] **M — Add bounded duplicate-generation protection.** Ensure double-clicks and - retried requests cannot create duplicate voice, image or casting jobs. +- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate + repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs. - [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice tables, update only affected rows, and disable preloading on low-memory devices. - [ ] **M — Add explicit cache invalidation and eviction.** Cache `/api/voices` safely diff --git a/routes/conversation.py b/routes/conversation.py index d3b254c..31ab866 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -60,6 +60,7 @@ _attribution_llm_lock = threading.Lock() # entries are pruned an hour after completion so this can't grow unbounded. _consistent_image_jobs: dict[str, dict] = {} _consistent_image_jobs_lock = threading.Lock() +_consistent_image_idempotency: dict[str, str] = {} def _cij_new(job_id: str, name: str) -> None: @@ -72,6 +73,9 @@ def _cij_new(job_id: str, name: str) -> None: cutoff = now - 3600 for stale_id in [j for j, v in _consistent_image_jobs.items() if v.get("done") and v.get("updated", 0) < cutoff]: _consistent_image_jobs.pop(stale_id, None) + for key, mapped_id in list(_consistent_image_idempotency.items()): + if mapped_id == stale_id: + _consistent_image_idempotency.pop(key, None) def _cij_set(job_id: str, **fields) -> None: @@ -3014,6 +3018,14 @@ async def character_generate_consistent_images(request: Request): the face consistent across both deliverables. """ data = await request.json() + idempotency_key = str(request.headers.get("idempotency-key") or "").strip() + if len(idempotency_key) > 200: + raise HTTPException(400, "Idempotency-Key is too long") + if idempotency_key: + with _consistent_image_jobs_lock: + previous_job_id = _consistent_image_idempotency.get(idempotency_key) + if previous_job_id and previous_job_id in _consistent_image_jobs: + return {"job_id": previous_job_id, "deduplicated": True} name = str(data.get("name") or "").strip() book = str(data.get("book") or "").strip() sheet: dict = data.get("sheet") or {} @@ -3068,6 +3080,9 @@ async def character_generate_consistent_images(request: Request): # fetch that a page reload would then throw away mid-render. job_id = uuid.uuid4().hex _cij_new(job_id, name or "Character") + if idempotency_key: + with _consistent_image_jobs_lock: + _consistent_image_idempotency[idempotency_key] = job_id def _worker() -> None: try: