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/),
|
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).
|
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
|
## [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.
|
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
|
# 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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_settings_dict
|
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.llm import LlmClient, MockLlmClient
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.db import get_db
|
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)
|
# World builder stream (SSE)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -107,7 +141,7 @@ async def builder_stream(
|
|||||||
preset = (
|
preset = (
|
||||||
await db.execute(select(WorldPreset).where(WorldPreset.id == world.preset_id))
|
await db.execute(select(WorldPreset).where(WorldPreset.id == world.preset_id))
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
emitter = SseEmitter()
|
emitter = SseEmitter(debug=get_settings().debug)
|
||||||
player_name = (world.environment or {}).get("player", {}).get("name", "Hero")
|
player_name = (world.environment or {}).get("player", {}).get("name", "Hero")
|
||||||
notes = world.description
|
notes = world.description
|
||||||
llm = _llm_factory(settings)
|
llm = _llm_factory(settings)
|
||||||
@@ -152,7 +186,7 @@ async def editor_stream(
|
|||||||
settings: dict = Depends(get_settings_dict),
|
settings: dict = Depends(get_settings_dict),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
world = await _load_world(db, world_id, user)
|
world = await _load_world(db, world_id, user)
|
||||||
emitter = SseEmitter()
|
emitter = SseEmitter(debug=get_settings().debug)
|
||||||
llm = _llm_factory(settings)
|
llm = _llm_factory(settings)
|
||||||
|
|
||||||
async def run_bg():
|
async def run_bg():
|
||||||
@@ -234,7 +268,7 @@ async def iterate_stream(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if step is None:
|
if step is None:
|
||||||
raise HTTPException(404, "step not found")
|
raise HTTPException(404, "step not found")
|
||||||
emitter = SseEmitter()
|
emitter = SseEmitter(debug=get_settings().debug)
|
||||||
llm = _llm_factory(settings)
|
llm = _llm_factory(settings)
|
||||||
|
|
||||||
async def run_bg():
|
async def run_bg():
|
||||||
@@ -349,7 +383,7 @@ async def intro_stream(
|
|||||||
settings: dict = Depends(get_settings_dict),
|
settings: dict = Depends(get_settings_dict),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
world = await _load_world(db, world_id, user)
|
world = await _load_world(db, world_id, user)
|
||||||
emitter = SseEmitter()
|
emitter = SseEmitter(debug=get_settings().debug)
|
||||||
llm = _llm_factory(settings)
|
llm = _llm_factory(settings)
|
||||||
|
|
||||||
async def run_bg():
|
async def run_bg():
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
|
|||||||
"value": [],
|
"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).",
|
"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": {
|
"ui.header_title": {
|
||||||
"value": "",
|
"value": "",
|
||||||
"description": "Header title in the navbar. If empty, falls back to ui.page_title.",
|
"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)
|
sse_emitter=sse.emit)
|
||||||
tools = registry.to_openai_format("orchestrator_phase2")
|
tools = registry.to_openai_format("orchestrator_phase2")
|
||||||
phase2_msg: dict[str, Any] = {}
|
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(
|
resp = await llm.complete(
|
||||||
stage="orchestrator_phase2",
|
stage="orchestrator_phase2",
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -158,12 +159,13 @@ async def run_iteration(
|
|||||||
db=db, world=world, step=step, llm=llm, sse=sse, settings=settings,
|
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(
|
suggest_msgs = await build_orchestrator_phase3_suggest_context(
|
||||||
db=db, world=world, scene_text=step.scene_text or "", settings=settings,
|
db=db, world=world, scene_text=step.scene_text or "", settings=settings,
|
||||||
)
|
)
|
||||||
suggest_tools = registry.to_openai_format("orchestrator_phase3_suggest")
|
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(
|
resp = await llm.complete(
|
||||||
stage="orchestrator_phase3_suggest",
|
stage="orchestrator_phase3_suggest",
|
||||||
messages=suggest_msgs,
|
messages=suggest_msgs,
|
||||||
@@ -173,9 +175,19 @@ async def run_iteration(
|
|||||||
world_id=world.id, step_id=step.id, session=db,
|
world_id=world.id, step_id=step.id, session=db,
|
||||||
)
|
)
|
||||||
msg = resp.get("message", {})
|
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 []
|
tcs = msg.get("tool_calls") or []
|
||||||
|
found = False
|
||||||
for tc in tcs:
|
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":
|
if fn.get("name") == "suggest_actions":
|
||||||
try:
|
try:
|
||||||
args = json.loads(fn.get("arguments") or "{}")
|
args = json.loads(fn.get("arguments") or "{}")
|
||||||
@@ -185,11 +197,24 @@ async def run_iteration(
|
|||||||
if result.ok:
|
if result.ok:
|
||||||
step.suggested_actions = result.data.get("actions", [])
|
step.suggested_actions = result.data.get("actions", [])
|
||||||
await sse.emit("suggested_actions", {"actions": step.suggested_actions})
|
await sse.emit("suggested_actions", {"actions": step.suggested_actions})
|
||||||
|
found = True
|
||||||
break
|
break
|
||||||
if step.suggested_actions:
|
if found:
|
||||||
break
|
break
|
||||||
|
# No suggest_actions call — retry with nudge
|
||||||
suggest_msgs.append(msg)
|
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 db.commit()
|
||||||
await sse.emit("iteration_complete", {
|
await sse.emit("iteration_complete", {
|
||||||
|
|||||||
@@ -25,17 +25,25 @@ class SseEmitter:
|
|||||||
In a producer task:
|
In a producer task:
|
||||||
await emitter.emit("tool_call", {...})
|
await emitter.emit("tool_call", {...})
|
||||||
await emitter.done({"result": "ok"})
|
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()
|
self._queue: asyncio.Queue[tuple[str, str, str] | None] = asyncio.Queue()
|
||||||
# (event_type, data_json, event_id)
|
|
||||||
self._event_counter = 0
|
self._event_counter = 0
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
self._debug = debug
|
||||||
|
|
||||||
async def emit(self, event_type: str, data: Any) -> None:
|
async def emit(self, event_type: str, data: Any) -> None:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
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
|
self._event_counter += 1
|
||||||
event_id = f"evt_{self._event_counter}"
|
event_id = f"evt_{self._event_counter}"
|
||||||
try:
|
try:
|
||||||
@@ -44,6 +52,76 @@ class SseEmitter:
|
|||||||
data_str = json.dumps({"error": "serialization_failed"})
|
data_str = json.dumps({"error": "serialization_failed"})
|
||||||
await self._queue.put((event_type, data_str, event_id))
|
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:
|
async def ping(self) -> None:
|
||||||
await self.emit("ping", {"ts": _now_iso()})
|
await self.emit("ping", {"ts": _now_iso()})
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,16 @@ async def _run_tool_loop(
|
|||||||
max_substeps: int,
|
max_substeps: int,
|
||||||
settings: dict[str, Any],
|
settings: dict[str, Any],
|
||||||
) -> dict[str, Any] | None:
|
) -> 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()
|
registry = get_registry()
|
||||||
ctx = ToolContext(
|
ctx = ToolContext(
|
||||||
db=db, world=world, stage=stage,
|
db=db, world=world, stage=stage,
|
||||||
@@ -324,6 +333,8 @@ async def _run_tool_loop(
|
|||||||
]
|
]
|
||||||
tools = registry.to_openai_format(stage)
|
tools = registry.to_openai_format(stage)
|
||||||
last_terminal_result: dict[str, Any] | None = None
|
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):
|
for substep in range(max_substeps):
|
||||||
await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")})
|
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
|
msg["content"] = content
|
||||||
tool_calls = msg.get("tool_calls") or []
|
tool_calls = msg.get("tool_calls") or []
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
# No tool calls — append assistant message and retry with a nudge.
|
# No tool calls — retry with nudge (up to tool_retry_attempts)
|
||||||
# Up to 3 retries.
|
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": "assistant", "content": msg.get("content", "")})
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -363,13 +382,43 @@ async def _run_tool_loop(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
continue
|
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(msg)
|
||||||
for tc in tool_calls:
|
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)
|
||||||
|
any_failed = False
|
||||||
|
for tc in non_terminal_calls:
|
||||||
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
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", "")
|
tname = fn.get("name", "")
|
||||||
|
args_str = fn.get("arguments", "{}")
|
||||||
|
if not isinstance(args_str, str):
|
||||||
|
args_str = json.dumps(args_str)
|
||||||
try:
|
try:
|
||||||
targs = json.loads(fn.get("arguments") or "{}")
|
targs = json.loads(args_str) if args_str else {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
targs = {}
|
targs = {}
|
||||||
result = await registry.execute(tname, targs, ctx)
|
result = await registry.execute(tname, targs, ctx)
|
||||||
@@ -379,9 +428,49 @@ async def _run_tool_loop(
|
|||||||
"name": tname,
|
"name": tname,
|
||||||
"content": json.dumps(result.to_dict(), ensure_ascii=False),
|
"content": json.dumps(result.to_dict(), ensure_ascii=False),
|
||||||
})
|
})
|
||||||
if tname == terminal_tool:
|
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()
|
last_terminal_result = result.to_dict()
|
||||||
return last_terminal_result
|
return last_terminal_result
|
||||||
|
|
||||||
# If we exhausted substeps without terminal, return None
|
|
||||||
return last_terminal_result
|
return last_terminal_result
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ export function SettingsPanel() {
|
|||||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||||
// Collapsed state: Set of group ids that are collapsed. Default: ALL
|
// Collapsed state: Set of group ids that are collapsed. Default: ALL
|
||||||
// groups collapsed (the user clicks to expand the one they want to edit).
|
// 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 [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(allGroupIds));
|
||||||
|
|
||||||
const toggleCollapsed = (id: string) => {
|
const toggleCollapsed = (id: string) => {
|
||||||
@@ -323,6 +326,14 @@ export function SettingsPanel() {
|
|||||||
onToggle={() => toggleCollapsed("text_replacements")}
|
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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -807,3 +818,211 @@ function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: TextRep
|
|||||||
</Card>
|
</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 {
|
export interface ActionInputProps {
|
||||||
onSubmit: (action: string) => void;
|
onSubmit: (action: string) => void;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
suggestedActions: string[];
|
|
||||||
onSuggestedClick?: (action: string) => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
autoFocus?: boolean;
|
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({
|
export function ActionInput({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
suggestedActions,
|
|
||||||
onSuggestedClick,
|
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -33,29 +35,8 @@ export function ActionInput({
|
|||||||
setText("");
|
setText("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggested = (action: string) => {
|
|
||||||
if (submitting) return;
|
|
||||||
if (onSuggestedClick) onSuggestedClick(action);
|
|
||||||
else onSubmit(action);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<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">
|
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, type UIEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@@ -8,33 +8,109 @@ import { ToolCallBubble } from "./ToolCallBubble";
|
|||||||
|
|
||||||
export interface ChatViewProps {
|
export interface ChatViewProps {
|
||||||
className?: string;
|
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 { t } = useTranslation();
|
||||||
const world = useSessionStore((s) => s.world);
|
const world = useSessionStore((s) => s.world);
|
||||||
const recentSteps = useSessionStore((s) => s.recentSteps);
|
const recentSteps = useSessionStore((s) => s.recentSteps);
|
||||||
const streamMessages = useSessionStore((s) => s.streamMessages);
|
const streamMessages = useSessionStore((s) => s.streamMessages);
|
||||||
const streamingText = useSessionStore((s) => s.streamingText);
|
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 error = useSessionStore((s) => s.error);
|
||||||
|
const pendingPlayerAction = useSessionStore((s) => s.pendingPlayerAction);
|
||||||
|
const currentPhaseLabel = useSessionStore((s) => s.currentPhaseLabel);
|
||||||
|
|
||||||
const introScene = world?.intro_scene || null;
|
const introScene = world?.intro_scene || null;
|
||||||
const hasIntro = Boolean(introScene);
|
const hasIntro = Boolean(introScene);
|
||||||
const noSteps = recentSteps.length === 0;
|
|
||||||
// Empty state: no intro scene AND no steps AND nothing streaming.
|
// Empty state: no intro scene AND no steps AND nothing streaming.
|
||||||
const empty = noSteps && !submitting && streamMessages.length === 0 && !hasIntro;
|
const empty =
|
||||||
// Show intro scene as the first chat message when there are no steps yet
|
recentSteps.length === 0 &&
|
||||||
// (and we're not currently streaming a new response that would replace it).
|
!submittingStore &&
|
||||||
const showIntro = hasIntro && noSteps && !streamingText;
|
streamMessages.length === 0 &&
|
||||||
|
!hasIntro &&
|
||||||
|
!pendingPlayerAction;
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = 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(() => {
|
useEffect(() => {
|
||||||
|
if (!nearBottomRef.current) return;
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
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 (
|
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 && (
|
{empty && (
|
||||||
<div className="m-auto max-w-md text-center text-sm text-fg-muted py-8 space-y-2">
|
<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>
|
<p>{t("play.no_intro_scene")}</p>
|
||||||
@@ -49,7 +125,12 @@ export function ChatView({ className }: ChatViewProps) {
|
|||||||
</div>
|
</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">
|
<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">
|
<p className="text-xs font-semibold text-fg-muted mb-0.5">
|
||||||
{t("play.game_master")}
|
{t("play.game_master")}
|
||||||
@@ -59,24 +140,59 @@ export function ChatView({ className }: ChatViewProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{recentSteps.map((step) => (
|
{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 && (
|
{streamMessages.length > 0 && (
|
||||||
<div className="space-y-2 border-l-2 border-accent/40 pl-3">
|
<div className="space-y-2 border-l-2 border-accent/40 pl-3">
|
||||||
{streamMessages
|
{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) => {
|
.map((m) => {
|
||||||
if (m.kind === "tool_call" && m.tool) {
|
if (m.kind === "tool_call" && m.tool) {
|
||||||
return (
|
return (
|
||||||
<ToolCallBubble
|
<ToolCallBubble
|
||||||
key={m.id}
|
key={m.id}
|
||||||
tool={m.tool}
|
tool={m.tool}
|
||||||
|
args={m.toolArgs}
|
||||||
result={m.toolResult}
|
result={m.toolResult}
|
||||||
success={m.toolSuccess ?? false}
|
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") {
|
if (m.kind === "phase_start") {
|
||||||
return (
|
return (
|
||||||
<p key={m.id} className="text-xs text-fg-muted">
|
<p key={m.id} className="text-xs text-fg-muted">
|
||||||
@@ -123,8 +239,12 @@ export function ChatView({ className }: ChatViewProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{submitting && !streamingText && (
|
{/* No streaming text yet but we're mid-stream — show a
|
||||||
<p className="text-xs text-fg-muted italic">{t("play.streaming")}</p>
|
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>
|
</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 { t } = useTranslation();
|
||||||
|
const suggestions = step.suggested_actions || [];
|
||||||
|
const interactive = isLastStep && selectedAction == null && !submitting && onSuggestedClick;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{step.player_action && (
|
{step.player_action && (
|
||||||
@@ -155,13 +300,41 @@ function StepBlock({ step }: { step: Step }) {
|
|||||||
{t("play.game_master")}
|
{t("play.game_master")}
|
||||||
</p>
|
</p>
|
||||||
<p className="whitespace-pre-wrap text-sm text-fg">{step.scene_text}</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">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{step.suggested_actions.map((a, i) => (
|
{suggestions.map((a, i) => {
|
||||||
<span key={i} className="badge bg-bg-soft text-fg-muted">
|
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}
|
{a}
|
||||||
</span>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,74 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export type SseStatusKind = "idle" | "connecting" | "open" | "error" | "closed";
|
||||||
|
|
||||||
export interface SseStatusProps {
|
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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES = {
|
/**
|
||||||
idle: { dot: "bg-fg-dim", label: "sse.disconnected" },
|
* SSE connection status indicator.
|
||||||
connecting: { dot: "bg-warn animate-pulse", label: "sse.connecting" },
|
*
|
||||||
open: { dot: "bg-ok animate-pulse", label: "sse.connected" },
|
* Behaviour (per FE-6 spec):
|
||||||
error: { dot: "bg-err", label: "sse.error" },
|
* - The "Disconnected" / idle dot is hidden entirely — it's not useful
|
||||||
closed: { dot: "bg-fg-dim", label: "sse.disconnected" },
|
* to the user. We only render this component when the stream is in a
|
||||||
} as const;
|
* 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();
|
||||||
|
|
||||||
export function SseStatus({ status, className }: SseStatusProps) {
|
// Idle / closed → don't render at all (no useful info for the user).
|
||||||
const s = STATUS_STYLES[status];
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<span className={cn("h-2 w-2 rounded-full", s.dot)} />
|
<span className="h-2 w-2 rounded-full bg-accent animate-pulse" />
|
||||||
{/* Status text is fixed for now; could be i18n'd if needed */}
|
<span>{phaseLabel}</span>
|
||||||
<span>
|
|
||||||
{status === "idle" && "—"}
|
|
||||||
{status === "connecting" && "Connecting…"}
|
|
||||||
{status === "open" && "Connected"}
|
|
||||||
{status === "error" && "Connection error"}
|
|
||||||
{status === "closed" && "Disconnected"}
|
|
||||||
</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");
|
||||||
|
|
||||||
|
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", dotClass)} />
|
||||||
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,37 @@ import { cn } from "@/lib/cn";
|
|||||||
|
|
||||||
export interface ToolCallBubbleProps {
|
export interface ToolCallBubbleProps {
|
||||||
tool: string;
|
tool: string;
|
||||||
|
/** Raw arguments object (debug mode only). */
|
||||||
|
args?: unknown;
|
||||||
result: unknown;
|
result: unknown;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallBubble({ tool, result, success, className }: ToolCallBubbleProps) {
|
function safeStringify(v: unknown): string {
|
||||||
const { t } = useTranslation();
|
if (typeof v === "string") return v;
|
||||||
const resultPreview = useMemo(() => {
|
|
||||||
try {
|
try {
|
||||||
const str = typeof result === "string" ? result : JSON.stringify(result);
|
return JSON.stringify(v, null, 2);
|
||||||
if (str.length <= 200) return str;
|
|
||||||
return str.slice(0, 200) + "…";
|
|
||||||
} catch {
|
} catch {
|
||||||
return String(result);
|
return String(v);
|
||||||
}
|
}
|
||||||
}, [result]);
|
}
|
||||||
|
|
||||||
|
export function ToolCallBubble({ tool, args, result, success, className }: ToolCallBubbleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resultStr = useMemo(() => safeStringify(result), [result]);
|
||||||
|
const argsStr = useMemo(
|
||||||
|
() => (args == null ? "" : safeStringify(args)),
|
||||||
|
[args],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
success
|
||||||
? "border-ok/30 bg-ok/5 text-fg"
|
? "border-ok/30 bg-ok/5 text-fg"
|
||||||
: "border-err/30 bg-err/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"
|
role="status"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<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={cn("h-1.5 w-1.5 shrink-0 rounded-full", success ? "bg-ok" : "bg-err")} />
|
||||||
<span className="font-mono font-medium text-fg-muted">
|
<span className="shrink-0 font-mono font-medium text-fg-muted">
|
||||||
{t("builder.tool_call")}:
|
{t("builder.tool_call")}:
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-fg">{tool}</span>
|
<span className="truncate font-mono text-fg">{tool}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
...s,
|
...s,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
sseStatus: "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"));
|
pushToast("error", d?.message || t("builder.build_failed"));
|
||||||
controllerRef.current?.close();
|
controllerRef.current?.close();
|
||||||
@@ -197,14 +197,27 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
const d = event.data as { step?: number | string; message?: string };
|
const d = event.data as { step?: number | string; message?: string };
|
||||||
const stepVal = d.step;
|
const stepVal = d.step;
|
||||||
const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_");
|
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) => ({
|
setState((s) => ({
|
||||||
...s,
|
...s,
|
||||||
step: stepVal,
|
step: stepVal,
|
||||||
message: d.message,
|
message: finalMsg,
|
||||||
logs: [
|
logs: [
|
||||||
...s.logs,
|
...s.logs,
|
||||||
{
|
{
|
||||||
text: `[${stepVal ?? "?"}] ${d.message || ""}`,
|
text: `[${stageText}] ${finalMsg}`,
|
||||||
kind: isSkip ? ("skip" as const) : ("info" as const),
|
kind: isSkip ? ("skip" as const) : ("info" as const),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -250,11 +263,17 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
break;
|
break;
|
||||||
case "intro_scene_chunk": {
|
case "intro_scene_chunk": {
|
||||||
const d = event.data as { text: string };
|
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 }));
|
setState((s) => ({ ...s, introScene: s.introScene + d.text }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "intro_scene_complete": {
|
case "intro_scene_complete": {
|
||||||
const d = event.data as { text: string };
|
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 }));
|
setState((s) => ({ ...s, introScene: d.text }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -348,12 +367,15 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
|
|
||||||
// On done, navigate to the EDIT page (not play) — the user should
|
// On done, navigate to the EDIT page (not play) — the user should
|
||||||
// review the world, generate the intro scene, then click Play when ready.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (state.phase === "done" && createdWorldIdRef.current) {
|
if (state.phase === "done" && createdWorldIdRef.current) {
|
||||||
const id = createdWorldIdRef.current;
|
const id = createdWorldIdRef.current;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
navigate(`/worlds/${id}/edit`);
|
navigate(`/worlds/${id}/edit`);
|
||||||
}, 800);
|
}, 150);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [state.phase, navigate]);
|
}, [state.phase, navigate]);
|
||||||
@@ -531,14 +553,12 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
message={state.message}
|
message={state.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{state.introScene && (
|
{/* Note: the intro scene text is intentionally NOT rendered
|
||||||
<div>
|
here. Storing it in `state.introScene` keeps it available
|
||||||
<p className="label">{t("builder.intro_scene")}</p>
|
for debugging, but displaying it briefly before the
|
||||||
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
|
redirect to the edit page caused a jarring flash of
|
||||||
{state.introScene}
|
large text. The user will see the intro scene on the
|
||||||
</p>
|
edit page after the redirect. */}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{state.logs.length > 0 && (
|
{state.logs.length > 0 && (
|
||||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
<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>
|
<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 { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { AdminApi, WorldsApi, toErrorMessage } from "@/lib/api";
|
import { AdminApi, WorldsApi, toErrorMessage } from "@/lib/api";
|
||||||
|
import { displayGameTime } from "@/lib/formatTime";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
import type { WorldListItem, WorldStatus } from "@/types";
|
import type { WorldListItem, WorldStatus } from "@/types";
|
||||||
@@ -45,9 +46,13 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
|||||||
const isArchived = world.status === "archived";
|
const isArchived = world.status === "archived";
|
||||||
const isDraft = world.status === "draft";
|
const isDraft = world.status === "draft";
|
||||||
const isAdmin = !!user?.is_admin;
|
const isAdmin = !!user?.is_admin;
|
||||||
// Prefer the human-readable time string ("Day 1, 08:00"); fall back to the
|
// The world list endpoint does NOT include `current_time_human` —
|
||||||
// raw `current_time` value when the backend doesn't provide the human form.
|
// compute the human-readable form client-side via the world's language.
|
||||||
const displayedTime = world.current_time_human || world.current_time;
|
const displayedTime = displayGameTime(
|
||||||
|
world.current_time,
|
||||||
|
world.current_time_human,
|
||||||
|
world.language,
|
||||||
|
);
|
||||||
|
|
||||||
const handleRestore = async () => {
|
const handleRestore = async () => {
|
||||||
setRestoring(true);
|
setRestoring(true);
|
||||||
|
|||||||
@@ -138,7 +138,28 @@
|
|||||||
"schema_generated": "World schema generated",
|
"schema_generated": "World schema generated",
|
||||||
"environment_generated": "Environment generated",
|
"environment_generated": "Environment generated",
|
||||||
"entities_generated": "Entities 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": {
|
"editor": {
|
||||||
"title": "World Editor",
|
"title": "World Editor",
|
||||||
@@ -223,7 +244,12 @@
|
|||||||
"trigger_fired": "Trigger fired",
|
"trigger_fired": "Trigger fired",
|
||||||
"summary_generated": "Summary generated",
|
"summary_generated": "Summary generated",
|
||||||
"load_failed": "Failed to load session.",
|
"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": {
|
"sse": {
|
||||||
"connecting": "Connecting…",
|
"connecting": "Connecting…",
|
||||||
@@ -333,6 +359,17 @@
|
|||||||
"text_replacements_from": "From",
|
"text_replacements_from": "From",
|
||||||
"text_replacements_to": "To",
|
"text_replacements_to": "To",
|
||||||
"text_replacements_add": "Add rule",
|
"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": {
|
"setting_desc": {
|
||||||
"llm.api_url": "Chat completions endpoint URL for the LLM provider.",
|
"llm.api_url": "Chat completions endpoint URL for the LLM provider.",
|
||||||
"llm.api_key": "API key for the LLM provider (stored as string).",
|
"llm.api_key": "API key for the LLM provider (stored as string).",
|
||||||
|
|||||||
@@ -138,7 +138,28 @@
|
|||||||
"schema_generated": "Схема мира сгенерирована",
|
"schema_generated": "Схема мира сгенерирована",
|
||||||
"environment_generated": "Окружение сгенерировано",
|
"environment_generated": "Окружение сгенерировано",
|
||||||
"entities_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": {
|
"editor": {
|
||||||
"title": "Редактор мира",
|
"title": "Редактор мира",
|
||||||
@@ -223,7 +244,12 @@
|
|||||||
"trigger_fired": "Сработал триггер",
|
"trigger_fired": "Сработал триггер",
|
||||||
"summary_generated": "Сгенерирована сводка",
|
"summary_generated": "Сгенерирована сводка",
|
||||||
"load_failed": "Не удалось загрузить сессию.",
|
"load_failed": "Не удалось загрузить сессию.",
|
||||||
"no_actions_yet": "Сделайте действие, чтобы начать."
|
"no_actions_yet": "Сделайте действие, чтобы начать.",
|
||||||
|
"loading_more": "Загрузка ещё…",
|
||||||
|
"phase_reading": "Чтение…",
|
||||||
|
"phase_planning": "Планирование…",
|
||||||
|
"phase_writing": "Написание…",
|
||||||
|
"phase_sending": "Обработка…"
|
||||||
},
|
},
|
||||||
"sse": {
|
"sse": {
|
||||||
"connecting": "Подключение…",
|
"connecting": "Подключение…",
|
||||||
@@ -333,6 +359,17 @@
|
|||||||
"text_replacements_from": "С",
|
"text_replacements_from": "С",
|
||||||
"text_replacements_to": "На",
|
"text_replacements_to": "На",
|
||||||
"text_replacements_add": "Добавить правило",
|
"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": {
|
"setting_desc": {
|
||||||
"llm.api_url": "URL endpoint chat completions провайдера LLM.",
|
"llm.api_url": "URL endpoint chat completions провайдера LLM.",
|
||||||
"llm.api_key": "API-ключ провайдера LLM (хранится строкой).",
|
"llm.api_key": "API-ключ провайдера LLM (хранится строкой).",
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import type {
|
|||||||
EmbeddingsProbeResult,
|
EmbeddingsProbeResult,
|
||||||
EmbeddingsTestResult,
|
EmbeddingsTestResult,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
|
HistoryResponse,
|
||||||
IterateResponse,
|
IterateResponse,
|
||||||
LlmLog,
|
LlmLog,
|
||||||
LlmLogDetail,
|
LlmLogDetail,
|
||||||
LlmTestResult,
|
LlmTestResult,
|
||||||
LlmToolsTestResult,
|
LlmToolsTestResult,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
|
NameBankResponse,
|
||||||
Paginated,
|
Paginated,
|
||||||
PresetListItem,
|
PresetListItem,
|
||||||
PublicSettings,
|
PublicSettings,
|
||||||
@@ -332,6 +334,18 @@ export const SessionsApi = {
|
|||||||
rollback: (worldId: string) =>
|
rollback: (worldId: string) =>
|
||||||
request<void>(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }),
|
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 ----
|
// ---- World editor: accept / reject proposed changes & answer clarifications ----
|
||||||
/** Accept proposed changes from the world_editor stream. */
|
/** Accept proposed changes from the world_editor stream. */
|
||||||
applyChanges: (worldId: string) =>
|
applyChanges: (worldId: string) =>
|
||||||
@@ -473,6 +487,29 @@ export const AdminApi = {
|
|||||||
},
|
},
|
||||||
hardDeleteWorld: (id: string) =>
|
hardDeleteWorld: (id: string) =>
|
||||||
request<{ ok: boolean; deleted: string }>(`/admin/worlds/${id}`, { method: "DELETE" }),
|
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 = {
|
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_start",
|
||||||
"phase_end",
|
"phase_end",
|
||||||
"tool_call",
|
"tool_call",
|
||||||
|
"status",
|
||||||
"llm_call_start",
|
"llm_call_start",
|
||||||
"llm_call_end",
|
"llm_call_end",
|
||||||
"scene_chunk",
|
"scene_chunk",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSessionStore } from "@/stores/sessionStore";
|
|||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||||
import { toErrorMessage } from "@/lib/api";
|
import { toErrorMessage } from "@/lib/api";
|
||||||
|
import { displayGameTime } from "@/lib/formatTime";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { Spinner } from "@/components/ui/Spinner";
|
import { Spinner } from "@/components/ui/Spinner";
|
||||||
@@ -29,14 +30,27 @@ export function PlayPage() {
|
|||||||
const error = useSessionStore((s) => s.error);
|
const error = useSessionStore((s) => s.error);
|
||||||
const submitting = useSessionStore((s) => s.submitting);
|
const submitting = useSessionStore((s) => s.submitting);
|
||||||
const sseStatus = useSessionStore((s) => s.sseStatus);
|
const sseStatus = useSessionStore((s) => s.sseStatus);
|
||||||
|
const currentPhaseLabel = useSessionStore((s) => s.currentPhaseLabel);
|
||||||
const fetchState = useSessionStore((s) => s.fetchState);
|
const fetchState = useSessionStore((s) => s.fetchState);
|
||||||
const sendAction = useSessionStore((s) => s.sendAction);
|
const sendAction = useSessionStore((s) => s.sendAction);
|
||||||
const retry = useSessionStore((s) => s.retry);
|
const retry = useSessionStore((s) => s.retry);
|
||||||
const rollback = useSessionStore((s) => s.rollback);
|
const rollback = useSessionStore((s) => s.rollback);
|
||||||
const reset = useSessionStore((s) => s.reset);
|
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 [rollbackOpen, setRollbackOpen] = useState(false);
|
||||||
const [redirected, setRedirected] = 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(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -56,6 +70,16 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}, [id, world, redirected, navigate, pushToast, t]);
|
}, [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}"
|
// Page title: "{world.name} | {headerTitle}"
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (world) {
|
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) => {
|
const handleSend = (action: string) => {
|
||||||
|
// Custom text → grey out all suggested actions for the last step.
|
||||||
|
setSelectedAction("custom");
|
||||||
void sendAction(id, action, "manual").catch((err) => {
|
void sendAction(id, action, "manual").catch((err) => {
|
||||||
pushToast("error", toErrorMessage(err, "Failed"));
|
pushToast("error", toErrorMessage(err, "Failed"));
|
||||||
|
setSelectedAction(null);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggested = (action: string) => {
|
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) => {
|
void sendAction(id, action, "suggested").catch((err) => {
|
||||||
pushToast("error", toErrorMessage(err, "Failed"));
|
pushToast("error", toErrorMessage(err, "Failed"));
|
||||||
|
setSelectedAction(null);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScrollTop = () => {
|
||||||
|
if (!hasMoreHistory || loadingMore) return;
|
||||||
|
void loadMoreHistory(id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
void retry(id).catch((err) => {
|
void retry(id).catch((err) => {
|
||||||
pushToast("error", toErrorMessage(err, "Failed"));
|
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 (
|
return (
|
||||||
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
|
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
|
||||||
{/* Environment panel */}
|
{/* Environment panel */}
|
||||||
@@ -211,24 +272,23 @@ export function PlayPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{(() => {
|
{displayedTime ? `${t("worlds.current_time")}: ${displayedTime}` : ""}
|
||||||
// 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}` : "";
|
|
||||||
})()}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SseStatus status={sseStatus} />
|
<SseStatus status={sseStatus} phaseLabel={phaseLabel} />
|
||||||
</header>
|
</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">
|
<footer className="border-t border-fg-dim/20 p-3">
|
||||||
<ActionInput
|
<ActionInput
|
||||||
onSubmit={handleSend}
|
onSubmit={handleSend}
|
||||||
onSuggestedClick={handleSuggested}
|
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
suggestedActions={nextActions}
|
|
||||||
placeholder={t("play.action_placeholder")}
|
placeholder={t("play.action_placeholder")}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</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
|
* Normalize the plot_rails field — the backend may return either an
|
||||||
* old-style array of PlotRail objects or a new-style container object
|
* 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 { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { WorldsApi } from "@/lib/api";
|
import { WorldsApi } from "@/lib/api";
|
||||||
|
import { displayGameTime } from "@/lib/formatTime";
|
||||||
import { useWorldsStore } from "@/stores/worldsStore";
|
import { useWorldsStore } from "@/stores/worldsStore";
|
||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
|
||||||
@@ -86,8 +87,14 @@ export function WorldEditPage() {
|
|||||||
// world to status "ready" with intro_scene set, and IntroSceneGenerator
|
// world to status "ready" with intro_scene set, and IntroSceneGenerator
|
||||||
// then returns null on its next render.
|
// then returns null on its next render.
|
||||||
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
|
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
|
||||||
// Prefer the human-readable time string; fall back to the raw value.
|
// The world detail endpoint (GET /api/worlds/{id}) does NOT include
|
||||||
const displayedTime = world.current_time_human || world.current_time;
|
// `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 (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface StreamMessage {
|
|||||||
kind:
|
kind:
|
||||||
| "scene_chunk"
|
| "scene_chunk"
|
||||||
| "tool_call"
|
| "tool_call"
|
||||||
|
| "status"
|
||||||
| "llm_call_start"
|
| "llm_call_start"
|
||||||
| "llm_call_end"
|
| "llm_call_end"
|
||||||
| "phase_start"
|
| "phase_start"
|
||||||
@@ -30,16 +31,25 @@ interface StreamMessage {
|
|||||||
| "suggested_actions";
|
| "suggested_actions";
|
||||||
text?: string;
|
text?: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
|
toolArgs?: unknown;
|
||||||
toolResult?: unknown;
|
toolResult?: unknown;
|
||||||
toolSuccess?: boolean;
|
toolSuccess?: boolean;
|
||||||
phase?: string;
|
phase?: string | number;
|
||||||
phaseName?: string;
|
phaseName?: string;
|
||||||
step?: number;
|
step?: number;
|
||||||
totalSteps?: number;
|
totalSteps?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/** Type tag for status events ("tool" | "phase" | …). */
|
||||||
|
statusType?: string;
|
||||||
actions?: string[];
|
actions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingStep {
|
||||||
|
stepId: string;
|
||||||
|
sequenceNumber: number | null;
|
||||||
|
playerAction: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionStateStore {
|
interface SessionStateStore {
|
||||||
world: World | null;
|
world: World | null;
|
||||||
environment: Environment | null;
|
environment: Environment | null;
|
||||||
@@ -48,17 +58,46 @@ interface SessionStateStore {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
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
|
// Streaming state
|
||||||
sseStatus: SseStatus;
|
sseStatus: SseStatus;
|
||||||
streamingText: string;
|
streamingText: string;
|
||||||
streamingStepId: string | null;
|
streamingStepId: string | null;
|
||||||
streamMessages: StreamMessage[];
|
streamMessages: StreamMessage[];
|
||||||
submitting: boolean;
|
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
|
// SSE controllers
|
||||||
_controller: SseController | null;
|
_controller: SseController | null;
|
||||||
|
/** Saved worldId for the active stream. */
|
||||||
|
_streamWorldId: string | null;
|
||||||
|
|
||||||
fetchState: (worldId: string) => Promise<void>;
|
fetchState: (worldId: string) => Promise<void>;
|
||||||
|
loadMoreHistory: (worldId: string) => Promise<void>;
|
||||||
sendAction: (worldId: string, action: string, actionSource: "manual" | "suggested") => Promise<void>;
|
sendAction: (worldId: string, action: string, actionSource: "manual" | "suggested") => Promise<void>;
|
||||||
retry: (worldId: string) => Promise<void>;
|
retry: (worldId: string) => Promise<void>;
|
||||||
rollback: (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);
|
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 {
|
function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string): void {
|
||||||
// Mutable copy via set call
|
// Mutable copy via set call
|
||||||
const pushMessage = (m: StreamMessage) => {
|
const pushMessage = (m: StreamMessage) => {
|
||||||
@@ -109,24 +169,43 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "phase_start": {
|
case "phase_start": {
|
||||||
const d = event.data as { phase: string; name: string };
|
const d = event.data as { phase: string | number; name?: string };
|
||||||
pushMessage({ id: uid(), kind: "phase_start", phase: d.phase, phaseName: d.name });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "phase_end": {
|
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` });
|
pushMessage({ id: uid(), kind: "phase_end", phase: d.phase, message: `${d.duration_ms}ms` });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_call": {
|
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 };
|
const d = event.data as { tool: string; arguments: unknown; result: unknown; is_success: boolean };
|
||||||
pushMessage({
|
pushMessage({
|
||||||
id: uid(),
|
id: uid(),
|
||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
tool: d.tool,
|
tool: d.tool,
|
||||||
|
toolArgs: d.arguments,
|
||||||
toolResult: d.result,
|
toolResult: d.result,
|
||||||
toolSuccess: d.is_success,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "llm_call_start": {
|
case "llm_call_start": {
|
||||||
@@ -174,17 +253,72 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "iteration_complete": {
|
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" });
|
pushMessage({ id: uid(), kind: "iteration_complete" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "done": {
|
case "done": {
|
||||||
// Refresh session state from REST
|
// Append the newly-persisted step to recentSteps locally (built
|
||||||
void useSessionStore.getState().fetchState(worldId);
|
// 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({
|
patch({
|
||||||
sseStatus: "closed",
|
sseStatus: "closed",
|
||||||
submitting: false,
|
submitting: false,
|
||||||
streamingText: "",
|
streamingText: "",
|
||||||
streamingStepId: null,
|
streamingStepId: null,
|
||||||
|
pendingPlayerAction: null,
|
||||||
|
pendingStep: null,
|
||||||
|
currentPhaseLabel: null,
|
||||||
});
|
});
|
||||||
// Close the controller
|
// Close the controller
|
||||||
const c = useSessionStore.getState()._controller;
|
const c = useSessionStore.getState()._controller;
|
||||||
@@ -208,24 +342,38 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
hasMoreHistory: false,
|
||||||
|
loadingMore: false,
|
||||||
|
oldestSequence: null,
|
||||||
|
|
||||||
sseStatus: "idle",
|
sseStatus: "idle",
|
||||||
streamingText: "",
|
streamingText: "",
|
||||||
streamingStepId: null,
|
streamingStepId: null,
|
||||||
streamMessages: [],
|
streamMessages: [],
|
||||||
submitting: false,
|
submitting: false,
|
||||||
|
pendingPlayerAction: null,
|
||||||
|
pendingStep: null,
|
||||||
|
currentPhaseLabel: null,
|
||||||
|
debugMode: false,
|
||||||
|
|
||||||
_controller: null,
|
_controller: null,
|
||||||
|
_streamWorldId: null,
|
||||||
|
|
||||||
fetchState: async (worldId) => {
|
fetchState: async (worldId) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const data: SessionState = await SessionsApi.state(worldId);
|
const data: SessionState = await SessionsApi.state(worldId);
|
||||||
|
const steps = data.recent_steps;
|
||||||
set({
|
set({
|
||||||
world: data.world,
|
world: data.world,
|
||||||
environment: data.environment,
|
environment: data.environment,
|
||||||
recentSteps: data.recent_steps,
|
recentSteps: steps,
|
||||||
nextActions: data.next_actions,
|
nextActions: data.next_actions,
|
||||||
loading: false,
|
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) {
|
} catch (err) {
|
||||||
set({
|
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) => {
|
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 {
|
try {
|
||||||
const res = await SessionsApi.iterate(worldId, action, actionSource);
|
const res = await SessionsApi.iterate(worldId, action, actionSource);
|
||||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||||
get().subscribeIterate(worldId, res.step_id);
|
get().subscribeIterate(worldId, res.step_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = toErrorMessage(err, "Failed to send action");
|
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);
|
useToastStore.getState().push("error", msg);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
retry: async (worldId) => {
|
retry: async (worldId) => {
|
||||||
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
|
set({
|
||||||
|
submitting: true,
|
||||||
|
error: null,
|
||||||
|
streamingText: "",
|
||||||
|
streamMessages: [],
|
||||||
|
pendingPlayerAction: null,
|
||||||
|
pendingStep: null,
|
||||||
|
currentPhaseLabel: null,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const res = await SessionsApi.retry(worldId);
|
const res = await SessionsApi.retry(worldId);
|
||||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||||
@@ -292,13 +485,20 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
|||||||
onClose: () => set({ sseStatus: "closed" }),
|
onClose: () => set({ sseStatus: "closed" }),
|
||||||
onEvent: (event) => handleEvent(get(), event, worldId),
|
onEvent: (event) => handleEvent(get(), event, worldId),
|
||||||
});
|
});
|
||||||
set({ _controller: controller });
|
set({ _controller: controller, _streamWorldId: worldId });
|
||||||
},
|
},
|
||||||
|
|
||||||
closeStream: () => {
|
closeStream: () => {
|
||||||
const c = get()._controller;
|
const c = get()._controller;
|
||||||
if (c) c.close();
|
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: () => {
|
reset: () => {
|
||||||
@@ -311,12 +511,20 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
|||||||
nextActions: [],
|
nextActions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
hasMoreHistory: false,
|
||||||
|
loadingMore: false,
|
||||||
|
oldestSequence: null,
|
||||||
sseStatus: "idle",
|
sseStatus: "idle",
|
||||||
streamingText: "",
|
streamingText: "",
|
||||||
streamingStepId: null,
|
streamingStepId: null,
|
||||||
streamMessages: [],
|
streamMessages: [],
|
||||||
submitting: false,
|
submitting: false,
|
||||||
|
pendingPlayerAction: null,
|
||||||
|
pendingStep: null,
|
||||||
|
currentPhaseLabel: null,
|
||||||
|
debugMode: false,
|
||||||
_controller: null,
|
_controller: null,
|
||||||
|
_streamWorldId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -202,6 +202,20 @@ export interface SessionState {
|
|||||||
next_actions: string[];
|
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 {
|
export interface IterateResponse {
|
||||||
stream_url: string;
|
stream_url: string;
|
||||||
step_id: 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