feat: add consistent image job cancellation
This commit is contained in:
parent
2cda7df636
commit
4ffe6e9012
@ -55,6 +55,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
original temporary result; reusing a key for different request data is rejected.
|
original temporary result; reusing a key for different request data is rejected.
|
||||||
- **Casting Idempotency.** Character-to-voice matching now applies the same bounded, expiring idempotency contract,
|
- **Casting Idempotency.** Character-to-voice matching now applies the same bounded, expiring idempotency contract,
|
||||||
preventing repeated clicks from issuing duplicate LLM casting requests.
|
preventing repeated clicks from issuing duplicate LLM casting requests.
|
||||||
|
- **Image Job Cancellation.** Consistent profile/reference jobs now expose a cooperative cancellation endpoint and
|
||||||
|
progress-panel Cancel control; queued work can be cancelled without interrupting unrelated ComfyUI jobs.
|
||||||
- **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed
|
- **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed
|
||||||
object URLs, reducing retained browser memory during repeated auditioning.
|
object URLs, reducing retained browser memory during repeated auditioning.
|
||||||
- **Character Library Smoke Coverage.** Added deterministic browser coverage for opening Characters / Cast, profile
|
- **Character Library Smoke Coverage.** Added deterministic browser coverage for opening Characters / Cast, profile
|
||||||
|
|||||||
9
TODO.md
9
TODO.md
@ -44,8 +44,8 @@ the first block before investing in architecture or polish.
|
|||||||
2,227 segments, 1,043 dialogue segments and 585 newline-fused segments, but zero
|
2,227 segments, 1,043 dialogue segments and 585 newline-fused segments, but zero
|
||||||
adjacent same-speaker dialogue pairs for `_audiobookMergeAdjacentDialogue` to merge.
|
adjacent same-speaker dialogue pairs for `_audiobookMergeAdjacentDialogue` to merge.
|
||||||
The remaining segmentation work is upstream splitting/fusion, not another merge pass.
|
The remaining segmentation work is upstream splitting/fusion, not another merge pass.
|
||||||
- [ ] **S — Finish the remaining Playwright smoke checks.** Characters/Cast, profile
|
- [x] **S — Finish the remaining Playwright smoke checks.** Characters/Cast, profile
|
||||||
sorting, column visibility, voice ratings, and starting/cancelling a generation job.
|
sorting, column visibility, voice ratings, and starting/cancelling a generation job are covered.
|
||||||
- [x] **M — Fix the Schwester Larissa unclosed-guillemet split.** Attribution normalization now repairs dialogue
|
- [x] **M — Fix the Schwester Larissa unclosed-guillemet split.** Attribution normalization now repairs dialogue
|
||||||
split before mixed German guillemet closes (`»…‹` / `«…›`), with an epigraph regression test.
|
split before mixed German guillemet closes (`»…‹` / `«…›`), with an epigraph regression test.
|
||||||
- [ ] **M — Make profile portraits and character sheets use one canonical identity.**
|
- [ ] **M — Make profile portraits and character sheets use one canonical identity.**
|
||||||
@ -120,7 +120,8 @@ the first block before investing in architecture or polish.
|
|||||||
## P1 — Backend stability and bounded concurrency
|
## P1 — Backend stability and bounded concurrency
|
||||||
|
|
||||||
- [ ] Introduce background jobs for voice design, transcription, casting, and image generation.
|
- [ ] Introduce background jobs for voice design, transcription, casting, and image generation.
|
||||||
- [ ] Give every job an ID, progress, cancellation, timeout, retry, and idempotency key.
|
- [ ] Give every job an ID, progress, cancellation, timeout, retry, and idempotency key. Consistent image jobs now
|
||||||
|
provide all except retry; voice design and casting remain synchronous request paths.
|
||||||
- [x] Prevent duplicate generation when a user clicks twice for image, voice-design, and casting submissions.
|
- [x] Prevent duplicate generation when a user clicks twice for image, voice-design, and casting submissions.
|
||||||
- [ ] Centralize provider health checks and error handling.
|
- [ ] Centralize provider health checks and error handling.
|
||||||
- [x] Limit concurrency to 1–2 image generations, 1–2 voice generations, and 1 STT request per user. Consistent
|
- [x] Limit concurrency to 1–2 image generations, 1–2 voice generations, and 1 STT request per user. Consistent
|
||||||
@ -170,7 +171,7 @@ the first block before investing in architecture or polish.
|
|||||||
- [x] Sort the profile column.
|
- [x] Sort the profile column.
|
||||||
- [x] Hide and show columns.
|
- [x] Hide and show columns.
|
||||||
- [x] Rate a voice.
|
- [x] Rate a voice.
|
||||||
- [ ] Start and cancel a generation job.
|
- [x] Start and cancel a generation job.
|
||||||
|
|
||||||
## P2 — Observability
|
## P2 — Observability
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,7 @@ _attribution_llm_lock = threading.Lock()
|
|||||||
# entries are pruned an hour after completion so this can't grow unbounded.
|
# entries are pruned an hour after completion so this can't grow unbounded.
|
||||||
_consistent_image_jobs: dict[str, dict] = {}
|
_consistent_image_jobs: dict[str, dict] = {}
|
||||||
_consistent_image_jobs_lock = threading.Lock()
|
_consistent_image_jobs_lock = threading.Lock()
|
||||||
|
_consistent_image_cancel_events: dict[str, threading.Event] = {}
|
||||||
_consistent_image_idempotency: dict[str, str] = {}
|
_consistent_image_idempotency: dict[str, str] = {}
|
||||||
_consistent_image_slots = threading.BoundedSemaphore(2)
|
_consistent_image_slots = threading.BoundedSemaphore(2)
|
||||||
_CAST_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
|
_CAST_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
|
||||||
@ -89,9 +90,11 @@ def _cij_new(job_id: str, name: str) -> None:
|
|||||||
"name": name, "stage": "queued", "detail": "Queued…",
|
"name": name, "stage": "queued", "detail": "Queued…",
|
||||||
"started": now, "updated": now, "done": False, "error": None, "result": None,
|
"started": now, "updated": now, "done": False, "error": None, "result": None,
|
||||||
}
|
}
|
||||||
|
_consistent_image_cancel_events[job_id] = threading.Event()
|
||||||
cutoff = now - 3600
|
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]:
|
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)
|
_consistent_image_jobs.pop(stale_id, None)
|
||||||
|
_consistent_image_cancel_events.pop(stale_id, None)
|
||||||
for key, mapped_id in list(_consistent_image_idempotency.items()):
|
for key, mapped_id in list(_consistent_image_idempotency.items()):
|
||||||
if mapped_id == stale_id:
|
if mapped_id == stale_id:
|
||||||
_consistent_image_idempotency.pop(key, None)
|
_consistent_image_idempotency.pop(key, None)
|
||||||
@ -106,6 +109,17 @@ def _cij_set(job_id: str, **fields) -> None:
|
|||||||
job["updated"] = time.time()
|
job["updated"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class _ConsistentImageCancelled(Exception):
|
||||||
|
"""Internal cooperative cancellation signal for a consistent-image worker."""
|
||||||
|
|
||||||
|
|
||||||
|
def _cij_check_cancelled(job_id: str) -> None:
|
||||||
|
with _consistent_image_jobs_lock:
|
||||||
|
event = _consistent_image_cancel_events.get(job_id)
|
||||||
|
if event is not None and event.is_set():
|
||||||
|
raise _ConsistentImageCancelled()
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _watchdog_close(resp, timeout_seconds):
|
def _watchdog_close(resp, timeout_seconds):
|
||||||
"""Force-close `resp`'s connection if the wrapped block hasn't finished
|
"""Force-close `resp`'s connection if the wrapped block hasn't finished
|
||||||
@ -3191,12 +3205,27 @@ async def character_generate_consistent_images(request: Request):
|
|||||||
_consistent_image_idempotency[idempotency_key] = job_id
|
_consistent_image_idempotency[idempotency_key] = job_id
|
||||||
|
|
||||||
def _worker() -> None:
|
def _worker() -> None:
|
||||||
acquired = _consistent_image_slots.acquire(timeout=900)
|
acquired = False
|
||||||
|
try:
|
||||||
|
for _ in range(900):
|
||||||
|
_cij_check_cancelled(job_id)
|
||||||
|
if _consistent_image_slots.acquire(timeout=1):
|
||||||
|
acquired = True
|
||||||
|
break
|
||||||
|
except _ConsistentImageCancelled:
|
||||||
|
_cij_set(job_id, stage="cancelled", detail="Cancelled", done=True,
|
||||||
|
cancelled=True, error="Generation cancelled")
|
||||||
|
with _consistent_image_jobs_lock:
|
||||||
|
_consistent_image_cancel_events.pop(job_id, None)
|
||||||
|
return
|
||||||
if not acquired:
|
if not acquired:
|
||||||
_cij_set(job_id, stage="error", detail="Image queue timed out", done=True,
|
_cij_set(job_id, stage="error", detail="Image queue timed out", done=True,
|
||||||
error="No image-generation slot became available within 15 minutes")
|
error="No image-generation slot became available within 15 minutes")
|
||||||
|
with _consistent_image_jobs_lock:
|
||||||
|
_consistent_image_cancel_events.pop(job_id, None)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
_cij_check_cancelled(job_id)
|
||||||
identity_image = identity_image_input
|
identity_image = identity_image_input
|
||||||
if identity_image.startswith("/"):
|
if identity_image.startswith("/"):
|
||||||
# Library records store same-origin image URLs rather than
|
# Library records store same-origin image URLs rather than
|
||||||
@ -3210,6 +3239,7 @@ async def character_generate_consistent_images(request: Request):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f"Could not load the existing identity image: {exc}")
|
raise RuntimeError(f"Could not load the existing identity image: {exc}")
|
||||||
if not identity_image:
|
if not identity_image:
|
||||||
|
_cij_check_cancelled(job_id)
|
||||||
_cij_set(job_id, stage="identity", detail="Generating shared identity portrait…")
|
_cij_set(job_id, stage="identity", detail="Generating shared identity portrait…")
|
||||||
identity_image = _comfyui_run(
|
identity_image = _comfyui_run(
|
||||||
comfy_url, _bundled_character_identity_workflow(),
|
comfy_url, _bundled_character_identity_workflow(),
|
||||||
@ -3218,14 +3248,15 @@ async def character_generate_consistent_images(request: Request):
|
|||||||
# stale_seconds doesn't fire a false "may be stuck"
|
# stale_seconds doesn't fire a false "may be stuck"
|
||||||
# warning) — the elapsed counter itself is computed
|
# warning) — the elapsed counter itself is computed
|
||||||
# client-side from "started", not baked into this text.
|
# client-side from "started", not baked into this text.
|
||||||
progress=lambda elapsed: _cij_set(job_id),
|
progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)),
|
||||||
)
|
)
|
||||||
|
_cij_check_cancelled(job_id)
|
||||||
_cij_set(job_id, stage="render", detail="Rendering profile, turnaround, scene & props…")
|
_cij_set(job_id, stage="render", detail="Rendering profile, turnaround, scene & props…")
|
||||||
result = _comfyui_run_consistent_images(
|
result = _comfyui_run_consistent_images(
|
||||||
comfy_url, _bundled_consistent_comfyui_workflow(), identity_image,
|
comfy_url, _bundled_consistent_comfyui_workflow(), identity_image,
|
||||||
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts,
|
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts,
|
||||||
generation_key,
|
generation_key,
|
||||||
progress=lambda elapsed: _cij_set(job_id),
|
progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)),
|
||||||
)
|
)
|
||||||
result = dict(result or {})
|
result = dict(result or {})
|
||||||
result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION
|
result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION
|
||||||
@ -3234,10 +3265,15 @@ async def character_generate_consistent_images(request: Request):
|
|||||||
_cij_set(job_id, stage="done", detail="Done", done=True, result=result)
|
_cij_set(job_id, stage="done", detail="Done", done=True, result=result)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail))
|
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail))
|
||||||
|
except _ConsistentImageCancelled:
|
||||||
|
_cij_set(job_id, stage="cancelled", detail="Cancelled", done=True,
|
||||||
|
cancelled=True, error="Generation cancelled")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc))
|
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc))
|
||||||
finally:
|
finally:
|
||||||
_consistent_image_slots.release()
|
_consistent_image_slots.release()
|
||||||
|
with _consistent_image_jobs_lock:
|
||||||
|
_consistent_image_cancel_events.pop(job_id, None)
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True, name=f"consistent-images-{job_id}").start()
|
threading.Thread(target=_worker, daemon=True, name=f"consistent-images-{job_id}").start()
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
@ -3261,6 +3297,27 @@ async def character_generate_consistent_images_status(job_id: str):
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/character-generate-consistent-images/cancel/{job_id}")
|
||||||
|
async def character_generate_consistent_images_cancel(job_id: str):
|
||||||
|
"""Request cooperative cancellation of a queued or running image job.
|
||||||
|
|
||||||
|
ComfyUI does not provide a safe per-client interrupt in all supported
|
||||||
|
versions, so the worker stops at its next stage/poll checkpoint without
|
||||||
|
interrupting another user's GPU job.
|
||||||
|
"""
|
||||||
|
with _consistent_image_jobs_lock:
|
||||||
|
job = _consistent_image_jobs.get(job_id)
|
||||||
|
event = _consistent_image_cancel_events.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(404, "Unknown or expired job id")
|
||||||
|
if job.get("done"):
|
||||||
|
return {"ok": True, "already_done": True, "cancelled": bool(job.get("cancelled"))}
|
||||||
|
if event is not None:
|
||||||
|
event.set()
|
||||||
|
job.update({"stage": "cancelling", "detail": "Cancelling…", "updated": time.time()})
|
||||||
|
return {"ok": True, "cancelled": True}
|
||||||
|
|
||||||
|
|
||||||
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
|
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
|
||||||
if provider == "openai":
|
if provider == "openai":
|
||||||
api_key = (keys.get("openai_image") or "").strip()
|
api_key = (keys.get("openai_image") or "").strip()
|
||||||
|
|||||||
2
static/dist/main.min.js
vendored
2
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -3312,6 +3312,7 @@ function _cijShowPanel(name) {
|
|||||||
panel.innerHTML = '<div class="cij-progress-head">'
|
panel.innerHTML = '<div class="cij-progress-head">'
|
||||||
+ '<span class="mdi mdi-face-recognition"></span>'
|
+ '<span class="mdi mdi-face-recognition"></span>'
|
||||||
+ '<span class="cij-progress-title">Generating images — ' + escHtml(name || 'Character') + '</span>'
|
+ '<span class="cij-progress-title">Generating images — ' + escHtml(name || 'Character') + '</span>'
|
||||||
|
+ '<button type="button" class="btn-secondary btn-sm cij-progress-cancel">Cancel</button>'
|
||||||
+ '<button type="button" class="cij-progress-close" title="Hide (keeps running in the background)"><span class="mdi mdi-close"></span></button>'
|
+ '<button type="button" class="cij-progress-close" title="Hide (keeps running in the background)"><span class="mdi mdi-close"></span></button>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="cij-progress-steps">' + stepsHtml + '</div>'
|
+ '<div class="cij-progress-steps">' + stepsHtml + '</div>'
|
||||||
@ -3319,6 +3320,19 @@ function _cijShowPanel(name) {
|
|||||||
+ '<div class="cij-progress-elapsed">Elapsed 0:00</div>';
|
+ '<div class="cij-progress-elapsed">Elapsed 0:00</div>';
|
||||||
document.body.appendChild(panel);
|
document.body.appendChild(panel);
|
||||||
panel.querySelector('.cij-progress-close').addEventListener('click', function () { panel.remove(); });
|
panel.querySelector('.cij-progress-close').addEventListener('click', function () { panel.remove(); });
|
||||||
|
const cancelButton = panel.querySelector('.cij-progress-cancel');
|
||||||
|
cancelButton.addEventListener('click', async function () {
|
||||||
|
cancelButton.disabled = true;
|
||||||
|
cancelButton.textContent = 'Cancelling…';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/character-generate-consistent-images/cancel/' + encodeURIComponent(panel.dataset.jobId), { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Cancellation request failed');
|
||||||
|
} catch (error) {
|
||||||
|
cancelButton.disabled = false;
|
||||||
|
cancelButton.textContent = 'Cancel';
|
||||||
|
toast(error.message || 'Could not cancel generation', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
update: function (stage, detail, elapsed, warn) {
|
update: function (stage, detail, elapsed, warn) {
|
||||||
const p = document.getElementById('cij-progress-panel');
|
const p = document.getElementById('cij-progress-panel');
|
||||||
@ -3356,6 +3370,7 @@ function _cijShowPanel(name) {
|
|||||||
p.classList.add(ok ? 'cij-progress-ok' : 'cij-progress-err');
|
p.classList.add(ok ? 'cij-progress-ok' : 'cij-progress-err');
|
||||||
setTimeout(function () { p.remove(); }, ok ? 2500 : 7000);
|
setTimeout(function () { p.remove(); }, ok ? 2500 : 7000);
|
||||||
},
|
},
|
||||||
|
setJobId: function (jobId) { panel.dataset.jobId = jobId; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3366,7 +3381,8 @@ function _cijFmtElapsed(seconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Polls the background job until it finishes, updating the panel each tick.
|
// Polls the background job until it finishes, updating the panel each tick.
|
||||||
// Flagged "stuck" (not aborted — there's no server-side cancel wired up yet)
|
// Flagged "stuck" while the backend reports no progress; cancellation is
|
||||||
|
// cooperative and completes at the next provider polling checkpoint.
|
||||||
// once 60s pass with no backend-side progress update, since the poll interval
|
// once 60s pass with no backend-side progress update, since the poll interval
|
||||||
// itself is only ~1.5s so any longer gap means the worker thread stopped
|
// itself is only ~1.5s so any longer gap means the worker thread stopped
|
||||||
// reporting, not that ComfyUI is merely slow.
|
// reporting, not that ComfyUI is merely slow.
|
||||||
@ -3420,6 +3436,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
|
|||||||
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
|
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
|
||||||
const { job_id } = await r.json();
|
const { job_id } = await r.json();
|
||||||
const panel = _cijShowPanel(rec.name);
|
const panel = _cijShowPanel(rec.name);
|
||||||
|
panel.setJobId(job_id);
|
||||||
const d = await _cijPollJob(job_id, panel);
|
const d = await _cijPollJob(job_id, panel);
|
||||||
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
|
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
|
||||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);
|
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);
|
||||||
|
|||||||
@ -57,3 +57,35 @@ test('Characters / Cast supports profile sorting, column visibility, and voice r
|
|||||||
await aliceRating.click();
|
await aliceRating.click();
|
||||||
await expect(aliceRating).toHaveClass(/is-on/);
|
await expect(aliceRating).toHaveClass(/is-on/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('consistent image generation can be cancelled from its progress panel', async ({ page }) => {
|
||||||
|
let cancelled = false;
|
||||||
|
let cancelRequests = 0;
|
||||||
|
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: [] } }));
|
||||||
|
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
|
||||||
|
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||||
|
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-cancel' } }));
|
||||||
|
await page.route('**/api/character-generate-consistent-images/cancel/job-cancel', async route => {
|
||||||
|
cancelRequests += 1;
|
||||||
|
cancelled = true;
|
||||||
|
await route.fulfill({ json: { ok: true, cancelled: true } });
|
||||||
|
});
|
||||||
|
await page.route('**/api/character-generate-consistent-images/status/job-cancel', route => route.fulfill({
|
||||||
|
json: cancelled
|
||||||
|
? { stage: 'cancelled', detail: 'Cancelled', elapsed: 1, stale_seconds: 0, done: true, cancelled: true, error: 'Generation cancelled', result: null }
|
||||||
|
: { stage: 'identity', detail: 'Generating shared identity portrait…', elapsed: 0, stale_seconds: 0, done: false, error: null, result: null },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__cancelRun = window._charAutoGenerateConsistentImages({
|
||||||
|
id: 'cancel-character', name: 'Cancel Character', book: 'Smoke Book',
|
||||||
|
sheet: { image_prompt: 'portrait', concept_art_prompt: 'reference' },
|
||||||
|
}, 'cinematic').catch(() => null);
|
||||||
|
});
|
||||||
|
await expect(page.locator('#cij-progress-panel')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.locator('#cij-progress-panel .cij-progress-cancel').click();
|
||||||
|
await expect.poll(() => cancelRequests).toBe(1);
|
||||||
|
await expect(page.locator('#cij-progress-panel')).toHaveClass(/cij-progress-err/, { timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|||||||
@ -7,7 +7,14 @@ import unittest
|
|||||||
|
|
||||||
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-conversation-test-config-"))
|
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-conversation-test-config-"))
|
||||||
|
|
||||||
from routes.conversation import _attribution_parse, _fallback_attribute_response
|
from routes.conversation import (
|
||||||
|
_attribution_parse,
|
||||||
|
_cij_check_cancelled,
|
||||||
|
_cij_new,
|
||||||
|
_consistent_image_cancel_events,
|
||||||
|
_consistent_image_jobs,
|
||||||
|
_fallback_attribute_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AttributionParserTests(unittest.TestCase):
|
class AttributionParserTests(unittest.TestCase):
|
||||||
@ -29,6 +36,26 @@ class AttributionParserTests(unittest.TestCase):
|
|||||||
self.assertTrue(dialogue["text"].endswith("Land. Lieblich bisweilen, mild und voll der guten Menschen. ‹"))
|
self.assertTrue(dialogue["text"].endswith("Land. Lieblich bisweilen, mild und voll der guten Menschen. ‹"))
|
||||||
self.assertEqual(narration["text"], "Zitiert aus dem Buch.")
|
self.assertEqual(narration["text"], "Zitiert aus dem Buch.")
|
||||||
|
|
||||||
|
def test_consistent_image_job_can_be_cancelled(self):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from server import app
|
||||||
|
|
||||||
|
job_id = "cancel-test"
|
||||||
|
_cij_new(job_id, "Test character")
|
||||||
|
try:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
f"/api/character-generate-consistent-images/cancel/{job_id}",
|
||||||
|
headers={"origin": "http://testserver"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json()["cancelled"])
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
_cij_check_cancelled(job_id)
|
||||||
|
finally:
|
||||||
|
_consistent_image_jobs.pop(job_id, None)
|
||||||
|
_consistent_image_cancel_events.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user