This commit is contained in:
Mikan
2026-06-21 04:11:38 +03:00
parent bd85e186dc
commit 4dee4fb0a8
19 changed files with 1423 additions and 115 deletions

View File

@@ -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/), 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). 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 `<tool_call>calc{...}</tool_call>`. 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.<field>` (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 `<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`/`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 ## [1.1.0] — 2026-06-21
This is a major bugfix release addressing 20+ issues found during user testing. This is a major bugfix release addressing 20+ issues found during user testing.

View File

@@ -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 # 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") @router.post("/test/llm")
async def test_llm( async def test_llm(
@@ -229,9 +255,9 @@ async def test_llm(
_user: User = Depends(require_admin), _user: User = Depends(require_admin),
) -> dict: ) -> dict:
settings = await get_all_settings(db) settings = await get_all_settings(db)
api_url = api_url or settings.get("llm.api_url", "") api_url = _resolve(api_url, settings.get("llm.api_url", ""))
api_key = api_key or settings.get("llm.api_key", "") api_key = _resolve(api_key, settings.get("llm.api_key", ""))
model = model or settings.get("llm.model", "") model = _resolve(model, settings.get("llm.model", ""))
if not api_url: if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0} "elapsed_ms": 0}
@@ -265,37 +291,49 @@ async def test_llm_tools(
_user: User = Depends(require_admin), _user: User = Depends(require_admin),
) -> dict: ) -> dict:
settings = await get_all_settings(db) settings = await get_all_settings(db)
api_url = api_url or settings.get("llm.api_url", "") api_url = _resolve(api_url, settings.get("llm.api_url", ""))
api_key = api_key or settings.get("llm.api_key", "") api_key = _resolve(api_key, settings.get("llm.api_key", ""))
model = model or settings.get("llm.model", "") model = _resolve(model, settings.get("llm.model", ""))
if not api_url: if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0, "has_tool_calls": False} "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() start = time.monotonic()
try: try:
tools = [{ tools = [{
"type": "function", "type": "function",
"function": { "function": {
"name": "calc", "name": "calc",
"description": "Evaluate a math expression", "description": "Evaluate a math expression. You MUST call this tool.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"required": ["expression"], "required": ["expression"],
"properties": {"expression": {"type": "string"}}, "properties": {"expression": {"type": "string", "description": "e.g. '2+2'"}},
}, },
}, },
}] }]
resp = await client.complete( resp = await client.complete(
stage="test_llm_tools", stage="test_llm_tools",
messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}], messages=[
tools=tools, temperature=0.0, max_tokens=100, {"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, session=db,
) )
elapsed = int((time.monotonic() - start) * 1000) elapsed = int((time.monotonic() - start) * 1000)
tcs = resp["message"].get("tool_calls") or [] 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 { return {
"ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed, "ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed,
"raw_response": resp["message"],
} }
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000) elapsed = int((time.monotonic() - start) * 1000)
@@ -303,6 +341,50 @@ async def test_llm_tools(
"elapsed_ms": elapsed, "has_tool_calls": False} "elapsed_ms": elapsed, "has_tool_calls": False}
# Pattern: call:tool_name{args} or <tool_call>name{args}</tool_call> or name(args)
import re as _re
_TOOL_CALL_PATTERNS = [
# call:name{json_args}
_re.compile(r"call:(\w+)\s*\{([^}]*)\}"),
# <tool_call>name{args}</tool_call>
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
# 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"}
- <tool_call>calc{"expression": "2+2"}</tool_call>
- 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") @router.post("/test/embeddings")
async def test_embeddings( async def test_embeddings(
api_url: str | None = None, api_url: str | None = None,
@@ -324,9 +406,9 @@ async def test_embeddings(
"ok": True, "dimension": emb.dimension, "model": "offline_hash", "ok": True, "dimension": emb.dimension, "model": "offline_hash",
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed, "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_url = _resolve(api_url, 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", "") api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
model = model or settings.get("embeddings.model", "") model = _resolve(model, settings.get("embeddings.model", ""))
if not api_url: if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"}, return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"},
"elapsed_ms": 0} "elapsed_ms": 0}
@@ -366,9 +448,9 @@ async def probe_dimension(
"dimension": int(settings.get("embeddings.dimension", 256)), "dimension": int(settings.get("embeddings.dimension", 256)),
"elapsed_ms": 0, "elapsed_ms": 0,
} }
api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") api_url = _resolve(api_url, 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", "") api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
model = model or settings.get("embeddings.model", "") model = _resolve(model, settings.get("embeddings.model", ""))
emb = build_openai_embedder( emb = build_openai_embedder(
api_url=api_url, api_key=api_key, model=model, api_url=api_url, api_key=api_key, model=model,
dimension=int(settings.get("embeddings.dimension", 1536)), dimension=int(settings.get("embeddings.dimension", 1536)),

View File

@@ -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 # Retry / rollback
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -31,6 +31,59 @@ from app.models import LlmCallLog
_logger = get_logger(__name__) _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*[\{\(]([^}\)]*)[\}\)]"),
# <tool_call>name{args}</tool_call> or <tool_call>\n{"name": ..., "arguments": ...}\n</tool_call>
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
# 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"}
- <tool_call>calc{"expression": "2+2"}</tool_call>
- 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): class LLMError(Exception):
"""Base LLM error.""" """Base LLM error."""
@@ -218,6 +271,18 @@ class LlmClient:
finish_reason = choice.get("finish_reason", "stop") finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {}) 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
# "<tool_call>name{args}</tool_call>"), 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 log_id: uuid.UUID | None = None
if session is not None: if session is not None:
log_id = await self._write_log_safely( log_id = await self._write_log_safely(

View File

@@ -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 from __future__ import annotations
import asyncio
import json import json
import uuid import uuid
from typing import Any from typing import Any
@@ -20,6 +34,14 @@ from app.prompts.registry import get_prompt
_logger = get_logger(__name__) _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( async def run_world_editor(
*, *,
db: AsyncSession, db: AsyncSession,
@@ -28,17 +50,13 @@ async def run_world_editor(
llm: LlmClient | MockLlmClient, llm: LlmClient | MockLlmClient,
sse: SseEmitter, sse: SseEmitter,
max_iterations: int = 8, max_iterations: int = 8,
answer_timeout: float = 120.0,
) -> None: ) -> None:
"""Run a world_editor iteration: instruction → propose_changes → done. """Run a world_editor iteration."""
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).
"""
try: try:
registry = get_registry() registry = get_registry()
ctx = ToolContext(db=db, world=world, stage="world_editor", sse_emitter=sse.emit) ctx = ToolContext(db=db, world=world, stage="world_editor", sse_emitter=sse.emit)
# Snapshot current entities for the prompt
entities = ( entities = (
await db.execute( await db.execute(
select(Entity).where( select(Entity).where(
@@ -48,13 +66,16 @@ async def run_world_editor(
).scalars().all() ).scalars().all()
entities_summary = "\n".join( entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities 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( sys_prompt = get_prompt("world_editor", "en").format(
world_name=world.name, world_name=world.name,
world_description=world.description or "", world_description=world.description or "",
language=world.language, language=world.language,
schemas_summary=summarize_schemas(world.schemas or []), schemas_summary=summarize_schemas(world.schemas or []) or "(no schemas yet)",
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), environment_json=env_json,
entities_summary=entities_summary, entities_summary=entities_summary,
instruction=instruction, instruction=instruction,
) )
@@ -74,40 +95,103 @@ async def run_world_editor(
session=db, session=db,
) )
msg = resp.get("message", {}) 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: if not tcs:
# Done content = msg.get("content", "")
await sse.emit("comment", {"text": msg.get("content", "")}) if content:
await sse.emit("comment", {"text": content})
break break
messages.append(msg) messages.append(msg)
done = False done = False
for tc in tcs: 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", "") tname = fn.get("name", "")
args_str = fn.get("arguments", "{}")
if not isinstance(args_str, str):
args_str = json.dumps(args_str)
try: try:
targs = json.loads(fn.get("arguments") or "{}") targs = json.loads(args_str) if args_str else {}
except json.JSONDecodeError: except json.JSONDecodeError:
targs = {} targs = {}
if tname == "ask_user": if tname == "ask_user":
# Non-interactive: emit clarification and stop
await sse.emit("clarification", { await sse.emit("clarification", {
"question": targs.get("question"), "question": targs.get("question", ""),
"options": targs.get("options"), "options": targs.get("options"),
}) })
await sse.done({"status": "needs_clarification"}) # Wait for user answer via REST
return fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
if tname == "propose_changes": _pending_clarifications[world.id] = fut
await sse.emit("change_proposed", { try:
"diff": targs.get("diff", []), answer = await asyncio.wait_for(fut, timeout=answer_timeout)
"comment": targs.get("comment", ""), 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) continue
await _apply_diff(world, targs.get("diff", [])) if tname == "propose_changes":
await db.commit() diff = targs.get("diff", [])
await sse.emit("apply_changes", {}) comment = targs.get("comment", "")
done = True await sse.emit("change_proposed", {
break "diff": diff,
# Execute tool "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) result = await registry.execute(tname, targs, ctx)
messages.append({ messages.append({
"role": "tool", "role": "tool",
@@ -123,26 +207,61 @@ async def run_world_editor(
await sse.error("internal_error", str(e)) 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: async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
"""Apply a propose_changes diff to the world. """Apply a propose_changes diff to the world.
Supports paths into environment and basic field operations. Supported path formats:
- `environment.<field_path>` → 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 from app.core.state_validator import apply_patch
env_patch: dict[str, Any] = {} env_patch: dict[str, Any] = {}
schemas_patch: dict[str, Any] = {}
for d in diff: for d in diff:
path = d.get("path", "") if not isinstance(d, dict):
continue
path = d.get("path", "") or ""
op = d.get("op", "replace") op = d.get("op", "replace")
new = d.get("new") new = d.get("new")
if path.startswith("environment."): if path.startswith("environment."):
field = path[len("environment."):] field = path[len("environment."):]
env_patch[field] = new 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."): elif path.startswith("schemas."):
# For simplicity, replace entire schemas if any schema patch present # For simplicity, replace entire schemas
schemas_patch[path] = new if isinstance(new, list):
world.schemas = new
if env_patch: if env_patch:
new_env, errors = apply_patch(dict(world.environment or {}), env_patch) new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
if not errors: if not errors:
world.environment = new_env world.environment = new_env
else:
_logger.warning("apply_diff_errors", errors=errors)

View File

@@ -25,9 +25,10 @@ Current time: {current_time}
{entities_summary} {entities_summary}
# Hard rules # Hard rules
- Call `submit_step` exactly once with {scene_text, delta_time}. - You MUST call the `submit_step` tool with arguments named `scene_text` and `delta_time`.
- scene_text length: 300-2000 characters. - The `scene_text` argument is a string (300-2000 characters) containing the narrative.
- Write in second person ("You wake up in..."). - 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}. - After submit_step, call `suggest_actions` with 1-3 short actions in {language}.
- Write in second person ("You wake up in...").
""", """,
} }

View File

@@ -24,12 +24,13 @@ Current time: {current_time}
{environment_json} {environment_json}
# Hard rules # Hard rules
- Call `submit_step` exactly once with {scene_text, delta_time}. - You MUST call the `submit_step` tool with arguments named `scene_text` and `delta_time`.
- scene_text length: 200-2000 characters. - 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..."). - Write in second person ("You enter the tavern...").
- Show, don't tell — describe sensory details. - Show, don't tell — describe sensory details.
- Do NOT reference tools, schemas, or game mechanics in the narrative. - Do NOT reference tools, schemas, or game mechanics in the narrative.
- The narrative must be in {language}. - 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`.
""", """,
} }

View File

@@ -213,6 +213,21 @@ export function SettingsPanel() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/*
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.
*/}
<input
type="password"
autoComplete="off"
style={{ display: "none" }}
aria-hidden="true"
tabIndex={-1}
readOnly
/>
<div> <div>
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2> <h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
<p className="mt-1 text-xs text-fg-muted"> <p className="mt-1 text-xs text-fg-muted">
@@ -302,6 +317,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
<select <select
id={`setting-${settingKey}`} id={`setting-${settingKey}`}
className="input" className="input"
autoComplete="off"
value={value === "true" ? "true" : value === "false" ? "false" : value} value={value === "true" ? "true" : value === "false" ? "false" : value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
> >
@@ -320,6 +336,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
<select <select
id={`setting-${settingKey}`} id={`setting-${settingKey}`}
className="input" className="input"
autoComplete="off"
value={value || "offline_hash"} value={value || "offline_hash"}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
> >
@@ -340,6 +357,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
type="number" type="number"
step={1} step={1}
min={0} min={0}
autoComplete="off"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
@@ -356,6 +374,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
step={0.01} step={0.01}
min={0} min={0}
max={2} max={2}
autoComplete="off"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
@@ -363,12 +382,17 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
} }
if (ft === "secret") { 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 ( return (
<Input <Input
id={`setting-${settingKey}`} id={`setting-${settingKey}`}
label={label} label={label}
hint={hint} hint={hint}
type="password" type="text"
autoComplete="off"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={t("admin.api_key")} placeholder={t("admin.api_key")}
@@ -381,6 +405,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
id={`setting-${settingKey}`} id={`setting-${settingKey}`}
label={label} label={label}
hint={hint} hint={hint}
autoComplete="off"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />

View File

@@ -353,6 +353,11 @@ function RecreateCollectionsCard() {
function TestResultCard({ result }: { result: Record<string, unknown> }) { function TestResultCard({ result }: { result: Record<string, unknown> }) {
const { t } = useTranslation(); const { t } = useTranslation();
const ok = result.ok === true; 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 ( return (
<div className={`mt-3 rounded-md border p-3 text-xs ${ok ? "border-ok/30 bg-ok/5" : "border-err/30 bg-err/5"}`}> <div className={`mt-3 rounded-md border p-3 text-xs ${ok ? "border-ok/30 bg-ok/5" : "border-err/30 bg-err/5"}`}>
<p className={`font-semibold ${ok ? "text-ok" : "text-err"}`}> <p className={`font-semibold ${ok ? "text-ok" : "text-err"}`}>
@@ -393,15 +398,75 @@ function TestResultCard({ result }: { result: Record<string, unknown> }) {
first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}] first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}]
</p> </p>
)} )}
{result.tool_calls != null && (
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2"> {/* LLM-tools-test specific rendering */}
{JSON.stringify(result.tool_calls, null, 2)} {hasToolCallsField && hasToolCalls && (
</pre> <div className="mt-2">
<p className="font-semibold text-ok">{t("admin.tool_calls_detected")}</p>
{Array.isArray(toolCalls) ? (
<ul className="mt-1 space-y-1">
{(toolCalls as Array<Record<string, unknown>>).map((tc, i) => (
<li key={i} className="rounded border border-fg-dim/20 bg-bg-soft p-2">
<div className="flex flex-wrap items-center gap-2">
{typeof tc.name === "string" && (
<span className="badge bg-accent/15 text-accent">{tc.name}</span>
)}
{typeof tc.id === "string" && (
<span className="font-mono text-fg-muted">#{tc.id}</span>
)}
</div>
{tc.arguments != null && (
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-fg-muted">
{safeStringify(tc.arguments)}
</pre>
)}
{tc.function != null && typeof tc.function === "object" && (
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-fg-muted">
{safeStringify(tc.function)}
</pre>
)}
</li>
))}
</ul>
) : toolCalls != null ? (
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2">
{safeStringify(toolCalls)}
</pre>
) : null}
</div>
)}
{hasToolCallsField && !hasToolCalls && (
<div className="mt-2 rounded border border-warn/30 bg-warn/5 p-2 text-warn">
<p className="font-semibold">{t("admin.no_tool_calls_warning_title")}</p>
<p className="mt-1 text-fg">{t("admin.no_tool_calls_warning")}</p>
</div>
)}
{/* Show the raw LLM message in a collapsible details section */}
{rawResponse != null && (
<details className="mt-2 rounded border border-fg-dim/20 bg-bg-soft p-2">
<summary className="cursor-pointer text-xs text-fg-muted">
{t("admin.raw_response")}
</summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-fg-muted">
{safeStringify(rawResponse)}
</pre>
</details>
)} )}
</div> </div>
); );
} }
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. */ /** Extract a human-readable error message from a test result object. */
function extractErr(result: Record<string, unknown>): string { function extractErr(result: Record<string, unknown>): string {
const e = result.error; const e = result.error;

View File

@@ -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<IntroState>(INITIAL_STATE);
const controllerRef = useRef<SseController | null>(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 (
<Card title={t("editor.generate_intro_title")}>
<div className="space-y-3">
<p className="text-sm text-fg-muted">{t("editor.generate_intro_help")}</p>
{state.phase === "idle" && (
<Button onClick={() => void start()} size="lg">
{t("editor.generate_intro_button")}
</Button>
)}
{busy && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-fg-muted">
<Spinner size="sm" /> {t("editor.intro_generating")}
</div>
<SseStatus status={state.sseStatus} />
</div>
{state.phases.length > 0 && (
<PhaseProgress
phases={state.phases}
currentPhase={state.currentPhase}
step={state.step}
totalSteps={state.totalSteps}
message={state.message}
/>
)}
{state.introScene && (
<div>
<p className="label">{t("editor.intro_scene")}</p>
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
{state.introScene}
</p>
</div>
)}
{state.logs.length > 0 && (
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
<summary className="cursor-pointer text-xs text-fg-muted">
Logs ({state.logs.length})
</summary>
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
{state.logs.join("\n")}
</pre>
</details>
)}
</div>
)}
{state.phase === "done" && (
<div className="space-y-3">
<p className="text-sm text-ok">{t("editor.world_ready")}</p>
{state.introScene && (
<div>
<p className="label">{t("editor.intro_scene")}</p>
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
{state.introScene}
</p>
</div>
)}
<div className="flex gap-2">
<Button variant="primary" onClick={() => navigate(`/worlds/${world.id}/play`)}>
{t("worlds.play")}
</Button>
<Button variant="secondary" onClick={retry}>
{t("editor.regenerate_intro")}
</Button>
</div>
</div>
)}
{state.phase === "error" && (
<div className="space-y-3">
<p className="text-sm text-err">
{state.errorMessage || t("editor.intro_failed")}
</p>
{state.logs.length > 0 && (
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2" open>
<summary className="cursor-pointer text-xs text-fg-muted">
Logs ({state.logs.length})
</summary>
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
{state.logs.join("\n")}
</pre>
</details>
)}
<Button variant="secondary" onClick={retry}>
{t("common.retry")}
</Button>
</div>
)}
</div>
</Card>
);
}

View File

@@ -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 { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -72,6 +72,10 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
sseStatus: "idle", sseStatus: "idle",
}); });
const [controller, setController] = useState<SseController | null>(null); const [controller, setController] = useState<SseController | null>(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<SseController | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -130,7 +134,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
logs: [...s.logs, `[error] ${d?.message || "Stream error"}`], logs: [...s.logs, `[error] ${d?.message || "Stream error"}`],
})); }));
pushToast("error", d?.message || t("builder.build_failed")); pushToast("error", d?.message || t("builder.build_failed"));
controller?.close(); controllerRef.current?.close();
break; break;
} }
case "warning": { case "warning": {
@@ -201,7 +205,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
phase: "done", phase: "done",
sseStatus: "closed", sseStatus: "closed",
})); }));
controller?.close(); controllerRef.current?.close();
pushToast("success", t("builder.build_complete")); pushToast("success", t("builder.build_complete"));
break; break;
} }
@@ -209,7 +213,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
break; break;
} }
}, },
[controller, pushToast, t], [pushToast, t],
); );
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -266,6 +270,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
onEvent: handleEvent, onEvent: handleEvent,
}); });
setController(c); setController(c);
controllerRef.current = c;
// Save world id for redirect on done // Save world id for redirect on done
createdWorldIdRef.current = res.world_id; createdWorldIdRef.current = res.world_id;
} catch (err) { } catch (err) {
@@ -292,6 +297,43 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
} }
}, [state.phase, navigate]); }, [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 ( return (
<div className={cn("grid gap-4 lg:grid-cols-3", className)}> <div className={cn("grid gap-4 lg:grid-cols-3", className)}>
<Card title={t("builder.step_choose_mode")} className="lg:col-span-1"> <Card title={t("builder.step_choose_mode")} className="lg:col-span-1">
@@ -433,9 +475,19 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
{state.phase === "error" && ( {state.phase === "error" && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-err">{t("builder.build_failed")}</p> <p className="text-sm text-err">{t("builder.build_failed")}</p>
<Button variant="secondary" onClick={() => setState({ ...state, phase: "form" })}> <div className="flex gap-2">
{t("common.back")} <Button
</Button> variant="primary"
onClick={handleRetry}
disabled={!createdWorldIdRef.current}
title={!createdWorldIdRef.current ? t("builder.retry_disabled") : undefined}
>
{t("common.retry")}
</Button>
<Button variant="secondary" onClick={() => setState({ ...state, phase: "form" })}>
{t("common.back")}
</Button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -43,6 +43,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
const isReady = world.status === "ready"; const isReady = world.status === "ready";
const isArchived = world.status === "archived"; const isArchived = world.status === "archived";
const isDraft = world.status === "draft";
const isAdmin = !!user?.is_admin; const isAdmin = !!user?.is_admin;
const handleRestore = async () => { const handleRestore = async () => {
@@ -135,6 +136,34 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
</Button> </Button>
)} )}
</> </>
) : isDraft ? (
<>
<Button
size="sm"
variant="primary"
onClick={() => navigate(`/worlds/${world.id}/edit`)}
fullWidth
>
{t("worlds.continue_setup")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/worlds/${world.id}/edit`)}
>
{t("worlds.edit")}
</Button>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(world)}
aria-label={t("common.delete")}
>
🗑
</Button>
)}
</>
) : ( ) : (
<> <>
<Button <Button

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { WorldsApi, SessionsApi } from "@/lib/api"; import { WorldsApi, SessionsApi, toErrorMessage } from "@/lib/api";
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse"; import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
import { useToastStore } from "@/stores/toastStore"; import { useToastStore } from "@/stores/toastStore";
import type { World } from "@/types"; import type { World } from "@/types";
@@ -14,12 +14,28 @@ import { SseStatus } from "@/components/sessions/SseStatus";
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error"; type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
interface DiffItem {
path?: string;
op?: string;
old?: unknown;
new?: unknown;
[key: string]: unknown;
}
interface LogEntry { interface LogEntry {
id: string; id: string;
kind: "comment" | "clarification" | "change_proposed" | "info" | "error"; kind: "comment" | "clarification" | "change_proposed" | "info" | "error";
text: string; text: string;
options?: string[]; options?: string[];
diff?: unknown; diff?: unknown;
/** For clarification entries: whether the user has already answered. */
answered?: boolean;
/** For clarification entries: the answer text the user submitted. */
answerText?: string;
/** For change_proposed entries: the user's decision ("accept" | "reject" | undefined). */
decision?: "accept" | "reject";
/** Whether the accept/reject request is in-flight. */
deciding?: boolean;
} }
export interface WorldEditorProps { export interface WorldEditorProps {
@@ -65,6 +81,11 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
} }
}, [world.id, onWorldUpdated]); }, [world.id, onWorldUpdated]);
/** Update a single log entry by id. */
const patchLog = useCallback((id: string, patch: Partial<LogEntry>) => {
setLogs((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l)));
}, []);
const handleEvent = useCallback( const handleEvent = useCallback(
(event: SseEvent) => { (event: SseEvent) => {
switch (event.event) { switch (event.event) {
@@ -99,7 +120,12 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
setPhase("awaiting_clarification"); setPhase("awaiting_clarification");
setLogs((l) => [ setLogs((l) => [
...l, ...l,
{ id: uid(), kind: "clarification", text: d.question, options: d.options }, {
id: uid(),
kind: "clarification",
text: d.question,
options: d.options,
},
]); ]);
break; break;
} }
@@ -108,17 +134,27 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
setPhase("changes_proposed"); setPhase("changes_proposed");
setLogs((l) => [ setLogs((l) => [
...l, ...l,
{ id: uid(), kind: "change_proposed", text: d.comment, diff: d.diff }, {
id: uid(),
kind: "change_proposed",
text: d.comment,
diff: d.diff,
},
]); ]);
break; break;
} }
case "apply_changes": { case "apply_changes": {
// Emitted by the backend after a successful POST /apply.
// The stream continues; the LLM may emit more events.
setPhase("streaming");
void refreshWorld(); void refreshWorld();
pushToast("success", t("editor.changes_applied")); pushToast("success", t("editor.changes_applied"));
break; break;
} }
case "discard_changes": { case "discard_changes": {
setPhase("streaming");
setLogs((l) => [...l, { id: uid(), kind: "info", text: t("editor.changes_discarded") }]); setLogs((l) => [...l, { id: uid(), kind: "info", text: t("editor.changes_discarded") }]);
pushToast("info", t("editor.changes_discarded"));
break; break;
} }
case "done": { case "done": {
@@ -166,8 +202,47 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
controllerRef.current = c; controllerRef.current = c;
} catch (err) { } catch (err) {
setPhase("error"); setPhase("error");
const msg = err instanceof Error ? err.message : "Failed"; pushToast("error", toErrorMessage(err, t("editor.streaming")));
pushToast("error", msg); }
};
/** Submit a clarification answer. */
const handleAnswer = async (entryId: string, text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
patchLog(entryId, { answered: true, answerText: trimmed });
try {
await SessionsApi.answerClarification(world.id, trimmed);
// The stream will continue; backend emits more events.
} catch (err) {
patchLog(entryId, { answered: false, answerText: undefined });
pushToast("error", toErrorMessage(err, t("editor.answer_failed")));
}
};
/** Accept proposed changes. */
const handleAccept = async (entryId: string) => {
patchLog(entryId, { deciding: true });
try {
await SessionsApi.applyChanges(world.id);
patchLog(entryId, { deciding: false, decision: "accept" });
// The backend will emit an `apply_changes` SSE event when it processes
// the change — that handler refreshes the world and shows the toast.
} catch (err) {
patchLog(entryId, { deciding: false });
pushToast("error", toErrorMessage(err, t("editor.apply_failed")));
}
};
/** Reject proposed changes. */
const handleReject = async (entryId: string) => {
patchLog(entryId, { deciding: true });
try {
await SessionsApi.discardChanges(world.id);
patchLog(entryId, { deciding: false, decision: "reject" });
} catch (err) {
patchLog(entryId, { deciding: false });
pushToast("error", toErrorMessage(err, t("editor.discard_failed")));
} }
}; };
@@ -180,8 +255,7 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
onWorldUpdated?.(updated); onWorldUpdated?.(updated);
pushToast("success", t("editor.json_saved")); pushToast("success", t("editor.json_saved"));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Failed"; pushToast("error", toErrorMessage(err, t("editor.json_saved")));
pushToast("error", msg);
} finally { } finally {
setSubmittingJson(false); setSubmittingJson(false);
} }
@@ -213,7 +287,13 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
{logs.length > 0 && ( {logs.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{logs.map((log) => ( {logs.map((log) => (
<LogEntryView key={log.id} entry={log} /> <LogEntryView
key={log.id}
entry={log}
onAnswer={(text) => void handleAnswer(log.id, text)}
onAccept={() => void handleAccept(log.id)}
onReject={() => void handleReject(log.id)}
/>
))} ))}
</div> </div>
)} )}
@@ -250,46 +330,219 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
); );
} }
function LogEntryView({ entry }: { entry: LogEntry }) { interface LogEntryViewProps {
const { t } = useTranslation(); entry: LogEntry;
onAnswer: (text: string) => void;
onAccept: () => void;
onReject: () => void;
}
function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps) {
if (entry.kind === "clarification") { if (entry.kind === "clarification") {
return ( return (
<div className="rounded-md border border-warn/30 bg-warn/5 p-3"> <ClarificationCard
<p className="text-xs font-semibold uppercase tracking-wide text-warn"> entry={entry}
{t("editor.clarification")} onAnswer={onAnswer}
</p> />
<p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>
{entry.options && entry.options.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-sm text-fg-muted">
{entry.options.map((o, i) => (
<li key={i}>{o}</li>
))}
</ul>
)}
</div>
); );
} }
if (entry.kind === "change_proposed") { if (entry.kind === "change_proposed") {
return ( return (
<div className="rounded-md border border-accent/30 bg-accent/5 p-3"> <ChangeProposedCard
<p className="text-xs font-semibold uppercase tracking-wide text-accent"> entry={entry}
{t("editor.change_proposed")} onAccept={onAccept}
</p> onReject={onReject}
<p className="mt-1 text-sm text-fg">{entry.text}</p> />
{entry.diff != null && (
<pre className="mt-2 max-h-40 overflow-auto rounded bg-bg-soft p-2 text-[10px] font-mono text-fg-muted">
{JSON.stringify(entry.diff, null, 2)}
</pre>
)}
</div>
); );
} }
if (entry.kind === "error") { if (entry.kind === "error") {
return <p className="text-xs text-err">{entry.text}</p>; return <p className="text-xs text-err">{entry.text}</p>;
} }
return <p className="text-xs text-fg-muted">{entry.text}</p>; return <p className="text-xs text-fg-muted">{entry.text}</p>;
} }
function ClarificationCard({
entry,
onAnswer,
}: {
entry: LogEntry;
onAnswer: (text: string) => void;
}) {
const { t } = useTranslation();
const [text, setText] = useState("");
const hasOptions = Array.isArray(entry.options) && entry.options.length > 0;
return (
<div className="rounded-md border border-warn/30 bg-warn/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-warn">
{t("editor.clarification")}
</p>
<p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>
{entry.answered ? (
<p className="mt-2 rounded bg-bg-soft p-2 text-xs text-fg-muted">
<span className="font-semibold text-fg">{t("editor.your_answer")}:</span>{" "}
{entry.answerText}
</p>
) : hasOptions ? (
<div className="mt-2 flex flex-wrap gap-2">
{entry.options!.map((opt, i) => (
<Button
key={i}
size="sm"
variant="secondary"
onClick={() => onAnswer(opt)}
>
{opt}
</Button>
))}
</div>
) : (
<div className="mt-2 flex gap-2">
<input
type="text"
className="input flex-1"
placeholder={t("editor.answer_placeholder")}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (text.trim()) onAnswer(text);
}
}}
autoComplete="off"
/>
<Button
size="sm"
onClick={() => text.trim() && onAnswer(text)}
disabled={!text.trim()}
>
{t("common.submit")}
</Button>
</div>
)}
</div>
);
}
function ChangeProposedCard({
entry,
onAccept,
onReject,
}: {
entry: LogEntry;
onAccept: () => void;
onReject: () => void;
}) {
const { t } = useTranslation();
const diffItems = normalizeDiff(entry.diff);
return (
<div className="rounded-md border border-accent/30 bg-accent/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-accent">
{t("editor.change_proposed")}
</p>
{entry.text && <p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>}
{diffItems.length > 0 && (
<ul className="mt-2 space-y-1.5">
{diffItems.map((item, i) => (
<li key={i} className="rounded border border-fg-dim/20 bg-bg-soft p-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
{item.op && (
<span className={cn("badge", OP_BADGE_CLASS[item.op] || "bg-bg-soft text-fg-muted")}>
{item.op}
</span>
)}
{item.path && (
<code className="font-mono text-fg">{item.path}</code>
)}
</div>
{item.new !== undefined && (
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-all text-[10px] font-mono text-fg-muted">
{safeStringify(item.new)}
</pre>
)}
</li>
))}
</ul>
)}
{entry.diff != null && diffItems.length === 0 && (
<pre className="mt-2 max-h-48 overflow-auto rounded bg-bg-soft p-2 text-[10px] font-mono text-fg-muted">
{safeStringify(entry.diff)}
</pre>
)}
{entry.decision ? (
<p className="mt-2 text-xs text-fg-muted">
{entry.decision === "accept"
? `${t("editor.changes_applied")}`
: `${t("editor.changes_discarded")}`}
</p>
) : (
<div className="mt-3 flex gap-2">
<Button
size="sm"
variant="primary"
onClick={onAccept}
loading={entry.deciding}
disabled={entry.deciding}
>
{t("editor.accept")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={onReject}
loading={entry.deciding}
disabled={entry.deciding}
>
{t("editor.reject")}
</Button>
</div>
)}
</div>
);
}
const OP_BADGE_CLASS: Record<string, string> = {
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<string, unknown>;
// 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 { function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36); return Math.random().toString(36).slice(2) + Date.now().toString(36);
} }

View File

@@ -89,6 +89,7 @@
"status_failed": "Failed", "status_failed": "Failed",
"status_archived": "Archived", "status_archived": "Archived",
"restore": "Restore", "restore": "Restore",
"continue_setup": "Continue setup",
"restored": "World restored.", "restored": "World restored.",
"restore_failed": "Failed to restore world.", "restore_failed": "Failed to restore world.",
"delete_permanent": "Delete permanently", "delete_permanent": "Delete permanently",
@@ -123,7 +124,8 @@
"llm_call": "LLM call", "llm_call": "LLM call",
"schema_generated": "World schema generated", "schema_generated": "World schema generated",
"environment_generated": "Environment generated", "environment_generated": "Environment generated",
"entities_generated": "Entities generated" "entities_generated": "Entities generated",
"retry_disabled": "Cannot retry — no world was created yet."
}, },
"editor": { "editor": {
"title": "World Editor", "title": "World Editor",
@@ -142,7 +144,24 @@
"changes_discarded": "Changes discarded.", "changes_discarded": "Changes discarded.",
"edit_world": "Edit world", "edit_world": "Edit world",
"submit_instruction": "Submitting…", "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": { "play": {
"title": "Play", "title": "Play",
@@ -171,7 +190,7 @@
"rollback": "Rollback one step", "rollback": "Rollback one step",
"rollback_confirm": "Rollback the last step?", "rollback_confirm": "Rollback the last step?",
"rolled_back": "Rolled back one 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_retry": "No steps to retry yet.",
"no_steps_to_rollback": "No steps to rollback yet.", "no_steps_to_rollback": "No steps to rollback yet.",
"streaming": "AI is responding…", "streaming": "AI is responding…",
@@ -259,7 +278,11 @@
"icons_upload": "Upload", "icons_upload": "Upload",
"icons_uploaded": "Icon uploaded.", "icons_uploaded": "Icon uploaded.",
"icons_upload_failed": "Failed to upload icon.", "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": { "errors": {
"generic": "Something went wrong.", "generic": "Something went wrong.",

View File

@@ -89,6 +89,7 @@
"status_failed": "Ошибка", "status_failed": "Ошибка",
"status_archived": "В архиве", "status_archived": "В архиве",
"restore": "Восстановить", "restore": "Восстановить",
"continue_setup": "Продолжить настройку",
"restored": "Мир восстановлен.", "restored": "Мир восстановлен.",
"restore_failed": "Не удалось восстановить мир.", "restore_failed": "Не удалось восстановить мир.",
"delete_permanent": "Удалить навсегда", "delete_permanent": "Удалить навсегда",
@@ -123,7 +124,8 @@
"llm_call": "Вызов LLM", "llm_call": "Вызов LLM",
"schema_generated": "Схема мира сгенерирована", "schema_generated": "Схема мира сгенерирована",
"environment_generated": "Окружение сгенерировано", "environment_generated": "Окружение сгенерировано",
"entities_generated": "Сущности сгенерированы" "entities_generated": "Сущности сгенерированы",
"retry_disabled": "Нельзя повторить — мир ещё не создан."
}, },
"editor": { "editor": {
"title": "Редактор мира", "title": "Редактор мира",
@@ -142,7 +144,24 @@
"changes_discarded": "Изменения отменены.", "changes_discarded": "Изменения отменены.",
"edit_world": "Редактировать мир", "edit_world": "Редактировать мир",
"submit_instruction": "Отправка…", "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": { "play": {
"title": "Игра", "title": "Игра",
@@ -171,7 +190,7 @@
"rollback": "Откатить один шаг", "rollback": "Откатить один шаг",
"rollback_confirm": "Откатить последний шаг?", "rollback_confirm": "Откатить последний шаг?",
"rolled_back": "Шаг откатан.", "rolled_back": "Шаг откатан.",
"not_ready": "Мир ещё не готов. Сначала завершите создание мира.", "not_ready": "Этот мир ещё не готов. Сначала сгенерируйте вступительную сцену.",
"no_steps_to_retry": "Нет шагов для повтора.", "no_steps_to_retry": "Нет шагов для повтора.",
"no_steps_to_rollback": "Нет шагов для отката.", "no_steps_to_rollback": "Нет шагов для отката.",
"streaming": "AI отвечает…", "streaming": "AI отвечает…",
@@ -259,7 +278,11 @@
"icons_upload": "Загрузить", "icons_upload": "Загрузить",
"icons_uploaded": "Иконка загружена.", "icons_uploaded": "Иконка загружена.",
"icons_upload_failed": "Не удалось загрузить иконку.", "icons_upload_failed": "Не удалось загрузить иконку.",
"choose_file": "Выбрать файл" "choose_file": "Выбрать файл",
"tool_calls_detected": "Обнаружены вызовы инструментов",
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
"no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.",
"raw_response": "Полный ответ модели"
}, },
"errors": { "errors": {
"generic": "Что-то пошло не так.", "generic": "Что-то пошло не так.",

View File

@@ -311,6 +311,14 @@ export const WorldsApi = {
}; };
// ===== Sessions API ===== // ===== Sessions API =====
export interface GenerateIntroResponse {
stream_url: string;
}
export interface SimpleOkResponse {
ok: boolean;
}
export const SessionsApi = { export const SessionsApi = {
state: (worldId: string) => state: (worldId: string) =>
request<SessionState>(`/sessions/worlds/${worldId}/state`), request<SessionState>(`/sessions/worlds/${worldId}/state`),
@@ -323,6 +331,30 @@ export const SessionsApi = {
request<RetryResponse>(`/sessions/worlds/${worldId}/retry`, { method: "POST" }), request<RetryResponse>(`/sessions/worlds/${worldId}/retry`, { method: "POST" }),
rollback: (worldId: string) => rollback: (worldId: string) =>
request<void>(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }), request<void>(`/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<SimpleOkResponse>(`/sessions/worlds/${worldId}/apply`, { method: "POST" }),
/** Reject proposed changes from the world_editor stream. */
discardChanges: (worldId: string) =>
request<SimpleOkResponse>(`/sessions/worlds/${worldId}/discard`, { method: "POST" }),
/** Answer a clarification question from the world_editor stream. */
answerClarification: (worldId: string, text: string) =>
request<SimpleOkResponse>(`/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<GenerateIntroResponse>(`/worlds/${worldId}/generate-intro`, { method: "POST" }),
// SSE stream URLs (used by SSE client) // SSE stream URLs (used by SSE client)
iterateStreamUrl: (worldId: string, stepId: string) => iterateStreamUrl: (worldId: string, stepId: string) =>
buildUrl(`/sessions/worlds/${worldId}/iterate/stream`, { step_id: stepId }), buildUrl(`/sessions/worlds/${worldId}/iterate/stream`, { step_id: stepId }),
@@ -330,6 +362,9 @@ export const SessionsApi = {
buildUrl(`/sessions/worlds/${worldId}/builder/stream`), buildUrl(`/sessions/worlds/${worldId}/builder/stream`),
editorStreamUrl: (worldId: string, instruction: string) => editorStreamUrl: (worldId: string, instruction: string) =>
buildUrl(`/sessions/worlds/${worldId}/editor/stream`, { instruction }), 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 ===== // ===== Presets API =====

View File

@@ -1,11 +1,13 @@
import { useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { WorldsApi } from "@/lib/api";
import { useWorldsStore } from "@/stores/worldsStore"; import { useWorldsStore } from "@/stores/worldsStore";
import { useToastStore } from "@/stores/toastStore"; import { useToastStore } from "@/stores/toastStore";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Spinner } from "@/components/ui/Spinner"; import { Spinner } from "@/components/ui/Spinner";
import { WorldEditor } from "@/components/worlds/WorldEditor"; import { WorldEditor } from "@/components/worlds/WorldEditor";
import { IntroSceneGenerator } from "@/components/worlds/IntroSceneGenerator";
export function WorldEditPage() { export function WorldEditPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -26,6 +28,16 @@ export function WorldEditPage() {
return () => setCurrentWorld(null); return () => setCurrentWorld(null);
}, [id, fetchWorld, setCurrentWorld, pushToast, t]); }, [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) { if (!id) {
return <p className="p-4 text-sm text-err">{t("worlds.not_found")}</p>; return <p className="p-4 text-sm text-err">{t("worlds.not_found")}</p>;
} }
@@ -53,6 +65,8 @@ export function WorldEditPage() {
return null; return null;
} }
const isDraft = world.status === "draft";
return ( return (
<div className="mx-auto max-w-7xl space-y-4 p-4"> <div className="mx-auto max-w-7xl space-y-4 p-4">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
@@ -60,10 +74,21 @@ export function WorldEditPage() {
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1> <h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
<p className="text-sm text-fg-muted">{world.name}</p> <p className="text-sm text-fg-muted">{world.name}</p>
</div> </div>
<Button variant="secondary" onClick={() => navigate(`/worlds/${world.id}/play`)}> <Button
variant="secondary"
onClick={() => navigate(`/worlds/${world.id}/play`)}
disabled={isDraft}
title={isDraft ? t("editor.cannot_play_draft") : undefined}
className={isDraft ? "opacity-50 cursor-not-allowed" : ""}
>
{t("worlds.play")} {t("worlds.play")}
</Button> </Button>
</header> </header>
{isDraft && (
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
)}
<WorldEditor <WorldEditor
world={world} world={world}
onWorldUpdated={(w) => setCurrentWorld(w)} onWorldUpdated={(w) => setCurrentWorld(w)}

View File

@@ -301,6 +301,8 @@ export interface LlmToolsTestResult {
has_tool_calls?: boolean; has_tool_calls?: boolean;
elapsed_ms?: number; elapsed_ms?: number;
error?: string; error?: string;
/** Full LLM message returned by the model (for debugging when no tool_calls). */
raw_response?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -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"} {"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"}