Merge pull request #40 from benjiyaya/fix/preview-modal-hardening-1.3.2

fix: harden prompt preview — fail fast and never block Generate (1.3.2)
This commit is contained in:
Benji
2026-09-01 00:04:41 +08:00
committed by GitHub
9 changed files with 152 additions and 19 deletions
+1 -1
View File
@@ -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 = [
+7 -4
View File
@@ -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]:
@@ -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}
+50 -2
View File
@@ -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")
+2 -2
View File
@@ -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"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "calliope-web",
"version": "1.3.1",
"version": "1.3.2",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -38,7 +38,10 @@
let basedOn = $state('');
let fromDraft = $state(false);
let stale = $state(false);
let loadedScene = $state<number | null>(null);
/** Scene id whose resolve already fired once — guards against store-transition re-runs. */
let attemptedFor = $state<number | null>(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"
></textarea>
{#if fromDraft}
{#if failed}
<div class="stale-hint" role="status">
<Icon name="alert" size={14} />
<span>Rewrite unavailable — showing raw scene text. You can edit and Generate, or Retry.</span>
</div>
{:else if fromDraft}
<p class="hint">Loaded from your saved draft. Regenerate re-runs the rewrite.</p>
{:else if workflow?.prompt_profile === 'minimax_h3_ref'}
<p class="hint">MiniMax H3 six-section rewrite. Edit freely — this exact text goes to the (Input:prompt) node.</p>
+41
View File
@@ -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`).
+1
View File
@@ -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)