fix
This commit is contained in:
51
CHANGELOG.md
51
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 `<details>` 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.
|
||||
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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()})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -123,7 +123,10 @@ export function SettingsPanel() {
|
||||
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 allGroupIds = useMemo(
|
||||
() => GROUPS.map((g) => g.id).concat(["text_replacements", "name_banks"]),
|
||||
[],
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => 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" && (
|
||||
<NameBanksCard
|
||||
collapsed={collapsed.has("name_banks")}
|
||||
onToggle={() => toggleCollapsed("name_banks")}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -807,3 +818,211 @@ function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: TextRep
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 (
|
||||
<Card
|
||||
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.name_banks_title")}</span>
|
||||
</button>
|
||||
}
|
||||
description={!collapsed ? t("admin.name_banks_help") : undefined}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="space-y-4">
|
||||
<NameBankEditor language="en" title={t("admin.name_banks_en")} pushToast={pushToast} />
|
||||
<NameBankEditor language="ru" title={t("admin.name_banks_ru")} pushToast={pushToast} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>([]);
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-fg">{title}</h4>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{loading ? "…" : `(${names.length})`}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{names.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("admin.name_banks_empty")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{names.map((n) => (
|
||||
<span
|
||||
key={n}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-fg-dim/30 bg-bg-soft px-2 py-0.5 text-xs text-fg"
|
||||
>
|
||||
{n}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(n)}
|
||||
className="text-fg-muted hover:text-err"
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.name_banks_add_placeholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleAdd();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
{t("admin.name_banks_add")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{suggestedActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{suggestedActions.map((a, i) => (
|
||||
<button
|
||||
key={`${a}-${i}`}
|
||||
type="button"
|
||||
onClick={() => handleSuggested(a)}
|
||||
disabled={submitting}
|
||||
className="badge bg-bg-soft text-fg hover:bg-bg-card hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type UIEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -8,33 +8,109 @@ import { ToolCallBubble } from "./ToolCallBubble";
|
||||
|
||||
export interface ChatViewProps {
|
||||
className?: string;
|
||||
/** Called when the user scrolls to (or near) the top of the chat —
|
||||
* the parent should call `loadMoreHistory(worldId)` to fetch older
|
||||
* steps. */
|
||||
onScrollTop?: () => void;
|
||||
/** When true, render the "Loading more…" indicator at the top. */
|
||||
loadingMore?: boolean;
|
||||
/** Action selected for the most recent step (used to highlight +
|
||||
* grey-out the rest). String value = the chosen action; "custom"
|
||||
* means a free-text action was used (grey out all). */
|
||||
selectedActionForLastStep?: string | "custom" | null;
|
||||
/** Called when the user clicks one of the suggested-action chips on
|
||||
* the most recent step. */
|
||||
onSuggestedClick?: (action: string) => void;
|
||||
/** Whether the chat is currently submitting (disables chip clicks). */
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
export function ChatView({ className }: ChatViewProps) {
|
||||
export function ChatView({
|
||||
className,
|
||||
onScrollTop,
|
||||
loadingMore,
|
||||
selectedActionForLastStep,
|
||||
onSuggestedClick,
|
||||
submitting,
|
||||
}: 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 submittingStore = useSessionStore((s) => s.submitting);
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const pendingPlayerAction = useSessionStore((s) => s.pendingPlayerAction);
|
||||
const currentPhaseLabel = useSessionStore((s) => s.currentPhaseLabel);
|
||||
|
||||
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 empty =
|
||||
recentSteps.length === 0 &&
|
||||
!submittingStore &&
|
||||
streamMessages.length === 0 &&
|
||||
!hasIntro &&
|
||||
!pendingPlayerAction;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
// Track the previous scroll height so we can preserve the user's
|
||||
// position when older steps are prepended.
|
||||
const prevScrollHeightRef = useRef<number | null>(null);
|
||||
// Track whether the user is near the bottom (so we auto-scroll only
|
||||
// when they are).
|
||||
const nearBottomRef = useRef(true);
|
||||
|
||||
// Auto-scroll to bottom when new content arrives — but only if the
|
||||
// user is already near the bottom (so we don't yank them away from
|
||||
// older messages they're reading).
|
||||
useEffect(() => {
|
||||
if (!nearBottomRef.current) return;
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [recentSteps, streamMessages, streamingText, submitting, showIntro]);
|
||||
}, [recentSteps, streamMessages, streamingText, submittingStore, pendingPlayerAction, introScene]);
|
||||
|
||||
// When older steps are prepended, preserve the user's scroll position
|
||||
// (keep the previously-visible content in view).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || prevScrollHeightRef.current == null) return;
|
||||
const newHeight = el.scrollHeight;
|
||||
const diff = newHeight - prevScrollHeightRef.current;
|
||||
if (diff > 0) {
|
||||
el.scrollTop = el.scrollTop + diff;
|
||||
}
|
||||
prevScrollHeightRef.current = null;
|
||||
}, [recentSteps]);
|
||||
|
||||
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
nearBottomRef.current = distanceFromBottom < 80;
|
||||
// Trigger pagination when the user is within 60px of the top.
|
||||
if (el.scrollTop < 60 && onScrollTop) {
|
||||
// Save the current scroll height so we can restore position after
|
||||
// the new steps are prepended.
|
||||
prevScrollHeightRef.current = el.scrollHeight;
|
||||
onScrollTop();
|
||||
}
|
||||
};
|
||||
|
||||
const lastStepId = recentSteps.length > 0 ? recentSteps[recentSteps.length - 1].id : null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}
|
||||
>
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-xs text-fg-muted">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-fg-muted border-t-transparent" />
|
||||
{t("play.loading_more")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{empty && (
|
||||
<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>
|
||||
@@ -49,7 +125,12 @@ export function ChatView({ className }: ChatViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showIntro && introScene && (
|
||||
{/* Intro scene is ALWAYS shown as the first chat message (when
|
||||
present). Previously this only rendered when there were no
|
||||
recent_steps, which meant it disappeared after the first
|
||||
iteration — that broke the user's mental model of the chat
|
||||
history. */}
|
||||
{hasIntro && 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")}
|
||||
@@ -59,24 +140,59 @@ export function ChatView({ className }: ChatViewProps) {
|
||||
)}
|
||||
|
||||
{recentSteps.map((step) => (
|
||||
<StepBlock key={step.id} step={step} />
|
||||
<StepBlock
|
||||
key={step.id}
|
||||
step={step}
|
||||
isLastStep={step.id === lastStepId}
|
||||
selectedAction={step.id === lastStepId ? selectedActionForLastStep ?? null : null}
|
||||
onSuggestedClick={onSuggestedClick}
|
||||
submitting={submitting ?? submittingStore}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pending player action — shown immediately when the user clicks
|
||||
a suggested action or sends custom text, before the GM
|
||||
responds. */}
|
||||
{pendingPlayerAction && (
|
||||
<div className="ml-auto max-w-[85%] rounded-lg bg-accent/15 px-3 py-2 text-right">
|
||||
<p className="text-xs font-semibold text-accent mb-0.5">
|
||||
{t("play.you")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-fg">{pendingPlayerAction}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamMessages.length > 0 && (
|
||||
<div className="space-y-2 border-l-2 border-accent/40 pl-3">
|
||||
{streamMessages
|
||||
.filter((m) => m.kind === "tool_call" || m.kind === "phase_start" || m.kind === "warning" || m.kind === "error" || m.kind === "trigger_fired" || m.kind === "summary_generated")
|
||||
.filter((m) =>
|
||||
m.kind === "tool_call" ||
|
||||
m.kind === "status" ||
|
||||
m.kind === "phase_start" ||
|
||||
m.kind === "warning" ||
|
||||
m.kind === "error" ||
|
||||
m.kind === "trigger_fired" ||
|
||||
m.kind === "summary_generated",
|
||||
)
|
||||
.map((m) => {
|
||||
if (m.kind === "tool_call" && m.tool) {
|
||||
return (
|
||||
<ToolCallBubble
|
||||
key={m.id}
|
||||
tool={m.tool}
|
||||
args={m.toolArgs}
|
||||
result={m.toolResult}
|
||||
success={m.toolSuccess ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (m.kind === "status" && m.message) {
|
||||
return (
|
||||
<p key={m.id} className="text-xs text-fg-muted italic">
|
||||
✦ {m.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (m.kind === "phase_start") {
|
||||
return (
|
||||
<p key={m.id} className="text-xs text-fg-muted">
|
||||
@@ -123,8 +239,12 @@ export function ChatView({ className }: ChatViewProps) {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{submitting && !streamingText && (
|
||||
<p className="text-xs text-fg-muted italic">{t("play.streaming")}</p>
|
||||
{/* No streaming text yet but we're mid-stream — show a
|
||||
friendly phase label so the user knows what's happening. */}
|
||||
{submittingStore && !streamingText && (
|
||||
<p className="text-xs text-fg-muted italic">
|
||||
{currentPhaseLabel ? phaseLabelToText(currentPhaseLabel, t) : t("play.streaming")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -138,8 +258,33 @@ export function ChatView({ className }: ChatViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepBlock({ step }: { step: Step }) {
|
||||
/** Map a backend phase label to a localized "X…" status string. */
|
||||
function phaseLabelToText(label: string, t: (k: string) => string): string {
|
||||
switch (label) {
|
||||
case "planning":
|
||||
return t("play.phase_planning");
|
||||
case "writing":
|
||||
return t("play.phase_writing");
|
||||
case "sending":
|
||||
return t("play.phase_sending");
|
||||
default:
|
||||
return `${label}…`;
|
||||
}
|
||||
}
|
||||
|
||||
interface StepBlockProps {
|
||||
step: Step;
|
||||
isLastStep: boolean;
|
||||
selectedAction: string | "custom" | null;
|
||||
onSuggestedClick?: (action: string) => void;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
function StepBlock({ step, isLastStep, selectedAction, onSuggestedClick, submitting }: StepBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const suggestions = step.suggested_actions || [];
|
||||
const interactive = isLastStep && selectedAction == null && !submitting && onSuggestedClick;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{step.player_action && (
|
||||
@@ -155,13 +300,41 @@ function StepBlock({ step }: { step: Step }) {
|
||||
{t("play.game_master")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-fg">{step.scene_text}</p>
|
||||
{step.suggested_actions && step.suggested_actions.length > 0 && (
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{step.suggested_actions.map((a, i) => (
|
||||
<span key={i} className="badge bg-bg-soft text-fg-muted">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
{suggestions.map((a, i) => {
|
||||
const isSelected = selectedAction === a;
|
||||
const isGreyed = selectedAction != null && !isSelected;
|
||||
// Older steps: render as static badges. Last step: render
|
||||
// as interactive buttons (until one is selected).
|
||||
if (interactive) {
|
||||
return (
|
||||
<button
|
||||
key={`${a}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onSuggestedClick?.(a)}
|
||||
className="badge bg-bg-soft text-fg hover:bg-accent/15 hover:text-accent transition-colors"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={`${a}-${i}`}
|
||||
className={cn(
|
||||
"badge",
|
||||
isSelected
|
||||
? "bg-accent/20 text-accent ring-1 ring-accent/40"
|
||||
: isGreyed
|
||||
? "bg-bg-soft text-fg-dim opacity-50"
|
||||
: "bg-bg-soft text-fg-muted",
|
||||
)}
|
||||
>
|
||||
{a}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,74 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type SseStatusKind = "idle" | "connecting" | "open" | "error" | "closed";
|
||||
|
||||
export interface SseStatusProps {
|
||||
status: "idle" | "connecting" | "open" | "error" | "closed";
|
||||
status: SseStatusKind;
|
||||
/** Friendly current phase label (e.g. "Planning…"). When provided and
|
||||
* status is "open", the indicator shows the phase label instead of a
|
||||
* generic "Connected" string. */
|
||||
phaseLabel?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLES = {
|
||||
idle: { dot: "bg-fg-dim", label: "sse.disconnected" },
|
||||
connecting: { dot: "bg-warn animate-pulse", label: "sse.connecting" },
|
||||
open: { dot: "bg-ok animate-pulse", label: "sse.connected" },
|
||||
error: { dot: "bg-err", label: "sse.error" },
|
||||
closed: { dot: "bg-fg-dim", label: "sse.disconnected" },
|
||||
} as const;
|
||||
/**
|
||||
* SSE connection status indicator.
|
||||
*
|
||||
* Behaviour (per FE-6 spec):
|
||||
* - The "Disconnected" / idle dot is hidden entirely — it's not useful
|
||||
* to the user. We only render this component when the stream is in a
|
||||
* non-idle state (connecting, open, error) OR when a phase label is
|
||||
* being shown.
|
||||
* - The error state ("Connection error") is shown briefly while the SSE
|
||||
* client is retrying — once it reconnects, the indicator flips back to
|
||||
* the phase label / "Connected".
|
||||
* - In production mode the backend filters `tool_call` / `llm_call_*`
|
||||
* events, so the user only sees friendly phase labels: "Reading…",
|
||||
* "Planning…", "Writing…", "Processing…".
|
||||
*/
|
||||
export function SseStatus({ status, phaseLabel, className }: SseStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Idle / closed → don't render at all (no useful info for the user).
|
||||
if (status === "idle" || status === "closed") {
|
||||
// Still render a phase label if we have one (e.g. while the
|
||||
// streaming area is mid-stream but the SSE channel briefly closed).
|
||||
if (!phaseLabel) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
||||
role="status"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-accent animate-pulse" />
|
||||
<span>{phaseLabel}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const dotClass =
|
||||
status === "open"
|
||||
? "bg-ok animate-pulse"
|
||||
: status === "connecting"
|
||||
? "bg-warn animate-pulse"
|
||||
: "bg-err";
|
||||
|
||||
const label =
|
||||
status === "error"
|
||||
? t("sse.reconnecting")
|
||||
: phaseLabel
|
||||
? phaseLabel
|
||||
: status === "open"
|
||||
? t("sse.connected")
|
||||
: t("sse.connecting");
|
||||
|
||||
export function SseStatus({ status, className }: SseStatusProps) {
|
||||
const s = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
||||
role="status"
|
||||
>
|
||||
<span className={cn("h-2 w-2 rounded-full", s.dot)} />
|
||||
{/* Status text is fixed for now; could be i18n'd if needed */}
|
||||
<span>
|
||||
{status === "idle" && "—"}
|
||||
{status === "connecting" && "Connecting…"}
|
||||
{status === "open" && "Connected"}
|
||||
{status === "error" && "Connection error"}
|
||||
{status === "closed" && "Disconnected"}
|
||||
</span>
|
||||
<span className={cn("h-2 w-2 rounded-full", dotClass)} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,27 +4,37 @@ import { cn } from "@/lib/cn";
|
||||
|
||||
export interface ToolCallBubbleProps {
|
||||
tool: string;
|
||||
/** Raw arguments object (debug mode only). */
|
||||
args?: unknown;
|
||||
result: unknown;
|
||||
success: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallBubble({ tool, result, success, className }: ToolCallBubbleProps) {
|
||||
function safeStringify(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolCallBubble({ tool, args, result, success, className }: ToolCallBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const resultPreview = useMemo(() => {
|
||||
try {
|
||||
const str = typeof result === "string" ? result : JSON.stringify(result);
|
||||
if (str.length <= 200) return str;
|
||||
return str.slice(0, 200) + "…";
|
||||
} catch {
|
||||
return String(result);
|
||||
}
|
||||
}, [result]);
|
||||
const resultStr = useMemo(() => safeStringify(result), [result]);
|
||||
const argsStr = useMemo(
|
||||
() => (args == null ? "" : safeStringify(args)),
|
||||
[args],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1.5 text-xs",
|
||||
// max-w-full + overflow-hidden keep the bubble within the chat
|
||||
// container; the inner <pre> uses overflow-x-auto so long JSON
|
||||
// scrolls horizontally instead of breaking the layout.
|
||||
"max-w-full overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
|
||||
success
|
||||
? "border-ok/30 bg-ok/5 text-fg"
|
||||
: "border-err/30 bg-err/5 text-fg",
|
||||
@@ -33,13 +43,32 @@ export function ToolCallBubble({ tool, result, success, className }: ToolCallBub
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", success ? "bg-ok" : "bg-err")} />
|
||||
<span className="font-mono font-medium text-fg-muted">
|
||||
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", success ? "bg-ok" : "bg-err")} />
|
||||
<span className="shrink-0 font-mono font-medium text-fg-muted">
|
||||
{t("builder.tool_call")}:
|
||||
</span>
|
||||
<span className="font-mono text-fg">{tool}</span>
|
||||
<span className="truncate font-mono text-fg">{tool}</span>
|
||||
</div>
|
||||
<p className="mt-1 break-words font-mono text-[10px] text-fg-dim">{resultPreview}</p>
|
||||
{/* Arguments (debug only — production backend filters tool_call
|
||||
events entirely, so this only renders in debug mode). */}
|
||||
{argsStr && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-[10px] font-mono text-fg-dim">
|
||||
args
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-bg-soft/60 p-1.5 font-mono text-[10px] text-fg-dim break-all whitespace-pre-wrap">
|
||||
{argsStr}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-[10px] font-mono text-fg-dim">
|
||||
result
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-bg-soft/60 p-1.5 font-mono text-[10px] text-fg-dim break-all whitespace-pre-wrap">
|
||||
{resultStr}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
...s,
|
||||
phase: "error",
|
||||
sseStatus: "error",
|
||||
logs: [...s.logs, { text: `[error] ${d?.message || "Stream error"}`, kind: "error" as const }],
|
||||
logs: [...s.logs, { text: `[error] ${d?.message || t("builder.build_failed")}`, kind: "error" as const }],
|
||||
}));
|
||||
pushToast("error", d?.message || t("builder.build_failed"));
|
||||
controllerRef.current?.close();
|
||||
@@ -197,14 +197,27 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
const d = event.data as { step?: number | string; message?: string };
|
||||
const stepVal = d.step;
|
||||
const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_");
|
||||
// Localize the stage label and message via the i18n keys
|
||||
// `builder.step_label.<stage>` and `builder.step_msg.<stage>`.
|
||||
// Falls back to the raw values if no translation exists.
|
||||
const stageKey = typeof stepVal === "string" || typeof stepVal === "number"
|
||||
? `builder.step_label.${stepVal}`
|
||||
: "builder.step_label.unknown";
|
||||
const msgKey = typeof stepVal === "string" || typeof stepVal === "number"
|
||||
? `builder.step_msg.${stepVal}`
|
||||
: "builder.step_label.unknown";
|
||||
const stageLabel = t(stageKey);
|
||||
const stageText = stageLabel === stageKey ? String(stepVal ?? "?") : stageLabel;
|
||||
const msgText = t(msgKey);
|
||||
const finalMsg = msgText === msgKey ? (d.message || "") : msgText;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
step: stepVal,
|
||||
message: d.message,
|
||||
message: finalMsg,
|
||||
logs: [
|
||||
...s.logs,
|
||||
{
|
||||
text: `[${stepVal ?? "?"}] ${d.message || ""}`,
|
||||
text: `[${stageText}] ${finalMsg}`,
|
||||
kind: isSkip ? ("skip" as const) : ("info" as const),
|
||||
},
|
||||
],
|
||||
@@ -250,11 +263,17 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
break;
|
||||
case "intro_scene_chunk": {
|
||||
const d = event.data as { text: string };
|
||||
// Store the streamed intro scene text so we can persist it on
|
||||
// the world via the redirect — but do NOT render it as large
|
||||
// text on the builder page. The user will see it on the edit
|
||||
// page after the redirect.
|
||||
setState((s) => ({ ...s, introScene: s.introScene + d.text }));
|
||||
break;
|
||||
}
|
||||
case "intro_scene_complete": {
|
||||
const d = event.data as { text: string };
|
||||
// Store but do not prominently display — the user will see it
|
||||
// on the edit page.
|
||||
setState((s) => ({ ...s, introScene: d.text }));
|
||||
break;
|
||||
}
|
||||
@@ -348,12 +367,15 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
|
||||
// On done, navigate to the EDIT page (not play) — the user should
|
||||
// review the world, generate the intro scene, then click Play when ready.
|
||||
// We use a very short delay (just long enough for the success toast to
|
||||
// register) so the user isn't left looking at a "done" state that
|
||||
// renders the intro scene as large text.
|
||||
useEffect(() => {
|
||||
if (state.phase === "done" && createdWorldIdRef.current) {
|
||||
const id = createdWorldIdRef.current;
|
||||
const timer = window.setTimeout(() => {
|
||||
navigate(`/worlds/${id}/edit`);
|
||||
}, 800);
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [state.phase, navigate]);
|
||||
@@ -531,14 +553,12 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
message={state.message}
|
||||
/>
|
||||
)}
|
||||
{state.introScene && (
|
||||
<div>
|
||||
<p className="label">{t("builder.intro_scene")}</p>
|
||||
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
|
||||
{state.introScene}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Note: the intro scene text is intentionally NOT rendered
|
||||
here. Storing it in `state.introScene` keeps it available
|
||||
for debugging, but displaying it briefly before the
|
||||
redirect to the edit page caused a jarring flash of
|
||||
large text. The user will see the intro scene on the
|
||||
edit page after the redirect. */}
|
||||
{state.logs.length > 0 && (
|
||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { AdminApi, WorldsApi, toErrorMessage } from "@/lib/api";
|
||||
import { displayGameTime } from "@/lib/formatTime";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { WorldListItem, WorldStatus } from "@/types";
|
||||
@@ -45,9 +46,13 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
||||
const isArchived = world.status === "archived";
|
||||
const isDraft = world.status === "draft";
|
||||
const isAdmin = !!user?.is_admin;
|
||||
// Prefer the human-readable time string ("Day 1, 08:00"); fall back to the
|
||||
// raw `current_time` value when the backend doesn't provide the human form.
|
||||
const displayedTime = world.current_time_human || world.current_time;
|
||||
// The world list endpoint does NOT include `current_time_human` —
|
||||
// compute the human-readable form client-side via the world's language.
|
||||
const displayedTime = displayGameTime(
|
||||
world.current_time,
|
||||
world.current_time_human,
|
||||
world.language,
|
||||
);
|
||||
|
||||
const handleRestore = async () => {
|
||||
setRestoring(true);
|
||||
|
||||
@@ -138,7 +138,28 @@
|
||||
"schema_generated": "World schema generated",
|
||||
"environment_generated": "Environment generated",
|
||||
"entities_generated": "Entities generated",
|
||||
"retry_disabled": "Cannot retry — no world was created yet."
|
||||
"retry_disabled": "Cannot retry — no world was created yet.",
|
||||
"step_label": {
|
||||
"generating_schema": "generating_schema",
|
||||
"skipping_schema": "skipping_schema",
|
||||
"generating_environment": "generating_environment",
|
||||
"skipping_environment": "skipping_environment",
|
||||
"generating_entities": "generating_entities",
|
||||
"skipping_entities": "skipping_entities",
|
||||
"generating_intro": "generating_intro",
|
||||
"skipping_intro": "skipping_intro",
|
||||
"unknown": "?"
|
||||
},
|
||||
"step_msg": {
|
||||
"generating_schema": "Generating world schema…",
|
||||
"skipping_schema": "Schemas already exist, skipping…",
|
||||
"generating_environment": "Generating environment…",
|
||||
"skipping_environment": "Environment already set, skipping…",
|
||||
"generating_entities": "Generating entities…",
|
||||
"skipping_entities": "Entities already exist, skipping…",
|
||||
"generating_intro": "Generating intro scene…",
|
||||
"skipping_intro": "Intro scene already exists, skipping…"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"title": "World Editor",
|
||||
@@ -223,7 +244,12 @@
|
||||
"trigger_fired": "Trigger fired",
|
||||
"summary_generated": "Summary generated",
|
||||
"load_failed": "Failed to load session.",
|
||||
"no_actions_yet": "Take an action to begin."
|
||||
"no_actions_yet": "Take an action to begin.",
|
||||
"loading_more": "Loading more…",
|
||||
"phase_reading": "Reading…",
|
||||
"phase_planning": "Planning…",
|
||||
"phase_writing": "Writing…",
|
||||
"phase_sending": "Processing…"
|
||||
},
|
||||
"sse": {
|
||||
"connecting": "Connecting…",
|
||||
@@ -333,6 +359,17 @@
|
||||
"text_replacements_from": "From",
|
||||
"text_replacements_to": "To",
|
||||
"text_replacements_add": "Add rule",
|
||||
"name_banks_title": "Name Banks",
|
||||
"name_banks_help": "Manage the character name banks used by the random-name button in the world builder. Names are stored per language.",
|
||||
"name_banks_en": "English Names",
|
||||
"name_banks_ru": "Russian Names",
|
||||
"name_banks_empty": "No names yet. Add one below.",
|
||||
"name_banks_add": "Add",
|
||||
"name_banks_add_placeholder": "New name…",
|
||||
"name_banks_already_exists": "That name is already in the bank.",
|
||||
"name_banks_load_failed": "Failed to load name bank.",
|
||||
"name_banks_add_failed": "Failed to add name.",
|
||||
"name_banks_remove_failed": "Failed to remove name.",
|
||||
"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).",
|
||||
|
||||
@@ -138,7 +138,28 @@
|
||||
"schema_generated": "Схема мира сгенерирована",
|
||||
"environment_generated": "Окружение сгенерировано",
|
||||
"entities_generated": "Сущности сгенерированы",
|
||||
"retry_disabled": "Нельзя повторить — мир ещё не создан."
|
||||
"retry_disabled": "Нельзя повторить — мир ещё не создан.",
|
||||
"step_label": {
|
||||
"generating_schema": "generating_schema",
|
||||
"skipping_schema": "skipping_schema",
|
||||
"generating_environment": "generating_environment",
|
||||
"skipping_environment": "skipping_environment",
|
||||
"generating_entities": "generating_entities",
|
||||
"skipping_entities": "skipping_entities",
|
||||
"generating_intro": "generating_intro",
|
||||
"skipping_intro": "skipping_intro",
|
||||
"unknown": "?"
|
||||
},
|
||||
"step_msg": {
|
||||
"generating_schema": "Генерация схемы мира…",
|
||||
"skipping_schema": "Схема уже существует, пропуск…",
|
||||
"generating_environment": "Генерация окружения…",
|
||||
"skipping_environment": "Окружение уже задано, пропуск…",
|
||||
"generating_entities": "Генерация сущностей…",
|
||||
"skipping_entities": "Сущности уже существуют, пропуск…",
|
||||
"generating_intro": "Генерация вступительной сцены…",
|
||||
"skipping_intro": "Вступительная сцена уже существует, пропуск…"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"title": "Редактор мира",
|
||||
@@ -223,7 +244,12 @@
|
||||
"trigger_fired": "Сработал триггер",
|
||||
"summary_generated": "Сгенерирована сводка",
|
||||
"load_failed": "Не удалось загрузить сессию.",
|
||||
"no_actions_yet": "Сделайте действие, чтобы начать."
|
||||
"no_actions_yet": "Сделайте действие, чтобы начать.",
|
||||
"loading_more": "Загрузка ещё…",
|
||||
"phase_reading": "Чтение…",
|
||||
"phase_planning": "Планирование…",
|
||||
"phase_writing": "Написание…",
|
||||
"phase_sending": "Обработка…"
|
||||
},
|
||||
"sse": {
|
||||
"connecting": "Подключение…",
|
||||
@@ -333,6 +359,17 @@
|
||||
"text_replacements_from": "С",
|
||||
"text_replacements_to": "На",
|
||||
"text_replacements_add": "Добавить правило",
|
||||
"name_banks_title": "Банки имён",
|
||||
"name_banks_help": "Управление банками имён персонажей для кнопки случайного имени в мастере создания мира. Имена хранятся по языкам.",
|
||||
"name_banks_en": "Английские имена",
|
||||
"name_banks_ru": "Русские имена",
|
||||
"name_banks_empty": "Имён пока нет. Добавьте ниже.",
|
||||
"name_banks_add": "Добавить",
|
||||
"name_banks_add_placeholder": "Новое имя…",
|
||||
"name_banks_already_exists": "Такое имя уже есть в банке.",
|
||||
"name_banks_load_failed": "Не удалось загрузить банк имён.",
|
||||
"name_banks_add_failed": "Не удалось добавить имя.",
|
||||
"name_banks_remove_failed": "Не удалось удалить имя.",
|
||||
"setting_desc": {
|
||||
"llm.api_url": "URL endpoint chat completions провайдера LLM.",
|
||||
"llm.api_key": "API-ключ провайдера LLM (хранится строкой).",
|
||||
|
||||
@@ -10,12 +10,14 @@ import type {
|
||||
EmbeddingsProbeResult,
|
||||
EmbeddingsTestResult,
|
||||
HealthResponse,
|
||||
HistoryResponse,
|
||||
IterateResponse,
|
||||
LlmLog,
|
||||
LlmLogDetail,
|
||||
LlmTestResult,
|
||||
LlmToolsTestResult,
|
||||
LoginPayload,
|
||||
NameBankResponse,
|
||||
Paginated,
|
||||
PresetListItem,
|
||||
PublicSettings,
|
||||
@@ -332,6 +334,18 @@ export const SessionsApi = {
|
||||
rollback: (worldId: string) =>
|
||||
request<void>(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }),
|
||||
|
||||
/**
|
||||
* Fetch older chat history with pagination (for scroll-up loading).
|
||||
* Endpoint: GET /api/sessions/worlds/{id}/history?before={seq}&limit=20.
|
||||
* Returns `{steps, has_more, oldest_sequence}`. The `steps` array is
|
||||
* ordered oldest-first (same as `recent_steps` in the state response).
|
||||
*/
|
||||
getHistory: (worldId: string, before?: number, limit = 20) =>
|
||||
request<HistoryResponse>(
|
||||
`/sessions/worlds/${worldId}/history`,
|
||||
{ query: { before, limit } },
|
||||
),
|
||||
|
||||
// ---- World editor: accept / reject proposed changes & answer clarifications ----
|
||||
/** Accept proposed changes from the world_editor stream. */
|
||||
applyChanges: (worldId: string) =>
|
||||
@@ -473,6 +487,29 @@ export const AdminApi = {
|
||||
},
|
||||
hardDeleteWorld: (id: string) =>
|
||||
request<{ ok: boolean; deleted: string }>(`/admin/worlds/${id}`, { method: "DELETE" }),
|
||||
|
||||
// ---- Name banks ----
|
||||
/** GET /api/admin/names/{language} → {language, names, count}. */
|
||||
getNameBank: (language: string) =>
|
||||
request<NameBankResponse>(`/admin/names/${encodeURIComponent(language)}`),
|
||||
/** PUT /api/admin/names/{language} body {names: [...]} → updates entire bank. */
|
||||
updateNameBank: (language: string, names: string[]) =>
|
||||
request<NameBankResponse>(
|
||||
`/admin/names/${encodeURIComponent(language)}`,
|
||||
{ method: "PUT", body: { names } },
|
||||
),
|
||||
/** POST /api/admin/names/{language}/add body {name} → adds one name. */
|
||||
addName: (language: string, name: string) =>
|
||||
request<NameBankResponse>(
|
||||
`/admin/names/${encodeURIComponent(language)}/add`,
|
||||
{ method: "POST", body: { name } },
|
||||
),
|
||||
/** DELETE /api/admin/names/{language}/{name} → removes one name. */
|
||||
removeName: (language: string, name: string) =>
|
||||
request<NameBankResponse>(
|
||||
`/admin/names/${encodeURIComponent(language)}/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
};
|
||||
|
||||
export const MiscApi = {
|
||||
|
||||
55
frontend/src/lib/formatTime.ts
Normal file
55
frontend/src/lib/formatTime.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Format a game time string as a human-readable localized string. Mirrors
|
||||
* the backend `app.core.time_utils.format_time_human` so the frontend can
|
||||
* render the same string for worlds loaded via endpoints that do NOT
|
||||
* include a pre-computed `current_time_human` (e.g. GET /api/worlds/{id}).
|
||||
*
|
||||
* Supported input formats (matches the backend `GameTime.parse` regex):
|
||||
* - "day_1_hour_8" → "Day 1, 08:00" / "День 1, 08:00"
|
||||
* - "day_3_hour_14_min_30" → "Day 3, 14:30" / "День 3, 14:30"
|
||||
* - "year_2_day_5_hour_12" → "Year 2, Day 5, 12:00" / "Год 2, День 5, 12:00"
|
||||
* - "year_2_day_5_hour_12_min_0" → "Year 2, Day 5, 12:00" / "Год 2, День 5, 12:00"
|
||||
*
|
||||
* If the input doesn't match the regex, the original string is returned
|
||||
* unchanged (same behaviour as the backend).
|
||||
*/
|
||||
const TIME_RE = /^(?:year_(\d+)_)?day_(\d+)_hour_(\d+)(?:_min_(\d+))?$/;
|
||||
|
||||
export function formatGameTime(timeStr: string | null | undefined, language: string): string {
|
||||
if (!timeStr) return "";
|
||||
const m = TIME_RE.exec(timeStr.trim());
|
||||
if (!m) return timeStr;
|
||||
const year = m[1] ? parseInt(m[1], 10) : 1;
|
||||
const day = parseInt(m[2], 10);
|
||||
const hour = parseInt(m[3], 10);
|
||||
const minute = m[4] ? parseInt(m[4], 10) : 0;
|
||||
|
||||
const isRu = language === "ru";
|
||||
const parts: string[] = [];
|
||||
if (year !== 1) {
|
||||
parts.push(isRu ? `Год ${year}` : `Year ${year}`);
|
||||
}
|
||||
parts.push(isRu ? `День ${day}` : `Day ${day}`);
|
||||
if (minute) {
|
||||
parts.push(`${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`);
|
||||
} else {
|
||||
parts.push(`${String(hour).padStart(2, "0")}:00`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best display string for a game time value. Prefers a
|
||||
* pre-computed `current_time_human` (provided by the session-state
|
||||
* endpoint); falls back to computing it client-side via `formatGameTime`
|
||||
* using the world's language.
|
||||
*/
|
||||
export function displayGameTime(
|
||||
timeStr: string | null | undefined,
|
||||
humanStr: string | null | undefined,
|
||||
language: string | null | undefined,
|
||||
): string {
|
||||
if (humanStr) return humanStr;
|
||||
if (!timeStr) return "";
|
||||
return formatGameTime(timeStr, language || "en");
|
||||
}
|
||||
@@ -127,6 +127,7 @@ export const KNOWN_EVENTS = [
|
||||
"phase_start",
|
||||
"phase_end",
|
||||
"tool_call",
|
||||
"status",
|
||||
"llm_call_start",
|
||||
"llm_call_end",
|
||||
"scene_chunk",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||
import { toErrorMessage } from "@/lib/api";
|
||||
import { displayGameTime } from "@/lib/formatTime";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
@@ -29,14 +30,27 @@ export function PlayPage() {
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const submitting = useSessionStore((s) => s.submitting);
|
||||
const sseStatus = useSessionStore((s) => s.sseStatus);
|
||||
const currentPhaseLabel = useSessionStore((s) => s.currentPhaseLabel);
|
||||
const fetchState = useSessionStore((s) => s.fetchState);
|
||||
const sendAction = useSessionStore((s) => s.sendAction);
|
||||
const retry = useSessionStore((s) => s.retry);
|
||||
const rollback = useSessionStore((s) => s.rollback);
|
||||
const reset = useSessionStore((s) => s.reset);
|
||||
const loadMoreHistory = useSessionStore((s) => s.loadMoreHistory);
|
||||
const hasMoreHistory = useSessionStore((s) => s.hasMoreHistory);
|
||||
const loadingMore = useSessionStore((s) => s.loadingMore);
|
||||
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
/**
|
||||
* Tracks the user's selection for the most recent step's suggested
|
||||
* actions:
|
||||
* - string → the chosen action text (highlight that one, grey the rest)
|
||||
* - "custom" → a free-text action was used (grey out all)
|
||||
* - null → no selection yet (suggestions are interactive)
|
||||
* Cleared when a new step arrives (i.e. when the GM responds).
|
||||
*/
|
||||
const [selectedAction, setSelectedAction] = useState<string | "custom" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -56,6 +70,16 @@ export function PlayPage() {
|
||||
}
|
||||
}, [id, world, redirected, navigate, pushToast, t]);
|
||||
|
||||
// When a new step arrives (recentSteps count increases), clear the
|
||||
// selected-action state so the new step's suggestions are interactive.
|
||||
const prevStepsLenRef = useState<{ len: number }>(() => ({ len: 0 }))[0];
|
||||
useEffect(() => {
|
||||
if (recentSteps.length > prevStepsLenRef.len) {
|
||||
setSelectedAction(null);
|
||||
}
|
||||
prevStepsLenRef.len = recentSteps.length;
|
||||
}, [recentSteps.length, prevStepsLenRef]);
|
||||
|
||||
// Page title: "{world.name} | {headerTitle}"
|
||||
useEffect(() => {
|
||||
if (world) {
|
||||
@@ -111,18 +135,49 @@ export function PlayPage() {
|
||||
),
|
||||
);
|
||||
|
||||
// Compute the localized, human-readable game time. Prefers the
|
||||
// backend-provided `current_time_human` (only available on the session
|
||||
// state endpoint); falls back to computing it client-side.
|
||||
const displayedTime = displayGameTime(
|
||||
world.current_time,
|
||||
world.current_time_human,
|
||||
world.language,
|
||||
);
|
||||
|
||||
// Friendly phase label for the status indicator. When the stream is
|
||||
// open, prefer the current phase label from the store; otherwise show
|
||||
// a "Reading…" hint while we wait for the first phase_start event.
|
||||
const phaseLabel = currentPhaseLabel
|
||||
? phaseLabelToText(currentPhaseLabel, t)
|
||||
: submitting
|
||||
? t("play.phase_reading")
|
||||
: null;
|
||||
|
||||
const handleSend = (action: string) => {
|
||||
// Custom text → grey out all suggested actions for the last step.
|
||||
setSelectedAction("custom");
|
||||
void sendAction(id, action, "manual").catch((err) => {
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
setSelectedAction(null);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSuggested = (action: string) => {
|
||||
// Highlight the clicked action, grey out the rest. The chat view
|
||||
// also shows the action immediately as a pending player bubble
|
||||
// (driven by the session store's pendingPlayerAction).
|
||||
setSelectedAction(action);
|
||||
void sendAction(id, action, "suggested").catch((err) => {
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
setSelectedAction(null);
|
||||
});
|
||||
};
|
||||
|
||||
const handleScrollTop = () => {
|
||||
if (!hasMoreHistory || loadingMore) return;
|
||||
void loadMoreHistory(id);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
void retry(id).catch((err) => {
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
@@ -139,6 +194,12 @@ export function PlayPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// The nextActions from the store are the suggested actions for the
|
||||
// most recent step. We pass them to the ChatView via the step block
|
||||
// (the last step already carries its own suggested_actions); the
|
||||
// ActionInput no longer renders them.
|
||||
void nextActions;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
|
||||
{/* Environment panel */}
|
||||
@@ -211,24 +272,23 @@ export function PlayPage() {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{(() => {
|
||||
// Prefer the human-readable form; fall back to the raw
|
||||
// current_time string when the backend doesn't supply it
|
||||
// (e.g. older session state cached locally).
|
||||
const time = world.current_time_human || world.current_time;
|
||||
return time ? `${t("worlds.current_time")}: ${time}` : "";
|
||||
})()}
|
||||
{displayedTime ? `${t("worlds.current_time")}: ${displayedTime}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<SseStatus status={sseStatus} />
|
||||
<SseStatus status={sseStatus} phaseLabel={phaseLabel} />
|
||||
</header>
|
||||
<ChatView className="flex-1 min-h-0" />
|
||||
<ChatView
|
||||
className="flex-1 min-h-0"
|
||||
onScrollTop={handleScrollTop}
|
||||
loadingMore={loadingMore}
|
||||
selectedActionForLastStep={selectedAction}
|
||||
onSuggestedClick={handleSuggested}
|
||||
submitting={submitting}
|
||||
/>
|
||||
<footer className="border-t border-fg-dim/20 p-3">
|
||||
<ActionInput
|
||||
onSubmit={handleSend}
|
||||
onSuggestedClick={handleSuggested}
|
||||
submitting={submitting}
|
||||
suggestedActions={nextActions}
|
||||
placeholder={t("play.action_placeholder")}
|
||||
/>
|
||||
</footer>
|
||||
@@ -256,6 +316,20 @@ export function PlayPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Map a backend phase label to a localized "X…" status string. */
|
||||
function phaseLabelToText(label: string, t: (k: string) => string): string {
|
||||
switch (label) {
|
||||
case "planning":
|
||||
return t("play.phase_planning");
|
||||
case "writing":
|
||||
return t("play.phase_writing");
|
||||
case "sending":
|
||||
return t("play.phase_sending");
|
||||
default:
|
||||
return `${label}…`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the plot_rails field — the backend may return either an
|
||||
* old-style array of PlotRail objects or a new-style container object
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WorldsApi } from "@/lib/api";
|
||||
import { displayGameTime } from "@/lib/formatTime";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||
@@ -86,8 +87,14 @@ export function WorldEditPage() {
|
||||
// world to status "ready" with intro_scene set, and IntroSceneGenerator
|
||||
// then returns null on its next render.
|
||||
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
|
||||
// Prefer the human-readable time string; fall back to the raw value.
|
||||
const displayedTime = world.current_time_human || world.current_time;
|
||||
// The world detail endpoint (GET /api/worlds/{id}) does NOT include
|
||||
// `current_time_human` — only the session state endpoint does. Compute
|
||||
// the human-readable form client-side via the world's language.
|
||||
const displayedTime = displayGameTime(
|
||||
world.current_time,
|
||||
world.current_time_human,
|
||||
world.language,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
|
||||
@@ -16,6 +16,7 @@ interface StreamMessage {
|
||||
kind:
|
||||
| "scene_chunk"
|
||||
| "tool_call"
|
||||
| "status"
|
||||
| "llm_call_start"
|
||||
| "llm_call_end"
|
||||
| "phase_start"
|
||||
@@ -30,16 +31,25 @@ interface StreamMessage {
|
||||
| "suggested_actions";
|
||||
text?: string;
|
||||
tool?: string;
|
||||
toolArgs?: unknown;
|
||||
toolResult?: unknown;
|
||||
toolSuccess?: boolean;
|
||||
phase?: string;
|
||||
phase?: string | number;
|
||||
phaseName?: string;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
/** Type tag for status events ("tool" | "phase" | …). */
|
||||
statusType?: string;
|
||||
actions?: string[];
|
||||
}
|
||||
|
||||
interface PendingStep {
|
||||
stepId: string;
|
||||
sequenceNumber: number | null;
|
||||
playerAction: string | null;
|
||||
}
|
||||
|
||||
interface SessionStateStore {
|
||||
world: World | null;
|
||||
environment: Environment | null;
|
||||
@@ -48,17 +58,46 @@ interface SessionStateStore {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// History pagination
|
||||
/** True when there are (likely) older steps that can be loaded by
|
||||
* scrolling up. Set optimistically on fetchState (when state returns
|
||||
* the maximum of 10 steps), and reconciled with the real value when
|
||||
* loadMoreHistory is called. */
|
||||
hasMoreHistory: boolean;
|
||||
loadingMore: boolean;
|
||||
/** Sequence number of the oldest step currently in `recentSteps`.
|
||||
* Used as the `before` cursor for the next pagination call. */
|
||||
oldestSequence: number | null;
|
||||
|
||||
// Streaming state
|
||||
sseStatus: SseStatus;
|
||||
streamingText: string;
|
||||
streamingStepId: string | null;
|
||||
streamMessages: StreamMessage[];
|
||||
submitting: boolean;
|
||||
/** Player action that has been submitted but not yet reflected in
|
||||
* `recentSteps`. Rendered as a player bubble above the streaming area
|
||||
* so the user gets immediate feedback. Cleared when the new step is
|
||||
* appended (on `done`). */
|
||||
pendingPlayerAction: string | null;
|
||||
/** Set on `iteration_complete`. Carries the step_id + sequence_number
|
||||
* of the step the backend just persisted, so we can append a fully
|
||||
* formed Step object locally on `done` without refetching. */
|
||||
pendingStep: PendingStep | null;
|
||||
/** Friendly current phase name for the status indicator (e.g.
|
||||
* "Planning…"). Cleared when streaming ends. */
|
||||
currentPhaseLabel: string | null;
|
||||
/** Set to true once any `tool_call` event arrives — implies the
|
||||
* backend is running in debug mode. */
|
||||
debugMode: boolean;
|
||||
|
||||
// SSE controllers
|
||||
_controller: SseController | null;
|
||||
/** Saved worldId for the active stream. */
|
||||
_streamWorldId: string | null;
|
||||
|
||||
fetchState: (worldId: string) => Promise<void>;
|
||||
loadMoreHistory: (worldId: string) => Promise<void>;
|
||||
sendAction: (worldId: string, action: string, actionSource: "manual" | "suggested") => Promise<void>;
|
||||
retry: (worldId: string) => Promise<void>;
|
||||
rollback: (worldId: string) => Promise<void>;
|
||||
@@ -72,6 +111,27 @@ function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
/** Build a localized phase label from a phase_start event payload. */
|
||||
function phaseLabel(phase: string | number | undefined, name: string | undefined): string | null {
|
||||
if (name) return name;
|
||||
if (phase == null) return null;
|
||||
// Friendly fallbacks for production mode (where backend sends phase
|
||||
// numbers 1/2/3 with names planning/writing/sending).
|
||||
switch (String(phase)) {
|
||||
case "1":
|
||||
case "planning":
|
||||
return "planning";
|
||||
case "2":
|
||||
case "writing":
|
||||
return "writing";
|
||||
case "3":
|
||||
case "sending":
|
||||
return "sending";
|
||||
default:
|
||||
return String(phase);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string): void {
|
||||
// Mutable copy via set call
|
||||
const pushMessage = (m: StreamMessage) => {
|
||||
@@ -109,24 +169,43 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
|
||||
break;
|
||||
}
|
||||
case "phase_start": {
|
||||
const d = event.data as { phase: string; name: string };
|
||||
pushMessage({ id: uid(), kind: "phase_start", phase: d.phase, phaseName: d.name });
|
||||
const d = event.data as { phase: string | number; name?: string };
|
||||
const label = phaseLabel(d.phase, d.name);
|
||||
pushMessage({ id: uid(), kind: "phase_start", phase: d.phase, phaseName: label ?? undefined });
|
||||
if (label) patch({ currentPhaseLabel: label });
|
||||
break;
|
||||
}
|
||||
case "phase_end": {
|
||||
const d = event.data as { phase: string; duration_ms: number };
|
||||
const d = event.data as { phase: string | number; duration_ms: number };
|
||||
pushMessage({ id: uid(), kind: "phase_end", phase: d.phase, message: `${d.duration_ms}ms` });
|
||||
break;
|
||||
}
|
||||
case "tool_call": {
|
||||
// tool_call events only arrive in debug mode (the backend filters
|
||||
// them out entirely in production). Mark debugMode=true so the UI
|
||||
// can show the raw bubble.
|
||||
const d = event.data as { tool: string; arguments: unknown; result: unknown; is_success: boolean };
|
||||
pushMessage({
|
||||
id: uid(),
|
||||
kind: "tool_call",
|
||||
tool: d.tool,
|
||||
toolArgs: d.arguments,
|
||||
toolResult: d.result,
|
||||
toolSuccess: d.is_success,
|
||||
});
|
||||
patch({ debugMode: true });
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
// Production-mode transformed tool_call events carry a friendly
|
||||
// message string + a type tag.
|
||||
const d = event.data as { message?: string; type?: string };
|
||||
pushMessage({
|
||||
id: uid(),
|
||||
kind: "status",
|
||||
message: d?.message,
|
||||
statusType: d?.type,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "llm_call_start": {
|
||||
@@ -174,17 +253,72 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
|
||||
break;
|
||||
}
|
||||
case "iteration_complete": {
|
||||
const d = event.data as { step_id?: string; sequence_number?: number };
|
||||
// Save the new step's id + sequence_number so we can build a local
|
||||
// Step object on `done` without refetching everything.
|
||||
const cur = useSessionStore.getState();
|
||||
useSessionStore.setState({
|
||||
pendingStep: {
|
||||
stepId: d.step_id || cur.streamingStepId || cur.pendingStep?.stepId || "",
|
||||
sequenceNumber: d.sequence_number ?? cur.pendingStep?.sequenceNumber ?? null,
|
||||
playerAction: cur.pendingPlayerAction,
|
||||
},
|
||||
});
|
||||
pushMessage({ id: uid(), kind: "iteration_complete" });
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
// Refresh session state from REST
|
||||
void useSessionStore.getState().fetchState(worldId);
|
||||
// Append the newly-persisted step to recentSteps locally (built
|
||||
// from the streaming text + the pending player action), instead
|
||||
// of refetching state (which would replace the whole list and
|
||||
// discard any older steps the user already paginated in).
|
||||
const cur = useSessionStore.getState();
|
||||
const pending = cur.pendingStep;
|
||||
const streamingText = cur.streamingText;
|
||||
const nextActions = cur.nextActions;
|
||||
if (pending && (streamingText || pending.playerAction)) {
|
||||
const newStep: Step = {
|
||||
id: pending.stepId || uid(),
|
||||
sequence_number: pending.sequenceNumber ?? (cur.recentSteps.at(-1)?.sequence_number ?? 0) + 1,
|
||||
player_action: pending.playerAction,
|
||||
scene_text: streamingText,
|
||||
suggested_actions: nextActions,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const nextSteps = [...cur.recentSteps, newStep];
|
||||
useSessionStore.setState({
|
||||
recentSteps: nextSteps,
|
||||
// Update pagination cursor — the new step is now the newest.
|
||||
// The oldest step is unchanged so oldestSequence stays the same.
|
||||
});
|
||||
}
|
||||
// Refresh env/world/nextActions in the background WITHOUT
|
||||
// replacing recentSteps. We do this by calling fetchState and
|
||||
// then merging: keep our recentSteps, take everything else.
|
||||
void (async () => {
|
||||
try {
|
||||
const data: SessionState = await SessionsApi.state(worldId);
|
||||
useSessionStore.setState((s) => ({
|
||||
world: data.world,
|
||||
environment: data.environment,
|
||||
nextActions: data.next_actions,
|
||||
// Only replace recentSteps if our local list is empty (e.g.
|
||||
// we never built a pending step for some reason) — otherwise
|
||||
// preserve the user's full paginated history.
|
||||
recentSteps: s.recentSteps.length === 0 ? data.recent_steps : s.recentSteps,
|
||||
}));
|
||||
} catch {
|
||||
/* background refresh failure is non-fatal */
|
||||
}
|
||||
})();
|
||||
patch({
|
||||
sseStatus: "closed",
|
||||
submitting: false,
|
||||
streamingText: "",
|
||||
streamingStepId: null,
|
||||
pendingPlayerAction: null,
|
||||
pendingStep: null,
|
||||
currentPhaseLabel: null,
|
||||
});
|
||||
// Close the controller
|
||||
const c = useSessionStore.getState()._controller;
|
||||
@@ -208,24 +342,38 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
hasMoreHistory: false,
|
||||
loadingMore: false,
|
||||
oldestSequence: null,
|
||||
|
||||
sseStatus: "idle",
|
||||
streamingText: "",
|
||||
streamingStepId: null,
|
||||
streamMessages: [],
|
||||
submitting: false,
|
||||
pendingPlayerAction: null,
|
||||
pendingStep: null,
|
||||
currentPhaseLabel: null,
|
||||
debugMode: false,
|
||||
|
||||
_controller: null,
|
||||
_streamWorldId: null,
|
||||
|
||||
fetchState: async (worldId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data: SessionState = await SessionsApi.state(worldId);
|
||||
const steps = data.recent_steps;
|
||||
set({
|
||||
world: data.world,
|
||||
environment: data.environment,
|
||||
recentSteps: data.recent_steps,
|
||||
recentSteps: steps,
|
||||
nextActions: data.next_actions,
|
||||
loading: false,
|
||||
// State endpoint returns up to 10 steps. If we got 10, optimistically
|
||||
// assume there are older steps to paginate in.
|
||||
hasMoreHistory: steps.length >= 10,
|
||||
oldestSequence: steps.length > 0 ? steps[0].sequence_number : null,
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
@@ -235,22 +383,67 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreHistory: async (worldId) => {
|
||||
const { oldestSequence, loadingMore } = get();
|
||||
if (loadingMore) return;
|
||||
if (oldestSequence == null) return;
|
||||
set({ loadingMore: true });
|
||||
try {
|
||||
const res = await SessionsApi.getHistory(worldId, oldestSequence, 20);
|
||||
if (res.steps.length > 0) {
|
||||
set((s) => ({
|
||||
recentSteps: [...res.steps, ...s.recentSteps],
|
||||
oldestSequence: res.oldest_sequence ?? res.steps[0].sequence_number,
|
||||
hasMoreHistory: res.has_more,
|
||||
loadingMore: false,
|
||||
}));
|
||||
} else {
|
||||
set({ hasMoreHistory: false, loadingMore: false });
|
||||
}
|
||||
} catch (err) {
|
||||
set({ loadingMore: false });
|
||||
useToastStore.getState().push("error", toErrorMessage(err, "Failed to load history"));
|
||||
}
|
||||
},
|
||||
|
||||
sendAction: async (worldId, action, actionSource) => {
|
||||
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
|
||||
set({
|
||||
submitting: true,
|
||||
error: null,
|
||||
streamingText: "",
|
||||
streamMessages: [],
|
||||
// Show the player's action immediately as a pending bubble.
|
||||
pendingPlayerAction: action,
|
||||
pendingStep: null,
|
||||
currentPhaseLabel: null,
|
||||
});
|
||||
try {
|
||||
const res = await SessionsApi.iterate(worldId, action, actionSource);
|
||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||
get().subscribeIterate(worldId, res.step_id);
|
||||
} catch (err) {
|
||||
const msg = toErrorMessage(err, "Failed to send action");
|
||||
set({ submitting: false, error: msg });
|
||||
set({
|
||||
submitting: false,
|
||||
error: msg,
|
||||
pendingPlayerAction: null,
|
||||
pendingStep: null,
|
||||
});
|
||||
useToastStore.getState().push("error", msg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
retry: async (worldId) => {
|
||||
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
|
||||
set({
|
||||
submitting: true,
|
||||
error: null,
|
||||
streamingText: "",
|
||||
streamMessages: [],
|
||||
pendingPlayerAction: null,
|
||||
pendingStep: null,
|
||||
currentPhaseLabel: null,
|
||||
});
|
||||
try {
|
||||
const res = await SessionsApi.retry(worldId);
|
||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||
@@ -292,13 +485,20 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
onClose: () => set({ sseStatus: "closed" }),
|
||||
onEvent: (event) => handleEvent(get(), event, worldId),
|
||||
});
|
||||
set({ _controller: controller });
|
||||
set({ _controller: controller, _streamWorldId: worldId });
|
||||
},
|
||||
|
||||
closeStream: () => {
|
||||
const c = get()._controller;
|
||||
if (c) c.close();
|
||||
set({ _controller: null, sseStatus: "closed", submitting: false });
|
||||
set({
|
||||
_controller: null,
|
||||
sseStatus: "closed",
|
||||
submitting: false,
|
||||
pendingPlayerAction: null,
|
||||
pendingStep: null,
|
||||
currentPhaseLabel: null,
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
@@ -311,12 +511,20 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
nextActions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
hasMoreHistory: false,
|
||||
loadingMore: false,
|
||||
oldestSequence: null,
|
||||
sseStatus: "idle",
|
||||
streamingText: "",
|
||||
streamingStepId: null,
|
||||
streamMessages: [],
|
||||
submitting: false,
|
||||
pendingPlayerAction: null,
|
||||
pendingStep: null,
|
||||
currentPhaseLabel: null,
|
||||
debugMode: false,
|
||||
_controller: null,
|
||||
_streamWorldId: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -202,6 +202,20 @@ export interface SessionState {
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
/** Response from GET /api/sessions/worlds/{id}/history. */
|
||||
export interface HistoryResponse {
|
||||
steps: Step[];
|
||||
has_more: boolean;
|
||||
oldest_sequence: number | null;
|
||||
}
|
||||
|
||||
/** Response from the admin name-bank endpoints. */
|
||||
export interface NameBankResponse {
|
||||
language: string;
|
||||
names: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IterateResponse {
|
||||
stream_url: string;
|
||||
step_id: string;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRecoverPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/formatTime.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRecoverPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user