Fixed RPG

This commit is contained in:
2026-05-29 08:52:33 +03:00
parent e5c0df308f
commit 600ad78f05
24 changed files with 2804 additions and 144 deletions
+99 -2
View File
@@ -13,7 +13,13 @@ async def init_db():
persona_id TEXT DEFAULT 'default', persona_id TEXT DEFAULT 'default',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
title TEXT DEFAULT 'Новый чат' title TEXT DEFAULT 'Новый чат',
rpg_enabled INTEGER DEFAULT 0,
facts_json TEXT DEFAULT '[]',
global_plot TEXT DEFAULT '',
status_quo TEXT DEFAULT '',
plot_arc_json TEXT DEFAULT '{}',
rng_seed INTEGER
); );
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -38,7 +44,13 @@ async def init_db():
sd_enabled INTEGER DEFAULT 0, sd_enabled INTEGER DEFAULT 0,
lora_name TEXT DEFAULT '', lora_name TEXT DEFAULT '',
lora_weight REAL DEFAULT 0.8, lora_weight REAL DEFAULT 0.8,
appearance_tags TEXT DEFAULT '' appearance_tags TEXT DEFAULT '',
personality TEXT DEFAULT '',
scenario TEXT DEFAULT '',
first_mes TEXT DEFAULT '',
mes_example TEXT DEFAULT '',
lorebook_json TEXT DEFAULT '[]',
avatar_path TEXT DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS characters ( CREATE TABLE IF NOT EXISTS characters (
@@ -54,10 +66,16 @@ async def init_db():
lora_weight REAL DEFAULT 0.8, lora_weight REAL DEFAULT 0.8,
appearance_tags TEXT DEFAULT '', appearance_tags TEXT DEFAULT '',
lorebook_json TEXT DEFAULT '[]', lorebook_json TEXT DEFAULT '[]',
avatar_path TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
""") """)
await _migrate_messages_columns(db) await _migrate_messages_columns(db)
await _migrate_personas_columns(db)
await _migrate_sessions_columns(db)
await _migrate_characters_columns(db)
await _migrate_rpg_quests(db)
await _migrate_action_resolutions(db)
await db.commit() await db.commit()
@@ -68,3 +86,82 @@ async def _migrate_messages_columns(db):
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt TEXT") await db.execute("ALTER TABLE messages ADD COLUMN image_prompt TEXT")
if "image_path" not in cols: if "image_path" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_path TEXT") await db.execute("ALTER TABLE messages ADD COLUMN image_path TEXT")
async def _migrate_personas_columns(db):
async with db.execute("PRAGMA table_info(personas)") as cur:
cols = {row[1] for row in await cur.fetchall()}
if "personality" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN personality TEXT DEFAULT ''")
if "scenario" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN scenario TEXT DEFAULT ''")
if "first_mes" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN first_mes TEXT DEFAULT ''")
if "mes_example" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN mes_example TEXT DEFAULT ''")
if "lorebook_json" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN lorebook_json TEXT DEFAULT '[]'")
if "avatar_path" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN avatar_path TEXT DEFAULT ''")
async def _migrate_sessions_columns(db):
async with db.execute("PRAGMA table_info(sessions)") as cur:
cols = {row[1] for row in await cur.fetchall()}
if "rpg_enabled" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN rpg_enabled INTEGER DEFAULT 0")
if "facts_json" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN facts_json TEXT DEFAULT '[]'")
if "global_plot" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN global_plot TEXT DEFAULT ''")
if "status_quo" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN status_quo TEXT DEFAULT ''")
if "plot_arc_json" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN plot_arc_json TEXT DEFAULT '{}'")
if "rng_seed" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN rng_seed INTEGER")
if "affinity" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN affinity INTEGER DEFAULT 0")
if "genre" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN genre TEXT DEFAULT 'adventure'")
if "rpg_settings_json" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN rpg_settings_json TEXT DEFAULT '{}'")
async def _migrate_rpg_quests(db):
await db.executescript("""
CREATE TABLE IF NOT EXISTS rpg_quests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rpg_quests_session ON rpg_quests(session_id);
""")
async def _migrate_action_resolutions(db):
await db.executescript(
"""
CREATE TABLE IF NOT EXISTS action_resolutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
message_id INTEGER,
intent_text TEXT NOT NULL,
roll INTEGER NOT NULL,
outcome TEXT NOT NULL,
resolution_text TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_action_resolutions_session
ON action_resolutions(session_id);
"""
)
async def _migrate_characters_columns(db):
async with db.execute("PRAGMA table_info(characters)") as cur:
cols = {row[1] for row in await cur.fetchall()}
if "avatar_path" not in cols:
await db.execute("ALTER TABLE characters ADD COLUMN avatar_path TEXT DEFAULT ''")
+30 -1
View File
@@ -5,6 +5,23 @@ class ChatRequest(BaseModel):
message: str message: str
session_id: str session_id: str
persona_id: Optional[str] = "default" persona_id: Optional[str] = "default"
is_narrator_choice: bool = False
skip_user_add: bool = False
class MessageEditRequest(BaseModel):
content: str
truncate_after: bool = False
class RegenerateRequest(BaseModel):
session_id: str
persona_id: Optional[str] = "default"
message_id: Optional[int] = None
class ForkSessionRequest(BaseModel):
until_message_id: int
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
reply: str reply: str
@@ -16,11 +33,17 @@ class PersonaCreate(BaseModel):
name: str name: str
emoji: str = "🤖" emoji: str = "🤖"
description: str = "" description: str = ""
prompt: str prompt: str = ""
sd_enabled: bool = False sd_enabled: bool = False
lora_name: str = "" lora_name: str = ""
lora_weight: float = 0.8 lora_weight: float = 0.8
appearance_tags: str = "" appearance_tags: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
lorebook_json: str = "[]"
avatar_path: str = ""
class PersonaResponse(BaseModel): class PersonaResponse(BaseModel):
persona_id: str persona_id: str
@@ -33,3 +56,9 @@ class PersonaResponse(BaseModel):
lora_name: str = "" lora_name: str = ""
lora_weight: float = 0.8 lora_weight: float = 0.8
appearance_tags: str = "" appearance_tags: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
lorebook_json: str = "[]"
avatar_path: str = ""
+17
View File
@@ -56,6 +56,23 @@ async def patch_card(card_id: str, body: CardPatch):
return await get_character(card_id) return await get_character(card_id)
@router.post("/{card_id}/avatar")
async def upload_avatar(card_id: str, file: UploadFile = File(...)):
card = await get_character(card_id)
if not card:
raise HTTPException(status_code=404, detail="Карточка не найдена")
content = await file.read()
if not content.startswith(b"\x89PNG"):
raise HTTPException(status_code=400, detail="Нужен PNG")
from services.character_card import _save_avatar_bytes
rel = _save_avatar_bytes(content, f"card_{card_id}")
await update_character(card_id, {"avatar_path": rel})
# sync persona
from services.personas import patch_persona
await patch_persona(f"card_{card_id}", {"avatar_path": rel})
return {"avatar_path": f"/static/{rel}"}
@router.post("/import") @router.post("/import")
async def import_card( async def import_card(
file: UploadFile = File(...), file: UploadFile = File(...),
+372 -10
View File
@@ -1,22 +1,38 @@
import json import json
import os import os
import random
import aiosqlite import aiosqlite
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from database.db import DB_PATH from database.db import DB_PATH
from models.schemas import ChatRequest, ChatResponse from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest
from services.llm import send_message, stream_message from services.llm import send_message, stream_message
from services.memory import ( from services.memory import (
get_history, get_history,
add_message, add_message,
clear_history, clear_history,
get_or_create_session, get_or_create_session,
get_session,
update_session_title, update_session_title,
update_session_persona,
get_message_count, get_message_count,
get_last_assistant_message_id, get_last_assistant_message_id,
update_message_image, update_message_image,
update_session_facts,
update_session_status_quo,
update_session_affinity,
update_session_genre,
update_session_rpg_settings,
upsert_quest,
get_quests,
add_action_resolution,
get_message,
update_message_content,
delete_messages_after,
delete_message,
) )
from services.personas import get_persona from services.personas import get_persona
from services.sd_prompt import ( from services.sd_prompt import (
@@ -27,12 +43,51 @@ from services.sd_prompt import (
from services.lorebook import get_lorebook_context from services.lorebook import get_lorebook_context
from services.character_card import get_character from services.character_card import get_character
from services import sdbackend as sd_service from services import sdbackend as sd_service
from services.rpg_facts import extract_facts, merge_facts, facts_to_prompt
from services.rpg_plot import generate_plot_arc, should_advance_arc, pop_matching_beats
from services.rpg_narrator import narrator_pre, narrator_post
router = APIRouter(prefix="/chat", tags=["chat"]) router = APIRouter(prefix="/chat", tags=["chat"])
DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу." DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу."
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes") SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
def affinity_prompt_block(affinity: int) -> str:
if affinity >= 10:
tone = "very warm, trusting, affectionate"
elif affinity >= 5:
tone = "friendly and open"
elif affinity >= 1:
tone = "slightly positive"
elif affinity <= -5:
tone = "hostile or deeply distrustful"
elif affinity <= -1:
tone = "cold and wary"
else:
tone = "neutral"
return (
f"\n\n--- Relationship ---\n"
f"Affinity toward player: {affinity} ({tone}). "
f"Reflect this in your attitude and word choice.\n---"
)
DEFAULT_RPG_SETTINGS = {
"dice": True,
"narrator": True,
"quests": True,
"affinity": True,
"choices": True,
}
def get_rpg_settings(session: dict) -> dict:
try:
s = json.loads(session.get("rpg_settings_json") or "{}")
return {**DEFAULT_RPG_SETTINGS, **s}
except Exception:
return DEFAULT_RPG_SETTINGS
async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str: async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str:
persona = await get_persona(persona_id) persona = await get_persona(persona_id)
@@ -41,11 +96,17 @@ async def get_system_prompt(persona_id: str, history: list, user_message: str =
prompt = persona["prompt"] prompt = persona["prompt"]
if persona.get("lorebook_json"):
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
if lore:
prompt = prompt + "\n\n" + lore
if persona_id.startswith("card_"): if persona_id.startswith("card_"):
card_id = persona_id[5:] card_id = persona_id[5:]
card = await get_character(card_id) card = await get_character(card_id)
if card: if card:
# match lorebook against recent context + current message
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:] recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}] context = recent + [{"role": "user", "content": user_message}]
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context) lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
@@ -60,20 +121,37 @@ async def get_chat_history(session_id: str):
return await get_history(session_id) return await get_history(session_id)
@router.get("/system/{session_id}")
async def get_system_blob(session_id: str):
history = await get_history(session_id)
system_msg = next((m for m in history if m.get("role") == "system"), None)
session = await get_session(session_id)
return {
"system_prompt": system_msg.get("content") if system_msg else "",
"facts_json": session.get("facts_json") if session else "[]",
"status_quo": session.get("status_quo") if session else "",
"plot_arc_json": session.get("plot_arc_json") if session else "{}",
"rpg_enabled": bool(session.get("rpg_enabled")) if session else False,
}
@router.post("/init") @router.post("/init")
async def init_chat(request: ChatRequest): async def init_chat(request: ChatRequest):
"""Called when opening a new chat. Seeds system prompt and first_mes if card persona."""
persona_id = request.persona_id or "default" persona_id = request.persona_id or "default"
await get_or_create_session(request.session_id, persona_id) await get_or_create_session(request.session_id, persona_id)
history = await get_history(request.session_id) history = await get_history(request.session_id)
if history: if history:
return {"first_mes": None} # already initialized return {"first_mes": None}
system_prompt = await get_system_prompt(persona_id, [], "") system_prompt = await get_system_prompt(persona_id, [], "")
await add_message(request.session_id, "system", system_prompt) await add_message(request.session_id, "system", system_prompt)
first_mes = None first_mes = None
if persona_id.startswith("card_"): persona = await get_persona(persona_id)
if persona and persona.get("first_mes"):
first_mes = persona["first_mes"]
await add_message(request.session_id, "assistant", first_mes)
elif persona_id.startswith("card_"):
card = await get_character(persona_id[5:]) card = await get_character(persona_id[5:])
if card and card.get("first_mes"): if card and card.get("first_mes"):
first_mes = card["first_mes"] first_mes = card["first_mes"]
@@ -82,6 +160,50 @@ async def init_chat(request: ChatRequest):
return {"first_mes": first_mes} return {"first_mes": first_mes}
class RpgBootstrapRequest(BaseModel):
session_id: str
persona_id: str = "default"
genre: str = "adventure"
@router.post("/rpg/bootstrap")
async def rpg_bootstrap(req: RpgBootstrapRequest):
await get_or_create_session(req.session_id, req.persona_id)
session = await get_session(req.session_id)
persona = await get_persona(req.persona_id) or {}
# Save genre
await update_session_genre(req.session_id, req.genre)
arc_json = (session.get("plot_arc_json") or "{}") if session else "{}"
try:
arc = json.loads(arc_json) if isinstance(arc_json, str) else {}
except Exception:
arc = {}
if not arc:
facts_block = facts_to_prompt((session or {}).get("facts_json", "[]"))
arc = await generate_plot_arc(
persona.get("name", req.persona_id),
persona.get("description", ""),
persona.get("scenario", ""),
persona.get("first_mes", ""),
facts_block=facts_block,
genre=req.genre,
)
if arc:
from services.memory import update_session_plot_arc
await update_session_plot_arc(req.session_id, json.dumps(arc, ensure_ascii=False))
# Seed quests from beats
for beat in arc.get("beats", []):
injection = beat.get("injection", "").strip()
if injection:
await upsert_quest(req.session_id, injection[:120])
quests = await get_quests(req.session_id)
return {"plot_arc": arc, "quests": quests}
@router.post("/stream") @router.post("/stream")
async def chat_stream(request: ChatRequest): async def chat_stream(request: ChatRequest):
persona_id = request.persona_id or "default" persona_id = request.persona_id or "default"
@@ -89,8 +211,114 @@ async def chat_stream(request: ChatRequest):
await get_or_create_session(request.session_id, persona_id) await get_or_create_session(request.session_id, persona_id)
history = await get_history(request.session_id) history = await get_history(request.session_id)
session = await get_session(request.session_id)
system_prompt = await get_system_prompt(persona_id, history, request.message) system_prompt = await get_system_prompt(persona_id, history, request.message)
arc = {}
roll = None
outcome = None
resolution_text = ""
narrator_msg = None # shown as narrator bubble before assistant reply
rpg_settings = {}
if session and session.get("rpg_enabled"):
rpg_settings = get_rpg_settings(session)
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
if facts_block:
system_prompt = system_prompt + "\n\n" + facts_block
try:
arc = json.loads(session.get("plot_arc_json") or "{}")
except Exception:
arc = {}
if arc:
system_prompt = system_prompt + "\n\n--- PlotArc ---\n" + json.dumps(
{k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False
) + "\n---"
status_quo = (session.get("status_quo") or "").strip()
if status_quo:
system_prompt = system_prompt + "\n\n--- Status quo ---\n" + status_quo + "\n---"
if rpg_settings.get("affinity", True):
aff = int(session.get("affinity") or 0)
system_prompt = system_prompt + affinity_prompt_block(aff)
if rpg_settings.get("narrator", True):
persona = await get_persona(persona_id) or {}
recent_txt = "\n".join(
f"{m['role']}: {m['content']}" for m in history[-8:]
if m.get("role") in ("user", "assistant")
)
# Phase 1: ask narrator if check is needed (no roll yet)
pre = await narrator_pre(
persona.get("name", persona_id),
recent_txt,
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_block,
request.message,
)
needs_check = pre.get("needs_check", False) and rpg_settings.get("dice", True)
if needs_check:
# Phase 2: roll and get resolution
roll = random.randint(1, 20)
if roll == 1:
outcome = "critical failure"
elif roll <= 8:
outcome = "failure"
elif roll >= 20:
outcome = "critical success"
else:
outcome = "success"
pre2 = await narrator_pre(
persona.get("name", persona_id),
recent_txt,
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_block,
request.message,
roll=roll,
outcome=outcome,
)
resolution_text = (pre2.get("resolution_text") or "").strip()
directives = pre2.get("directives") or []
pre_sq = (pre2.get("status_quo_update") or "").strip()
else:
directives = pre.get("directives") or []
pre_sq = (pre.get("status_quo_update") or "").strip()
if directives:
system_prompt = system_prompt + "\n\n--- Narrator directives ---\n" + "\n".join(f"- {d}" for d in directives) + "\n---"
if pre_sq:
await update_session_status_quo(request.session_id, pre_sq)
session["status_quo"] = pre_sq
if resolution_text:
await add_action_resolution(
request.session_id,
intent_text=request.message,
roll=roll,
outcome=outcome,
resolution_text=resolution_text,
message_id=None,
)
narrator_msg = {"roll": roll, "outcome": outcome, "text": resolution_text}
# Inject outcome into system prompt so character reply is consistent
if roll is not None:
system_prompt = (
system_prompt
+ f"\n\n--- Mechanics ---\n"
+ f"Roll d20={roll}. Outcome: {outcome}.\n"
+ "Your reply MUST be consistent with this outcome. Do NOT contradict the narrator resolution.\n"
+ "---"
)
# is_narrator_choice: wrap message so LLM understands context
user_message_content = request.message
if request.is_narrator_choice:
user_message_content = f"[Player chose: {request.message}]"
if not history: if not history:
await add_message(request.session_id, "system", system_prompt) await add_message(request.session_id, "system", system_prompt)
elif history[0]["role"] == "system" and history[0]["content"] != system_prompt: elif history[0]["role"] == "system" and history[0]["content"] != system_prompt:
@@ -103,12 +331,14 @@ async def chat_stream(request: ChatRequest):
) )
await db.commit() await db.commit()
await add_message(request.session_id, "user", request.message) if not request.skip_user_add:
await add_message(request.session_id, "user", user_message_content)
messages = await get_history(request.session_id) messages = await get_history(request.session_id)
full_reply = [] full_reply = []
async def generate(): async def generate():
nonlocal arc
async for chunk in stream_message( async for chunk in stream_message(
[{"role": m["role"], "content": m["content"]} for m in messages] [{"role": m["role"], "content": m["content"]} for m in messages]
): ):
@@ -133,10 +363,97 @@ async def chat_stream(request: ChatRequest):
image_prompt=prompt_str, image_prompt=prompt_str,
) )
choices = []
debug_blocks = []
quests_updated = []
if session and session.get("rpg_enabled"):
if not arc:
persona = await get_persona(persona_id) or {}
genre = (session.get("genre") or "adventure")
arc = await generate_plot_arc(
persona.get("name", persona_id),
persona.get("description", ""),
persona.get("scenario", ""),
persona.get("first_mes", ""),
facts_block=facts_to_prompt(session.get("facts_json", "[]")),
genre=genre,
)
if arc:
from services.memory import update_session_plot_arc
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
debug_blocks.append({"type": "plot_arc", "text": json.dumps(arc, ensure_ascii=False, indent=2)})
if rpg_settings.get("quests", True):
for beat in arc.get("beats", []):
inj = beat.get("injection", "").strip()
if inj:
await upsert_quest(request.session_id, inj[:120])
trig = should_advance_arc(request.message)
if trig and arc:
arc, beats = pop_matching_beats(arc, trig, max_beats=1)
if beats:
from services.memory import update_session_plot_arc
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
inj = beats[0].get("injection", "")
if inj:
debug_blocks.append({"type": "narrator_injection", "text": inj})
if rpg_settings.get("choices", True):
beat_choices = beats[0].get("choices") or []
if beat_choices:
choices = choices + beat_choices
ctx = [m for m in (await get_history(request.session_id)) if m["role"] in ("user", "assistant")][-10:]
new_facts = await extract_facts(ctx)
if new_facts:
merged = merge_facts(session.get("facts_json", "[]"), new_facts)
await update_session_facts(request.session_id, merged)
session["facts_json"] = merged
debug_blocks.append({"type": "facts", "text": facts_to_prompt(merged)})
persona = await get_persona(persona_id) or {}
ctx_txt = "\n".join(
f"{m['role']}: {m['content']}" for m in ctx[-8:]
if m.get("role") in ("user", "assistant")
)
post = await narrator_post(
persona.get("name", persona_id),
ctx_txt,
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_to_prompt(session.get("facts_json", "[]")),
)
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(request.session_id, sq)
session["status_quo"] = sq
debug_blocks.append({"type": "status_quo", "text": f"--- Status quo ---\n{sq}\n---"})
if rpg_settings.get("choices", True):
extra_choices = post.get("choices") or []
if extra_choices:
choices = choices + extra_choices
if rpg_settings.get("affinity", True):
delta = int(post.get("affinity_delta") or 0)
if delta:
await update_session_affinity(request.session_id, delta)
if rpg_settings.get("quests", True):
for qu in (post.get("quest_updates") or []):
title = (qu.get("title") or "").strip()
status = qu.get("status", "active")
if title:
await upsert_quest(request.session_id, title[:120], status)
quests_updated = await get_quests(request.session_id)
count = await get_message_count(request.session_id) count = await get_message_count(request.session_id)
if count == 2: if count == 2 and not request.skip_user_add:
title = request.message[:40] + ("" if len(request.message) > 40 else "") persona = await get_persona(persona_id) or {}
await update_session_title(request.session_id, title) persona_name = persona.get("name", persona_id)
preview = request.message[:40] + ("" if len(request.message) > 40 else "")
current = (session or {}).get("title") or "Новый чат"
if current in ("", "Новый чат"):
await update_session_title(request.session_id, f"{persona_name}{preview}")
image_path = None image_path = None
image_error = None image_error = None
@@ -150,11 +467,20 @@ async def chat_stream(request: ChatRequest):
else: else:
image_error = err image_error = err
# Fetch current affinity for UI
updated_session = await get_session(request.session_id)
affinity = updated_session.get("affinity", 0) if updated_session else 0
yield f"data: {json.dumps({ yield f"data: {json.dumps({
'done': True, 'done': True,
'image_prompt': prompt_str, 'image_prompt': prompt_str,
'image_path': f'/static/{image_path}' if image_path else None, 'image_path': f'/static/{image_path}' if image_path else None,
'image_error': image_error, 'image_error': image_error,
'choices': choices,
'debug': debug_blocks,
'narrator': narrator_msg,
'affinity': affinity,
'quests': quests_updated,
})}\n\n" })}\n\n"
return StreamingResponse( return StreamingResponse(
@@ -193,6 +519,42 @@ async def chat(request: ChatRequest):
) )
@router.patch("/messages/{message_id}")
async def edit_message(message_id: int, req: MessageEditRequest):
msg = await get_message(message_id)
if not msg:
raise HTTPException(status_code=404, detail="Сообщение не найдено")
await update_message_content(message_id, req.content)
if req.truncate_after:
await delete_messages_after(msg["session_id"], message_id)
return {"status": "updated", "message_id": message_id}
@router.post("/regenerate")
async def regenerate_chat(req: RegenerateRequest):
msg_id = req.message_id or await get_last_assistant_message_id(req.session_id)
if not msg_id:
raise HTTPException(status_code=400, detail="Нет сообщения для перегенерации")
msg = await get_message(msg_id)
if not msg or msg.get("role") != "assistant":
raise HTTPException(status_code=400, detail="Неверное сообщение")
await delete_message(msg_id)
history = await get_history(req.session_id)
last_user = next((m for m in reversed(history) if m["role"] == "user"), None)
if not last_user:
raise HTTPException(status_code=400, detail="Нет сообщения пользователя")
user_text = last_user["content"]
if user_text.startswith("[Player chose: ") and user_text.endswith("]"):
user_text = user_text[15:-1]
stream_req = ChatRequest(
message=user_text,
session_id=req.session_id,
persona_id=req.persona_id,
skip_user_add=True,
)
return await chat_stream(stream_req)
@router.delete("/{session_id}") @router.delete("/{session_id}")
async def clear_chat(session_id: str): async def clear_chat(session_id: str):
await clear_history(session_id) await clear_history(session_id)
+70 -2
View File
@@ -1,6 +1,14 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, File, UploadFile
from pydantic import BaseModel
from typing import Optional
from models.schemas import PersonaCreate from models.schemas import PersonaCreate
from services.personas import get_all_personas, get_persona, create_persona, delete_persona from services.personas import (
get_all_personas,
get_persona,
create_persona,
delete_persona,
patch_persona,
)
router = APIRouter(prefix="/personas", tags=["personas"]) router = APIRouter(prefix="/personas", tags=["personas"])
@@ -31,10 +39,70 @@ async def create_new_persona(data: PersonaCreate):
lora_name=data.lora_name, lora_name=data.lora_name,
lora_weight=data.lora_weight, lora_weight=data.lora_weight,
appearance_tags=data.appearance_tags, appearance_tags=data.appearance_tags,
personality=data.personality,
scenario=data.scenario,
first_mes=data.first_mes,
mes_example=data.mes_example,
lorebook_json=data.lorebook_json,
) )
return {"persona_id": data.persona_id, **persona} return {"persona_id": data.persona_id, **persona}
class PersonaPatch(BaseModel):
name: Optional[str] = None
emoji: Optional[str] = None
description: Optional[str] = None
prompt: Optional[str] = None
sd_enabled: Optional[bool] = None
lora_name: Optional[str] = None
lora_weight: Optional[float] = None
appearance_tags: Optional[str] = None
personality: Optional[str] = None
scenario: Optional[str] = None
first_mes: Optional[str] = None
mes_example: Optional[str] = None
lorebook_json: Optional[str] = None
avatar_path: Optional[str] = None
@router.patch("/{persona_id}")
async def patch_one_persona(persona_id: str, body: PersonaPatch):
fields = {k: v for k, v in body.model_dump().items() if v is not None}
ok = await patch_persona(persona_id, fields)
if not ok:
raise HTTPException(status_code=400, detail="Нельзя редактировать этого персонажа")
persona = await get_persona(persona_id)
if not persona:
raise HTTPException(status_code=404, detail="Персонаж не найден")
return {"persona_id": persona_id, **persona}
@router.post("/{persona_id}/avatar")
async def upload_persona_avatar(persona_id: str, file: UploadFile = File(...)):
# only custom personas editable
persona = await get_persona(persona_id)
if not persona:
raise HTTPException(status_code=404, detail="Персонаж не найден")
if not persona.get("custom"):
raise HTTPException(status_code=400, detail="Нельзя менять аватар встроенного персонажа")
content = await file.read()
if not content.startswith(b"\x89PNG"):
raise HTTPException(status_code=400, detail="Нужен PNG")
from pathlib import Path
import uuid
avatars_dir = Path("static/avatars")
avatars_dir.mkdir(parents=True, exist_ok=True)
fname = f"persona_{persona_id}_{uuid.uuid4().hex[:8]}.png"
path = avatars_dir / fname
path.write_bytes(content)
rel = f"avatars/{fname}"
ok = await patch_persona(persona_id, {"avatar_path": rel})
if not ok:
raise HTTPException(status_code=400, detail="Нельзя изменить аватар")
return {"avatar_path": f"/static/{rel}"}
@router.delete("/{persona_id}") @router.delete("/{persona_id}")
async def remove_persona(persona_id: str): async def remove_persona(persona_id: str):
if not await delete_persona(persona_id): if not await delete_persona(persona_id):
+44 -3
View File
@@ -6,8 +6,19 @@ from services.memory import (
update_session_title, update_session_title,
update_session_persona, update_session_persona,
get_history, get_history,
get_message_count get_message_count,
update_session_rpg,
update_session_facts,
update_session_global_plot,
update_session_status_quo,
update_session_genre,
update_session_rpg_settings,
get_quests,
get_last_message_preview,
fork_session,
get_session,
) )
from models.schemas import ForkSessionRequest
router = APIRouter(prefix="/sessions", tags=["sessions"]) router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -18,10 +29,20 @@ async def list_sessions():
result = [] result = []
for s in sessions: for s in sessions:
count = await get_message_count(s["session_id"]) count = await get_message_count(s["session_id"])
result.append({**s, "message_count": count}) preview = await get_last_message_preview(s["session_id"])
result.append({
**s,
"message_count": count,
"last_message_preview": preview,
})
return result return result
@router.get("/{session_id}/quests")
async def list_quests(session_id: str):
return await get_quests(session_id)
@router.get("/{session_id}") @router.get("/{session_id}")
async def get_session(session_id: str): async def get_session(session_id: str):
sessions = await get_all_sessions() sessions = await get_all_sessions()
@@ -33,16 +54,36 @@ async def get_session(session_id: str):
@router.patch("/{session_id}") @router.patch("/{session_id}")
async def patch_session(session_id: str, data: dict): async def patch_session(session_id: str, data: dict):
# ensure session exists before patching
await get_or_create_session(session_id, data.get("persona_id", "default")) await get_or_create_session(session_id, data.get("persona_id", "default"))
if "title" in data: if "title" in data:
await update_session_title(session_id, data["title"]) await update_session_title(session_id, data["title"])
if "persona_id" in data: if "persona_id" in data:
await update_session_persona(session_id, data["persona_id"]) await update_session_persona(session_id, data["persona_id"])
if "rpg_enabled" in data:
await update_session_rpg(session_id, bool(data["rpg_enabled"]))
if "facts_json" in data:
await update_session_facts(session_id, data["facts_json"])
if "global_plot" in data:
await update_session_global_plot(session_id, data["global_plot"])
if "status_quo" in data:
await update_session_status_quo(session_id, data["status_quo"])
if "genre" in data:
await update_session_genre(session_id, data["genre"])
if "rpg_settings_json" in data:
await update_session_rpg_settings(session_id, data["rpg_settings_json"])
return {"status": "updated"} return {"status": "updated"}
@router.post("/{session_id}/fork")
async def fork_session_route(session_id: str, req: ForkSessionRequest):
new_id = await fork_session(session_id, req.until_message_id)
if not new_id:
raise HTTPException(status_code=404, detail="Сессия не найдена")
return {"session_id": new_id, "source_session_id": session_id}
@router.delete("/{session_id}") @router.delete("/{session_id}")
async def remove_session(session_id: str): async def remove_session(session_id: str):
await delete_session(session_id) await delete_session(session_id)
return {"status": "deleted", "session_id": session_id} return {"status": "deleted", "session_id": session_id}
+18 -3
View File
@@ -1,6 +1,7 @@
import json import json
import base64 import base64
import uuid import uuid
from pathlib import Path
import aiosqlite import aiosqlite
from database.db import DB_PATH from database.db import DB_PATH
@@ -110,8 +111,8 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
await db.execute( await db.execute(
"""INSERT OR REPLACE INTO characters """INSERT OR REPLACE INTO characters
(card_id, name, description, personality, scenario, first_mes, (card_id, name, description, personality, scenario, first_mes,
mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json) mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json, avatar_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
card_id, card_id,
card["name"], card["name"],
@@ -125,6 +126,7 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
lora_weight, lora_weight,
card.get("appearance_tags", ""), card.get("appearance_tags", ""),
card["lorebook_json"], card["lorebook_json"],
card.get("avatar_path", ""),
), ),
) )
await db.commit() await db.commit()
@@ -171,7 +173,7 @@ async def update_appearance_tags(card_id: str, appearance_tags: str):
async def update_character(card_id: str, fields: dict) -> bool: async def update_character(card_id: str, fields: dict) -> bool:
allowed = {"name", "description", "personality", "scenario", "first_mes", allowed = {"name", "description", "personality", "scenario", "first_mes",
"mes_example", "appearance_tags", "lora_name", "lora_weight"} "mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path"}
updates = {k: v for k, v in fields.items() if k in allowed} updates = {k: v for k, v in fields.items() if k in allowed}
if not updates: if not updates:
return False return False
@@ -190,6 +192,9 @@ async def import_card_file(content: bytes, filename: str, lora_name: str = "", l
card = parse_png_card(content) card = parse_png_card(content)
if not card: if not card:
raise ValueError("PNG does not contain character card metadata") raise ValueError("PNG does not contain character card metadata")
# Use the PNG itself as avatar
avatar_rel = _save_avatar_bytes(content, f"card_{card['card_id']}")
card["avatar_path"] = avatar_rel
else: else:
card = parse_card_v2(json.loads(content.decode("utf-8"))) card = parse_card_v2(json.loads(content.decode("utf-8")))
@@ -210,5 +215,15 @@ async def import_card_file(content: bytes, filename: str, lora_name: str = "", l
lora_name=lora_name, lora_name=lora_name,
lora_weight=lora_weight, lora_weight=lora_weight,
appearance_tags=saved.get("appearance_tags", ""), appearance_tags=saved.get("appearance_tags", ""),
avatar_path=saved.get("avatar_path", ""),
) )
return saved return saved
def _save_avatar_bytes(png_bytes: bytes, prefix: str) -> str:
avatars_dir = Path("static/avatars")
avatars_dir.mkdir(parents=True, exist_ok=True)
fname = f"{prefix}_{uuid.uuid4().hex[:8]}.png"
path = avatars_dir / fname
path.write_bytes(png_bytes)
return f"avatars/{fname}"
+16
View File
@@ -31,6 +31,22 @@ async def send_message(messages: list) -> str:
return data["choices"][0]["message"]["content"] return data["choices"][0]["message"]["content"]
async def send_message_with_model(messages: list, model: str) -> str:
payload = {
"model": model,
"messages": messages,
}
async with httpx.AsyncClient(timeout=90) as client:
response = await client.post(
OPENROUTER_URL,
headers=HEADERS,
json=payload
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def stream_message(messages: list): async def stream_message(messages: list):
"""Стриминг — отдаём чанки по мере получения""" """Стриминг — отдаём чанки по мере получения"""
payload = { payload = {
+290 -1
View File
@@ -36,6 +36,17 @@ async def get_all_sessions() -> list:
return [dict(r) for r in rows] return [dict(r) for r in rows]
async def get_session(session_id: str) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM sessions WHERE session_id = ?",
(session_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def update_session_title(session_id: str, title: str): async def update_session_title(session_id: str, title: str):
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
@@ -47,16 +58,118 @@ async def update_session_title(session_id: str, title: str):
async def update_session_persona(session_id: str, persona_id: str): async def update_session_persona(session_id: str, persona_id: str):
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT persona_id FROM sessions WHERE session_id = ?",
(session_id,),
) as cur:
row = await cur.fetchone()
prev = row["persona_id"] if row else None
await db.execute( await db.execute(
"UPDATE sessions SET persona_id = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", "UPDATE sessions SET persona_id = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(persona_id, session_id), (persona_id, session_id),
) )
# If persona changed, reset RPG state bound to the persona/arc.
if prev is not None and prev != persona_id:
await db.execute(
"""UPDATE sessions
SET facts_json = '[]',
global_plot = '',
status_quo = '',
plot_arc_json = '{}'
WHERE session_id = ?""",
(session_id,),
)
await db.execute(
"DELETE FROM action_resolutions WHERE session_id = ?",
(session_id,),
)
await db.commit() await db.commit()
async def update_session_rpg(session_id: str, rpg_enabled: bool):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET rpg_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(1 if rpg_enabled else 0, session_id),
)
await db.commit()
async def update_session_facts(session_id: str, facts_json: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET facts_json = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(facts_json, session_id),
)
await db.commit()
async def update_session_global_plot(session_id: str, global_plot: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET global_plot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(global_plot, session_id),
)
await db.commit()
async def update_session_status_quo(session_id: str, status_quo: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET status_quo = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(status_quo, session_id),
)
await db.commit()
async def update_session_plot_arc(session_id: str, plot_arc_json: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET plot_arc_json = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(plot_arc_json, session_id),
)
await db.commit()
async def add_action_resolution(
session_id: str,
intent_text: str,
roll: int,
outcome: str,
resolution_text: str,
message_id: int | None = None,
):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO action_resolutions
(session_id, message_id, intent_text, roll, outcome, resolution_text)
VALUES (?, ?, ?, ?, ?, ?)""",
(session_id, message_id, intent_text, roll, outcome, resolution_text),
)
await db.commit()
async def get_last_action_resolution(session_id: str) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT * FROM action_resolutions
WHERE session_id = ?
ORDER BY id DESC LIMIT 1""",
(session_id,),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def delete_session(session_id: str): async def delete_session(session_id: str):
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM messages WHERE session_id = ?", (session_id,)) await db.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
await db.execute("DELETE FROM rpg_quests WHERE session_id = ?", (session_id,))
await db.execute("DELETE FROM action_resolutions WHERE session_id = ?", (session_id,))
await db.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) await db.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
await db.commit() await db.commit()
@@ -65,13 +178,14 @@ async def get_history(session_id: str) -> list:
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
async with db.execute( async with db.execute(
"""SELECT role, content, image_prompt, image_path """SELECT id, role, content, image_prompt, image_path
FROM messages WHERE session_id = ? ORDER BY id""", FROM messages WHERE session_id = ? ORDER BY id""",
(session_id,), (session_id,),
) as cursor: ) as cursor:
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [ return [
{ {
"id": r["id"],
"role": r["role"], "role": r["role"],
"content": r["content"], "content": r["content"],
"image_prompt": r["image_prompt"], "image_prompt": r["image_prompt"],
@@ -81,6 +195,114 @@ async def get_history(session_id: str) -> list:
] ]
async def get_message(message_id: int) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT id, session_id, role, content FROM messages WHERE id = ?",
(message_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def update_message_content(message_id: int, content: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET content = ? WHERE id = ?",
(content, message_id),
)
await db.commit()
async def delete_messages_after(session_id: str, message_id: int):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"DELETE FROM messages WHERE session_id = ? AND id > ?",
(session_id, message_id),
)
await db.commit()
async def delete_message(message_id: int):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.commit()
async def get_last_message_preview(session_id: str, max_len: int = 80) -> str:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT content, role FROM messages
WHERE session_id = ? AND role IN ('user', 'assistant')
ORDER BY id DESC LIMIT 1""",
(session_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
return ""
prefix = "Вы: " if row["role"] == "user" else "AI: "
text = (row["content"] or "").replace("\n", " ").strip()
if len(text) > max_len:
text = text[:max_len] + ""
return prefix + text
async def fork_session(source_session_id: str, until_message_id: int) -> str | None:
source = await get_session(source_session_id)
if not source:
return None
import uuid
new_id = "sess_" + uuid.uuid4().hex[:8]
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO sessions
(session_id, persona_id, title, rpg_enabled, facts_json, global_plot,
status_quo, plot_arc_json, genre, rpg_settings_json, affinity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
new_id,
source["persona_id"],
(source.get("title") or "Новый чат") + " (ветка)",
source.get("rpg_enabled", 0),
source.get("facts_json", "[]"),
source.get("global_plot", ""),
source.get("status_quo", ""),
source.get("plot_arc_json", "{}"),
source.get("genre", "adventure"),
source.get("rpg_settings_json", "{}"),
source.get("affinity", 0),
),
)
async with db.execute(
"""SELECT role, content, image_prompt, image_path FROM messages
WHERE session_id = ? AND id <= ? ORDER BY id""",
(source_session_id, until_message_id),
) as cur:
rows = await cur.fetchall()
for r in rows:
await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, ?, ?, ?, ?)""",
(new_id, r[0], r[1], r[2], r[3]),
)
async with db.execute(
"SELECT title, status FROM rpg_quests WHERE session_id = ?",
(source_session_id,),
) as cur:
quests = await cur.fetchall()
for q in quests:
await db.execute(
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
(new_id, q[0], q[1]),
)
await db.commit()
return new_id
async def add_message( async def add_message(
session_id: str, session_id: str,
role: str, role: str,
@@ -131,6 +353,73 @@ async def clear_history(session_id: str):
await db.commit() await db.commit()
async def update_session_affinity(session_id: str, delta: int):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET affinity = affinity + ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(delta, session_id),
)
await db.commit()
async def update_session_genre(session_id: str, genre: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET genre = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(genre, session_id),
)
await db.commit()
async def update_session_rpg_settings(session_id: str, settings_json: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET rpg_settings_json = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(settings_json, session_id),
)
await db.commit()
async def upsert_quest(session_id: str, title: str, status: str = "active"):
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT id FROM rpg_quests WHERE session_id = ? AND title = ?",
(session_id, title),
) as cur:
row = await cur.fetchone()
if row:
await db.execute(
"UPDATE rpg_quests SET status = ? WHERE id = ?",
(status, row[0]),
)
else:
await db.execute(
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
(session_id, title, status),
)
await db.commit()
async def get_quests(session_id: str) -> list:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT id, title, status FROM rpg_quests WHERE session_id = ? ORDER BY id",
(session_id,),
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def update_quest_status(session_id: str, title: str, status: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE rpg_quests SET status = ? WHERE session_id = ? AND title = ?",
(status, session_id, title),
)
await db.commit()
async def get_message_count(session_id: str) -> int: async def get_message_count(session_id: str) -> int:
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
+95 -4
View File
@@ -63,9 +63,29 @@ def _row_to_persona(row: dict) -> dict:
"lora_name": row["lora_name"] or "", "lora_name": row["lora_name"] or "",
"lora_weight": row["lora_weight"] if row["lora_weight"] is not None else 0.8, "lora_weight": row["lora_weight"] if row["lora_weight"] is not None else 0.8,
"appearance_tags": row["appearance_tags"] or "", "appearance_tags": row["appearance_tags"] or "",
"personality": row.get("personality", "") or "",
"scenario": row.get("scenario", "") or "",
"first_mes": row.get("first_mes", "") or "",
"mes_example": row.get("mes_example", "") or "",
"lorebook_json": row.get("lorebook_json", "[]") or "[]",
"avatar_path": row.get("avatar_path", "") or "",
} }
def build_persona_prompt(data: dict) -> str:
parts = [
f"You are {data.get('name', '').strip()}." if data.get("name") else "",
f"Description: {data.get('description', '').strip()}",
f"Personality: {data.get('personality', '').strip()}",
f"Scenario: {data.get('scenario', '').strip()}",
]
ex = (data.get("mes_example") or "").strip()
if ex:
parts.append(f"Example dialogue:\n{ex}")
parts.append("Stay in character. Reply as the character. Do not add image tags.")
return "\n\n".join(p for p in parts if p and p.split(": ", 1)[-1].strip())
async def get_all_personas() -> dict: async def get_all_personas() -> dict:
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
@@ -96,16 +116,33 @@ async def create_persona(
lora_name: str = "", lora_name: str = "",
lora_weight: float = 0.8, lora_weight: float = 0.8,
appearance_tags: str = "", appearance_tags: str = "",
personality: str = "",
scenario: str = "",
first_mes: str = "",
mes_example: str = "",
lorebook_json: str = "[]",
avatar_path: str = "",
) -> dict: ) -> dict:
final_prompt = prompt.strip() or build_persona_prompt(
{
"name": name,
"description": description,
"personality": personality,
"scenario": scenario,
"mes_example": mes_example,
}
)
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
"""INSERT INTO personas """INSERT INTO personas
(persona_id, name, emoji, description, prompt, custom, (persona_id, name, emoji, description, prompt, custom,
sd_enabled, lora_name, lora_weight, appearance_tags) sd_enabled, lora_name, lora_weight, appearance_tags,
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)""", personality, scenario, first_mes, mes_example, lorebook_json, avatar_path)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
persona_id, name, emoji, description, prompt, persona_id, name, emoji, description, final_prompt,
1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags, 1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags,
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
), ),
) )
await db.commit() await db.commit()
@@ -113,12 +150,18 @@ async def create_persona(
"name": name, "name": name,
"emoji": emoji, "emoji": emoji,
"description": description, "description": description,
"prompt": prompt, "prompt": final_prompt,
"custom": True, "custom": True,
"sd_enabled": sd_enabled, "sd_enabled": sd_enabled,
"lora_name": lora_name, "lora_name": lora_name,
"lora_weight": lora_weight, "lora_weight": lora_weight,
"appearance_tags": appearance_tags, "appearance_tags": appearance_tags,
"personality": personality,
"scenario": scenario,
"first_mes": first_mes,
"mes_example": mes_example,
"lorebook_json": lorebook_json,
"avatar_path": avatar_path,
} }
@@ -166,3 +209,51 @@ async def update_persona_prompt(persona_id: str, prompt: str):
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute("UPDATE personas SET prompt = ? WHERE persona_id = ?", (prompt, persona_id)) await db.execute("UPDATE personas SET prompt = ? WHERE persona_id = ?", (prompt, persona_id))
await db.commit() await db.commit()
async def patch_persona(persona_id: str, fields: dict) -> bool:
allowed = {
"name",
"emoji",
"description",
"prompt",
"sd_enabled",
"lora_name",
"lora_weight",
"appearance_tags",
"personality",
"scenario",
"first_mes",
"mes_example",
"lorebook_json",
"avatar_path",
}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return False
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
# disallow editing built-in personas
async with db.execute("SELECT custom FROM personas WHERE persona_id = ?", (persona_id,)) as cur:
row = await cur.fetchone()
if not row or not row[0]:
return False
# rebuild prompt if user didn't explicitly set it
raw_fields = {"name", "description", "personality", "scenario", "mes_example"}
if "prompt" not in updates and (raw_fields & updates.keys()):
async with db.execute("SELECT * FROM personas WHERE persona_id = ?", (persona_id,)) as cur:
existing = await cur.fetchone()
if existing:
merged = dict(existing)
merged.update(updates)
updates["prompt"] = build_persona_prompt(merged)
cols = ", ".join(f"{k} = ?" for k in updates)
cur2 = await db.execute(
f"UPDATE personas SET {cols} WHERE persona_id = ?",
(*updates.values(), persona_id),
)
await db.commit()
return cur2.rowcount > 0
+76
View File
@@ -0,0 +1,76 @@
import json
import os
from services.llm import send_message_with_model, send_message
FACTS_MODEL = os.getenv("RPG_FACTS_MODEL", "").strip() or "deepseek/deepseek-chat-v3"
FACTS_SYSTEM = """Extract stable facts from the conversation.
Return ONLY valid JSON (no markdown), as an array of short strings.
Rules:
- Facts must be durable (names, relations, inventory, locations, world rules).
- Do not include ephemeral actions unless they change state.
- Avoid duplicates.
- Keep each fact <= 120 chars.
Example output:
["User name is Alex", "We are in a ruined castle", "NPC Mira distrusts the user"]"""
def merge_facts(existing_json: str, new_facts: list[str], limit: int = 80) -> str:
try:
existing = json.loads(existing_json or "[]")
if not isinstance(existing, list):
existing = []
except json.JSONDecodeError:
existing = []
seen = {str(x).strip() for x in existing if str(x).strip()}
merged = [str(x).strip() for x in existing if str(x).strip()]
for f in new_facts:
s = str(f).strip()
if not s or s in seen:
continue
seen.add(s)
merged.append(s)
if len(merged) > limit:
merged = merged[-limit:]
return json.dumps(merged, ensure_ascii=False)
async def extract_facts(context_messages: list[dict]) -> list[str]:
# Build a compact transcript
transcript = "\n".join(
f"{m.get('role')}: {m.get('content','')}".strip()
for m in context_messages
if m.get("role") in ("user", "assistant")
)[-6000:]
messages = [
{"role": "system", "content": FACTS_SYSTEM},
{"role": "user", "content": transcript},
]
raw = await (send_message_with_model(messages, FACTS_MODEL) if FACTS_MODEL else send_message(messages))
try:
data = json.loads(raw.strip())
if isinstance(data, list):
return [str(x) for x in data][:40]
except Exception:
return []
return []
def facts_to_prompt(facts_json: str, max_items: int = 20) -> str:
try:
facts = json.loads(facts_json or "[]")
if not isinstance(facts, list):
return ""
except json.JSONDecodeError:
return ""
facts = [str(x).strip() for x in facts if str(x).strip()]
if not facts:
return ""
block = "\n".join(f"- {x}" for x in facts[-max_items:])
return f"--- Facts (persistent memory) ---\n{block}\n---"
+111
View File
@@ -0,0 +1,111 @@
import json
import os
import random
from services.llm import send_message_with_model
import logging
logger = logging.getLogger(__name__)
NARRATOR_MODEL = os.getenv("RPG_NARRATOR_MODEL", "").strip() or "deepseek/deepseek-chat-v3"
NARRATOR_PRE_SYSTEM = """You are the System/Narrator of an RPG chat.
Decide if the user's action requires a skill/ability check (physical action, persuasion, deception, stealth, combat, etc.).
Pure dialogue, questions, or passive observation do NOT require a check.
Return ONLY valid JSON (no markdown):
{
"needs_check": true,
"check_reason": "brief reason why a check is needed (e.g. 'jumping over a pit')",
"directives": ["short imperative rules for the next character reply"],
"resolution_text": "what actually happens as result of the action — written as narrator prose (1-2 sentences). Only if needs_check=true and roll/outcome provided.",
"status_quo_update": "optional short update about the world state"
}
If needs_check=false: directives may still guide tone/pacing, resolution_text must be empty string.
If needs_check=true and roll/outcome are provided: resolution_text MUST reflect the outcome.
- critical failure (1): embarrassing or painful failure with extra complication
- failure (2-8): action fails, partial or no progress
- success (9-19): action succeeds as intended
- critical success (20): spectacular success with bonus effect"""
NARRATOR_POST_SYSTEM = """You are the System/Narrator of an RPG chat.
After the character replied, update persistent state.
Return ONLY valid JSON (no markdown):
{
"status_quo_update": "what changed in the world/state (1-3 sentences)",
"facts": ["durable facts only"],
"choices": [{"id":"a","label":"..."}, ...],
"affinity_delta": 0,
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}]
}
Rules:
- affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral.
- quest_updates: only include if a quest was clearly started, completed, or failed. Empty array otherwise.
- choices: 0-4 options for what the player can do next."""
async def narrator_pre(
persona_name: str,
context: str,
global_plot: str,
facts_block: str,
user_message: str,
roll: int | None = None,
outcome: str | None = None,
) -> dict:
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
user = (
f"Persona: {persona_name}\n"
f"{roll_block}"
f"User action: {user_message}\n\n"
f"Global plot:\n{global_plot}\n\n"
f"Facts:\n{facts_block}\n\n"
f"Recent context:\n{context}\n"
)
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
data = json.loads(cleaned)
if isinstance(data, dict):
return data
except Exception:
logger.warning("Narrator-pre JSON parse failed. Raw=%.500s", raw)
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""}
async def narrator_post(
persona_name: str,
context: str,
global_plot: str,
facts_block: str,
) -> dict:
user = (
f"Persona: {persona_name}\n\n"
f"Global plot:\n{global_plot}\n\n"
f"Facts:\n{facts_block}\n\n"
f"Recent context:\n{context}\n"
)
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
data = json.loads(cleaned)
if isinstance(data, dict):
return data
except Exception:
logger.warning("Narrator-post JSON parse failed. Raw=%.500s", raw)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []}
+105
View File
@@ -0,0 +1,105 @@
import json
import os
from services.llm import send_message_with_model, send_message
import logging
logger = logging.getLogger(__name__)
PLOT_MODEL = os.getenv("RPG_PLOT_MODEL", "").strip() or "deepseek/deepseek-chat-v3"
GENRE_LABELS = {
"adventure": "Adventure",
"horror": "Horror",
"romance": "Romance",
"slice_of_life": "Slice of Life",
"fantasy": "Fantasy",
"sci_fi": "Sci-Fi",
}
ARC_SYSTEM = """You are a narrative designer for an RPG chat.
Given the opening scene (greeting), character info, current facts, and genre(s), produce a STRUCTURED PLOT ARC.
Return ONLY valid JSON (no markdown):
{
"title": "short arc title",
"boundaries": ["things that must remain true to preserve immersion"],
"phase": "opening|hook|complication|reveal|climax|aftermath",
"cast": [{"name":"NPC name","role":"helper|antagonist|bystander","motivation":"..."}],
"secrets": ["hidden truths not revealed yet"],
"beats": [
{"id":"b1","trigger":"event_driven:rest|event_driven:travel|event_driven:help_request|event_driven:after_fail|event_driven:after_success",
"injection":"1-3 sentences to introduce the beat WITHOUT breaking current scene",
"choices":[{"id":"a","label":"..."},{"id":"b","label":"..."}]}
],
"next_beat_hint": "short hint for narrator what to push next"
}
Rules:
- Respect the opening scene. Do not jump to unrelated characters immediately.
- Beats must feel like natural developments fitting the genre(s). For cross-genre, blend tropes organically.
- Keep injections immersive (in-world narration)."""
def format_genres(genre: str) -> str:
parts = [g.strip() for g in genre.replace("+", ",").split(",") if g.strip()]
if not parts:
return "Adventure"
labels = [GENRE_LABELS.get(g, g.replace("_", " ").title()) for g in parts]
if len(labels) == 1:
return labels[0]
return " + ".join(labels) + " (cross-genre blend)"
async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenario: str, greeting: str, facts_block: str = "", genre: str = "adventure") -> dict:
user = (
f"Character: {persona_name}\n"
f"Description: {persona_desc}\n"
f"Scenario: {persona_scenario}\n"
f"Greeting: {greeting}\n"
f"Genre: {format_genres(genre)}\n"
f"Facts:\n{facts_block}\n"
).strip()
messages = [
{"role": "system", "content": ARC_SYSTEM},
{"role": "user", "content": user},
]
raw = await (send_message_with_model(messages, PLOT_MODEL) if PLOT_MODEL else send_message(messages))
cleaned = raw.strip()
# common OpenRouter formatting: fenced json
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
data = json.loads(cleaned)
return data if isinstance(data, dict) else {}
except Exception:
logger.warning("PlotArc JSON parse failed. Raw=%.500s", raw)
return {}
def should_advance_arc(user_text: str) -> str | None:
t = (user_text or "").lower()
if any(x in t for x in ["отдыха", "ночлег", "спим", "сон", "разбить лагерь", "лагерь", "отдохн"]):
return "event_driven:rest"
if any(x in t for x in ["идем дальше", "пойдем дальше", "в путь", "продолжаем путь", "уходим", "возвращаемся", "переходим"]):
return "event_driven:travel"
if any(x in t for x in ["помоги", "помочь", "нужна помощь", "спасите", "help"]):
return "event_driven:help_request"
return None
def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]:
beats = arc.get("beats", [])
if not isinstance(beats, list):
return arc, []
matched, remaining = [], []
for b in beats:
if len(matched) < max_beats and isinstance(b, dict) and b.get("trigger") == trigger:
matched.append(b)
else:
remaining.append(b)
arc["beats"] = remaining
return arc, matched
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+286
View File
@@ -5,6 +5,7 @@ body {
color: #e0e0e0; color: #e0e0e0;
font-family: 'Segoe UI', sans-serif; font-family: 'Segoe UI', sans-serif;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -45,6 +46,21 @@ header h1 { font-size: 1.1rem; color: #e94560; }
white-space: nowrap; white-space: nowrap;
} }
.rpg-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: #888;
border: 1px solid #0f3460;
border-radius: 10px;
padding: 4px 10px;
cursor: pointer;
user-select: none;
}
.rpg-toggle input { accent-color: #e94560; }
.rpg-toggle:hover { border-color: #e94560; color: #e94560; }
.app-body { display: flex; flex: 1; overflow: hidden; } .app-body { display: flex; flex: 1; overflow: hidden; }
.sidebar { .sidebar {
@@ -96,6 +112,7 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.session-item:hover { background: #1a1a2e; } .session-item:hover { background: #1a1a2e; }
.session-item.active { background: #1a1a2e; border-left-color: #e94560; } .session-item.active { background: #1a1a2e; border-left-color: #e94560; }
.session-item .s-title { flex: 1; font-size: 0.82rem; color: #ccc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .session-item .s-title { flex: 1; font-size: 0.82rem; color: #ccc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-item .s-companion { flex: 1; font-size: 0.72rem; color: #777; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-item .s-meta { font-size: 0.7rem; color: #555; } .session-item .s-meta { font-size: 0.7rem; color: #555; }
.session-item .s-del { background: none; border: none; color: #555; cursor: pointer; opacity: 0; } .session-item .s-del { background: none; border: none; color: #555; cursor: pointer; opacity: 0; }
.session-item:hover .s-del { opacity: 1; } .session-item:hover .s-del { opacity: 1; }
@@ -111,6 +128,38 @@ header h1 { font-size: 1.1rem; color: #e94560; }
border-bottom: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
} }
.system-blob {
border-bottom: 1px solid #0f3460;
background: #11162a;
padding: 8px 16px;
}
.system-blob-header {
display: flex;
align-items: center;
justify-content: space-between;
color: #888;
font-size: 0.8rem;
margin-bottom: 6px;
}
.system-blob-header button {
background: transparent;
border: 1px solid #0f3460;
border-radius: 8px;
color: #888;
padding: 4px 10px;
cursor: pointer;
}
.system-blob-header button:hover { border-color: #e94560; color: #e94560; }
.system-blob-content {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.78rem;
color: #aaa;
max-height: 140px;
overflow: auto;
margin: 0;
}
.persona-card { .persona-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -128,6 +177,13 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.persona-card.active { border-color: #e94560; background: #1f1535; } .persona-card.active { border-color: #e94560; background: #1f1535; }
.persona-card .emoji { font-size: 1.2rem; } .persona-card .emoji { font-size: 1.2rem; }
.persona-card .pname { font-size: 0.7rem; color: #ccc; } .persona-card .pname { font-size: 0.7rem; color: #ccc; }
.persona-card .avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #0f3460;
}
.persona-card .del-btn { .persona-card .del-btn {
position: absolute; top: -5px; right: -5px; position: absolute; top: -5px; right: -5px;
width: 14px; height: 14px; width: 14px; height: 14px;
@@ -224,6 +280,27 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.chat-image { margin-top: 8px; max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; } .chat-image { margin-top: 8px; max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; }
.image-error { margin-top: 6px; font-size: 0.75rem; color: #888; } .image-error { margin-top: 6px; font-size: 0.75rem; color: #888; }
.choice-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.choice-btn {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
color: #ccc;
font-size: 0.8rem;
padding: 6px 10px;
cursor: pointer;
}
.choice-btn:hover {
border-color: #e94560;
color: #e94560;
}
.typing { .typing {
align-self: flex-start; align-self: flex-start;
display: flex; gap: 4px; display: flex; gap: 4px;
@@ -240,6 +317,7 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.input-area { .input-area {
display: flex; gap: 10px; display: flex; gap: 10px;
padding: 12px 16px; padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
border-top: 1px solid #0f3460; border-top: 1px solid #0f3460;
} }
@@ -276,6 +354,8 @@ textarea:focus { border-color: #e94560; }
display: none; position: fixed; inset: 0; display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.7); background: rgba(0,0,0,0.7);
z-index: 100; align-items: center; justify-content: center; z-index: 100; align-items: center; justify-content: center;
padding: 16px;
overflow-y: auto;
} }
.modal-overlay.open { display: flex; } .modal-overlay.open { display: flex; }
@@ -283,8 +363,48 @@ textarea:focus { border-color: #e94560; }
background: #16213e; border: 1px solid #0f3460; background: #16213e; border: 1px solid #0f3460;
border-radius: 16px; padding: 24px; border-radius: 16px; padding: 24px;
width: 100%; max-width: 440px; width: 100%; max-width: 440px;
max-height: calc(100vh - 32px);
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: 12px;
margin: auto;
} }
.modal-wizard { max-width: 480px; }
.modal-wizard-header { flex-shrink: 0; }
.modal-wizard-header h2 { margin-bottom: 8px; }
.modal-wizard-body {
flex: 1; min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.modal-wizard-footer {
flex-shrink: 0;
margin-top: 4px;
}
.wizard-steps {
display: flex; align-items: center; justify-content: center;
gap: 0; margin-bottom: 4px;
}
.wizard-step-dot {
width: 28px; height: 28px; border-radius: 50%;
background: #1a1a2e; border: 1px solid #0f3460;
color: #666; font-size: 0.75rem;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.wizard-step-dot.active { border-color: #e94560; color: #e94560; background: #1f0a14; }
.wizard-step-dot.done { border-color: #9b7fd4; color: #9b7fd4; }
.wizard-step-line { width: 40px; height: 1px; background: #0f3460; }
.wizard-page { display: none; flex-direction: column; gap: 12px; }
.wizard-page.active { display: flex; }
.wizard-page-title { font-size: 0.85rem; color: #9b7fd4; margin: 0 0 4px; }
.wizard-hint { font-size: 0.8rem; color: #666; margin: -4px 0 4px; }
.selected-genres-label { font-size: 0.8rem; color: #e94560; margin-top: 4px; }
.wizard-nav { display: flex; gap: 8px; }
.wizard-nav-btn {
padding: 8px 18px; border-radius: 8px; border: none; cursor: pointer;
background: #0f3460; color: #ccc;
}
.wizard-nav-btn:hover { color: #e0e0e0; }
.wizard-nav-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.modal h2 { font-size: 1.1rem; color: #e94560; } .modal h2 { font-size: 1.1rem; color: #e94560; }
.modal label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; color: #888; } .modal label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; color: #888; }
.modal input, .modal textarea { .modal input, .modal textarea {
@@ -293,6 +413,7 @@ textarea:focus { border-color: #e94560; }
padding: 8px 10px; outline: none; font-family: inherit; padding: 8px 10px; outline: none; font-family: inherit;
} }
.modal-buttons { display: flex; gap: 8px; justify-content: flex-end; } .modal-buttons { display: flex; gap: 8px; justify-content: flex-end; }
.modal-wizard-footer { justify-content: space-between; align-items: center; }
.modal-buttons button { padding: 8px 18px; border-radius: 8px; border: none; cursor: pointer; } .modal-buttons button { padding: 8px 18px; border-radius: 8px; border: none; cursor: pointer; }
#modalCancel, #cardModalCancel { background: #0f3460; color: #aaa; } #modalCancel, #cardModalCancel { background: #0f3460; color: #aaa; }
#modalSave, #cardModalImport { background: #e94560; color: white; } #modalSave, #cardModalImport { background: #e94560; color: white; }
@@ -304,3 +425,168 @@ textarea:focus { border-color: #e94560; }
} }
.empty-state .big { font-size: 2.5rem; } .empty-state .big { font-size: 2.5rem; }
.hidden { display: none !important; } .hidden { display: none !important; }
/* Narrator message bubble */
.message.narrator { align-self: center; max-width: 80%; }
.message.narrator .label { color: #9b7fd4; font-size: 0.75rem; margin-bottom: 4px; }
.message.narrator .bubble {
background: #1a1230;
border: 1px solid #4a2d8a;
border-left: 3px solid #9b7fd4;
border-radius: 12px;
padding: 10px 14px;
color: #ccc;
font-size: 0.9rem;
}
/* Dice block inside narrator bubble */
.dice-block {
display: flex; align-items: center; gap: 8px;
margin-bottom: 8px;
padding: 4px 8px;
border-radius: 8px;
background: #0f0a1e;
font-size: 0.8rem;
width: fit-content;
}
.dice-icon { font-size: 1.1rem; }
.dice-roll { font-size: 1.3rem; font-weight: bold; }
.dice-outcome { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
.outcome-crit-fail { border: 1px solid #c0392b; }
.outcome-crit-fail .dice-roll { color: #e74c3c; }
.outcome-crit-fail .dice-outcome { color: #e74c3c; }
.outcome-fail { border: 1px solid #555; }
.outcome-fail .dice-roll { color: #aaa; }
.outcome-fail .dice-outcome { color: #888; }
.outcome-success { border: 1px solid #27ae60; }
.outcome-success .dice-roll { color: #2ecc71; }
.outcome-success .dice-outcome { color: #2ecc71; }
.outcome-crit-success { border: 1px solid #f39c12; }
.outcome-crit-success .dice-roll { color: #f1c40f; }
.outcome-crit-success .dice-outcome { color: #f1c40f; }
.narrator-text { white-space: pre-wrap; line-height: 1.5; }
/* Affinity display in header */
.affinity-display {
font-size: 0.8rem; padding: 4px 10px;
border: 1px solid #0f3460; border-radius: 10px;
color: #aaa; white-space: nowrap;
}
.affinity-display.affinity-high { border-color: #e94560; color: #e94560; }
.affinity-display.affinity-low { border-color: #555; color: #666; }
/* Quest panel in sidebar */
.quest-panel {
border-top: 1px solid #0f3460;
padding: 10px 14px;
flex-shrink: 0;
}
.quest-panel-header {
font-size: 0.75rem; color: #888;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 6px;
}
.quest-item {
font-size: 0.8rem; padding: 4px 0;
color: #bbb; line-height: 1.4;
border-bottom: 1px solid #0f3460;
}
.quest-item:last-child { border-bottom: none; }
.quest-done { color: #555; text-decoration: line-through; }
.quest-failed { color: #c0392b; }
/* Genre modal grid */
.genre-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 8px; margin: 4px 0;
}
.genre-btn {
background: #1a1a2e; border: 1px solid #0f3460;
border-radius: 8px; color: #ccc;
padding: 10px 8px; cursor: pointer;
font-size: 0.85rem; text-align: center;
transition: all 0.15s;
}
.genre-btn:hover { border-color: #9b7fd4; color: #e0e0e0; }
.genre-btn.selected { border-color: #e94560; color: #e94560; background: #1f0a14; }
/* RPG settings checkboxes */
.rpg-settings-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 6px; margin: 4px 0;
}
.rpg-settings-grid label {
display: flex; align-items: center; gap: 6px;
font-size: 0.8rem; color: #aaa; cursor: pointer;
flex-direction: row;
}
.rpg-settings-grid input[type=checkbox] { accent-color: #e94560; }
.header-icon-btn {
background: transparent; border: 1px solid #0f3460;
border-radius: 10px; color: #aaa; padding: 4px 10px;
cursor: pointer; font-size: 1rem;
}
.header-icon-btn:hover { border-color: #e94560; color: #e94560; }
.rpg-badge {
font-size: 0.7rem; padding: 3px 8px;
border: 1px solid #9b7fd4; border-radius: 8px;
color: #9b7fd4; text-transform: uppercase;
letter-spacing: 0.05em;
}
.persona-pick-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.persona-pick-card {
background: #1a1a2e; border: 1px solid #0f3460;
border-radius: 10px; padding: 10px 8px;
cursor: pointer; text-align: center;
font-size: 0.8rem; color: #ccc;
transition: all 0.15s;
}
.persona-pick-card:hover { border-color: #9b7fd4; }
.persona-pick-card.selected { border-color: #e94560; color: #e94560; background: #1f0a14; }
.persona-pick-card .emoji { font-size: 1.5rem; display: block; margin-bottom: 4px; }
.rpg-mode-option {
display: flex; align-items: center; gap: 8px;
font-size: 0.9rem; color: #ccc; cursor: pointer;
flex-direction: row !important;
padding: 8px 0;
}
.chat-settings-meta {
margin-top: 12px; padding: 10px;
background: #1a1a2e; border-radius: 8px;
font-size: 0.8rem; color: #888; line-height: 1.5;
}
.session-item .s-preview {
font-size: 0.75rem; color: #666;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 2px;
}
.session-item .s-row {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.session-item .s-badge {
font-size: 0.65rem; padding: 1px 5px;
border: 1px solid #9b7fd4; border-radius: 4px;
color: #9b7fd4;
}
.session-item .s-date { font-size: 0.7rem; color: #555; margin-left: auto; }
.session-item .s-title[contenteditable] {
outline: none; border-bottom: 1px dashed #e94560;
}
.message-actions {
display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;
}
.message-actions button {
background: #0f3460; border: none; border-radius: 6px;
color: #aaa; font-size: 0.7rem; padding: 3px 8px; cursor: pointer;
}
.message-actions button:hover { color: #e94560; }
.message .bubble-edit {
width: 100%; min-height: 60px;
background: #1a1a2e; border: 1px solid #e94560;
border-radius: 8px; color: #e0e0e0; padding: 8px;
font-family: inherit; resize: vertical;
}
+196 -6
View File
@@ -2,7 +2,7 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>AI Chat</title> <title>AI Chat</title>
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
</head> </head>
@@ -12,6 +12,9 @@
<button id="sidebarToggle" type="button"></button> <button id="sidebarToggle" type="button"></button>
<h1>🤖 AI Chat</h1> <h1>🤖 AI Chat</h1>
<span class="header-title" id="headerTitle">Новый чат</span> <span class="header-title" id="headerTitle">Новый чат</span>
<span id="rpgBadge" class="rpg-badge hidden" title="RPG режим">RPG</span>
<button id="chatSettingsBtn" type="button" class="header-icon-btn" title="Настройки чата">⚙️</button>
<span id="affinityDisplay" class="affinity-display hidden"></span>
</header> </header>
<div class="app-body"> <div class="app-body">
@@ -21,10 +24,21 @@
<button id="newChatBtn" type="button">+ Новый</button> <button id="newChatBtn" type="button">+ Новый</button>
</div> </div>
<div class="session-list" id="sessionList"></div> <div class="session-list" id="sessionList"></div>
<div class="quest-panel hidden" id="questPanel">
<div class="quest-panel-header">Квесты</div>
<div id="questList"></div>
</div>
</aside> </aside>
<div class="main"> <div class="main">
<div class="persona-bar" id="personaBar"></div> <div class="persona-bar" id="personaBar"></div>
<div class="system-blob" id="systemBlob">
<div class="system-blob-header">
<span>System</span>
<button type="button" id="systemBlobToggle">Скрыть</button>
</div>
<pre class="system-blob-content" id="systemBlobContent"></pre>
</div>
<div class="messages" id="messages"> <div class="messages" id="messages">
<div class="empty-state" id="emptyState"> <div class="empty-state" id="emptyState">
<span class="big">💬</span> <span class="big">💬</span>
@@ -41,8 +55,20 @@
</div> </div>
<div class="modal-overlay" id="modalOverlay"> <div class="modal-overlay" id="modalOverlay">
<div class="modal"> <div class="modal modal-wizard">
<div class="modal-wizard-header">
<h2>✨ Новый персонаж</h2> <h2>✨ Новый персонаж</h2>
<div class="wizard-steps">
<span class="wizard-step-dot active" data-step="1">1</span>
<span class="wizard-step-line"></span>
<span class="wizard-step-dot" data-step="2">2</span>
<span class="wizard-step-line"></span>
<span class="wizard-step-dot" data-step="3">3</span>
</div>
</div>
<div class="modal-wizard-body">
<div class="wizard-page active" data-step="1">
<p class="wizard-page-title">Основное</p>
<label>ID (латиницей) <label>ID (латиницей)
<input type="text" id="pId" placeholder="my_hero"> <input type="text" id="pId" placeholder="my_hero">
</label> </label>
@@ -55,8 +81,29 @@
<label>Описание <label>Описание
<input type="text" id="pDesc" placeholder="Краткое описание"> <input type="text" id="pDesc" placeholder="Краткое описание">
</label> </label>
<label>Системный промт </div>
<textarea id="pPrompt" rows="4" placeholder="Ты — ..."></textarea> <div class="wizard-page" data-step="2">
<p class="wizard-page-title">Характер и сценарий</p>
<label>Личность
<textarea id="pPersonality" rows="3" placeholder="calm, confident, sarcastic..."></textarea>
</label>
<label>Сценарий / мир
<textarea id="pScenario" rows="3" placeholder="где вы находитесь, что происходит, правила мира"></textarea>
</label>
<label>Первое сообщение (first_mes)
<textarea id="pFirstMes" rows="3" placeholder="приветствие персонажа"></textarea>
</label>
<label>Пример диалога (mes_example)
<textarea id="pMesExample" rows="3" placeholder="пример стиля речи персонажа"></textarea>
</label>
</div>
<div class="wizard-page" data-step="3">
<p class="wizard-page-title">Дополнительно</p>
<label>Lorebook JSON (опционально)
<textarea id="pLorebook" rows="3" placeholder='[]'></textarea>
</label>
<label>Системный промт (опционально, если пусто — соберём автоматически)
<textarea id="pPrompt" rows="3" placeholder=""></textarea>
</label> </label>
<label><input type="checkbox" id="pSdEnabled"> Генерировать SD-промпт</label> <label><input type="checkbox" id="pSdEnabled"> Генерировать SD-промпт</label>
<label>LoRA <label>LoRA
@@ -65,9 +112,15 @@
<label>Теги внешности (SD) <label>Теги внешности (SD)
<input type="text" id="pAppearance" placeholder="blue hair, elf ears"> <input type="text" id="pAppearance" placeholder="blue hair, elf ears">
</label> </label>
<div class="modal-buttons"> </div>
</div>
<div class="modal-buttons modal-wizard-footer">
<button id="modalCancel" type="button">Отмена</button> <button id="modalCancel" type="button">Отмена</button>
<button id="modalSave" type="button">Создать</button> <div class="wizard-nav">
<button id="modalPrev" type="button" class="wizard-nav-btn hidden">← Назад</button>
<button id="modalNext" type="button" class="wizard-nav-btn">Далее →</button>
<button id="modalSave" type="button" class="hidden">Создать</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -95,6 +148,9 @@
<div class="modal" style="max-width:560px;max-height:90vh;overflow-y:auto"> <div class="modal" style="max-width:560px;max-height:90vh;overflow-y:auto">
<h2>✏️ Редактор карточки</h2> <h2>✏️ Редактор карточки</h2>
<input type="hidden" id="editCardId"> <input type="hidden" id="editCardId">
<label>Аватар (PNG)
<input type="file" id="editCardAvatar" accept=".png">
</label>
<label>Имя <input type="text" id="editName"></label> <label>Имя <input type="text" id="editName"></label>
<label>Описание <textarea id="editDescription" rows="4"></textarea></label> <label>Описание <textarea id="editDescription" rows="4"></textarea></label>
<label>Личность <textarea id="editPersonality" rows="3"></textarea></label> <label>Личность <textarea id="editPersonality" rows="3"></textarea></label>
@@ -111,6 +167,140 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="personaEditOverlay">
<div class="modal" style="max-width:560px;max-height:90vh;overflow-y:auto">
<h2>✏️ Редактор персонажа</h2>
<input type="hidden" id="editPersonaId">
<label>Аватар (PNG)
<input type="file" id="editPAvatar" accept=".png">
</label>
<label>Имя <input type="text" id="editPName"></label>
<label>Эмодзи <input type="text" id="editPEmoji" maxlength="4"></label>
<label>Описание <textarea id="editPDesc" rows="3"></textarea></label>
<label>Личность <textarea id="editPPersonality" rows="3"></textarea></label>
<label>Сценарий <textarea id="editPScenario" rows="3"></textarea></label>
<label>Первое сообщение <textarea id="editPFirstMes" rows="3"></textarea></label>
<label>Пример диалога <textarea id="editPMesExample" rows="3"></textarea></label>
<label>Lorebook JSON <textarea id="editPLorebook" rows="3"></textarea></label>
<label>Системный промпт (опционально) <textarea id="editPPrompt" rows="3"></textarea></label>
<label><input type="checkbox" id="editPSdEnabled"> Генерировать SD-промпт</label>
<label>LoRA <input type="text" id="editPLora"></label>
<label>Вес LoRA <input type="number" id="editPLoraWeight" value="0.8" min="0" max="2" step="0.1"></label>
<label>Теги внешности (SD) <input type="text" id="editPAppearance"></label>
<div class="modal-buttons">
<button id="personaEditCancel" type="button">Отмена</button>
<button id="personaEditSave" type="button" style="background:#e94560;color:white">Сохранить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="newChatModal">
<div class="modal modal-wizard">
<div class="modal-wizard-header">
<h2>💬 Новый чат</h2>
<div class="wizard-steps">
<span class="wizard-step-dot active" data-step="1">1</span>
<span class="wizard-step-line"></span>
<span class="wizard-step-dot" data-step="2">2</span>
<span class="wizard-step-line"></span>
<span class="wizard-step-dot" data-step="3">3</span>
</div>
</div>
<div class="modal-wizard-body">
<div class="wizard-page active" data-step="1">
<p class="wizard-page-title">Персонаж</p>
<div class="persona-pick-grid" id="newChatPersonaGrid"></div>
</div>
<div class="wizard-page" data-step="2">
<p class="wizard-page-title">Режим</p>
<label class="rpg-mode-option">
<input type="radio" name="newChatRpg" value="0" checked> Обычный чат
</label>
<label class="rpg-mode-option">
<input type="radio" name="newChatRpg" value="1"> RPG режим
</label>
</div>
<div class="wizard-page" data-step="3">
<div id="newChatPlainStep">
<p class="wizard-page-title">Название (опционально)</p>
<label>Название чата
<input type="text" id="newChatTitle" placeholder="Оставь пустым — сгенерируем автоматически">
</label>
</div>
<div id="newChatRpgStep" class="hidden">
<p class="wizard-page-title">Жанры и настройки RPG</p>
<p class="wizard-hint">Можно выбрать несколько жанров</p>
<div class="genre-grid" id="newChatGenreGrid">
<button type="button" class="genre-btn" data-genre="adventure">⚔️ Приключение</button>
<button type="button" class="genre-btn" data-genre="horror">👻 Хоррор</button>
<button type="button" class="genre-btn" data-genre="romance">💕 Романтика</button>
<button type="button" class="genre-btn" data-genre="slice_of_life">☕ Повседневность</button>
<button type="button" class="genre-btn" data-genre="fantasy">🧙 Фэнтези</button>
<button type="button" class="genre-btn" data-genre="sci_fi">🚀 Sci-Fi</button>
</div>
<p class="selected-genres-label hidden" id="newChatGenresLabel"></p>
<div class="rpg-settings-grid" style="margin-top:12px">
<label><input type="checkbox" id="ncSettingDice" checked> 🎲 Проверки d20</label>
<label><input type="checkbox" id="ncSettingNarrator" checked> 📖 Нарратор</label>
<label><input type="checkbox" id="ncSettingQuests" checked> 📜 Квесты</label>
<label><input type="checkbox" id="ncSettingAffinity" checked> 💖 Симпатия</label>
<label><input type="checkbox" id="ncSettingChoices" checked> 🔘 Кнопки выбора</label>
</div>
</div>
</div>
</div>
<div class="modal-buttons modal-wizard-footer">
<button id="newChatCancel" type="button">Отмена</button>
<div class="wizard-nav">
<button id="newChatPrev" type="button" class="wizard-nav-btn hidden">← Назад</button>
<button id="newChatNext" type="button" class="wizard-nav-btn">Далее →</button>
<button id="newChatCreate" type="button" class="hidden" style="background:#e94560;color:white">Создать</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="chatSettingsModal">
<div class="modal modal-wizard" style="max-width:520px">
<div class="modal-wizard-header">
<h2>⚙️ Настройки чата</h2>
</div>
<div class="modal-wizard-body">
<label>Название чата
<input type="text" id="chatSettingsTitle">
</label>
<label class="rpg-mode-option">
<input type="checkbox" id="chatSettingsRpg"> RPG режим
</label>
<div id="chatSettingsRpgBlock" class="hidden">
<p class="wizard-page-title">Жанры</p>
<div class="genre-grid" id="chatSettingsGenreGrid">
<button type="button" class="genre-btn" data-genre="adventure">⚔️ Приключение</button>
<button type="button" class="genre-btn" data-genre="horror">👻 Хоррор</button>
<button type="button" class="genre-btn" data-genre="romance">💕 Романтика</button>
<button type="button" class="genre-btn" data-genre="slice_of_life">☕ Повседневность</button>
<button type="button" class="genre-btn" data-genre="fantasy">🧙 Фэнтези</button>
<button type="button" class="genre-btn" data-genre="sci_fi">🚀 Sci-Fi</button>
</div>
<p class="selected-genres-label hidden" id="chatSettingsGenresLabel"></p>
<p class="wizard-page-title" style="margin-top:12px">Настройки RPG</p>
<div class="rpg-settings-grid">
<label><input type="checkbox" id="csSettingDice"> 🎲 Проверки d20</label>
<label><input type="checkbox" id="csSettingNarrator"> 📖 Нарратор</label>
<label><input type="checkbox" id="csSettingQuests"> 📜 Квесты</label>
<label><input type="checkbox" id="csSettingAffinity"> 💖 Симпатия</label>
<label><input type="checkbox" id="csSettingChoices"> 🔘 Кнопки выбора</label>
</div>
<div class="chat-settings-meta" id="chatSettingsMeta"></div>
</div>
</div>
<div class="modal-buttons modal-wizard-footer">
<button id="chatSettingsCancel" type="button">Отмена</button>
<button id="chatSettingsSave" type="button" style="background:#e94560;color:white">Сохранить</button>
</div>
</div>
</div>
<script type="module" src="/static/js/app.js"></script> <script type="module" src="/static/js/app.js"></script>
</body> </body>
</html> </html>
+12 -2
View File
@@ -1,5 +1,7 @@
import { toggleSidebar, dom } from './state.js'; import { toggleSidebar, dom } from './state.js';
import { initSessions, createNewChat } from './sessions.js'; import {
initSessions, openNewChatWizard, initNewChatWizard, initChatSettings, openChatSettings,
} from './sessions.js';
import { loadPersonas, initPersonaModals } from './personas.js'; import { loadPersonas, initPersonaModals } from './personas.js';
import { sendMessage, clearHistory } from './chat.js'; import { sendMessage, clearHistory } from './chat.js';
@@ -8,7 +10,8 @@ document.getElementById('sidebarToggle').addEventListener('click', () => {
document.getElementById('sidebar').classList.toggle('collapsed', !open); document.getElementById('sidebar').classList.toggle('collapsed', !open);
}); });
document.getElementById('newChatBtn').addEventListener('click', createNewChat); document.getElementById('newChatBtn').addEventListener('click', openNewChatWizard);
document.getElementById('chatSettingsBtn')?.addEventListener('click', openChatSettings);
dom.inputEl.addEventListener('input', () => { dom.inputEl.addEventListener('input', () => {
dom.inputEl.style.height = 'auto'; dom.inputEl.style.height = 'auto';
@@ -25,6 +28,13 @@ dom.inputEl.addEventListener('keydown', (e) => {
dom.sendBtn.addEventListener('click', sendMessage); dom.sendBtn.addEventListener('click', sendMessage);
dom.clearBtn.addEventListener('click', clearHistory); dom.clearBtn.addEventListener('click', clearHistory);
dom.systemBlobToggle?.addEventListener('click', () => {
const hidden = dom.systemBlobContent.classList.toggle('hidden');
dom.systemBlobToggle.textContent = hidden ? 'Показать' : 'Скрыть';
});
initPersonaModals(); initPersonaModals();
initNewChatWizard();
initChatSettings();
await initSessions(); await initSessions();
loadPersonas(); loadPersonas();
+331 -54
View File
@@ -53,6 +53,102 @@ export function createImagePromptBlock(promptText) {
return block; return block;
} }
const OUTCOME_CLASS = {
'critical failure': 'outcome-crit-fail',
'failure': 'outcome-fail',
'success': 'outcome-success',
'critical success': 'outcome-crit-success',
};
function renderNarratorMessage(narrator) {
// narrator = { roll, outcome, text }
const wrapper = document.createElement('div');
wrapper.className = 'message narrator';
const label = document.createElement('div');
label.className = 'label';
label.textContent = '📖 Рассказчик';
wrapper.appendChild(label);
const bubble = document.createElement('div');
bubble.className = 'bubble';
const diceBlock = document.createElement('div');
diceBlock.className = `dice-block ${OUTCOME_CLASS[narrator.outcome] || ''}`;
diceBlock.innerHTML = `<span class="dice-icon">🎲</span><span class="dice-roll">${narrator.roll}</span><span class="dice-outcome">${narrator.outcome}</span>`;
bubble.appendChild(diceBlock);
const textEl = document.createElement('div');
textEl.className = 'narrator-text';
textEl.textContent = narrator.text;
bubble.appendChild(textEl);
wrapper.appendChild(bubble);
dom.messagesEl.appendChild(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
return wrapper;
}
function renderChoices(wrapper, choices) {
if (!choices || !choices.length) return;
const row = document.createElement('div');
row.className = 'choice-row';
for (const c of choices) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'choice-btn';
btn.textContent = c.label;
btn.addEventListener('click', () => {
sendMessage(c.label, true);
});
row.appendChild(btn);
}
wrapper.appendChild(row);
}
function renderDebugBlocks(wrapper, blocks) {
if (!blocks || !blocks.length) return;
for (const b of blocks) {
if (!b?.text) continue;
if (b.type === 'narrator_injection') {
// Show beat injections as narrator bubbles (no dice)
const w = document.createElement('div');
w.className = 'message narrator';
const lbl = document.createElement('div');
lbl.className = 'label';
lbl.textContent = '📖 Рассказчик';
const bub = document.createElement('div');
bub.className = 'bubble';
bub.textContent = b.text;
w.appendChild(lbl);
w.appendChild(bub);
dom.messagesEl.appendChild(w);
}
// facts/status_quo/plot_arc — silently skip (debug only, not shown to user)
}
}
export function updateQuestPanel(quests) {
const list = document.getElementById('questList');
if (!list) return;
list.innerHTML = '';
for (const q of quests) {
const el = document.createElement('div');
el.className = `quest-item quest-${q.status}`;
el.textContent = (q.status === 'done' ? '✅ ' : q.status === 'failed' ? '❌ ' : '🔸 ') + q.title;
list.appendChild(el);
}
}
export function updateAffinityDisplay(affinity) {
const el = dom.affinityDisplay;
if (!el) return;
el.classList.remove('hidden');
const hearts = affinity >= 10 ? '❤️❤️❤️' : affinity >= 5 ? '❤️❤️' : affinity >= 1 ? '❤️' : affinity <= -5 ? '💔' : '🤍';
el.textContent = `${hearts} ${affinity > 0 ? '+' : ''}${affinity}`;
el.className = `affinity-display ${affinity > 5 ? 'affinity-high' : affinity < -3 ? 'affinity-low' : ''}`;
}
async function generateImageViaA1111(promptText, block) { async function generateImageViaA1111(promptText, block) {
block.parentElement.querySelector('.chat-image')?.remove(); block.parentElement.querySelector('.chat-image')?.remove();
block.parentElement.querySelector('.image-error')?.remove(); block.parentElement.querySelector('.image-error')?.remove();
@@ -86,7 +182,208 @@ export function appendChatImage(wrapper, imagePath) {
wrapper.appendChild(img); wrapper.appendChild(img);
} }
export function addMessage(role, content = '', imagePrompt = null, imagePath = null) { function attachMessageActions(wrapper, messageId, role) {
if (!messageId) return;
wrapper.dataset.messageId = String(messageId);
const actions = document.createElement('div');
actions.className = 'message-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.textContent = '✏️';
editBtn.title = 'Редактировать';
editBtn.addEventListener('click', () => startEditMessage(wrapper, messageId));
actions.appendChild(editBtn);
if (role === 'assistant') {
const regenBtn = document.createElement('button');
regenBtn.type = 'button';
regenBtn.textContent = '🔄';
regenBtn.title = 'Перегенерировать';
regenBtn.addEventListener('click', () => regenerateMessage(messageId, wrapper));
actions.appendChild(regenBtn);
}
const branchBtn = document.createElement('button');
branchBtn.type = 'button';
branchBtn.textContent = '🌿';
branchBtn.title = 'Ветка отсюда';
branchBtn.addEventListener('click', () => forkFromMessage(messageId));
actions.appendChild(branchBtn);
wrapper.appendChild(actions);
}
async function startEditMessage(wrapper, messageId) {
const bubble = wrapper.querySelector('.bubble');
if (!bubble || wrapper.querySelector('.bubble-edit')) return;
const original = bubble.textContent;
const ta = document.createElement('textarea');
ta.className = 'bubble-edit';
ta.value = original;
bubble.replaceWith(ta);
wrapper.querySelector('.message-actions')?.remove();
const saveRow = document.createElement('div');
saveRow.className = 'message-actions';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Сохранить';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Отмена';
saveRow.appendChild(saveBtn);
saveRow.appendChild(cancelBtn);
wrapper.appendChild(saveRow);
const truncate = role => confirm(
role === 'user'
? 'Удалить все сообщения после этого? (рекомендуется)'
: 'Удалить все сообщения после этого?',
);
cancelBtn.addEventListener('click', () => reloadChatFromServer(sessionId));
saveBtn.addEventListener('click', async () => {
const role = wrapper.classList.contains('user') ? 'user' : 'assistant';
const doTruncate = truncate(role);
const res = await fetch(`/chat/messages/${messageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: ta.value.trim(), truncate_after: doTruncate }),
});
if (!res.ok) { alert('Ошибка сохранения'); return; }
await reloadChatFromServer(sessionId);
const { loadSessions } = await import('./sessions.js');
loadSessions();
});
}
async function regenerateMessage(messageId, wrapper) {
if (!sessionId) return;
wrapper?.remove();
showTyping();
dom.sendBtn.disabled = true;
try {
const res = await fetch('/chat/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
persona_id: currentPersona,
message_id: messageId,
}),
});
if (!res.ok) throw new Error('Ошибка: ' + res.status);
removeTyping();
await consumeStream(res);
} catch (err) {
removeTyping();
addMessage('assistant', '⚠️ ' + err.message);
} finally {
dom.sendBtn.disabled = false;
}
}
async function forkFromMessage(messageId) {
if (!sessionId) return;
const res = await fetch(`/sessions/${sessionId}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ until_message_id: messageId }),
});
const data = await res.json();
if (!res.ok) { alert(data.detail || 'Ошибка'); return; }
const { switchSession, loadSessions } = await import('./sessions.js');
await switchSession(data.session_id);
await loadSessions();
}
export async function reloadChatFromServer(id) {
const sid = id || sessionId;
if (!sid) return;
const histRes = await fetch(`/chat/history/${sid}`);
if (!histRes.ok) return;
const messages = await histRes.json();
clearMessages();
messages.filter(m => m.role !== 'system').forEach(m => {
addMessage(
m.role === 'user' ? 'user' : 'assistant',
m.content,
m.image_prompt,
m.image_path ? `/static/${m.image_path}` : null,
m.id,
);
});
}
async function consumeStream(res) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let bubble = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.chunk !== undefined) {
if (!bubble) {
bubble = addMessage('assistant', '');
bubble.classList.add('typing-active');
}
bubble.textContent += data.chunk;
bubble.textContent = bubble.textContent.replace(/\[IMAGE_PROMPT:.*?\]/gs, '').trim();
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
}
if (data.done) {
bubble?.classList.remove('typing-active');
if (data.narrator && !bubble) {
renderNarratorMessage(data.narrator);
} else if (data.narrator && bubble) {
const assistantWrapper = bubble.parentElement;
dom.messagesEl.insertBefore(buildNarratorWrapper(data.narrator), assistantWrapper);
}
if (data.image_prompt && bubble) {
bubble.parentElement.appendChild(createImagePromptBlock(data.image_prompt));
}
if (data.image_path && bubble) {
appendChatImage(bubble.parentElement, data.image_path);
}
if (data.image_error && bubble) {
const err = document.createElement('div');
err.className = 'image-error';
err.textContent = '🖼 ' + data.image_error;
bubble.parentElement.appendChild(err);
}
if (data.choices && bubble) {
renderChoices(bubble.parentElement, data.choices);
}
if (data.debug) {
renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
}
if (data.affinity !== undefined) {
updateAffinityDisplay(data.affinity);
}
if (data.quests?.length) {
updateQuestPanel(data.quests);
}
await reloadChatFromServer(sessionId);
const { loadSessions } = await import('./sessions.js');
loadSessions();
}
} catch { /* skip */ }
}
}
}
export function addMessage(role, content = '', imagePrompt = null, imagePath = null, messageId = null) {
updateEmptyState(); updateEmptyState();
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
@@ -149,6 +446,8 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt)); if (prompt) wrapper.appendChild(createImagePromptBlock(prompt));
if (imagePath) appendChatImage(wrapper, imagePath); if (imagePath) appendChatImage(wrapper, imagePath);
attachMessageActions(wrapper, messageId, role);
dom.messagesEl.appendChild(wrapper); dom.messagesEl.appendChild(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight; dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
return bubble; return bubble;
@@ -175,75 +474,32 @@ export function clearMessages() {
} }
} }
export async function sendMessage() { export async function sendMessage(text, isNarratorChoice = false) {
const text = dom.inputEl.value.trim(); if (typeof text !== 'string') text = dom.inputEl.value.trim();
if (!text || !sessionId) return; if (!text || !sessionId) return;
dom.inputEl.value = ''; dom.inputEl.value = '';
dom.inputEl.style.height = 'auto'; dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true; dom.sendBtn.disabled = true;
addMessage('user', text); addMessage('user', isNarratorChoice ? `[${text}]` : text);
showTyping(); showTyping();
try { try {
const res = await fetch('/chat/stream', { const res = await fetch('/chat/stream', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId, persona_id: currentPersona }), body: JSON.stringify({
message: text,
session_id: sessionId,
persona_id: currentPersona,
is_narrator_choice: isNarratorChoice,
}),
}); });
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status); if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let bubble = null;
removeTyping(); removeTyping();
await consumeStream(res);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.chunk !== undefined) {
if (!bubble) {
bubble = addMessage('assistant', '');
bubble.classList.add('typing-active');
}
bubble.textContent += data.chunk;
bubble.textContent = bubble.textContent.replace(/\[IMAGE_PROMPT:.*?\]/gs, '').trim();
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
}
if (data.done) {
bubble?.classList.remove('typing-active');
if (data.image_prompt && bubble) {
bubble.parentElement.appendChild(createImagePromptBlock(data.image_prompt));
}
if (data.image_path && bubble) {
appendChatImage(bubble.parentElement, data.image_path);
}
if (data.image_error && bubble) {
const err = document.createElement('div');
err.className = 'image-error';
err.textContent = '🖼 ' + data.image_error;
bubble.parentElement.appendChild(err);
}
const { loadSessions } = await import('./sessions.js');
loadSessions();
}
} catch { /* skip */ }
}
}
} catch (err) { } catch (err) {
removeTyping(); removeTyping();
addMessage('assistant', '⚠️ Ошибка: ' + err.message); addMessage('assistant', '⚠️ Ошибка: ' + err.message);
@@ -253,6 +509,27 @@ export async function sendMessage() {
} }
} }
function buildNarratorWrapper(narrator) {
const wrapper = document.createElement('div');
wrapper.className = 'message narrator';
const label = document.createElement('div');
label.className = 'label';
label.textContent = '📖 Рассказчик';
wrapper.appendChild(label);
const bubble = document.createElement('div');
bubble.className = 'bubble';
const diceBlock = document.createElement('div');
diceBlock.className = `dice-block ${OUTCOME_CLASS[narrator.outcome] || ''}`;
diceBlock.innerHTML = `<span class="dice-icon">🎲</span><span class="dice-roll">${narrator.roll}</span><span class="dice-outcome">${narrator.outcome}</span>`;
bubble.appendChild(diceBlock);
const textEl = document.createElement('div');
textEl.className = 'narrator-text';
textEl.textContent = narrator.text;
bubble.appendChild(textEl);
wrapper.appendChild(bubble);
return wrapper;
}
export async function clearHistory() { export async function clearHistory() {
if (!sessionId) return; if (!sessionId) return;
await fetch(`/chat/${sessionId}`, { method: 'DELETE' }); await fetch(`/chat/${sessionId}`, { method: 'DELETE' });
+116 -5
View File
@@ -1,5 +1,10 @@
import { currentPersona, setCurrentPersona, sessionId } from './state.js'; import { currentPersona, setCurrentPersona, sessionId } from './state.js';
import { initChat } from './chat.js'; import { initChat } from './chat.js';
import { initWizard } from './utils.js';
export let personaIndex = new Map();
let createWizard;
export function highlightPersona(personaId) { export function highlightPersona(personaId) {
document.querySelectorAll('.persona-card').forEach(c => { document.querySelectorAll('.persona-card').forEach(c => {
@@ -10,6 +15,7 @@ export function highlightPersona(personaId) {
export async function loadPersonas() { export async function loadPersonas() {
const res = await fetch('/personas/'); const res = await fetch('/personas/');
const personas = await res.json(); const personas = await res.json();
personaIndex = new Map(personas.map(p => [p.persona_id, p]));
const bar = document.getElementById('personaBar'); const bar = document.getElementById('personaBar');
bar.innerHTML = ''; bar.innerHTML = '';
@@ -18,11 +24,14 @@ export async function loadPersonas() {
card.className = 'persona-card' + (p.persona_id === currentPersona ? ' active' : ''); card.className = 'persona-card' + (p.persona_id === currentPersona ? ' active' : '');
card.dataset.id = p.persona_id; card.dataset.id = p.persona_id;
const isCard = p.persona_id.startsWith('card_'); const isCard = p.persona_id.startsWith('card_');
const isCustomPersona = p.custom && !isCard;
const avatar = p.avatar_path ? `/static/${p.avatar_path}` : '';
card.innerHTML = ` card.innerHTML = `
<span class="emoji">${p.emoji}</span> ${avatar ? `<img class="avatar" src="${avatar}" alt="">` : `<span class="emoji">${p.emoji}</span>`}
<span class="pname">${p.name}</span> <span class="pname">${p.name}</span>
${p.custom ? `<button class="del-btn" type="button">✕</button>` : ''} ${p.custom ? `<button class="del-btn" type="button">✕</button>` : ''}
${isCard ? `<button class="edit-btn" type="button">✏️</button>` : ''} ${isCard ? `<button class="edit-btn" type="button">✏️</button>` : ''}
${isCustomPersona ? `<button class="edit-persona-btn" type="button">✏️</button>` : ''}
`; `;
card.addEventListener('click', () => selectPersona(p.persona_id)); card.addEventListener('click', () => selectPersona(p.persona_id));
card.querySelector('.del-btn')?.addEventListener('click', async (e) => { card.querySelector('.del-btn')?.addEventListener('click', async (e) => {
@@ -48,6 +57,27 @@ export async function loadPersonas() {
document.getElementById('editLoraWeight').value = data.lora_weight ?? 0.8; document.getElementById('editLoraWeight').value = data.lora_weight ?? 0.8;
document.getElementById('cardEditOverlay').classList.add('open'); document.getElementById('cardEditOverlay').classList.add('open');
}); });
card.querySelector('.edit-persona-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
const r = await fetch(`/personas/${p.persona_id}`);
const data = await r.json();
document.getElementById('editPersonaId').value = p.persona_id;
document.getElementById('editPName').value = data.name || '';
document.getElementById('editPEmoji').value = data.emoji || '';
document.getElementById('editPDesc').value = data.description || '';
document.getElementById('editPPersonality').value = data.personality || '';
document.getElementById('editPScenario').value = data.scenario || '';
document.getElementById('editPFirstMes').value = data.first_mes || '';
document.getElementById('editPMesExample').value = data.mes_example || '';
document.getElementById('editPLorebook').value = data.lorebook_json || '[]';
document.getElementById('editPPrompt').value = data.prompt || '';
document.getElementById('editPSdEnabled').checked = !!data.sd_enabled;
document.getElementById('editPLora').value = data.lora_name || '';
document.getElementById('editPLoraWeight').value = data.lora_weight ?? 0.8;
document.getElementById('editPAppearance').value = data.appearance_tags || '';
document.getElementById('personaEditOverlay').classList.add('open');
});
bar.appendChild(card); bar.appendChild(card);
}); });
@@ -55,7 +85,10 @@ export async function loadPersonas() {
addBtn.type = 'button'; addBtn.type = 'button';
addBtn.className = 'persona-add'; addBtn.className = 'persona-add';
addBtn.innerHTML = '<span>Создать</span>'; addBtn.innerHTML = '<span>Создать</span>';
addBtn.addEventListener('click', () => document.getElementById('modalOverlay').classList.add('open')); addBtn.addEventListener('click', () => {
document.getElementById('modalOverlay').classList.add('open');
createWizard?.reset();
});
bar.appendChild(addBtn); bar.appendChild(addBtn);
const importBtn = document.createElement('button'); const importBtn = document.createElement('button');
@@ -80,8 +113,24 @@ export async function selectPersona(personaId) {
} }
export function initPersonaModals() { export function initPersonaModals() {
const createModal = document.getElementById('modalOverlay');
createWizard = initWizard(createModal.querySelector('.modal-wizard'), {
totalSteps: 3,
validateStep(step) {
if (step !== 1) return true;
const id = document.getElementById('pId').value.trim();
const name = document.getElementById('pName').value.trim();
if (!id || !name) {
alert('Заполни ID и имя');
return false;
}
return true;
},
});
document.getElementById('modalCancel').addEventListener('click', () => { document.getElementById('modalCancel').addEventListener('click', () => {
document.getElementById('modalOverlay').classList.remove('open'); createModal.classList.remove('open');
createWizard.reset();
}); });
document.getElementById('cardModalCancel').addEventListener('click', () => { document.getElementById('cardModalCancel').addEventListener('click', () => {
document.getElementById('cardModalOverlay').classList.remove('open'); document.getElementById('cardModalOverlay').classList.remove('open');
@@ -90,6 +139,14 @@ export function initPersonaModals() {
document.getElementById('cardEditOverlay').classList.remove('open'); document.getElementById('cardEditOverlay').classList.remove('open');
}); });
// custom persona editor (reuses create modal fields)
const personaEditCancel = document.getElementById('personaEditCancel');
if (personaEditCancel) {
personaEditCancel.addEventListener('click', () => {
document.getElementById('personaEditOverlay').classList.remove('open');
});
}
document.getElementById('modalSave').addEventListener('click', async () => { document.getElementById('modalSave').addEventListener('click', async () => {
const data = { const data = {
persona_id: document.getElementById('pId').value.trim(), persona_id: document.getElementById('pId').value.trim(),
@@ -100,9 +157,14 @@ export function initPersonaModals() {
sd_enabled: document.getElementById('pSdEnabled').checked, sd_enabled: document.getElementById('pSdEnabled').checked,
lora_name: document.getElementById('pLora').value.trim(), lora_name: document.getElementById('pLora').value.trim(),
appearance_tags: document.getElementById('pAppearance').value.trim(), appearance_tags: document.getElementById('pAppearance').value.trim(),
personality: document.getElementById('pPersonality').value.trim(),
scenario: document.getElementById('pScenario').value.trim(),
first_mes: document.getElementById('pFirstMes').value.trim(),
mes_example: document.getElementById('pMesExample').value.trim(),
lorebook_json: document.getElementById('pLorebook').value.trim() || '[]',
}; };
if (!data.persona_id || !data.name || !data.prompt) { if (!data.persona_id || !data.name) {
alert('Заполни ID, имя и промт'); alert('Заполни ID и имя');
return; return;
} }
await fetch('/personas/', { await fetch('/personas/', {
@@ -111,6 +173,7 @@ export function initPersonaModals() {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
document.getElementById('modalOverlay').classList.remove('open'); document.getElementById('modalOverlay').classList.remove('open');
createWizard.reset();
await loadPersonas(); await loadPersonas();
await selectPersona(data.persona_id); await selectPersona(data.persona_id);
}); });
@@ -134,6 +197,15 @@ export function initPersonaModals() {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { alert('Ошибка сохранения'); return; } if (!res.ok) { alert('Ошибка сохранения'); return; }
const avatarFile = document.getElementById('editCardAvatar')?.files?.[0];
if (avatarFile) {
const form = new FormData();
form.append('file', avatarFile);
await fetch(`/characters/${cardId}/avatar`, { method: 'POST', body: form });
document.getElementById('editCardAvatar').value = '';
}
document.getElementById('cardEditOverlay').classList.remove('open'); document.getElementById('cardEditOverlay').classList.remove('open');
await loadPersonas(); await loadPersonas();
}); });
@@ -160,5 +232,44 @@ export function initPersonaModals() {
await loadPersonas(); await loadPersonas();
await selectPersona(data.persona_id); await selectPersona(data.persona_id);
}); });
const personaEditSave = document.getElementById('personaEditSave');
if (personaEditSave) {
personaEditSave.addEventListener('click', async () => {
const personaId = document.getElementById('editPersonaId').value;
const body = {
name: document.getElementById('editPName').value.trim() || undefined,
emoji: document.getElementById('editPEmoji').value.trim() || undefined,
description: document.getElementById('editPDesc').value.trim() || undefined,
personality: document.getElementById('editPPersonality').value.trim() || undefined,
scenario: document.getElementById('editPScenario').value.trim() || undefined,
first_mes: document.getElementById('editPFirstMes').value.trim() || undefined,
mes_example: document.getElementById('editPMesExample').value.trim() || undefined,
lorebook_json: document.getElementById('editPLorebook').value.trim() || undefined,
prompt: document.getElementById('editPPrompt').value.trim() || undefined,
sd_enabled: document.getElementById('editPSdEnabled').checked,
lora_name: document.getElementById('editPLora').value.trim() || undefined,
lora_weight: parseFloat(document.getElementById('editPLoraWeight').value) || undefined,
appearance_tags: document.getElementById('editPAppearance').value.trim() || undefined,
};
const res = await fetch(`/personas/${personaId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) { alert('Ошибка сохранения'); return; }
const avatarFile = document.getElementById('editPAvatar')?.files?.[0];
if (avatarFile) {
const form = new FormData();
form.append('file', avatarFile);
await fetch(`/personas/${personaId}/avatar`, { method: 'POST', body: form });
document.getElementById('editPAvatar').value = '';
}
document.getElementById('personaEditOverlay').classList.remove('open');
await loadPersonas();
});
}
} }
+400 -30
View File
@@ -1,6 +1,13 @@
import { sessionId, setSessionId, setCurrentPersona, currentPersona, dom } from './state.js'; import {
import { clearMessages, addMessage, initChat } from './chat.js'; sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled,
import { highlightPersona } from './personas.js'; } from './state.js';
import {
clearMessages, addMessage, initChat, updateQuestPanel, updateAffinityDisplay, reloadChatFromServer,
} from './chat.js';
import { highlightPersona, personaIndex, loadPersonas } from './personas.js';
import {
initWizard, GENRE_LABELS, bindGenreGrid, resetGenreGrid, formatSessionDate,
} from './utils.js';
function escapeTitle(t) { function escapeTitle(t) {
const d = document.createElement('div'); const d = document.createElement('div');
@@ -8,6 +15,38 @@ function escapeTitle(t) {
return d.innerHTML; return d.innerHTML;
} }
let newChatPersonaId = currentPersona;
const newChatGenres = new Set();
const chatSettingsGenres = new Set();
export function applySessionUi(session) {
if (!session) return;
dom.headerTitle.textContent = session.title || 'Новый чат';
const rpgOn = !!session.rpg_enabled;
setRpgEnabled(rpgOn);
document.getElementById('rpgBadge')?.classList.toggle('hidden', !rpgOn);
let settings = { quests: true, affinity: true };
try {
settings = { ...settings, ...JSON.parse(session.rpg_settings_json || '{}') };
} catch { /* ignore */ }
document.getElementById('questPanel')?.classList.toggle('hidden', !rpgOn || !settings.quests);
if (rpgOn && settings.affinity) {
updateAffinityDisplay(session.affinity ?? 0);
dom.affinityDisplay?.classList.remove('hidden');
} else {
dom.affinityDisplay?.classList.add('hidden');
}
if (rpgOn && settings.quests) {
fetch(`/sessions/${session.session_id}/quests`)
.then(r => r.ok ? r.json() : [])
.then(q => updateQuestPanel(q))
.catch(() => {});
}
}
export async function loadSessions() { export async function loadSessions() {
const res = await fetch('/sessions/'); const res = await fetch('/sessions/');
const sessions = await res.json(); const sessions = await res.json();
@@ -16,16 +55,49 @@ export async function loadSessions() {
sessions.forEach(s => { sessions.forEach(s => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'session-item' + (s.session_id === sessionId ? ' active' : ''); item.className = 'session-item' + (s.session_id === sessionId ? ' active' : '');
const personaName = personaIndex.get(s.persona_id)?.name || s.persona_id || 'default';
const dateStr = formatSessionDate(s.updated_at);
item.innerHTML = ` item.innerHTML = `
<div class="s-title">${escapeTitle(s.title || 'Новый чат')}</div> <div class="s-row">
<div class="s-title" title="Двойной клик — переименовать">${escapeTitle(s.title || 'Новый чат')}</div>
${s.rpg_enabled ? '<span class="s-badge">RPG</span>' : ''}
${dateStr ? `<span class="s-date">${dateStr}</span>` : ''}
</div>
<div class="s-companion">С: ${escapeTitle(personaName)}</div>
<div class="s-preview">${escapeTitle(s.last_message_preview || '')}</div>
<div class="s-meta">${s.message_count} сообщ.</div> <div class="s-meta">${s.message_count} сообщ.</div>
<button class="s-del" type="button">🗑</button> <button class="s-del" type="button">🗑</button>
`; `;
item.addEventListener('click', () => switchSession(s.session_id)); item.addEventListener('click', (e) => {
if (e.target.closest('.s-del') || item.querySelector('.s-title')?.isContentEditable) return;
switchSession(s.session_id);
});
const titleEl = item.querySelector('.s-title');
titleEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
titleEl.contentEditable = 'true';
titleEl.focus();
});
titleEl.addEventListener('blur', async () => {
titleEl.contentEditable = 'false';
const t = titleEl.textContent.trim();
if (t && t !== (s.title || 'Новый чат')) {
await fetch(`/sessions/${s.session_id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: t }),
});
if (s.session_id === sessionId) dom.headerTitle.textContent = t;
loadSessions();
}
});
titleEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); }
});
item.querySelector('.s-del').addEventListener('click', async (e) => { item.querySelector('.s-del').addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
await fetch(`/sessions/${s.session_id}`, { method: 'DELETE' }); await fetch(`/sessions/${s.session_id}`, { method: 'DELETE' });
if (s.session_id === sessionId) createNewChat(); if (s.session_id === sessionId) openNewChatWizard();
else loadSessions(); else loadSessions();
}); });
dom.sessionList.appendChild(item); dom.sessionList.appendChild(item);
@@ -41,37 +113,335 @@ export async function switchSession(id) {
export async function loadChatHistory(id) { export async function loadChatHistory(id) {
const sessionRes = await fetch(`/sessions/${id}`); const sessionRes = await fetch(`/sessions/${id}`);
let session = null;
if (sessionRes.ok) { if (sessionRes.ok) {
const s = await sessionRes.json(); session = await sessionRes.json();
dom.headerTitle.textContent = s.title || 'Новый чат'; if (session.persona_id) {
if (s.persona_id) { setCurrentPersona(session.persona_id);
setCurrentPersona(s.persona_id); highlightPersona(session.persona_id);
highlightPersona(s.persona_id);
} }
applySessionUi(session);
} }
const histRes = await fetch(`/chat/history/${id}`); try {
if (!histRes.ok) return; const blobRes = await fetch(`/chat/system/${id}`);
if (blobRes.ok) {
const blob = await blobRes.json();
const parts = [];
if (blob.system_prompt) parts.push(blob.system_prompt);
if (blob.status_quo) parts.push(`--- Status quo ---\n${blob.status_quo}\n---`);
if (blob.facts_json) parts.push(`facts_json: ${blob.facts_json}`);
if (blob.plot_arc_json) parts.push(`plot_arc_json: ${blob.plot_arc_json}`);
dom.systemBlobContent.textContent = parts.filter(Boolean).join('\n\n') || '—';
}
} catch { /* ignore */ }
const messages = await histRes.json(); await reloadChatFromServer(id);
clearMessages();
messages.filter(m => m.role !== 'system').forEach(m => {
addMessage(
m.role === 'user' ? 'user' : 'assistant',
m.content,
m.image_prompt,
m.image_path ? `/static/${m.image_path}` : null,
);
});
} }
export async function createNewChat() { async function bootstrapRpg(sid, personaId, genreValue, settings) {
setSessionId('sess_' + Math.random().toString(36).slice(2, 10)); await fetch(`/sessions/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rpg_enabled: true,
genre: genreValue,
rpg_settings_json: JSON.stringify(settings),
}),
});
const res = await fetch('/chat/rpg/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sid, persona_id: personaId, genre: genreValue }),
});
if (res.ok) {
const data = await res.json();
if (data.quests) updateQuestPanel(data.quests);
if (data.plot_arc) {
const title = data.plot_arc.title || '';
const hint = data.plot_arc.next_beat_hint || '';
if (title || hint) addMessage('assistant', `📖 ${title}${hint ? '\n' + hint : ''}`);
}
}
}
function fillNewChatPersonaGrid() {
const grid = document.getElementById('newChatPersonaGrid');
if (!grid) return;
grid.innerHTML = '';
newChatPersonaId = currentPersona;
for (const p of personaIndex.values()) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'persona-pick-card' + (p.persona_id === newChatPersonaId ? ' selected' : '');
card.dataset.id = p.persona_id;
card.innerHTML = `<span class="emoji">${p.emoji || '🤖'}</span>${p.name}`;
card.addEventListener('click', () => {
newChatPersonaId = p.persona_id;
grid.querySelectorAll('.persona-pick-card').forEach(c => {
c.classList.toggle('selected', c.dataset.id === newChatPersonaId);
});
});
grid.appendChild(card);
}
}
function updateNewChatGenresLabel() {
const el = document.getElementById('newChatGenresLabel');
const nextBtn = document.getElementById('newChatNext');
const labels = [...newChatGenres].map(g => GENRE_LABELS[g] || g);
if (el) {
if (labels.length) {
el.textContent = `Выбрано: ${labels.join(' + ')}`;
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
const isRpg = document.querySelector('input[name="newChatRpg"]:checked')?.value === '1';
if (nextBtn && isRpg) {
const wizard = newChatModalEl?.querySelector('.modal-wizard');
const onStep3 = wizard?.querySelector('.wizard-page[data-step="3"]')?.classList.contains('active');
if (onStep3) nextBtn.disabled = newChatGenres.size === 0;
}
}
const newChatModalEl = document.getElementById('newChatModal');
let newChatWizard;
function isNewChatRpg() {
return document.querySelector('input[name="newChatRpg"]:checked')?.value === '1';
}
function syncNewChatStep3() {
const plain = document.getElementById('newChatPlainStep');
const rpg = document.getElementById('newChatRpgStep');
if (isNewChatRpg()) {
plain?.classList.add('hidden');
rpg?.classList.remove('hidden');
} else {
plain?.classList.remove('hidden');
rpg?.classList.add('hidden');
}
}
export function openNewChatWizard() {
fillNewChatPersonaGrid();
resetGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres);
updateNewChatGenresLabel();
document.querySelector('input[name="newChatRpg"][value="0"]')?.click();
document.getElementById('newChatTitle').value = '';
syncNewChatStep3();
newChatWizard?.reset();
newChatModalEl?.classList.add('open');
}
export async function createNewChatFromWizard() {
const sid = 'sess_' + Math.random().toString(36).slice(2, 10);
setSessionId(sid);
setCurrentPersona(newChatPersonaId);
clearMessages(); clearMessages();
dom.headerTitle.textContent = 'Новый чат';
highlightPersona(currentPersona); const customTitle = document.getElementById('newChatTitle')?.value.trim();
const rpg = isNewChatRpg();
await fetch(`/sessions/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persona_id: newChatPersonaId, rpg_enabled: rpg }),
});
if (customTitle) {
await fetch(`/sessions/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: customTitle }),
});
dom.headerTitle.textContent = customTitle;
} else {
const pName = personaIndex.get(newChatPersonaId)?.name || newChatPersonaId;
dom.headerTitle.textContent = rpg ? `${pName} — RPG` : `${pName} — новый чат`;
}
highlightPersona(newChatPersonaId);
await initChat(); await initChat();
loadSessions();
if (rpg) {
const genreValue = [...newChatGenres].join(',') || 'adventure';
const settings = {
dice: document.getElementById('ncSettingDice')?.checked ?? true,
narrator: document.getElementById('ncSettingNarrator')?.checked ?? true,
quests: document.getElementById('ncSettingQuests')?.checked ?? true,
affinity: document.getElementById('ncSettingAffinity')?.checked ?? true,
choices: document.getElementById('ncSettingChoices')?.checked ?? true,
};
await bootstrapRpg(sid, newChatPersonaId, genreValue, settings);
}
await reloadChatFromServer(sid);
newChatModalEl?.classList.remove('open');
const sessionRes = await fetch(`/sessions/${sid}`);
if (sessionRes.ok) applySessionUi(await sessionRes.json());
await loadSessions();
}
export function initNewChatWizard() {
if (!newChatModalEl) return;
newChatWizard = initWizard(newChatModalEl.querySelector('.modal-wizard'), {
totalSteps: 3,
onStepChange(step) {
syncNewChatStep3();
const nextBtn = document.getElementById('newChatNext');
if (step === 3 && isNewChatRpg()) {
nextBtn.disabled = newChatGenres.size === 0;
} else {
nextBtn.disabled = false;
}
},
validateStep(step) {
if (step === 1 && !newChatPersonaId) {
alert('Выбери персонажа');
return false;
}
if (step === 3 && isNewChatRpg() && newChatGenres.size === 0) {
alert('Выбери хотя бы один жанр');
return false;
}
return true;
},
});
bindGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres, updateNewChatGenresLabel);
document.getElementById('newChatCancel')?.addEventListener('click', () => {
newChatModalEl.classList.remove('open');
newChatWizard.reset();
});
document.getElementById('newChatCreate')?.addEventListener('click', createNewChatFromWizard);
}
function updateChatSettingsGenresLabel() {
const el = document.getElementById('chatSettingsGenresLabel');
const labels = [...chatSettingsGenres].map(g => GENRE_LABELS[g] || g);
if (!el) return;
if (labels.length) {
el.textContent = `Выбрано: ${labels.join(' + ')}`;
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
function loadRpgSettingsToDom(prefix, settings) {
document.getElementById(`${prefix}SettingDice`).checked = settings.dice !== false;
document.getElementById(`${prefix}SettingNarrator`).checked = settings.narrator !== false;
document.getElementById(`${prefix}SettingQuests`).checked = settings.quests !== false;
document.getElementById(`${prefix}SettingAffinity`).checked = settings.affinity !== false;
document.getElementById(`${prefix}SettingChoices`).checked = settings.choices !== false;
}
function readRpgSettingsFromDom(prefix) {
return {
dice: document.getElementById(`${prefix}SettingDice`)?.checked ?? true,
narrator: document.getElementById(`${prefix}SettingNarrator`)?.checked ?? true,
quests: document.getElementById(`${prefix}SettingQuests`)?.checked ?? true,
affinity: document.getElementById(`${prefix}SettingAffinity`)?.checked ?? true,
choices: document.getElementById(`${prefix}SettingChoices`)?.checked ?? true,
};
}
export async function openChatSettings() {
if (!sessionId) return;
const res = await fetch(`/sessions/${sessionId}`);
if (!res.ok) return;
const s = await res.json();
document.getElementById('chatSettingsTitle').value = s.title || '';
const rpgOn = !!s.rpg_enabled;
document.getElementById('chatSettingsRpg').checked = rpgOn;
document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !rpgOn);
chatSettingsGenres.clear();
(s.genre || 'adventure').split(',').forEach(g => {
const t = g.trim();
if (t) chatSettingsGenres.add(t);
});
resetGenreGrid(document.getElementById('chatSettingsGenreGrid'), chatSettingsGenres);
document.getElementById('chatSettingsGenreGrid')?.querySelectorAll('.genre-btn').forEach(btn => {
if (chatSettingsGenres.has(btn.dataset.genre)) btn.classList.add('selected');
});
updateChatSettingsGenresLabel();
let settings = {};
try { settings = JSON.parse(s.rpg_settings_json || '{}'); } catch { /* ignore */ }
loadRpgSettingsToDom('cs', settings);
let phase = '';
try {
const arc = JSON.parse(s.plot_arc_json || '{}');
phase = arc.phase || '';
} catch { /* ignore */ }
document.getElementById('chatSettingsMeta').innerHTML = [
`Симпатия: ${s.affinity ?? 0}`,
s.genre ? `Жанр: ${(s.genre || '').split(',').map(g => GENRE_LABELS[g.trim()] || g).join(' + ')}` : '',
phase ? `Фаза арки: ${phase}` : '',
].filter(Boolean).join('<br>');
document.getElementById('chatSettingsModal').classList.add('open');
}
export function initChatSettings() {
bindGenreGrid(
document.getElementById('chatSettingsGenreGrid'),
chatSettingsGenres,
updateChatSettingsGenresLabel,
);
document.getElementById('chatSettingsRpg')?.addEventListener('change', (e) => {
document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !e.target.checked);
});
document.getElementById('chatSettingsCancel')?.addEventListener('click', () => {
document.getElementById('chatSettingsModal').classList.remove('open');
});
document.getElementById('chatSettingsSave')?.addEventListener('click', async () => {
if (!sessionId) return;
const title = document.getElementById('chatSettingsTitle').value.trim();
const rpgOn = document.getElementById('chatSettingsRpg').checked;
const genreValue = [...chatSettingsGenres].join(',') || 'adventure';
const settings = readRpgSettingsFromDom('cs');
const body = {
title: title || undefined,
rpg_enabled: rpgOn,
genre: genreValue,
rpg_settings_json: JSON.stringify(settings),
};
await fetch(`/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (rpgOn) {
const sessionRes = await fetch(`/sessions/${sessionId}`);
const s = sessionRes.ok ? await sessionRes.json() : {};
let arc = {};
try { arc = JSON.parse(s.plot_arc_json || '{}'); } catch { /* ignore */ }
if (!arc || !Object.keys(arc).length) {
await bootstrapRpg(sessionId, currentPersona, genreValue, settings);
}
}
document.getElementById('chatSettingsModal').classList.remove('open');
const updated = await (await fetch(`/sessions/${sessionId}`)).json();
applySessionUi(updated);
dom.headerTitle.textContent = updated.title || 'Новый чат';
await loadSessions();
});
} }
export async function initSessions() { export async function initSessions() {
@@ -79,8 +449,8 @@ export async function initSessions() {
if (sessionId) { if (sessionId) {
const check = await fetch(`/sessions/${sessionId}`); const check = await fetch(`/sessions/${sessionId}`);
if (check.ok) await switchSession(sessionId); if (check.ok) await switchSession(sessionId);
else createNewChat(); else openNewChatWizard();
} else { } else {
createNewChat(); openNewChatWizard();
} }
} }
+8
View File
@@ -1,6 +1,7 @@
export let sessionId = localStorage.getItem('chat_session_id') || null; export let sessionId = localStorage.getItem('chat_session_id') || null;
export let currentPersona = localStorage.getItem('persona_id') || 'default'; export let currentPersona = localStorage.getItem('persona_id') || 'default';
export let sidebarOpen = true; export let sidebarOpen = true;
export let rpgEnabled = false;
export function toggleSidebar() { sidebarOpen = !sidebarOpen; return sidebarOpen; } export function toggleSidebar() { sidebarOpen = !sidebarOpen; return sidebarOpen; }
export function setSessionId(id) { export function setSessionId(id) {
@@ -13,6 +14,8 @@ export function setCurrentPersona(id) {
localStorage.setItem('persona_id', id); localStorage.setItem('persona_id', id);
} }
export function setRpgEnabled(v) { rpgEnabled = !!v; }
export const dom = { export const dom = {
messagesEl: document.getElementById('messages'), messagesEl: document.getElementById('messages'),
inputEl: document.getElementById('input'), inputEl: document.getElementById('input'),
@@ -21,4 +24,9 @@ export const dom = {
sessionList: document.getElementById('sessionList'), sessionList: document.getElementById('sessionList'),
headerTitle: document.getElementById('headerTitle'), headerTitle: document.getElementById('headerTitle'),
emptyState: document.getElementById('emptyState'), emptyState: document.getElementById('emptyState'),
affinityDisplay: document.getElementById('affinityDisplay'),
rpgBadge: document.getElementById('rpgBadge'),
systemBlob: document.getElementById('systemBlob'),
systemBlobContent: document.getElementById('systemBlobContent'),
systemBlobToggle: document.getElementById('systemBlobToggle'),
}; };
+91
View File
@@ -14,3 +14,94 @@ export async function copyToClipboard(text) {
return false; return false;
} }
} }
export const GENRE_LABELS = {
adventure: 'Приключение',
horror: 'Хоррор',
romance: 'Романтика',
slice_of_life: 'Повседневность',
fantasy: 'Фэнтези',
sci_fi: 'Sci-Fi',
};
export function initWizard(modalEl, { totalSteps, onStepChange, validateStep }) {
let step = 1;
const pages = modalEl.querySelectorAll('.wizard-page');
const dots = modalEl.querySelectorAll('.wizard-step-dot');
const prevBtn = modalEl.querySelector('[id$="Prev"]');
const nextBtn = modalEl.querySelector('[id$="Next"]');
const saveBtn = modalEl.querySelector('[id$="Save"], [id$="Confirm"], [id$="Create"]');
function render() {
pages.forEach(p => p.classList.toggle('active', Number(p.dataset.step) === step));
dots.forEach(d => {
const n = Number(d.dataset.step);
d.classList.toggle('active', n === step);
d.classList.toggle('done', n < step);
});
prevBtn?.classList.toggle('hidden', step <= 1);
nextBtn?.classList.toggle('hidden', step >= totalSteps);
saveBtn?.classList.toggle('hidden', step < totalSteps);
onStepChange?.(step);
}
function goTo(next) {
if (next > step && validateStep && !validateStep(step)) return;
step = Math.max(1, Math.min(totalSteps, next));
render();
}
prevBtn?.addEventListener('click', () => goTo(step - 1));
nextBtn?.addEventListener('click', () => goTo(step + 1));
render();
return {
reset() { step = 1; render(); },
getStep: () => step,
goTo,
render,
};
}
export function bindGenreGrid(gridEl, selectedSet, onChange) {
gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.genre-btn');
if (!btn) return;
const genre = btn.dataset.genre;
if (selectedSet.has(genre)) {
selectedSet.delete(genre);
btn.classList.remove('selected');
} else {
selectedSet.add(genre);
btn.classList.add('selected');
}
onChange?.();
});
}
export function resetGenreGrid(gridEl, selectedSet) {
selectedSet.clear();
gridEl.querySelectorAll('.genre-btn').forEach(b => b.classList.remove('selected'));
}
export function getRpgSettingsFromDom(prefix = '') {
const id = (name) => document.getElementById(prefix + name);
return {
dice: id('settingDice')?.checked ?? true,
narrator: id('settingNarrator')?.checked ?? true,
quests: id('settingQuests')?.checked ?? true,
affinity: id('settingAffinity')?.checked ?? true,
choices: id('settingChoices')?.checked ?? true,
};
}
export function formatSessionDate(iso) {
if (!iso) return '';
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
if (Number.isNaN(d.getTime())) return '';
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
}