fix
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"""Glossary + Triggers routes."""
|
"""Glossary + Triggers + public UI settings routes."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
from typing import Any, Dict, List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -11,12 +11,37 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from app.db import get_db_dep
|
from app.db import get_db_dep
|
||||||
from app.deps import get_current_user
|
from app.deps import get_current_user
|
||||||
from app.models import DeferredTrigger, GlossaryEntry, Session, User, World
|
from app.models import DeferredTrigger, GlossaryEntry, Session, Setting, User, World
|
||||||
from app.schemas import GlossaryEntryOut, TriggerOut
|
from app.schemas import GlossaryEntryOut, TriggerOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["misc"])
|
router = APIRouter(prefix="/api", tags=["misc"])
|
||||||
|
|
||||||
|
|
||||||
|
# Public, unauthenticated UI settings (logo URL etc.) — used by the frontend
|
||||||
|
# on the login/register/home pages BEFORE the user is authenticated, so the
|
||||||
|
# branding (logo, eventually theme) shows up everywhere.
|
||||||
|
#
|
||||||
|
# Only a curated subset of settings is exposed here. Anything sensitive (api
|
||||||
|
# keys, internal URLs, admin tokens) MUST stay behind /api/admin/settings.
|
||||||
|
PUBLIC_SETTING_KEYS = ("ui.logo_url",)
|
||||||
|
_PUBLIC_DEFAULTS: Dict[str, Any] = {"ui.logo_url": "/logo.png"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/public")
|
||||||
|
async def get_public_settings(db: AsyncSession = Depends(get_db_dep)):
|
||||||
|
"""Return UI settings that are safe to expose without authentication.
|
||||||
|
|
||||||
|
Used by the frontend to render the logo (and other public branding) on
|
||||||
|
every page, including login/register. The response shape is a flat
|
||||||
|
`{key: value}` dict.
|
||||||
|
"""
|
||||||
|
out: Dict[str, Any] = dict(_PUBLIC_DEFAULTS)
|
||||||
|
rows = await db.execute(select(Setting).where(Setting.key.in_(PUBLIC_SETTING_KEYS)))
|
||||||
|
for row in rows.scalars().all():
|
||||||
|
out[row.key] = row.value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/worlds/{world_id}/glossary", response_model=List[GlossaryEntryOut])
|
@router.get("/worlds/{world_id}/glossary", response_model=List[GlossaryEntryOut])
|
||||||
async def list_glossary(
|
async def list_glossary(
|
||||||
world_id: UUID,
|
world_id: UUID,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sse_starlette.sse import EventSourceResponse
|
|||||||
|
|
||||||
from app.db import get_db_dep
|
from app.db import get_db_dep
|
||||||
from app.deps import get_current_user
|
from app.deps import get_current_user
|
||||||
from app.engine.orchestrator import run_iteration
|
from app.engine.orchestrator import generate_intro_scene, run_iteration
|
||||||
from app.models import Message, Session, User, World
|
from app.models import Message, Session, User, World
|
||||||
from app.schemas import IterationRequest, MessageOut, SessionCreate, SessionOut
|
from app.schemas import IterationRequest, MessageOut, SessionCreate, SessionOut
|
||||||
|
|
||||||
@@ -142,6 +142,35 @@ async def iterate_session(
|
|||||||
return EventSourceResponse(event_generator())
|
return EventSourceResponse(event_generator())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/intro")
|
||||||
|
async def intro_session(
|
||||||
|
session_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_dep),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SSE stream that generates the opening cinematic scene for a new session."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalars().first()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
w_result = await db.execute(select(World).where(World.id == session.world_id))
|
||||||
|
world = w_result.scalars().first()
|
||||||
|
if not world or (world.owner_id != user.id and not user.is_admin):
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
async for event in generate_intro_scene(db=db, user_id=user.id, session_id=session_id):
|
||||||
|
yield {"event": event["type"], "data": json.dumps(event.get("data", {}), ensure_ascii=False, default=str)}
|
||||||
|
except Exception as e:
|
||||||
|
yield {"event": "error", "data": json.dumps({"message": str(e)}, ensure_ascii=False)}
|
||||||
|
yield {"event": "done", "data": "{}"}
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{session_id}", status_code=204)
|
@router.delete("/{session_id}", status_code=204)
|
||||||
async def delete_session(
|
async def delete_session(
|
||||||
session_id: UUID,
|
session_id: UUID,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from app.db import get_db_dep
|
from app.db import get_db_dep
|
||||||
from app.deps import get_current_user
|
from app.deps import get_current_user
|
||||||
from app.engine.world_builder import commit_world_builder, continue_world_builder, start_world_builder
|
from app.engine.world_builder import commit_world_builder, continue_world_builder, start_world_builder
|
||||||
|
from app.engine.world_editor import edit_world_via_chat, reset_editor_dialogue
|
||||||
from app.models import User, World
|
from app.models import User, World
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
WorldBuilderCommit,
|
WorldBuilderCommit,
|
||||||
@@ -19,6 +20,8 @@ from app.schemas import (
|
|||||||
WorldBuilderReply,
|
WorldBuilderReply,
|
||||||
WorldBuilderStart,
|
WorldBuilderStart,
|
||||||
WorldCreate,
|
WorldCreate,
|
||||||
|
WorldEditorChatReply,
|
||||||
|
WorldEditorChatRequest,
|
||||||
WorldOut,
|
WorldOut,
|
||||||
WorldUpdate,
|
WorldUpdate,
|
||||||
)
|
)
|
||||||
@@ -159,3 +162,56 @@ async def builder_commit(
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"builder_commit_failed: {e}")
|
raise HTTPException(status_code=500, detail=f"builder_commit_failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# === World Editor (AI-assisted editing of an existing world) ===
|
||||||
|
|
||||||
|
@router.post("/{world_id}/chat", response_model=WorldEditorChatReply)
|
||||||
|
async def world_editor_chat(
|
||||||
|
world_id: UUID,
|
||||||
|
payload: WorldEditorChatRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_dep),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Chat with the AI to edit an existing world's definition.
|
||||||
|
|
||||||
|
Returns the AI's prose reply plus the proposed new definition. The
|
||||||
|
frontend must call PATCH /worlds/{id} to actually persist the change.
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(World).where(World.id == world_id))
|
||||||
|
world = result.scalars().first()
|
||||||
|
if not world:
|
||||||
|
raise HTTPException(status_code=404, detail="world_not_found")
|
||||||
|
if world.owner_id != user.id and not user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
try:
|
||||||
|
ai_message, new_defn, changed = await edit_world_via_chat(
|
||||||
|
db=db, user=user, world=world, message=payload.message,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"world_editor_chat_failed: {e}")
|
||||||
|
return WorldEditorChatReply(
|
||||||
|
ai_message=ai_message,
|
||||||
|
definition=new_defn,
|
||||||
|
changed=changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{world_id}/chat/reset")
|
||||||
|
async def world_editor_chat_reset(
|
||||||
|
world_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_dep),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Clear the cached editor dialogue for a world (start fresh)."""
|
||||||
|
result = await db.execute(select(World).where(World.id == world_id))
|
||||||
|
world = result.scalars().first()
|
||||||
|
if not world:
|
||||||
|
raise HTTPException(status_code=404, detail="world_not_found")
|
||||||
|
if world.owner_id != user.id and not user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
reset_editor_dialogue(world_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -16,6 +16,37 @@ from app.models import LlmCallLog
|
|||||||
log = get_logger("llm")
|
log = get_logger("llm")
|
||||||
|
|
||||||
|
|
||||||
|
# vendor-specific end-of-sequence / control tokens that some local models
|
||||||
|
# emit into the content stream. Strip them so they don't leak to the user.
|
||||||
|
_EOS_TOKENS = (
|
||||||
|
"<eos>",
|
||||||
|
"</s>",
|
||||||
|
"<|endoftext|>",
|
||||||
|
"<|im_end|>",
|
||||||
|
"<|end|>",
|
||||||
|
"<|eot_id|>",
|
||||||
|
"<|eom_id|>",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_model_text(text: str) -> str:
|
||||||
|
"""Remove vendor-specific end-of-sequence tokens and collapse whitespace.
|
||||||
|
|
||||||
|
Some local models leak control tokens into the visible content stream.
|
||||||
|
We strip them so they never reach the user.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
cleaned = text
|
||||||
|
for tok in _EOS_TOKENS:
|
||||||
|
cleaned = cleaned.replace(tok, "")
|
||||||
|
while "\n\n\n" in cleaned:
|
||||||
|
cleaned = cleaned.replace("\n\n\n", "\n\n")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LlmResponse:
|
class LlmResponse:
|
||||||
"""Non-streaming response wrapper."""
|
"""Non-streaming response wrapper."""
|
||||||
|
|
||||||
@@ -93,7 +124,7 @@ class LlmClient:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
choice = (data.get("choices") or [{}])[0]
|
choice = (data.get("choices") or [{}])[0]
|
||||||
msg = choice.get("message", {})
|
msg = choice.get("message", {})
|
||||||
text = msg.get("content") or ""
|
text = _clean_model_text(msg.get("content") or "")
|
||||||
tool_calls = msg.get("tool_calls") or []
|
tool_calls = msg.get("tool_calls") or []
|
||||||
usage = data.get("usage") or {}
|
usage = data.get("usage") or {}
|
||||||
except httpx.ConnectError as e:
|
except httpx.ConnectError as e:
|
||||||
@@ -206,8 +237,10 @@ class LlmClient:
|
|||||||
continue
|
continue
|
||||||
delta = choices[0].get("delta", {})
|
delta = choices[0].get("delta", {})
|
||||||
if delta.get("content"):
|
if delta.get("content"):
|
||||||
full_text_parts.append(delta["content"])
|
piece = _clean_model_text(delta["content"])
|
||||||
yield {"type": "delta", "content": delta["content"]}
|
if piece:
|
||||||
|
full_text_parts.append(piece)
|
||||||
|
yield {"type": "delta", "content": piece}
|
||||||
if delta.get("tool_calls"):
|
if delta.get("tool_calls"):
|
||||||
for tc in delta["tool_calls"]:
|
for tc in delta["tool_calls"]:
|
||||||
idx = tc.get("index", 0)
|
idx = tc.get("index", 0)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ EDITABLE_SETTING_KEYS = {
|
|||||||
"embedding.model": str, # e.g. text-embedding-3-small, bge-m3, nomic-embed-text
|
"embedding.model": str, # e.g. text-embedding-3-small, bge-m3, nomic-embed-text
|
||||||
"embedding.dim": int, # vector dimension; 0 = auto-probe from endpoint
|
"embedding.dim": int, # vector dimension; 0 = auto-probe from endpoint
|
||||||
"embedding.request_timeout": int, # request timeout, seconds
|
"embedding.request_timeout": int, # request timeout, seconds
|
||||||
|
# UI customization (logo URL/path shown in navbar + home page + favicon)
|
||||||
|
"ui.logo_url": str, # e.g. "/logo.png", "https://.../logo.png", or "data:image/png;base64,..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,25 @@ async def run_iteration(
|
|||||||
settings_map = await get_all_settings(db)
|
settings_map = await get_all_settings(db)
|
||||||
llm = LlmClient(settings_map)
|
llm = LlmClient(settings_map)
|
||||||
|
|
||||||
# Save the player's action as a message
|
# Save the player's action as a message — UNLESS this is a retry of the
|
||||||
|
# previous action (frontend re-sent the same action_text after an error).
|
||||||
|
# In that case we reuse the existing player_action row so the chat
|
||||||
|
# history doesn't fill up with duplicates.
|
||||||
|
last_msg_result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.where(Message.session_id == session_id)
|
||||||
|
.order_by(Message.seq.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_msg = last_msg_result.scalars().first()
|
||||||
|
is_retry = (
|
||||||
|
last_msg is not None
|
||||||
|
and last_msg.kind == "player_action"
|
||||||
|
and last_msg.content == action_text
|
||||||
|
)
|
||||||
|
if is_retry:
|
||||||
|
player_msg = last_msg
|
||||||
|
else:
|
||||||
next_seq = await _next_seq(db, session_id)
|
next_seq = await _next_seq(db, session_id)
|
||||||
player_msg = Message(
|
player_msg = Message(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -476,3 +494,122 @@ def _safe_parse_json(s: str) -> Any:
|
|||||||
return json.loads(s) if s else {}
|
return json.loads(s) if s else {}
|
||||||
except Exception:
|
except Exception:
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_intro_scene(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session_id: uuid.UUID,
|
||||||
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
|
"""Generate the opening cinematic scene for a freshly-created session.
|
||||||
|
|
||||||
|
Yields the same SSE event stream shape as `run_iteration` so the
|
||||||
|
frontend can consume it identically. Saves a `narrative_step` message
|
||||||
|
of kind `intro_scene` (still kind=narrative_step for compatibility,
|
||||||
|
but with payload.kind=intro so the UI can style it differently if
|
||||||
|
desired).
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||||
|
session = result.scalars().first()
|
||||||
|
if not session:
|
||||||
|
yield {"type": "error", "data": {"message": "session_not_found"}}
|
||||||
|
return
|
||||||
|
result = await db.execute(select(World).where(World.id == session.world_id))
|
||||||
|
world = result.scalars().first()
|
||||||
|
if not world:
|
||||||
|
yield {"type": "error", "data": {"message": "world_not_found"}}
|
||||||
|
return
|
||||||
|
|
||||||
|
settings_map = await get_all_settings(db)
|
||||||
|
llm = LlmClient(settings_map)
|
||||||
|
|
||||||
|
yield {"type": "status", "data": {"message": "writing_scene"}}
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
defn = world.definition or {}
|
||||||
|
player_state = world.state.get("player", {}) if world.state else {}
|
||||||
|
system_prompt = get_prompt("intro_scene", world.language).format(
|
||||||
|
setting_description=defn.get("setting_description", "")[:1200],
|
||||||
|
current_time=world.current_time or "",
|
||||||
|
player_state=_json.dumps(player_state, ensure_ascii=False)[:800],
|
||||||
|
plot_rails=_json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:600],
|
||||||
|
world_language=world.language or "en",
|
||||||
|
)
|
||||||
|
|
||||||
|
step_resp = await llm.chat(
|
||||||
|
messages=[{"role": "system", "content": system_prompt}],
|
||||||
|
tools=STEP_WRITER_TOOL_SCHEMAS,
|
||||||
|
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
|
||||||
|
max_tokens=1500,
|
||||||
|
purpose="intro_scene",
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
step_text = step_resp.text or ""
|
||||||
|
step_options: List[str] = []
|
||||||
|
for tc in (step_resp.tool_calls or []):
|
||||||
|
if tc.get("function", {}).get("name") == "submit_scene":
|
||||||
|
args_str = tc.get("function", {}).get("arguments", "{}")
|
||||||
|
try:
|
||||||
|
scene_data = _json.loads(args_str) if args_str else {}
|
||||||
|
if scene_data.get("narrative"):
|
||||||
|
step_text = scene_data["narrative"]
|
||||||
|
if scene_data.get("options") and isinstance(scene_data["options"], list):
|
||||||
|
step_options = [str(o) for o in scene_data["options"]][:5]
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
log.warning("intro_scene_invalid_json", args=args_str[:200])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Fallback: extract JSON from text.
|
||||||
|
import re as _re
|
||||||
|
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "")
|
||||||
|
if json_match:
|
||||||
|
try:
|
||||||
|
step_data = _json.loads(json_match.group(0))
|
||||||
|
if "narrative" in step_data:
|
||||||
|
step_text = step_data["narrative"]
|
||||||
|
if "options" in step_data and isinstance(step_data["options"], list):
|
||||||
|
step_options = [str(o) for o in step_data["options"]][:5]
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Save as a narrative_step message flagged as intro in payload.
|
||||||
|
step_seq = await _next_seq(db, session_id)
|
||||||
|
step_msg = Message(
|
||||||
|
session_id=session_id,
|
||||||
|
seq=step_seq,
|
||||||
|
role="assistant",
|
||||||
|
kind="narrative_step",
|
||||||
|
content=step_text,
|
||||||
|
payload={
|
||||||
|
"kind": "intro",
|
||||||
|
"options": step_options,
|
||||||
|
"world_time": world.current_time,
|
||||||
|
"player_state": world.state.get("player", {}),
|
||||||
|
},
|
||||||
|
is_pinned=True,
|
||||||
|
hidden=False,
|
||||||
|
)
|
||||||
|
db.add(step_msg)
|
||||||
|
session.last_played_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(step_msg)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "step_complete",
|
||||||
|
"data": {
|
||||||
|
"message_id": str(step_msg.id),
|
||||||
|
"seq": step_msg.seq,
|
||||||
|
"narrative": step_text,
|
||||||
|
"options": step_options,
|
||||||
|
"state": world.state,
|
||||||
|
"world_time": world.current_time,
|
||||||
|
"player_state": world.state.get("player", {}),
|
||||||
|
"fired_triggers": [],
|
||||||
|
"is_intro": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield {"type": "done", "data": {}}
|
||||||
|
|||||||
279
backend/app/engine/world_editor.py
Normal file
279
backend/app/engine/world_editor.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""AI-assisted editor for an EXISTING world.
|
||||||
|
|
||||||
|
The player chats with the AI; each turn the AI returns:
|
||||||
|
- a short player-facing message describing what it changed / will change,
|
||||||
|
- an updated `definition` (the full new WorldDefinition).
|
||||||
|
|
||||||
|
The caller (API endpoint) decides whether to persist the new definition
|
||||||
|
to the World row. The editor itself is stateless aside from the in-memory
|
||||||
|
dialogue cache (keyed by world_id), so the player can iterate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.llm import LlmClient
|
||||||
|
from app.core.settings_service import cast_setting, get_all_settings
|
||||||
|
from app.logging_setup import get_logger
|
||||||
|
from app.models import User, World
|
||||||
|
from app.prompts.templates import get_prompt
|
||||||
|
from app.schemas import WorldDefinition
|
||||||
|
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
|
||||||
|
|
||||||
|
log = get_logger("world_editor")
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory dialogue cache: world_id -> list of messages.
|
||||||
|
_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_world_via_chat(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
world: World,
|
||||||
|
message: str,
|
||||||
|
) -> Tuple[str, Optional[Dict[str, Any]], bool]:
|
||||||
|
"""Run one turn of AI-assisted world editing.
|
||||||
|
|
||||||
|
Returns (ai_message, new_definition_dict_or_None, changed).
|
||||||
|
- ai_message: short prose reply for the player (in world.language).
|
||||||
|
- new_definition_dict: the full updated definition if the AI proposed
|
||||||
|
changes this turn, else None.
|
||||||
|
- changed: True if new_definition_dict is not None and differs from
|
||||||
|
the current world.definition.
|
||||||
|
"""
|
||||||
|
settings_map = await get_all_settings(db)
|
||||||
|
llm = LlmClient(settings_map)
|
||||||
|
|
||||||
|
# Get / init dialogue state for this world.
|
||||||
|
dialogue = _DIALOGUES.get(world.id)
|
||||||
|
if not dialogue:
|
||||||
|
system_prompt = _build_system_prompt(world)
|
||||||
|
dialogue = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": _build_seed_message(world)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_DIALOGUES[world.id] = dialogue
|
||||||
|
|
||||||
|
# Authorization: only the owner (or admin) may continue an existing dialogue.
|
||||||
|
if dialogue["user_id"] != user.id and not user.is_admin:
|
||||||
|
raise ValueError("forbidden")
|
||||||
|
|
||||||
|
dialogue["messages"].append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
response = await llm.chat(
|
||||||
|
messages=dialogue["messages"],
|
||||||
|
tools=WORLD_BUILDER_TOOL_SCHEMAS,
|
||||||
|
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
|
||||||
|
purpose="world_editor",
|
||||||
|
user_id=user.id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_message, new_defn, _is_final = _extract_world_definition(
|
||||||
|
response.text, response.tool_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the model did not return a tool call but did emit JSON in text,
|
||||||
|
# _extract_world_definition handles it. If still None, just return the
|
||||||
|
# conversational message without changes.
|
||||||
|
if new_defn is None:
|
||||||
|
dialogue["messages"].append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": response.text or "",
|
||||||
|
"tool_calls": response.tool_calls or None,
|
||||||
|
})
|
||||||
|
return ai_message, None, False
|
||||||
|
|
||||||
|
new_defn_dict = new_defn.model_dump()
|
||||||
|
changed = new_defn_dict != (world.definition or {})
|
||||||
|
|
||||||
|
dialogue["messages"].append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": response.text or "",
|
||||||
|
"tool_calls": response.tool_calls or None,
|
||||||
|
})
|
||||||
|
return ai_message, new_defn_dict, changed
|
||||||
|
|
||||||
|
|
||||||
|
def reset_editor_dialogue(world_id: uuid.UUID) -> None:
|
||||||
|
"""Drop the cached editor dialogue for a world (e.g. after manual save)."""
|
||||||
|
_DIALOGUES.pop(world_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_system_prompt(world: World) -> str:
|
||||||
|
"""System prompt for the world editor.
|
||||||
|
|
||||||
|
Reuses the world-builder prompt but overrides the workflow: instead of
|
||||||
|
designing from scratch, the AI is told to MODIFY the existing definition.
|
||||||
|
"""
|
||||||
|
base = get_prompt("world_builder", world.language)
|
||||||
|
override = (
|
||||||
|
"\n\nADDITIONAL CONTEXT — YOU ARE EDITING AN EXISTING WORLD:\n"
|
||||||
|
"The world already exists with the definition provided in the first "
|
||||||
|
"user message. The player will give you edit instructions in their "
|
||||||
|
"language ({world_language}). For EACH instruction:\n"
|
||||||
|
"1. Call `submit_world_definition` with the FULL updated definition "
|
||||||
|
"(not just the changed fields — the entire object, all required keys).\n"
|
||||||
|
"2. Your text response should briefly summarize what you changed in "
|
||||||
|
"the player's language. 2-4 sentences max.\n"
|
||||||
|
"3. NEVER set `is_final=true` — the player will commit changes "
|
||||||
|
"manually via the Save button.\n"
|
||||||
|
"4. Preserve `initial_state` consistency with `world_schema`. If you "
|
||||||
|
"change the schema, update the state accordingly.\n"
|
||||||
|
"5. Preserve `initial_time` and `calendar` unless the player asks to "
|
||||||
|
"change them.\n"
|
||||||
|
).format(world_language=world.language or "en")
|
||||||
|
return base + override
|
||||||
|
|
||||||
|
|
||||||
|
def _build_seed_message(world: World) -> str:
|
||||||
|
"""First user message: dumps the current world definition as context."""
|
||||||
|
defn = world.definition or {}
|
||||||
|
parts = [
|
||||||
|
"=== CURRENT WORLD DEFINITION ===",
|
||||||
|
f"Name: {world.name}",
|
||||||
|
f"Language: {world.language}",
|
||||||
|
f"Current time: {world.current_time or '(none)'}",
|
||||||
|
f"Definition JSON:\n```json\n{json.dumps(defn, ensure_ascii=False, indent=2)}\n```",
|
||||||
|
f"Live state JSON:\n```json\n{json.dumps(world.state or {}, ensure_ascii=False, indent=2)[:2000]}\n```",
|
||||||
|
"",
|
||||||
|
"The player will now give you edit instructions. Apply each one by "
|
||||||
|
"calling submit_world_definition with the FULL updated definition.",
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# === Output extraction (mirrors world_builder._extract_world_definition) ===
|
||||||
|
def _extract_world_definition(
|
||||||
|
text: str,
|
||||||
|
tool_calls: Optional[List[Dict[str, Any]]],
|
||||||
|
) -> Tuple[str, Optional[WorldDefinition], bool]:
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
proposed: Optional[WorldDefinition] = None
|
||||||
|
is_final = False
|
||||||
|
ai_text = _sanitize_model_text(text or "")
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
for tc in tool_calls:
|
||||||
|
if tc.get("function", {}).get("name") == "submit_world_definition":
|
||||||
|
args_str = tc.get("function", {}).get("arguments", "{}")
|
||||||
|
data = _safe_json_loads(args_str, {})
|
||||||
|
if data:
|
||||||
|
proposed = _try_build_definition(data)
|
||||||
|
is_final = bool(data.get("is_final", False))
|
||||||
|
break
|
||||||
|
|
||||||
|
if proposed is None:
|
||||||
|
json_str = _extract_json_block(ai_text)
|
||||||
|
if json_str:
|
||||||
|
data = _safe_json_loads(json_str, None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
target = data
|
||||||
|
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
|
||||||
|
target = data["proposed_definition"]
|
||||||
|
if "is_final" in data:
|
||||||
|
is_final = bool(data["is_final"])
|
||||||
|
if "ai_message" in data and isinstance(data["ai_message"], str):
|
||||||
|
ai_text = data["ai_message"]
|
||||||
|
else:
|
||||||
|
if "is_final" in data:
|
||||||
|
is_final = bool(data["is_final"])
|
||||||
|
proposed = _try_build_definition(target)
|
||||||
|
|
||||||
|
if proposed is not None:
|
||||||
|
ai_text = _strip_json_blocks(ai_text).strip()
|
||||||
|
if not ai_text:
|
||||||
|
ai_text = "(definition updated)"
|
||||||
|
|
||||||
|
return ai_text, proposed, is_final
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_model_text(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
import re as _re
|
||||||
|
cleaned = _re.sub(r"<\s*/?\s*[a-zA-Z]+\s*>", "", text)
|
||||||
|
cleaned = _re.sub(r"\n{3,}", "\n\n", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_json_blocks(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
import re as _re
|
||||||
|
cleaned = _re.sub(r"```[a-zA-Z]*\s*[\s\S]*?```", "", text)
|
||||||
|
m = _re.search(r"\n\{[\s\S]*\}\s*$", cleaned)
|
||||||
|
if m:
|
||||||
|
cleaned = cleaned[: m.start()] + cleaned[m.end():]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json_loads(s: str, default: Any) -> Any:
|
||||||
|
if not s:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
import re as _re
|
||||||
|
repaired = _re.sub(r",\s*([}\]])", r"\1", s)
|
||||||
|
try:
|
||||||
|
return json.loads(repaired)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
repaired2 = repaired.replace("'", '"')
|
||||||
|
return json.loads(repaired2)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
|
||||||
|
try:
|
||||||
|
return WorldDefinition.model_validate(data)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("world_editor_definition_invalid", error=str(e), keys=list(data.keys()))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_block(text: str) -> Optional[str]:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
import re as _re
|
||||||
|
m = _re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
start = text.find("{")
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
in_str = False
|
||||||
|
esc = False
|
||||||
|
for i in range(start, len(text)):
|
||||||
|
c = text[i]
|
||||||
|
if in_str:
|
||||||
|
if esc:
|
||||||
|
esc = False
|
||||||
|
elif c == "\\":
|
||||||
|
esc = True
|
||||||
|
elif c == '"':
|
||||||
|
in_str = False
|
||||||
|
else:
|
||||||
|
if c == '"':
|
||||||
|
in_str = True
|
||||||
|
elif c == "{":
|
||||||
|
depth += 1
|
||||||
|
elif c == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start:i + 1]
|
||||||
|
return None
|
||||||
@@ -52,6 +52,8 @@ DEFAULT_SETTINGS = [
|
|||||||
("embedding.model", settings.default_embedding_model, "Embedding model name (e.g. text-embedding-3-small, bge-m3, nomic-embed-text)"),
|
("embedding.model", settings.default_embedding_model, "Embedding model name (e.g. text-embedding-3-small, bge-m3, nomic-embed-text)"),
|
||||||
("embedding.dim", settings.default_embedding_dim, "Vector dimension. 0 = auto-probe from endpoint on first use"),
|
("embedding.dim", settings.default_embedding_dim, "Vector dimension. 0 = auto-probe from endpoint on first use"),
|
||||||
("embedding.request_timeout", settings.default_embedding_request_timeout, "Embeddings request timeout, seconds"),
|
("embedding.request_timeout", settings.default_embedding_request_timeout, "Embeddings request timeout, seconds"),
|
||||||
|
# UI customization
|
||||||
|
("ui.logo_url", "/logo.png", "Logo image URL or path shown in navbar, home page, and favicon. Use a URL (https://...), an absolute path (/logo.png), or a data: URI. Default is the bundled Mikan logo."),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,35 @@ Call the `submit_trigger_result` tool with:
|
|||||||
Your text response is ignored — only the tool call is used."""
|
Your text response is ignored — only the tool call is used."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# === Intro Scene (opening scene generated when a session starts) ===
|
||||||
|
INTRO_SCENE_SYSTEM = """You are the narrative writer of a role-playing game. The player has just created a new session and you must write the OPENING scene that sets the stage for the adventure.
|
||||||
|
|
||||||
|
CONTEXT:
|
||||||
|
- Setting: {setting_description}
|
||||||
|
- Initial world time: {current_time}
|
||||||
|
- Player state: {player_state}
|
||||||
|
- Plot rails (main goal + hooks): {plot_rails}
|
||||||
|
|
||||||
|
YOUR JOB:
|
||||||
|
1. Call the `submit_scene` tool with:
|
||||||
|
- `narrative`: 250-500 words of cinematic, second-person ("You...") prose that:
|
||||||
|
a) establishes the setting and mood,
|
||||||
|
b) introduces the player character based on `player_state`,
|
||||||
|
c) plants the seed of the main goal / first hook from `plot_rails`,
|
||||||
|
d) ends with a clear decision moment or call to action.
|
||||||
|
- `options`: exactly 3 short (5-12 words) options for the player's first action.
|
||||||
|
|
||||||
|
CRITICAL:
|
||||||
|
- Write the narrative in {world_language}.
|
||||||
|
- Do NOT assume the player has done anything yet — this is the very first scene.
|
||||||
|
- Do NOT use the player's name if it is empty or generic; address them as "you".
|
||||||
|
- Set the tone: atmospheric, evocative, but grounded in the setting.
|
||||||
|
- Your text response is ignored — only the `submit_scene` tool call is used.
|
||||||
|
"""
|
||||||
|
|
||||||
PROMPTS = {
|
PROMPTS = {
|
||||||
"en": {
|
"en": {
|
||||||
"world_builder": WORLD_BUILDER_SYSTEM,
|
"world_builder": WORLD_BUILDER_SYSTEM,
|
||||||
@@ -141,6 +170,7 @@ PROMPTS = {
|
|||||||
"summarizer": SUMMARIZER_SYSTEM,
|
"summarizer": SUMMARIZER_SYSTEM,
|
||||||
"subagent": SUBAGENT_SYSTEM,
|
"subagent": SUBAGENT_SYSTEM,
|
||||||
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
|
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
|
||||||
|
"intro_scene": INTRO_SCENE_SYSTEM,
|
||||||
},
|
},
|
||||||
# Russian keys kept for backward compatibility but always return the English
|
# Russian keys kept for backward compatibility but always return the English
|
||||||
# prompts — system content is always English per the project convention.
|
# prompts — system content is always English per the project convention.
|
||||||
@@ -151,6 +181,7 @@ PROMPTS = {
|
|||||||
"summarizer": SUMMARIZER_SYSTEM,
|
"summarizer": SUMMARIZER_SYSTEM,
|
||||||
"subagent": SUBAGENT_SYSTEM,
|
"subagent": SUBAGENT_SYSTEM,
|
||||||
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
|
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
|
||||||
|
"intro_scene": INTRO_SCENE_SYSTEM,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,3 +237,15 @@ class TriggerCreate(BaseModel):
|
|||||||
fire_at: str
|
fire_at: str
|
||||||
description: str
|
description: str
|
||||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# === World Editor (AI-assisted editing of an existing world) ===
|
||||||
|
class WorldEditorChatRequest(BaseModel):
|
||||||
|
message: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class WorldEditorChatReply(BaseModel):
|
||||||
|
ai_message: str
|
||||||
|
definition: Optional[Dict[str, Any]] = None
|
||||||
|
changed: bool = False
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI RPG</title>
|
<title>AI RPG</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@@ -1,5 +1,7 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { useUiStore } from "@/store/ui";
|
||||||
import { Navbar } from "@/components/ui/Navbar";
|
import { Navbar } from "@/components/ui/Navbar";
|
||||||
import { HomePage } from "@/pages/HomePage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
@@ -26,6 +28,15 @@ function AdminRoute({ children }: { children: JSX.Element }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// Load public UI settings (logo URL, etc.) once on app boot. These are
|
||||||
|
// unauthenticated and cached by the api layer, so subsequent navigations
|
||||||
|
// do not re-fetch. The admin panel calls load(true) after saving to
|
||||||
|
// pick up a new logo URL without a full page reload.
|
||||||
|
const loadUi = useUiStore((s) => s.load);
|
||||||
|
useEffect(() => {
|
||||||
|
loadUi();
|
||||||
|
}, [loadUi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|||||||
@@ -229,4 +229,41 @@ export const miscApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Public UI settings — no auth required. Used on login/register/home pages
|
||||||
|
// to render branding (logo, eventually theme). Caches the result in-process
|
||||||
|
// so multiple components can call getPublicSettings() without re-fetching.
|
||||||
|
export type PublicUiSettings = {
|
||||||
|
logo_url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let _publicSettingsCache: PublicUiSettings | null = null;
|
||||||
|
let _publicSettingsPromise: Promise<PublicUiSettings> | null = null;
|
||||||
|
|
||||||
|
export const uiApi = {
|
||||||
|
/** Fetch public UI settings (logo URL, etc.). Cached after first call. */
|
||||||
|
getPublicSettings: async (force = false): Promise<PublicUiSettings> => {
|
||||||
|
if (_publicSettingsCache && !force) return _publicSettingsCache;
|
||||||
|
if (_publicSettingsPromise && !force) return _publicSettingsPromise;
|
||||||
|
_publicSettingsPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/settings/public");
|
||||||
|
_publicSettingsCache = {
|
||||||
|
logo_url: data["ui.logo_url"] || "/logo.png",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
_publicSettingsCache = { logo_url: "/logo.png" };
|
||||||
|
} finally {
|
||||||
|
_publicSettingsPromise = null;
|
||||||
|
}
|
||||||
|
return _publicSettingsCache;
|
||||||
|
})();
|
||||||
|
return _publicSettingsPromise;
|
||||||
|
},
|
||||||
|
/** Reset the in-memory cache. Call after admin saves new ui.logo_url. */
|
||||||
|
resetCache: () => {
|
||||||
|
_publicSettingsCache = null;
|
||||||
|
_publicSettingsPromise = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const SSE_ENDPOINT = "/api/sessions";
|
export const SSE_ENDPOINT = "/api/sessions";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { LogOut, Shield, Globe, BookOpen } from "lucide-react";
|
import { LogOut, Shield, Globe, BookOpen } from "lucide-react";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { useUiStore } from "@/store/ui";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { cn } from "./cn";
|
import { cn } from "./cn";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export function Navbar() {
|
|||||||
const { user, logout, isAdmin } = useAuthStore();
|
const { user, logout, isAdmin } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [langOpen, setLangOpen] = useState(false);
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
|
const logoUrl = useUiStore((s) => s.logoUrl);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@@ -26,7 +28,21 @@ export function Navbar() {
|
|||||||
<header className="border-b border-ink-800 bg-ink-950/80 backdrop-blur sticky top-0 z-40">
|
<header className="border-b border-ink-800 bg-ink-950/80 backdrop-blur sticky top-0 z-40">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
<Link to="/" className="flex items-center gap-2 text-ink-100 hover:text-accent-400 transition-colors">
|
<Link to="/" className="flex items-center gap-2 text-ink-100 hover:text-accent-400 transition-colors">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="logo"
|
||||||
|
className="w-7 h-7 rounded object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
// If the configured logo fails to load, hide the broken image
|
||||||
|
// so the navbar degrades gracefully. The bundled /logo.png is
|
||||||
|
// always available as the default fallback.
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<BookOpen size={20} className="text-accent-500" />
|
<BookOpen size={20} className="text-accent-500" />
|
||||||
|
)}
|
||||||
<span className="font-serif text-lg font-semibold">{t("app.title")}</span>
|
<span className="font-serif text-lg font-semibold">{t("app.title")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export const en = {
|
|||||||
send: "Send",
|
send: "Send",
|
||||||
accept: "Accept world and create",
|
accept: "Accept world and create",
|
||||||
accepting: "Creating world...",
|
accepting: "Creating world...",
|
||||||
|
editor_chat_title: "AI chat",
|
||||||
|
editor_chat_desc: "Describe changes — the AI will update the world definition.",
|
||||||
|
editor_chat_empty: "Nothing yet. Tell the AI what to change.",
|
||||||
|
editor_chat_ph: "e.g. add a vampire faction in the south...",
|
||||||
|
editor_you: "You",
|
||||||
|
editor_pending_defn: "AI proposed a new definition.",
|
||||||
|
editor_apply: "Apply",
|
||||||
|
editor_reset: "Reset chat",
|
||||||
status_draft: "Draft",
|
status_draft: "Draft",
|
||||||
status_ready: "Ready",
|
status_ready: "Ready",
|
||||||
status_active: "Active",
|
status_active: "Active",
|
||||||
@@ -84,6 +92,7 @@ export const en = {
|
|||||||
no_messages: "Start with your first action!",
|
no_messages: "Start with your first action!",
|
||||||
new_session: "New session",
|
new_session: "New session",
|
||||||
error_iter: "Iteration failed",
|
error_iter: "Iteration failed",
|
||||||
|
retry: "Retry",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: "Admin panel",
|
title: "Admin panel",
|
||||||
@@ -106,6 +115,12 @@ export const en = {
|
|||||||
trigger_settings_desc: "Fire when in-world time advances (no polling)",
|
trigger_settings_desc: "Fire when in-world time advances (no polling)",
|
||||||
triggers_enabled: "Enabled",
|
triggers_enabled: "Enabled",
|
||||||
triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.",
|
triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.",
|
||||||
|
ui_settings: "UI customization",
|
||||||
|
ui_settings_desc: "Branding shown to all users (logo, favicon).",
|
||||||
|
ui_logo_url: "Logo URL",
|
||||||
|
ui_logo_url_hint:
|
||||||
|
"Path (e.g. /logo.png), full URL (https://.../logo.png), or data: URI. Default /logo.png is the bundled Mikan logo. Used in navbar, home page, and browser tab.",
|
||||||
|
ui_logo_preview: "Preview",
|
||||||
embedding_settings: "Embeddings (RAG)",
|
embedding_settings: "Embeddings (RAG)",
|
||||||
embedding_provider: "Provider",
|
embedding_provider: "Provider",
|
||||||
embedding_provider_hash: "Hash (offline fallback, no semantics)",
|
embedding_provider_hash: "Hash (offline fallback, no semantics)",
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export const ru = {
|
|||||||
send: "Отправить",
|
send: "Отправить",
|
||||||
accept: "Принять мир и создать",
|
accept: "Принять мир и создать",
|
||||||
accepting: "Создаём мир...",
|
accepting: "Создаём мир...",
|
||||||
|
editor_chat_title: "Чат с ИИ",
|
||||||
|
editor_chat_desc: "Опишите изменения — ИИ обновит определение мира.",
|
||||||
|
editor_chat_empty: "Пока пусто. Напишите, что изменить в мире.",
|
||||||
|
editor_chat_ph: "Например: добавь фракцию вампиров на юге...",
|
||||||
|
editor_you: "Вы",
|
||||||
|
editor_pending_defn: "ИИ предложил новое определение.",
|
||||||
|
editor_apply: "Применить",
|
||||||
|
editor_reset: "Сбросить диалог",
|
||||||
status_draft: "Черновик",
|
status_draft: "Черновик",
|
||||||
status_ready: "Готов",
|
status_ready: "Готов",
|
||||||
status_active: "Активен",
|
status_active: "Активен",
|
||||||
@@ -84,6 +92,7 @@ export const ru = {
|
|||||||
no_messages: "Начните с первого действия!",
|
no_messages: "Начните с первого действия!",
|
||||||
new_session: "Новая сессия",
|
new_session: "Новая сессия",
|
||||||
error_iter: "Ошибка при выполнении итерации",
|
error_iter: "Ошибка при выполнении итерации",
|
||||||
|
retry: "Повторить",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: "Панель администратора",
|
title: "Панель администратора",
|
||||||
@@ -106,6 +115,12 @@ export const ru = {
|
|||||||
trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)",
|
trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)",
|
||||||
triggers_enabled: "Включены",
|
triggers_enabled: "Включены",
|
||||||
triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.",
|
triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.",
|
||||||
|
ui_settings: "Настройки интерфейса",
|
||||||
|
ui_settings_desc: "Брендинг, видимый всем пользователям (логотип, favicon).",
|
||||||
|
ui_logo_url: "URL логотипа",
|
||||||
|
ui_logo_url_hint:
|
||||||
|
"Путь (напр. /logo.png), полный URL (https://.../logo.png) или data: URI. По умолчанию /logo.png — встроенный логотип Mikan. Используется в навбаре, на главной и во вкладке браузера.",
|
||||||
|
ui_logo_preview: "Превью",
|
||||||
embedding_settings: "Эмбеддинги (RAG)",
|
embedding_settings: "Эмбеддинги (RAG)",
|
||||||
embedding_provider: "Провайдер",
|
embedding_provider: "Провайдер",
|
||||||
embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",
|
embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { adminApi } from "@/api";
|
import { adminApi, uiApi } from "@/api";
|
||||||
import type { LlmLog, SettingsOut } from "@/types";
|
import type { LlmLog, SettingsOut } from "@/types";
|
||||||
|
import { useUiStore } from "@/store/ui";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||||
@@ -63,6 +64,12 @@ export function AdminPanelPage() {
|
|||||||
setValues(s.values);
|
setValues(s.values);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
// If the admin changed the logo URL, refresh the public-UI cache so
|
||||||
|
// the navbar/favicon update live without a full page reload.
|
||||||
|
if ("ui.logo_url" in payload) {
|
||||||
|
uiApi.resetCache();
|
||||||
|
await useUiStore.getState().load(true);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -441,6 +448,40 @@ export function AdminPanelPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title={t("admin.ui_settings")}
|
||||||
|
subtitle={t("admin.ui_settings_desc")}
|
||||||
|
/>
|
||||||
|
<CardBody className="space-y-3">
|
||||||
|
<Input
|
||||||
|
label={t("admin.ui_logo_url")}
|
||||||
|
value={values["ui.logo_url"] || ""}
|
||||||
|
onChange={(e) => setValues({ ...values, "ui.logo_url": e.target.value })}
|
||||||
|
placeholder="/logo.png"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-ink-500">{t("admin.ui_logo_url_hint")}</p>
|
||||||
|
{/* Live preview so the admin sees the configured logo before saving. */}
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<span className="text-xs text-ink-400">{t("admin.ui_logo_preview")}:</span>
|
||||||
|
<div className="w-10 h-10 rounded border border-ink-700 bg-ink-900 flex items-center justify-center overflow-hidden">
|
||||||
|
{values["ui.logo_url"] ? (
|
||||||
|
<img
|
||||||
|
src={values["ui.logo_url"]}
|
||||||
|
alt="preview"
|
||||||
|
className="w-8 h-8 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-ink-500">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{saved && <span className="text-sm text-green-400 self-center">{t("admin.saved")}</span>}
|
{saved && <span className="text-sm text-green-400 self-center">{t("admin.saved")}</span>}
|
||||||
<Button onClick={save} disabled={saving}>
|
<Button onClick={save} disabled={saving}>
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { useUiStore } from "@/store/ui";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
|
import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const logoUrl = useUiStore((s) => s.logoUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-12">
|
<div className="max-w-5xl mx-auto px-4 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent-500/10 border border-accent-500/30 mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent-500/10 border border-accent-500/30 mb-4 overflow-hidden">
|
||||||
<BookOpen className="text-accent-500" size={32} />
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="logo"
|
||||||
|
className="w-12 h-12 rounded object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fall back to the BookOpen icon if the configured logo URL
|
||||||
|
// fails to load (e.g. typo in admin settings, dead link).
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||||
|
const sib = (e.currentTarget as HTMLImageElement).nextElementSibling as HTMLElement | null;
|
||||||
|
if (sib) sib.style.display = "block";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<BookOpen
|
||||||
|
className="text-accent-500"
|
||||||
|
size={32}
|
||||||
|
style={{ display: logoUrl ? "none" : "block" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-serif font-bold text-ink-100 mb-3">{t("app.title")}</h1>
|
<h1 className="text-4xl font-serif font-bold text-ink-100 mb-3">{t("app.title")}</h1>
|
||||||
<p className="text-ink-400 max-w-2xl mx-auto">{t("app.subtitle")}</p>
|
<p className="text-ink-400 max-w-2xl mx-auto">{t("app.subtitle")}</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Card, CardBody } from "@/components/ui/Card";
|
|||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { GlossaryModal } from "@/components/world/GlossaryModal";
|
import { GlossaryModal } from "@/components/world/GlossaryModal";
|
||||||
import { CharacterSheet } from "@/components/world/CharacterSheet";
|
import { CharacterSheet } from "@/components/world/CharacterSheet";
|
||||||
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2 } from "lucide-react";
|
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2, RefreshCw } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
export function SessionPage() {
|
export function SessionPage() {
|
||||||
@@ -28,6 +28,7 @@ export function SessionPage() {
|
|||||||
const [iterating, setIterating] = useState(false);
|
const [iterating, setIterating] = useState(false);
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [lastFailedAction, setLastFailedAction] = useState<string | null>(null);
|
||||||
const [glossaryOpen, setGlossaryOpen] = useState(false);
|
const [glossaryOpen, setGlossaryOpen] = useState(false);
|
||||||
const [glossary, setGlossary] = useState<GlossaryEntry[]>([]);
|
const [glossary, setGlossary] = useState<GlossaryEntry[]>([]);
|
||||||
const [triggers, setTriggers] = useState<Trigger[]>([]);
|
const [triggers, setTriggers] = useState<Trigger[]>([]);
|
||||||
@@ -61,15 +62,92 @@ export function SessionPage() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages, status]);
|
}, [messages, status]);
|
||||||
|
|
||||||
const runIteration = async () => {
|
// Auto-generate intro scene for fresh sessions (no messages yet).
|
||||||
if (!id || !actionText.trim() || iterating) return;
|
const introTriggeredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || introTriggeredRef.current) return;
|
||||||
|
if (messages.length === 0 && !iterating && !error) {
|
||||||
|
introTriggeredRef.current = true;
|
||||||
|
runIntro();
|
||||||
|
}
|
||||||
|
}, [id, messages.length, iterating, error]);
|
||||||
|
|
||||||
|
const runIntro = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setIterating(true);
|
||||||
|
setStatus(t("session.status_writing_scene"));
|
||||||
setError("");
|
setError("");
|
||||||
|
try {
|
||||||
|
await fetchEventSource(`/api/sessions/${id}/intro`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ session_id: id }),
|
||||||
|
openWhenHidden: true,
|
||||||
|
onmessage(ev) {
|
||||||
|
const eventName = ev.event;
|
||||||
|
let data: any = {};
|
||||||
|
try { data = JSON.parse(ev.data || "{}"); } catch { data = {}; }
|
||||||
|
if (eventName === "status") {
|
||||||
|
const msg = data.message || "";
|
||||||
|
if (msg === "writing_scene") setStatus(t("session.status_writing_scene"));
|
||||||
|
else setStatus(msg);
|
||||||
|
} else if (eventName === "step_complete") {
|
||||||
|
const stepMsg: Message = {
|
||||||
|
id: data.message_id || `intro-${Date.now()}`,
|
||||||
|
seq: data.seq || 1,
|
||||||
|
role: "assistant",
|
||||||
|
kind: "narrative_step",
|
||||||
|
content: data.narrative || "",
|
||||||
|
payload: { options: data.options || [], world_time: data.world_time, kind: "intro" },
|
||||||
|
is_pinned: true,
|
||||||
|
hidden: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, stepMsg]);
|
||||||
|
if (world && data.world_time) {
|
||||||
|
setWorld({ ...world, current_time: data.world_time, state: data.state || world.state });
|
||||||
|
}
|
||||||
|
} else if (eventName === "error") {
|
||||||
|
setError(data.message || t("session.error_iter"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
setIterating(false);
|
||||||
|
setStatus("");
|
||||||
|
},
|
||||||
|
onerror(err) {
|
||||||
|
setIterating(false);
|
||||||
|
setStatus("");
|
||||||
|
setError(String(err) || t("session.error_iter"));
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setIterating(false);
|
||||||
|
setStatus("");
|
||||||
|
setError(err.message || t("session.error_iter"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runIteration = async (overrideAction?: string) => {
|
||||||
|
if (!id || iterating) return;
|
||||||
|
const rawAction = overrideAction ?? actionText;
|
||||||
|
if (!rawAction.trim()) return;
|
||||||
|
setError("");
|
||||||
|
setLastFailedAction(null);
|
||||||
setIterating(true);
|
setIterating(true);
|
||||||
setStatus(t("session.status_planning"));
|
setStatus(t("session.status_planning"));
|
||||||
const action = actionText.trim();
|
const action = rawAction.trim();
|
||||||
setActionText("");
|
if (overrideAction === undefined) setActionText("");
|
||||||
|
|
||||||
// Optimistic: show user action immediately
|
// Optimistic: show user action immediately (skip on retry if already shown)
|
||||||
|
const alreadyShown = messages.some(
|
||||||
|
(m) => m.kind === "player_action" && m.content === action && m.id?.startsWith("tmp-")
|
||||||
|
);
|
||||||
|
if (!alreadyShown) {
|
||||||
const optimisticUserMsg: Message = {
|
const optimisticUserMsg: Message = {
|
||||||
id: `tmp-${Date.now()}`,
|
id: `tmp-${Date.now()}`,
|
||||||
seq: messages.length + 1,
|
seq: messages.length + 1,
|
||||||
@@ -82,6 +160,7 @@ export function SessionPage() {
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, optimisticUserMsg]);
|
setMessages((prev) => [...prev, optimisticUserMsg]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchEventSource(`/api/sessions/${id}/iterate`, {
|
await fetchEventSource(`/api/sessions/${id}/iterate`, {
|
||||||
@@ -132,6 +211,7 @@ export function SessionPage() {
|
|||||||
}
|
}
|
||||||
} else if (eventName === "error") {
|
} else if (eventName === "error") {
|
||||||
setError(data.message || t("session.error_iter"));
|
setError(data.message || t("session.error_iter"));
|
||||||
|
setLastFailedAction(action);
|
||||||
} else if (eventName === "done") {
|
} else if (eventName === "done") {
|
||||||
// Final reload to get fresh seq/order
|
// Final reload to get fresh seq/order
|
||||||
load();
|
load();
|
||||||
@@ -147,6 +227,7 @@ export function SessionPage() {
|
|||||||
setIterating(false);
|
setIterating(false);
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setError(String(err) || t("session.error_iter"));
|
setError(String(err) || t("session.error_iter"));
|
||||||
|
setLastFailedAction(action);
|
||||||
throw err; // stop retry
|
throw err; // stop retry
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -237,6 +318,23 @@ export function SessionPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{lastFailedAction ? (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="flex-1 text-sm text-red-300 bg-red-950/40 border border-red-800/60 rounded-lg px-3 py-2">
|
||||||
|
{error || t("session.error_iter")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => runIteration(lastFailedAction)}
|
||||||
|
disabled={iterating}
|
||||||
|
className="self-end"
|
||||||
|
>
|
||||||
|
<Loader2 className={iterating ? "animate-spin mr-1" : "hidden"} size={14} />
|
||||||
|
{!iterating && <RefreshCw size={14} className="mr-1" />}
|
||||||
|
{iterating ? t("session.sending") : t("session.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={actionText}
|
value={actionText}
|
||||||
@@ -252,12 +350,13 @@ export function SessionPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={runIteration} disabled={iterating || !actionText.trim()} className="self-end">
|
<Button onClick={() => runIteration()} disabled={iterating || !actionText.trim()} className="self-end">
|
||||||
<Send size={14} className="mr-1" />
|
<Send size={14} className="mr-1" />
|
||||||
{iterating ? t("session.sending") : t("session.send")}
|
{iterating ? t("session.sending") : t("session.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
)}
|
||||||
|
{error && !lastFailedAction && <p className="text-xs text-red-400">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,54 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import axios from "axios";
|
||||||
import { worldsApi, sessionsApi } from "@/api";
|
import { worldsApi, sessionsApi } from "@/api";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
import type { World } from "@/types";
|
import type { World } from "@/types";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input, Textarea } from "@/components/ui/Input";
|
||||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||||
import { Play } from "lucide-react";
|
import { Play, Save, RotateCcw, Send, Sparkles, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface EditorChatMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatReply {
|
||||||
|
ai_message: string;
|
||||||
|
definition: Record<string, any> | null;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function WorldEditPage() {
|
export function WorldEditPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
const [world, setWorld] = useState<World | null>(null);
|
const [world, setWorld] = useState<World | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
const [definitionText, setDefinitionText] = useState("");
|
const [definitionText, setDefinitionText] = useState("");
|
||||||
const [stateText, setStateText] = useState("");
|
const [stateText, setStateText] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// AI chat state
|
||||||
|
const [chatMessages, setChatMessages] = useState<EditorChatMessage[]>([]);
|
||||||
|
const [chatInput, setChatInput] = useState("");
|
||||||
|
const [chatLoading, setChatLoading] = useState(false);
|
||||||
|
const [pendingDefinition, setPendingDefinition] = useState<Record<string, any> | null>(null);
|
||||||
|
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
const w = await worldsApi.get(id);
|
const w = await worldsApi.get(id);
|
||||||
setWorld(w);
|
setWorld(w);
|
||||||
|
setName(w.name);
|
||||||
setDefinitionText(JSON.stringify(w.definition, null, 2));
|
setDefinitionText(JSON.stringify(w.definition, null, 2));
|
||||||
setStateText(JSON.stringify(w.state, null, 2));
|
setStateText(JSON.stringify(w.state, null, 2));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -34,6 +59,10 @@ export function WorldEditPage() {
|
|||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatScrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [chatMessages, chatLoading]);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!id || !world) return;
|
if (!id || !world) return;
|
||||||
setError("");
|
setError("");
|
||||||
@@ -42,14 +71,18 @@ export function WorldEditPage() {
|
|||||||
const definition = JSON.parse(definitionText);
|
const definition = JSON.parse(definitionText);
|
||||||
const state = JSON.parse(stateText);
|
const state = JSON.parse(stateText);
|
||||||
const updated = await worldsApi.update(id, {
|
const updated = await worldsApi.update(id, {
|
||||||
|
name,
|
||||||
definition,
|
definition,
|
||||||
state,
|
state,
|
||||||
current_time: world.current_time,
|
current_time: world.current_time,
|
||||||
status: world.status === "draft" ? "ready" : world.status,
|
status: world.status === "draft" ? "ready" : world.status,
|
||||||
});
|
});
|
||||||
setWorld(updated);
|
setWorld(updated);
|
||||||
|
// Reset chat (definition changed -> stale context)
|
||||||
|
setChatMessages([]);
|
||||||
|
setPendingDefinition(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || t("errors.unknown"));
|
setError(err.message || err.response?.data?.detail || t("errors.unknown"));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -65,13 +98,58 @@ export function WorldEditPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendChatMessage = async () => {
|
||||||
|
if (!id || !chatInput.trim() || chatLoading) return;
|
||||||
|
const msg = chatInput.trim();
|
||||||
|
setChatInput("");
|
||||||
|
setChatMessages((prev) => [...prev, { role: "user", text: msg }]);
|
||||||
|
setChatLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<ChatReply>(
|
||||||
|
`/api/worlds/${id}/chat`,
|
||||||
|
{ message: msg },
|
||||||
|
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
setChatMessages((prev) => [...prev, { role: "assistant", text: data.ai_message }]);
|
||||||
|
if (data.definition) {
|
||||||
|
setPendingDefinition(data.definition);
|
||||||
|
setDefinitionText(JSON.stringify(data.definition, null, 2));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err.response?.data?.detail || err.message || t("errors.unknown");
|
||||||
|
setChatMessages((prev) => [...prev, { role: "assistant", text: `⚠️ ${detail}` }]);
|
||||||
|
} finally {
|
||||||
|
setChatLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetChat = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/worlds/${id}/chat/reset`, {}, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setChatMessages([]);
|
||||||
|
setPendingDefinition(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPendingDefinition = () => {
|
||||||
|
if (!pendingDefinition) return;
|
||||||
|
setDefinitionText(JSON.stringify(pendingDefinition, null, 2));
|
||||||
|
setPendingDefinition(null);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
if (loading) return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
||||||
if (!world) return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
|
if (!world) return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4 gap-2">
|
||||||
<h1 className="text-xl font-serif text-ink-100">
|
<h1 className="text-xl font-serif text-ink-100 flex-1 min-w-0 truncate">
|
||||||
{t("worlds.edit")}: {world.name}
|
{t("worlds.edit")}: {world.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -87,14 +165,115 @@ export function WorldEditPage() {
|
|||||||
|
|
||||||
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
{/* World name row */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader title={t("worlds.name")} />
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={save} disabled={saving || !name.trim()}>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin mr-1" /> : <Save size={14} className="mr-1" />}
|
||||||
|
{saving ? t("common.loading") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* AI chat panel (1 col) */}
|
||||||
|
<Card className="lg:col-span-1 flex flex-col">
|
||||||
|
<CardHeader
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Sparkles size={14} className="text-accent-400" />
|
||||||
|
{t("worlds.editor_chat_title")}
|
||||||
|
</span> as any
|
||||||
|
}
|
||||||
|
subtitle={t("worlds.editor_chat_desc")}
|
||||||
|
/>
|
||||||
|
<CardBody className="flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 mb-3 max-h-[55vh]">
|
||||||
|
{chatMessages.length === 0 && (
|
||||||
|
<div className="text-xs text-ink-500 italic text-center py-6">
|
||||||
|
{t("worlds.editor_chat_empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chatMessages.map((m, i) => (
|
||||||
|
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[90%] rounded-xl p-2.5 text-xs whitespace-pre-wrap ${
|
||||||
|
m.role === "user"
|
||||||
|
? "bg-accent-500/20 border border-accent-500/40 text-ink-100"
|
||||||
|
: "bg-ink-800 border border-ink-700 text-ink-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-ink-400 mb-1">
|
||||||
|
{m.role === "user" ? t("worlds.editor_you") : "AI"}
|
||||||
|
</div>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{chatLoading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-ink-800 border border-ink-700 rounded-xl p-2.5 text-ink-400 text-xs pulse-soft flex items-center gap-2">
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={chatScrollRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingDefinition && (
|
||||||
|
<div className="mb-3 p-2 rounded-lg bg-accent-500/10 border border-accent-500/40 text-xs text-accent-200 flex items-center justify-between gap-2">
|
||||||
|
<span>{t("worlds.editor_pending_defn")}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={applyPendingDefinition}>
|
||||||
|
{t("worlds.editor_apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={chatInput}
|
||||||
|
onChange={(e) => setChatInput(e.target.value)}
|
||||||
|
placeholder={t("worlds.editor_chat_ph")}
|
||||||
|
disabled={chatLoading}
|
||||||
|
rows={2}
|
||||||
|
className="min-h-[60px] flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendChatMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button size="sm" onClick={sendChatMessage} disabled={chatLoading || !chatInput.trim()}>
|
||||||
|
<Send size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={resetChat} disabled={chatLoading} title={t("worlds.editor_reset")}>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* JSON editors (2 cols) */}
|
||||||
|
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="World definition (JSON)" subtitle="setting, rules, schema, plot_rails, initial_state" />
|
<CardHeader title="World definition (JSON)" subtitle="setting, rules, schema, plot_rails, initial_state" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<textarea
|
<textarea
|
||||||
value={definitionText}
|
value={definitionText}
|
||||||
onChange={(e) => setDefinitionText(e.target.value)}
|
onChange={(e) => setDefinitionText(e.target.value)}
|
||||||
className="w-full h-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
className="w-full h-[55vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
@@ -105,12 +284,13 @@ export function WorldEditPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={stateText}
|
value={stateText}
|
||||||
onChange={(e) => setStateText(e.target.value)}
|
onChange={(e) => setStateText(e.target.value)}
|
||||||
className="w-full h-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
className="w-full h-[55vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||||
|
|||||||
38
frontend/src/store/ui.ts
Normal file
38
frontend/src/store/ui.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { uiApi, type PublicUiSettings } from "@/api";
|
||||||
|
|
||||||
|
interface UiState {
|
||||||
|
/** Logo URL (or path) to show in navbar, home page, and favicon. */
|
||||||
|
logoUrl: string;
|
||||||
|
/** True while the public UI settings are being fetched for the first time. */
|
||||||
|
loading: boolean;
|
||||||
|
/** Loads public UI settings from /api/settings/public (cached in api layer). */
|
||||||
|
load: (force?: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOGO_URL = "/logo.png";
|
||||||
|
|
||||||
|
export const useUiStore = create<UiState>((set) => ({
|
||||||
|
logoUrl: DEFAULT_LOGO_URL,
|
||||||
|
loading: false,
|
||||||
|
load: async (force = false) => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const s: PublicUiSettings = await uiApi.getPublicSettings(force);
|
||||||
|
const next = s.logo_url || DEFAULT_LOGO_URL;
|
||||||
|
set({ logoUrl: next, loading: false });
|
||||||
|
// Dynamically update the document favicon so a custom logo is reflected
|
||||||
|
// in the browser tab without a page reload.
|
||||||
|
try {
|
||||||
|
const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
if (existing && existing.href !== next) {
|
||||||
|
existing.href = next;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — DOM might not be ready during SSR/early hydration
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
set({ logoUrl: DEFAULT_LOGO_URL, loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
20
frontend/tsconfig.check.json
Normal file
20
frontend/tsconfig.check.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] },
|
||||||
|
"typeRoots": ["/tmp/ts-check/node_modules/@types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user