diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f67e95..8333e25 100644 --- a/CHANGELOG.md +++ b/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 `` 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. diff --git a/app/api/misc.py b/app/api/misc.py index 242e7c8..4e0566f 100644 --- a/app/api/misc.py +++ b/app/api/misc.py @@ -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.""" diff --git a/app/api/worlds.py b/app/api/worlds.py index 82dc973..c6d3c40 100644 --- a/app/api/worlds.py +++ b/app/api/worlds.py @@ -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", diff --git a/app/core/settings_service.py b/app/core/settings_service.py index 924ef17..5d2b939 100644 --- a/app/core/settings_service.py +++ b/app/core/settings_service.py @@ -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. diff --git a/app/engine/world_builder.py b/app/engine/world_builder.py index 7f63b30..07ad628 100644 --- a/app/engine/world_builder.py +++ b/app/engine/world_builder.py @@ -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 diff --git a/app/engine/world_editor.py b/app/engine/world_editor.py index 0d2f136..d6b4b91 100644 --- a/app/engine/world_editor.py +++ b/app/engine/world_editor.py @@ -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.` → 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.` → replace a single schema type + - `environment.` → apply_patch to world.environment + - `environment` → replace entire world.environment + - `player.` → shorthand for environment.player. + - `current_location` → shorthand for environment.current_location + - `plot_rails.` → shorthand for environment.plot_rails. """ 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) diff --git a/app/prompts/stages/orchestrator_phase1.py b/app/prompts/stages/orchestrator_phase1.py index 2812324..fa8af71 100644 --- a/app/prompts/stages/orchestrator_phase1.py +++ b/app/prompts/stages/orchestrator_phase1.py @@ -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. diff --git a/app/prompts/stages/world_builder_schema.py b/app/prompts/stages/world_builder_schema.py index 2567f60..63c9b28 100644 --- a/app/prompts/stages/world_builder_schema.py +++ b/app/prompts/stages/world_builder_schema.py @@ -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": "", } diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 23ca881..82f6299 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a3d3a6..09a66d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -164,6 +164,14 @@ export default function App() { /> + + + } + /> + diff --git a/frontend/src/components/admin/LlmLogsTable.tsx b/frontend/src/components/admin/LlmLogsTable.tsx index a5d5a96..58bd43e 100644 --- a/frontend/src/components/admin/LlmLogsTable.tsx +++ b/frontend/src/components/admin/LlmLogsTable.tsx @@ -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
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 (
- -
+ + {newLogsCount > 0 && ( + + {t("admin.logs_new_count", { count: newLogsCount })} + + )} + +
+ } + > +
+

+ {autoRefreshActive + ? t("admin.logs_auto_refresh_on") + : t("admin.logs_auto_refresh_off")} +

diff --git a/frontend/src/components/admin/SettingsPanel.tsx b/frontend/src/components/admin/SettingsPanel.tsx index fa950f8..e7d6e6c 100644 --- a/frontend/src/components/admin/SettingsPanel.tsx +++ b/frontend/src/components/admin/SettingsPanel.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [draft, setDraft] = useState>({}); + // 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>(() => 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 /> -
-

{t("admin.tab_settings")}

-

- Each card saves independently. Secret values (api_key) are masked after save. -

+
+
+

{t("admin.tab_settings")}

+

+ {t("admin.settings_hint")} +

+
+
+ + +
{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 ( toggleCollapsed(g.id)} onChange={(key, value) => setDraft((d) => ({ ...d, [key]: value })) } @@ -289,6 +319,8 @@ export function SettingsPanel() { void handleSaveKey("llm.text_replacements", v)} + collapsed={collapsed.has("text_replacements")} + onToggle={() => toggleCollapsed("text_replacements")} /> )} @@ -303,11 +335,22 @@ interface SettingsGroupCardProps { title: string; entries: Array<{ key: string; description?: string }>; draft: Record; + 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 ( + + {collapsed ? "▶" : "▼"} + + {title} + + } actions={ - + !collapsed && ( + + ) } > -
- {showGrid && ( -
- {regularEntries.map(({ key, description }) => ( - onChange(key, v)} - /> - ))} -
- )} - {modelEntry && ( - onChange("llm.model", v)} - apiUrl={draft["llm.api_url"] ?? ""} - apiKey={draft["llm.api_key"] ?? ""} - description={modelEntry.description} - /> - )} -
+ {!collapsed && ( +
+ {showGrid && ( +
+ {regularEntries.map(({ key, description }) => ( + onChange(key, v)} + /> + ))} +
+ )} + {modelEntry && ( + onChange("llm.model", v)} + apiUrl={draft["llm.api_url"] ?? ""} + apiKey={draft["llm.api_key"] ?? ""} + description={modelEntry.description} + /> + )} +
+ )}
); } @@ -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 (
- - + onChange(e.target.checked ? "true" : "false")} + className="h-4 w-4" + /> + {label} + {hint &&

{hint}

}
); @@ -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(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 ( + + {collapsed ? "▶" : "▼"} + + {t("admin.text_replacements_title")} + + } + description={!collapsed ? t("admin.text_replacements_help") : undefined} actions={ - + !collapsed && ( + + ) } > -
- {rules.length === 0 && ( -

{t("admin.text_replacements_empty")}

- )} - {rules.map((rule, idx) => ( -
- updateRule(idx, "from", e.target.value)} - autoComplete="off" - /> - - updateRule(idx, "to", e.target.value)} - autoComplete="off" - /> - -
- ))} - -
+ {!collapsed && ( +
+ {rules.length === 0 && ( +

{t("admin.text_replacements_empty")}

+ )} + {rules.map((rule, idx) => ( +
+ updateRule(idx, "from", e.target.value)} + autoComplete="off" + /> + + updateRule(idx, "to", e.target.value)} + autoComplete="off" + /> + +
+ ))} + +
+ )}
); } diff --git a/frontend/src/components/admin/TestButtons.tsx b/frontend/src/components/admin/TestButtons.tsx index 4ced2f7..a31cf77 100644 --- a/frontend/src/components/admin/TestButtons.tsx +++ b/frontend/src/components/admin/TestButtons.tsx @@ -221,7 +221,7 @@ function EmbeddingsTestCard() { ))}

- If provider=openai and api_url is empty, the system falls back to llm.api_url + {t("admin.test_provider_hint")}

@@ -328,7 +328,7 @@ function RecreateCollectionsCard() { return (

- Drops and recreates Qdrant collections based on current embeddings dimension. + {t("admin.recreate_collections_hint")}