Fix remaining code-review findings (15 bugs; #11 skipped as AID-compatible)
Backend: - provider: fall back to parsing a plain JSON body when a server ignores stream=true (was: silent empty turn); error if response has no text (#5) - memorybank: clamp cursors after undo/retry shrinks the action list, and translate summary_cursor (list position) to an Action.index boundary before comparing with Memory.source_end (#7) - memorybank: pinned memories now count toward the top_k budget (#8) - memorybank: cosine() returns 0.0 on dimension mismatch; changing the embedding model clears stored vectors so they re-embed (#9) - scripting: MAX_STORY_CARDS cap now counts cards inserted during the hook, so a script can't add unbounded cards in one turn (#10) - settings: /test tolerates non-dict JSON from /models (#13) - scenarios: import accepts worldInformation as a story-card source (#14) Frontend: - per-key debounce timers in PlotPanel and ScenarioEditor — editing two things within 600ms no longer drops the first save (#15, #16) - Continue button no longer discards typed input (#17) - failed retry resyncs actions from the server instead of leaving the removed action missing (#18) - Settings save/test surface errors instead of hanging on Testing… (#19) - InsightsPanel ignores stale responses from superseded requests (#20) - placeholder scan includes story-card trigger keys (#21) addStoryCard returning the 0-based index (falsy for the first card) matches real AI Dungeon per the scripting guidebook — kept, documented (#11). Statuses updated in CODE_REVIEW_FINDINGS.md; stale entries for previously fixed items (#1-4, #6, #12) corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
co-authored by
Claude Fable 5
parent
3ee653a631
commit
253b533d3b
+29
-21
@@ -4,107 +4,115 @@ Full-codebase review (no git history, so whole project was the scope).
|
|||||||
Status: `pending` = not yet fixed, `fixed` = applied, `verify-failed` = finding was wrong on closer look, `skipped` = intentionally not fixed.
|
Status: `pending` = not yet fixed, `fixed` = applied, `verify-failed` = finding was wrong on closer look, `skipped` = intentionally not fixed.
|
||||||
Resume point: fix `pending` items top-to-bottom (they are ranked by severity).
|
Resume point: fix `pending` items top-to-bottom (they are ranked by severity).
|
||||||
|
|
||||||
|
**2026-07-06 update (branch `bugfix-code-review`):** every finding re-verified against
|
||||||
|
current code. #1/#2/#3/#4/#6/#12 had already been fixed in earlier sessions (statuses were
|
||||||
|
stale); #5/#7/#8/#9/#10/#13/#14 (backend) and #15–#21 (frontend) fixed in this pass.
|
||||||
|
#11 is skipped: real AI Dungeon's `addStoryCard` also returns the new card's index
|
||||||
|
(0-falsy included) per the official scripting guidebook, so changing it would break
|
||||||
|
compatibility — documented in `engine.py`'s prelude instead. The cleanup backlog
|
||||||
|
(R/S/E/A items) below remains open by design.
|
||||||
|
|
||||||
## Correctness bugs
|
## Correctness bugs
|
||||||
|
|
||||||
### 1. [pending] seed_demo.py doesn't stamp schema version → server crashes on next start
|
### 1. [fixed] seed_demo.py doesn't stamp schema version → server crashes on next start
|
||||||
- `backend/seed_demo.py:10`
|
- `backend/seed_demo.py:10`
|
||||||
- Fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. Next server start sees tables exist, replays every ALTER TABLE migration → `duplicate column name` crash.
|
- Fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. Next server start sees tables exist, replays every ALTER TABLE migration → `duplicate column name` crash.
|
||||||
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
|
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
|
||||||
|
|
||||||
### 2. [pending] Turn-lock race: two simultaneous turns can run on the same adventure
|
### 2. [fixed] Turn-lock race: two simultaneous turns can run on the same adventure
|
||||||
- `backend/app/routers/adventures.py:302`
|
- `backend/app/routers/adventures.py:302`
|
||||||
- `ensure_not_generating()` runs in the route handler but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-click Continue → both requests pass the 409 check → duplicate Action.index rows, interleaved generations.
|
- `ensure_not_generating()` runs in the route handler but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-click Continue → both requests pass the 409 check → duplicate Action.index rows, interleaved generations.
|
||||||
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
|
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
|
||||||
|
|
||||||
### 3. [pending] Migration 10 renumbers indexes with a correlated subquery on the table being updated
|
### 3. [fixed] Migration 10 renumbers indexes with a correlated subquery on the table being updated
|
||||||
- `backend/app/migrations.py:34`
|
- `backend/app/migrations.py:34`
|
||||||
- SQLite may evaluate the subquery against partially-updated rows → duplicate indexes survive the "repair".
|
- SQLite may evaluate the subquery against partially-updated rows → duplicate indexes survive the "repair".
|
||||||
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
|
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
|
||||||
|
|
||||||
### 4. [pending] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
|
### 4. [fixed] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
|
||||||
- `backend/app/database.py:8`
|
- `backend/app/database.py:8`
|
||||||
- Deleting a Script leaves orphaned `scenario_scripts` rows; SQLite rowid reuse can attach a future script to an old scenario.
|
- Deleting a Script leaves orphaned `scenario_scripts` rows; SQLite rowid reuse can attach a future script to an old scenario.
|
||||||
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
|
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
|
||||||
|
|
||||||
### 5. [pending] Provider generate() silently yields nothing for non-SSE 200 responses
|
### 5. [fixed] Provider generate() silently yields nothing for non-SSE 200 responses
|
||||||
- `backend/app/providers/openai_compatible.py:84`
|
- `backend/app/providers/openai_compatible.py:84`
|
||||||
- Server that ignores `stream=true` and returns plain JSON → no `data:` lines → empty AI action, no error.
|
- Server that ignores `stream=true` and returns plain JSON → no `data:` lines → empty AI action, no error.
|
||||||
- Fix: buffer non-SSE body and fall back to parsing it as a single JSON completion.
|
- Fix: buffer non-SSE body and fall back to parsing it as a single JSON completion.
|
||||||
|
|
||||||
### 6. [pending] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
|
### 6. [fixed] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
|
||||||
- `backend/app/memorybank.py:146`
|
- `backend/app/memorybank.py:146`
|
||||||
- `asyncio.create_task` result not referenced; task can vanish silently; adventure ID can stay stuck in `_running`.
|
- `asyncio.create_task` result not referenced; task can vanish silently; adventure ID can stay stuck in `_running`.
|
||||||
- Fix: keep strong refs in a set, discard in done-callback.
|
- Fix: keep strong refs in a set, discard in done-callback.
|
||||||
|
|
||||||
### 7. [pending] Memory cursors are list positions but Memory.source_start/end are Action.index values
|
### 7. [fixed] Memory cursors are list positions but Memory.source_start/end are Action.index values
|
||||||
- `backend/app/memorybank.py:213`
|
- `backend/app/memorybank.py:213`
|
||||||
- After any action deletion (indexes keep gaps, positions shift), summarization skips/duplicates blocks and `_update_story_summary` folds the wrong memories.
|
- After any action deletion (indexes keep gaps, positions shift), summarization skips/duplicates blocks and `_update_story_summary` folds the wrong memories.
|
||||||
- Fix: use one space consistently — track cursors by Action.index (position-independent), or renumber on delete.
|
- Fix: use one space consistently — track cursors by Action.index (position-independent), or renumber on delete.
|
||||||
|
|
||||||
### 8. [pending] Pinned memories don't count toward memory_top_k cap
|
### 8. [fixed] Pinned memories don't count toward memory_top_k cap
|
||||||
- `backend/app/memorybank.py:120`
|
- `backend/app/memorybank.py:120`
|
||||||
- 6 pinned + top_k=5 → 11 memories injected, blowing token budget.
|
- 6 pinned + top_k=5 → 11 memories injected, blowing token budget.
|
||||||
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
|
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
|
||||||
|
|
||||||
### 9. [pending] Embedding-model change → cosine() zips different-dimension vectors silently
|
### 9. [fixed] Embedding-model change → cosine() zips different-dimension vectors silently
|
||||||
- `backend/app/memorybank.py:69`
|
- `backend/app/memorybank.py:69`
|
||||||
- Old 768-dim embeddings scored against new 1536-dim query → garbage similarity, no error, never re-embedded.
|
- Old 768-dim embeddings scored against new 1536-dim query → garbage similarity, no error, never re-embedded.
|
||||||
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
|
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
|
||||||
|
|
||||||
### 10. [pending] MAX_STORY_CARDS cap is a no-op for cards created in one hook
|
### 10. [fixed] MAX_STORY_CARDS cap is a no-op for cards created in one hook
|
||||||
- `backend/app/scripting/pipeline.py:59`
|
- `backend/app/scripting/pipeline.py:59`
|
||||||
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards (seen_ids ⊂ existing). A script can insert unbounded cards in one turn.
|
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards (seen_ids ⊂ existing). A script can insert unbounded cards in one turn.
|
||||||
- Fix: count inserts made during the loop.
|
- Fix: count inserts made during the loop.
|
||||||
|
|
||||||
### 11. [pending] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
|
### 11. [skipped] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
|
||||||
- `backend/app/scripting/engine.py:39`
|
- `backend/app/scripting/engine.py:39`
|
||||||
- `if (!addStoryCard(...))` misfires when the card list was empty.
|
- `if (!addStoryCard(...))` misfires when the card list was empty.
|
||||||
- Fix: return `storyCards.length` (1-based, always truthy) or `true`; document.
|
- Fix: return `storyCards.length` (1-based, always truthy) or `true`; document.
|
||||||
|
|
||||||
### 12. [pending] scenario_id=0 truthiness bug in create_story_card
|
### 12. [fixed] scenario_id=0 truthiness bug in create_story_card
|
||||||
- `backend/app/routers/story_cards.py:28`
|
- `backend/app/routers/story_cards.py:28`
|
||||||
- `scenario_id or ...` picks the wrong owner when id is 0. Use `is not None`.
|
- `scenario_id or ...` picks the wrong owner when id is 0. Use `is not None`.
|
||||||
|
|
||||||
### 13. [pending] test_connection 500s on non-dict JSON from /models
|
### 13. [fixed] test_connection 500s on non-dict JSON from /models
|
||||||
- `backend/app/routers/settings.py:54`
|
- `backend/app/routers/settings.py:54`
|
||||||
- Only ValueError caught; `data.get`/`m.get` on non-dict raises AttributeError → 500 instead of `{ok:false}`.
|
- Only ValueError caught; `data.get`/`m.get` on non-dict raises AttributeError → 500 instead of `{ok:false}`.
|
||||||
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
|
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
|
||||||
|
|
||||||
### 14. [pending] AI Dungeon exports with `worldInformation` key lose all story cards silently
|
### 14. [fixed] AI Dungeon exports with `worldInformation` key lose all story cards silently
|
||||||
- `backend/app/routers/scenarios.py:133`
|
- `backend/app/routers/scenarios.py:133`
|
||||||
- Import reads only `storyCards`/`worldInfo`; `worldInformation` is in _IGNORED_KEYS so it's dropped and not reported.
|
- Import reads only `storyCards`/`worldInfo`; `worldInformation` is in _IGNORED_KEYS so it's dropped and not reported.
|
||||||
- Fix: accept `worldInformation` as a card source too.
|
- Fix: accept `worldInformation` as a card source too.
|
||||||
|
|
||||||
### 15. [pending] Shared debounce timer loses edits (Play PlotPanel)
|
### 15. [fixed] Shared debounce timer loses edits (Play PlotPanel)
|
||||||
- `frontend/src/pages/Play.jsx:28`
|
- `frontend/src/pages/Play.jsx:28`
|
||||||
- One `saveTimer` shared by all plot fields AND story-card saves; editing a second thing within 600ms cancels the first pending PATCH → silent data loss.
|
- One `saveTimer` shared by all plot fields AND story-card saves; editing a second thing within 600ms cancels the first pending PATCH → silent data loss.
|
||||||
- Fix: per-key timers (e.g. a Map keyed by field/card id).
|
- Fix: per-key timers (e.g. a Map keyed by field/card id).
|
||||||
|
|
||||||
### 16. [pending] Same shared-debounce data loss in ScenarioEditor
|
### 16. [fixed] Same shared-debounce data loss in ScenarioEditor
|
||||||
- `frontend/src/pages/ScenarioEditor.jsx:22`
|
- `frontend/src/pages/ScenarioEditor.jsx:22`
|
||||||
- Same fix as #15.
|
- Same fix as #15.
|
||||||
|
|
||||||
### 17. [pending] Continue button silently discards typed input text
|
### 17. [fixed] Continue button silently discards typed input text
|
||||||
- `frontend/src/pages/Play.jsx:469`
|
- `frontend/src/pages/Play.jsx:469`
|
||||||
- Clicking Continue with text in the box sends type 'continue' (backend ignores text) and clears the input.
|
- Clicking Continue with text in the box sends type 'continue' (backend ignores text) and clears the input.
|
||||||
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
|
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
|
||||||
|
|
||||||
### 18. [pending] retry() optimistically deletes last AI action with no rollback on failure
|
### 18. [fixed] retry() optimistically deletes last AI action with no rollback on failure
|
||||||
- `frontend/src/pages/Play.jsx:475`
|
- `frontend/src/pages/Play.jsx:475`
|
||||||
- Failed retry (409/network) leaves UI missing an action that still exists server-side.
|
- Failed retry (409/network) leaves UI missing an action that still exists server-side.
|
||||||
- Fix: restore the removed action in the catch path (or only remove on first stream event).
|
- Fix: restore the removed action in the catch path (or only remove on first stream event).
|
||||||
|
|
||||||
### 19. [pending] Settings test()/save() have no error handling → stuck on "Testing…"
|
### 19. [fixed] Settings test()/save() have no error handling → stuck on "Testing…"
|
||||||
- `frontend/src/pages/Settings.jsx:66`
|
- `frontend/src/pages/Settings.jsx:66`
|
||||||
- Rejection leaves `{pending:true}` forever + unhandled rejection.
|
- Rejection leaves `{pending:true}` forever + unhandled rejection.
|
||||||
- Fix: try/catch → setTestResult({ok:false, error:msg}).
|
- Fix: try/catch → setTestResult({ok:false, error:msg}).
|
||||||
|
|
||||||
### 20. [pending] InsightsPanel race: slow earlier request overwrites newer report
|
### 20. [fixed] InsightsPanel race: slow earlier request overwrites newer report
|
||||||
- `frontend/src/pages/Play.jsx:302`
|
- `frontend/src/pages/Play.jsx:302`
|
||||||
- No staleness guard; slow getAdventureContext can clobber a newer action snapshot.
|
- No staleness guard; slow getAdventureContext can clobber a newer action snapshot.
|
||||||
- Fix: track a request id / cancelled flag in the effect.
|
- Fix: track a request id / cancelled flag in the effect.
|
||||||
|
|
||||||
### 21. [pending] extractPlaceholders ignores ${...} in story-card trigger keys
|
### 21. [fixed] extractPlaceholders ignores ${...} in story-card trigger keys
|
||||||
- `frontend/src/pages/Scenarios.jsx:50`
|
- `frontend/src/pages/Scenarios.jsx:50`
|
||||||
- Backend fills placeholders in card.keys but the modal never prompts for those names → literal `${hero}` keys never match.
|
- Backend fills placeholders in card.keys but the modal never prompts for those names → literal `${hero}` keys never match.
|
||||||
- Fix: also scan card.keys when collecting placeholder names.
|
- Fix: also scan card.keys when collecting placeholder names.
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
|||||||
|
|
||||||
|
|
||||||
def cosine(a: list[float], b: list[float]) -> float:
|
def cosine(a: list[float], b: list[float]) -> float:
|
||||||
|
# Different lengths means the embedding model changed since this vector was
|
||||||
|
# stored; zip() would silently score garbage.
|
||||||
|
if len(a) != len(b):
|
||||||
|
return 0.0
|
||||||
dot = sum(x * y for x, y in zip(a, b))
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
||||||
return dot / norm if norm else 0.0
|
return dot / norm if norm else 0.0
|
||||||
@@ -118,10 +122,12 @@ async def retrieve_memories(
|
|||||||
key=lambda pair: pair[0],
|
key=lambda pair: pair[0],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
# Pinned memories are always used; the rest fill up to top_k by similarity.
|
# Pinned memories are always used and count toward top_k, so the injected
|
||||||
|
# set never exceeds the configured budget (unless pinned alone exceed it).
|
||||||
top_k = max(1, settings.memory_top_k)
|
top_k = max(1, settings.memory_top_k)
|
||||||
used = [(score, m) for score, m in scored if m.pinned]
|
used = [(score, m) for score, m in scored if m.pinned]
|
||||||
used += [(score, m) for score, m in scored if not m.pinned][:top_k]
|
remaining = max(0, top_k - len(used))
|
||||||
|
used += [(score, m) for score, m in scored if not m.pinned][:remaining]
|
||||||
used.sort(key=lambda pair: pair[0], reverse=True)
|
used.sort(key=lambda pair: pair[0], reverse=True)
|
||||||
|
|
||||||
if update_stats:
|
if update_stats:
|
||||||
@@ -162,6 +168,11 @@ async def run_post_turn(adventure_id: int) -> None:
|
|||||||
settings = db.get(models.Settings, 1)
|
settings = db.get(models.Settings, 1)
|
||||||
if adventure is None or settings is None:
|
if adventure is None or settings is None:
|
||||||
return
|
return
|
||||||
|
# Undo/retry can shrink the action list below a stored cursor, which
|
||||||
|
# would stall summarization until the story grew past it again.
|
||||||
|
count = len(story_actions(adventure))
|
||||||
|
adventure.memory_cursor = min(adventure.memory_cursor, count)
|
||||||
|
adventure.summary_cursor = min(adventure.summary_cursor, count)
|
||||||
if adventure.auto_summarize:
|
if adventure.auto_summarize:
|
||||||
await _create_due_memories(adventure, settings, db)
|
await _create_due_memories(adventure, settings, db)
|
||||||
await _update_story_summary(adventure, settings, db)
|
await _update_story_summary(adventure, settings, db)
|
||||||
@@ -213,10 +224,17 @@ async def _update_story_summary(
|
|||||||
|
|
||||||
# Fold in memories covering the uncovered stretch; fall back to raw story
|
# Fold in memories covering the uncovered stretch; fall back to raw story
|
||||||
# text if memory creation is lagging (e.g. it just failed).
|
# text if memory creation is lagging (e.g. it just failed).
|
||||||
|
# summary_cursor is a position into story_actions(); Memory.source_end is
|
||||||
|
# an Action.index. Translate the cursor to an index boundary before
|
||||||
|
# comparing — the two spaces diverge once actions are deleted or empty.
|
||||||
|
if adventure.summary_cursor < len(actions):
|
||||||
|
boundary = actions[adventure.summary_cursor].index
|
||||||
|
else:
|
||||||
|
boundary = actions[-1].index + 1 if actions else 0
|
||||||
new_events = [
|
new_events = [
|
||||||
m.text
|
m.text
|
||||||
for m in adventure.memories
|
for m in adventure.memories
|
||||||
if m.source_end is not None and m.source_end >= adventure.summary_cursor
|
if m.source_end is not None and m.source_end >= boundary
|
||||||
]
|
]
|
||||||
if new_events:
|
if new_events:
|
||||||
events_text = "\n".join(f"- {t}" for t in new_events)
|
events_text = "\n".join(f"- {t}" for t in new_events)
|
||||||
|
|||||||
@@ -119,9 +119,16 @@ class OpenAICompatibleProvider(Provider):
|
|||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
detail = (await resp.aread()).decode(errors="replace")[:500]
|
detail = (await resp.aread()).decode(errors="replace")[:500]
|
||||||
raise ProviderError(self._friendly_http_error(resp.status_code, detail))
|
raise ProviderError(self._friendly_http_error(resp.status_code, detail))
|
||||||
|
# Some servers ignore stream=true and return one plain JSON
|
||||||
|
# body; buffer non-SSE lines so we can fall back to it.
|
||||||
|
saw_sse = False
|
||||||
|
raw_lines: list[str] = []
|
||||||
async for line in resp.aiter_lines():
|
async for line in resp.aiter_lines():
|
||||||
if not line.startswith("data:"):
|
if not line.startswith("data:"):
|
||||||
|
if not saw_sse:
|
||||||
|
raw_lines.append(line)
|
||||||
continue
|
continue
|
||||||
|
saw_sse = True
|
||||||
data = line[5:].strip()
|
data = line[5:].strip()
|
||||||
if data == "[DONE]":
|
if data == "[DONE]":
|
||||||
debuglog.finish_entry(log, response="".join(received))
|
debuglog.finish_entry(log, response="".join(received))
|
||||||
@@ -137,6 +144,27 @@ class OpenAICompatibleProvider(Provider):
|
|||||||
if chunk:
|
if chunk:
|
||||||
received.append(chunk)
|
received.append(chunk)
|
||||||
yield "text", chunk
|
yield "text", chunk
|
||||||
|
if not saw_sse:
|
||||||
|
body_text = "\n".join(raw_lines).strip()
|
||||||
|
try:
|
||||||
|
payload = json.loads(body_text)
|
||||||
|
except ValueError:
|
||||||
|
raise ProviderError(
|
||||||
|
"AI endpoint returned neither an SSE stream nor JSON: "
|
||||||
|
+ body_text[:200]
|
||||||
|
)
|
||||||
|
reasoning = self._extract_reasoning(payload)
|
||||||
|
if reasoning:
|
||||||
|
yield "reasoning", reasoning
|
||||||
|
chunk = self._extract_chunk(payload)
|
||||||
|
if chunk:
|
||||||
|
received.append(chunk)
|
||||||
|
yield "text", chunk
|
||||||
|
if not received:
|
||||||
|
raise ProviderError(
|
||||||
|
"AI endpoint returned a response with no text: "
|
||||||
|
+ body_text[:200]
|
||||||
|
)
|
||||||
debuglog.finish_entry(log, response="".join(received))
|
debuglog.finish_entry(log, response="".join(received))
|
||||||
except httpx.ConnectError as exc:
|
except httpx.ConnectError as exc:
|
||||||
error = f"Could not connect to {self.base_url} — is the AI server running?"
|
error = f"Could not connect to {self.base_url} — is the AI server running?"
|
||||||
|
|||||||
@@ -130,7 +130,13 @@ def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)):
|
|||||||
db.add(scenario)
|
db.add(scenario)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
cards = bundle.get("storyCards") or bundle.get("worldInfo") or []
|
# AI Dungeon exports have used all three names for the same list.
|
||||||
|
cards = (
|
||||||
|
bundle.get("storyCards")
|
||||||
|
or bundle.get("worldInfo")
|
||||||
|
or bundle.get("worldInformation")
|
||||||
|
or []
|
||||||
|
)
|
||||||
for card in cards:
|
for card in cards:
|
||||||
if not isinstance(card, dict):
|
if not isinstance(card, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -25,8 +25,17 @@ def read_settings(db: Session = Depends(get_db)):
|
|||||||
@router.put("", response_model=schemas.SettingsOut)
|
@router.put("", response_model=schemas.SettingsOut)
|
||||||
def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_db)):
|
def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_db)):
|
||||||
settings = get_settings(db)
|
settings = get_settings(db)
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
fields = payload.model_dump(exclude_unset=True)
|
||||||
|
embedding_model_changed = (
|
||||||
|
"embedding_model" in fields
|
||||||
|
and fields["embedding_model"] != settings.embedding_model
|
||||||
|
)
|
||||||
|
for field, value in fields.items():
|
||||||
setattr(settings, field, value)
|
setattr(settings, field, value)
|
||||||
|
if embedding_model_changed:
|
||||||
|
# Vectors from the old model have a different dimensionality/space;
|
||||||
|
# clear them so the post-turn task re-embeds with the new model.
|
||||||
|
db.query(models.Memory).update({"embedding": None})
|
||||||
db.commit()
|
db.commit()
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@@ -52,6 +61,6 @@ async def test_connection(db: Session = Depends(get_db)):
|
|||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
models_available = [m.get("id", "?") for m in data.get("data", [])]
|
models_available = [m.get("id", "?") for m in data.get("data", [])]
|
||||||
except ValueError:
|
except (ValueError, AttributeError, TypeError):
|
||||||
pass
|
pass # non-JSON or unexpected shape — connectivity is still confirmed
|
||||||
return {"ok": True, "models": models_available}
|
return {"ok": True, "models": models_available}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function log(msg) {
|
|||||||
}
|
}
|
||||||
var console = { log: log };
|
var console = { log: log };
|
||||||
|
|
||||||
|
// Returns the new card's index, or false if a card with those keys exists —
|
||||||
|
// matching real AI Dungeon. Note index 0 is falsy; that quirk is upstream's.
|
||||||
function addStoryCard(keys, entry, type) {
|
function addStoryCard(keys, entry, type) {
|
||||||
for (var i = 0; i < storyCards.length; i++) {
|
for (var i = 0; i < storyCards.length; i++) {
|
||||||
if (storyCards[i].keys === keys) return false;
|
if (storyCards[i].keys === keys) return false;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class ScriptPipeline:
|
|||||||
def _apply_cards(self, returned: list) -> None:
|
def _apply_cards(self, returned: list) -> None:
|
||||||
existing = {c.id: c for c in self.adventure.story_cards}
|
existing = {c.id: c for c in self.adventure.story_cards}
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
|
added = 0
|
||||||
for item in returned:
|
for item in returned:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
@@ -56,13 +57,14 @@ class ScriptPipeline:
|
|||||||
seen_ids.add(card_id)
|
seen_ids.add(card_id)
|
||||||
card = existing[card_id]
|
card = existing[card_id]
|
||||||
card.keys, card.entry, card.type = keys, entry, card_type
|
card.keys, card.entry, card.type = keys, entry, card_type
|
||||||
elif len(existing) + len(seen_ids) < MAX_STORY_CARDS:
|
elif len(existing) + added < MAX_STORY_CARDS:
|
||||||
self.db.add(
|
self.db.add(
|
||||||
models.StoryCard(
|
models.StoryCard(
|
||||||
adventure_id=self.adventure.id,
|
adventure_id=self.adventure.id,
|
||||||
keys=keys, entry=entry, type=card_type,
|
keys=keys, entry=entry, type=card_type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
added += 1
|
||||||
for card_id, card in existing.items():
|
for card_id, card in existing.items():
|
||||||
if card_id not in seen_ids:
|
if card_id not in seen_ids:
|
||||||
self.db.delete(card)
|
self.db.delete(card)
|
||||||
|
|||||||
@@ -50,12 +50,17 @@ const SECTION_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlotPanel({ adventure, setAdventure }) {
|
function PlotPanel({ adventure, setAdventure }) {
|
||||||
const saveTimer = useRef(null)
|
// One timer per field/card: a single shared timer would cancel the pending
|
||||||
|
// save of whatever was edited previously within the debounce window.
|
||||||
|
const saveTimers = useRef(new Map())
|
||||||
|
const debounceSave = (key, fn) => {
|
||||||
|
clearTimeout(saveTimers.current.get(key))
|
||||||
|
saveTimers.current.set(key, setTimeout(fn, 600))
|
||||||
|
}
|
||||||
|
|
||||||
const setField = (field, value) => {
|
const setField = (field, value) => {
|
||||||
setAdventure({ ...adventure, [field]: value })
|
setAdventure({ ...adventure, [field]: value })
|
||||||
clearTimeout(saveTimer.current)
|
debounceSave(field, () => api.updateAdventure(adventure.id, { [field]: value }))
|
||||||
saveTimer.current = setTimeout(() => api.updateAdventure(adventure.id, { [field]: value }), 600)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCard = async () => {
|
const addCard = async () => {
|
||||||
@@ -68,12 +73,11 @@ function PlotPanel({ adventure, setAdventure }) {
|
|||||||
...adventure,
|
...adventure,
|
||||||
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
|
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
|
||||||
})
|
})
|
||||||
clearTimeout(saveTimer.current)
|
debounceSave(`card-${card.id}`, () => {
|
||||||
saveTimer.current = setTimeout(() => {
|
|
||||||
api.updateStoryCard(card.id, {
|
api.updateStoryCard(card.id, {
|
||||||
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
||||||
})
|
})
|
||||||
}, 600)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCard = async (cardId) => {
|
const deleteCard = async (cardId) => {
|
||||||
@@ -324,11 +328,15 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
|||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let stale = false // a slow earlier request must not clobber a newer one
|
||||||
setError(null)
|
setError(null)
|
||||||
const load = inspectActionId
|
const load = inspectActionId
|
||||||
? api.getActionContext(advId, inspectActionId)
|
? api.getActionContext(advId, inspectActionId)
|
||||||
: api.getAdventureContext(advId)
|
: api.getAdventureContext(advId)
|
||||||
load.then(setReport).catch((err) => { setReport(null); setError(err.message) })
|
load
|
||||||
|
.then((r) => { if (!stale) setReport(r) })
|
||||||
|
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
|
||||||
|
return () => { stale = true }
|
||||||
}, [advId, inspectActionId, refreshKey])
|
}, [advId, inspectActionId, refreshKey])
|
||||||
|
|
||||||
if (error) return <div className="empty">{error}</div>
|
if (error) return <div className="empty">{error}</div>
|
||||||
@@ -502,6 +510,11 @@ export default function Play() {
|
|||||||
|
|
||||||
function send(type = mode) {
|
function send(type = mode) {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
|
if (type === 'continue') {
|
||||||
|
// Continue never consumes typed text — leave it in the box.
|
||||||
|
runTurn((signal) => api.sendAction(id, { type: 'continue', text: '' }, handleEvent, signal))
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = { type: text ? type : 'continue', text }
|
const payload = { type: text ? type : 'continue', text }
|
||||||
setInput('')
|
setInput('')
|
||||||
runTurn((signal) => api.sendAction(id, payload, handleEvent, signal))
|
runTurn((signal) => api.sendAction(id, payload, handleEvent, signal))
|
||||||
@@ -510,7 +523,16 @@ export default function Play() {
|
|||||||
function retry() {
|
function retry() {
|
||||||
setActions((prev) =>
|
setActions((prev) =>
|
||||||
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
|
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
|
||||||
runTurn((signal) => api.retry(id, handleEvent, signal))
|
runTurn(async (signal) => {
|
||||||
|
try {
|
||||||
|
await api.retry(id, handleEvent, signal)
|
||||||
|
} catch (err) {
|
||||||
|
// Failed retry (409, network): the optimistically removed action may
|
||||||
|
// still exist server-side — resync instead of guessing.
|
||||||
|
api.getAdventure(id).then((adv) => setActions(adv.actions)).catch(() => {})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function undo() {
|
async function undo() {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ export default function ScenarioEditor() {
|
|||||||
const [scenario, setScenario] = useState(null)
|
const [scenario, setScenario] = useState(null)
|
||||||
const [allScripts, setAllScripts] = useState([])
|
const [allScripts, setAllScripts] = useState([])
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const saveTimer = useRef(null)
|
// One timer per field/card: a single shared timer would cancel the pending
|
||||||
|
// save of whatever was edited previously within the debounce window.
|
||||||
|
const saveTimers = useRef(new Map())
|
||||||
|
const debounceSave = (key, fn) => {
|
||||||
|
clearTimeout(saveTimers.current.get(key))
|
||||||
|
saveTimers.current.set(key, setTimeout(fn, 600))
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios'))
|
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios'))
|
||||||
@@ -19,12 +25,11 @@ export default function ScenarioEditor() {
|
|||||||
const setField = (field, value) => {
|
const setField = (field, value) => {
|
||||||
const next = { ...scenario, [field]: value }
|
const next = { ...scenario, [field]: value }
|
||||||
setScenario(next)
|
setScenario(next)
|
||||||
clearTimeout(saveTimer.current)
|
debounceSave(field, async () => {
|
||||||
saveTimer.current = setTimeout(async () => {
|
|
||||||
await api.updateScenario(id, { [field]: value })
|
await api.updateScenario(id, { [field]: value })
|
||||||
setStatus('Saved')
|
setStatus('Saved')
|
||||||
setTimeout(() => setStatus(''), 1500)
|
setTimeout(() => setStatus(''), 1500)
|
||||||
}, 600)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCard = async () => {
|
const addCard = async () => {
|
||||||
@@ -37,12 +42,11 @@ export default function ScenarioEditor() {
|
|||||||
...scenario,
|
...scenario,
|
||||||
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
|
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
|
||||||
})
|
})
|
||||||
clearTimeout(saveTimer.current)
|
debounceSave(`card-${card.id}`, () => {
|
||||||
saveTimer.current = setTimeout(() => {
|
|
||||||
api.updateStoryCard(card.id, {
|
api.updateStoryCard(card.id, {
|
||||||
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
||||||
})
|
})
|
||||||
}, 600)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCard = async (cardId) => {
|
const deleteCard = async (cardId) => {
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export default function Scenarios() {
|
|||||||
const scenario = await api.getScenario(scenarioId)
|
const scenario = await api.getScenario(scenarioId)
|
||||||
const names = extractPlaceholders(
|
const names = extractPlaceholders(
|
||||||
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
||||||
...scenario.story_cards.map((c) => c.entry),
|
// Cards can carry ${placeholders} in trigger keys too, not just entries.
|
||||||
|
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
|
||||||
)
|
)
|
||||||
if (names.length === 0) return begin(scenarioId)
|
if (names.length === 0) return begin(scenarioId)
|
||||||
setPending({ scenario, names })
|
setPending({ scenario, names })
|
||||||
|
|||||||
@@ -56,16 +56,23 @@ export default function Settings() {
|
|||||||
const setField = (field, value) => setSettings({ ...settings, [field]: value })
|
const setField = (field, value) => setSettings({ ...settings, [field]: value })
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
try {
|
||||||
await api.updateSettings(settings)
|
await api.updateSettings(settings)
|
||||||
setSaved('Settings saved')
|
setSaved('Settings saved')
|
||||||
setTimeout(() => setSaved(''), 2000)
|
} catch (err) {
|
||||||
|
setSaved(`Save failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
setTimeout(() => setSaved(''), 4000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const test = async () => {
|
const test = async () => {
|
||||||
setTestResult({ pending: true })
|
setTestResult({ pending: true })
|
||||||
|
try {
|
||||||
await api.updateSettings(settings)
|
await api.updateSettings(settings)
|
||||||
const result = await api.testConnection()
|
setTestResult(await api.testConnection())
|
||||||
setTestResult(result)
|
} catch (err) {
|
||||||
|
setTestResult({ ok: false, detail: err.message })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings) return null
|
if (!settings) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user