diff --git a/calliope-backend/pyproject.toml b/calliope-backend/pyproject.toml index 3ef6f0e5..2b07ad1f 100644 --- a/calliope-backend/pyproject.toml +++ b/calliope-backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "calliope" -version = "1.3.1" +version = "1.3.2" description = "Local-first AI story-to-video studio" requires-python = ">=3.11" dependencies = [ diff --git a/calliope-backend/src/calliope/agent/llm.py b/calliope-backend/src/calliope/agent/llm.py index 46444b1b..7d06d15e 100644 --- a/calliope-backend/src/calliope/agent/llm.py +++ b/calliope-backend/src/calliope/agent/llm.py @@ -22,25 +22,28 @@ class LLMClient: base_url: str | None = None, model: str | None = None, api_key: str | None = None, + timeout: float = 120.0, ) -> None: self.base_url = (base_url or settings.llm_base_url).rstrip("/") self.model = model or settings.llm_model self.api_key = api_key if api_key is not None else settings.llm_api_key # With every completion streamed (chat/chat_with_tools consume - # chat_stream), 120 s bounds the gap BETWEEN chunks, not total + # chat_stream), the timeout bounds the gap BETWEEN chunks, not total # generation time: a thinking model streaming reasoning_content keeps # the connection fed for as long as it genuinely works, while a dead - # server still fails fast. - self.client = httpx.AsyncClient(timeout=120.0) + # server still fails fast. Shorter timeouts (e.g. the 30 s preview + # path) trade headroom for a snappier deterministic fallback. + self.client = httpx.AsyncClient(timeout=timeout) @classmethod - def for_role(cls, role: str) -> LLMClient: + def for_role(cls, role: str, *, timeout: float = 120.0) -> LLMClient: """Client for an agent role's assigned profile (active fallback).""" profile = settings.resolve_llm_for_role(role) return cls( base_url=profile.get("base_url"), model=profile.get("model"), api_key=profile.get("api_key") if isinstance(profile.get("api_key"), str) else None, + timeout=timeout, ) def _headers(self) -> dict[str, str]: diff --git a/calliope-backend/src/calliope/agent/video_agent.py b/calliope-backend/src/calliope/agent/video_agent.py index d380974c..0b1e200c 100644 --- a/calliope-backend/src/calliope/agent/video_agent.py +++ b/calliope-backend/src/calliope/agent/video_agent.py @@ -67,9 +67,18 @@ def _h3_subjects( return subjects[:cap], paths[:cap] -async def _h3_rewrite(scene: dict[str, Any], subjects: list[dict[str, Any]]) -> str: - """LLM rewrite into H3's six-section format, deterministic template on failure.""" - client = LLMClient.for_role("video") +async def _h3_rewrite( + scene: dict[str, Any], + subjects: list[dict[str, Any]], + *, + timeout: float = 120.0, +) -> str: + """LLM rewrite into H3's six-section format, deterministic template on failure. + + The timeout bounds the whole wait for a dead endpoint before the template + kicks in — the preview path passes a short value so the UI fails fast. + """ + client = LLMClient.for_role("video", timeout=timeout) try: return await client.chat( build_minimax_h3_ref_messages(scene, subjects), temperature=0.4 @@ -249,7 +258,9 @@ async def preview_scene_prompt( "agent.thinking", {"message": f"H3 prompt rewrite · scene {scene.get('order_index')}", "project_id": project_id}, ) - prompt = await _h3_rewrite(scene, subjects) + # Preview is interactive — fail fast to the deterministic template + # instead of making the user wait out a dead endpoint. + prompt = await _h3_rewrite(scene, subjects, timeout=30.0) else: prompt = scene_video_prompt(scene, characters) return {"prompt": prompt, "profile": profile, "from_draft": False, "based_on": based_on} diff --git a/calliope-backend/tests/test_scene_settings.py b/calliope-backend/tests/test_scene_settings.py index 5149e21d..7df47947 100644 --- a/calliope-backend/tests/test_scene_settings.py +++ b/calliope-backend/tests/test_scene_settings.py @@ -145,7 +145,7 @@ def test_preview_prompt_endpoint_h3_profile(client, monkeypatch): finally: conn.close() - async def fake_rewrite(scene_, subjects): + async def fake_rewrite(scene_, subjects, **kwargs): return "H3 REWRITE" monkeypatch.setattr("calliope.agent.video_agent._h3_rewrite", fake_rewrite) @@ -214,7 +214,7 @@ def test_preview_prompt_fresh_draft_shortcircuits_llm(client, monkeypatch): called = [] - async def fake_rewrite(scene_, subjects): + async def fake_rewrite(scene_, subjects, **kwargs): called.append(1) return "SHOULD NOT BE USED" @@ -226,6 +226,54 @@ def test_preview_prompt_fresh_draft_shortcircuits_llm(client, monkeypatch): assert called == [] # LLM never invoked +def test_preview_prompt_dead_llm_returns_deterministic_fallback(client, monkeypatch): + """A failing LLM rewrite falls back to minimax_h3_ref_fallback — never 500s. + + This is the failure mode behind the Discord report: a dead LLM endpoint + must not leave the preview modal hanging or erroring out. + """ + from calliope.agent import video_agent + + pid = _mk_project(client, "Preview dead LLM") + scene = _add_scene(client, pid, 1, action="A lone rider crosses the salt flats.") + + conn = get_db(settings.db_path) + try: + wf_id = _insert_h3_workflow(conn, "H3 dead") + conn.execute( + "UPDATE scenes SET workflow_id = ? WHERE id = ?", (wf_id, scene["id"]) + ) + conn.commit() + finally: + conn.close() + + class DeadClient: + def __init__(self, *args, **kwargs): + pass + + async def chat(self, messages, temperature=0.7, response_format=None): + raise RuntimeError("connection refused") + + async def close(self): + return None + + monkeypatch.setattr( + video_agent, + "LLMClient", + type("LLMClientStub", (), {"for_role": staticmethod(lambda role, **kw: DeadClient())}), + ) + + result = asyncio_run(video_agent.preview_scene_prompt(pid, scene["id"])) + assert result["profile"] == "minimax_h3_ref" + assert result["from_draft"] is False + # Deterministic template content, not an exception and not empty + assert "salt flats" in result["prompt"] + assert result["prompt"].startswith("subject_definitions:") + # No jobs were enqueued by the preview + jobs = client.get(f"/api/jobs?project_id={pid}").json() + assert [j for j in jobs if j["kind"] == "video"] == [] + + def test_enqueue_prompts_override(client, monkeypatch): """Confirmed prompt from the review modal wins over the rewrite.""" pid = _mk_project(client, "Prompt override") diff --git a/calliope-web/package-lock.json b/calliope-web/package-lock.json index 20c9d3ac..0aede72e 100644 --- a/calliope-web/package-lock.json +++ b/calliope-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "calliope-web", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calliope-web", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@tanstack/svelte-query": "^5.66.0" }, diff --git a/calliope-web/package.json b/calliope-web/package.json index 80235f1d..c2655635 100644 --- a/calliope-web/package.json +++ b/calliope-web/package.json @@ -1,6 +1,6 @@ { "name": "calliope-web", - "version": "1.3.1", + "version": "1.3.2", "private": true, "scripts": { "dev": "vite dev", diff --git a/calliope-web/src/lib/components/video/PromptPreviewModal.svelte b/calliope-web/src/lib/components/video/PromptPreviewModal.svelte index 31c991c0..da2dbdd0 100644 --- a/calliope-web/src/lib/components/video/PromptPreviewModal.svelte +++ b/calliope-web/src/lib/components/video/PromptPreviewModal.svelte @@ -38,7 +38,10 @@ let basedOn = $state(''); let fromDraft = $state(false); let stale = $state(false); - let loadedScene = $state(null); + /** Scene id whose resolve already fired once — guards against store-transition re-runs. */ + let attemptedFor = $state(null); + /** Resolve failed — modal shows a client-side prose fallback instead of a dead end. */ + let failed = $state(false); const preview = createMutation({ mutationFn: async () => { @@ -52,21 +55,41 @@ text = data.prompt; basedOn = data.based_on; fromDraft = data.from_draft; + failed = false; stale = false; }, onError: (err) => { + failed = true; + if (scene) { + // Never a dead end: populate the editor with raw scene text so the + // user can edit and Generate (confirm sends it via prompts override), + // or hit Regenerate to retry the rewrite. + text = proseFallback(scene); + basedOn = ''; + fromDraft = false; + } toast.error(err instanceof Error ? err.message : String(err)); }, }); - // Resolve whenever the modal opens for a (new) scene. + // Resolve once per scene. `attemptedFor` is set before mutating so + // mutation-store transitions (pending → success/error) can't re-trigger + // this effect — the old `text` guard fired duplicate requests while the + // first was still pending. $effect(() => { if (!open || !scene) return; - if (loadedScene === scene.id && text) return; - loadedScene = scene.id; + if (attemptedFor === scene.id) return; + attemptedFor = scene.id; $preview.mutate(); }); + function proseFallback(s: Scene): string { + const heading = (s.heading || '').trim(); + const action = (s.action || '').trim(); + const dialog = (s.dialog || '').trim(); + return [heading, action, dialog].filter(Boolean).join('\n\n'); + } + // Stale check: a draft saved against different scene content should warn. $effect(() => { if (!scene || !basedOn) return; @@ -93,6 +116,7 @@ } function regenerate() { + failed = false; $preview.mutate(); } @@ -136,7 +160,12 @@ aria-label="Prompt text sent to the workflow" > - {#if fromDraft} + {#if failed} +
+ + Rewrite unavailable — showing raw scene text. You can edit and Generate, or Retry. +
+ {:else if fromDraft}

Loaded from your saved draft. Regenerate re-runs the rewrite.

{:else if workflow?.prompt_profile === 'minimax_h3_ref'}

MiniMax H3 six-section rewrite. Edit freely — this exact text goes to the (Input:prompt) node.

diff --git a/docs/changelogs/2026-08-31-hardening.md b/docs/changelogs/2026-08-31-hardening.md new file mode 100644 index 00000000..0eed51d9 --- /dev/null +++ b/docs/changelogs/2026-08-31-hardening.md @@ -0,0 +1,41 @@ +# Calliope 1.3.2 — prompt preview hardening + +Follow-up to 1.3.0's prompt review gate. Fixes the failure mode where an +unreachable or slow LLM endpoint left the **Review prompt** modal hanging on +"Resolving prompt…" until the connection died (`Failed to fetch`), and made +Generate impossible. + +## Fixed + +- **Preview requests no longer double-fire.** The modal's resolve effect was + subscribed to its own request store and re-fired on every state transition + while the prompt text was still empty — producing duplicate (sometimes + parallel) `POST /preview-prompt` calls per scene. The effect now fires + exactly once per scene. +- **Dead LLM endpoints fail fast.** The preview rewrite now times out after + **30 s** (enqueue keeps the 120 s headroom for thinking models) and returns + the deterministic six-section H3 template instead of hanging. +- **The modal is never a dead end.** When the rewrite can't be resolved, the + editor is populated with the raw scene text (heading + action + dialog) and + a hint explains the situation. You can edit that text and Generate — the + confirmed text wins — or hit **Regenerate** to retry. Previously an error + left an empty editor with a spinner-less dead end and re-firing requests + on every reopen. + +## Notes + +- The new client-side fallback text is **not** H3-formatted; that's + intentional and labeled in the UI. Generate sends it verbatim via the + confirmed-prompt override. +- If you hit this in the wild, also check **Settings → LLM**: a wrong base + URL or API key is the usual cause of ~60 s hangs (a proxy cutting an idle + connection to a dead endpoint). + +## Upgrade + +```bash +git pull +cd calliope-web && npm install +``` + +Backend deps unchanged. Restart both processes (`start.bat`). diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index 1db8a7e3..63cfb94c 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -2,6 +2,7 @@ Dated notes for GitHub releases. Newest first. +- [2026-08-31 hardening](./2026-08-31-hardening.md) — Prompt preview fails fast + never blocks Generate (1.3.2) - [2026-08-31 patch](./2026-08-31-patch.md) — SvelteKit security bump so `npm audit fix --force` is never needed (1.3.1) - [2026-08-31](./2026-08-31.md) — Prompt review gate, ComfyUI error surfacing, saved scene setups (1.3.0) - [2026-08-29](./2026-08-29.md) — Model per agent + clip-native export fps + video-extend continue (1.2.3)