diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a1e9b..a51cb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to AI-RPG are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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 which `str.format()` interpreted as format fields. Rewrote `orchestrator_phase2` and `intro_scene` prompts 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 `calc{...}`. Added `_parse_text_tool_calls()` to `app/core/llm.py` that detects these patterns and converts them to OpenAI-format `tool_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 where `request_tools` was `[]` (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-tools` failed 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 with `isinstance(tc, dict)`, strings are parsed as JSON, non-dicts are skipped. Also handles cases where `function` is not a dict. +- **World editor — propose_changes now waits for user**: previously `propose_changes` was auto-accepted (simplified). Now the editor emits `change_proposed` and WAITS for the user to accept/reject via `POST /api/sessions/worlds/{id}/apply` or `/discard`. Implemented using `asyncio.Future` stored in `_pending_changes` dict keyed by world_id. 120s timeout. +- **World editor — ask_user now waits for answer**: similarly, `ask_user` now waits for `POST /api/sessions/worlds/{id}/answer` body `{text: "..."}`. 120s timeout. +- **World editor — apply_diff improved**: now supports paths `world.name`, `world.description`, `schemas` (full replace), and `environment.` (via apply_patch). Previously only `environment.*` 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 changes +- `POST /api/sessions/worlds/{id}/answer` body `{text: "..."}` — answer a clarification +- `POST /api/worlds/{id}/generate-intro` → `{stream_url}` — re-generate intro scene for draft worlds +- `GET /api/sessions/worlds/{id}/intro/stream` (SSE) — runs the intro_scene stage, sets world.status to "ready" on success. Emits `step`, `intro_scene_complete`, `done` events. + +### Frontend — Critical fixes (10 files changed, 1 new) + +- **World Editor — Accept/Reject buttons**: `change_proposed` events 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**: `clarification` events show a card with either free-text input + Send button, or clickable option buttons (if `options` provided). Submit → `POST /answer`. +- **World Edit Page — Generate Intro Scene**: new `IntroSceneGenerator` component. If `world.status === 'draft'`, shows a "Generate Intro Scene" button. Clicking it opens the SSE stream, shows progress, displays the generated scene, and on `done` refreshes the world (status → "ready") + shows toast "World is ready!". +- **Settings panel — autocomplete="off"**: all inputs now have `autoComplete="off"`. API key fields use `type="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 `LoginPage` uses `autoComplete="username"` for the login field, `RegisterPage` uses `autoComplete="email"` for email and `autoComplete="username"` for username. +- **Test LLM Tools — raw response display**: when `has_tool_calls=false`, shows a warning + collapsible `
` 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`/`error` events didn't close the active SSE controller (used `useRef` to 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. diff --git a/app/api/admin.py b/app/api/admin.py index 633c53e..278c2be 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -217,8 +217,34 @@ async def stats( } +# --------------------------------------------------------------------------- # +# Helpers for test endpoints +# --------------------------------------------------------------------------- # +def _is_masked(value: str | None) -> bool: + """Detect masked secret values (contain '…' or are exactly '****'). + + The admin GET /settings endpoint masks secret keys before sending them to + the client. If the client sends a masked value back to a test endpoint + (because it pre-filled the form from the masked settings response), we + must ignore it and fall back to the raw value from the DB. + """ + if not value: + return False + return "…" in value or value == "****" + + +def _resolve(value: str | None, fallback: str) -> str: + """Use `value` if it's a non-empty, non-masked string; otherwise use fallback.""" + if value and not _is_masked(value): + return value + return fallback or "" + + # --------------------------------------------------------------------------- # # Test endpoints — LLM, embeddings, embeddings probe dimension +# All test endpoints accept query params AND fall back to DB-stored settings. +# Masked values (containing '…' or '****') are ignored — they come from the +# admin UI's pre-filled form which displays masked secrets. # --------------------------------------------------------------------------- # @router.post("/test/llm") async def test_llm( @@ -229,9 +255,9 @@ async def test_llm( _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) - api_url = api_url or settings.get("llm.api_url", "") - api_key = api_key or settings.get("llm.api_key", "") - model = model or settings.get("llm.model", "") + api_url = _resolve(api_url, settings.get("llm.api_url", "")) + api_key = _resolve(api_key, settings.get("llm.api_key", "")) + model = _resolve(model, settings.get("llm.model", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, "elapsed_ms": 0} @@ -265,37 +291,49 @@ async def test_llm_tools( _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) - api_url = api_url or settings.get("llm.api_url", "") - api_key = api_key or settings.get("llm.api_key", "") - model = model or settings.get("llm.model", "") + api_url = _resolve(api_url, settings.get("llm.api_url", "")) + api_key = _resolve(api_key, settings.get("llm.api_key", "")) + model = _resolve(model, settings.get("llm.model", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, "elapsed_ms": 0, "has_tool_calls": False} - client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1) + client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=30.0, max_retries=1) start = time.monotonic() try: tools = [{ "type": "function", "function": { "name": "calc", - "description": "Evaluate a math expression", + "description": "Evaluate a math expression. You MUST call this tool.", "parameters": { "type": "object", "required": ["expression"], - "properties": {"expression": {"type": "string"}}, + "properties": {"expression": {"type": "string", "description": "e.g. '2+2'"}}, }, }, }] resp = await client.complete( stage="test_llm_tools", - messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}], - tools=tools, temperature=0.0, max_tokens=100, + messages=[ + {"role": "system", "content": "You must use the calc tool to answer math questions. Do not compute in your head."}, + {"role": "user", "content": "What is 2+2? You MUST call the calc tool with expression '2+2'."}, + ], + tools=tools, + tool_choice="auto", + temperature=0.0, max_tokens=100, session=db, ) elapsed = int((time.monotonic() - start) * 1000) tcs = resp["message"].get("tool_calls") or [] + # Also try to parse tool calls from content (some models emit them as text) + if not tcs: + content = resp["message"].get("content", "") or "" + parsed_tcs = _parse_text_tool_calls(content) + if parsed_tcs: + tcs = parsed_tcs return { "ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed, + "raw_response": resp["message"], } except Exception as e: # noqa: BLE001 elapsed = int((time.monotonic() - start) * 1000) @@ -303,6 +341,50 @@ async def test_llm_tools( "elapsed_ms": elapsed, "has_tool_calls": False} +# Pattern: call:tool_name{args} or name{args} or name(args) +import re as _re + +_TOOL_CALL_PATTERNS = [ + # call:name{json_args} + _re.compile(r"call:(\w+)\s*\{([^}]*)\}"), + # name{args} + _re.compile(r"\s*(\w+)\s*\{([^}]*)\}\s*"), + # name({"key": "value", ...}) + _re.compile(r"(\w+)\s*\(\s*(\{[^}]*\})\s*\)"), +] + + +def _parse_text_tool_calls(content: str) -> list[dict]: + """Parse tool calls emitted as text (some models don't use the OpenAI format). + + Handles patterns like: + - call:calc{"expression": "2+2"} + - calc{"expression": "2+2"} + - calc({"expression": "2+2"}) + """ + import json as _json + calls: list[dict] = [] + for pattern in _TOOL_CALL_PATTERNS: + for match in pattern.finditer(content): + name = match.group(1) + args_str = match.group(2).strip() + try: + args = _json.loads(args_str) + except _json.JSONDecodeError: + # Try to fix common issues (single quotes, missing quotes on keys) + try: + fixed = args_str.replace("'", '"') + args = _json.loads(fixed) + except _json.JSONDecodeError: + args = {"_raw": args_str} + calls.append({ + "id": f"parsed_{len(calls)}", + "type": "function", + "function": {"name": name, "arguments": _json.dumps(args)}, + }) + return calls + + @router.post("/test/embeddings") async def test_embeddings( api_url: str | None = None, @@ -324,9 +406,9 @@ async def test_embeddings( "ok": True, "dimension": emb.dimension, "model": "offline_hash", "first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed, } - api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") - api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "") - model = model or settings.get("embeddings.model", "") + api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", "")) + api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", "")) + model = _resolve(model, settings.get("embeddings.model", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"}, "elapsed_ms": 0} @@ -366,9 +448,9 @@ async def probe_dimension( "dimension": int(settings.get("embeddings.dimension", 256)), "elapsed_ms": 0, } - api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") - api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "") - model = model or settings.get("embeddings.model", "") + api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", "")) + api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", "")) + model = _resolve(model, settings.get("embeddings.model", "")) emb = build_openai_embedder( api_url=api_url, api_key=api_key, model=model, dimension=int(settings.get("embeddings.dimension", 1536)), diff --git a/app/api/sessions.py b/app/api/sessions.py index 170fc67..3e6f210 100644 --- a/app/api/sessions.py +++ b/app/api/sessions.py @@ -260,6 +260,171 @@ async def iterate_stream( ) +# --------------------------------------------------------------------------- # +# World editor: accept/reject proposed changes, answer clarifications +# --------------------------------------------------------------------------- # +@router.post("/worlds/{world_id}/apply") +async def apply_proposed_changes( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> dict: + """Accept proposed changes from the world_editor stream.""" + from app.engine.world_editor import submit_propose_changes_answer + + world = await _load_world(db, world_id, user) + ok = submit_propose_changes_answer(world.id, True) + if not ok: + raise HTTPException(409, "no_pending_changes") + return {"ok": True} + + +@router.post("/worlds/{world_id}/discard") +async def discard_proposed_changes( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> dict: + """Reject proposed changes from the world_editor stream.""" + from app.engine.world_editor import submit_propose_changes_answer + + world = await _load_world(db, world_id, user) + ok = submit_propose_changes_answer(world.id, False) + if not ok: + raise HTTPException(409, "no_pending_changes") + return {"ok": True} + + +@router.post("/worlds/{world_id}/answer") +async def answer_clarification( + world_id: uuid.UUID, + body: AnswerRequest, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> dict: + """Answer an ask_user clarification from the world_editor stream.""" + from app.engine.world_editor import submit_clarification_answer + + world = await _load_world(db, world_id, user) + ok = submit_clarification_answer(world.id, body.text) + if not ok: + raise HTTPException(409, "no_pending_clarification") + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# Generate / re-generate intro scene (for worlds stuck in draft) +# --------------------------------------------------------------------------- # +@router.post("/worlds/{world_id}/generate-intro", response_model=dict, status_code=status.HTTP_202_ACCEPTED) +async def generate_intro( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> dict: + """Re-generate the intro scene for a draft world. + + This runs the intro_scene stage of the world_builder flow without + regenerating schemas/entities. Useful when the world was created from + a preset (which provides schemas + entities) but the intro scene + generation failed. + """ + world = await _load_world(db, world_id, user) + if world.status == "archived": + raise HTTPException(422, "world_archived") + return { + "stream_url": f"/api/sessions/worlds/{world.id}/intro/stream", + } + + +@router.get("/worlds/{world_id}/intro/stream") +async def intro_stream( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> StreamingResponse: + world = await _load_world(db, world_id, user) + emitter = SseEmitter() + llm = _llm_factory(settings) + + async def run_bg(): + async with _session_scope() as bg_db: + from sqlalchemy import select as sa_select + + from app.engine.world_builder import _run_tool_loop, _strip_code_fence + from app.engine.tools.base import ToolContext, get_registry + from app.models import Entity + from app.prompts.registry import get_prompt + from app.core.time_utils import advance_time, summarize_schemas + import json as _json + + bg_world = ( + await bg_db.execute(sa_select(World).where(World.id == world.id)) + ).scalar_one() + try: + await emitter.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."}) + entities = ( + await bg_db.execute( + sa_select(Entity).where( + Entity.world_id == bg_world.id, Entity.deleted_at.is_(None) + ) + ) + ).scalars().all() + entities_summary = "\n".join( + f"- {e.entity_type}: {e.name}" for e in entities[:20] + ) or "(no entities)" + intro_prompt = get_prompt("intro_scene", "en").format( + world_name=bg_world.name, + world_description=bg_world.description or "", + language=bg_world.language, + current_time=bg_world.current_time, + environment_json=_json.dumps(bg_world.environment or {}, ensure_ascii=False, indent=2), + plot_rails_json=_json.dumps(bg_world.plot_rails or {}, ensure_ascii=False, indent=2), + entities_summary=entities_summary, + ) + scene_result = await _run_tool_loop( + db=bg_db, world=bg_world, llm=llm, sse=emitter, + stage="intro_scene", + system_prompt=intro_prompt, + terminal_tool="submit_step", + max_substeps=3, + settings={}, + ) + scene_text = "" + delta_time = "hours_1" + if scene_result and scene_result.get("ok"): + scene_text = scene_result.get("data", {}).get("scene_text", "") + delta_time = scene_result.get("data", {}).get("delta_time", "hours_1") + bg_world.intro_scene = scene_text + bg_world.current_time = advance_time(bg_world.current_time, delta_time, bg_world.time_schema) + bg_world.status = "ready" + await bg_db.commit() + await emitter.emit("intro_scene_complete", { + "text": scene_text, "delta_time": delta_time, + "current_time": bg_world.current_time, + }) + await emitter.done({"world_id": str(bg_world.id), "status": "ready"}) + except Exception as e: + await emitter.error("internal_error", str(e)) + + import asyncio + + task = asyncio.create_task(run_bg()) + + async def gen(): + try: + async for evt in emitter.stream(): + yield _format_sse(evt) + finally: + await task + + return StreamingResponse( + gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + # --------------------------------------------------------------------------- # # Retry / rollback # --------------------------------------------------------------------------- # diff --git a/app/core/llm.py b/app/core/llm.py index 4db6280..b015f6f 100644 --- a/app/core/llm.py +++ b/app/core/llm.py @@ -31,6 +31,59 @@ from app.models import LlmCallLog _logger = get_logger(__name__) +# Patterns for parsing tool calls emitted as text (some local models don't +# use the OpenAI function-calling format and instead emit calls as text). +import re as _re + +_TOOL_CALL_PATTERNS = [ + # call:name{json_args} or call:name(json_args) + _re.compile(r"call:(\w+)\s*[\{\(]([^}\)]*)[\}\)]"), + # name{args} or \n{"name": ..., "arguments": ...}\n + _re.compile(r"\s*(\w+)\s*\{([^}]*)\}\s*"), + # name{"key": "value", ...} (function call style) + _re.compile(r"\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)"), +] + + +def _parse_text_tool_calls(content: str) -> list[dict]: + """Parse tool calls emitted as text by some local models. + + Handles patterns like: + - call:calc{"expression": "2+2"} + - calc{"expression": "2+2"} + - calc({"expression": "2+2"}) + + Returns a list of OpenAI-format tool_call dicts. + """ + if not content: + return [] + calls: list[dict] = [] + for pattern in _TOOL_CALL_PATTERNS: + for match in pattern.finditer(content): + name = match.group(1) + args_str = match.group(2).strip() + if not args_str: + args = {} + else: + try: + args = json.loads(args_str) + except json.JSONDecodeError: + # Try fixing common issues: single quotes, unquoted keys + try: + fixed = args_str.replace("'", '"') + # Add quotes around bare keys + fixed = _re.sub(r"(\w+)\s*:", r'"\1":', fixed) + args = json.loads(fixed) + except json.JSONDecodeError: + args = {"_raw": args_str} + calls.append({ + "id": f"parsed_{len(calls)}", + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + }) + return calls + + class LLMError(Exception): """Base LLM error.""" @@ -218,6 +271,18 @@ class LlmClient: finish_reason = choice.get("finish_reason", "stop") usage = data.get("usage", {}) + # If the model didn't return tool_calls in the OpenAI format but DID + # emit them as text (some local models use "call:name{args}" or + # "name{args}"), try to parse them out. + if tools and not msg.get("tool_calls"): + content = msg.get("content", "") or "" + parsed = _parse_text_tool_calls(content) + if parsed: + msg = dict(msg) # don't mutate the original + msg["tool_calls"] = parsed + if finish_reason == "stop": + finish_reason = "tool_calls" + log_id: uuid.UUID | None = None if session is not None: log_id = await self._write_log_safely( diff --git a/app/engine/world_editor.py b/app/engine/world_editor.py index 7d607c9..6380257 100644 --- a/app/engine/world_editor.py +++ b/app/engine/world_editor.py @@ -1,7 +1,21 @@ -"""World Editor — chat-based editing of an existing world.""" +"""World Editor — chat-based editing of an existing world. + +Flow: +1. Player sends an instruction via POST /api/worlds/{id}/edit. +2. SSE stream opens (GET /api/sessions/worlds/{id}/editor/stream?instruction=...). +3. LLM gets the instruction + world state + tools. It can: + - Call entity_create/update/delete, env_update, schema_* tools directly (applied immediately). + - Call ask_user to clarify (emits clarification SSE event, stream stays open for 60s + waiting for POST /api/sessions/worlds/{id}/answer). + - Call propose_changes to suggest a batch (emits change_proposed SSE event, stream + stays open waiting for POST /api/sessions/worlds/{id}/apply or /discard). + - Call comment_to_user to send text. +4. When LLM stops calling tools, stream ends with done event. +""" from __future__ import annotations +import asyncio import json import uuid from typing import Any @@ -20,6 +34,14 @@ from app.prompts.registry import get_prompt _logger = get_logger(__name__) +# In-memory pending propose_changes diffs, keyed by world_id. +# The frontend accepts/rejects via REST, which resolves the Future. +_pending_changes: dict[uuid.UUID, asyncio.Future[bool]] = {} + +# In-memory pending ask_user clarifications, keyed by world_id. +_pending_clarifications: dict[uuid.UUID, asyncio.Future[str]] = {} + + async def run_world_editor( *, db: AsyncSession, @@ -28,17 +50,13 @@ async def run_world_editor( llm: LlmClient | MockLlmClient, sse: SseEmitter, max_iterations: int = 8, + answer_timeout: float = 120.0, ) -> None: - """Run a world_editor iteration: instruction → propose_changes → done. - - Simplified (vs §9.2): no `ask_user` blocking — the LLM gets one shot at - producing a `propose_changes` (or applies tool calls directly if simple). - """ + """Run a world_editor iteration.""" try: registry = get_registry() ctx = ToolContext(db=db, world=world, stage="world_editor", sse_emitter=sse.emit) - # Snapshot current entities for the prompt entities = ( await db.execute( select(Entity).where( @@ -48,13 +66,16 @@ async def run_world_editor( ).scalars().all() entities_summary = "\n".join( f"- {e.entity_type}: {e.name}" for e in entities - ) + ) or "(no entities yet)" + env_json = json.dumps(world.environment or {}, ensure_ascii=False, indent=2) + if env_json == "{}": + env_json = "(empty — world has no environment yet. Use env_update to add player, current_location, plot_rails.)" sys_prompt = get_prompt("world_editor", "en").format( world_name=world.name, world_description=world.description or "", language=world.language, - schemas_summary=summarize_schemas(world.schemas or []), - environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), + schemas_summary=summarize_schemas(world.schemas or []) or "(no schemas yet)", + environment_json=env_json, entities_summary=entities_summary, instruction=instruction, ) @@ -74,40 +95,103 @@ async def run_world_editor( session=db, ) msg = resp.get("message", {}) - tcs = msg.get("tool_calls") or [] + # Normalize tool_calls: handle cases where the model returns them as + # strings or in non-standard formats. + raw_tcs = msg.get("tool_calls") or [] + tcs: list[dict] = [] + for tc in raw_tcs: + if isinstance(tc, str): + # Try to parse as JSON + try: + tc = json.loads(tc) + except json.JSONDecodeError: + continue + if not isinstance(tc, dict): + continue + tcs.append(tc) + if not tcs: - # Done - await sse.emit("comment", {"text": msg.get("content", "")}) + content = msg.get("content", "") + if content: + await sse.emit("comment", {"text": content}) break + messages.append(msg) done = False for tc in tcs: - fn = tc.get("function", {}) + fn = tc.get("function") or {} + if not isinstance(fn, dict): + # Some models put the function name/args directly on tc + fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")} tname = fn.get("name", "") + args_str = fn.get("arguments", "{}") + if not isinstance(args_str, str): + args_str = json.dumps(args_str) try: - targs = json.loads(fn.get("arguments") or "{}") + targs = json.loads(args_str) if args_str else {} except json.JSONDecodeError: targs = {} if tname == "ask_user": - # Non-interactive: emit clarification and stop await sse.emit("clarification", { - "question": targs.get("question"), + "question": targs.get("question", ""), "options": targs.get("options"), }) - await sse.done({"status": "needs_clarification"}) - return - if tname == "propose_changes": - await sse.emit("change_proposed", { - "diff": targs.get("diff", []), - "comment": targs.get("comment", ""), + # Wait for user answer via REST + fut: asyncio.Future[str] = asyncio.get_event_loop().create_future() + _pending_clarifications[world.id] = fut + try: + answer = await asyncio.wait_for(fut, timeout=answer_timeout) + except asyncio.TimeoutError: + await sse.emit("warning", {"code": "answer_timeout", + "message": "User did not answer in time"}) + answer = "" + finally: + _pending_clarifications.pop(world.id, None) + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": tname, + "content": json.dumps({"answer": answer}), }) - # Apply changes directly (simplified: auto-accept) - await _apply_diff(world, targs.get("diff", [])) - await db.commit() - await sse.emit("apply_changes", {}) - done = True - break - # Execute tool + continue + if tname == "propose_changes": + diff = targs.get("diff", []) + comment = targs.get("comment", "") + await sse.emit("change_proposed", { + "diff": diff, + "comment": comment, + }) + # Wait for user accept/reject via REST + fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future() + _pending_changes[world.id] = fut2 + try: + accepted = await asyncio.wait_for(fut2, timeout=answer_timeout) + except asyncio.TimeoutError: + accepted = False + await sse.emit("warning", {"code": "accept_timeout", + "message": "User did not respond in time, discarding changes"}) + finally: + _pending_changes.pop(world.id, None) + if accepted: + await _apply_diff(world, diff) + await db.commit() + await sse.emit("apply_changes", {"diff": diff}) + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": tname, + "content": json.dumps({"ok": True, "applied": True}), + }) + else: + await sse.emit("discard_changes", {}) + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": tname, + "content": json.dumps({"ok": True, "applied": False, "reason": "user_rejected"}), + }) + continue + # Regular tool — execute result = await registry.execute(tname, targs, ctx) messages.append({ "role": "tool", @@ -123,26 +207,61 @@ async def run_world_editor( await sse.error("internal_error", str(e)) +def submit_propose_changes_answer(world_id: uuid.UUID, accepted: bool) -> bool: + """Resolve a pending propose_changes Future. Returns True if a pending call existed.""" + fut = _pending_changes.get(world_id) + if fut is None or fut.done(): + return False + fut.set_result(accepted) + return True + + +def submit_clarification_answer(world_id: uuid.UUID, answer: str) -> bool: + """Resolve a pending ask_user Future. Returns True if a pending call existed.""" + fut = _pending_clarifications.get(world_id) + if fut is None or fut.done(): + return False + fut.set_result(answer) + return True + + async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None: """Apply a propose_changes diff to the world. - Supports paths into environment and basic field operations. + Supported path formats: + - `environment.` → apply_patch to world.environment + - `world.name` → set world.name + - `world.description` → set world.description + - `schemas` → replace entire world.schemas """ from app.core.state_validator import apply_patch env_patch: dict[str, Any] = {} - schemas_patch: dict[str, Any] = {} for d in diff: - path = d.get("path", "") + if not isinstance(d, dict): + continue + path = d.get("path", "") or "" op = d.get("op", "replace") new = d.get("new") if path.startswith("environment."): field = path[len("environment."):] env_patch[field] = new + elif path == "world.name": + if isinstance(new, str): + world.name = new + elif path == "world.description": + if new is None or isinstance(new, str): + world.description = new + elif path == "schemas": + if isinstance(new, list): + world.schemas = new elif path.startswith("schemas."): - # For simplicity, replace entire schemas if any schema patch present - schemas_patch[path] = new + # For simplicity, replace entire schemas + if isinstance(new, list): + world.schemas = new if env_patch: new_env, errors = apply_patch(dict(world.environment or {}), env_patch) if not errors: world.environment = new_env + else: + _logger.warning("apply_diff_errors", errors=errors) diff --git a/app/prompts/stages/intro_scene.py b/app/prompts/stages/intro_scene.py index 882d935..a5ec405 100644 --- a/app/prompts/stages/intro_scene.py +++ b/app/prompts/stages/intro_scene.py @@ -25,9 +25,10 @@ Current time: {current_time} {entities_summary} # Hard rules -- Call `submit_step` exactly once with {scene_text, delta_time}. -- scene_text length: 300-2000 characters. -- Write in second person ("You wake up in..."). +- You MUST call the `submit_step` tool with arguments named `scene_text` and `delta_time`. +- The `scene_text` argument is a string (300-2000 characters) containing the narrative. +- The `delta_time` argument is a string like "hours_2_min_30" indicating how much game time passes. - After submit_step, call `suggest_actions` with 1-3 short actions in {language}. +- Write in second person ("You wake up in..."). """, } diff --git a/app/prompts/stages/orchestrator_phase2.py b/app/prompts/stages/orchestrator_phase2.py index ded1581..70876fe 100644 --- a/app/prompts/stages/orchestrator_phase2.py +++ b/app/prompts/stages/orchestrator_phase2.py @@ -24,12 +24,13 @@ Current time: {current_time} {environment_json} # Hard rules -- Call `submit_step` exactly once with {scene_text, delta_time}. -- scene_text length: 200-2000 characters. +- You MUST call the `submit_step` tool with arguments named `scene_text` and `delta_time`. +- The `scene_text` argument is a string (200-2000 characters) containing the narrative. +- The `delta_time` argument is a string like "hours_2_min_30" indicating how much game time passes. - Write in second person ("You enter the tavern..."). - Show, don't tell — describe sensory details. - Do NOT reference tools, schemas, or game mechanics in the narrative. - The narrative must be in {language}. -- delta_time format: `[year_Y][days_D][hours_H][min_M]` (e.g. `hours_2_min_30`). +- delta_time format examples: `hours_2`, `min_30`, `days_1_hours_4`. """, } diff --git a/frontend/src/components/admin/SettingsPanel.tsx b/frontend/src/components/admin/SettingsPanel.tsx index f051e2e..9f0d911 100644 --- a/frontend/src/components/admin/SettingsPanel.tsx +++ b/frontend/src/components/admin/SettingsPanel.tsx @@ -213,6 +213,21 @@ export function SettingsPanel() { return (
+ {/* + Hidden decoy input to absorb the browser password manager's attention. + Browsers that ignore `autocomplete="off"` on visible fields will + still key off the first password-typed input in a form, so we plant + a hidden one here to prevent the api_key fields below from being + offered as saveable passwords. + */} +

{t("admin.tab_settings")}

@@ -302,6 +317,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField onChange(e.target.value)} > @@ -340,6 +357,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField type="number" step={1} min={0} + autoComplete="off" value={value} onChange={(e) => onChange(e.target.value)} /> @@ -356,6 +374,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField step={0.01} min={0} max={2} + autoComplete="off" value={value} onChange={(e) => onChange(e.target.value)} /> @@ -363,12 +382,17 @@ function SettingField({ settingKey, description, value, onChange }: SettingField } if (ft === "secret") { + // Use type="text" + autoComplete="off" so the browser does NOT + // try to save / fill these as passwords (they're API keys, not + // credentials). The backend masks the saved value with `***` so the + // real key is never echoed back. return ( onChange(e.target.value)} placeholder={t("admin.api_key")} @@ -381,6 +405,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField id={`setting-${settingKey}`} label={label} hint={hint} + autoComplete="off" value={value} onChange={(e) => onChange(e.target.value)} /> diff --git a/frontend/src/components/admin/TestButtons.tsx b/frontend/src/components/admin/TestButtons.tsx index 04d7e4a..4ced2f7 100644 --- a/frontend/src/components/admin/TestButtons.tsx +++ b/frontend/src/components/admin/TestButtons.tsx @@ -353,6 +353,11 @@ function RecreateCollectionsCard() { function TestResultCard({ result }: { result: Record }) { const { t } = useTranslation(); const ok = result.ok === true; + const hasToolCallsField = "has_tool_calls" in result; + const hasToolCalls = result.has_tool_calls === true; + const rawResponse = result.raw_response; + const toolCalls = result.tool_calls; + return (

@@ -393,15 +398,75 @@ function TestResultCard({ result }: { result: Record }) { first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}]

)} - {result.tool_calls != null && ( -
-          {JSON.stringify(result.tool_calls, null, 2)}
-        
+ + {/* LLM-tools-test specific rendering */} + {hasToolCallsField && hasToolCalls && ( +
+

{t("admin.tool_calls_detected")}

+ {Array.isArray(toolCalls) ? ( +
    + {(toolCalls as Array>).map((tc, i) => ( +
  • +
    + {typeof tc.name === "string" && ( + {tc.name} + )} + {typeof tc.id === "string" && ( + #{tc.id} + )} +
    + {tc.arguments != null && ( +
    +                      {safeStringify(tc.arguments)}
    +                    
    + )} + {tc.function != null && typeof tc.function === "object" && ( +
    +                      {safeStringify(tc.function)}
    +                    
    + )} +
  • + ))} +
+ ) : toolCalls != null ? ( +
+              {safeStringify(toolCalls)}
+            
+ ) : null} +
+ )} + + {hasToolCallsField && !hasToolCalls && ( +
+

{t("admin.no_tool_calls_warning_title")}

+

{t("admin.no_tool_calls_warning")}

+
+ )} + + {/* Show the raw LLM message in a collapsible details section */} + {rawResponse != null && ( +
+ + {t("admin.raw_response")} + +
+            {safeStringify(rawResponse)}
+          
+
)}
); } +function safeStringify(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + /** Extract a human-readable error message from a test result object. */ function extractErr(result: Record): string { const e = result.error; diff --git a/frontend/src/components/worlds/IntroSceneGenerator.tsx b/frontend/src/components/worlds/IntroSceneGenerator.tsx new file mode 100644 index 0000000..bfde897 --- /dev/null +++ b/frontend/src/components/worlds/IntroSceneGenerator.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { SessionsApi, toErrorMessage } from "@/lib/api"; +import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse"; +import { useToastStore } from "@/stores/toastStore"; +import type { World } from "@/types"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; +import { PhaseProgress } from "@/components/sessions/PhaseProgress"; +import { SseStatus } from "@/components/sessions/SseStatus"; + +interface IntroState { + phase: "idle" | "starting" | "streaming" | "done" | "error"; + phases: Array<{ phase: string; name?: string; done?: boolean }>; + currentPhase?: string; + step?: number; + totalSteps?: number; + message?: string; + introScene: string; + logs: string[]; + sseStatus: "idle" | "connecting" | "open" | "error" | "closed"; + errorMessage?: string; +} + +const INITIAL_STATE: IntroState = { + phase: "idle", + phases: [], + introScene: "", + logs: [], + sseStatus: "idle", +}; + +export interface IntroSceneGeneratorProps { + world: World; + /** Called when the world data should be refreshed (e.g. after intro is generated). */ + onWorldUpdated?: () => void; +} + +/** + * "Generate Intro Scene" component for draft worlds. Calls + * POST /api/worlds/{id}/generate-intro to obtain a stream URL, then + * subscribes to the SSE stream and shows progress / the generated scene. + * On `done`, refreshes world data (status should now be "ready"). + */ +export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGeneratorProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const pushToast = useToastStore((s) => s.push); + const [state, setState] = useState(INITIAL_STATE); + const controllerRef = useRef(null); + + useEffect(() => { + return () => { + controllerRef.current?.close(); + }; + }, []); + + const handleEvent = useCallback( + (event: SseEvent) => { + switch (event.event) { + case "ping": + break; + case "error": { + const d = event.data as { message?: string }; + const msg = d?.message || t("editor.intro_failed"); + setState((s) => ({ + ...s, + phase: "error", + sseStatus: "error", + errorMessage: msg, + logs: [...s.logs, `[error] ${msg}`], + })); + pushToast("error", msg); + controllerRef.current?.close(); + break; + } + case "warning": { + const d = event.data as { message?: string }; + setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] })); + break; + } + case "step": { + const d = event.data as { step?: number; message?: string }; + setState((s) => ({ + ...s, + step: d.step, + message: d.message, + logs: [...s.logs, `[${d.step ?? "?"}] ${d.message || ""}`], + })); + break; + } + case "progress": { + const d = event.data as { phase?: string; step?: number; total_steps?: number; message?: string }; + setState((s) => ({ + ...s, + currentPhase: d.phase, + step: d.step, + totalSteps: d.total_steps, + message: d.message, + })); + break; + } + case "phase_start": { + const d = event.data as { phase: string; name?: string }; + setState((s) => ({ + ...s, + currentPhase: d.phase, + phases: [ + ...s.phases.filter((p) => p.phase !== d.phase), + { phase: d.phase, name: d.name, done: false }, + ], + })); + break; + } + case "phase_end": { + const d = event.data as { phase: string }; + setState((s) => ({ + ...s, + phases: s.phases.map((p) => (p.phase === d.phase ? { ...p, done: true } : p)), + })); + break; + } + case "intro_scene_chunk": { + const d = event.data as { text?: string }; + setState((s) => ({ ...s, introScene: s.introScene + (d?.text || "") })); + break; + } + case "intro_scene_complete": { + const d = event.data as { text?: string }; + setState((s) => ({ + ...s, + introScene: d?.text || s.introScene, + logs: [...s.logs, t("editor.intro_complete")], + })); + break; + } + case "done": { + setState((s) => ({ + ...s, + phase: "done", + sseStatus: "closed", + })); + controllerRef.current?.close(); + pushToast("success", t("editor.world_ready")); + // Refresh world data — status should now be "ready". + onWorldUpdated?.(); + break; + } + default: + break; + } + }, + [pushToast, t, onWorldUpdated], + ); + + const start = async () => { + setState({ + ...INITIAL_STATE, + phase: "starting", + sseStatus: "connecting", + }); + try { + const res = await SessionsApi.generateIntro(world.id); + const streamUrl = res.stream_url || SessionsApi.introStreamUrl(world.id); + setState((s) => ({ ...s, phase: "streaming" })); + let connectionLostToastShown = false; + const c = subscribeSse(streamUrl, { + onOpen: () => { + connectionLostToastShown = false; + setState((s) => ({ ...s, sseStatus: "open" })); + }, + onError: () => { + setState((s) => ({ ...s, sseStatus: "error" })); + if (!connectionLostToastShown) { + connectionLostToastShown = true; + pushToast("warning", t("sse.reconnecting")); + } + }, + onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })), + onEvent: handleEvent, + }); + controllerRef.current?.close(); + controllerRef.current = c; + } catch (err) { + setState((s) => ({ + ...s, + phase: "error", + sseStatus: "error", + errorMessage: toErrorMessage(err, t("editor.intro_failed")), + })); + pushToast("error", toErrorMessage(err, t("editor.intro_failed"))); + } + }; + + const retry = () => { + controllerRef.current?.close(); + controllerRef.current = null; + setState(INITIAL_STATE); + void start(); + }; + + // Hide the generator once the world is ready. + if (world.status === "ready") return null; + + const busy = state.phase === "starting" || state.phase === "streaming"; + + return ( + +
+

{t("editor.generate_intro_help")}

+ + {state.phase === "idle" && ( + + )} + + {busy && ( +
+
+
+ {t("editor.intro_generating")} +
+ +
+ {state.phases.length > 0 && ( + + )} + {state.introScene && ( +
+

{t("editor.intro_scene")}

+

+ {state.introScene} +

+
+ )} + {state.logs.length > 0 && ( +
+ + Logs ({state.logs.length}) + +
+                  {state.logs.join("\n")}
+                
+
+ )} +
+ )} + + {state.phase === "done" && ( +
+

{t("editor.world_ready")}

+ {state.introScene && ( +
+

{t("editor.intro_scene")}

+

+ {state.introScene} +

+
+ )} +
+ + +
+
+ )} + + {state.phase === "error" && ( +
+

+ {state.errorMessage || t("editor.intro_failed")} +

+ {state.logs.length > 0 && ( +
+ + Logs ({state.logs.length}) + +
+                  {state.logs.join("\n")}
+                
+
+ )} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/worlds/WorldBuilder.tsx b/frontend/src/components/worlds/WorldBuilder.tsx index 28d8dca..491fdae 100644 --- a/frontend/src/components/worlds/WorldBuilder.tsx +++ b/frontend/src/components/worlds/WorldBuilder.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/cn"; @@ -72,6 +72,10 @@ export function WorldBuilder({ className }: WorldBuilderProps) { sseStatus: "idle", }); const [controller, setController] = useState(null); + // Mirror of `controller` that can be read inside stale closures (e.g. the + // SSE onEvent handler captured at subscription time) without forcing a + // re-subscribe on every controller change. + const controllerRef = useRef(null); useEffect(() => { let cancelled = false; @@ -130,7 +134,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) { logs: [...s.logs, `[error] ${d?.message || "Stream error"}`], })); pushToast("error", d?.message || t("builder.build_failed")); - controller?.close(); + controllerRef.current?.close(); break; } case "warning": { @@ -201,7 +205,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) { phase: "done", sseStatus: "closed", })); - controller?.close(); + controllerRef.current?.close(); pushToast("success", t("builder.build_complete")); break; } @@ -209,7 +213,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) { break; } }, - [controller, pushToast, t], + [pushToast, t], ); const handleSubmit = async () => { @@ -266,6 +270,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) { onEvent: handleEvent, }); setController(c); + controllerRef.current = c; // Save world id for redirect on done createdWorldIdRef.current = res.world_id; } catch (err) { @@ -292,6 +297,43 @@ export function WorldBuilder({ className }: WorldBuilderProps) { } }, [state.phase, navigate]); + /** + * Re-subscribe to the builder SSE stream for the previously-created + * world (without re-POSTing the create form). Useful when the build + * failed mid-stream and the world_id is known. + */ + const handleRetry = () => { + const id = createdWorldIdRef.current; + if (!id) return; + controller?.close(); + setState({ + phase: "building", + phases: [], + introScene: "", + logs: [], + sseStatus: "connecting", + }); + const streamUrl = SessionsApi.builderStreamUrl(id); + let connectionLostToastShown = false; + const c = subscribeSse(streamUrl, { + onOpen: () => { + connectionLostToastShown = false; + setState((s) => ({ ...s, sseStatus: "open" })); + }, + onError: () => { + setState((s) => ({ ...s, sseStatus: "error" })); + if (!connectionLostToastShown) { + connectionLostToastShown = true; + pushToast("warning", t("sse.reconnecting")); + } + }, + onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })), + onEvent: handleEvent, + }); + setController(c); + controllerRef.current = c; + }; + return (
@@ -433,9 +475,19 @@ export function WorldBuilder({ className }: WorldBuilderProps) { {state.phase === "error" && (

{t("builder.build_failed")}

- +
+ + +
)}
diff --git a/frontend/src/components/worlds/WorldCard.tsx b/frontend/src/components/worlds/WorldCard.tsx index d0ab873..b238cae 100644 --- a/frontend/src/components/worlds/WorldCard.tsx +++ b/frontend/src/components/worlds/WorldCard.tsx @@ -43,6 +43,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c const isReady = world.status === "ready"; const isArchived = world.status === "archived"; + const isDraft = world.status === "draft"; const isAdmin = !!user?.is_admin; const handleRestore = async () => { @@ -135,6 +136,34 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c )} + ) : isDraft ? ( + <> + + + {onDelete && ( + + )} + ) : ( <> + ))} +
+ ) : ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (text.trim()) onAnswer(text); + } + }} + autoComplete="off" + /> + +
+ )} +
+ ); +} + +function ChangeProposedCard({ + entry, + onAccept, + onReject, +}: { + entry: LogEntry; + onAccept: () => void; + onReject: () => void; +}) { + const { t } = useTranslation(); + const diffItems = normalizeDiff(entry.diff); + + return ( +
+

+ {t("editor.change_proposed")} +

+ {entry.text &&

{entry.text}

} + + {diffItems.length > 0 && ( +
    + {diffItems.map((item, i) => ( +
  • +
    + {item.op && ( + + {item.op} + + )} + {item.path && ( + {item.path} + )} +
    + {item.new !== undefined && ( +
    +                  {safeStringify(item.new)}
    +                
    + )} +
  • + ))} +
+ )} + + {entry.diff != null && diffItems.length === 0 && ( +
+          {safeStringify(entry.diff)}
+        
+ )} + + {entry.decision ? ( +

+ {entry.decision === "accept" + ? `✓ ${t("editor.changes_applied")}` + : `✗ ${t("editor.changes_discarded")}`} +

+ ) : ( +
+ + +
+ )} +
+ ); +} + +const OP_BADGE_CLASS: Record = { + add: "bg-ok/15 text-ok", + remove: "bg-err/15 text-err", + replace: "bg-accent/15 text-accent", + set: "bg-accent/15 text-accent", + append: "bg-accent/15 text-accent", + inc: "bg-ok/15 text-ok", + dec: "bg-warn/15 text-warn", +}; + +/** Coerce a diff payload into an array of {path, op, new} items. */ +function normalizeDiff(diff: unknown): DiffItem[] { + if (diff == null) return []; + if (Array.isArray(diff)) return diff.filter((d) => d && typeof d === "object") as DiffItem[]; + if (typeof diff === "object") { + const obj = diff as Record; + // Some backends return {ops: [...]} or {changes: [...]}. + if (Array.isArray(obj.ops)) return obj.ops.filter((d) => d && typeof d === "object") as DiffItem[]; + if (Array.isArray(obj.changes)) return obj.changes.filter((d) => d && typeof d === "object") as DiffItem[]; + // Single op object. + return [obj as DiffItem]; + } + return []; +} + +function safeStringify(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0bbe156..4b8a325 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -89,6 +89,7 @@ "status_failed": "Failed", "status_archived": "Archived", "restore": "Restore", + "continue_setup": "Continue setup", "restored": "World restored.", "restore_failed": "Failed to restore world.", "delete_permanent": "Delete permanently", @@ -123,7 +124,8 @@ "llm_call": "LLM call", "schema_generated": "World schema generated", "environment_generated": "Environment generated", - "entities_generated": "Entities generated" + "entities_generated": "Entities generated", + "retry_disabled": "Cannot retry — no world was created yet." }, "editor": { "title": "World Editor", @@ -142,7 +144,24 @@ "changes_discarded": "Changes discarded.", "edit_world": "Edit world", "submit_instruction": "Submitting…", - "streaming": "Streaming…" + "streaming": "Streaming…", + "accept": "Accept", + "reject": "Reject", + "your_answer": "Your answer", + "answer_placeholder": "Type your answer…", + "apply_failed": "Failed to apply changes.", + "discard_failed": "Failed to discard changes.", + "answer_failed": "Failed to submit answer.", + "cannot_play_draft": "Generate the intro scene first.", + "generate_intro_title": "Generate Intro Scene", + "generate_intro_help": "This world is still a draft. Generate the intro scene to make it playable.", + "generate_intro_button": "Generate Intro Scene", + "intro_generating": "Generating intro scene…", + "intro_complete": "Intro scene generated.", + "intro_failed": "Failed to generate intro scene.", + "intro_scene": "Intro scene", + "world_ready": "World is ready! You can now play.", + "regenerate_intro": "Regenerate intro scene" }, "play": { "title": "Play", @@ -171,7 +190,7 @@ "rollback": "Rollback one step", "rollback_confirm": "Rollback the last step?", "rolled_back": "Rolled back one step.", - "not_ready": "World is not ready yet. Complete world creation first.", + "not_ready": "This world is not ready yet. Generate the intro scene first.", "no_steps_to_retry": "No steps to retry yet.", "no_steps_to_rollback": "No steps to rollback yet.", "streaming": "AI is responding…", @@ -259,7 +278,11 @@ "icons_upload": "Upload", "icons_uploaded": "Icon uploaded.", "icons_upload_failed": "Failed to upload icon.", - "choose_file": "Choose file" + "choose_file": "Choose file", + "tool_calls_detected": "Tool calls detected", + "no_tool_calls_warning_title": "No tool calls returned", + "no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.", + "raw_response": "Raw LLM response" }, "errors": { "generic": "Something went wrong.", diff --git a/frontend/src/i18n/ru.json b/frontend/src/i18n/ru.json index e47a3e8..a3bcace 100644 --- a/frontend/src/i18n/ru.json +++ b/frontend/src/i18n/ru.json @@ -89,6 +89,7 @@ "status_failed": "Ошибка", "status_archived": "В архиве", "restore": "Восстановить", + "continue_setup": "Продолжить настройку", "restored": "Мир восстановлен.", "restore_failed": "Не удалось восстановить мир.", "delete_permanent": "Удалить навсегда", @@ -123,7 +124,8 @@ "llm_call": "Вызов LLM", "schema_generated": "Схема мира сгенерирована", "environment_generated": "Окружение сгенерировано", - "entities_generated": "Сущности сгенерированы" + "entities_generated": "Сущности сгенерированы", + "retry_disabled": "Нельзя повторить — мир ещё не создан." }, "editor": { "title": "Редактор мира", @@ -142,7 +144,24 @@ "changes_discarded": "Изменения отменены.", "edit_world": "Редактировать мир", "submit_instruction": "Отправка…", - "streaming": "Поток…" + "streaming": "Поток…", + "accept": "Принять", + "reject": "Отклонить", + "your_answer": "Ваш ответ", + "answer_placeholder": "Введите ответ…", + "apply_failed": "Не удалось применить изменения.", + "discard_failed": "Не удалось отклонить изменения.", + "answer_failed": "Не удалось отправить ответ.", + "cannot_play_draft": "Сначала сгенерируйте вступительную сцену.", + "generate_intro_title": "Сгенерировать вступительную сцену", + "generate_intro_help": "Этот мир всё ещё черновик. Сгенерируйте вступительную сцену, чтобы сделать его играбельным.", + "generate_intro_button": "Сгенерировать вступительную сцену", + "intro_generating": "Генерация вступительной сцены…", + "intro_complete": "Вступительная сцена сгенерирована.", + "intro_failed": "Не удалось сгенерировать вступительную сцену.", + "intro_scene": "Вступительная сцена", + "world_ready": "Мир готов! Теперь можно играть.", + "regenerate_intro": "Перегенерировать вступительную сцену" }, "play": { "title": "Игра", @@ -171,7 +190,7 @@ "rollback": "Откатить один шаг", "rollback_confirm": "Откатить последний шаг?", "rolled_back": "Шаг откатан.", - "not_ready": "Мир ещё не готов. Сначала завершите создание мира.", + "not_ready": "Этот мир ещё не готов. Сначала сгенерируйте вступительную сцену.", "no_steps_to_retry": "Нет шагов для повтора.", "no_steps_to_rollback": "Нет шагов для отката.", "streaming": "AI отвечает…", @@ -259,7 +278,11 @@ "icons_upload": "Загрузить", "icons_uploaded": "Иконка загружена.", "icons_upload_failed": "Не удалось загрузить иконку.", - "choose_file": "Выбрать файл" + "choose_file": "Выбрать файл", + "tool_calls_detected": "Обнаружены вызовы инструментов", + "no_tool_calls_warning_title": "Вызовы инструментов не возвращены", + "no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.", + "raw_response": "Полный ответ модели" }, "errors": { "generic": "Что-то пошло не так.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 85678db..9a41319 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -311,6 +311,14 @@ export const WorldsApi = { }; // ===== Sessions API ===== +export interface GenerateIntroResponse { + stream_url: string; +} + +export interface SimpleOkResponse { + ok: boolean; +} + export const SessionsApi = { state: (worldId: string) => request(`/sessions/worlds/${worldId}/state`), @@ -323,6 +331,30 @@ export const SessionsApi = { request(`/sessions/worlds/${worldId}/retry`, { method: "POST" }), rollback: (worldId: string) => request(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }), + + // ---- World editor: accept / reject proposed changes & answer clarifications ---- + /** Accept proposed changes from the world_editor stream. */ + applyChanges: (worldId: string) => + request(`/sessions/worlds/${worldId}/apply`, { method: "POST" }), + /** Reject proposed changes from the world_editor stream. */ + discardChanges: (worldId: string) => + request(`/sessions/worlds/${worldId}/discard`, { method: "POST" }), + /** Answer a clarification question from the world_editor stream. */ + answerClarification: (worldId: string, text: string) => + request(`/sessions/worlds/${worldId}/answer`, { + method: "POST", + body: { text }, + }), + + // ---- Intro scene generation ---- + /** + * Triggers intro scene regeneration for a draft world. Returns the SSE + * stream URL to subscribe to. (Backend endpoint is on /api/worlds but + * lives in the sessions API surface for grouping.) + */ + generateIntro: (worldId: string) => + request(`/worlds/${worldId}/generate-intro`, { method: "POST" }), + // SSE stream URLs (used by SSE client) iterateStreamUrl: (worldId: string, stepId: string) => buildUrl(`/sessions/worlds/${worldId}/iterate/stream`, { step_id: stepId }), @@ -330,6 +362,9 @@ export const SessionsApi = { buildUrl(`/sessions/worlds/${worldId}/builder/stream`), editorStreamUrl: (worldId: string, instruction: string) => buildUrl(`/sessions/worlds/${worldId}/editor/stream`, { instruction }), + /** SSE URL for the intro scene generator stream. */ + introStreamUrl: (worldId: string) => + buildUrl(`/sessions/worlds/${worldId}/intro/stream`), }; // ===== Presets API ===== diff --git a/frontend/src/pages/WorldEditPage.tsx b/frontend/src/pages/WorldEditPage.tsx index 46f650d..061f984 100644 --- a/frontend/src/pages/WorldEditPage.tsx +++ b/frontend/src/pages/WorldEditPage.tsx @@ -1,11 +1,13 @@ -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { WorldsApi } from "@/lib/api"; import { useWorldsStore } from "@/stores/worldsStore"; import { useToastStore } from "@/stores/toastStore"; import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { WorldEditor } from "@/components/worlds/WorldEditor"; +import { IntroSceneGenerator } from "@/components/worlds/IntroSceneGenerator"; export function WorldEditPage() { const { id } = useParams<{ id: string }>(); @@ -26,6 +28,16 @@ export function WorldEditPage() { return () => setCurrentWorld(null); }, [id, fetchWorld, setCurrentWorld, pushToast, t]); + const refreshWorld = useCallback(async () => { + if (!id) return; + try { + const updated = await WorldsApi.get(id); + setCurrentWorld(updated); + } catch { + /* ignore */ + } + }, [id, setCurrentWorld]); + if (!id) { return

{t("worlds.not_found")}

; } @@ -53,6 +65,8 @@ export function WorldEditPage() { return null; } + const isDraft = world.status === "draft"; + return (
@@ -60,10 +74,21 @@ export function WorldEditPage() {

{t("editor.title")}

{world.name}

- + + {isDraft && ( + void refreshWorld()} /> + )} + setCurrentWorld(w)} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 94cb2b1..5f96bb8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -301,6 +301,8 @@ export interface LlmToolsTestResult { has_tool_calls?: boolean; elapsed_ms?: number; error?: string; + /** Full LLM message returned by the model (for debugging when no tool_calls). */ + raw_response?: unknown; [key: string]: unknown; } diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 84ae3fc..bac256a 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"} \ No newline at end of file