26 KiB
Changelog
All notable changes to AI-RPG are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[1.2.0] — 2026-06-21
This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.).
Backend — Critical fixes
- Prompt templates: fixed
KeyError: 'scene_text, delta_time'that crashed world_builder during intro scene generation. The prompts contained literal{scene_text, delta_time}braces whichstr.format()interpreted as format fields. Rewroteorchestrator_phase2andintro_sceneprompts to describe the tool arguments in prose instead of using brace notation. All 11 prompt templates now format correctly (verified with a test). - LLM tool-call parsing for local models: many local models (gemma4, qwen, etc.) don't use the OpenAI function-calling format — they emit tool calls as text like
call:calc{"expression": "2+2"}or<tool_call>calc{...}</tool_call>. Added_parse_text_tool_calls()toapp/core/llm.pythat detects these patterns and converts them to OpenAI-formattool_calls. The LLM client now automatically parses text-based tool calls when the model doesn't return them in the standard format. This fixes the issue whererequest_toolswas[](empty) in logs even though tools were sent — actually tools WERE sent, but the model returned calls as text and they were ignored. - Test endpoints — ascii codec error:
POST /api/admin/test/llm-toolsfailed with'ascii' codec can't encode character '\u2026'when the client sent a masked api_key (containing…) back as a query parameter. Added_is_masked()and_resolve()helpers that detect masked values (…or****) and fall back to the raw DB value. All 4 test endpoints (test/llm, test/llm-tools, test/embeddings, test/embeddings/probe-dimension) now use these helpers. - Test LLM tools — better prompt: the test now sends a system message "You must use the calc tool" and a user message "You MUST call the calc tool with expression '2+2'" to encourage tool use. Also increased timeout from 15s to 30s. The response now includes
raw_response(the full LLM message) for debugging. - World editor —
'str' object has no attribute 'get': the world_editor crashed when tool_calls contained string entries instead of dicts (some models return non-standard formats). Added type normalization: each tool_call is checked withisinstance(tc, dict), strings are parsed as JSON, non-dicts are skipped. Also handles cases wherefunctionis not a dict. - World editor — propose_changes now waits for user: previously
propose_changeswas auto-accepted (simplified). Now the editor emitschange_proposedand WAITS for the user to accept/reject viaPOST /api/sessions/worlds/{id}/applyor/discard. Implemented usingasyncio.Futurestored in_pending_changesdict keyed by world_id. 120s timeout. - World editor — ask_user now waits for answer: similarly,
ask_usernow waits forPOST /api/sessions/worlds/{id}/answerbody{text: "..."}. 120s timeout. - World editor — apply_diff improved: now supports paths
world.name,world.description,schemas(full replace), andenvironment.<field>(via apply_patch). Previously onlyenvironment.*paths worked. - World editor — better empty-state handling: when world has no schemas/entities/environment, the prompt now shows "(no schemas yet)", "(no entities yet)", and "(empty — world has no environment yet. Use env_update to add player, current_location, plot_rails.)" instead of empty strings, so the LLM understands the context.
Backend — New endpoints
POST /api/sessions/worlds/{id}/apply— accept proposed changes (resolves the pending Future)POST /api/sessions/worlds/{id}/discard— reject proposed changesPOST /api/sessions/worlds/{id}/answerbody{text: "..."}— answer a clarificationPOST /api/worlds/{id}/generate-intro→{stream_url}— re-generate intro scene for draft worldsGET /api/sessions/worlds/{id}/intro/stream(SSE) — runs the intro_scene stage, sets world.status to "ready" on success. Emitsstep,intro_scene_complete,doneevents.
Frontend — Critical fixes (10 files changed, 1 new)
- World Editor — Accept/Reject buttons:
change_proposedevents now show a card with the diff (color-coded: green=add, red=remove, yellow=replace) and two buttons. Accept →POST /apply, Reject →POST /discard. After decision, the card collapses to a status line. - World Editor — Answer input:
clarificationevents show a card with either free-text input + Send button, or clickable option buttons (ifoptionsprovided). Submit →POST /answer. - World Edit Page — Generate Intro Scene: new
IntroSceneGeneratorcomponent. Ifworld.status === 'draft', shows a "Generate Intro Scene" button. Clicking it opens the SSE stream, shows progress, displays the generated scene, and ondonerefreshes the world (status → "ready") + shows toast "World is ready!". - Settings panel — autocomplete="off": all inputs now have
autoComplete="off". API key fields usetype="text"(browsers won't save them as passwords). Added a hidden decoy password input to absorb the password manager's attention. - Username/email autocomplete: verified
LoginPageusesautoComplete="username"for the login field,RegisterPageusesautoComplete="email"for email andautoComplete="username"for username. - Test LLM Tools — raw response display: when
has_tool_calls=false, shows a warning + collapsible<details>with the raw LLM response so the user can see what the model returned. - World Builder — Retry button: on error, shows a "Retry" button that re-subscribes to the builder stream. Also fixed a stale-closure bug where
done/errorevents didn't close the active SSE controller (useduseRefto track the current controller). - World Card — draft state: draft worlds show a "Continue setup" button (links to edit page) and a "Draft" badge. No "Play" button for drafts.
- Play page — better not-ready toast: "This world is not ready yet. Generate the intro scene first."
Verification
- Backend: 68 unit tests pass, 50 routes.
- Frontend:
tsc --noEmit→ 0 errors.npm run build→ success (368 KB JS / 23 KB CSS, ~112 KB gzipped).
[1.1.0] — 2026-06-21
This is a major bugfix release addressing 20+ issues found during user testing.
Backend — Critical fixes
- app/api/sessions.py: fixed
TypeError: 'async_generator' object does not support the asynchronous context manager protocolthat broke ALL SSE streams (world builder, world editor, orchestrator). The_session_scope()function was an async generator (usedyield) but was called withasync with. Added@asynccontextmanagerdecorator. Without this fix, no LLM calls were ever made — the background task crashed immediately. - app/models/init.py: made
WorldPreset.owner_idnullable (NOT NULL→NULL,ondelete=CASCADE→SET NULL). Builtin presets are now system presets withowner_id=NULL. Previously,seed_builtin_presetscrashed on first startup with a foreign key violation because no admin user existed yet. - app/main.py: added
_apply_schema_fixups(engine)that runsALTER TABLE world_presets ALTER COLUMN owner_id DROP NOT NULLon every startup (idempotent, wrapped in try/except). This fixes existing databases that were created with the old NOT NULL constraint. - app/main.py: admin setup URL is now ALWAYS printed on startup (even if admin already exists), per user request. The URL is blocked by the backend if an admin exists, but the token is visible for reference.
- app/migrations/seed.py:
seed_builtin_presetsnow usesowner_id=Noneinstead of looking for an admin user or a sentinel UUID. - requirements.txt: pinned
bcrypt==4.0.1. bcrypt 4.1+ removed the__about__module which breaks passlib 1.7.4's version detection. This caused the annoying(trapped) error reading bcrypt versionwarning on every password hash operation.
Backend — API improvements
- app/api/misc.py: added
GET /api/settings/public(no auth) — returns{page_title, favicon_url, logo_url, og_image_url}. The frontend uses this on app load to set the document title, favicon, and navbar logo. - app/api/worlds.py:
GET /api/worldsnow excludes archived worlds by default. Pass?status_filter=archivedto see only archived, or?status_filter=allto see everything. - app/api/admin.py:
GET /api/admin/statsnow returns separate counts:worlds(active),worlds_archived,worlds_total. Previously it counted ALL worlds including archived. - app/api/admin.py: added
DELETE /api/admin/worlds/{id}— hard-delete a world (cascade deletes entities, steps, logs, triggers, story_entries). Also cleans up Qdrant points (best-effort). This gives admins a way to permanently remove archived worlds. - app/core/llm.py: improved error messages for LLM API failures:
- Non-JSON responses now raise
LLMResponseError("LLM provider returned non-JSON response (check that api_url points to an OpenAI-compatible endpoint). First 200 chars: ...")instead of a genericparse_error. - HTTP 4xx/5xx errors now include the response body (or extracted
error.messagefrom JSON). - All error messages now include the HTTP status code for easier debugging.
- Non-JSON responses now raise
Frontend — Critical fixes (19 files changed, 1 new)
- Black page after settings change: root cause was
AdminApi.updateSettingsreturning{updated: ...}but the store expecting a different shape. Fixed the return type and removed the refetch-after-save that caused re-render loops. - [object Object] error in chat: added
toErrorMessage()helper that properly extracts.messagefromApiErrorobjects. All error toasts now show human-readable strings. - Play page access control:
/worlds/:id/playnow redirects to/worlds/:id/editwith a toast ifworld.status !== 'ready'. - Retry/Rollback buttons: disabled when
recentSteps.length === 0withopacity-50 cursor-not-allowed. - Archived worlds: hidden from the worlds list (both server-side and client-side filter). After deleting, removed from local state immediately.
- World card archived state: no Play button, "Archived" badge, "Restore" button (
PATCHstatus→ready), admin-only "Delete permanently" button (DELETE /api/admin/worlds/{id}). - LLM logs detail modal: now shows
request_messages,response_message,tool_calls,error_message,prompt_tokens,completion_tokens,latency_ms,temperature,model,stage,status(color-coded),created_at— all in pretty-printed JSON<pre>blocks. - Dropdowns:
embeddings.provider→<select>withoffline_hash/openai; boolean settings →<select>withtrue/false; LLM log stage filter →<select>with all 16 stage values; LLM log status filter →<select>with all 6 status values. - Numeric inputs: all integer/float settings now use
<input type="number">withmin/maxattributes. String-to-number conversion on save. - Test page pre-fills: diagnostic forms now pre-fill from current settings (
llm.api_url,llm.api_key,llm.model,embeddings.api_url,embeddings.model,embeddings.provider). - Embeddings test provider: defaults to current
embeddings.providersetting (was hardcoded toopenai). Added hint: "If provider=openai and api_url is empty, falls back to llm.api_url". - Embeddings URL fallback hints: added muted hint text next to
embeddings.api_url,embeddings.api_key,embeddings.modelfields. - UI settings applied: new
uiSettingsStorefetchesGET /api/settings/publicon app load. Setsdocument.title, favicon<link>, and navbar logo. Re-fetches after admin settings save. - Light theme fix: rewrote CSS with CSS-variable-based theming. Light mode now uses
#fafafa/#1a1a1a/#ffffffwith high-contrast text. Dark mode unchanged. - Single Create button: removed redundant "Create World" from navbar. Only one Create button remains (top of worlds list page).
- Username validation: client-side regex check + 422 error parsing with field-level inline messages. Specific toast: "Username can only contain letters, numbers, and underscores".
- SSE error handling:
sessionStorenow shows toast on SSEerrorevents. One-shot "Connection lost. Retrying…" toast on unexpected stream closure. - Settings panel UX: 6 grouped cards (LLM / Embeddings / Qdrant / Context / Game / UI), each with its own Save button. No page reload or full refetch after save — just a success toast.
- Admin stats panel: now shows 6 cards: Users, Active Worlds, Archived, Total Worlds, Steps, Avg LLM Latency.
Verification
- Backend: 68 unit tests pass.
- Frontend:
tsc --noEmit→ 0 errors.npm run build→ success (354 KB JS / 23 KB CSS, ~109 KB gzipped).
[1.0.5] — 2026-06-20
Fixed
- app/main.py: added
create_all_tables(engine)call at the start oflifespan. Without this, the backend started but crashed on every DB query withrelation "settings" does not existbecause tables were never created in PostgreSQL. Tables are now created idempotently on every startup viaBase.metadata.create_all( SQLAlchemy skips tables that already exist). - app/main.py: also added
seed_builtin_presets(session)call so the 2 builtin presets (Classic Fantasy, Deep Space Outpost) are seeded on first startup, not just settings. - app/migrations/versions/: renamed
001_initial_schema.py→_001_initial_schema.py. Python module names cannot start with a digit, sofrom app.migrations.versions.001_initial_schema import create_all_tableswas a SyntaxError. The leading underscore is a conventional marker for "internal" modules. - README.md: updated references to the renamed migration file.
Why this happened
The lifespan handler was supposed to run DB migrations as its first step, but I forgot to wire it up. The seed_default_settings() call immediately tried to SELECT FROM settings against a fresh PostgreSQL database with no tables. SQLAlchemy 2.x's create_all is idempotent (skips existing tables), so this is safe to call on every startup — equivalent to alembic upgrade head for our single-migration MVP.
[1.0.4] — 2026-06-20
Fixed (proper fix for CORS env parsing)
- app/config.py: replaced the broken
Annotated[list[str], NoDecode]approach with a simpler, more robust one:cors_originsis now declared as a plainstr(comma-separated, e.g."http://a,http://b"or"*").- Added a
cors_origins_listproperty that splits the string into alist[str]on demand. - This sidesteps the entire
EnvSettingsSource.decode_complex_value/ JSON-parsing codepath — pydantic-settings sees astrfield, takes the env value as-is, no JSON parsing attempted.
- app/main.py: updated
CORSMiddleware(allow_origins=cfg.cors_origins_list)to use the new property. - Why v1.0.3 didn't work: in pydantic-settings 2.7.0,
NoDecodeis importable but_annotation_is_complex()doesn't actually check for it (only checks forJson). The marker was added to the package surface but the inner logic was only wired up in a later version. Switching to a plainstrfield is the most reliable fix and works across all pydantic-settings 2.x versions. - All 68 unit tests still pass.
[1.0.3] — 2026-06-20
Fixed
- app/config.py: fixed
SettingsError: error parsing value for field "cors_origins" from source "EnvSettingsSource"that crashed the backend on startup whenCORS_ORIGINSwas set as a comma-separated string (e.g.http://localhost:8080,http://localhost:5173,http://localhost).- Root cause: pydantic-settings v2 by default tries to JSON-parse complex-typed env vars before applying field validators. The comma-separated string isn't valid JSON, so parsing failed before our
@field_validator(mode="before")could split it. - Fix: declared
cors_origins: Annotated[list[str], NoDecode]—NoDecodeis a pydantic-settings marker that disables JSON pre-parsing, so the raw string reaches our_split_corsvalidator unchanged.
- Root cause: pydantic-settings v2 by default tries to JSON-parse complex-typed env vars before applying field validators. The comma-separated string isn't valid JSON, so parsing failed before our
- requirements.txt: bumped
pydantic2.7.1 → 2.9.2 andpydantic-settings2.2.1 → 2.7.0. TheNoDecodeannotation was only introduced in pydantic-settings 2.6+, so the older versions couldn't support the fix above. All 68 unit tests still pass with the new versions.
[1.0.2] — 2026-06-20
Fixed
- requirements.txt: added missing
email-validator==2.2.0dependency. Pydantic'sEmailStrtype (used inRegisterRequest,AdminRegisterRequest,LoginRequest.user.email,UserPublic.email,TokenResponse.user.email) requires this package at runtime, but it's not bundled with pydantic itself. Without it the backend crashed at startup withImportError: email-validator is not installed, run pip install pydantic[email]. Also removed a duplicatehttpx==0.27.0line.
[1.0.1] — 2026-06-20
Fixed
- docker-compose.yml: removed obsolete
version: "3.9"(caused warning in modern Docker Compose). - docker-compose.yml: fixed frontend build context — was
./frontend(brokeCOPY deploy/nginx.confandCOPY frontend/package*.jsonin Dockerfile.frontend). Now correctly.(project root) withdockerfile: deploy/Dockerfile.frontend. - docker-compose.yml: fixed frontend port mapping — was
5173:5173but the frontend container is nginx on port 80. Now8080:80so the app is accessible athttp://localhost:8080. - docker-compose.yml: added
extra_hosts: ["host.docker.internal:host-gateway"]to the backend service — enablesLLM_API_URL=http://host.docker.internal:11434/v1to work on Linux hosts (not just Docker Desktop on Mac/Windows). - docker-compose.yml: added
VITE_API_BASE_URL: /apibuild arg for the frontend service — bakes the relative/apiURL into the Vite bundle so the browser uses the same origin + nginx proxies/api→backend:8000. - deploy/Dockerfile.frontend: added
ARG VITE_API_BASE_URL=/api+ENVso the build arg actually gets baked into the Vite bundle. - deploy/nginx.conf: extended SSE timeouts from 300s → 600s; added
proxy_send_timeout; addedUpgrade/Connectionheaders for future WebSocket support; added gzip for static assets. - .env.example: clarified
VITE_API_BASE_URL— only used by localnpm run dev, ignored by docker build (which uses/apirelative). Removed the misleadinghttp://localhost/apidefault. - frontend/src/lib/api.ts: now respects
VITE_API_BASE_URLenv var with fallback to relative/api. Works both for local dev (point at separate backend) and Docker (nginx proxy). - frontend/src/vite-env.d.ts: added Vite env type declarations so TypeScript knows about
import.meta.env.VITE_API_BASE_URL. - app/api/deps.py: added
?access_token=<jwt>query parameter fallback for SSE endpoints. NativeEventSourcecannot sendAuthorizationheaders, so the frontend SSE client passes the token via query string. Without this fix, all SSE endpoints (/iterate/stream,/builder/stream,/editor/stream) returned 401. - .dockerignore: added at project root — excludes
node_modules/,__pycache__/,.venv/,data/,.git/, etc. from Docker build contexts (faster builds, smaller context transfer).
[1.0.0] — 2026-06-20
Added — Sprint 1: Foundation
- docker-compose with 4 services: db (PostgreSQL 15), qdrant (1.9), backend (FastAPI/uvicorn), frontend (Vite dev / nginx prod).
- SQLAlchemy 2.x async models for all 10 tables:
users,settings,world_presets,worlds,entities,steps,step_tool_calls,deferred_triggers,story_entries,llm_call_logs. - Alembic-equivalent idempotent migration
001_initial_schema.py(creates all tables, no pgvector). init_qdrant.pycreates collectionsentitiesandstory_entrieswith payload indexes onworld_id,entity_type,entry_type,deleted,created_at.- Settings seed: 33 default keys (llm., embeddings., context., qdrant., game., ui., admin.setup_token).
- 2 builtin presets: Classic Fantasy, Deep Space Outpost.
GET /api/healthreturns{status, db, qdrant, llm, embeddings, version}.
Added — Sprint 2: Auth + Admin
POST /api/register(only if at least one admin exists),POST /api/register/admin?token=,POST /api/auth/login(email or username),POST /api/auth/refresh,POST /api/auth/logout,GET /api/auth/me.- JWT (access + refresh) with python-jose, password hashing with passlib[bcrypt].
- Password strength validation: ≥8 chars, ≥1 letter, ≥1 digit, blacklist of trivial passwords.
- Anti-enumeration: same
401 invalid_credentialsfor "user not found" and "wrong password". - Admin setup token: auto-generated on first startup, printed in logs.
GET/PATCH /api/admin/settingswith secret masking.GET /api/admin/llm-logswith filters (world_id, stage, status, pagination) + detail view.GET /api/admin/users,PATCH /api/admin/users/{id}(admin/active toggle).GET /api/admin/stats(users, worlds, steps, avg LLM latency).- Diagnostic endpoints:
POST /api/admin/test/llm,/test/llm-tools,/test/embeddings,/test/embeddings/probe-dimension,/embeddings/recreate-collections. POST /api/admin/upload-icon(multipart, favicon/logo/og_image, ≤1MB, PNG/SVG/JPG/WebP/ICO).
Added — Sprint 3: World Builder
LlmClient— OpenAI-compatible chat completions client with retry (3 attempts, exp backoff), streaming, tool-calling, separate-transaction logging.MockLlmClient— replay-based mock for tests/dev when no LLM configured.app/prompts/— 11 stage prompts in English (world_builder_schema/env/entities, world_editor, orchestrator_phase1/2/3_summary/3_suggest, intro_scene, subagent, summary).POST /api/worldscreates draft world + returns SSE URL.GET /api/sessions/worlds/{id}/builder/streamruns the 4-step builder flow: schemas → environment → entities (tool-calling loop) → intro scene (with submit_step + suggest_actions).- If preset_id provided, schemas/environment copied from preset (skip first LLM call).
Added — Sprint 4: World Editor
POST /api/worlds/{id}/editbody{instruction}→ SSE URL.GET /api/sessions/worlds/{id}/editor/stream?instruction=runs LLM with world_editor tools.propose_changestool returns diff,ask_useremits clarification event,comment_to_userfor chat.- Optimistic locking via
updated_atonPATCH /api/worlds/{id}(409 state_conflict on mismatch). PATCH /api/worlds/{id}for direct JSON edits.DELETE /api/worlds/{id}soft-delete (status='archived').
Added — Sprint 5: Orchestrator
POST /api/sessions/worlds/{id}/iteratebody{action, action_source}creates new step + returns SSE URL.GET /api/sessions/worlds/{id}/iterate/stream?step_id=runs the 3-phase orchestrator:- Phase 1: tool-calling loop with
submit_planterminator, max 8 substeps (configurable). - Phase 2: writer LLM call with
submit_step, streamsscene_chunkevents. - Phase 3: persist + deferred triggers + summary (if history > threshold) + suggest_actions.
- Phase 1: tool-calling loop with
POST /api/sessions/worlds/{id}/retrysoft-deletes last step + creates new one with same action.POST /api/sessions/worlds/{id}/rollbacksoft-deletes last step.- 17 game tools: entity_create/get/list/update/delete, env_update/get, update_plot_rails, advance_time, schedule_trigger, calc (with dice), random_choice (deterministic), rag_query/add, run_subagent, submit_plan, submit_step, suggest_actions.
- 4 schema tools: schema_add_type/add_field/remove_field/modify_field.
Added — Sprint 6: Frontend polish
- React 18 + Vite 5 + TypeScript 5 + Tailwind CSS 3 + zustand 4 + react-i18next 14.
- Dark theme by default (Tailwind
dark:class on<html>). - 8 pages: Login, Register, AdminRegister, WorldsList, WorldBuilder, WorldEdit, Play, Admin.
- 23 components: 9 UI primitives, 5 session, 3 worlds, 6 admin.
- 5 zustand stores: auth, ui, worlds, session, toast.
- i18n bundles in English + Russian (complete).
- Native EventSource SSE client with reconnect + Last-Event-ID.
- Mobile-first responsive layout.
- TypeScript strict mode, 0 type errors,
npm run buildsucceeds (340 KB JS / 22 KB CSS).
Added — Sprint 7: RAG + Triggers + Summary + Context
app/core/rag.py— two-stage retrieval: Qdrant vector search → PostgreSQL hydration.HashEmbedder(offline, deterministic) andOpenAIEmbedder(OpenAI-compatible API) implementations.- Embedder provider selection via
embeddings.providersetting (offline_hash|openai). - Auto-fallback:
embeddings.api_urlfalls back tollm.api_urlif empty (and same for api_key). - Embedder cache with
reset_embedder_cache()(called on settings update). rag_queryreturns empty list on embedder/Qdrant failure (non-blocking).rag_addsaves story entry withembedding_status='pending'if embedding fails (background indexer can retry).index_entityhelper for entity vectors.- World isolation via Qdrant payload filter
world_id. _cleanup_qdrant(world_id)deletes all points for a world on world delete (best-effort).- Deferred triggers: Phase 3.1 fires triggers where
fire_at <= current_time, appends summary to scene_text, marksis_fired=true. - Summary: Phase 3.2 generates summary when
len(recent_steps) > compression_threshold_messages, stores as StoryEntry withmetadata.type=summary. - Context manager: builds messages with system prompt + optional summary + last N guaranteed messages + current action.
Added — Sprint 8: Production
README.mdwith quickstart, architecture diagram, API overview, testing instructions.pytest.ini+ 65+ unit tests across 8 test files.CHANGELOG.md(this file).docker-compose.ymlwith healthchecks for db and qdrant.deploy/Dockerfile.backend,deploy/Dockerfile.frontend,deploy/nginx.conf..env.examplewith all 24 env vars documented..gitignorefor Python, Node, env, IDE, data dirs.- Frontend production build verified (
npm run buildproducesdist/).