rebase
This commit is contained in:
40
CHANGELOG.md
40
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/),
|
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.
|
||||||
|
|||||||
116
app/api/admin.py
116
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
|
# 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)),
|
||||||
|
|||||||
@@ -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
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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":
|
||||||
|
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 db.commit()
|
||||||
await sse.emit("apply_changes", {})
|
await sse.emit("apply_changes", {"diff": diff})
|
||||||
done = True
|
messages.append({
|
||||||
break
|
"role": "tool",
|
||||||
# Execute 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)
|
||||||
|
|||||||
@@ -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...").
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
<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>
|
</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;
|
||||||
|
|||||||
303
frontend/src/components/worlds/IntroSceneGenerator.tsx
Normal file
303
frontend/src/components/worlds/IntroSceneGenerator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,10 +475,20 @@ 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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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" })}>
|
<Button variant="secondary" onClick={() => setState({ ...state, phase: "form" })}>
|
||||||
{t("common.back")}
|
{t("common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Что-то пошло не так.",
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user