fix
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -4,6 +4,48 @@ 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.4.0] — 2026-06-21
|
||||||
|
|
||||||
|
Major UX release: redirect to edit after world creation, environment panel, admin separate routes, collapsible settings, name bank, page titles.
|
||||||
|
|
||||||
|
### Backend — Critical fixes
|
||||||
|
|
||||||
|
- **KeyError in world_builder_schema prompt**: the prompt contained a literal `{name, type, required, min, max, properties}` brace group that `str.format()` tried to interpret as a format field. Rewrote the prompt to describe field properties in prose. All 11 prompts verified to format correctly.
|
||||||
|
- **propose_changes not applying world.name / world.description**: `apply_diff` only handled `environment.*` paths. Now supports shorthand paths: `name`, `description`, `language`, `player.*`, `current_location`, `plot_rails.*`, `schemas`. Also skips empty `{}` new values (model sometimes returns empty objects). Also syncs `world.plot_rails` column from environment.
|
||||||
|
- **action_source validation error**: `IterateRequest.action_source` was `Literal["custom", "suggested"]` which rejected any other string. Changed to `str` with default "custom" — accepts any value.
|
||||||
|
- **Tool-call retry with nudge**: when the model returns no tool calls, the loop now appends a system message: "You did not call any tools. You MUST use the available tools. If you tried to call a tool but it didn't work, try again with proper JSON arguments." and retries (up to max_substeps).
|
||||||
|
- **Text replacements applied earlier**: now applied to LLM content in the tool loop (not just scene_text), so reasoning/comments are also cleaned before being shown to the user or fed back to the model.
|
||||||
|
- **Language instruction in prompts**: added "Language rule" section to `orchestrator_phase1`, `world_builder_schema`, and other prompts: "All entity names, location names, character names, item names, and descriptions that the PLAYER will see MUST be in `{language}`. Internal reasoning and tool arguments stay in English."
|
||||||
|
- **World description from preset**: when creating a world from a preset, `world.description` is now set from `preset.description` (was using `body.notes` or null).
|
||||||
|
- **Schema `show` field**: `world_builder_schema` prompt now instructs the LLM to include a `show` boolean on each field (default true; if false, the field is hidden from the player UI).
|
||||||
|
|
||||||
|
### Backend — New settings & endpoints
|
||||||
|
|
||||||
|
- **`ui.header_title` setting**: separate title for the navbar header. If empty, falls back to `ui.page_title`. Returned in `GET /api/settings/public` as `header_title`.
|
||||||
|
- **`character_names.en` / `character_names.ru` settings**: name banks for the random name button. Each is a JSON array of ~20 names. Extensible via admin settings.
|
||||||
|
- **`GET /api/names/{language}`** (no auth) — returns `{name: "random name", language, count}`. Used by the world builder form's 🎲 button.
|
||||||
|
|
||||||
|
### Frontend — 16 files changed
|
||||||
|
|
||||||
|
- **Redirect to edit after world creation**: WorldBuilder now redirects to `/worlds/{id}/edit` (not `/play`) after the builder stream completes. The user manually clicks "Generate intro scene" then "Play".
|
||||||
|
- **Play page — intro_scene + suggested actions**: if no recent_steps but `world.intro_scene` exists, it's shown as the first assistant message. `next_actions` shown as clickable buttons. Empty states with links to edit page.
|
||||||
|
- **Environment panel**: shows Player (name, HP progress bar, mana/strength, inventory), Current Location, Plot Rails (hooks, current_goals, completed_goals). "No environment data" empty state.
|
||||||
|
- **Edit world button**: ⚙️ button in PlayPage header → links to `/worlds/{id}/edit`.
|
||||||
|
- **Admin separate routes**: `/admin/stats`, `/admin/logs`, `/admin/users`, `/admin/settings`, `/admin/test`, `/admin/icons`. `/admin` redirects to `/admin/stats`. Page reload preserves the current tab.
|
||||||
|
- **LLM logs auto-refresh**: polls every 5 seconds (first page only, paused when filter inputs are focused). Pause/Resume button. "N new" badge when new logs arrive.
|
||||||
|
- **Collapsible settings cards**: all 7 cards (LLM, Embeddings, Qdrant, Context, Game, UI, Text Replacements) are collapsible, default collapsed. Chevron icon (▶/▼). "Expand all" / "Collapse all" buttons.
|
||||||
|
- **Boolean settings → checkboxes**: replaced dropdowns with `<input type="checkbox">` for all boolean settings.
|
||||||
|
- **Localized setting descriptions**: all ~36 setting descriptions now have translation keys (`admin.setting_desc.{key}`). Also localized the test page hints and the "Each card saves independently..." text.
|
||||||
|
- **Page title**: PlayPage and WorldEditPage set `document.title = "{world.name} | {headerTitle}"`. Navbar uses `header_title` from `/api/settings/public`.
|
||||||
|
- **World builder form simplified**: removed `form_data` JSON. Now has: Setting (textarea), World name (default "New World" / "Новый Мир"), Player name (text + 🎲 random button), Language (select), Notes (textarea). Preset mode hides Setting field, defaults world name to preset name.
|
||||||
|
- **Name bank random button**: 🎲 button next to player name. Calls `GET /api/names/{language}` and fills the field. Disabled while fetching.
|
||||||
|
- **World edit — current_time_human**: shows "Day 1, 08:00" instead of "day_1_hour_8".
|
||||||
|
- **World editor — comment + tool_call display**: `comment` SSE events shown as assistant chat bubbles. `tool_call` events shown as ToolCallBubble components in the chat log.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Backend: 68 unit tests pass, 53 routes.
|
||||||
|
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (393 KB JS / 24 KB CSS, ~119 KB gzipped).
|
||||||
|
|
||||||
## [1.3.0] — 2026-06-21
|
## [1.3.0] — 2026-06-21
|
||||||
|
|
||||||
Major release: world builder rewritten to use tools (instead of JSON), resumable builder flow, admin recovery, LLM model list, text replacements, human-readable time.
|
Major release: world builder rewritten to use tools (instead of JSON), resumable builder flow, admin recovery, LLM model list, text replacements, human-readable time.
|
||||||
|
|||||||
@@ -68,14 +68,36 @@ async def public_settings(db: AsyncSession = Depends(get_db)) -> dict:
|
|||||||
favicon_url = await get_setting(db, "ui.favicon_url")
|
favicon_url = await get_setting(db, "ui.favicon_url")
|
||||||
logo_url = await get_setting(db, "ui.logo_url")
|
logo_url = await get_setting(db, "ui.logo_url")
|
||||||
og_image_url = await get_setting(db, "ui.og_image_url")
|
og_image_url = await get_setting(db, "ui.og_image_url")
|
||||||
|
header_title = await get_setting(db, "ui.header_title")
|
||||||
return {
|
return {
|
||||||
"page_title": page_title or "AI-RPG",
|
"page_title": page_title or "AI-RPG",
|
||||||
"favicon_url": favicon_url or "/icon.png",
|
"favicon_url": favicon_url or "/icon.png",
|
||||||
"logo_url": logo_url or "/icon.png",
|
"logo_url": logo_url or "/icon.png",
|
||||||
"og_image_url": og_image_url or "",
|
"og_image_url": og_image_url or "",
|
||||||
|
"header_title": header_title or page_title or "AI-RPG",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/names/{language}")
|
||||||
|
async def get_name_bank(
|
||||||
|
language: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Return a random character name for the given language.
|
||||||
|
|
||||||
|
No auth required — used by the world builder form's "random name" button.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
key = f"character_names.{language}"
|
||||||
|
names = await get_setting(db, key)
|
||||||
|
if not names or not isinstance(names, list):
|
||||||
|
# Fallback to English
|
||||||
|
names = await get_setting(db, "character_names.en") or ["Hero"]
|
||||||
|
pick = random.choice(names) if names else "Hero"
|
||||||
|
return {"name": pick, "language": language, "count": len(names)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/i18n/{lang}")
|
@router.get("/i18n/{lang}")
|
||||||
async def i18n(lang: str) -> dict:
|
async def i18n(lang: str) -> dict:
|
||||||
"""Return translation JSON for the given language."""
|
"""Return translation JSON for the given language."""
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def create_world(
|
|||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
preset_id=preset.id if preset else None,
|
preset_id=preset.id if preset else None,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
description=body.notes,
|
description=preset.description if preset else (body.notes or None),
|
||||||
language=body.language,
|
language=body.language,
|
||||||
status="draft",
|
status="draft",
|
||||||
current_time="day_1_hour_8",
|
current_time="day_1_hour_8",
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
|
|||||||
"value": [],
|
"value": [],
|
||||||
"description": "List of {from, to} pairs. Each 'from' substring in LLM scene_text output is replaced with 'to' (can be empty string to remove).",
|
"description": "List of {from, to} pairs. Each 'from' substring in LLM scene_text output is replaced with 'to' (can be empty string to remove).",
|
||||||
},
|
},
|
||||||
|
"ui.header_title": {
|
||||||
|
"value": "",
|
||||||
|
"description": "Header title in the navbar. If empty, falls back to ui.page_title.",
|
||||||
|
},
|
||||||
|
"character_names.en": {
|
||||||
|
"value": ["Eric", "Lyra", "Kael", "Mira", "Thorne", "Elara", "Gareth", "Sera", "Darian", "Ivy",
|
||||||
|
"Bran", "Wren", "Callum", "Astrid", "Rurik", "Faye", "Owen", "Selene", "Magnus", "Tara"],
|
||||||
|
"description": "Character name bank for English worlds (used by random name button).",
|
||||||
|
},
|
||||||
|
"character_names.ru": {
|
||||||
|
"value": ["Эрик", "Элара", "Мира", "Каэль", "Гарет", "Сера", "Дариан", "Тара", "Бран", "Рен",
|
||||||
|
"Алексей", "Мария", "Иван", "Ольга", "Дмитрий", "Анна", "Сергей", "Елена", "Андрей", "Наталья"],
|
||||||
|
"description": "Character name bank for Russian worlds (used by random name button).",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keys whose values should never be returned to the client in plaintext.
|
# Keys whose values should never be returned to the client in plaintext.
|
||||||
|
|||||||
@@ -341,13 +341,26 @@ async def _run_tool_loop(
|
|||||||
"tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0),
|
"tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0),
|
||||||
})
|
})
|
||||||
msg = resp.get("message", {})
|
msg = resp.get("message", {})
|
||||||
|
# Apply text replacements to content
|
||||||
|
content = msg.get("content", "") or ""
|
||||||
|
if content:
|
||||||
|
from app.core.settings_service import apply_text_replacements
|
||||||
|
content = await apply_text_replacements(db, content)
|
||||||
|
msg = dict(msg)
|
||||||
|
msg["content"] = content
|
||||||
tool_calls = msg.get("tool_calls") or []
|
tool_calls = msg.get("tool_calls") or []
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
# No tool calls — append assistant message and ask again
|
# No tool calls — append assistant message and retry with a nudge.
|
||||||
|
# Up to 3 retries.
|
||||||
messages.append({"role": "assistant", "content": msg.get("content", "")})
|
messages.append({"role": "assistant", "content": msg.get("content", "")})
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": f"You must call a tool. Available terminal tool: {terminal_tool}. If you are done with your work, call {terminal_tool} now.",
|
"content": (
|
||||||
|
f"You did not call any tools in your previous response. "
|
||||||
|
f"You MUST use the available tools to accomplish the task. "
|
||||||
|
f"If you tried to call a tool but it didn't work, try again with proper JSON arguments. "
|
||||||
|
f"When you are done, call {terminal_tool}."
|
||||||
|
),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -217,10 +217,16 @@ 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.
|
||||||
|
|
||||||
Supported path formats:
|
Supported path formats:
|
||||||
- `environment.<field_path>` → apply_patch to world.environment
|
|
||||||
- `world.name` → set world.name
|
- `world.name` → set world.name
|
||||||
- `world.description` → set world.description
|
- `world.description` → set world.description
|
||||||
|
- `world.language` → set world.language
|
||||||
- `schemas` → replace entire world.schemas
|
- `schemas` → replace entire world.schemas
|
||||||
|
- `schemas.<type>` → replace a single schema type
|
||||||
|
- `environment.<field_path>` → apply_patch to world.environment
|
||||||
|
- `environment` → replace entire world.environment
|
||||||
|
- `player.<field>` → shorthand for environment.player.<field>
|
||||||
|
- `current_location` → shorthand for environment.current_location
|
||||||
|
- `plot_rails.<field>` → shorthand for environment.plot_rails.<field>
|
||||||
"""
|
"""
|
||||||
from app.core.state_validator import apply_patch
|
from app.core.state_validator import apply_patch
|
||||||
|
|
||||||
@@ -231,25 +237,69 @@ async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
|
|||||||
path = d.get("path", "") or ""
|
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."):
|
old = d.get("old") # noqa: F841 — not used but part of the schema
|
||||||
field = path[len("environment."):]
|
|
||||||
env_patch[field] = new
|
# Skip if new is empty dict {} (model sometimes returns empty)
|
||||||
elif path == "world.name":
|
if isinstance(new, dict) and not new and op == "replace":
|
||||||
if isinstance(new, str):
|
_logger.warning("apply_diff_skip_empty", path=path)
|
||||||
|
continue
|
||||||
|
if new is None and op == "replace":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# World-level fields
|
||||||
|
if path == "world.name" or path == "name":
|
||||||
|
if isinstance(new, str) and new.strip():
|
||||||
world.name = new
|
world.name = new
|
||||||
elif path == "world.description":
|
elif path == "world.description" or path == "description":
|
||||||
if new is None or isinstance(new, str):
|
if isinstance(new, str):
|
||||||
world.description = new
|
world.description = new
|
||||||
|
elif path == "world.language" or path == "language":
|
||||||
|
if isinstance(new, str):
|
||||||
|
world.language = new
|
||||||
|
# Schemas
|
||||||
elif path == "schemas":
|
elif path == "schemas":
|
||||||
if isinstance(new, list):
|
if isinstance(new, list):
|
||||||
world.schemas = new
|
world.schemas = new
|
||||||
elif path.startswith("schemas."):
|
elif path.startswith("schemas."):
|
||||||
# For simplicity, replace entire schemas
|
|
||||||
if isinstance(new, list):
|
if isinstance(new, list):
|
||||||
world.schemas = new
|
world.schemas = new
|
||||||
|
# Environment (full replace)
|
||||||
|
elif path == "environment":
|
||||||
|
if isinstance(new, dict):
|
||||||
|
world.environment = new
|
||||||
|
# Environment field paths
|
||||||
|
elif path.startswith("environment."):
|
||||||
|
field = path[len("environment."):]
|
||||||
|
env_patch[field] = new
|
||||||
|
# Shorthand: player.xxx → environment.player.xxx
|
||||||
|
elif path.startswith("player."):
|
||||||
|
field = path[len("player."):]
|
||||||
|
env_patch[f"player.{field}"] = new
|
||||||
|
elif path == "player":
|
||||||
|
if isinstance(new, dict):
|
||||||
|
env_patch["player"] = new
|
||||||
|
# Shorthand: current_location
|
||||||
|
elif path == "current_location":
|
||||||
|
if isinstance(new, str):
|
||||||
|
env_patch["current_location"] = new
|
||||||
|
elif isinstance(new, dict) and "name" in new:
|
||||||
|
env_patch["current_location"] = new["name"]
|
||||||
|
# Shorthand: plot_rails.xxx
|
||||||
|
elif path.startswith("plot_rails."):
|
||||||
|
field = path[len("plot_rails."):]
|
||||||
|
env_patch[f"plot_rails.{field}"] = new
|
||||||
|
elif path == "plot_rails":
|
||||||
|
if isinstance(new, dict):
|
||||||
|
env_patch["plot_rails"] = new
|
||||||
|
else:
|
||||||
|
_logger.warning("apply_diff_unknown_path", path=path)
|
||||||
|
|
||||||
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
|
||||||
|
# Sync plot_rails column
|
||||||
|
if isinstance(new_env.get("plot_rails"), dict):
|
||||||
|
world.plot_rails = new_env["plot_rails"]
|
||||||
else:
|
else:
|
||||||
_logger.warning("apply_diff_errors", errors=errors)
|
_logger.warning("apply_diff_errors", errors=errors)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
PROMPTS = {
|
PROMPTS = {
|
||||||
"en": """You are the Game Master (GM) of a text RPG in the world "{world_name}".
|
"en": """You are the Game Master (GM) of a text RPG in the world "{world_name}".
|
||||||
|
|
||||||
|
# Language rule
|
||||||
|
The world's language is `{language}`. All entity names, location names, character names, item names, and descriptions that the PLAYER will see MUST be in `{language}`. Internal reasoning and tool arguments stay in English.
|
||||||
|
|
||||||
# Your responsibilities
|
# Your responsibilities
|
||||||
1. Evaluate the player's action and decide what happened mechanically.
|
1. Evaluate the player's action and decide what happened mechanically.
|
||||||
2. Call tools for ANY state change in the world.
|
2. Call tools for ANY state change in the world.
|
||||||
|
|||||||
@@ -13,29 +13,32 @@ Player name: {player_name}
|
|||||||
Language: {language}
|
Language: {language}
|
||||||
Notes: {notes}
|
Notes: {notes}
|
||||||
|
|
||||||
|
# Language rule
|
||||||
|
The world's language is `{language}`. All entity names, location names, character names, item names, and descriptions that the PLAYER will see MUST be in `{language}`. Internal field names (like "health", "stats") stay in English.
|
||||||
|
|
||||||
# Required entity types
|
# Required entity types
|
||||||
You MUST create at minimum these entity types (call schema_add_type for each):
|
You MUST create at minimum these entity types (call schema_add_type for each):
|
||||||
1. `character` — with fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
|
1. `character` — fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
|
||||||
2. `item` — with fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
|
2. `item` — fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
|
||||||
3. `location` — with fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
|
3. `location` — fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
|
||||||
4. `faction` — with fields: name (string, required), description (string), alignment (string)
|
4. `faction` — fields: name (string, required), description (string), alignment (string)
|
||||||
|
|
||||||
You may add additional entity types if the setting requires (e.g. `quest`, `spell`, `vehicle`).
|
You may add additional entity types if the setting requires (e.g. `quest`, `spell`, `vehicle`).
|
||||||
|
|
||||||
# schema_add_type arguments
|
# schema_add_type arguments
|
||||||
Each call to schema_add_type needs:
|
Each call to schema_add_type needs these arguments:
|
||||||
- `type`: lowercase identifier like "character"
|
- `type`: lowercase identifier like "character"
|
||||||
- `verbose`: display name like "Character"
|
- `verbose`: display name like "Character"
|
||||||
- `plural`: plural form like "characters"
|
- `plural`: plural form like "characters"
|
||||||
- `properties`: array of field definitions, each with {name, type, required, min, max, properties}
|
- `properties`: an array of field definition objects. Each field object has these keys: "name" (string), "type" (one of: string, integer, number, boolean, object, array), "required" (boolean), "min" (integer, optional), "max" (integer, optional), "properties" (array, only for type=object), "show" (boolean, default true — if false, the field is hidden from the player UI).
|
||||||
|
|
||||||
Example properties for a character type:
|
Example properties array for a character type:
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{{"name": "name", "type": "string", "required": true}},
|
{{"name": "name", "type": "string", "required": true, "show": true}},
|
||||||
{{"name": "stats", "type": "object", "required": true, "properties": [
|
{{"name": "stats", "type": "object", "required": true, "show": false, "properties": [
|
||||||
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
|
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100, "show": true}},
|
||||||
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}}
|
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100, "show": true}}
|
||||||
]}}
|
]}}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -47,6 +50,7 @@ Call `submit_plan` with a 1-sentence summary of the world you designed.
|
|||||||
- Call schema_add_type for EACH entity type (one call per type).
|
- Call schema_add_type for EACH entity type (one call per type).
|
||||||
- After all schema_add_type calls, call submit_plan exactly once.
|
- After all schema_add_type calls, call submit_plan exactly once.
|
||||||
- After max 10 tool calls you MUST call submit_plan.
|
- After max 10 tool calls you MUST call submit_plan.
|
||||||
|
- If you previously tried to call tools but they didn't work, try again — make sure to use proper JSON arguments.
|
||||||
""",
|
""",
|
||||||
"ru": "",
|
"ru": "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class WorldEditRequest(BaseModel):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class IterateRequest(BaseModel):
|
class IterateRequest(BaseModel):
|
||||||
action: str = Field(min_length=1, max_length=4000)
|
action: str = Field(min_length=1, max_length=4000)
|
||||||
action_source: Literal["custom", "suggested"] = "custom"
|
action_source: str = "custom" # accept any string, default to "custom"
|
||||||
|
|
||||||
|
|
||||||
class AnswerRequest(BaseModel):
|
class AnswerRequest(BaseModel):
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<Navigate to="/admin/stats" replace />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/:tab"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAdmin>
|
<ProtectedRoute requireAdmin>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AdminApi, toErrorMessage } from "@/lib/api";
|
import { AdminApi, toErrorMessage } from "@/lib/api";
|
||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
@@ -30,6 +30,9 @@ const STAGE_OPTIONS = [
|
|||||||
|
|
||||||
const STATUS_OPTIONS = ["", "ok", "timeout", "api_error", "parse_error", "validation_error"];
|
const STATUS_OPTIONS = ["", "ok", "timeout", "api_error", "parse_error", "validation_error"];
|
||||||
|
|
||||||
|
/** Auto-refresh interval (ms). */
|
||||||
|
const AUTO_REFRESH_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
function statusColor(status: string): string {
|
||||||
if (status === "ok") return "bg-ok/15 text-ok";
|
if (status === "ok") return "bg-ok/15 text-ok";
|
||||||
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
|
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
|
||||||
@@ -51,6 +54,14 @@ export function LlmLogsTable() {
|
|||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
// Auto-refresh state.
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [newLogsCount, setNewLogsCount] = useState(0);
|
||||||
|
// True while any filter input/select has focus — we pause polling then so
|
||||||
|
// we don't yank the table out from under the user.
|
||||||
|
const filterFocusRef = useRef(false);
|
||||||
|
const [, forceRerender] = useState(0);
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -62,6 +73,8 @@ export function LlmLogsTable() {
|
|||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
});
|
});
|
||||||
setData(res);
|
setData(res);
|
||||||
|
// Reset the "new logs" counter when we explicitly (re)fetch.
|
||||||
|
setNewLogsCount(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushToast("error", toErrorMessage(err));
|
pushToast("error", toErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -73,6 +86,40 @@ export function LlmLogsTable() {
|
|||||||
void fetchLogs();
|
void fetchLogs();
|
||||||
}, [fetchLogs]);
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
// Silent auto-refresh polling. Only when:
|
||||||
|
// - autoRefresh is enabled
|
||||||
|
// - user is on the first page
|
||||||
|
// - no filter input is currently focused
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const interval = window.setInterval(async () => {
|
||||||
|
if (page !== 1) return;
|
||||||
|
if (filterFocusRef.current) return;
|
||||||
|
try {
|
||||||
|
const res = await AdminApi.llmLogs({
|
||||||
|
world_id: appliedFilters.world_id || undefined,
|
||||||
|
stage: appliedFilters.stage || undefined,
|
||||||
|
status_filter: appliedFilters.status_filter || undefined,
|
||||||
|
page: 1,
|
||||||
|
per_page: perPage,
|
||||||
|
});
|
||||||
|
setData((prev) => {
|
||||||
|
if (!prev) return res;
|
||||||
|
// Detect new items by comparing top-of-list ids.
|
||||||
|
const prevIds = new Set(prev.items.map((l) => l.id));
|
||||||
|
const newOnes = res.items.filter((l) => !prevIds.has(l.id));
|
||||||
|
if (newOnes.length > 0) {
|
||||||
|
setNewLogsCount((n) => n + newOnes.length);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Silent — don't spam toasts on auto-refresh errors.
|
||||||
|
}
|
||||||
|
}, AUTO_REFRESH_INTERVAL_MS);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [autoRefresh, page, appliedFilters, perPage]);
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
setAppliedFilters(filters);
|
setAppliedFilters(filters);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -92,15 +139,50 @@ export function LlmLogsTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter input focus tracking — we use a wrapping <div> with onFocus /
|
||||||
|
// onBlur (capture phase) so any input/select inside counts.
|
||||||
|
const handleFilterFocus = () => {
|
||||||
|
filterFocusRef.current = true;
|
||||||
|
forceRerender((n) => n + 1);
|
||||||
|
};
|
||||||
|
const handleFilterBlur = () => {
|
||||||
|
filterFocusRef.current = false;
|
||||||
|
forceRerender((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
// Short preview of the world_id filter value, for the column header.
|
// Short preview of the world_id filter value, for the column header.
|
||||||
const worldFilterPreview = appliedFilters.world_id
|
const worldFilterPreview = appliedFilters.world_id
|
||||||
? appliedFilters.world_id.slice(0, 8)
|
? appliedFilters.world_id.slice(0, 8)
|
||||||
: "";
|
: "";
|
||||||
|
const autoRefreshActive = autoRefresh && page === 1 && !filterFocusRef.current;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card title={t("admin.tab_logs")}>
|
<Card
|
||||||
<div className="grid gap-2 sm:grid-cols-4">
|
title={t("admin.tab_logs")}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{newLogsCount > 0 && (
|
||||||
|
<span className="badge bg-accent/15 text-accent">
|
||||||
|
{t("admin.logs_new_count", { count: newLogsCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={autoRefresh ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setAutoRefresh((v) => !v)}
|
||||||
|
title={t("admin.logs_auto_refresh_tip")}
|
||||||
|
>
|
||||||
|
{autoRefresh ? "⏸ " + t("admin.logs_pause") : "▶ " + t("admin.logs_resume")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid gap-2 sm:grid-cols-4"
|
||||||
|
onFocus={handleFilterFocus}
|
||||||
|
onBlur={handleFilterBlur}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
label={t("admin.logs_filter_world")}
|
label={t("admin.logs_filter_world")}
|
||||||
value={filters.world_id}
|
value={filters.world_id}
|
||||||
@@ -139,6 +221,11 @@ export function LlmLogsTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-fg-muted">
|
||||||
|
{autoRefreshActive
|
||||||
|
? t("admin.logs_auto_refresh_on")
|
||||||
|
: t("admin.logs_auto_refresh_off")}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -86,19 +86,20 @@ function fieldType(key: string): FieldType {
|
|||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the appropriate hint text for a given setting key, if any. */
|
/**
|
||||||
function hintFor(key: string): string | undefined {
|
* Returns the localized description for a setting key. The key format is
|
||||||
switch (key) {
|
* `admin.setting_desc.{setting_key}`. Falls back to the backend-provided
|
||||||
case "embeddings.api_url":
|
* description, then to undefined.
|
||||||
case "embeddings.api_key":
|
*/
|
||||||
return "If empty, falls back to llm.api_url / llm.api_key";
|
function useSettingDesc(): (key: string, fallback?: string) => string | undefined {
|
||||||
case "embeddings.model":
|
const { t } = useTranslation();
|
||||||
return "Default: text-embedding-3-small";
|
return (key: string, fallback?: string) => {
|
||||||
case "embeddings.provider":
|
const tKey = `admin.setting_desc.${key}`;
|
||||||
return "If provider=openai and api_url is empty, the system falls back to llm.api_url";
|
const translated = t(tKey);
|
||||||
default:
|
// i18next returns the key itself when no translation exists.
|
||||||
return undefined;
|
if (translated === tKey) return fallback;
|
||||||
}
|
return translated;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function castValue(key: string, raw: string): string {
|
function castValue(key: string, raw: string): string {
|
||||||
@@ -120,6 +121,22 @@ export function SettingsPanel() {
|
|||||||
const [data, setData] = useState<AdminSettingsResponse | null>(null);
|
const [data, setData] = useState<AdminSettingsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||||
|
// Collapsed state: Set of group ids that are collapsed. Default: ALL
|
||||||
|
// groups collapsed (the user clicks to expand the one they want to edit).
|
||||||
|
const allGroupIds = useMemo(() => GROUPS.map((g) => g.id).concat(["text_replacements"]), []);
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(allGroupIds));
|
||||||
|
|
||||||
|
const toggleCollapsed = (id: string) => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAll = () => setCollapsed(new Set());
|
||||||
|
const collapseAll = () => setCollapsed(new Set(allGroupIds));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -174,7 +191,7 @@ export function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Object.keys(diff).length === 0) {
|
if (Object.keys(diff).length === 0) {
|
||||||
pushToast("info", "No changes to save.");
|
pushToast("info", t("admin.no_changes"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +233,7 @@ export function SettingsPanel() {
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
const before = data.settings[key] ?? "";
|
const before = data.settings[key] ?? "";
|
||||||
if (before === value) {
|
if (before === value) {
|
||||||
pushToast("info", "No changes to save.");
|
pushToast("info", t("admin.no_changes"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -260,12 +277,22 @@ export function SettingsPanel() {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<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">
|
||||||
Each card saves independently. Secret values (api_key) are masked after save.
|
{t("admin.settings_hint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={expandAll}>
|
||||||
|
{t("admin.expand_all")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={collapseAll}>
|
||||||
|
{t("admin.collapse_all")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{GROUPS.map((g) => {
|
{GROUPS.map((g) => {
|
||||||
const entries = grouped[g.id];
|
const entries = grouped[g.id];
|
||||||
// LLM group always renders even if api_url/api_key/etc. are missing
|
// LLM group always renders even if api_url/api_key/etc. are missing
|
||||||
@@ -273,6 +300,7 @@ export function SettingsPanel() {
|
|||||||
// fetcher, text replacements) live here. For other groups, skip
|
// fetcher, text replacements) live here. For other groups, skip
|
||||||
// when empty.
|
// when empty.
|
||||||
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
|
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
|
||||||
|
const isCollapsed = collapsed.has(g.id);
|
||||||
return (
|
return (
|
||||||
<Fragment key={g.id}>
|
<Fragment key={g.id}>
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
@@ -280,6 +308,8 @@ export function SettingsPanel() {
|
|||||||
title={t(g.labelKey)}
|
title={t(g.labelKey)}
|
||||||
entries={entries || []}
|
entries={entries || []}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
|
collapsed={isCollapsed}
|
||||||
|
onToggle={() => toggleCollapsed(g.id)}
|
||||||
onChange={(key, value) =>
|
onChange={(key, value) =>
|
||||||
setDraft((d) => ({ ...d, [key]: value }))
|
setDraft((d) => ({ ...d, [key]: value }))
|
||||||
}
|
}
|
||||||
@@ -289,6 +319,8 @@ export function SettingsPanel() {
|
|||||||
<TextReplacementsCard
|
<TextReplacementsCard
|
||||||
rawValue={draft["llm.text_replacements"] ?? ""}
|
rawValue={draft["llm.text_replacements"] ?? ""}
|
||||||
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
|
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
|
||||||
|
collapsed={collapsed.has("text_replacements")}
|
||||||
|
onToggle={() => toggleCollapsed("text_replacements")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -303,11 +335,22 @@ interface SettingsGroupCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
entries: Array<{ key: string; description?: string }>;
|
entries: Array<{ key: string; description?: string }>;
|
||||||
draft: Record<string, string>;
|
draft: Record<string, string>;
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
onChange: (key: string, value: string) => void;
|
onChange: (key: string, value: string) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
|
function SettingsGroupCard({
|
||||||
|
groupId,
|
||||||
|
title,
|
||||||
|
entries,
|
||||||
|
draft,
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
}: SettingsGroupCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -331,13 +374,28 @@ function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }:
|
|||||||
void groupId; // groupId currently used only for the model-entry lookup above
|
void groupId; // groupId currently used only for the model-entry lookup above
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={title}
|
title={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center gap-2 text-left"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-fg-muted w-3 inline-block">
|
||||||
|
{collapsed ? "▶" : "▼"}
|
||||||
|
</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
|
!collapsed && (
|
||||||
<Button size="sm" onClick={handleSave} loading={saving}>
|
<Button size="sm" onClick={handleSave} loading={saving}>
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!collapsed && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{showGrid && (
|
{showGrid && (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -362,6 +420,7 @@ function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }:
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,23 +435,30 @@ interface SettingFieldProps {
|
|||||||
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
|
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ft = fieldType(settingKey);
|
const ft = fieldType(settingKey);
|
||||||
const hint = hintFor(settingKey) || description;
|
const localizedDesc = useSettingDesc();
|
||||||
|
const hint = localizedDesc(settingKey, description);
|
||||||
const label = settingKey;
|
const label = settingKey;
|
||||||
|
|
||||||
if (ft === "boolean") {
|
if (ft === "boolean") {
|
||||||
|
// Render booleans as a checkbox (with the key as the label) rather than
|
||||||
|
// a dropdown — it's a more natural control for a true/false toggle.
|
||||||
|
const checked = value === "true";
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
|
<label
|
||||||
<select
|
htmlFor={`setting-${settingKey}`}
|
||||||
id={`setting-${settingKey}`}
|
className="flex items-center gap-2 text-sm text-fg cursor-pointer"
|
||||||
className="input"
|
|
||||||
autoComplete="off"
|
|
||||||
value={value === "true" ? "true" : value === "false" ? "false" : value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
>
|
>
|
||||||
<option value="true">true</option>
|
<input
|
||||||
<option value="false">false</option>
|
id={`setting-${settingKey}`}
|
||||||
</select>
|
type="checkbox"
|
||||||
|
autoComplete="off"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? "true" : "false")}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{label}</span>
|
||||||
|
</label>
|
||||||
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -503,10 +569,11 @@ interface LlmModelFieldProps {
|
|||||||
*/
|
*/
|
||||||
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
|
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const localizedDesc = useSettingDesc();
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
const [models, setModels] = useState<string[] | null>(null);
|
const [models, setModels] = useState<string[] | null>(null);
|
||||||
const [fetchError, setFetchError] = useState(false);
|
const [fetchError, setFetchError] = useState(false);
|
||||||
const hint = description;
|
const hint = localizedDesc("llm.model", description);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
@@ -604,6 +671,8 @@ interface TextReplacementRule {
|
|||||||
interface TextReplacementsCardProps {
|
interface TextReplacementsCardProps {
|
||||||
rawValue: string;
|
rawValue: string;
|
||||||
onSave: (serializedJson: string) => void;
|
onSave: (serializedJson: string) => void;
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse the persisted JSON string into a list of rules. Tolerates
|
/** Parse the persisted JSON string into a list of rules. Tolerates
|
||||||
@@ -634,7 +703,7 @@ function serializeReplacements(rules: TextReplacementRule[]): string {
|
|||||||
return JSON.stringify(rules.map((r) => ({ from: r.from, to: r.to })));
|
return JSON.stringify(rules.map((r) => ({ from: r.from, to: r.to })));
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: TextReplacementsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Local working copy — only committed to parent draft when Save is
|
// Local working copy — only committed to parent draft when Save is
|
||||||
// clicked. This avoids marking the LLM group as dirty on every keystroke.
|
// clicked. This avoids marking the LLM group as dirty on every keystroke.
|
||||||
@@ -673,14 +742,29 @@ function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={t("admin.text_replacements_title")}
|
title={
|
||||||
description={t("admin.text_replacements_help")}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center gap-2 text-left"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-fg-muted w-3 inline-block">
|
||||||
|
{collapsed ? "▶" : "▼"}
|
||||||
|
</span>
|
||||||
|
<span>{t("admin.text_replacements_title")}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
description={!collapsed ? t("admin.text_replacements_help") : undefined}
|
||||||
actions={
|
actions={
|
||||||
|
!collapsed && (
|
||||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!collapsed && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rules.length === 0 && (
|
{rules.length === 0 && (
|
||||||
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
|
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
|
||||||
@@ -719,6 +803,7 @@ function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
|||||||
+ {t("admin.text_replacements_add")}
|
+ {t("admin.text_replacements_add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function EmbeddingsTestCard() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-fg-muted">
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
If provider=openai and api_url is empty, the system falls back to llm.api_url
|
{t("admin.test_provider_hint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,7 +328,7 @@ function RecreateCollectionsCard() {
|
|||||||
return (
|
return (
|
||||||
<Card title={t("admin.recreate_collections")}>
|
<Card title={t("admin.recreate_collections")}>
|
||||||
<p className="text-xs text-fg-muted mb-3">
|
<p className="text-xs text-fg-muted mb-3">
|
||||||
Drops and recreates Qdrant collections based on current embeddings dimension.
|
{t("admin.recreate_collections_hint")}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={run} loading={loading} variant="danger">
|
<Button onClick={run} loading={loading} variant="danger">
|
||||||
{loading ? t("admin.running") : t("admin.recreate_collections")}
|
{loading ? t("admin.running") : t("admin.recreate_collections")}
|
||||||
@@ -336,10 +336,10 @@ function RecreateCollectionsCard() {
|
|||||||
{result && (
|
{result && (
|
||||||
<div className="mt-3 text-sm">
|
<div className="mt-3 text-sm">
|
||||||
<p>
|
<p>
|
||||||
<span className="text-fg-muted">Dropped:</span> {result.dropped}
|
<span className="text-fg-muted">{t("admin.recreate_dropped")}:</span> {result.dropped}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-fg-muted">Created:</span> {result.created}
|
<span className="text-fg-muted">{t("admin.recreate_created")}:</span> {result.created}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-fg-muted">{t("admin.dimension")}:</span> {result.dimension}
|
<span className="text-fg-muted">{t("admin.dimension")}:</span> {result.dimension}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import type { Step } from "@/types";
|
import type { Step } from "@/types";
|
||||||
@@ -11,23 +12,49 @@ export interface ChatViewProps {
|
|||||||
|
|
||||||
export function ChatView({ className }: ChatViewProps) {
|
export function ChatView({ className }: ChatViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const world = useSessionStore((s) => s.world);
|
||||||
const recentSteps = useSessionStore((s) => s.recentSteps);
|
const recentSteps = useSessionStore((s) => s.recentSteps);
|
||||||
const streamMessages = useSessionStore((s) => s.streamMessages);
|
const streamMessages = useSessionStore((s) => s.streamMessages);
|
||||||
const streamingText = useSessionStore((s) => s.streamingText);
|
const streamingText = useSessionStore((s) => s.streamingText);
|
||||||
const submitting = useSessionStore((s) => s.submitting);
|
const submitting = useSessionStore((s) => s.submitting);
|
||||||
const error = useSessionStore((s) => s.error);
|
const error = useSessionStore((s) => s.error);
|
||||||
const empty = recentSteps.length === 0 && !submitting && streamMessages.length === 0;
|
|
||||||
|
const introScene = world?.intro_scene || null;
|
||||||
|
const hasIntro = Boolean(introScene);
|
||||||
|
const noSteps = recentSteps.length === 0;
|
||||||
|
// Empty state: no intro scene AND no steps AND nothing streaming.
|
||||||
|
const empty = noSteps && !submitting && streamMessages.length === 0 && !hasIntro;
|
||||||
|
// Show intro scene as the first chat message when there are no steps yet
|
||||||
|
// (and we're not currently streaming a new response that would replace it).
|
||||||
|
const showIntro = hasIntro && noSteps && !streamingText;
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
}, [recentSteps, streamMessages, streamingText, submitting]);
|
}, [recentSteps, streamMessages, streamingText, submitting, showIntro]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}>
|
<div className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}>
|
||||||
{empty && (
|
{empty && (
|
||||||
<div className="m-auto text-center text-sm text-fg-muted py-8">
|
<div className="m-auto max-w-md text-center text-sm text-fg-muted py-8 space-y-2">
|
||||||
<p>{t("play.no_actions_yet")}</p>
|
<p>{t("play.no_intro_scene")}</p>
|
||||||
|
{world && (
|
||||||
|
<Link
|
||||||
|
to={`/worlds/${world.id}/edit`}
|
||||||
|
className="inline-block rounded-md border border-accent/40 px-3 py-1.5 text-accent hover:bg-accent/10"
|
||||||
|
>
|
||||||
|
{t("play.go_to_edit")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showIntro && introScene && (
|
||||||
|
<div className="max-w-[90%] rounded-lg bg-bg-card px-3 py-2">
|
||||||
|
<p className="text-xs font-semibold text-fg-muted mb-0.5">
|
||||||
|
{t("play.game_master")}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-fg">{introScene}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
import { useUiSettingsStore } from "@/stores/uiSettingsStore";
|
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
@@ -19,7 +19,9 @@ export function Navbar() {
|
|||||||
const language = useUiStore((s) => s.language);
|
const language = useUiStore((s) => s.language);
|
||||||
const setLanguage = useUiStore((s) => s.setLanguage);
|
const setLanguage = useUiStore((s) => s.setLanguage);
|
||||||
const pushToast = useToastStore((s) => s.push);
|
const pushToast = useToastStore((s) => s.push);
|
||||||
const logoUrl = useUiSettingsStore((s) => s.settings?.logo_url);
|
const settings = useUiSettingsStore((s) => s.settings);
|
||||||
|
const logoUrl = settings?.logo_url;
|
||||||
|
const headerTitle = selectHeaderTitle(settings);
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -55,13 +57,13 @@ export function Navbar() {
|
|||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt={t("common.app_name")}
|
alt={headerTitle}
|
||||||
className="h-7 w-7 rounded object-contain"
|
className="h-7 w-7 rounded object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-lg">🎲</span>
|
<span className="text-lg">🎲</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-fg">{t("common.app_name")}</span>
|
<span className="font-semibold text-fg">{headerTitle}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<div className="hidden md:flex items-center gap-1 ml-4">
|
<div className="hidden md:flex items-center gap-1 ml-4">
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ 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";
|
||||||
import { WorldsApi, PresetsApi, SessionsApi } from "@/lib/api";
|
import { WorldsApi, PresetsApi, SessionsApi, MiscApi, 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 {
|
import type {
|
||||||
CreateWorldPayload,
|
CreateWorldPayload,
|
||||||
Language,
|
Language,
|
||||||
PresetListItem,
|
PresetListItem,
|
||||||
World,
|
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
@@ -41,13 +40,6 @@ interface BuilderState {
|
|||||||
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FORM_DATA: Record<string, unknown> = {
|
|
||||||
setting: "fantasy",
|
|
||||||
tone: "epic",
|
|
||||||
starting_level: 1,
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface WorldBuilderProps {
|
export interface WorldBuilderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -62,14 +54,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
const [presetsLoading, setPresetsLoading] = useState(false);
|
const [presetsLoading, setPresetsLoading] = useState(false);
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
// Form fields
|
||||||
|
const [setting, setSetting] = useState("");
|
||||||
|
const [name, setName] = useState<string>(() => t("worlds.default_name"));
|
||||||
const [language, setLanguage] = useState<Language>("en");
|
const [language, setLanguage] = useState<Language>("en");
|
||||||
const [playerName, setPlayerName] = useState("");
|
const [playerName, setPlayerName] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [formData, setFormData] = useState<string>(() =>
|
const [nameFetching, setNameFetching] = useState(false);
|
||||||
JSON.stringify(DEFAULT_FORM_DATA, null, 2),
|
|
||||||
);
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const [state, setState] = useState<BuilderState>({
|
const [state, setState] = useState<BuilderState>({
|
||||||
@@ -84,6 +75,18 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
// SSE onEvent handler captured at subscription time) without forcing a
|
// SSE onEvent handler captured at subscription time) without forcing a
|
||||||
// re-subscribe on every controller change.
|
// re-subscribe on every controller change.
|
||||||
const controllerRef = useRef<SseController | null>(null);
|
const controllerRef = useRef<SseController | null>(null);
|
||||||
|
// Created world id — saved on submit so we can redirect to its edit page
|
||||||
|
// when the builder stream completes.
|
||||||
|
const createdWorldIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// When the user changes language, refresh the default world name (only if
|
||||||
|
// the user hasn't customized it). This keeps the placeholder in sync.
|
||||||
|
const lastDefaultNameRef = useRef<string>(t("worlds.default_name"));
|
||||||
|
useEffect(() => {
|
||||||
|
const def = t("worlds.default_name");
|
||||||
|
setName((cur) => (cur === lastDefaultNameRef.current ? def : cur));
|
||||||
|
lastDefaultNameRef.current = def;
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -92,7 +95,12 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPresets(res.items);
|
setPresets(res.items);
|
||||||
if (res.items.length > 0) setSelectedPresetId(res.items[0].id);
|
if (res.items.length > 0) {
|
||||||
|
setSelectedPresetId(res.items[0].id);
|
||||||
|
// Default world name for preset mode = preset name.
|
||||||
|
setName(res.items[0].name);
|
||||||
|
lastDefaultNameRef.current = res.items[0].name;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -110,21 +118,51 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
};
|
};
|
||||||
}, [controller]);
|
}, [controller]);
|
||||||
|
|
||||||
|
// When mode switches, reset the default world name appropriately.
|
||||||
|
const handleModeChange = (next: Mode) => {
|
||||||
|
if (next === mode) return;
|
||||||
|
setMode(next);
|
||||||
|
if (next === "preset") {
|
||||||
|
const p = presets.find((x) => x.id === selectedPresetId);
|
||||||
|
if (p) {
|
||||||
|
setName(p.name);
|
||||||
|
lastDefaultNameRef.current = p.name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const def = t("worlds.default_name");
|
||||||
|
setName(def);
|
||||||
|
lastDefaultNameRef.current = def;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When user picks a different preset, update the default world name to
|
||||||
|
// match the preset's name (unless they've already customized it).
|
||||||
|
const handlePresetChange = (id: string) => {
|
||||||
|
setSelectedPresetId(id);
|
||||||
|
const p = presets.find((x) => x.id === id);
|
||||||
|
if (p) {
|
||||||
|
setName(p.name);
|
||||||
|
lastDefaultNameRef.current = p.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
if (submitting || state.phase === "building") return false;
|
if (submitting || state.phase === "building") return false;
|
||||||
if (!name.trim() || !playerName.trim()) return false;
|
if (!name.trim() || !playerName.trim()) return false;
|
||||||
if (mode === "preset" && !selectedPresetId) return false;
|
if (mode === "preset" && !selectedPresetId) return false;
|
||||||
if (mode === "form" && formError) return false;
|
if (mode === "form" && !setting.trim()) return false;
|
||||||
return true;
|
return true;
|
||||||
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, formError]);
|
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, setting]);
|
||||||
|
|
||||||
const onFormChange = (v: string) => {
|
const handleRandomName = async () => {
|
||||||
setFormData(v);
|
setNameFetching(true);
|
||||||
try {
|
try {
|
||||||
JSON.parse(v);
|
const res = await MiscApi.randomName(language);
|
||||||
setFormError(null);
|
if (res?.name) setPlayerName(res.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormError(err instanceof Error ? err.message : "Invalid JSON");
|
pushToast("error", toErrorMessage(err, t("builder.name_random_failed")));
|
||||||
|
} finally {
|
||||||
|
setNameFetching(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,10 +297,15 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const parsedForm = JSON.parse(formData);
|
// Backend still expects `form_data` as a free-form object for
|
||||||
|
// mode=form. We collect our structured fields into that object so
|
||||||
|
// the user no longer has to edit raw JSON.
|
||||||
payload = {
|
payload = {
|
||||||
mode: "form",
|
mode: "form",
|
||||||
form_data: parsedForm,
|
form_data: {
|
||||||
|
setting: setting.trim(),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
language,
|
language,
|
||||||
player_name: playerName.trim(),
|
player_name: playerName.trim(),
|
||||||
@@ -295,7 +338,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
// 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) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to create world";
|
const message = toErrorMessage(err, t("builder.build_failed"));
|
||||||
pushToast("error", message);
|
pushToast("error", message);
|
||||||
setState((s) => ({ ...s, phase: "error" }));
|
setState((s) => ({ ...s, phase: "error" }));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -303,16 +346,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdWorldIdRef = useMemoRef<string | null>(null);
|
// On done, navigate to the EDIT page (not play) — the user should
|
||||||
|
// review the world, generate the intro scene, then click Play when ready.
|
||||||
// On done, navigate to play page
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.phase === "done" && createdWorldIdRef.current) {
|
if (state.phase === "done" && createdWorldIdRef.current) {
|
||||||
const id = createdWorldIdRef.current;
|
const id = createdWorldIdRef.current;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
void WorldsApi.get(id).then((w: World) => {
|
navigate(`/worlds/${id}/edit`);
|
||||||
if (w.status === "ready") navigate(`/worlds/${id}/play`);
|
|
||||||
});
|
|
||||||
}, 800);
|
}, 800);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -363,13 +403,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
active={mode === "preset"}
|
active={mode === "preset"}
|
||||||
title={t("builder.mode_preset")}
|
title={t("builder.mode_preset")}
|
||||||
description={t("builder.preset_help")}
|
description={t("builder.preset_help")}
|
||||||
onClick={() => setMode("preset")}
|
onClick={() => handleModeChange("preset")}
|
||||||
/>
|
/>
|
||||||
<ModeButton
|
<ModeButton
|
||||||
active={mode === "form"}
|
active={mode === "form"}
|
||||||
title={t("builder.mode_form")}
|
title={t("builder.mode_form")}
|
||||||
description={t("builder.form_help")}
|
description={t("builder.form_help")}
|
||||||
onClick={() => setMode("form")}
|
onClick={() => handleModeChange("form")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -392,7 +432,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={selectedPresetId}
|
value={selectedPresetId}
|
||||||
onChange={(e) => setSelectedPresetId(e.target.value)}
|
onChange={(e) => handlePresetChange(e.target.value)}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
@@ -406,32 +446,49 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === "form" && (
|
{mode === "form" && (
|
||||||
<div>
|
|
||||||
<label className="label">{t("builder.step_form")}</label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData}
|
label={t("builder.setting")}
|
||||||
onChange={(e) => onFormChange(e.target.value)}
|
hint={t("builder.setting_help")}
|
||||||
rows={10}
|
value={setting}
|
||||||
className="font-mono text-xs"
|
onChange={(e) => setSetting(e.target.value)}
|
||||||
error={formError ?? undefined}
|
rows={4}
|
||||||
|
placeholder={t("builder.setting_placeholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label={t("builder.world_name")}
|
label={t("builder.world_name")}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="The Forgotten Realm"
|
placeholder={t("worlds.default_name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<div>
|
||||||
label={t("builder.player_name")}
|
<label className="label">{t("builder.player_name")}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
value={playerName}
|
value={playerName}
|
||||||
onChange={(e) => setPlayerName(e.target.value)}
|
onChange={(e) => setPlayerName(e.target.value)}
|
||||||
placeholder="Aria"
|
placeholder="Aria"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void handleRandomName()}
|
||||||
|
loading={nameFetching}
|
||||||
|
disabled={nameFetching}
|
||||||
|
title={t("builder.name_random_tip")}
|
||||||
|
aria-label={t("builder.name_random_tip")}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
🎲
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">{t("common.language")}</label>
|
<label className="label">{t("common.language")}</label>
|
||||||
<select
|
<select
|
||||||
@@ -453,7 +510,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button onClick={handleSubmit} loading={submitting} disabled={!canSubmit} fullWidth>
|
<Button onClick={() => void handleSubmit()} loading={submitting} disabled={!canSubmit} fullWidth>
|
||||||
{submitting ? t("builder.creating") : t("builder.create_button")}
|
{submitting ? t("builder.creating") : t("builder.create_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,10 +617,3 @@ function ModeButton({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small helper: useRef but returned as memo-like value for ergonomics.
|
|
||||||
function useMemoRef<T>(initial: T): { current: T } {
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const ref = useMemo(() => ({ current: initial }), []);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Card } from "@/components/ui/Card";
|
|||||||
import { JsonEditor } from "@/components/ui/JsonEditor";
|
import { JsonEditor } from "@/components/ui/JsonEditor";
|
||||||
import { Spinner } from "@/components/ui/Spinner";
|
import { Spinner } from "@/components/ui/Spinner";
|
||||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||||
|
import { ToolCallBubble } from "@/components/sessions/ToolCallBubble";
|
||||||
|
|
||||||
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
|
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ interface DiffItem {
|
|||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string;
|
id: string;
|
||||||
kind: "comment" | "clarification" | "change_proposed" | "info" | "error";
|
kind: "comment" | "clarification" | "change_proposed" | "info" | "error" | "tool_call";
|
||||||
text: string;
|
text: string;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
diff?: unknown;
|
diff?: unknown;
|
||||||
@@ -36,6 +37,10 @@ interface LogEntry {
|
|||||||
decision?: "accept" | "reject";
|
decision?: "accept" | "reject";
|
||||||
/** Whether the accept/reject request is in-flight. */
|
/** Whether the accept/reject request is in-flight. */
|
||||||
deciding?: boolean;
|
deciding?: boolean;
|
||||||
|
/** For tool_call entries: tool name + result + success. */
|
||||||
|
tool?: string;
|
||||||
|
toolResult?: unknown;
|
||||||
|
toolSuccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorldEditorProps {
|
export interface WorldEditorProps {
|
||||||
@@ -110,6 +115,21 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
|||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "tool_call": {
|
||||||
|
const d = event.data as { tool: string; arguments?: unknown; result?: unknown; is_success?: boolean };
|
||||||
|
setLogs((l) => [
|
||||||
|
...l,
|
||||||
|
{
|
||||||
|
id: uid(),
|
||||||
|
kind: "tool_call",
|
||||||
|
text: d.tool,
|
||||||
|
tool: d.tool,
|
||||||
|
toolResult: d.result,
|
||||||
|
toolSuccess: d.is_success ?? false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "comment": {
|
case "comment": {
|
||||||
const d = event.data as { text: string };
|
const d = event.data as { text: string };
|
||||||
setLogs((l) => [...l, { id: uid(), kind: "comment", text: d.text }]);
|
setLogs((l) => [...l, { id: uid(), kind: "comment", text: d.text }]);
|
||||||
@@ -338,6 +358,8 @@ interface LogEntryViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps) {
|
function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (entry.kind === "clarification") {
|
if (entry.kind === "clarification") {
|
||||||
return (
|
return (
|
||||||
<ClarificationCard
|
<ClarificationCard
|
||||||
@@ -357,9 +379,33 @@ function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "tool_call" && entry.tool) {
|
||||||
|
return (
|
||||||
|
<ToolCallBubble
|
||||||
|
tool={entry.tool}
|
||||||
|
result={entry.toolResult}
|
||||||
|
success={entry.toolSuccess ?? false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "comment") {
|
||||||
|
// Render comments from the world editor as assistant chat bubbles —
|
||||||
|
// they're the LLM's narrative reply to the user's instruction.
|
||||||
|
return (
|
||||||
|
<div className="max-w-[90%] rounded-lg bg-bg-card px-3 py-2">
|
||||||
|
<p className="text-xs font-semibold text-fg-muted mb-0.5">
|
||||||
|
{t("editor.assistant")}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-fg">{entry.text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <p className="text-xs text-fg-muted">{entry.text}</p>;
|
return <p className="text-xs text-fg-muted">{entry.text}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"never_played": "Not played yet",
|
"never_played": "Not played yet",
|
||||||
"current_time": "Current time",
|
"current_time": "Current time",
|
||||||
"player": "Player",
|
"player": "Player",
|
||||||
|
"default_name": "New World",
|
||||||
"delete_confirm": "Delete this world? This cannot be undone.",
|
"delete_confirm": "Delete this world? This cannot be undone.",
|
||||||
"deleted": "World deleted.",
|
"deleted": "World deleted.",
|
||||||
"delete_failed": "Failed to delete world.",
|
"delete_failed": "Failed to delete world.",
|
||||||
@@ -112,8 +113,13 @@
|
|||||||
"mode_form": "Custom form",
|
"mode_form": "Custom form",
|
||||||
"preset_help": "Start from a pre-configured world template.",
|
"preset_help": "Start from a pre-configured world template.",
|
||||||
"form_help": "Configure the world yourself with custom rules.",
|
"form_help": "Configure the world yourself with custom rules.",
|
||||||
|
"setting": "Setting",
|
||||||
|
"setting_help": "Describe the world setting (e.g. 'post-apocalyptic underground bunker').",
|
||||||
|
"setting_placeholder": "post-apocalyptic underground bunker",
|
||||||
"world_name": "World name",
|
"world_name": "World name",
|
||||||
"player_name": "Player character name",
|
"player_name": "Player character name",
|
||||||
|
"name_random_tip": "Roll a random name",
|
||||||
|
"name_random_failed": "Failed to fetch a random name.",
|
||||||
"notes": "Notes (optional)",
|
"notes": "Notes (optional)",
|
||||||
"notes_help": "Additional instructions for the Game Master.",
|
"notes_help": "Additional instructions for the Game Master.",
|
||||||
"language_en": "English",
|
"language_en": "English",
|
||||||
@@ -145,6 +151,7 @@
|
|||||||
"clarification": "Clarification needed",
|
"clarification": "Clarification needed",
|
||||||
"change_proposed": "Change proposed",
|
"change_proposed": "Change proposed",
|
||||||
"comment": "Comment",
|
"comment": "Comment",
|
||||||
|
"assistant": "Assistant",
|
||||||
"apply_changes": "Apply changes",
|
"apply_changes": "Apply changes",
|
||||||
"discard_changes": "Discard changes",
|
"discard_changes": "Discard changes",
|
||||||
"changes_applied": "Changes applied.",
|
"changes_applied": "Changes applied.",
|
||||||
@@ -178,12 +185,22 @@
|
|||||||
"weather": "Weather",
|
"weather": "Weather",
|
||||||
"player": "Player",
|
"player": "Player",
|
||||||
"hp": "HP",
|
"hp": "HP",
|
||||||
|
"health": "Health",
|
||||||
|
"mana": "Mana",
|
||||||
|
"strength": "Strength",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"inventory": "Inventory",
|
"inventory": "Inventory",
|
||||||
"conditions": "Conditions",
|
"conditions": "Conditions",
|
||||||
"npcs": "NPCs",
|
"npcs": "NPCs",
|
||||||
"items": "Items",
|
"items": "Items",
|
||||||
"plot_rails": "Plot rails",
|
"plot_rails": "Plot rails",
|
||||||
|
"hooks": "Hooks",
|
||||||
|
"current_goals": "Current goals",
|
||||||
|
"completed_goals": "Completed goals",
|
||||||
|
"no_env_data": "No environment data yet.",
|
||||||
|
"no_intro_scene": "This world has no intro scene yet. Go to Edit to generate one.",
|
||||||
|
"go_to_edit": "Go to Edit",
|
||||||
|
"edit_world": "Edit world",
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
"scene": "Scene",
|
"scene": "Scene",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
@@ -227,6 +244,10 @@
|
|||||||
"settings_saved": "Settings saved.",
|
"settings_saved": "Settings saved.",
|
||||||
"settings_save_failed": "Failed to save settings.",
|
"settings_save_failed": "Failed to save settings.",
|
||||||
"settings_load_failed": "Failed to load settings.",
|
"settings_load_failed": "Failed to load settings.",
|
||||||
|
"settings_hint": "Each card saves independently. Secret values (api_key) are masked after save.",
|
||||||
|
"expand_all": "Expand all",
|
||||||
|
"collapse_all": "Collapse all",
|
||||||
|
"no_changes": "No changes to save.",
|
||||||
"group_llm": "LLM Configuration",
|
"group_llm": "LLM Configuration",
|
||||||
"group_embeddings": "Embeddings",
|
"group_embeddings": "Embeddings",
|
||||||
"group_qdrant": "Qdrant",
|
"group_qdrant": "Qdrant",
|
||||||
@@ -248,6 +269,12 @@
|
|||||||
"logs_prompt": "Prompt",
|
"logs_prompt": "Prompt",
|
||||||
"logs_response": "Response",
|
"logs_response": "Response",
|
||||||
"logs_error": "Error",
|
"logs_error": "Error",
|
||||||
|
"logs_new_count": "{{count}} new",
|
||||||
|
"logs_pause": "Pause",
|
||||||
|
"logs_resume": "Resume",
|
||||||
|
"logs_auto_refresh_on": "Auto-refresh active (page 1, 5s).",
|
||||||
|
"logs_auto_refresh_off": "Auto-refresh paused.",
|
||||||
|
"logs_auto_refresh_tip": "Toggle auto-refresh (5s polling on page 1).",
|
||||||
"users_email": "Email",
|
"users_email": "Email",
|
||||||
"users_username": "Username",
|
"users_username": "Username",
|
||||||
"users_admin": "Admin",
|
"users_admin": "Admin",
|
||||||
@@ -270,6 +297,10 @@
|
|||||||
"test_embeddings": "Test embeddings",
|
"test_embeddings": "Test embeddings",
|
||||||
"probe_dimension": "Probe dimension",
|
"probe_dimension": "Probe dimension",
|
||||||
"recreate_collections": "Recreate collections",
|
"recreate_collections": "Recreate collections",
|
||||||
|
"recreate_collections_hint": "Drops and recreates Qdrant collections based on current embeddings dimension.",
|
||||||
|
"recreate_dropped": "Dropped",
|
||||||
|
"recreate_created": "Created",
|
||||||
|
"test_provider_hint": "If provider=openai and api_url is empty, the system falls back to llm.api_url.",
|
||||||
"api_url": "API URL",
|
"api_url": "API URL",
|
||||||
"api_key": "API key",
|
"api_key": "API key",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
@@ -301,7 +332,46 @@
|
|||||||
"text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.",
|
"text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.",
|
||||||
"text_replacements_from": "From",
|
"text_replacements_from": "From",
|
||||||
"text_replacements_to": "To",
|
"text_replacements_to": "To",
|
||||||
"text_replacements_add": "Add rule"
|
"text_replacements_add": "Add rule",
|
||||||
|
"setting_desc": {
|
||||||
|
"llm.api_url": "Chat completions endpoint URL for the LLM provider.",
|
||||||
|
"llm.api_key": "API key for the LLM provider (stored as string).",
|
||||||
|
"llm.model": "Default model name used for all LLM calls.",
|
||||||
|
"llm.max_tokens": "Maximum number of tokens to generate per response.",
|
||||||
|
"llm.timeout_seconds": "Request timeout in seconds.",
|
||||||
|
"llm.temperature_orchestrator": "Sampling temperature for the orchestrator phase (0–2).",
|
||||||
|
"llm.temperature_writer": "Sampling temperature for the scene writer phase (0–2).",
|
||||||
|
"llm.text_replacements": "JSON list of {from, to} text replacements applied to all LLM scene output.",
|
||||||
|
"embeddings.provider": "Embeddings provider: 'offline_hash' (no API) or 'openai'.",
|
||||||
|
"embeddings.api_url": "Embeddings API URL. If empty, falls back to llm.api_url.",
|
||||||
|
"embeddings.api_key": "Embeddings API key. If empty, falls back to llm.api_key.",
|
||||||
|
"embeddings.model": "Embeddings model name (default: text-embedding-3-small).",
|
||||||
|
"embeddings.dimension": "Vector dimension used by the Qdrant collection.",
|
||||||
|
"embeddings.batch_size": "Number of texts to embed per API request.",
|
||||||
|
"embeddings.timeout_seconds": "Embeddings API request timeout in seconds.",
|
||||||
|
"embeddings.cache_ttl_seconds": "Time-to-live for the in-memory embeddings cache.",
|
||||||
|
"embeddings.max_text_chars": "Maximum characters of text passed to the embeddings API per call.",
|
||||||
|
"qdrant.url": "Qdrant server URL.",
|
||||||
|
"qdrant.api_key": "Qdrant API key (optional — only if the server requires auth).",
|
||||||
|
"qdrant.collection_name": "Name of the Qdrant collection used for entity vectors.",
|
||||||
|
"context.guaranteed_messages": "Number of recent chat messages always kept in the LLM context window.",
|
||||||
|
"context.compression_threshold_messages": "When recent message count exceeds this, older messages are summarized.",
|
||||||
|
"context.compression_threshold_tokens": "When estimated token count exceeds this, older messages are summarized.",
|
||||||
|
"context.scene_text_truncate_tokens": "Maximum tokens of scene_text retained per step.",
|
||||||
|
"context.safety_margin_tokens": "Reserved token budget kept between context and model max_tokens.",
|
||||||
|
"context.auto_rag_on_entity_mention": "If true, automatically retrieve entity context when an entity is mentioned.",
|
||||||
|
"game.max_substeps_per_iteration": "Maximum number of tool-call sub-steps per player iteration.",
|
||||||
|
"game.max_suggested_actions": "Maximum number of suggested actions returned to the player.",
|
||||||
|
"game.deferred_triggers_enabled": "If true, deferred triggers may fire on subsequent iterations.",
|
||||||
|
"ui.page_title": "Page <title> shown in the browser tab.",
|
||||||
|
"ui.header_title": "Title shown in the navbar (falls back to page_title).",
|
||||||
|
"ui.favicon_url": "URL of the favicon.",
|
||||||
|
"ui.logo_url": "URL of the navbar logo image.",
|
||||||
|
"ui.og_image_url": "URL of the Open Graph image used for social previews.",
|
||||||
|
"ui.character_names.en": "Comma-separated list of English character names for the random-name button.",
|
||||||
|
"ui.character_names.ru": "Comma-separated list of Russian character names for the random-name button.",
|
||||||
|
"admin.setup_token": "Token required to create the first admin account."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Something went wrong.",
|
"generic": "Something went wrong.",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"never_played": "Ещё не играли",
|
"never_played": "Ещё не играли",
|
||||||
"current_time": "Текущее время",
|
"current_time": "Текущее время",
|
||||||
"player": "Игрок",
|
"player": "Игрок",
|
||||||
|
"default_name": "Новый Мир",
|
||||||
"delete_confirm": "Удалить этот мир? Действие необратимо.",
|
"delete_confirm": "Удалить этот мир? Действие необратимо.",
|
||||||
"deleted": "Мир удалён.",
|
"deleted": "Мир удалён.",
|
||||||
"delete_failed": "Не удалось удалить мир.",
|
"delete_failed": "Не удалось удалить мир.",
|
||||||
@@ -112,8 +113,13 @@
|
|||||||
"mode_form": "Своя форма",
|
"mode_form": "Своя форма",
|
||||||
"preset_help": "Начать с готового шаблона мира.",
|
"preset_help": "Начать с готового шаблона мира.",
|
||||||
"form_help": "Настроить мир самостоятельно с собственными правилами.",
|
"form_help": "Настроить мир самостоятельно с собственными правилами.",
|
||||||
|
"setting": "Сеттинг",
|
||||||
|
"setting_help": "Опишите сеттинг мира (например, «постапокалиптический подземный бункер»).",
|
||||||
|
"setting_placeholder": "постапокалиптический подземный бункер",
|
||||||
"world_name": "Название мира",
|
"world_name": "Название мира",
|
||||||
"player_name": "Имя персонажа игрока",
|
"player_name": "Имя персонажа игрока",
|
||||||
|
"name_random_tip": "Сгенерировать случайное имя",
|
||||||
|
"name_random_failed": "Не удалось получить случайное имя.",
|
||||||
"notes": "Заметки (необязательно)",
|
"notes": "Заметки (необязательно)",
|
||||||
"notes_help": "Дополнительные инструкции для мастера игры.",
|
"notes_help": "Дополнительные инструкции для мастера игры.",
|
||||||
"language_en": "Английский",
|
"language_en": "Английский",
|
||||||
@@ -145,6 +151,7 @@
|
|||||||
"clarification": "Требуется уточнение",
|
"clarification": "Требуется уточнение",
|
||||||
"change_proposed": "Предложено изменение",
|
"change_proposed": "Предложено изменение",
|
||||||
"comment": "Комментарий",
|
"comment": "Комментарий",
|
||||||
|
"assistant": "Ассистент",
|
||||||
"apply_changes": "Применить изменения",
|
"apply_changes": "Применить изменения",
|
||||||
"discard_changes": "Отменить изменения",
|
"discard_changes": "Отменить изменения",
|
||||||
"changes_applied": "Изменения применены.",
|
"changes_applied": "Изменения применены.",
|
||||||
@@ -178,12 +185,22 @@
|
|||||||
"weather": "Погода",
|
"weather": "Погода",
|
||||||
"player": "Игрок",
|
"player": "Игрок",
|
||||||
"hp": "ОЗ",
|
"hp": "ОЗ",
|
||||||
|
"health": "Здоровье",
|
||||||
|
"mana": "Мана",
|
||||||
|
"strength": "Сила",
|
||||||
"level": "Уровень",
|
"level": "Уровень",
|
||||||
"inventory": "Инвентарь",
|
"inventory": "Инвентарь",
|
||||||
"conditions": "Состояния",
|
"conditions": "Состояния",
|
||||||
"npcs": "NPC",
|
"npcs": "NPC",
|
||||||
"items": "Предметы",
|
"items": "Предметы",
|
||||||
"plot_rails": "Сюжетные линии",
|
"plot_rails": "Сюжетные линии",
|
||||||
|
"hooks": "Зацепки",
|
||||||
|
"current_goals": "Текущие цели",
|
||||||
|
"completed_goals": "Завершённые цели",
|
||||||
|
"no_env_data": "Данные об окружении пока отсутствуют.",
|
||||||
|
"no_intro_scene": "У этого мира ещё нет вступительной сцены. Перейдите в редактор, чтобы сгенерировать её.",
|
||||||
|
"go_to_edit": "Перейти в редактор",
|
||||||
|
"edit_world": "Редактировать мир",
|
||||||
"chat": "Чат",
|
"chat": "Чат",
|
||||||
"scene": "Сцена",
|
"scene": "Сцена",
|
||||||
"you": "Вы",
|
"you": "Вы",
|
||||||
@@ -227,6 +244,10 @@
|
|||||||
"settings_saved": "Настройки сохранены.",
|
"settings_saved": "Настройки сохранены.",
|
||||||
"settings_save_failed": "Не удалось сохранить настройки.",
|
"settings_save_failed": "Не удалось сохранить настройки.",
|
||||||
"settings_load_failed": "Не удалось загрузить настройки.",
|
"settings_load_failed": "Не удалось загрузить настройки.",
|
||||||
|
"settings_hint": "Каждая карточка сохраняется независимо. Секретные значения (api_key) маскируются после сохранения.",
|
||||||
|
"expand_all": "Развернуть все",
|
||||||
|
"collapse_all": "Свернуть все",
|
||||||
|
"no_changes": "Нет изменений для сохранения.",
|
||||||
"group_llm": "Конфигурация LLM",
|
"group_llm": "Конфигурация LLM",
|
||||||
"group_embeddings": "Эмбеддинги",
|
"group_embeddings": "Эмбеддинги",
|
||||||
"group_qdrant": "Qdrant",
|
"group_qdrant": "Qdrant",
|
||||||
@@ -248,6 +269,12 @@
|
|||||||
"logs_prompt": "Запрос",
|
"logs_prompt": "Запрос",
|
||||||
"logs_response": "Ответ",
|
"logs_response": "Ответ",
|
||||||
"logs_error": "Ошибка",
|
"logs_error": "Ошибка",
|
||||||
|
"logs_new_count": "{{count}} нов.",
|
||||||
|
"logs_pause": "Пауза",
|
||||||
|
"logs_resume": "Возобновить",
|
||||||
|
"logs_auto_refresh_on": "Автообновление активно (стр. 1, 5с).",
|
||||||
|
"logs_auto_refresh_off": "Автообновление приостановлено.",
|
||||||
|
"logs_auto_refresh_tip": "Переключить автообновление (опрос каждые 5с на стр. 1).",
|
||||||
"users_email": "Почта",
|
"users_email": "Почта",
|
||||||
"users_username": "Имя пользователя",
|
"users_username": "Имя пользователя",
|
||||||
"users_admin": "Админ",
|
"users_admin": "Админ",
|
||||||
@@ -270,6 +297,10 @@
|
|||||||
"test_embeddings": "Тест эмбеддингов",
|
"test_embeddings": "Тест эмбеддингов",
|
||||||
"probe_dimension": "Определить размерность",
|
"probe_dimension": "Определить размерность",
|
||||||
"recreate_collections": "Пересоздать коллекции",
|
"recreate_collections": "Пересоздать коллекции",
|
||||||
|
"recreate_collections_hint": "Удаляет и пересоздаёт коллекции Qdrant на основе текущей размерности эмбеддингов.",
|
||||||
|
"recreate_dropped": "Удалено",
|
||||||
|
"recreate_created": "Создано",
|
||||||
|
"test_provider_hint": "Если provider=openai и api_url пуст, система использует llm.api_url.",
|
||||||
"api_url": "URL API",
|
"api_url": "URL API",
|
||||||
"api_key": "Ключ API",
|
"api_key": "Ключ API",
|
||||||
"model": "Модель",
|
"model": "Модель",
|
||||||
@@ -301,7 +332,46 @@
|
|||||||
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
|
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
|
||||||
"text_replacements_from": "С",
|
"text_replacements_from": "С",
|
||||||
"text_replacements_to": "На",
|
"text_replacements_to": "На",
|
||||||
"text_replacements_add": "Добавить правило"
|
"text_replacements_add": "Добавить правило",
|
||||||
|
"setting_desc": {
|
||||||
|
"llm.api_url": "URL endpoint chat completions провайдера LLM.",
|
||||||
|
"llm.api_key": "API-ключ провайдера LLM (хранится строкой).",
|
||||||
|
"llm.model": "Имя модели по умолчанию для всех вызовов LLM.",
|
||||||
|
"llm.max_tokens": "Максимальное число токенов в ответе.",
|
||||||
|
"llm.timeout_seconds": "Таймаут запроса в секундах.",
|
||||||
|
"llm.temperature_orchestrator": "Температура сэмплинга для фазы оркестратора (0–2).",
|
||||||
|
"llm.temperature_writer": "Температура сэмплинга для фазы писателя сцены (0–2).",
|
||||||
|
"llm.text_replacements": "JSON-список замен {from, to}, применяемых ко всему выводу LLM.",
|
||||||
|
"embeddings.provider": "Провайдер эмбеддингов: 'offline_hash' (без API) или 'openai'.",
|
||||||
|
"embeddings.api_url": "URL API эмбеддингов. Если пусто, используется llm.api_url.",
|
||||||
|
"embeddings.api_key": "API-ключ эмбеддингов. Если пусто, используется llm.api_key.",
|
||||||
|
"embeddings.model": "Имя модели эмбеддингов (по умолчанию: text-embedding-3-small).",
|
||||||
|
"embeddings.dimension": "Размерность векторов в коллекции Qdrant.",
|
||||||
|
"embeddings.batch_size": "Количество текстов на один API-запрос эмбеддингов.",
|
||||||
|
"embeddings.timeout_seconds": "Таймаут API-запроса эмбеддингов в секундах.",
|
||||||
|
"embeddings.cache_ttl_seconds": "Время жизни in-memory кэша эмбеддингов.",
|
||||||
|
"embeddings.max_text_chars": "Максимум символов текста на один вызов API эмбеддингов.",
|
||||||
|
"qdrant.url": "URL сервера Qdrant.",
|
||||||
|
"qdrant.api_key": "API-ключ Qdrant (опционально — только если сервер требует аутентификацию).",
|
||||||
|
"qdrant.collection_name": "Имя коллекции Qdrant для векторов сущностей.",
|
||||||
|
"context.guaranteed_messages": "Количество недавних сообщений чата, всегда удерживаемых в контексте LLM.",
|
||||||
|
"context.compression_threshold_messages": "При превышении этого числа сообщений старые суммируются.",
|
||||||
|
"context.compression_threshold_tokens": "При превышении оценки токенов старые сообщения суммируются.",
|
||||||
|
"context.scene_text_truncate_tokens": "Максимум токенов scene_text, сохраняемых на шаг.",
|
||||||
|
"context.safety_margin_tokens": "Резерв токенов между контекстом и max_tokens модели.",
|
||||||
|
"context.auto_rag_on_entity_mention": "Если true, автоматически извлекать контекст сущности при её упоминании.",
|
||||||
|
"game.max_substeps_per_iteration": "Максимум подшагов вызова инструментов на итерацию игрока.",
|
||||||
|
"game.max_suggested_actions": "Максимум предложенных действий, возвращаемых игроку.",
|
||||||
|
"game.deferred_triggers_enabled": "Если true, отложенные триггеры могут срабатывать на последующих итерациях.",
|
||||||
|
"ui.page_title": "<title> страницы во вкладке браузера.",
|
||||||
|
"ui.header_title": "Заголовок в навбаре (по умолчанию page_title).",
|
||||||
|
"ui.favicon_url": "URL фавикона.",
|
||||||
|
"ui.logo_url": "URL логотипа в навбаре.",
|
||||||
|
"ui.og_image_url": "URL Open Graph изображения для соц. превью.",
|
||||||
|
"ui.character_names.en": "Список английских имён персонажей через запятую для кнопки случайного имени.",
|
||||||
|
"ui.character_names.ru": "Список русских имён персонажей через запятую для кнопки случайного имени.",
|
||||||
|
"admin.setup_token": "Токен для создания первого аккаунта администратора."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Что-то пошло не так.",
|
"generic": "Что-то пошло не так.",
|
||||||
|
|||||||
@@ -478,4 +478,18 @@ export const AdminApi = {
|
|||||||
export const MiscApi = {
|
export const MiscApi = {
|
||||||
health: () => request<HealthResponse>("/health"),
|
health: () => request<HealthResponse>("/health"),
|
||||||
publicSettings: () => request<PublicSettings>("/settings/public"),
|
publicSettings: () => request<PublicSettings>("/settings/public"),
|
||||||
|
/**
|
||||||
|
* Fetch a random character name from the name bank for the given language.
|
||||||
|
* Endpoint: GET /api/names/{language} (no auth).
|
||||||
|
* Returns: `{name, language, count}`.
|
||||||
|
*/
|
||||||
|
randomName: (language: string) =>
|
||||||
|
request<RandomNameResponse>(`/names/${encodeURIComponent(language)}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Response from GET /api/names/{language}. */
|
||||||
|
export interface RandomNameResponse {
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { SettingsPanel } from "@/components/admin/SettingsPanel";
|
import { SettingsPanel } from "@/components/admin/SettingsPanel";
|
||||||
@@ -9,21 +9,27 @@ import { TestButtons } from "@/components/admin/TestButtons";
|
|||||||
import { IconsPanel } from "@/components/admin/IconsPanel";
|
import { IconsPanel } from "@/components/admin/IconsPanel";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
type Tab = "settings" | "logs" | "users" | "stats" | "test" | "icons";
|
type Tab = "stats" | "logs" | "users" | "settings" | "test" | "icons";
|
||||||
|
|
||||||
const TABS: Array<{ id: Tab; labelKey: string }> = [
|
const TABS: Array<{ id: Tab; labelKey: string; path: string }> = [
|
||||||
{ id: "settings", labelKey: "admin.tab_settings" },
|
{ id: "stats", labelKey: "admin.tab_stats", path: "stats" },
|
||||||
{ id: "logs", labelKey: "admin.tab_logs" },
|
{ id: "logs", labelKey: "admin.tab_logs", path: "logs" },
|
||||||
{ id: "users", labelKey: "admin.tab_users" },
|
{ id: "users", labelKey: "admin.tab_users", path: "users" },
|
||||||
{ id: "stats", labelKey: "admin.tab_stats" },
|
{ id: "settings", labelKey: "admin.tab_settings", path: "settings" },
|
||||||
{ id: "test", labelKey: "admin.tab_test" },
|
{ id: "test", labelKey: "admin.tab_test", path: "test" },
|
||||||
{ id: "icons", labelKey: "admin.tab_icons" },
|
{ id: "icons", labelKey: "admin.tab_icons", path: "icons" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const VALID_TABS = new Set<string>(TABS.map((t) => t.id));
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [tab, setTab] = useState<Tab>("stats");
|
const navigate = useNavigate();
|
||||||
|
const { tab = "stats" } = useParams<{ tab?: string }>();
|
||||||
|
|
||||||
|
// Defensive: if the route param is somehow invalid, fall back to stats.
|
||||||
|
const activeTab: Tab = VALID_TABS.has(tab) ? (tab as Tab) : "stats";
|
||||||
|
|
||||||
if (!user?.is_admin) {
|
if (!user?.is_admin) {
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +39,10 @@ export function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goTab = (next: Tab) => {
|
||||||
|
navigate(`/admin/${next}`);
|
||||||
|
};
|
||||||
|
|
||||||
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>
|
<header>
|
||||||
@@ -43,14 +53,14 @@ export function AdminPage() {
|
|||||||
{TABS.map((tabDef) => (
|
{TABS.map((tabDef) => (
|
||||||
<button
|
<button
|
||||||
key={tabDef.id}
|
key={tabDef.id}
|
||||||
onClick={() => setTab(tabDef.id)}
|
onClick={() => goTab(tabDef.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||||
tab === tabDef.id
|
activeTab === tabDef.id
|
||||||
? "border-accent text-fg"
|
? "border-accent text-fg"
|
||||||
: "border-transparent text-fg-muted hover:text-fg hover:border-fg-dim/30",
|
: "border-transparent text-fg-muted hover:text-fg hover:border-fg-dim/30",
|
||||||
)}
|
)}
|
||||||
aria-current={tab === tabDef.id ? "page" : undefined}
|
aria-current={activeTab === tabDef.id ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t(tabDef.labelKey)}
|
{t(tabDef.labelKey)}
|
||||||
</button>
|
</button>
|
||||||
@@ -58,12 +68,12 @@ export function AdminPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{tab === "settings" && <SettingsPanel />}
|
{activeTab === "stats" && <StatsPanel />}
|
||||||
{tab === "logs" && <LlmLogsTable />}
|
{activeTab === "logs" && <LlmLogsTable />}
|
||||||
{tab === "users" && <UsersTable />}
|
{activeTab === "users" && <UsersTable />}
|
||||||
{tab === "stats" && <StatsPanel />}
|
{activeTab === "settings" && <SettingsPanel />}
|
||||||
{tab === "test" && <TestButtons />}
|
{activeTab === "test" && <TestButtons />}
|
||||||
{tab === "icons" && <IconsPanel />}
|
{activeTab === "icons" && <IconsPanel />}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
|
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||||
import { toErrorMessage } from "@/lib/api";
|
import { toErrorMessage } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
@@ -10,13 +11,15 @@ import { Spinner } from "@/components/ui/Spinner";
|
|||||||
import { ChatView } from "@/components/sessions/ChatView";
|
import { ChatView } from "@/components/sessions/ChatView";
|
||||||
import { ActionInput } from "@/components/sessions/ActionInput";
|
import { ActionInput } from "@/components/sessions/ActionInput";
|
||||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||||
import type { Environment, PlotRail } from "@/types";
|
import type { Environment, PlotRailsContainer } from "@/types";
|
||||||
|
|
||||||
export function PlayPage() {
|
export function PlayPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const pushToast = useToastStore((s) => s.push);
|
const pushToast = useToastStore((s) => s.push);
|
||||||
|
const settings = useUiSettingsStore((s) => s.settings);
|
||||||
|
const headerTitle = selectHeaderTitle(settings);
|
||||||
|
|
||||||
const world = useSessionStore((s) => s.world);
|
const world = useSessionStore((s) => s.world);
|
||||||
const environment = useSessionStore((s) => s.environment);
|
const environment = useSessionStore((s) => s.environment);
|
||||||
@@ -53,6 +56,17 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}, [id, world, redirected, navigate, pushToast, t]);
|
}, [id, world, redirected, navigate, pushToast, t]);
|
||||||
|
|
||||||
|
// Page title: "{world.name} | {headerTitle}"
|
||||||
|
useEffect(() => {
|
||||||
|
if (world) {
|
||||||
|
document.title = `${world.name} | ${headerTitle}`;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Restore default title (App.tsx effect will re-apply on next mount).
|
||||||
|
document.title = headerTitle;
|
||||||
|
};
|
||||||
|
}, [world, headerTitle]);
|
||||||
|
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|
||||||
if (loading && !world) {
|
if (loading && !world) {
|
||||||
@@ -76,8 +90,26 @@ export function PlayPage() {
|
|||||||
if (world.status !== "ready") return null;
|
if (world.status !== "ready") return null;
|
||||||
|
|
||||||
const player = environment?.player;
|
const player = environment?.player;
|
||||||
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
|
|
||||||
const noSteps = recentSteps.length === 0;
|
const noSteps = recentSteps.length === 0;
|
||||||
|
const plotRailsContainer = normalizePlotRails(world.plot_rails);
|
||||||
|
const envHasData = Boolean(
|
||||||
|
environment && (
|
||||||
|
environment.location ||
|
||||||
|
environment.time_of_day ||
|
||||||
|
environment.weather ||
|
||||||
|
player ||
|
||||||
|
(Array.isArray(environment.npcs) && environment.npcs.length > 0) ||
|
||||||
|
(Array.isArray(environment.items) && environment.items.length > 0)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const plotRailsHasData = Boolean(
|
||||||
|
plotRailsContainer &&
|
||||||
|
(
|
||||||
|
(plotRailsContainer.hooks && plotRailsContainer.hooks.length > 0) ||
|
||||||
|
(plotRailsContainer.current_goals && plotRailsContainer.current_goals.length > 0) ||
|
||||||
|
(plotRailsContainer.completed_goals && plotRailsContainer.completed_goals.length > 0)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const handleSend = (action: string) => {
|
const handleSend = (action: string) => {
|
||||||
void sendAction(id, action, "manual").catch((err) => {
|
void sendAction(id, action, "manual").catch((err) => {
|
||||||
@@ -112,66 +144,30 @@ export function PlayPage() {
|
|||||||
{/* Environment panel */}
|
{/* Environment panel */}
|
||||||
<aside className="order-2 lg:order-1 lg:w-72 lg:shrink-0 overflow-y-auto space-y-3">
|
<aside className="order-2 lg:order-1 lg:w-72 lg:shrink-0 overflow-y-auto space-y-3">
|
||||||
<Card title={t("play.environment")}>
|
<Card title={t("play.environment")}>
|
||||||
|
{!envHasData ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t("play.no_env_data")}</p>
|
||||||
|
) : (
|
||||||
<dl className="space-y-1 text-sm">
|
<dl className="space-y-1 text-sm">
|
||||||
<EnvRow label={t("play.location")} value={environment?.location} />
|
<EnvRow label={t("play.location")} value={environment?.location} />
|
||||||
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
|
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
|
||||||
<EnvRow label={t("play.weather")} value={environment?.weather} />
|
<EnvRow label={t("play.weather")} value={environment?.weather} />
|
||||||
</dl>
|
</dl>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{player && (
|
{player && (
|
||||||
<Card title={t("play.player")}>
|
<Card title={t("play.player")}>
|
||||||
<dl className="space-y-1 text-sm">
|
<PlayerPanel environment={environment} />
|
||||||
<EnvRow label={t("common.name")} value={player.name} />
|
|
||||||
{typeof player.hp === "number" && (
|
|
||||||
<EnvRow
|
|
||||||
label={t("play.hp")}
|
|
||||||
value={player.max_hp != null ? `${player.hp} / ${player.max_hp}` : String(player.hp)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{typeof player.level === "number" && (
|
|
||||||
<EnvRow label={t("play.level")} value={String(player.level)} />
|
|
||||||
)}
|
|
||||||
{Array.isArray(player.conditions) && player.conditions.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-fg-dim">{t("play.conditions")}</p>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{player.conditions.map((c, i) => (
|
|
||||||
<span key={i} className="badge bg-warn/15 text-warn">{c}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Array.isArray(player.inventory) && player.inventory.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-fg-dim">{t("play.inventory")}</p>
|
|
||||||
<ul className="mt-1 list-disc pl-5 text-fg-muted">
|
|
||||||
{player.inventory.slice(0, 8).map((it, i) => (
|
|
||||||
<li key={i}>{typeof it === "string" ? it : (it as { name?: string }).name || JSON.stringify(it)}</li>
|
|
||||||
))}
|
|
||||||
{player.inventory.length > 8 && <li>… +{player.inventory.length - 8}</li>}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plotRails.length > 0 && (
|
|
||||||
<Card title={t("play.plot_rails")}>
|
<Card title={t("play.plot_rails")}>
|
||||||
<ul className="space-y-1.5 text-sm">
|
{!plotRailsHasData || !plotRailsContainer ? (
|
||||||
{plotRails.map((r, i) => (
|
<p className="text-xs text-fg-muted">{t("play.no_env_data")}</p>
|
||||||
<li key={r.id || i} className="rounded-md bg-bg-soft p-2">
|
) : (
|
||||||
<p className="font-medium text-fg">{r.title || `Rail ${i + 1}`}</p>
|
<PlotRailsView rails={plotRailsContainer} />
|
||||||
{r.description && <p className="text-xs text-fg-muted">{r.description}</p>}
|
|
||||||
{r.status && (
|
|
||||||
<span className="badge mt-1 bg-bg-card text-fg-muted">{r.status}</span>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -203,8 +199,17 @@ export function PlayPage() {
|
|||||||
<main className="order-1 lg:order-2 flex min-h-0 flex-1 flex-col">
|
<main className="order-1 lg:order-2 flex min-h-0 flex-1 flex-col">
|
||||||
<Card className="flex min-h-0 flex-1 flex-col p-0">
|
<Card className="flex min-h-0 flex-1 flex-col p-0">
|
||||||
<header className="flex items-center justify-between border-b border-fg-dim/20 p-3">
|
<header className="flex items-center justify-between border-b border-fg-dim/20 p-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="truncate text-base font-semibold text-fg">{world.name}</h2>
|
||||||
|
<Link
|
||||||
|
to={`/worlds/${world.id}/edit`}
|
||||||
|
className="shrink-0 rounded-md border border-fg-dim/30 px-2 py-0.5 text-xs text-fg-muted hover:bg-bg-soft hover:text-fg"
|
||||||
|
title={t("play.edit_world")}
|
||||||
|
>
|
||||||
|
⚙️ {t("common.edit")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{(() => {
|
{(() => {
|
||||||
// Prefer the human-readable form; fall back to the raw
|
// Prefer the human-readable form; fall back to the raw
|
||||||
@@ -251,6 +256,19 @@ export function PlayPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the plot_rails field — the backend may return either an
|
||||||
|
* old-style array of PlotRail objects or a new-style container object
|
||||||
|
* with hooks/current_goals/completed_goals. We always return the
|
||||||
|
* container shape (or null).
|
||||||
|
*/
|
||||||
|
function normalizePlotRails(raw: unknown): PlotRailsContainer | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (Array.isArray(raw)) return null; // old-style array, no container fields
|
||||||
|
if (typeof raw === "object") return raw as PlotRailsContainer;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function EnvRow({ label, value }: { label: string; value: unknown }) {
|
function EnvRow({ label, value }: { label: string; value: unknown }) {
|
||||||
if (value == null || value === "") return null;
|
if (value == null || value === "") return null;
|
||||||
const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
|
const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||||
@@ -261,3 +279,138 @@ function EnvRow({ label, value }: { label: string; value: unknown }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render the player block: name, stats (health/mana/strength) with a
|
||||||
|
* progress bar for health, plus inventory list. */
|
||||||
|
function PlayerPanel({ environment }: { environment: Environment | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = environment?.player;
|
||||||
|
if (!player) return null;
|
||||||
|
|
||||||
|
// Support both old (hp/max_hp) and new (health/mana/strength) shapes.
|
||||||
|
const health = numField(player.health ?? player.hp);
|
||||||
|
const maxHealth = numField(player.max_health ?? player.max_hp);
|
||||||
|
const mana = numField(player.mana);
|
||||||
|
const strength = numField(player.strength);
|
||||||
|
|
||||||
|
const inventory = Array.isArray(player.inventory) ? player.inventory : [];
|
||||||
|
const conditions = Array.isArray(player.conditions) ? player.conditions : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<EnvRow label={t("common.name")} value={player.name} />
|
||||||
|
|
||||||
|
{health != null && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-fg-dim">{t("play.health")}</span>
|
||||||
|
<span className="text-fg">
|
||||||
|
{health}{maxHealth != null ? ` / ${maxHealth}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{maxHealth != null && maxHealth > 0 && (
|
||||||
|
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-bg-soft">
|
||||||
|
<div
|
||||||
|
className="h-full bg-err/70"
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, (health / maxHealth) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mana != null && (
|
||||||
|
<EnvRow label={t("play.mana")} value={String(mana)} />
|
||||||
|
)}
|
||||||
|
{strength != null && (
|
||||||
|
<EnvRow label={t("play.strength")} value={String(strength)} />
|
||||||
|
)}
|
||||||
|
{typeof player.level === "number" && (
|
||||||
|
<EnvRow label={t("play.level")} value={String(player.level)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conditions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-fg-dim">{t("play.conditions")}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{conditions.map((c, i) => (
|
||||||
|
<span key={i} className="badge bg-warn/15 text-warn">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inventory.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-fg-dim">{t("play.inventory")}</p>
|
||||||
|
<ul className="mt-1 list-disc pl-5 text-fg-muted">
|
||||||
|
{inventory.slice(0, 12).map((it, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
{typeof it === "string"
|
||||||
|
? it
|
||||||
|
: (it as { name?: string })?.name
|
||||||
|
|| (it as { title?: string })?.title
|
||||||
|
|| JSON.stringify(it)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{inventory.length > 12 && <li>… +{inventory.length - 12}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlotRailsView({ rails }: { rails: PlotRailsContainer }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hooks = rails.hooks || [];
|
||||||
|
const currentGoals = rails.current_goals || [];
|
||||||
|
const completedGoals = rails.completed_goals || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{hooks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-fg-dim">{t("play.hooks")}</p>
|
||||||
|
<ul className="mt-1 list-disc pl-5 text-fg">
|
||||||
|
{hooks.map((h, i) => (
|
||||||
|
<li key={i}>{h}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentGoals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-fg-dim">{t("play.current_goals")}</p>
|
||||||
|
<ul className="mt-1 list-disc pl-5 text-fg">
|
||||||
|
{currentGoals.map((g, i) => (
|
||||||
|
<li key={i}>{g}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{completedGoals.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs uppercase text-fg-dim">
|
||||||
|
{t("play.completed_goals")} ({completedGoals.length})
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-1 list-disc pl-5 text-fg-muted line-through">
|
||||||
|
{completedGoals.map((g, i) => (
|
||||||
|
<li key={i}>{g}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce an unknown value to a number, or return null. */
|
||||||
|
function numField(v: unknown): number | null {
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
|
if (typeof v === "string") {
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { WorldsApi } from "@/lib/api";
|
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 { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||||
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";
|
||||||
@@ -19,6 +20,8 @@ export function WorldEditPage() {
|
|||||||
const fetchWorld = useWorldsStore((s) => s.fetchWorld);
|
const fetchWorld = useWorldsStore((s) => s.fetchWorld);
|
||||||
const setCurrentWorld = useWorldsStore((s) => s.setCurrentWorld);
|
const setCurrentWorld = useWorldsStore((s) => s.setCurrentWorld);
|
||||||
const pushToast = useToastStore((s) => s.push);
|
const pushToast = useToastStore((s) => s.push);
|
||||||
|
const settings = useUiSettingsStore((s) => s.settings);
|
||||||
|
const headerTitle = selectHeaderTitle(settings);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -28,6 +31,16 @@ export function WorldEditPage() {
|
|||||||
return () => setCurrentWorld(null);
|
return () => setCurrentWorld(null);
|
||||||
}, [id, fetchWorld, setCurrentWorld, pushToast, t]);
|
}, [id, fetchWorld, setCurrentWorld, pushToast, t]);
|
||||||
|
|
||||||
|
// Page title: "{world.name} | {headerTitle}"
|
||||||
|
useEffect(() => {
|
||||||
|
if (world) {
|
||||||
|
document.title = `${world.name} | ${headerTitle}`;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.title = headerTitle;
|
||||||
|
};
|
||||||
|
}, [world, headerTitle]);
|
||||||
|
|
||||||
const refreshWorld = useCallback(async () => {
|
const refreshWorld = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ function applyToDocument(s: PublicSettings): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the effective header title shown in the navbar: prefer
|
||||||
|
* `header_title` (set by admin), fall back to `page_title`, then "AI-RPG".
|
||||||
|
*/
|
||||||
|
export function selectHeaderTitle(s: PublicSettings | null): string {
|
||||||
|
if (!s) return "AI-RPG";
|
||||||
|
return s.header_title || s.page_title || "AI-RPG";
|
||||||
|
}
|
||||||
|
|
||||||
export const useUiSettingsStore = create<UiSettingsState>((set, get) => ({
|
export const useUiSettingsStore = create<UiSettingsState>((set, get) => ({
|
||||||
settings: null,
|
settings: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|||||||
@@ -114,6 +114,18 @@ export interface PlotRail {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New-style plot rails container (backend returns an object with hooks,
|
||||||
|
* current_goals, completed_goals arrays). Older backends returned an array
|
||||||
|
* of PlotRail objects — both shapes are supported.
|
||||||
|
*/
|
||||||
|
export interface PlotRailsContainer {
|
||||||
|
hooks?: string[];
|
||||||
|
current_goals?: string[];
|
||||||
|
completed_goals?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface World {
|
export interface World {
|
||||||
id: string;
|
id: string;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
@@ -126,7 +138,7 @@ export interface World {
|
|||||||
schemas: Schemas;
|
schemas: Schemas;
|
||||||
environment_schema: unknown;
|
environment_schema: unknown;
|
||||||
environment: Environment;
|
environment: Environment;
|
||||||
plot_rails: PlotRail[];
|
plot_rails: PlotRail[] | PlotRailsContainer;
|
||||||
current_time: string | null;
|
current_time: string | null;
|
||||||
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00").
|
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00").
|
||||||
* Returned by GET /api/sessions/worlds/{id}/state on the world object.
|
* Returned by GET /api/sessions/worlds/{id}/state on the world object.
|
||||||
@@ -288,6 +300,9 @@ export interface AdminStats {
|
|||||||
|
|
||||||
export interface PublicSettings {
|
export interface PublicSettings {
|
||||||
page_title: string;
|
page_title: string;
|
||||||
|
/** Optional header title for the navbar (falls back to page_title
|
||||||
|
* on the backend). May be absent on older backend versions. */
|
||||||
|
header_title?: string;
|
||||||
favicon_url: string;
|
favicon_url: string;
|
||||||
logo_url: string;
|
logo_url: string;
|
||||||
og_image_url: string;
|
og_image_url: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user