new RPG system
This commit is contained in:
+305
-23
@@ -396,16 +396,32 @@ async def delete_messages_after(session_id: str, message_id: int):
|
||||
"DELETE FROM messages WHERE session_id = ? AND id > ?",
|
||||
(session_id, message_id),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
await db.commit()
|
||||
await restore_session_to_message(session_id, message_id)
|
||||
|
||||
|
||||
async def delete_message(message_id: int):
|
||||
msg = await get_message(message_id)
|
||||
if not msg:
|
||||
return
|
||||
session_id = msg["session_id"]
|
||||
anchor = await get_max_message_id_before(session_id, message_id)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||
await db.execute(
|
||||
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
await db.commit()
|
||||
await restore_session_to_message(session_id, anchor)
|
||||
|
||||
|
||||
async def delete_message_and_following(session_id: str, message_id: int) -> bool:
|
||||
anchor = await get_max_message_id_before(session_id, message_id)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"DELETE FROM messages WHERE session_id = ? AND id >= ?",
|
||||
@@ -416,6 +432,7 @@ async def delete_message_and_following(session_id: str, message_id: int) -> bool
|
||||
(session_id,),
|
||||
)
|
||||
await db.commit()
|
||||
await restore_session_to_message(session_id, anchor)
|
||||
return True
|
||||
|
||||
|
||||
@@ -456,6 +473,185 @@ async def get_last_message_preview(session_id: str, max_len: int = 80) -> str:
|
||||
return prefix + text
|
||||
|
||||
|
||||
async def get_max_message_id_before(session_id: str, message_id: int) -> int | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT MAX(id) FROM messages WHERE session_id = ? AND id < ?",
|
||||
(session_id, message_id),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
return int(row[0])
|
||||
|
||||
|
||||
async def _collect_action_resolutions(session_id: str) -> list[dict]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""SELECT message_id, intent_text, roll, outcome, resolution_text
|
||||
FROM action_resolutions
|
||||
WHERE session_id = ?
|
||||
ORDER BY id""",
|
||||
(session_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"message_id": r["message_id"],
|
||||
"intent_text": r["intent_text"],
|
||||
"roll": r["roll"],
|
||||
"outcome": r["outcome"],
|
||||
"resolution_text": r["resolution_text"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def save_state_snapshot(session_id: str, message_id: int) -> None:
|
||||
"""Persist RPG session state as it exists right after this message."""
|
||||
session = await get_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
quests = await get_quests(session_id)
|
||||
resolutions = await _collect_action_resolutions(session_id)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO session_state_snapshots
|
||||
(message_id, session_id, facts_json, global_plot, status_quo,
|
||||
plot_arc_json, affinity, outfit_json, scene_json,
|
||||
narrative_stats_json, quests_json, action_resolutions_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
facts_json = excluded.facts_json,
|
||||
global_plot = excluded.global_plot,
|
||||
status_quo = excluded.status_quo,
|
||||
plot_arc_json = excluded.plot_arc_json,
|
||||
affinity = excluded.affinity,
|
||||
outfit_json = excluded.outfit_json,
|
||||
scene_json = excluded.scene_json,
|
||||
narrative_stats_json = excluded.narrative_stats_json,
|
||||
quests_json = excluded.quests_json,
|
||||
action_resolutions_json = excluded.action_resolutions_json""",
|
||||
(
|
||||
message_id,
|
||||
session_id,
|
||||
session.get("facts_json", "[]"),
|
||||
session.get("global_plot", ""),
|
||||
session.get("status_quo", ""),
|
||||
session.get("plot_arc_json", "{}"),
|
||||
int(session.get("affinity") or 0),
|
||||
session.get("outfit_json", "[]"),
|
||||
session.get("scene_json", "{}"),
|
||||
session.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'),
|
||||
json.dumps(quests, ensure_ascii=False),
|
||||
json.dumps(resolutions, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_snapshot_at_or_before(session_id: str, message_id: int) -> dict | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""SELECT * FROM session_state_snapshots
|
||||
WHERE session_id = ? AND message_id <= ?
|
||||
ORDER BY message_id DESC LIMIT 1""",
|
||||
(session_id, message_id),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def restore_session_from_snapshot(session_id: str, snapshot: dict) -> None:
|
||||
from services.rpg_state import DEFAULT_NARRATIVE_STATS
|
||||
|
||||
stats_default = json.dumps(DEFAULT_NARRATIVE_STATS, ensure_ascii=False)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""UPDATE sessions SET
|
||||
facts_json = ?,
|
||||
global_plot = ?,
|
||||
status_quo = ?,
|
||||
plot_arc_json = ?,
|
||||
affinity = ?,
|
||||
outfit_json = ?,
|
||||
scene_json = ?,
|
||||
narrative_stats_json = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ?""",
|
||||
(
|
||||
snapshot.get("facts_json", "[]"),
|
||||
snapshot.get("global_plot", ""),
|
||||
snapshot.get("status_quo", ""),
|
||||
snapshot.get("plot_arc_json", "{}"),
|
||||
int(snapshot.get("affinity") or 0),
|
||||
snapshot.get("outfit_json", "[]"),
|
||||
snapshot.get("scene_json", "{}"),
|
||||
snapshot.get("narrative_stats_json") or stats_default,
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
await db.execute("DELETE FROM rpg_quests WHERE session_id = ?", (session_id,))
|
||||
try:
|
||||
quests = json.loads(snapshot.get("quests_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
quests = []
|
||||
if isinstance(quests, list):
|
||||
for q in quests:
|
||||
if not isinstance(q, dict):
|
||||
continue
|
||||
title = (q.get("title") or "").strip()
|
||||
if title:
|
||||
await db.execute(
|
||||
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
|
||||
(session_id, title[:120], q.get("status", "active")),
|
||||
)
|
||||
await db.execute("DELETE FROM action_resolutions WHERE session_id = ?", (session_id,))
|
||||
try:
|
||||
resolutions = json.loads(snapshot.get("action_resolutions_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
resolutions = []
|
||||
if isinstance(resolutions, list):
|
||||
for r in resolutions:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
await db.execute(
|
||||
"""INSERT INTO action_resolutions
|
||||
(session_id, message_id, intent_text, roll, outcome, resolution_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
r.get("message_id"),
|
||||
r.get("intent_text", ""),
|
||||
int(r.get("roll") or 0),
|
||||
r.get("outcome", ""),
|
||||
r.get("resolution_text", ""),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def restore_session_to_message(session_id: str, anchor_message_id: int | None) -> bool:
|
||||
"""Restore RPG state to snapshot at anchor (or nearest earlier message)."""
|
||||
if anchor_message_id is None:
|
||||
await _reset_persona_bound_state_only(session_id)
|
||||
return False
|
||||
snap = await get_snapshot_at_or_before(session_id, anchor_message_id)
|
||||
if not snap:
|
||||
return False
|
||||
await restore_session_from_snapshot(session_id, snap)
|
||||
return True
|
||||
|
||||
|
||||
async def _reset_persona_bound_state_only(session_id: str) -> None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await _reset_persona_bound_state(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def fork_session(source_session_id: str, until_message_id: int) -> str | None:
|
||||
source = await get_session(source_session_id)
|
||||
if not source:
|
||||
@@ -464,6 +660,8 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
|
||||
import uuid
|
||||
new_id = "sess_" + uuid.uuid4().hex[:8]
|
||||
|
||||
snap = await get_snapshot_at_or_before(source_session_id, until_message_id)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO sessions
|
||||
@@ -476,40 +674,112 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
|
||||
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", "{}"),
|
||||
(snap or source).get("facts_json", "[]"),
|
||||
(snap or source).get("global_plot", ""),
|
||||
(snap or source).get("status_quo", ""),
|
||||
(snap or source).get("plot_arc_json", "{}"),
|
||||
source.get("genre", "adventure"),
|
||||
source.get("rpg_settings_json", "{}"),
|
||||
source.get("affinity", 0),
|
||||
source.get("outfit_json", "[]"),
|
||||
source.get("scene_json", "{}"),
|
||||
source.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'),
|
||||
int((snap or source).get("affinity") or 0),
|
||||
(snap or source).get("outfit_json", "[]"),
|
||||
(snap or source).get("scene_json", "{}"),
|
||||
(snap or source).get(
|
||||
"narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'
|
||||
),
|
||||
),
|
||||
)
|
||||
async with db.execute(
|
||||
"""SELECT role, content, image_prompt, image_path FROM messages
|
||||
"""SELECT id, role, content, image_prompt, image_path,
|
||||
image_prompt_alt, image_path_alt, choices_json
|
||||
FROM messages
|
||||
WHERE session_id = ? AND id <= ? ORDER BY id""",
|
||||
(source_session_id, until_message_id),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
id_map: dict[int, int] = {}
|
||||
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]),
|
||||
cur_ins = await db.execute(
|
||||
"""INSERT INTO messages
|
||||
(session_id, role, content, image_prompt, image_path,
|
||||
image_prompt_alt, image_path_alt, choices_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(new_id, r[1], r[2], r[3], r[4], r[5], r[6], r[7]),
|
||||
)
|
||||
id_map[int(r[0])] = int(cur_ins.lastrowid)
|
||||
if snap:
|
||||
try:
|
||||
quests = json.loads(snap.get("quests_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
quests = []
|
||||
if isinstance(quests, list):
|
||||
for q in quests:
|
||||
if isinstance(q, dict) and (q.get("title") or "").strip():
|
||||
await db.execute(
|
||||
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
|
||||
(new_id, q["title"][:120], q.get("status", "active")),
|
||||
)
|
||||
try:
|
||||
resolutions = json.loads(snap.get("action_resolutions_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
resolutions = []
|
||||
if isinstance(resolutions, list):
|
||||
for res in resolutions:
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
old_mid = res.get("message_id")
|
||||
new_mid = id_map.get(int(old_mid)) if old_mid is not None else None
|
||||
if new_mid is None:
|
||||
continue
|
||||
await db.execute(
|
||||
"""INSERT INTO action_resolutions
|
||||
(session_id, message_id, intent_text, roll, outcome, resolution_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
new_id,
|
||||
new_mid,
|
||||
res.get("intent_text", ""),
|
||||
int(res.get("roll") or 0),
|
||||
res.get("outcome", ""),
|
||||
res.get("resolution_text", ""),
|
||||
),
|
||||
)
|
||||
async with db.execute(
|
||||
"""SELECT message_id, facts_json, global_plot, status_quo, plot_arc_json,
|
||||
affinity, outfit_json, scene_json, narrative_stats_json,
|
||||
quests_json, action_resolutions_json
|
||||
FROM session_state_snapshots
|
||||
WHERE session_id = ? AND message_id <= ?""",
|
||||
(source_session_id, until_message_id),
|
||||
) as snap_cur:
|
||||
snap_rows = await snap_cur.fetchall()
|
||||
for srow in snap_rows:
|
||||
new_mid = id_map.get(int(srow[0]))
|
||||
if new_mid is None:
|
||||
continue
|
||||
res_json = srow[10]
|
||||
try:
|
||||
res_list = json.loads(res_json or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
res_list = []
|
||||
if isinstance(res_list, list):
|
||||
remapped = []
|
||||
for res in res_list:
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
old_mid = res.get("message_id")
|
||||
nm = id_map.get(int(old_mid)) if old_mid is not None else None
|
||||
if nm is not None:
|
||||
remapped.append({**res, "message_id": nm})
|
||||
res_json = json.dumps(remapped, ensure_ascii=False)
|
||||
await db.execute(
|
||||
"""INSERT INTO session_state_snapshots
|
||||
(message_id, session_id, facts_json, global_plot, status_quo,
|
||||
plot_arc_json, affinity, outfit_json, scene_json,
|
||||
narrative_stats_json, quests_json, action_resolutions_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(new_mid, new_id, srow[1], srow[2], srow[3], srow[4], srow[5],
|
||||
srow[6], srow[7], srow[8], srow[9], res_json),
|
||||
)
|
||||
await db.commit()
|
||||
return new_id
|
||||
|
||||
@@ -572,6 +842,17 @@ async def update_message_image_alt(message_id: int, image_path_alt: str):
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_last_message_id(session_id: str) -> int | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(session_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
async def get_last_assistant_message_id(session_id: str) -> int | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
@@ -591,6 +872,7 @@ async def clear_history(session_id: str):
|
||||
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
await _reset_persona_bound_state_only(session_id)
|
||||
|
||||
|
||||
async def update_session_affinity(session_id: str, delta: int):
|
||||
|
||||
Reference in New Issue
Block a user