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:
@@ -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 = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Generated
+2
-2
@@ -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,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>
|
||||
|
||||
@@ -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`).
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user