fix
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Glossary + Triggers routes."""
|
||||
"""Glossary + Triggers + public UI settings routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from typing import Any, Dict, List
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -11,12 +11,37 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.db import get_db_dep
|
||||
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
|
||||
|
||||
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])
|
||||
async def list_glossary(
|
||||
world_id: UUID,
|
||||
|
||||
@@ -14,7 +14,7 @@ from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.db import get_db_dep
|
||||
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.schemas import IterationRequest, MessageOut, SessionCreate, SessionOut
|
||||
|
||||
@@ -142,6 +142,35 @@ async def iterate_session(
|
||||
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)
|
||||
async def delete_session(
|
||||
session_id: UUID,
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.db import get_db_dep
|
||||
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_editor import edit_world_via_chat, reset_editor_dialogue
|
||||
from app.models import User, World
|
||||
from app.schemas import (
|
||||
WorldBuilderCommit,
|
||||
@@ -19,6 +20,8 @@ from app.schemas import (
|
||||
WorldBuilderReply,
|
||||
WorldBuilderStart,
|
||||
WorldCreate,
|
||||
WorldEditorChatReply,
|
||||
WorldEditorChatRequest,
|
||||
WorldOut,
|
||||
WorldUpdate,
|
||||
)
|
||||
@@ -159,3 +162,56 @@ async def builder_commit(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as 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")
|
||||
|
||||
|
||||
# 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:
|
||||
"""Non-streaming response wrapper."""
|
||||
|
||||
@@ -93,7 +124,7 @@ class LlmClient:
|
||||
data = resp.json()
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
msg = choice.get("message", {})
|
||||
text = msg.get("content") or ""
|
||||
text = _clean_model_text(msg.get("content") or "")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
usage = data.get("usage") or {}
|
||||
except httpx.ConnectError as e:
|
||||
@@ -206,8 +237,10 @@ class LlmClient:
|
||||
continue
|
||||
delta = choices[0].get("delta", {})
|
||||
if delta.get("content"):
|
||||
full_text_parts.append(delta["content"])
|
||||
yield {"type": "delta", "content": delta["content"]}
|
||||
piece = _clean_model_text(delta["content"])
|
||||
if piece:
|
||||
full_text_parts.append(piece)
|
||||
yield {"type": "delta", "content": piece}
|
||||
if delta.get("tool_calls"):
|
||||
for tc in delta["tool_calls"]:
|
||||
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.dim": int, # vector dimension; 0 = auto-probe from endpoint
|
||||
"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,21 +72,39 @@ async def run_iteration(
|
||||
settings_map = await get_all_settings(db)
|
||||
llm = LlmClient(settings_map)
|
||||
|
||||
# Save the player's action as a message
|
||||
next_seq = await _next_seq(db, session_id)
|
||||
player_msg = Message(
|
||||
session_id=session_id,
|
||||
seq=next_seq,
|
||||
role="user",
|
||||
kind="player_action",
|
||||
content=action_text,
|
||||
payload={},
|
||||
is_pinned=True,
|
||||
hidden=False,
|
||||
# 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)
|
||||
)
|
||||
db.add(player_msg)
|
||||
await db.commit()
|
||||
await db.refresh(player_msg)
|
||||
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)
|
||||
player_msg = Message(
|
||||
session_id=session_id,
|
||||
seq=next_seq,
|
||||
role="user",
|
||||
kind="player_action",
|
||||
content=action_text,
|
||||
payload={},
|
||||
is_pinned=True,
|
||||
hidden=False,
|
||||
)
|
||||
db.add(player_msg)
|
||||
await db.commit()
|
||||
await db.refresh(player_msg)
|
||||
|
||||
yield {"type": "status", "data": {"message": "planning"}}
|
||||
|
||||
@@ -476,3 +494,122 @@ def _safe_parse_json(s: str) -> Any:
|
||||
return json.loads(s) if s else {}
|
||||
except Exception:
|
||||
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.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"),
|
||||
# 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."""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# === 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 = {
|
||||
"en": {
|
||||
"world_builder": WORLD_BUILDER_SYSTEM,
|
||||
@@ -141,6 +170,7 @@ PROMPTS = {
|
||||
"summarizer": SUMMARIZER_SYSTEM,
|
||||
"subagent": SUBAGENT_SYSTEM,
|
||||
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
|
||||
"intro_scene": INTRO_SCENE_SYSTEM,
|
||||
},
|
||||
# Russian keys kept for backward compatibility but always return the English
|
||||
# prompts — system content is always English per the project convention.
|
||||
@@ -151,6 +181,7 @@ PROMPTS = {
|
||||
"summarizer": SUMMARIZER_SYSTEM,
|
||||
"subagent": SUBAGENT_SYSTEM,
|
||||
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
|
||||
"intro_scene": INTRO_SCENE_SYSTEM,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -237,3 +237,15 @@ class TriggerCreate(BaseModel):
|
||||
fire_at: str
|
||||
description: str
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user