From c45ab1ddd551c4ba39239a9682aaa8c056603d7f Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:24:42 +0300 Subject: [PATCH] fix --- CHANGELOG.md | 51 ++++ app/api/admin.py | 72 ++++++ app/api/sessions.py | 42 +++- app/core/settings_service.py | 4 + app/engine/game_master.py | 37 ++- app/engine/sse.py | 82 ++++++- app/engine/world_builder.py | 107 +++++++- .../src/components/admin/SettingsPanel.tsx | 221 ++++++++++++++++- .../src/components/sessions/ActionInput.tsx | 31 +-- frontend/src/components/sessions/ChatView.tsx | 217 ++++++++++++++-- .../src/components/sessions/SseStatus.tsx | 78 ++++-- .../components/sessions/ToolCallBubble.tsx | 59 +++-- .../src/components/worlds/WorldBuilder.tsx | 44 +++- frontend/src/components/worlds/WorldCard.tsx | 11 +- frontend/src/i18n/en.json | 41 +++- frontend/src/i18n/ru.json | 41 +++- frontend/src/lib/api.ts | 37 +++ frontend/src/lib/formatTime.ts | 55 +++++ frontend/src/lib/sse.ts | 1 + frontend/src/pages/PlayPage.tsx | 96 +++++++- frontend/src/pages/WorldEditPage.tsx | 11 +- frontend/src/stores/sessionStore.ts | 232 +++++++++++++++++- frontend/src/types/index.ts | 14 ++ frontend/tsconfig.tsbuildinfo | 2 +- 24 files changed, 1438 insertions(+), 148 deletions(-) create mode 100644 frontend/src/lib/formatTime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8333e25..b609604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ 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.5.0] — 2026-06-21 + +Major gameplay release: submit_plan logic, tool retry, production SSE mode, chat history, name bank editor. + +### Backend — Critical: submit_plan & tool-call logic + +- **submit_plan forbidden on first substep**: the terminal tool (submit_plan, submit_step) cannot be called on the first round of Phase 1. The model must do at least some work (call entity_*, env_update, etc.) first. If it tries, a system message is appended: "You called submit_plan without doing any work first." +- **submit_plan cancelled if other tools failed**: if the model calls submit_plan in the same response as other tools that FAILED, the submit_plan is cancelled with a tool_result: "submit_plan cancelled because other tools in this response failed. Fix the errors first." The model must retry. +- **Configurable tool retry**: new setting `llm.tool_retry_attempts` (default 3). When the model returns no tool calls, the loop retries with a system nudge: "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." Up to `tool_retry_attempts` retries before forcing the terminal tool. +- **suggest_actions retry**: Phase 3 suggest_actions now retries up to `tool_retry_attempts` times with the same nudge pattern. If all retries fail, defaults to `["Continue exploring", "Talk to someone nearby"]`. +- **submit_step retry**: Phase 2 writer now retries up to `tool_retry_attempts + 1` times. + +### Backend — Critical: Production SSE mode + +- **SseEmitter `debug` flag**: when `DEBUG=false` (production), the emitter transforms/filters events: + - `tool_call` events → transformed into `status` events with friendly messages (e.g. "Added new entity", "Updated game state", "Updated story progress"). Failed tool calls, RAG queries, calculations, and submit_* calls are filtered out entirely. + - `llm_call_start`/`llm_call_end` events → filtered out. + - `phase_start` events → friendly names: `planning` (Phase 1), `writing` (Phase 2), `sending` (Phase 3). +- All 4 SSE endpoints (builder, editor, iterate, intro streams) now pass `debug=get_settings().debug` to SseEmitter. + +### Backend — New: Chat history endpoint + +- **`GET /api/sessions/worlds/{id}/history?before={seq}&limit=20`** — returns full chat history with pagination. Used by the frontend for scroll-up loading. Returns `{steps: [...], has_more: boolean, oldest_sequence: number | null}`. + +### Backend — New: Name bank admin endpoints + +- `GET /api/admin/names/{language}` → `{language, names: [...], count}` +- `PUT /api/admin/names/{language}` body `{names: [...]}` → replaces entire bank +- `POST /api/admin/names/{language}/add` body `{name: "..."}` → adds one name +- `DELETE /api/admin/names/{language}/{name}` → removes one name + +### Backend — New setting + +- `llm.tool_retry_attempts` (integer, default 3) — number of retries when the LLM returns no tool calls. + +### Frontend — 17 files changed, 1 new + +- **Chat history preservation**: intro_scene ALWAYS shown as first message. New steps APPENDED (not replacing). Scroll-up pagination via `GET /api/sessions/worlds/{id}/history`. "Loading more..." indicator. Scroll position preserved on prepend. +- **Removed duplicate suggested actions**: the chips above the input field are gone. Suggested actions only appear under the last GM message. +- **Selected action marking**: clicking a suggested action immediately shows it as a player message. The clicked action is highlighted; others are greyed out. Custom input also greys out all suggestions. +- **Tool call bubble width fix**: `max-w-full overflow-hidden` on root, `truncate` on tool name, collapsible `
` with `overflow-auto` + `break-all` for args/result. +- **Debug-only elements**: "Disconnected" indicator hidden entirely. SseStatus only shows for connecting/error states. Production mode shows friendly phase labels: "Reading...", "Planning...", "Writing...", "Processing...". +- **Client-side time formatting**: new `formatGameTime(timeStr, language)` utility. Applied in WorldEditPage, WorldCard, PlayPage (computes `current_time_human` client-side since `GET /api/worlds/{id}` doesn't include it). +- **Name bank editor**: new card in SettingsPanel between UI Settings and Text Replacements. Shows en/ru name lists with × remove buttons, add input, and Save button. +- **No more large text flash**: WorldBuilder no longer renders intro_scene text before redirect. Redirect delay reduced from 800ms to 150ms. +- **Localized builder step messages**: all step labels and messages now use i18n keys. + +### Verification +- Backend: 68 unit tests pass, 58 routes. +- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (405 KB JS / 26 KB CSS, ~122 KB gzipped). + ## [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. diff --git a/app/api/admin.py b/app/api/admin.py index ecbaf86..09321ab 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -292,6 +292,78 @@ async def stats( } +# --------------------------------------------------------------------------- # +# Name bank — get/update character name banks per language +# --------------------------------------------------------------------------- # +@router.get("/names/{language}") +async def get_name_bank( + language: str, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + """Get the character name bank for a language.""" + names = await get_setting(db, f"character_names.{language}") + if not isinstance(names, list): + names = [] + return {"language": language, "names": names, "count": len(names)} + + +@router.put("/names/{language}") +async def update_name_bank( + language: str, + body: dict, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + """Update the character name bank for a language. + + Body: {names: ["name1", "name2", ...]} + """ + names = body.get("names", []) + if not isinstance(names, list): + raise HTTPException(400, "names must be an array") + # Validate all entries are strings + cleaned = [str(n).strip() for n in names if str(n).strip()] + await set_setting(db, f"character_names.{language}", cleaned) + return {"language": language, "names": cleaned, "count": len(cleaned)} + + +@router.post("/names/{language}/add") +async def add_name_to_bank( + language: str, + body: dict, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + """Add a single name to the bank. Body: {name: "..."}""" + name = body.get("name", "").strip() + if not name: + raise HTTPException(400, "name is required") + names = await get_setting(db, f"character_names.{language}") + if not isinstance(names, list): + names = [] + if name not in names: + names.append(name) + await set_setting(db, f"character_names.{language}", names) + return {"language": language, "names": names, "count": len(names)} + + +@router.delete("/names/{language}/{name}") +async def remove_name_from_bank( + language: str, + name: str, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + """Remove a name from the bank.""" + names = await get_setting(db, f"character_names.{language}") + if not isinstance(names, list): + names = [] + names = [n for n in names if n != name] + await set_setting(db, f"character_names.{language}", names) + return {"language": language, "names": names, "count": len(names)} + + # --------------------------------------------------------------------------- # # LLM model list — fetch available models from the LLM provider # --------------------------------------------------------------------------- # diff --git a/app/api/sessions.py b/app/api/sessions.py index 9725af5..a4b8359 100644 --- a/app/api/sessions.py +++ b/app/api/sessions.py @@ -13,6 +13,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_settings_dict +from app.config import get_settings from app.core.llm import LlmClient, MockLlmClient from app.core.logging import get_logger from app.db import get_db @@ -91,6 +92,39 @@ async def get_state( } +@router.get("/worlds/{world_id}/history") +async def get_history( + world_id: uuid.UUID, + before: int | None = None, # sequence_number to load before (for pagination) + limit: int = 20, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> dict: + """Return full chat history with pagination (for scroll-up loading).""" + world = await _load_world(db, world_id, user) + stmt = select(Step).where( + Step.world_id == world.id, Step.deleted_at.is_(None) + ) + if before is not None: + stmt = stmt.where(Step.sequence_number < before) + stmt = stmt.order_by(Step.sequence_number.desc()).limit(limit) + rows = (await db.execute(stmt)).scalars().all() + steps = [ + { + "id": str(s.id), "sequence_number": s.sequence_number, + "player_action": s.player_action, "scene_text": s.scene_text, + "suggested_actions": s.suggested_actions, "created_at": s.created_at.isoformat(), + } + for s in reversed(rows) # oldest first + ] + has_more = len(rows) == limit + return { + "steps": steps, + "has_more": has_more, + "oldest_sequence": steps[0]["sequence_number"] if steps else None, + } + + # --------------------------------------------------------------------------- # # World builder stream (SSE) # --------------------------------------------------------------------------- # @@ -107,7 +141,7 @@ async def builder_stream( preset = ( await db.execute(select(WorldPreset).where(WorldPreset.id == world.preset_id)) ).scalar_one_or_none() - emitter = SseEmitter() + emitter = SseEmitter(debug=get_settings().debug) player_name = (world.environment or {}).get("player", {}).get("name", "Hero") notes = world.description llm = _llm_factory(settings) @@ -152,7 +186,7 @@ async def editor_stream( settings: dict = Depends(get_settings_dict), ) -> StreamingResponse: world = await _load_world(db, world_id, user) - emitter = SseEmitter() + emitter = SseEmitter(debug=get_settings().debug) llm = _llm_factory(settings) async def run_bg(): @@ -234,7 +268,7 @@ async def iterate_stream( ).scalar_one_or_none() if step is None: raise HTTPException(404, "step not found") - emitter = SseEmitter() + emitter = SseEmitter(debug=get_settings().debug) llm = _llm_factory(settings) async def run_bg(): @@ -349,7 +383,7 @@ async def intro_stream( settings: dict = Depends(get_settings_dict), ) -> StreamingResponse: world = await _load_world(db, world_id, user) - emitter = SseEmitter() + emitter = SseEmitter(debug=get_settings().debug) llm = _llm_factory(settings) async def run_bg(): diff --git a/app/core/settings_service.py b/app/core/settings_service.py index 5d2b939..0d0bd72 100644 --- a/app/core/settings_service.py +++ b/app/core/settings_service.py @@ -60,6 +60,10 @@ 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).", }, + "llm.tool_retry_attempts": { + "value": 3, + "description": "Number of retries when the LLM returns no tool calls. Each retry includes a system nudge.", + }, "ui.header_title": { "value": "", "description": "Header title in the navbar. If empty, falls back to ui.page_title.", diff --git a/app/engine/game_master.py b/app/engine/game_master.py index 489df04..5ad46ad 100644 --- a/app/engine/game_master.py +++ b/app/engine/game_master.py @@ -88,7 +88,8 @@ async def run_iteration( sse_emitter=sse.emit) tools = registry.to_openai_format("orchestrator_phase2") phase2_msg: dict[str, Any] = {} - for retry in range(3): + phase2_retries = int(settings.get("llm.tool_retry_attempts", 3)) + for retry in range(phase2_retries + 1): resp = await llm.complete( stage="orchestrator_phase2", messages=messages, @@ -158,12 +159,13 @@ async def run_iteration( db=db, world=world, step=step, llm=llm, sse=sse, settings=settings, ) - # 3.3 Suggest actions + # 3.3 Suggest actions — retry up to tool_retry_attempts if no tools returned suggest_msgs = await build_orchestrator_phase3_suggest_context( db=db, world=world, scene_text=step.scene_text or "", settings=settings, ) suggest_tools = registry.to_openai_format("orchestrator_phase3_suggest") - for retry in range(2): + tool_retry_limit = int(settings.get("llm.tool_retry_attempts", 3)) + for retry in range(tool_retry_limit + 1): resp = await llm.complete( stage="orchestrator_phase3_suggest", messages=suggest_msgs, @@ -173,9 +175,19 @@ async def run_iteration( world_id=world.id, step_id=step.id, session=db, ) msg = resp.get("message", {}) + # Apply text replacements + 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 tcs = msg.get("tool_calls") or [] + found = False for tc in tcs: - fn = tc.get("function", {}) + fn = tc.get("function", {}) if isinstance(tc, dict) else {} + if not isinstance(fn, dict): + fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")} if fn.get("name") == "suggest_actions": try: args = json.loads(fn.get("arguments") or "{}") @@ -185,11 +197,24 @@ async def run_iteration( if result.ok: step.suggested_actions = result.data.get("actions", []) await sse.emit("suggested_actions", {"actions": step.suggested_actions}) + found = True break - if step.suggested_actions: + if found: break + # No suggest_actions call — retry with nudge suggest_msgs.append(msg) - suggest_msgs.append({"role": "user", "content": "Call suggest_actions with 1-3 actions."}) + suggest_msgs.append({ + "role": "user", + "content": ( + "You did not call suggest_actions. " + "You MUST call the suggest_actions tool with 1-3 short action strings. " + "If you tried before and it didn't work, try again with proper JSON arguments." + ), + }) + # If still no actions after retries, provide defaults + if not step.suggested_actions: + step.suggested_actions = ["Continue exploring", "Talk to someone nearby"] + await sse.emit("suggested_actions", {"actions": step.suggested_actions}) await db.commit() await sse.emit("iteration_complete", { diff --git a/app/engine/sse.py b/app/engine/sse.py index e912619..1eb5e35 100644 --- a/app/engine/sse.py +++ b/app/engine/sse.py @@ -25,17 +25,25 @@ class SseEmitter: In a producer task: await emitter.emit("tool_call", {...}) await emitter.done({"result": "ok"}) + + Production mode: when `debug=False`, raw `tool_call` and `llm_call_*` + events are filtered or transformed into friendly `status` events. """ - def __init__(self) -> None: + def __init__(self, debug: bool = True) -> None: self._queue: asyncio.Queue[tuple[str, str, str] | None] = asyncio.Queue() - # (event_type, data_json, event_id) self._event_counter = 0 self._closed = False + self._debug = debug async def emit(self, event_type: str, data: Any) -> None: if self._closed: return + # In production mode, transform/fiter debug-only events + if not self._debug: + event_type, data = self._transform_for_prod(event_type, data) + if event_type is None: + return # event filtered out self._event_counter += 1 event_id = f"evt_{self._event_counter}" try: @@ -44,6 +52,76 @@ class SseEmitter: data_str = json.dumps({"error": "serialization_failed"}) await self._queue.put((event_type, data_str, event_id)) + def _transform_for_prod(self, event_type: str, data: Any) -> tuple[str | None, Any]: + """Transform debug events into user-friendly status events for production.""" + if event_type == "tool_call": + # Transform tool_call into a friendly status message + tool = data.get("tool", "") if isinstance(data, dict) else "" + result = data.get("result", {}) if isinstance(data, dict) else {} + is_success = data.get("is_success", True) if isinstance(data, dict) else True + # Friendly message based on tool type + friendly = self._friendly_tool_message(tool, result, is_success) + if friendly: + return ("status", {"message": friendly, "type": "tool"}) + return (None, None) # filter out + elif event_type in ("llm_call_start", "llm_call_end"): + # Filter out raw LLM call events in production + return (None, None) + elif event_type == "phase_start": + # Keep but with friendly name + phase = data.get("phase") if isinstance(data, dict) else None + friendly_names = { + 1: "planning", + 2: "writing", + 3: "sending", + } + name = friendly_names.get(phase, data.get("name", "")) + return ("phase_start", {"phase": phase, "name": name, "status": name}) + elif event_type == "phase_end": + return (event_type, data) + elif event_type == "warning": + return (event_type, data) + else: + return (event_type, data) + + def _friendly_tool_message(self, tool: str, result: dict, is_success: bool) -> str | None: + """Generate a user-friendly message for a tool call.""" + if not is_success: + return None # hide failed tool calls in production + data = result.get("data", {}) if isinstance(result, dict) else {} + msg = result.get("message", "") if isinstance(result, dict) else "" + if tool == "entity_create": + name = data.get("entity_id", "") + return f"Added new entity" + (f": {name}" if name else "") + elif tool == "entity_update": + return "Updated entity" + elif tool == "entity_delete": + return "Removed entity" + elif tool == "env_update": + return "Updated game state" + elif tool == "update_plot_rails": + return "Updated story progress" + elif tool == "advance_time": + new_time = data.get("new_time", "") + return f"Time advanced" + (f" to {new_time}" if new_time else "") + elif tool == "schedule_trigger": + return "Scheduled future event" + elif tool == "rag_query": + return None # hide RAG queries in production + elif tool == "rag_add": + return "Recorded a new fact" + elif tool == "calc": + return None # hide calculations + elif tool == "random_choice": + return None + elif tool == "submit_plan": + return None + elif tool == "submit_step": + return None + elif tool == "suggest_actions": + return None + return msg if msg else None + async def ping(self) -> None: await self.emit("ping", {"ts": _now_iso()}) diff --git a/app/engine/world_builder.py b/app/engine/world_builder.py index 07ad628..c569233 100644 --- a/app/engine/world_builder.py +++ b/app/engine/world_builder.py @@ -312,7 +312,16 @@ async def _run_tool_loop( max_substeps: int, settings: dict[str, Any], ) -> dict[str, Any] | None: - """Generic tool-calling loop. Returns the result of the terminal tool call.""" + """Generic tool-calling loop. Returns the result of the terminal tool call. + + Rules: + - The terminal tool (e.g. submit_plan) cannot be called on the first substep. + The model must do at least some work first. + - If the terminal tool is called in the same response as other tools that + FAILED, the terminal call is cancelled (the model should fix errors first). + - If the model returns no tool calls, retry with a system nudge (up to + `llm.tool_retry_attempts` times, default 3). + """ registry = get_registry() ctx = ToolContext( db=db, world=world, stage=stage, @@ -324,6 +333,8 @@ async def _run_tool_loop( ] tools = registry.to_openai_format(stage) last_terminal_result: dict[str, Any] | None = None + tool_retry_attempts = int(settings.get("llm.tool_retry_attempts", 3)) if settings else 3 + no_tool_retries = 0 for substep in range(max_substeps): await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")}) @@ -350,8 +361,16 @@ async def _run_tool_loop( msg["content"] = content tool_calls = msg.get("tool_calls") or [] if not tool_calls: - # No tool calls — append assistant message and retry with a nudge. - # Up to 3 retries. + # No tool calls — retry with nudge (up to tool_retry_attempts) + no_tool_retries += 1 + if no_tool_retries > tool_retry_attempts: + # Exhausted retries — force terminal + messages.append({"role": "assistant", "content": msg.get("content", "")}) + messages.append({ + "role": "user", + "content": f"No more retries. You MUST call {terminal_tool} now to end this stage.", + }) + continue messages.append({"role": "assistant", "content": msg.get("content", "")}) messages.append({ "role": "user", @@ -363,13 +382,43 @@ async def _run_tool_loop( ), }) continue + # Reset retry counter on success + no_tool_retries = 0 + # Separate terminal from non-terminal calls + non_terminal_calls = [tc for tc in tool_calls if isinstance(tc, dict) + and (tc.get("function", {}) if isinstance(tc.get("function"), dict) else {}) + .get("name") != terminal_tool] + terminal_calls = [tc for tc in tool_calls if isinstance(tc, dict) + and (tc.get("function", {}) if isinstance(tc.get("function"), dict) else {}) + .get("name") == terminal_tool] + + # Rule 1: terminal tool not allowed on first substep + if substep == 0 and terminal_calls and not non_terminal_calls: + messages.append(msg) + messages.append({ + "role": "user", + "content": ( + f"You called {terminal_tool} without doing any work first. " + f"You MUST call other tools (entity_create, env_update, etc.) to accomplish the task. " + f"Only call {terminal_tool} after you have completed the work." + ), + }) + continue + + # Execute non-terminal calls first messages.append(msg) - for tc in tool_calls: + any_failed = False + for tc in non_terminal_calls: fn = tc.get("function", {}) if isinstance(tc, dict) else {} + if not isinstance(fn, dict): + fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")} tname = fn.get("name", "") + args_str = fn.get("arguments", "{}") + if not isinstance(args_str, str): + args_str = json.dumps(args_str) try: - targs = json.loads(fn.get("arguments") or "{}") + targs = json.loads(args_str) if args_str else {} except json.JSONDecodeError: targs = {} result = await registry.execute(tname, targs, ctx) @@ -379,9 +428,49 @@ async def _run_tool_loop( "name": tname, "content": json.dumps(result.to_dict(), ensure_ascii=False), }) - if tname == terminal_tool: - last_terminal_result = result.to_dict() - return last_terminal_result + if not result.ok: + any_failed = True + + # Rule 2: if any non-terminal tool failed, cancel terminal calls + if terminal_calls: + if any_failed: + # Cancel terminal — tell the model to fix errors first + for tc in terminal_calls: + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": terminal_tool, + "content": json.dumps({ + "ok": False, + "error": { + "code": "cancelled", + "message": f"{terminal_tool} cancelled because other tools in this response failed. Fix the errors first, then call {terminal_tool}.", + }, + }), + }) + continue + else: + # Execute terminal call + tc = terminal_calls[0] + fn = tc.get("function", {}) if isinstance(tc, dict) else {} + if not isinstance(fn, dict): + fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")} + args_str = fn.get("arguments", "{}") + if not isinstance(args_str, str): + args_str = json.dumps(args_str) + try: + targs = json.loads(args_str) if args_str else {} + except json.JSONDecodeError: + targs = {} + result = await registry.execute(terminal_tool, targs, ctx) + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": terminal_tool, + "content": json.dumps(result.to_dict(), ensure_ascii=False), + }) + if result.ok: + last_terminal_result = result.to_dict() + return last_terminal_result - # If we exhausted substeps without terminal, return None return last_terminal_result diff --git a/frontend/src/components/admin/SettingsPanel.tsx b/frontend/src/components/admin/SettingsPanel.tsx index e7d6e6c..772a628 100644 --- a/frontend/src/components/admin/SettingsPanel.tsx +++ b/frontend/src/components/admin/SettingsPanel.tsx @@ -123,7 +123,10 @@ export function SettingsPanel() { 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 allGroupIds = useMemo( + () => GROUPS.map((g) => g.id).concat(["text_replacements", "name_banks"]), + [], + ); const [collapsed, setCollapsed] = useState>(() => new Set(allGroupIds)); const toggleCollapsed = (id: string) => { @@ -323,6 +326,14 @@ export function SettingsPanel() { onToggle={() => toggleCollapsed("text_replacements")} /> )} + {/* Render the Name Banks card after the UI Settings group so + the visual order is: … → UI Settings → Name Banks. */} + {g.id === "ui" && ( + toggleCollapsed("name_banks")} + /> + )} ); })} @@ -807,3 +818,211 @@ function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: TextRep ); } + +// ============================================================================ +// Name Banks card — manages the character name banks per language. +// ============================================================================ + +interface NameBanksCardProps { + collapsed: boolean; + onToggle: () => void; +} + +/** + * Card for managing the character name banks (English + Russian). Each + * language shows a list of names with a × button to remove, plus an + * input + "Add" button to add a new name. The "Save" button calls + * PUT /api/admin/names/{language} with the full updated list. + */ +function NameBanksCard({ collapsed, onToggle }: NameBanksCardProps) { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + + return ( + + + {collapsed ? "▶" : "▼"} + + {t("admin.name_banks_title")} + + } + description={!collapsed ? t("admin.name_banks_help") : undefined} + > + {!collapsed && ( +
+ + +
+ )} +
+ ); +} + +interface NameBankEditorProps { + language: string; + title: string; + pushToast: (kind: "info" | "success" | "error" | "warning", msg: string) => void; +} + +function NameBankEditor({ language, title, pushToast }: NameBankEditorProps) { + const { t } = useTranslation(); + const [names, setNames] = useState([]); + const [loading, setLoading] = useState(true); + const [input, setInput] = useState(""); + const [saving, setSaving] = useState(false); + + // Fetch the current name bank on mount. + useEffect(() => { + let cancelled = false; + setLoading(true); + AdminApi.getNameBank(language) + .then((res) => { + if (cancelled) return; + setNames(res.names || []); + }) + .catch((err) => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : t("admin.name_banks_load_failed"); + pushToast("error", msg); + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [language, pushToast, t]); + + const handleAdd = async () => { + const value = input.trim(); + if (!value) return; + if (names.includes(value)) { + pushToast("info", t("admin.name_banks_already_exists")); + return; + } + setInput(""); + // Optimistic update — append locally, then call the API. If the API + // call fails, we revert by refetching. + const next = [...names, value]; + setNames(next); + try { + const res = await AdminApi.addName(language, value); + setNames(res.names || next); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : t("admin.name_banks_add_failed")); + // Revert by refetching. + try { + const fresh = await AdminApi.getNameBank(language); + setNames(fresh.names || []); + } catch { + /* give up */ + } + } + }; + + const handleRemove = async (name: string) => { + const prev = names; + const next = names.filter((n) => n !== name); + setNames(next); + try { + const res = await AdminApi.removeName(language, name); + setNames(res.names || next); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : t("admin.name_banks_remove_failed")); + setNames(prev); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + const res = await AdminApi.updateNameBank(language, names); + setNames(res.names || names); + pushToast("success", t("admin.settings_saved")); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : t("admin.settings_save_failed")); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

{title}

+ + {loading ? "…" : `(${names.length})`} + +
+ {loading ? ( +
+ {t("common.loading")} +
+ ) : ( + <> + {names.length === 0 ? ( +

{t("admin.name_banks_empty")}

+ ) : ( +
+ {names.map((n) => ( + + {n} + + + ))} +
+ )} +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAdd(); + } + }} + autoComplete="off" + /> + + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/sessions/ActionInput.tsx b/frontend/src/components/sessions/ActionInput.tsx index 33b6e4d..45151c8 100644 --- a/frontend/src/components/sessions/ActionInput.tsx +++ b/frontend/src/components/sessions/ActionInput.tsx @@ -6,18 +6,20 @@ import { Button } from "@/components/ui/Button"; export interface ActionInputProps { onSubmit: (action: string) => void; submitting: boolean; - suggestedActions: string[]; - onSuggestedClick?: (action: string) => void; placeholder?: string; className?: string; autoFocus?: boolean; } +/** + * Action input field (textarea + Send button). The suggested-action chips + * used to also live here — they were removed to avoid duplicating the chips + * that already appear under the last GM message. This component now only + * renders the text input + send button. + */ export function ActionInput({ onSubmit, submitting, - suggestedActions, - onSuggestedClick, placeholder, className, autoFocus = false, @@ -33,29 +35,8 @@ export function ActionInput({ setText(""); }; - const handleSuggested = (action: string) => { - if (submitting) return; - if (onSuggestedClick) onSuggestedClick(action); - else onSubmit(action); - }; - return (
- {suggestedActions.length > 0 && ( -
- {suggestedActions.map((a, i) => ( - - ))} -
- )}