fix(zerogpu): persist phase state, because zerogpu run each function in
Browse filesdedicated worker, so we need persistant data so workers can grab data
from worker to worker.
- visualnovel/engine.py +36 -1
- visualnovel/llm.py +1 -2
visualnovel/engine.py
CHANGED
|
@@ -19,6 +19,35 @@ import json
|
|
| 19 |
import time
|
| 20 |
from pathlib import Path
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from . import config, memory, orchestrator, state
|
| 23 |
from .llm import LLMBackend, get_llm
|
| 24 |
from .metrics import collector
|
|
@@ -97,9 +126,10 @@ class Engine:
|
|
| 97 |
state.save_memory(self.state)
|
| 98 |
self.tracer.log(event="start", setup=setup.model_dump(), opening=opening.model_dump())
|
| 99 |
intro = self.state.flags.get("situation_intro", "")
|
| 100 |
-
# Stash for start_images()
|
| 101 |
self._pending_out: DirectorOutput | None = opening
|
| 102 |
self._pending_intro: str = intro
|
|
|
|
| 103 |
# Build a text-only ViewState so the frontend can show dialogue immediately
|
| 104 |
s = self.state
|
| 105 |
speaker_ch = s.characters.get(opening.speaker)
|
|
@@ -138,6 +168,11 @@ class Engine:
|
|
| 138 |
def start_images(self) -> ViewState:
|
| 139 |
"""Phase 2 — paint backdrop + sprite, TTS. Call after start_text().
|
| 140 |
Returns the full ViewState."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
assert self.state is not None and self._pending_out is not None, "call start_text() first"
|
| 142 |
return self._view(self._pending_out, intro_text=self._pending_intro)
|
| 143 |
|
|
|
|
| 19 |
import time
|
| 20 |
from pathlib import Path
|
| 21 |
|
| 22 |
+
# ZeroGPU runs each @spaces.GPU call in a separate worker subprocess; in-memory
|
| 23 |
+
# state set by start_text() is invisible to start_images(). We bridge the gap
|
| 24 |
+
# by writing/reading a temp file between the two phases.
|
| 25 |
+
_PHASE_FILE = Path("/tmp/vn_phase_state.json")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _persist_phase(state: GameState, out: DirectorOutput, intro: str) -> None:
|
| 29 |
+
_PHASE_FILE.write_text(
|
| 30 |
+
json.dumps({"state": state.model_dump(), "out": out.model_dump(), "intro": intro}),
|
| 31 |
+
encoding="utf-8",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _restore_phase() -> tuple[GameState, DirectorOutput, str] | None:
|
| 36 |
+
if not _PHASE_FILE.exists():
|
| 37 |
+
return None
|
| 38 |
+
try:
|
| 39 |
+
d = json.loads(_PHASE_FILE.read_text(encoding="utf-8"))
|
| 40 |
+
from .schemas import DirectorOutput, GameState # noqa: PLC0415
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
GameState.model_validate(d["state"]),
|
| 44 |
+
DirectorOutput.model_validate(d["out"]),
|
| 45 |
+
d.get("intro", ""),
|
| 46 |
+
)
|
| 47 |
+
except Exception:
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
from . import config, memory, orchestrator, state
|
| 52 |
from .llm import LLMBackend, get_llm
|
| 53 |
from .metrics import collector
|
|
|
|
| 126 |
state.save_memory(self.state)
|
| 127 |
self.tracer.log(event="start", setup=setup.model_dump(), opening=opening.model_dump())
|
| 128 |
intro = self.state.flags.get("situation_intro", "")
|
| 129 |
+
# Stash for start_images() — also written to disk so ZeroGPU workers can pick it up
|
| 130 |
self._pending_out: DirectorOutput | None = opening
|
| 131 |
self._pending_intro: str = intro
|
| 132 |
+
_persist_phase(self.state, opening, intro)
|
| 133 |
# Build a text-only ViewState so the frontend can show dialogue immediately
|
| 134 |
s = self.state
|
| 135 |
speaker_ch = s.characters.get(opening.speaker)
|
|
|
|
| 168 |
def start_images(self) -> ViewState:
|
| 169 |
"""Phase 2 — paint backdrop + sprite, TTS. Call after start_text().
|
| 170 |
Returns the full ViewState."""
|
| 171 |
+
if self.state is None or self._pending_out is None:
|
| 172 |
+
# Restore state persisted by start_text() (handles ZeroGPU worker isolation)
|
| 173 |
+
restored = _restore_phase()
|
| 174 |
+
if restored is not None:
|
| 175 |
+
self.state, self._pending_out, self._pending_intro = restored
|
| 176 |
assert self.state is not None and self._pending_out is not None, "call start_text() first"
|
| 177 |
return self._view(self._pending_out, intro_text=self._pending_intro)
|
| 178 |
|
visualnovel/llm.py
CHANGED
|
@@ -158,8 +158,7 @@ class TransformersLLM:
|
|
| 158 |
+ "\nNo markdown fences. No explanation. Only the JSON object."
|
| 159 |
)
|
| 160 |
augmented = [
|
| 161 |
-
{**m, "content": m["content"] + hint} if m["role"] == "system" else m
|
| 162 |
-
for m in messages
|
| 163 |
]
|
| 164 |
text = self.complete(augmented, **kw)
|
| 165 |
return json.loads(text[text.find("{") : text.rfind("}") + 1])
|
|
|
|
| 158 |
+ "\nNo markdown fences. No explanation. Only the JSON object."
|
| 159 |
)
|
| 160 |
augmented = [
|
| 161 |
+
{**m, "content": m["content"] + hint} if m["role"] == "system" else m for m in messages
|
|
|
|
| 162 |
]
|
| 163 |
text = self.complete(augmented, **kw)
|
| 164 |
return json.loads(text[text.find("{") : text.rfind("}") + 1])
|