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/),
|
||||
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
|
||||
|
||||
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")
|
||||
logo_url = await get_setting(db, "ui.logo_url")
|
||||
og_image_url = await get_setting(db, "ui.og_image_url")
|
||||
header_title = await get_setting(db, "ui.header_title")
|
||||
return {
|
||||
"page_title": page_title or "AI-RPG",
|
||||
"favicon_url": favicon_url or "/icon.png",
|
||||
"logo_url": logo_url or "/icon.png",
|
||||
"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}")
|
||||
async def i18n(lang: str) -> dict:
|
||||
"""Return translation JSON for the given language."""
|
||||
|
||||
@@ -105,7 +105,7 @@ async def create_world(
|
||||
owner_id=user.id,
|
||||
preset_id=preset.id if preset else None,
|
||||
name=body.name,
|
||||
description=body.notes,
|
||||
description=preset.description if preset else (body.notes or None),
|
||||
language=body.language,
|
||||
status="draft",
|
||||
current_time="day_1_hour_8",
|
||||
|
||||
@@ -60,6 +60,20 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
|
||||
"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).",
|
||||
},
|
||||
"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.
|
||||
|
||||
@@ -341,13 +341,26 @@ async def _run_tool_loop(
|
||||
"tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0),
|
||||
})
|
||||
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 []
|
||||
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": "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
|
||||
|
||||
|
||||
@@ -217,10 +217,16 @@ async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
|
||||
"""Apply a propose_changes diff to the world.
|
||||
|
||||
Supported path formats:
|
||||
- `environment.<field_path>` → apply_patch to world.environment
|
||||
- `world.name` → set world.name
|
||||
- `world.description` → set world.description
|
||||
- `world.language` → set world.language
|
||||
- `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
|
||||
|
||||
@@ -231,25 +237,69 @@ async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
|
||||
path = d.get("path", "") or ""
|
||||
op = d.get("op", "replace")
|
||||
new = d.get("new")
|
||||
if path.startswith("environment."):
|
||||
field = path[len("environment."):]
|
||||
env_patch[field] = new
|
||||
elif path == "world.name":
|
||||
if isinstance(new, str):
|
||||
old = d.get("old") # noqa: F841 — not used but part of the schema
|
||||
|
||||
# Skip if new is empty dict {} (model sometimes returns empty)
|
||||
if isinstance(new, dict) and not new and op == "replace":
|
||||
_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
|
||||
elif path == "world.description":
|
||||
if new is None or isinstance(new, str):
|
||||
elif path == "world.description" or path == "description":
|
||||
if isinstance(new, str):
|
||||
world.description = new
|
||||
elif path == "world.language" or path == "language":
|
||||
if isinstance(new, str):
|
||||
world.language = new
|
||||
# Schemas
|
||||
elif path == "schemas":
|
||||
if isinstance(new, list):
|
||||
world.schemas = new
|
||||
elif path.startswith("schemas."):
|
||||
# For simplicity, replace entire schemas
|
||||
if isinstance(new, list):
|
||||
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:
|
||||
new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
|
||||
if not errors:
|
||||
world.environment = new_env
|
||||
# Sync plot_rails column
|
||||
if isinstance(new_env.get("plot_rails"), dict):
|
||||
world.plot_rails = new_env["plot_rails"]
|
||||
else:
|
||||
_logger.warning("apply_diff_errors", errors=errors)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
PROMPTS = {
|
||||
"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
|
||||
1. Evaluate the player's action and decide what happened mechanically.
|
||||
2. Call tools for ANY state change in the world.
|
||||
|
||||
@@ -13,29 +13,32 @@ Player name: {player_name}
|
||||
Language: {language}
|
||||
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
|
||||
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)
|
||||
2. `item` — with 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)
|
||||
4. `faction` — with fields: name (string, required), description (string), alignment (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` — fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
|
||||
3. `location` — fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
|
||||
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`).
|
||||
|
||||
# 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"
|
||||
- `verbose`: display name like "Character"
|
||||
- `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
|
||||
[
|
||||
{{"name": "name", "type": "string", "required": true}},
|
||||
{{"name": "stats", "type": "object", "required": true, "properties": [
|
||||
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
|
||||
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}}
|
||||
{{"name": "name", "type": "string", "required": true, "show": true}},
|
||||
{{"name": "stats", "type": "object", "required": true, "show": false, "properties": [
|
||||
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100, "show": true}},
|
||||
{{"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).
|
||||
- After all schema_add_type calls, call submit_plan exactly once.
|
||||
- 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": "",
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class WorldEditRequest(BaseModel):
|
||||
# --------------------------------------------------------------------------- #
|
||||
class IterateRequest(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -164,6 +164,14 @@ export default function App() {
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<Navigate to="/admin/stats" replace />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/:tab"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<Layout>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi, toErrorMessage } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
@@ -30,6 +30,9 @@ const STAGE_OPTIONS = [
|
||||
|
||||
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 {
|
||||
if (status === "ok") return "bg-ok/15 text-ok";
|
||||
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
|
||||
@@ -51,6 +54,14 @@ export function LlmLogsTable() {
|
||||
const [detailLoading, setDetailLoading] = 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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -62,6 +73,8 @@ export function LlmLogsTable() {
|
||||
per_page: perPage,
|
||||
});
|
||||
setData(res);
|
||||
// Reset the "new logs" counter when we explicitly (re)fetch.
|
||||
setNewLogsCount(0);
|
||||
} catch (err) {
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
@@ -73,6 +86,40 @@ export function LlmLogsTable() {
|
||||
void 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 = () => {
|
||||
setAppliedFilters(filters);
|
||||
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.
|
||||
const worldFilterPreview = appliedFilters.world_id
|
||||
? appliedFilters.world_id.slice(0, 8)
|
||||
: "";
|
||||
const autoRefreshActive = autoRefresh && page === 1 && !filterFocusRef.current;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card title={t("admin.tab_logs")}>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<Card
|
||||
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
|
||||
label={t("admin.logs_filter_world")}
|
||||
value={filters.world_id}
|
||||
@@ -139,6 +221,11 @@ export function LlmLogsTable() {
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -86,19 +86,20 @@ function fieldType(key: string): FieldType {
|
||||
return "text";
|
||||
}
|
||||
|
||||
/** Returns the appropriate hint text for a given setting key, if any. */
|
||||
function hintFor(key: string): string | undefined {
|
||||
switch (key) {
|
||||
case "embeddings.api_url":
|
||||
case "embeddings.api_key":
|
||||
return "If empty, falls back to llm.api_url / llm.api_key";
|
||||
case "embeddings.model":
|
||||
return "Default: text-embedding-3-small";
|
||||
case "embeddings.provider":
|
||||
return "If provider=openai and api_url is empty, the system falls back to llm.api_url";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Returns the localized description for a setting key. The key format is
|
||||
* `admin.setting_desc.{setting_key}`. Falls back to the backend-provided
|
||||
* description, then to undefined.
|
||||
*/
|
||||
function useSettingDesc(): (key: string, fallback?: string) => string | undefined {
|
||||
const { t } = useTranslation();
|
||||
return (key: string, fallback?: string) => {
|
||||
const tKey = `admin.setting_desc.${key}`;
|
||||
const translated = t(tKey);
|
||||
// i18next returns the key itself when no translation exists.
|
||||
if (translated === tKey) return fallback;
|
||||
return translated;
|
||||
};
|
||||
}
|
||||
|
||||
function castValue(key: string, raw: string): string {
|
||||
@@ -120,6 +121,22 @@ export function SettingsPanel() {
|
||||
const [data, setData] = useState<AdminSettingsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
@@ -174,7 +191,7 @@ export function SettingsPanel() {
|
||||
}
|
||||
}
|
||||
if (Object.keys(diff).length === 0) {
|
||||
pushToast("info", "No changes to save.");
|
||||
pushToast("info", t("admin.no_changes"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -216,7 +233,7 @@ export function SettingsPanel() {
|
||||
if (!data) return;
|
||||
const before = data.settings[key] ?? "";
|
||||
if (before === value) {
|
||||
pushToast("info", "No changes to save.");
|
||||
pushToast("info", t("admin.no_changes"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -260,11 +277,21 @@ export function SettingsPanel() {
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Each card saves independently. Secret values (api_key) are masked after save.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t("admin.settings_hint")}
|
||||
</p>
|
||||
</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) => {
|
||||
const entries = grouped[g.id];
|
||||
@@ -273,6 +300,7 @@ export function SettingsPanel() {
|
||||
// fetcher, text replacements) live here. For other groups, skip
|
||||
// when empty.
|
||||
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
|
||||
const isCollapsed = collapsed.has(g.id);
|
||||
return (
|
||||
<Fragment key={g.id}>
|
||||
<SettingsGroupCard
|
||||
@@ -280,6 +308,8 @@ export function SettingsPanel() {
|
||||
title={t(g.labelKey)}
|
||||
entries={entries || []}
|
||||
draft={draft}
|
||||
collapsed={isCollapsed}
|
||||
onToggle={() => toggleCollapsed(g.id)}
|
||||
onChange={(key, value) =>
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
@@ -289,6 +319,8 @@ export function SettingsPanel() {
|
||||
<TextReplacementsCard
|
||||
rawValue={draft["llm.text_replacements"] ?? ""}
|
||||
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
|
||||
collapsed={collapsed.has("text_replacements")}
|
||||
onToggle={() => toggleCollapsed("text_replacements")}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
@@ -303,11 +335,22 @@ interface SettingsGroupCardProps {
|
||||
title: string;
|
||||
entries: Array<{ key: string; description?: string }>;
|
||||
draft: Record<string, string>;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onChange: (key: string, value: string) => 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 [saving, setSaving] = useState(false);
|
||||
const handleSave = async () => {
|
||||
@@ -331,37 +374,53 @@ function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }:
|
||||
void groupId; // groupId currently used only for the model-entry lookup above
|
||||
return (
|
||||
<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={
|
||||
<Button size="sm" onClick={handleSave} loading={saving}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
!collapsed && (
|
||||
<Button size="sm" onClick={handleSave} loading={saving}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{showGrid && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{regularEntries.map(({ key, description }) => (
|
||||
<SettingField
|
||||
key={key}
|
||||
settingKey={key}
|
||||
description={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{modelEntry && (
|
||||
<LlmModelField
|
||||
value={draft["llm.model"] ?? ""}
|
||||
onChange={(v) => onChange("llm.model", v)}
|
||||
apiUrl={draft["llm.api_url"] ?? ""}
|
||||
apiKey={draft["llm.api_key"] ?? ""}
|
||||
description={modelEntry.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="space-y-3">
|
||||
{showGrid && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{regularEntries.map(({ key, description }) => (
|
||||
<SettingField
|
||||
key={key}
|
||||
settingKey={key}
|
||||
description={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{modelEntry && (
|
||||
<LlmModelField
|
||||
value={draft["llm.model"] ?? ""}
|
||||
onChange={(v) => onChange("llm.model", v)}
|
||||
apiUrl={draft["llm.api_url"] ?? ""}
|
||||
apiKey={draft["llm.api_key"] ?? ""}
|
||||
description={modelEntry.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -376,23 +435,30 @@ interface SettingFieldProps {
|
||||
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const ft = fieldType(settingKey);
|
||||
const hint = hintFor(settingKey) || description;
|
||||
const localizedDesc = useSettingDesc();
|
||||
const hint = localizedDesc(settingKey, description);
|
||||
const label = settingKey;
|
||||
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
|
||||
<select
|
||||
id={`setting-${settingKey}`}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
value={value === "true" ? "true" : value === "false" ? "false" : value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
<label
|
||||
htmlFor={`setting-${settingKey}`}
|
||||
className="flex items-center gap-2 text-sm text-fg cursor-pointer"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
<input
|
||||
id={`setting-${settingKey}`}
|
||||
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>}
|
||||
</div>
|
||||
);
|
||||
@@ -503,10 +569,11 @@ interface LlmModelFieldProps {
|
||||
*/
|
||||
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedDesc = useSettingDesc();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const hint = description;
|
||||
const hint = localizedDesc("llm.model", description);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setFetching(true);
|
||||
@@ -604,6 +671,8 @@ interface TextReplacementRule {
|
||||
interface TextReplacementsCardProps {
|
||||
rawValue: string;
|
||||
onSave: (serializedJson: string) => void;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** 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 })));
|
||||
}
|
||||
|
||||
function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
||||
function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: TextReplacementsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
// Local working copy — only committed to parent draft when Save is
|
||||
// clicked. This avoids marking the LLM group as dirty on every keystroke.
|
||||
@@ -673,52 +742,68 @@ function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("admin.text_replacements_title")}
|
||||
description={t("admin.text_replacements_help")}
|
||||
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>{t("admin.text_replacements_title")}</span>
|
||||
</button>
|
||||
}
|
||||
description={!collapsed ? t("admin.text_replacements_help") : undefined}
|
||||
actions={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
!collapsed && (
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{rules.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
|
||||
)}
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.text_replacements_from")}
|
||||
value={rule.from}
|
||||
onChange={(e) => updateRule(idx, "from", e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">→</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.text_replacements_to")}
|
||||
value={rule.to}
|
||||
onChange={(e) => updateRule(idx, "to", e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeRule(idx)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="secondary" onClick={addRule}>
|
||||
+ {t("admin.text_replacements_add")}
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
{rules.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
|
||||
)}
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.text_replacements_from")}
|
||||
value={rule.from}
|
||||
onChange={(e) => updateRule(idx, "from", e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">→</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.text_replacements_to")}
|
||||
value={rule.to}
|
||||
onChange={(e) => updateRule(idx, "to", e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeRule(idx)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="secondary" onClick={addRule}>
|
||||
+ {t("admin.text_replacements_add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ function EmbeddingsTestCard() {
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,7 +328,7 @@ function RecreateCollectionsCard() {
|
||||
return (
|
||||
<Card title={t("admin.recreate_collections")}>
|
||||
<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>
|
||||
<Button onClick={run} loading={loading} variant="danger">
|
||||
{loading ? t("admin.running") : t("admin.recreate_collections")}
|
||||
@@ -336,10 +336,10 @@ function RecreateCollectionsCard() {
|
||||
{result && (
|
||||
<div className="mt-3 text-sm">
|
||||
<p>
|
||||
<span className="text-fg-muted">Dropped:</span> {result.dropped}
|
||||
<span className="text-fg-muted">{t("admin.recreate_dropped")}:</span> {result.dropped}
|
||||
</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>
|
||||
<span className="text-fg-muted">{t("admin.dimension")}:</span> {result.dimension}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import type { Step } from "@/types";
|
||||
@@ -11,23 +12,49 @@ export interface ChatViewProps {
|
||||
|
||||
export function ChatView({ className }: ChatViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const world = useSessionStore((s) => s.world);
|
||||
const recentSteps = useSessionStore((s) => s.recentSteps);
|
||||
const streamMessages = useSessionStore((s) => s.streamMessages);
|
||||
const streamingText = useSessionStore((s) => s.streamingText);
|
||||
const submitting = useSessionStore((s) => s.submitting);
|
||||
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);
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [recentSteps, streamMessages, streamingText, submitting]);
|
||||
}, [recentSteps, streamMessages, streamingText, submitting, showIntro]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}>
|
||||
{empty && (
|
||||
<div className="m-auto text-center text-sm text-fg-muted py-8">
|
||||
<p>{t("play.no_actions_yet")}</p>
|
||||
<div className="m-auto max-w-md text-center text-sm text-fg-muted py-8 space-y-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useUiSettingsStore } from "@/stores/uiSettingsStore";
|
||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "./Button";
|
||||
|
||||
@@ -19,7 +19,9 @@ export function Navbar() {
|
||||
const language = useUiStore((s) => s.language);
|
||||
const setLanguage = useUiStore((s) => s.setLanguage);
|
||||
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 menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -55,13 +57,13 @@ export function Navbar() {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={t("common.app_name")}
|
||||
alt={headerTitle}
|
||||
className="h-7 w-7 rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
{user && (
|
||||
<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 { useTranslation } from "react-i18next";
|
||||
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 { useToastStore } from "@/stores/toastStore";
|
||||
import type {
|
||||
CreateWorldPayload,
|
||||
Language,
|
||||
PresetListItem,
|
||||
World,
|
||||
} from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
@@ -41,13 +40,6 @@ interface BuilderState {
|
||||
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_DATA: Record<string, unknown> = {
|
||||
setting: "fantasy",
|
||||
tone: "epic",
|
||||
starting_level: 1,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export interface WorldBuilderProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -62,14 +54,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
const [presetsLoading, setPresetsLoading] = useState(false);
|
||||
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 [playerName, setPlayerName] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [formData, setFormData] = useState<string>(() =>
|
||||
JSON.stringify(DEFAULT_FORM_DATA, null, 2),
|
||||
);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [nameFetching, setNameFetching] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [state, setState] = useState<BuilderState>({
|
||||
@@ -84,6 +75,18 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
// SSE onEvent handler captured at subscription time) without forcing a
|
||||
// re-subscribe on every controller change.
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
@@ -92,7 +95,12 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
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(() => {
|
||||
if (cancelled) return;
|
||||
@@ -110,21 +118,51 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
};
|
||||
}, [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(() => {
|
||||
if (submitting || state.phase === "building") return false;
|
||||
if (!name.trim() || !playerName.trim()) return false;
|
||||
if (mode === "preset" && !selectedPresetId) return false;
|
||||
if (mode === "form" && formError) return false;
|
||||
if (mode === "form" && !setting.trim()) return false;
|
||||
return true;
|
||||
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, formError]);
|
||||
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, setting]);
|
||||
|
||||
const onFormChange = (v: string) => {
|
||||
setFormData(v);
|
||||
const handleRandomName = async () => {
|
||||
setNameFetching(true);
|
||||
try {
|
||||
JSON.parse(v);
|
||||
setFormError(null);
|
||||
const res = await MiscApi.randomName(language);
|
||||
if (res?.name) setPlayerName(res.name);
|
||||
} 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,
|
||||
};
|
||||
} 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 = {
|
||||
mode: "form",
|
||||
form_data: parsedForm,
|
||||
form_data: {
|
||||
setting: setting.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
name: name.trim(),
|
||||
language,
|
||||
player_name: playerName.trim(),
|
||||
@@ -295,7 +338,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
// Save world id for redirect on done
|
||||
createdWorldIdRef.current = res.world_id;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create world";
|
||||
const message = toErrorMessage(err, t("builder.build_failed"));
|
||||
pushToast("error", message);
|
||||
setState((s) => ({ ...s, phase: "error" }));
|
||||
} finally {
|
||||
@@ -303,16 +346,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const createdWorldIdRef = useMemoRef<string | null>(null);
|
||||
|
||||
// On done, navigate to play page
|
||||
// On done, navigate to the EDIT page (not play) — the user should
|
||||
// review the world, generate the intro scene, then click Play when ready.
|
||||
useEffect(() => {
|
||||
if (state.phase === "done" && createdWorldIdRef.current) {
|
||||
const id = createdWorldIdRef.current;
|
||||
const timer = window.setTimeout(() => {
|
||||
void WorldsApi.get(id).then((w: World) => {
|
||||
if (w.status === "ready") navigate(`/worlds/${id}/play`);
|
||||
});
|
||||
navigate(`/worlds/${id}/edit`);
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
@@ -363,13 +403,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
active={mode === "preset"}
|
||||
title={t("builder.mode_preset")}
|
||||
description={t("builder.preset_help")}
|
||||
onClick={() => setMode("preset")}
|
||||
onClick={() => handleModeChange("preset")}
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "form"}
|
||||
title={t("builder.mode_form")}
|
||||
description={t("builder.form_help")}
|
||||
onClick={() => setMode("form")}
|
||||
onClick={() => handleModeChange("form")}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -392,7 +432,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
) : (
|
||||
<select
|
||||
value={selectedPresetId}
|
||||
onChange={(e) => setSelectedPresetId(e.target.value)}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
{presets.map((p) => (
|
||||
@@ -406,32 +446,49 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
)}
|
||||
|
||||
{mode === "form" && (
|
||||
<div>
|
||||
<label className="label">{t("builder.step_form")}</label>
|
||||
<Textarea
|
||||
value={formData}
|
||||
onChange={(e) => onFormChange(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-xs"
|
||||
error={formError ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label={t("builder.setting")}
|
||||
hint={t("builder.setting_help")}
|
||||
value={setting}
|
||||
onChange={(e) => setSetting(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t("builder.setting_placeholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t("builder.world_name")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="The Forgotten Realm"
|
||||
placeholder={t("worlds.default_name")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t("builder.player_name")}
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
placeholder="Aria"
|
||||
/>
|
||||
<div>
|
||||
<label className="label">{t("builder.player_name")}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
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>
|
||||
<label className="label">{t("common.language")}</label>
|
||||
<select
|
||||
@@ -453,7 +510,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
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")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -560,10 +617,3 @@ function ModeButton({
|
||||
</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 { Spinner } from "@/components/ui/Spinner";
|
||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
import { ToolCallBubble } from "@/components/sessions/ToolCallBubble";
|
||||
|
||||
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
|
||||
|
||||
@@ -24,7 +25,7 @@ interface DiffItem {
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
kind: "comment" | "clarification" | "change_proposed" | "info" | "error";
|
||||
kind: "comment" | "clarification" | "change_proposed" | "info" | "error" | "tool_call";
|
||||
text: string;
|
||||
options?: string[];
|
||||
diff?: unknown;
|
||||
@@ -36,6 +37,10 @@ interface LogEntry {
|
||||
decision?: "accept" | "reject";
|
||||
/** Whether the accept/reject request is in-flight. */
|
||||
deciding?: boolean;
|
||||
/** For tool_call entries: tool name + result + success. */
|
||||
tool?: string;
|
||||
toolResult?: unknown;
|
||||
toolSuccess?: boolean;
|
||||
}
|
||||
|
||||
export interface WorldEditorProps {
|
||||
@@ -110,6 +115,21 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
]);
|
||||
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": {
|
||||
const d = event.data as { text: string };
|
||||
setLogs((l) => [...l, { id: uid(), kind: "comment", text: d.text }]);
|
||||
@@ -338,6 +358,8 @@ interface LogEntryViewProps {
|
||||
}
|
||||
|
||||
function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (entry.kind === "clarification") {
|
||||
return (
|
||||
<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") {
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"never_played": "Not played yet",
|
||||
"current_time": "Current time",
|
||||
"player": "Player",
|
||||
"default_name": "New World",
|
||||
"delete_confirm": "Delete this world? This cannot be undone.",
|
||||
"deleted": "World deleted.",
|
||||
"delete_failed": "Failed to delete world.",
|
||||
@@ -112,8 +113,13 @@
|
||||
"mode_form": "Custom form",
|
||||
"preset_help": "Start from a pre-configured world template.",
|
||||
"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",
|
||||
"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_help": "Additional instructions for the Game Master.",
|
||||
"language_en": "English",
|
||||
@@ -145,6 +151,7 @@
|
||||
"clarification": "Clarification needed",
|
||||
"change_proposed": "Change proposed",
|
||||
"comment": "Comment",
|
||||
"assistant": "Assistant",
|
||||
"apply_changes": "Apply changes",
|
||||
"discard_changes": "Discard changes",
|
||||
"changes_applied": "Changes applied.",
|
||||
@@ -178,12 +185,22 @@
|
||||
"weather": "Weather",
|
||||
"player": "Player",
|
||||
"hp": "HP",
|
||||
"health": "Health",
|
||||
"mana": "Mana",
|
||||
"strength": "Strength",
|
||||
"level": "Level",
|
||||
"inventory": "Inventory",
|
||||
"conditions": "Conditions",
|
||||
"npcs": "NPCs",
|
||||
"items": "Items",
|
||||
"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",
|
||||
"scene": "Scene",
|
||||
"you": "You",
|
||||
@@ -227,6 +244,10 @@
|
||||
"settings_saved": "Settings saved.",
|
||||
"settings_save_failed": "Failed to save 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_embeddings": "Embeddings",
|
||||
"group_qdrant": "Qdrant",
|
||||
@@ -248,6 +269,12 @@
|
||||
"logs_prompt": "Prompt",
|
||||
"logs_response": "Response",
|
||||
"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_username": "Username",
|
||||
"users_admin": "Admin",
|
||||
@@ -270,6 +297,10 @@
|
||||
"test_embeddings": "Test embeddings",
|
||||
"probe_dimension": "Probe dimension",
|
||||
"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_key": "API key",
|
||||
"model": "Model",
|
||||
@@ -301,7 +332,46 @@
|
||||
"text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.",
|
||||
"text_replacements_from": "From",
|
||||
"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": {
|
||||
"generic": "Something went wrong.",
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"never_played": "Ещё не играли",
|
||||
"current_time": "Текущее время",
|
||||
"player": "Игрок",
|
||||
"default_name": "Новый Мир",
|
||||
"delete_confirm": "Удалить этот мир? Действие необратимо.",
|
||||
"deleted": "Мир удалён.",
|
||||
"delete_failed": "Не удалось удалить мир.",
|
||||
@@ -112,8 +113,13 @@
|
||||
"mode_form": "Своя форма",
|
||||
"preset_help": "Начать с готового шаблона мира.",
|
||||
"form_help": "Настроить мир самостоятельно с собственными правилами.",
|
||||
"setting": "Сеттинг",
|
||||
"setting_help": "Опишите сеттинг мира (например, «постапокалиптический подземный бункер»).",
|
||||
"setting_placeholder": "постапокалиптический подземный бункер",
|
||||
"world_name": "Название мира",
|
||||
"player_name": "Имя персонажа игрока",
|
||||
"name_random_tip": "Сгенерировать случайное имя",
|
||||
"name_random_failed": "Не удалось получить случайное имя.",
|
||||
"notes": "Заметки (необязательно)",
|
||||
"notes_help": "Дополнительные инструкции для мастера игры.",
|
||||
"language_en": "Английский",
|
||||
@@ -145,6 +151,7 @@
|
||||
"clarification": "Требуется уточнение",
|
||||
"change_proposed": "Предложено изменение",
|
||||
"comment": "Комментарий",
|
||||
"assistant": "Ассистент",
|
||||
"apply_changes": "Применить изменения",
|
||||
"discard_changes": "Отменить изменения",
|
||||
"changes_applied": "Изменения применены.",
|
||||
@@ -178,12 +185,22 @@
|
||||
"weather": "Погода",
|
||||
"player": "Игрок",
|
||||
"hp": "ОЗ",
|
||||
"health": "Здоровье",
|
||||
"mana": "Мана",
|
||||
"strength": "Сила",
|
||||
"level": "Уровень",
|
||||
"inventory": "Инвентарь",
|
||||
"conditions": "Состояния",
|
||||
"npcs": "NPC",
|
||||
"items": "Предметы",
|
||||
"plot_rails": "Сюжетные линии",
|
||||
"hooks": "Зацепки",
|
||||
"current_goals": "Текущие цели",
|
||||
"completed_goals": "Завершённые цели",
|
||||
"no_env_data": "Данные об окружении пока отсутствуют.",
|
||||
"no_intro_scene": "У этого мира ещё нет вступительной сцены. Перейдите в редактор, чтобы сгенерировать её.",
|
||||
"go_to_edit": "Перейти в редактор",
|
||||
"edit_world": "Редактировать мир",
|
||||
"chat": "Чат",
|
||||
"scene": "Сцена",
|
||||
"you": "Вы",
|
||||
@@ -227,6 +244,10 @@
|
||||
"settings_saved": "Настройки сохранены.",
|
||||
"settings_save_failed": "Не удалось сохранить настройки.",
|
||||
"settings_load_failed": "Не удалось загрузить настройки.",
|
||||
"settings_hint": "Каждая карточка сохраняется независимо. Секретные значения (api_key) маскируются после сохранения.",
|
||||
"expand_all": "Развернуть все",
|
||||
"collapse_all": "Свернуть все",
|
||||
"no_changes": "Нет изменений для сохранения.",
|
||||
"group_llm": "Конфигурация LLM",
|
||||
"group_embeddings": "Эмбеддинги",
|
||||
"group_qdrant": "Qdrant",
|
||||
@@ -248,6 +269,12 @@
|
||||
"logs_prompt": "Запрос",
|
||||
"logs_response": "Ответ",
|
||||
"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_username": "Имя пользователя",
|
||||
"users_admin": "Админ",
|
||||
@@ -270,6 +297,10 @@
|
||||
"test_embeddings": "Тест эмбеддингов",
|
||||
"probe_dimension": "Определить размерность",
|
||||
"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_key": "Ключ API",
|
||||
"model": "Модель",
|
||||
@@ -301,7 +332,46 @@
|
||||
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
|
||||
"text_replacements_from": "С",
|
||||
"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": {
|
||||
"generic": "Что-то пошло не так.",
|
||||
|
||||
@@ -478,4 +478,18 @@ export const AdminApi = {
|
||||
export const MiscApi = {
|
||||
health: () => request<HealthResponse>("/health"),
|
||||
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 { useAuthStore } from "@/stores/authStore";
|
||||
import { SettingsPanel } from "@/components/admin/SettingsPanel";
|
||||
@@ -9,21 +9,27 @@ import { TestButtons } from "@/components/admin/TestButtons";
|
||||
import { IconsPanel } from "@/components/admin/IconsPanel";
|
||||
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 }> = [
|
||||
{ id: "settings", labelKey: "admin.tab_settings" },
|
||||
{ id: "logs", labelKey: "admin.tab_logs" },
|
||||
{ id: "users", labelKey: "admin.tab_users" },
|
||||
{ id: "stats", labelKey: "admin.tab_stats" },
|
||||
{ id: "test", labelKey: "admin.tab_test" },
|
||||
{ id: "icons", labelKey: "admin.tab_icons" },
|
||||
const TABS: Array<{ id: Tab; labelKey: string; path: string }> = [
|
||||
{ id: "stats", labelKey: "admin.tab_stats", path: "stats" },
|
||||
{ id: "logs", labelKey: "admin.tab_logs", path: "logs" },
|
||||
{ id: "users", labelKey: "admin.tab_users", path: "users" },
|
||||
{ id: "settings", labelKey: "admin.tab_settings", path: "settings" },
|
||||
{ id: "test", labelKey: "admin.tab_test", path: "test" },
|
||||
{ id: "icons", labelKey: "admin.tab_icons", path: "icons" },
|
||||
];
|
||||
|
||||
const VALID_TABS = new Set<string>(TABS.map((t) => t.id));
|
||||
|
||||
export function AdminPage() {
|
||||
const { t } = useTranslation();
|
||||
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) {
|
||||
return (
|
||||
@@ -33,6 +39,10 @@ export function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const goTab = (next: Tab) => {
|
||||
navigate(`/admin/${next}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header>
|
||||
@@ -43,14 +53,14 @@ export function AdminPage() {
|
||||
{TABS.map((tabDef) => (
|
||||
<button
|
||||
key={tabDef.id}
|
||||
onClick={() => setTab(tabDef.id)}
|
||||
onClick={() => goTab(tabDef.id)}
|
||||
className={cn(
|
||||
"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-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)}
|
||||
</button>
|
||||
@@ -58,12 +68,12 @@ export function AdminPage() {
|
||||
</nav>
|
||||
|
||||
<section>
|
||||
{tab === "settings" && <SettingsPanel />}
|
||||
{tab === "logs" && <LlmLogsTable />}
|
||||
{tab === "users" && <UsersTable />}
|
||||
{tab === "stats" && <StatsPanel />}
|
||||
{tab === "test" && <TestButtons />}
|
||||
{tab === "icons" && <IconsPanel />}
|
||||
{activeTab === "stats" && <StatsPanel />}
|
||||
{activeTab === "logs" && <LlmLogsTable />}
|
||||
{activeTab === "users" && <UsersTable />}
|
||||
{activeTab === "settings" && <SettingsPanel />}
|
||||
{activeTab === "test" && <TestButtons />}
|
||||
{activeTab === "icons" && <IconsPanel />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||
import { toErrorMessage } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
@@ -10,13 +11,15 @@ import { Spinner } from "@/components/ui/Spinner";
|
||||
import { ChatView } from "@/components/sessions/ChatView";
|
||||
import { ActionInput } from "@/components/sessions/ActionInput";
|
||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
import type { Environment, PlotRail } from "@/types";
|
||||
import type { Environment, PlotRailsContainer } from "@/types";
|
||||
|
||||
export function PlayPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const settings = useUiSettingsStore((s) => s.settings);
|
||||
const headerTitle = selectHeaderTitle(settings);
|
||||
|
||||
const world = useSessionStore((s) => s.world);
|
||||
const environment = useSessionStore((s) => s.environment);
|
||||
@@ -53,6 +56,17 @@ export function PlayPage() {
|
||||
}
|
||||
}, [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 (loading && !world) {
|
||||
@@ -76,8 +90,26 @@ export function PlayPage() {
|
||||
if (world.status !== "ready") return null;
|
||||
|
||||
const player = environment?.player;
|
||||
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
|
||||
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) => {
|
||||
void sendAction(id, action, "manual").catch((err) => {
|
||||
@@ -112,66 +144,30 @@ export function PlayPage() {
|
||||
{/* Environment panel */}
|
||||
<aside className="order-2 lg:order-1 lg:w-72 lg:shrink-0 overflow-y-auto space-y-3">
|
||||
<Card title={t("play.environment")}>
|
||||
<dl className="space-y-1 text-sm">
|
||||
<EnvRow label={t("play.location")} value={environment?.location} />
|
||||
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
|
||||
<EnvRow label={t("play.weather")} value={environment?.weather} />
|
||||
</dl>
|
||||
{!envHasData ? (
|
||||
<p className="text-xs text-fg-muted">{t("play.no_env_data")}</p>
|
||||
) : (
|
||||
<dl className="space-y-1 text-sm">
|
||||
<EnvRow label={t("play.location")} value={environment?.location} />
|
||||
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
|
||||
<EnvRow label={t("play.weather")} value={environment?.weather} />
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{player && (
|
||||
<Card title={t("play.player")}>
|
||||
<dl className="space-y-1 text-sm">
|
||||
<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>
|
||||
<PlayerPanel environment={environment} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plotRails.length > 0 && (
|
||||
<Card title={t("play.plot_rails")}>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{plotRails.map((r, i) => (
|
||||
<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>
|
||||
{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 title={t("play.plot_rails")}>
|
||||
{!plotRailsHasData || !plotRailsContainer ? (
|
||||
<p className="text-xs text-fg-muted">{t("play.no_env_data")}</p>
|
||||
) : (
|
||||
<PlotRailsView rails={plotRailsContainer} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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">
|
||||
<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">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
{(() => {
|
||||
// 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 }) {
|
||||
if (value == null || value === "") return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WorldEditor } from "@/components/worlds/WorldEditor";
|
||||
@@ -19,6 +20,8 @@ export function WorldEditPage() {
|
||||
const fetchWorld = useWorldsStore((s) => s.fetchWorld);
|
||||
const setCurrentWorld = useWorldsStore((s) => s.setCurrentWorld);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const settings = useUiSettingsStore((s) => s.settings);
|
||||
const headerTitle = selectHeaderTitle(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -28,6 +31,16 @@ export function WorldEditPage() {
|
||||
return () => setCurrentWorld(null);
|
||||
}, [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 () => {
|
||||
if (!id) return;
|
||||
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) => ({
|
||||
settings: null,
|
||||
loaded: false,
|
||||
|
||||
@@ -114,6 +114,18 @@ export interface PlotRail {
|
||||
[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 {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
@@ -126,7 +138,7 @@ export interface World {
|
||||
schemas: Schemas;
|
||||
environment_schema: unknown;
|
||||
environment: Environment;
|
||||
plot_rails: PlotRail[];
|
||||
plot_rails: PlotRail[] | PlotRailsContainer;
|
||||
current_time: string | null;
|
||||
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00").
|
||||
* Returned by GET /api/sessions/worlds/{id}/state on the world object.
|
||||
@@ -288,6 +300,9 @@ export interface AdminStats {
|
||||
|
||||
export interface PublicSettings {
|
||||
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;
|
||||
logo_url: string;
|
||||
og_image_url: string;
|
||||
|
||||
Reference in New Issue
Block a user