Added RPG

This commit is contained in:
2026-05-28 14:29:43 +03:00
parent e5c0df308f
commit 87699172de
20 changed files with 1268 additions and 22 deletions
+79 -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,15 @@ 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_action_resolutions(db)
await db.commit() await db.commit()
@@ -68,3 +85,63 @@ 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")
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 ''")
+13 -1
View File
@@ -16,11 +16,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 +39,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(...),
+205 -1
View File
@@ -1,9 +1,11 @@
import json import json
import os import os
import random
import aiosqlite import aiosqlite
from fastapi import APIRouter from fastapi import APIRouter
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
@@ -13,10 +15,14 @@ from services.memory import (
add_message, add_message,
clear_history, clear_history,
get_or_create_session, get_or_create_session,
get_session,
update_session_title, update_session_title,
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,
add_action_resolution,
) )
from services.personas import get_persona from services.personas import get_persona
from services.sd_prompt import ( from services.sd_prompt import (
@@ -27,6 +33,9 @@ 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"])
@@ -41,6 +50,14 @@ async def get_system_prompt(persona_id: str, history: list, user_message: str =
prompt = persona["prompt"] prompt = persona["prompt"]
# persona-only lorebook
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)
@@ -60,6 +77,20 @@ 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.""" """Called when opening a new chat. Seeds system prompt and first_mes if card persona."""
@@ -73,7 +104,11 @@ async def init_chat(request: ChatRequest):
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 +117,39 @@ 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"
@router.post("/rpg/bootstrap")
async def rpg_bootstrap(req: RpgBootstrapRequest):
"""Generate plot arc early for debugging."""
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 {}
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,
)
if arc:
from services.memory import update_session_plot_arc
await update_session_plot_arc(req.session_id, json.dumps(arc, ensure_ascii=False))
return {"plot_arc": arc}
@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,7 +157,77 @@ 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)
# Experimental RPG: inject persistent facts + global plot
arc = {}
if session and session.get("rpg_enabled"):
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
if facts_block:
system_prompt = system_prompt + "\n\n" + facts_block
# load plot arc
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---"
# d20 outcome directive
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"
system_prompt = (
system_prompt
+ f"\n\n--- Mechanics ---\n"
+ f"Roll d20={roll}. Outcome: {outcome}.\n"
+ "You MUST incorporate this outcome into the narrative result.\n"
+ "---"
)
# System/Narrator pre-pass: add directives for the next reply + optional status quo update
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")
)
pre = await narrator_pre(
persona.get("name", persona_id),
recent_txt,
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_block,
roll,
outcome,
)
directives = pre.get("directives") or []
if directives:
system_prompt = system_prompt + "\n\n--- Narrator directives ---\n" + "\n".join(f"- {d}" for d in directives) + "\n---"
pre_sq = (pre.get("status_quo_update") or "").strip()
if pre_sq:
await update_session_status_quo(request.session_id, pre_sq)
session["status_quo"] = pre_sq
resolution_text = (pre.get("resolution_text") or "").strip()
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,
)
if not history: if not history:
await add_message(request.session_id, "system", system_prompt) await add_message(request.session_id, "system", system_prompt)
@@ -109,6 +247,7 @@ async def chat_stream(request: ChatRequest):
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,6 +272,68 @@ async def chat_stream(request: ChatRequest):
image_prompt=prompt_str, image_prompt=prompt_str,
) )
# Experimental RPG: facts autosave and plot generation
choices = []
debug_blocks = []
if session and session.get("rpg_enabled"):
# generate arc once
if not arc:
persona = await get_persona(persona_id) or {}
arc = await generate_plot_arc(
persona.get("name", persona_id),
persona.get("description", ""),
persona.get("scenario", ""),
persona.get("first_mes", ""),
facts_block=facts_block,
)
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)})
# event-driven beat injection
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})
beat_choices = beats[0].get("choices") or []
if beat_choices:
choices = (choices or []) + beat_choices
# extract facts
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)})
# System/Narrator post-pass: update status quo and optionally produce extra choices
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 update ---\n{sq}\n---"})
extra_choices = post.get("choices") or []
if extra_choices:
choices = (choices or []) + extra_choices
count = await get_message_count(request.session_id) count = await get_message_count(request.session_id)
if count == 2: if count == 2:
title = request.message[:40] + ("" if len(request.message) > 40 else "") title = request.message[:40] + ("" if len(request.message) > 40 else "")
@@ -155,6 +356,9 @@ async def chat_stream(request: ChatRequest):
'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,
'resolution': {'roll': roll, 'outcome': outcome, 'text': resolution_text} if (session and session.get('rpg_enabled') and resolution_text) else None,
})}\n\n" })}\n\n"
return StreamingResponse( return StreamingResponse(
+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):
+13 -1
View File
@@ -6,7 +6,11 @@ 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,
) )
router = APIRouter(prefix="/sessions", tags=["sessions"]) router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -39,6 +43,14 @@ async def patch_session(session_id: str, data: dict):
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"])
return {"status": "updated"} return {"status": "updated"}
+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 = {
+111
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,13 +58,113 @@ 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,))
+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---"
+104
View File
@@ -0,0 +1,104 @@
import json
import os
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.
You do NOT roleplay as the character. You update the status quo and enforce mechanics.
Return ONLY valid JSON (no markdown):
{
"directives": ["short imperative rules for the next character reply"],
"resolution_text": "what actually happens as the result of the user's described action (1-2 sentences)",
"status_quo_update": "optional short update about the world's state after applying outcome",
"choices": [{"id":"a","label":"..."}, ...]
}
Rules:
- directives must be actionable constraints (tone, consequences, new obstacles).
- resolution_text must be consistent with roll/outcome and should not contradict established facts.
- If outcome is failure/critical failure, impose meaningful complication.
- If outcome is success/critical success, allow progress and reward.
- Keep it short."""
NARRATOR_POST_SYSTEM = """You are the System/Narrator of an RPG chat.
After the character replied, update persistent status quo and facts.
Return ONLY valid JSON (no markdown):
{
"status_quo_update": "what changed in the world/state (short)",
"facts": ["durable facts only"],
"choices": [{"id":"a","label":"..."}, ...]
}
Rules:
- status_quo_update should be 1-3 sentences.
- facts must be stable and non-duplicative.
- choices optional (0-4)."""
async def narrator_pre(
persona_name: str,
context: str,
global_plot: str,
facts_block: str,
roll: int,
outcome: str,
) -> dict:
user = (
f"Persona: {persona_name}\n"
f"Roll d20={roll}\nOutcome={outcome}\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 {"directives": [], "status_quo_update": "", "choices": []}
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": []}
+85
View File
@@ -0,0 +1,85 @@
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"
ARC_SYSTEM = """You are a narrative designer for an RPG chat.
Given the opening scene (greeting), character info, and current facts, 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 (e.g., distant cry for help, tracks, messenger, uneasy silence).
- Keep injections immersive (in-world narration)."""
async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenario: str, greeting: str, facts_block: str = "") -> dict:
user = (
f"Character: {persona_name}\n"
f"Description: {persona_desc}\n"
f"Scenario: {persona_scenario}\n"
f"Greeting: {greeting}\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
+94
View File
@@ -45,6 +45,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 +111,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 +127,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 +176,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 +279,45 @@ 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;
}
.resolution-block {
margin-top: 8px;
padding: 8px 12px;
background: #11162a;
border: 1px solid #0f3460;
border-left: 3px solid #4a90d9;
border-radius: 8px;
color: #bbb;
font-size: 0.8rem;
}
.resolution-title {
color: #4a90d9;
font-size: 0.75rem;
margin-bottom: 4px;
}
.resolution-text {
white-space: pre-wrap;
}
.typing { .typing {
align-self: flex-start; align-self: flex-start;
display: flex; gap: 4px; display: flex; gap: 4px;
+58 -2
View File
@@ -12,6 +12,10 @@
<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>
<label class="rpg-toggle" title="Experimental RPG mode">
<input type="checkbox" id="rpgToggle">
<span>RPG</span>
</label>
</header> </header>
<div class="app-body"> <div class="app-body">
@@ -25,6 +29,13 @@
<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>
@@ -55,8 +66,23 @@
<label>Описание <label>Описание
<input type="text" id="pDesc" placeholder="Краткое описание"> <input type="text" id="pDesc" placeholder="Краткое описание">
</label> </label>
<label>Системный промт <label>Личность
<textarea id="pPrompt" rows="4" placeholder="Ты — ..."></textarea> <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>
<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
@@ -95,6 +121,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 +140,33 @@
</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>
<script type="module" src="/static/js/app.js"></script> <script type="module" src="/static/js/app.js"></script>
</body> </body>
</html> </html>
+37 -1
View File
@@ -1,4 +1,4 @@
import { toggleSidebar, dom } from './state.js'; import { toggleSidebar, dom, setRpgEnabled } from './state.js';
import { initSessions, createNewChat } from './sessions.js'; import { initSessions, createNewChat } 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';
@@ -25,6 +25,42 @@ 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.rpgToggle?.addEventListener('change', async () => {
setRpgEnabled(dom.rpgToggle.checked);
const { sessionId } = await import('./state.js');
if (!sessionId) return;
await fetch(`/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rpg_enabled: dom.rpgToggle.checked }),
});
// Debug: immediately bootstrap plot arc and show it in chat
if (dom.rpgToggle.checked) {
const { currentPersona } = await import('./state.js');
const res = await fetch('/chat/rpg/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, persona_id: currentPersona }),
});
if (res.ok) {
const data = await res.json();
if (data.plot_arc) {
const { addMessage } = await import('./chat.js');
const title = data.plot_arc.title || 'PlotArc';
const phase = data.plot_arc.phase || '';
const hint = data.plot_arc.next_beat_hint || '';
addMessage('assistant', `--- PlotArc ---\n${title}\nphase: ${phase}\nnext: ${hint}\n---`);
}
}
}
});
dom.systemBlobToggle?.addEventListener('click', () => {
const hidden = dom.systemBlobContent.classList.toggle('hidden');
dom.systemBlobToggle.textContent = hidden ? 'Показать' : 'Скрыть';
});
initPersonaModals(); initPersonaModals();
await initSessions(); await initSessions();
loadPersonas(); loadPersonas();
+56
View File
@@ -53,6 +53,52 @@ export function createImagePromptBlock(promptText) {
return block; return block;
} }
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', () => {
dom.inputEl.value = c.label;
dom.inputEl.focus();
});
row.appendChild(btn);
}
wrapper.appendChild(row);
}
function renderResolution(wrapper, resolution) {
if (!resolution?.text) return;
const block = document.createElement('div');
block.className = 'resolution-block';
block.innerHTML = `
<div class="resolution-title">Resolution (d20=${resolution.roll}, ${resolution.outcome})</div>
<div class="resolution-text"></div>
`;
block.querySelector('.resolution-text').textContent = resolution.text;
wrapper.appendChild(block);
}
function renderDebugBlocks(wrapper, blocks) {
if (!blocks || !blocks.length) return;
for (const b of blocks) {
if (!b?.text) continue;
if (b.type === 'global_plot') {
addMessage('assistant', `--- Global plot ---\n${b.text}\n---`);
} else if (b.type === 'facts') {
addMessage('assistant', b.text);
} else if (b.type === 'status_quo') {
addMessage('assistant', b.text);
} else {
addMessage('assistant', b.text);
}
}
}
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();
@@ -238,6 +284,16 @@ export async function sendMessage() {
err.textContent = '🖼 ' + data.image_error; err.textContent = '🖼 ' + data.image_error;
bubble.parentElement.appendChild(err); bubble.parentElement.appendChild(err);
} }
if (data.choices && bubble) {
renderChoices(bubble.parentElement, data.choices);
}
if (data.resolution && bubble) {
// show resolution under the last user message (best-effort: attach near assistant response)
renderResolution(bubble.parentElement, data.resolution);
}
if (data.debug) {
renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
}
const { loadSessions } = await import('./sessions.js'); const { loadSessions } = await import('./sessions.js');
loadSessions(); loadSessions();
} }
+91 -3
View File
@@ -1,6 +1,8 @@
import { currentPersona, setCurrentPersona, sessionId } from './state.js'; import { currentPersona, setCurrentPersona, sessionId } from './state.js';
import { initChat } from './chat.js'; import { initChat } from './chat.js';
export let personaIndex = new Map();
export function highlightPersona(personaId) { export function highlightPersona(personaId) {
document.querySelectorAll('.persona-card').forEach(c => { document.querySelectorAll('.persona-card').forEach(c => {
c.classList.toggle('active', c.dataset.id === personaId); c.classList.toggle('active', c.dataset.id === personaId);
@@ -10,6 +12,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 +21,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 +54,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);
}); });
@@ -90,6 +117,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 +135,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/', {
@@ -134,6 +174,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 +209,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();
});
}
} }
+23 -2
View File
@@ -1,6 +1,6 @@
import { sessionId, setSessionId, setCurrentPersona, currentPersona, dom } from './state.js'; import { sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled } from './state.js';
import { clearMessages, addMessage, initChat } from './chat.js'; import { clearMessages, addMessage, initChat } from './chat.js';
import { highlightPersona } from './personas.js'; import { highlightPersona, personaIndex } from './personas.js';
function escapeTitle(t) { function escapeTitle(t) {
const d = document.createElement('div'); const d = document.createElement('div');
@@ -16,8 +16,10 @@ 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';
item.innerHTML = ` item.innerHTML = `
<div class="s-title">${escapeTitle(s.title || 'Новый чат')}</div> <div class="s-title">${escapeTitle(s.title || 'Новый чат')}</div>
<div class="s-companion">С: ${escapeTitle(personaName)}</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>
`; `;
@@ -48,7 +50,26 @@ export async function loadChatHistory(id) {
setCurrentPersona(s.persona_id); setCurrentPersona(s.persona_id);
highlightPersona(s.persona_id); highlightPersona(s.persona_id);
} }
if (dom.rpgToggle) {
const enabled = !!s.rpg_enabled;
dom.rpgToggle.checked = enabled;
setRpgEnabled(enabled);
} }
}
// system blob
try {
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 histRes = await fetch(`/chat/history/${id}`); const histRes = await fetch(`/chat/history/${id}`);
if (!histRes.ok) return; if (!histRes.ok) return;
+7
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,8 @@ 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'),
rpgToggle: document.getElementById('rpgToggle'),
systemBlob: document.getElementById('systemBlob'),
systemBlobContent: document.getElementById('systemBlobContent'),
systemBlobToggle: document.getElementById('systemBlobToggle'),
}; };