This commit is contained in:
Mikan
2026-06-21 09:24:42 +03:00
parent 7cbe8da103
commit c45ab1ddd5
24 changed files with 1438 additions and 148 deletions

View File

@@ -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.

View File

@@ -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
# --------------------------------------------------------------------------- #

View File

@@ -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():

View File

@@ -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.",

View File

@@ -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", {

View File

@@ -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()})

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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).",

View File

@@ -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 (хранится строкой).",

View File

@@ -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 = {

View 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");
}

View File

@@ -127,6 +127,7 @@ export const KNOWN_EVENTS = [
"phase_start",
"phase_end",
"tool_call",
"status",
"llm_call_start",
"llm_call_end",
"scene_chunk",

View File

@@ -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

View File

@@ -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">

View File

@@ -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,
});
},
}));

View File

@@ -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;

View File

@@ -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"}