Fixed SD RPG

This commit is contained in:
2026-06-04 08:05:06 +03:00
parent d4cd8f02f4
commit 6189a5fb74
62 changed files with 6969 additions and 552 deletions
+369
View File
@@ -0,0 +1,369 @@
# RPG-режим: полный алгоритм запросов (шпаргалка)
Документ описывает, **какой HTTP-запрос что получает и что делает**, когда RPG включён полностью (`rpg_enabled = 1`) и в настройках активны все опции мастера:
```json
{
"dice": true,
"narrator": true,
"quests": true,
"affinity": true,
"choices": true,
"stats": false
}
```
> **Шкалы** (`lust` / `stamina` / `tension`): по умолчанию `stats: false`. Если включить чекбокс «Шкалы», после каждого хода добавляется ветка `stats_delta` (см. § «Вариант: stats включены»).
Пример проходит **от создания чата до 3-го сообщения игрока**, с перекрытием основных веток (диалог / d20 / кнопка выбора / сдвиг сюжета / факты / SD).
---
## Модели и роли
| Роль | Env / модель по умолчанию | Где вызывается |
|------|---------------------------|----------------|
| **Персонаж (чат)** | `CHAT_MODEL``mistralai/mistral-nemo` | `POST /chat/stream``stream_message()` |
| **Нарратор pre/post** | `RPG_NARRATOR_MODEL``deepseek/deepseek-chat-v3` | Перед/после ответа персонажа |
| **Сюжетная арка** | `RPG_PLOT_MODEL` → DeepSeek v3 | Opening, fallback если арки нет |
| **Факты** | `RPG_FACTS_MODEL` → DeepSeek v3 | После каждого ответа персонажа |
| **SD-сцена** | `SD_PROMPT_MODEL` или `SYSTEM_MODEL` | Opening + конец каждого stream |
| **Появление (prose)** | `SYSTEM_MODEL` | Только при сохранении тегов персоны (не в ходе RPG) |
Персонаж **не пишет** affinity/stats в БД — только читает их из `runtime_suffix`. Пишет **только narrator post** (`affinity_delta`, `stats_delta`, `scene_update`, …).
---
## Слои промпта (что видит CHAT_MODEL)
```
llm_system = static_prompt + runtime_suffix [+ context_warning]
```
| Слой | Источник | Когда меняется |
|------|----------|----------------|
| **static_prompt** | `get_system_prompt()` — persona + lorebook (последние 5 реплик + текущий user) + `ROLEPLAY_GUARDRAILS` | Каждый stream; в `messages` хранится **только** static (без RPG) |
| **facts_block** | До 20 фактов из `facts_json` | После `extract_facts` на прошлом ходу |
| **PlotArc** | `title`, `phase`, `next_beat_hint` | При генерации/сдвиге арки |
| **Status quo** | `sessions.status_quo` | narrator pre/post, opening |
| **Scene** | `sessions.scene_json` | narrator pre/post, opening |
| **Relationship** | `affinity` + тон | narrator post (`affinity_delta`) |
| **Character state** | lust/stamina/tension | Только если `stats: true` |
| **Narrator directives** | narrator **pre** (текущий ход) | Эфемерно, не в БД |
| **Mechanics** | d20 + outcome (текущий ход) | Только если была проверка |
| **Context warning** | `~N% of CHAT_CONTEXT_MAX` | Если оценка > 85% |
История в LLM: **все** `user` / `assistant` из БД + один объединённый `system`.
---
## Фаза 0: создание RPG-чата (UI → API)
### 0.1 `PATCH /sessions/{id}`
**Тело (пример):**
```json
{
"persona_id": "card_abc",
"rpg_enabled": true,
"genre": "fantasy",
"rpg_settings_json": "{\"dice\":true,\"narrator\":true,...}"
}
```
**Действия:** запись в `sessions` — флаги RPG, жанр, настройки. Сообщений ещё нет.
### 0.2 `POST /chat/init`
**Тело:** `{ "session_id", "persona_id", "first_mes_override"? }`
**Действия:**
1. `get_system_prompt(persona, [], "")` → static (персона, lorebook, guardrails).
2. `upsert_static_system_message` — одна строка `role=system` в `messages`.
3. Если истории нет — `add_message(assistant, first_mes)` из карты / override.
**LLM:** не вызывается.
**БД после:** `messages = [system, assistant(first_mes)]`.
### 0.3 `POST /chat/opening/process`
**Тело:** `{ "session_id", "persona_id", "rpg": true }`
Последовательность **только на opening** (без `narrator_pre`, без stream персонажа):
```mermaid
sequenceDiagram
participant UI
participant API as opening/process
participant Plot as RPG_PLOT_MODEL
participant Nar as RPG_NARRATOR_MODEL
participant SD as SD_PROMPT_MODEL
UI->>API: POST opening/process
API->>Plot: generate_plot_arc(first_mes, persona, genre)
Plot-->>API: plot_arc_json
API->>API: seed rpg_quests from beats
API->>Nar: narrator_post OPENING
Nar-->>API: status_quo, scene, affinity_delta, outfit, choices, quests
API->>API: apply_narrator_post → sessions
API->>SD: generate_sd_prompt + run_sd_for_message
API-->>UI: plot_arc, quests, affinity, choices, image_*
```
| Шаг | Модель | Вход | Выход в БД / UI |
|-----|--------|------|-----------------|
| 1 | `generate_plot_arc` | имя, description, scenario, **first_mes**, facts, genre | `plot_arc_json`; квесты из `beats[].title` |
| 2 | `narrator_post(is_opening=true)` | контекст: `assistant: {first_mes}`; arc; facts | `status_quo`, `scene_json`, `outfit_json`, `affinity += delta`, квесты, **choices** (в ответ API, не в messages) |
| 3 | `generate_sd_prompt` | история, outfit, **scene_json** | картинка на last assistant id |
**Особенности opening для narrator post:**
- Просит заполнить `scene_update` из greeting + scenario.
- Просит **non-zero `affinity_delta`**, если в first_mes явный тёплый/враждебный тон.
- `outfit_update` — начальная одежда из greeting (danbooru-теги).
**UI после:** `reloadChatFromServer`, панель квестов, 💖 affinity, кнопки `choices`, картинка SD.
---
## Состояние сессии перед 1-м сообщением игрока
| Поле | Пример |
|------|--------|
| `messages` | system, assistant(first_mes) |
| `plot_arc_json` | сгенерированная арка |
| `status_quo`, `scene_json`, `outfit_json` | из narrator opening |
| `affinity` | 0 или ±1..2 после opening |
| `facts_json` | `[]` |
| `narrative_stats_json` | `{"lust":0,"stamina":10,"tension":0}` (если stats выкл. — всё равно дефолт в БД) |
---
## Сообщение игрока №1 — обычная реплика (без d20)
**Пример:** игрок пишет: «Привет, как тебя зовут?»
**UI:** `POST /chat/stream`
```json
{
"session_id": "...",
"persona_id": "...",
"message": "Привет, как тебя зовут?",
"is_narrator_choice": false
}
```
### Этап A — подготовка (до stream)
1. **static_prompt** = `get_system_prompt(persona, history, message)` — lorebook по последним 5 репликам + guardrails.
2. **RPG pre (narrator):**
- **Вход user:** persona, user action, Global plot (= full arc JSON), Facts, Recent (до 8 реплик).
- **Модель:** `RPG_NARRATOR_MODEL`.
- **Ожидание:** `needs_check: false` для чистого диалога.
- **Эффекты:**
- `directives` → попадут в `narrator_extra`.
- `status_quo_update``UPDATE sessions.status_quo`.
- `scene_update` (partial) → merge в `scene_json`.
- **Нет** d20, **нет** bubble «Рассказчик».
3. **runtime_suffix** = `build_rpg_runtime_suffix(session)` + `narrator_extra` (directives только).
4. `upsert_static_system_message(static)` — в БД system без RPG-блоков.
5. `add_message(user, message)`.
6. **context_usage** — если > 85%, в конец system добавляется `[Context: ~N% …]`.
7. **llm_messages** = system(static+runtime) + вся история user/assistant.
### Этап B — SSE stream
| Событие SSE | Когда |
|-------------|--------|
| `{ "chunk": "..." }` | Поток `CHAT_MODEL` — ответ персонажа |
| `{ "done": true, ... }` | После сохранения assistant в БД |
**Персонаж:** один запрос stream, читает affinity/scene/status/arc/facts/directives, **не меняет** счётчики.
### Этап C — post-process (после ответа персонажа, тот же HTTP stream)
Порядок внутри `generate()`:
| # | Действие | Модель | Условие |
|---|----------|--------|---------|
| C1 | `generate_plot_arc` | PLOT | Только если `plot_arc_json` пуст (редко после opening) |
| C2 | `should_advance_arc(user_message)` | код | Ключевые слова: отдых → `event_driven:rest`, путь → `travel`, помощь → `help_request` |
| C3 | `pop_matching_beats` + injection | — | Если trig совпал с beat; choices из beat |
| C4 | `advance_phase` | — | Если beats пусты — фаза opening→hook→… |
| C5 | `extract_facts` | FACTS | Последние 10 реплик; merge до 80, в промпт 20 |
| C6 | **`narrator_post`** | NARRATOR | Контекст: последние 8 реплик **включая новый ответ** |
| C7 | **`apply_narrator_post`** | — | status_quo, affinity_delta, scene, outfit, quests, stats_delta* |
| C8 | `generate_sd_prompt` + Comfy | SD | outfit + scene_json + последние 6 реплик |
| C9 | SSE `done` | — | choices, affinity, quests, image_*, debug |
\* `stats_delta` только при `rpg_settings.stats === true`.
**Для реплики №1 (диалог):** narrator pre → no check; post → facts, возможно choices, affinity ±, scene/status.
---
## Сообщение игрока №2 — действие с проверкой d20
**Пример:** «Пытаюсь перепрыгнуть через пропасть»
Тот же `POST /chat/stream`. Отличия в **этапе A**:
```mermaid
flowchart TD
pre1[narrator_pre без броска]
need{needs_check AND dice?}
roll[d20 1-20]
pre2[narrator_pre с roll+outcome]
res[resolution_text + action_resolutions]
chat[CHAT_MODEL stream]
post[narrator_post + facts + SD]
pre1 --> need
need -->|да| roll --> pre2 --> res
need -->|нет| chat
res --> chat
chat --> post
```
| Шаг | Что происходит |
|-----|----------------|
| pre (фаза 1) | `needs_check: true` |
| Бросок | `random 1..20` → outcome: crit fail / fail / success / crit success |
| pre2 | Тот же user + `Roll d20=N`, `Outcome=...`**`resolution_text` обязателен** |
| UI | SSE **`{ "narrator": { roll, outcome, text } }`** **до** chunks — bubble «📖 Рассказчик» |
| runtime | + `--- Mechanics ---` (d20, outcome, «не противоречь») |
| БД | `action_resolutions` — intent, roll, outcome, resolution_text |
| Персонаж | Должен согласовать ответ с resolution + mechanics |
Post-process (C) — **тот же**, что на ходу 1. Facts могут записать «игрок не перепрыгнул», quest может обновиться.
---
## Сообщение игрока №3 — кнопка выбора + сдвиг арки
**Пример:** игрок жмёт choice «Отправиться в лес» (label уходит как user message)
или пишет: «Идём дальше в лес»
**UI:** `sendMessage(label, true)``is_narrator_choice: true`
| Отличие | Поведение |
|---------|-----------|
| Текст в БД | `"[Player chose: Отправиться в лес]"` вместо сырого label |
| pre/post | Как обычный ход; narrator видит метку выбора |
| **should_advance_arc** | Если в тексте есть «идем дальше», «в путь», … → `event_driven:travel` |
| beats | `pop_matching_beats(arc, trig)` — до 1 beat; **injection** в debug; **choices** из beat + post |
| phase | Если beats опустели → `advance_phase` (например hook → complication) |
Остальной pipeline = stream №1 или №2 (в зависимости от needs_check).
---
## Сводная таблица: кто что обновляет (ход 1–3)
| Данные | Opening | narrator pre | CHAT_MODEL | narrator post | extract_facts | arc engine |
|--------|---------|--------------|------------|---------------|---------------|------------|
| messages | first_mes | — | user + assistant | — | — | — |
| plot_arc_json | create | — | read | read | — | advance/beats |
| status_quo | post | pre/post | read | post | — | — |
| scene_json | post | pre/post | read | post | — | — |
| outfit_json | post | — | read | post | — | — |
| affinity | post | — | read | delta | — | — |
| facts_json | — | read | read | post.facts* | merge | — |
| rpg_quests | seed | — | — | post | — | — |
| action_resolutions | — | d20 path | — | — | — | — |
| narrative_stats | default | — | read** | stats_delta** | — | — |
\* facts из post в JSON схеме narrator — сейчас основной путь facts = `extract_facts`.
\** только при `stats: true`.
---
## Варианты настроек (перекрытие)
### `narrator: false`
- Нет narrator pre/post, нет directives/mechanics bubble.
- `runtime_suffix` = только `build_rpg_runtime_suffix` (facts, arc, status, scene, affinity).
- Post-process: нет C6C7; facts/arc/SD могут остаться (arc/SD/facts всё ещё в коде stream).
### `dice: false`
- `needs_check` от pre **игнорируется** — всегда ветка без броска (как диалог).
- pre2 и mechanics не вызываются.
### `affinity: false`
- Нет блока Relationship в runtime; `affinity_delta` не применяется.
### `quests: false`
- Opening не сидирует квесты; post не вызывает `upsert_quest`.
### `choices: false`
- UI не рисует кнопки; post/beat choices не добавляются в SSE.
### `stats: true`
- В runtime: блок lust/stamina/tension (010).
- post: `stats_delta` каждый ключ 2..+2, clamp 010.
- Дефолт stamina = 10.
### `is_narrator_choice: true`
- User content = `[Player chose: …]`.
### Контекст > 85%
- В system добавляется одна строка-предупреждение (не в БД).
---
## Вспомогательные запросы (не в примере 1–3, но в RPG)
| Запрос | Когда |
|--------|-------|
| `GET /chat/system/{id}` | Панель system blob: static, runtime-превью, scene, stats, **context_usage** |
| `GET /chat/history/{id}` | Перезагрузка чата |
| `POST /chat/rpg/bootstrap` | RPG на **старом** чате: arc + **narrator_post opening** + apply (как opening, без нового first_mes) |
| `POST /chat/` (не stream) | Упрощённый ход: static + runtime, один ответ, без narrator pre/post |
---
## Пример хронологии (3 сообщения, все опции ON)
| # | HTTP | LLM-вызовы | Заметки |
|---|------|------------|---------|
| 0 | PATCH session | — | rpg_enabled |
| 0 | POST init | — | system + first_mes |
| 0 | POST opening/process | Plot, Narrator post, SD | arc, scene, affinity, quests, choices |
| 1 | POST stream | Pre (no check), **Chat**, Facts, Post, SD | Диалог; +facts; choices в done |
| 2 | POST stream | Pre (check), **Pre2+d20**, **Chat**, Facts, Post, SD | Bubble рассказчика; action_resolutions |
| 3 | POST stream | Pre, Chat, Facts, Post, SD; arc travel? | `[Player chose:…]` или текст; beat injection |
**Порядок SSE на ходу 2:**
1. `narrator` (d20)
2. `chunk` × N
3. `image_generating` (если SD)
4. `done` (choices, affinity, quests, debug)
---
## Файлы для углубления
| Тема | Файл |
|------|------|
| Stream + RPG | `routers/chat.py``chat_stream` |
| Opening | `services/opening.py` |
| Narrator JSON | `services/rpg_narrator.py` |
| Runtime blocks | `routers/chat.py``build_rpg_runtime_suffix`, `services/rpg_state.py` |
| Persist post | `services/rpg_state.py``apply_narrator_post` |
| Arc / beats | `services/rpg_plot.py` |
| Facts | `services/rpg_facts.py` |
| UI stream | `static/js/chat.js``sendMessage`, `consumeStream` |
| New chat | `static/js/newChatWizard.js` |
| Context % | `services/context_budget.py` |
---
*Обновлено под текущую реализацию RP UX (anti-OOC, scene_json, context blob, rpFormat, bootstrap narrator).*
+17
View File
@@ -86,6 +86,12 @@ async def _migrate_messages_columns(db):
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt TEXT")
if "image_path" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_path TEXT")
if "image_prompt_alt" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt_alt TEXT")
if "image_path_alt" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_path_alt TEXT")
if "choices_json" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN choices_json TEXT")
async def _migrate_personas_columns(db):
@@ -105,6 +111,8 @@ async def _migrate_personas_columns(db):
await db.execute("ALTER TABLE personas ADD COLUMN avatar_path TEXT DEFAULT ''")
if "alternate_greetings_json" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'")
if "appearance_prose" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN appearance_prose TEXT DEFAULT ''")
async def _migrate_sessions_columns(db):
@@ -130,6 +138,13 @@ async def _migrate_sessions_columns(db):
await db.execute("ALTER TABLE sessions ADD COLUMN rpg_settings_json TEXT DEFAULT '{}'")
if "outfit_json" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN outfit_json TEXT DEFAULT '[]'")
if "scene_json" not in cols:
await db.execute("ALTER TABLE sessions ADD COLUMN scene_json TEXT DEFAULT '{}'")
if "narrative_stats_json" not in cols:
await db.execute(
"ALTER TABLE sessions ADD COLUMN narrative_stats_json TEXT DEFAULT "
"'{\"lust\":0,\"stamina\":10,\"tension\":0}'"
)
async def _migrate_rpg_quests(db):
await db.executescript("""
@@ -170,3 +185,5 @@ async def _migrate_characters_columns(db):
await db.execute("ALTER TABLE characters ADD COLUMN avatar_path TEXT DEFAULT ''")
if "alternate_greetings_json" not in cols:
await db.execute("ALTER TABLE characters ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'")
if "appearance_prose" not in cols:
await db.execute("ALTER TABLE characters ADD COLUMN appearance_prose TEXT DEFAULT ''")
+9 -1
View File
@@ -3,9 +3,10 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from routers import chat, personas, sessions, characters, images, translate
from routers import chat, personas, sessions, characters, images, translate, debug
from database.db import init_db
from services.persona_seed import seed_default_personas
from services.system_message_migration import migrate_static_system_messages
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
@@ -14,6 +15,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(messag
async def lifespan(app: FastAPI):
await init_db()
await seed_default_personas()
await migrate_static_system_messages()
yield
@@ -25,6 +27,7 @@ app.include_router(sessions.router)
app.include_router(characters.router)
app.include_router(images.router)
app.include_router(translate.router)
app.include_router(debug.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -34,6 +37,11 @@ async def root():
return FileResponse("static/index.html")
@app.get("/debug")
async def debug_page():
return FileResponse("static/debug.html")
@app.get("/health")
async def health():
return {"status": "ok"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+31
View File
@@ -24,6 +24,37 @@ class RegenerateRequest(BaseModel):
class ForkSessionRequest(BaseModel):
until_message_id: int
class RebindPersonaRequest(BaseModel):
persona_id: str
clear_history: bool = False
class QuestStatusPatch(BaseModel):
status: str
class RpgStateDebugPatch(BaseModel):
"""Debug override for relationship/stats (session-scoped to current player for now)."""
affinity: Optional[int] = None
lust: Optional[int] = None
stamina: Optional[int] = None
tension: Optional[int] = None
class SessionContextPatch(BaseModel):
"""Live-edit RPG / SD context fields for the active session."""
status_quo: Optional[str] = None
global_plot: Optional[str] = None
outfit_json: Optional[str] = None
scene_json: Optional[str] = None
facts_json: Optional[str] = None
plot_arc_json: Optional[str] = None
affinity: Optional[int] = None
lust: Optional[int] = None
stamina: Optional[int] = None
tension: Optional[int] = None
class ChatResponse(BaseModel):
reply: str
session_id: str
+1
View File
@@ -22,6 +22,7 @@ class CardPatch(BaseModel):
first_mes: Optional[str] = None
mes_example: Optional[str] = None
appearance_tags: Optional[str] = None
appearance_prose: Optional[str] = None
lora_name: Optional[str] = None
lora_weight: Optional[float] = None
alternate_greetings_json: Optional[str] = None
+498 -222
View File
@@ -3,14 +3,12 @@ import logging
import os
import random
import aiosqlite
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from database.db import DB_PATH
from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest
from services.llm import send_message, stream_message
from services.llm import LLMError, send_message, stream_message
from services.memory import (
get_history,
add_message,
@@ -18,41 +16,74 @@ from services.memory import (
get_or_create_session,
get_session,
update_session_title,
update_session_persona,
get_message_count,
get_last_assistant_message_id,
update_message_image,
update_session_facts,
update_session_status_quo,
update_session_affinity,
update_session_genre,
update_session_rpg_settings,
update_session_outfit,
update_session_plot_arc,
upsert_quest,
get_quests,
seed_quests_from_arc,
narrator_message_content,
parse_narrator_message,
add_action_resolution,
get_message,
update_message_content,
delete_messages_after,
delete_message,
delete_message_and_following,
update_message_choices,
clear_choices_for_session,
upsert_static_system_message,
)
from services.context_budget import compute_payload_usage, context_warning_line
from services.rpg_state import (
apply_narrator_post,
parse_scene_json,
parse_stats_json,
scene_prompt_block,
affinity_prompt_block,
stats_prompt_block,
format_narrator_outcome_for_llm,
format_user_message_for_llm,
)
from services.personas import get_persona
from services.chat_prompt import get_system_prompt, DEFAULT_PROMPT
from services.session_identity import resolve_session_persona
from services.sd_prompt import generate_sd_prompt, strip_image_prompt_tag, extract_image_prompt_tag
from services.lorebook import get_lorebook_context
from services.rp_sanitize import RP_OUTPUT_REMINDER, strip_ooc_from_reply
from services.sd_images import run_sd_for_message
from services.character_card import get_character
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, advance_phase
from services.rpg_facts import extract_facts, merge_facts_persist, facts_to_prompt, rp_day_from_scene
from services.rpg_context import format_narrator_context
from services.rpg_plot import (
generate_plot_arc,
process_arc_beats,
advance_phase,
replenish_arc_beats,
reconcile_plot_arc,
reconcile_plot_arc,
choices_from_beat,
choices_from_narrator,
)
from services.rpg_narrator import narrator_pre, narrator_post
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chat", tags=["chat"])
DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу."
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
DEFAULT_RPG_SETTINGS = {"dice": True, "narrator": True, "quests": True, "affinity": True, "choices": True}
DEFAULT_RPG_SETTINGS = {
"dice": True,
"narrator": True,
"quests": True,
"affinity": True,
"choices": True,
"stats": False,
}
def get_rpg_settings(session: dict) -> dict:
@@ -62,34 +93,61 @@ def get_rpg_settings(session: dict) -> dict:
return DEFAULT_RPG_SETTINGS
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 ---\nAffinity toward player: {affinity} ({tone}). Reflect this in your attitude and word choice.\n---"
def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str = "") -> str:
runtime_suffix = ""
if facts_block:
runtime_suffix += "\n\n" + facts_block
try:
arc = json.loads(session.get("plot_arc_json") or "{}")
except Exception:
arc = {}
if arc:
runtime_suffix += "\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:
from services.rp_sanitize import status_quo_prompt_block
runtime_suffix += status_quo_prompt_block(status_quo)
scene = parse_scene_json(session.get("scene_json"))
block = scene_prompt_block(scene)
if block:
runtime_suffix += block
if rpg_settings.get("affinity", True):
runtime_suffix += affinity_prompt_block(int(session.get("affinity") or 0))
if rpg_settings.get("stats", False):
stats = parse_stats_json(session.get("narrative_stats_json"))
runtime_suffix += stats_prompt_block(stats)
return runtime_suffix
async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str:
persona = await get_persona(persona_id)
if not persona:
return DEFAULT_PROMPT
prompt = persona["prompt"]
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
if persona.get("lorebook_json"):
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
if persona_id.startswith("card_"):
card = await get_character(persona_id[5:])
if card:
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
return prompt
def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
"""Build LLM payload: one system message (static + runtime), no duplicate system rows."""
out: list[dict] = []
system_used = False
for m in history:
if m["role"] == "system":
if not system_used:
out.append({"role": "system", "content": llm_system_content})
system_used = True
elif m["role"] == "narrator":
data = parse_narrator_message(m.get("content") or "")
if data:
out.append({"role": "user", "content": format_narrator_outcome_for_llm(data)})
elif m["role"] == "user":
has_res = bool(m.get("action_resolution"))
out.append({
"role": "user",
"content": format_user_message_for_llm(
m["content"], has_dice_resolution=has_res
),
})
else:
out.append({"role": m["role"], "content": m["content"]})
if not system_used:
out.insert(0, {"role": "system", "content": llm_system_content})
return out
@router.get("/history/{session_id}")
@@ -100,33 +158,68 @@ async def get_chat_history(session_id: str):
@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)
if session and session.get("rpg_enabled"):
persona_id_pre = (session.get("persona_id") or "default")
persona_pre = await get_persona(persona_id_pre) or {}
await reconcile_plot_arc(
session_id,
persona_name=persona_pre.get("name", persona_id_pre),
recent_context=(session.get("status_quo") or "")[:2000],
genre=session.get("genre") or "adventure",
)
session = await get_session(session_id) or session
persona_id = (session.get("persona_id") if session else None) or "default"
persona = await get_persona(persona_id) or {}
system_msg = next((m for m in history if m.get("role") == "system"), None)
stored = system_msg.get("content") if system_msg else ""
live_static = await get_system_prompt(persona_id, history, "")
system_prompt = live_static if live_static else stored
quests = await get_quests(session_id)
rpg_settings = get_rpg_settings(session) if session else DEFAULT_RPG_SETTINGS
facts_block = facts_to_prompt(session.get("facts_json", "[]")) if session else ""
runtime_suffix = ""
if session and session.get("rpg_enabled"):
runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block)
llm_system = system_prompt + runtime_suffix
context_usage = compute_payload_usage(history, llm_system)
return {
"system_prompt": system_msg.get("content") if system_msg else "",
"persona_id": persona_id,
"persona_name": persona.get("name", persona_id),
"system_prompt": system_prompt,
"status_quo": session.get("status_quo") if session else "",
"global_plot": session.get("global_plot") if session else "",
"facts_json": session.get("facts_json") if session else "[]",
"plot_arc_json": session.get("plot_arc_json") if session else "{}",
"outfit_json": session.get("outfit_json") if session else "[]",
"scene_json": session.get("scene_json") if session else "{}",
"narrative_stats_json": session.get("narrative_stats_json") if session else "{}",
"affinity": session.get("affinity", 0) if session else 0,
"genre": session.get("genre", "") if session else "",
"rpg_settings_json": session.get("rpg_settings_json") if session else "{}",
"rpg_enabled": bool(session.get("rpg_enabled")) if session else False,
"quests": quests,
"context_usage": context_usage,
}
@router.post("/init")
async def init_chat(request: ChatRequest):
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,
request.persona_id or "default",
)
persona_id = await resolve_session_persona(
request.session_id,
request.persona_id,
create_persona=request.persona_id,
)
history = await get_history(request.session_id)
if history:
return {"first_mes": None}
system_prompt = await get_system_prompt(persona_id, [], "")
await add_message(request.session_id, "system", system_prompt)
await upsert_static_system_message(request.session_id, system_prompt, [])
first_mes = None
if request.first_mes_override and request.first_mes_override.strip():
@@ -152,53 +245,67 @@ class RpgBootstrapRequest(BaseModel):
genre: str = "adventure"
class OpeningProcessRequest(BaseModel):
session_id: str
persona_id: str = "default"
rpg: bool = False
@router.post("/opening/process")
async def opening_process(req: OpeningProcessRequest):
await get_or_create_session(req.session_id, req.persona_id)
persona_id = await resolve_session_persona(req.session_id, req.persona_id)
try:
return await process_opening(req.session_id, persona_id, rpg=req.rpg)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@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
persona_id = await resolve_session_persona(req.session_id, req.persona_id)
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,
persona = await get_persona(persona_id) or {}
greeting = await resolve_greeting(req.session_id, persona)
arc = await ensure_plot_arc_and_quests(req.session_id, persona, greeting, req.genre)
session = await get_session(req.session_id) or {}
rpg_settings = get_rpg_settings(session)
if rpg_settings.get("narrator", True) and greeting:
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
post = await narrator_post(
persona.get("name", persona_id),
f"assistant: {greeting}",
arc_json,
facts_block,
is_opening=True,
)
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", []):
title = (beat.get("title") or beat.get("injection", "")).strip()
if title:
await upsert_quest(req.session_id, title[:120])
await apply_narrator_post(req.session_id, post, rpg_settings, session)
quests = await get_quests(req.session_id)
return {"plot_arc": arc, "quests": quests}
updated = await get_session(req.session_id) or {}
return {
"plot_arc": arc,
"quests": quests,
"affinity": updated.get("affinity", 0),
"scene_json": updated.get("scene_json", "{}"),
"narrative_stats_json": updated.get("narrative_stats_json", "{}"),
}
@router.post("/stream")
async def chat_stream(request: ChatRequest):
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, request.persona_id)
persona_id = await resolve_session_persona(
request.session_id,
request.persona_id,
create_persona=request.persona_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)
static_prompt = await get_system_prompt(persona_id, history, request.message)
runtime_suffix = ""
arc = {}
roll = None
@@ -206,26 +313,24 @@ async def chat_stream(request: ChatRequest):
resolution_text = ""
narrator_msg = None # shown as narrator bubble before assistant reply
rpg_settings = {}
facts_block = ""
narrator_extra = ""
pre = {}
directives: list = []
pre_ok = False
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)
quests_list = await get_quests(request.session_id)
narr_ctx = format_narrator_context(
arc, quests_list, session.get("status_quo") or ""
)
if rpg_settings.get("narrator", True):
persona = await get_persona(persona_id) or {}
@@ -241,7 +346,9 @@ async def chat_stream(request: ChatRequest):
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_block,
request.message,
extra_context=narr_ctx,
)
pre_ok = bool(pre.get("_ok"))
needs_check = pre.get("needs_check", False) and rpg_settings.get("dice", True)
@@ -265,6 +372,7 @@ async def chat_stream(request: ChatRequest):
request.message,
roll=roll,
outcome=outcome,
extra_context=narr_ctx,
)
resolution_text = (pre2.get("resolution_text") or "").strip()
directives = pre2.get("directives") or []
@@ -274,66 +382,95 @@ async def chat_stream(request: ChatRequest):
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---"
narrator_extra += (
"\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
pre_for_scene = pre2 if needs_check else pre
scene_up = pre_for_scene.get("scene_update")
if isinstance(scene_up, dict) and scene_up:
from services.rpg_state import merge_scene
from services.memory import update_session_scene
merged = merge_scene(
parse_scene_json(session.get("scene_json")), scene_up
)
scene_str = json.dumps(merged, ensure_ascii=False)
await update_session_scene(request.session_id, scene_str)
session["scene_json"] = scene_str
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}
narrator_msg = {
"roll": roll,
"outcome": outcome,
"text": resolution_text,
"original_intent": request.message,
}
# 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"
+ "---"
if roll is not None and resolution_text:
narrator_extra += (
f"\n\n--- Mechanics (this turn) ---\n"
f"Roll d20={roll}. Outcome: {outcome}.\n"
f"Narrator resolution: {resolution_text}\n"
"The character's next reply MUST match the narrator ruling in the message history "
"(immediately after the player's intent). Do NOT re-enact the attempt as full success on failure.\n"
"---"
)
# is_narrator_choice: wrap message so LLM understands context
runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra
llm_system = static_prompt + runtime_suffix
if persona_id != "default" or (session and session.get("rpg_enabled")):
llm_system += RP_OUTPUT_REMINDER
user_message_content = request.message
if request.is_narrator_choice:
user_message_content = f"[Player chose: {request.message}]"
if not history:
await add_message(request.session_id, "system", system_prompt)
elif history[0]["role"] == "system" and history[0]["content"] != system_prompt:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""UPDATE messages SET content = ?
WHERE session_id = ? AND role = 'system'
AND id = (SELECT MIN(id) FROM messages WHERE session_id = ?)""",
(system_prompt, request.session_id, request.session_id),
)
await db.commit()
await upsert_static_system_message(request.session_id, static_prompt, history)
user_msg_id = None
if not request.skip_user_add:
await add_message(request.session_id, "user", user_message_content)
await clear_choices_for_session(request.session_id)
user_msg_id = await add_message(request.session_id, "user", user_message_content)
if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id:
await add_action_resolution(
request.session_id,
intent_text=request.message,
roll=narrator_msg["roll"],
outcome=narrator_msg["outcome"],
resolution_text=narrator_msg["text"],
message_id=user_msg_id,
)
narrator_msg["user_message_id"] = user_msg_id
if narrator_msg and (narrator_msg.get("text") or "").strip():
await add_message(
request.session_id,
"narrator",
narrator_message_content(narrator_msg),
)
messages = await get_history(request.session_id)
usage = compute_payload_usage(messages, llm_system)
warn = context_warning_line(usage.get("percent", 0))
if warn:
llm_system += warn
llm_messages = messages_for_llm(messages, llm_system)
full_reply = []
async def generate():
nonlocal arc
# Send narrator BEFORE streaming so it appears above the reply
if narrator_msg:
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
try:
async for chunk in stream_message(
[{"role": m["role"], "content": m["content"]} for m in messages]
):
async for chunk in stream_message(llm_messages):
full_reply.append(chunk)
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
except Exception as e:
@@ -342,99 +479,176 @@ async def chat_stream(request: ChatRequest):
return
complete = "".join(full_reply)
display_text = strip_image_prompt_tag(complete)
raw_display = strip_image_prompt_tag(complete)
display_text = strip_ooc_from_reply(raw_display)
hist_with_reply = await get_history(request.session_id) + [
{"role": "assistant", "content": display_text}
]
sd_result = await generate_sd_prompt(
hist_with_reply, persona_id,
outfit_json=session.get("outfit_json", "[]") if session else "[]"
)
prompt_str = (sd_result[0] if sd_result and sd_result[0] else None) or extract_image_prompt_tag(complete)
if (display_text or complete).strip():
await add_message(request.session_id, "assistant", display_text or complete, image_prompt=prompt_str)
if (display_text or raw_display).strip():
await add_message(request.session_id, "assistant", display_text or raw_display)
choices = []
debug_blocks = []
quests_updated = []
narrator_meta = {}
if session and session.get("rpg_enabled"):
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_to_prompt(session.get("facts_json", "[]")),
genre=session.get("genre") or "adventure",
)
try:
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_to_prompt(session.get("facts_json", "[]")),
genre=session.get("genre") or "adventure",
)
if 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):
await seed_quests_from_arc(request.session_id, arc)
quests_list = await get_quests(request.session_id)
if 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", []):
t = (beat.get("title") or beat.get("injection", "")).strip()
if t:
await upsert_quest(request.session_id, t[:120])
beat_ctx = "\n".join(
f"{m['role']}: {m['content']}"
for m in (await get_history(request.session_id))[-6:]
if m.get("role") in ("user", "assistant")
)
arc, beats, pruned, beat_mode = await process_arc_beats(
arc,
quests_list,
request.message,
recent_context=beat_ctx,
last_dice_outcome=outcome if roll is not None else None,
)
if pruned or beats:
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
if pruned:
debug_blocks.append({
"type": "plot_arc_prune",
"text": f"Removed {len(pruned)} beat(s) already completed as quests",
})
if beats:
inj = beats[0].get("injection", "")
if inj:
debug_blocks.append({"type": "narrator_injection", "text": inj})
if rpg_settings.get("choices", True):
choices += choices_from_beat(beats[0])
if beat_mode in ("after_dice", "llm", "trigger", "stuck_recovery"):
debug_blocks.append({
"type": "plot_arc",
"text": (
f"Beat fired ({beat_mode}): "
f"«{beats[0].get('title', '')}»"
),
})
if advance_phase(arc):
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
if pruned and not arc.get("beats"):
narrator_meta["arc_pruned"] = len(pruned)
if beat_mode:
narrator_meta["beat_mode"] = beat_mode
trig = should_advance_arc(request.message)
if trig and arc:
arc, beats = pop_matching_beats(arc, trig, max_beats=1)
if beats:
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):
choices += beats[0].get("choices") or []
if advance_phase(arc):
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
ctx = [
m for m in (await get_history(request.session_id))
if m["role"] in ("user", "assistant")
][-10:]
new_facts = await extract_facts(
ctx,
rp_day_hint=rp_day_from_scene(session.get("scene_json")),
existing_json=session.get("facts_json", "[]"),
)
if new_facts:
merged = await merge_facts_persist(
session.get("facts_json", "[]"),
new_facts,
rp_day_default=rp_day_from_scene(session.get("scene_json")),
scene_context=json.dumps(
parse_scene_json(session.get("scene_json")),
ensure_ascii=False,
),
status_quo=session.get("status_quo") or "",
)
await update_session_facts(request.session_id, merged)
session["facts_json"] = merged
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
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")
)
narr_ctx_post = format_narrator_context(
arc, await get_quests(request.session_id), session.get("status_quo") or ""
)
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", "[]")),
extra_context=narr_ctx_post,
)
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:
debug_blocks.append({"type": "status_quo", "text": sq})
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(request.session_id, sq)
debug_blocks.append({"type": "status_quo", "text": sq})
if rpg_settings.get("choices", True):
choices += choices_from_narrator(post.get("choices") or [])
if rpg_settings.get("choices", True):
choices += post.get("choices") or []
applied = await apply_narrator_post(
request.session_id, post, rpg_settings, session
)
narrator_meta = {
"pre_ok": pre_ok,
"post_ok": bool(post.get("_ok")),
"choices_count": len(choices),
"directives_count": len(directives),
"dice": roll is not None,
**applied,
}
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 not arc.get("beats"):
persona = await get_persona(persona_id) or {}
arc = await replenish_arc_beats(
arc,
persona.get("name", persona_id),
ctx_txt,
await get_quests(request.session_id),
session.get("genre") or "adventure",
)
if arc.get("beats"):
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
debug_blocks.append({
"type": "plot_arc",
"text": f"Added {len(arc.get('beats', []))} new plot beats",
})
narrator_meta["beats_replenished"] = len(arc.get("beats", []))
if rpg_settings.get("quests", True):
await seed_quests_from_arc(request.session_id, arc)
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
from services.outfit_tags import outfit_list_to_json
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
outfit_str = json.dumps(outfit_update, ensure_ascii=False)
await update_session_outfit(request.session_id, outfit_str)
session["outfit_json"] = outfit_str
if rpg_settings.get("quests", True):
for qu in (post.get("quest_updates") or []):
t = (qu.get("title") or "").strip()
if t:
await upsert_quest(request.session_id, t[:120], qu.get("status", "active"))
session["outfit_json"] = outfit_list_to_json(outfit_update)
quests_updated = await get_quests(request.session_id)
except LLMError as e:
logger.warning("RPG post-process skipped after reply: %s", e)
except Exception as e:
logger.exception("RPG post-process failed after reply: %s", e)
count = await get_message_count(request.session_id)
if count == 2 and not request.skip_user_add:
@@ -443,23 +657,63 @@ async def chat_stream(request: ChatRequest):
if (session or {}).get("title", "Новый чат") in ("", "Новый чат"):
await update_session_title(request.session_id, f"{persona.get('name', persona_id)}{preview}")
image_path = None
image_error = None
if prompt_str and SD_AUTO_GENERATE:
updated_session = await get_session(request.session_id) or session
hist = await get_history(request.session_id)
bundle = await generate_sd_prompt(
hist,
persona_id,
outfit_json=updated_session.get("outfit_json", "[]") if updated_session else "[]",
scene_json=updated_session.get("scene_json", "{}") if updated_session else "{}",
)
prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(complete)
msg_id = await get_last_assistant_message_id(request.session_id)
if msg_id and choices:
await update_message_choices(
msg_id, json.dumps(choices, ensure_ascii=False)
)
sd_out: dict = {}
if bundle:
yield f"data: {json.dumps({
'image_generating': True,
'image_prompt': bundle.tag_full,
'image_prompt_alt': bundle.desc_full,
})}\n\n"
sd_out = await run_sd_for_message(bundle, msg_id)
elif prompt_str and SD_AUTO_GENERATE:
yield f"data: {json.dumps({'image_generating': True, 'image_prompt': prompt_str})}\n\n"
rel, err = await sd_service.generate_from_full_prompt(prompt_str)
if rel:
image_path = rel
msg_id = await get_last_assistant_message_id(request.session_id)
sd_out["image_path"] = f"/static/{rel}"
if msg_id:
await update_message_image(msg_id, rel)
else:
image_error = err
sd_out["image_error"] = err
sd_out["image_prompt"] = prompt_str
updated_session = await get_session(request.session_id)
affinity = updated_session.get("affinity", 0) if updated_session else 0
done_payload = {
"done": True,
"assistant_message_id": msg_id,
"assistant_content": display_text or raw_display,
"image_prompt": sd_out.get("image_prompt") or prompt_str,
"image_prompt_alt": sd_out.get("image_prompt_alt"),
"image_path": sd_out.get("image_path"),
"image_path_alt": sd_out.get("image_path_alt"),
"image_error": sd_out.get("image_error"),
"image_error_alt": sd_out.get("image_error_alt"),
"choices": choices,
"debug": debug_blocks,
"affinity": affinity,
"quests": quests_updated,
"narrator_meta": narrator_meta,
}
if rpg_settings.get("stats") and updated_session:
done_payload["narrative_stats"] = parse_stats_json(
updated_session.get("narrative_stats_json")
)
yield f"data: {json.dumps({'done': True, 'image_prompt': prompt_str, 'image_path': f'/static/{image_path}' if image_path else None, 'image_error': image_error, 'choices': choices, 'debug': debug_blocks, 'affinity': affinity, 'quests': quests_updated})}\n\n"
yield f"data: {json.dumps(done_payload)}\n\n"
return StreamingResponse(
generate(),
@@ -470,23 +724,37 @@ async def chat_stream(request: ChatRequest):
@router.post("/", response_model=ChatResponse)
async def chat(request: ChatRequest):
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, request.persona_id)
persona_id = await resolve_session_persona(
request.session_id,
request.persona_id,
create_persona=request.persona_id,
)
history = await get_history(request.session_id)
system_prompt = await get_system_prompt(persona_id, history, request.message)
if not history:
await add_message(request.session_id, "system", system_prompt)
static_prompt = await get_system_prompt(persona_id, history, request.message)
await upsert_static_system_message(request.session_id, static_prompt, history)
await add_message(request.session_id, "user", request.message)
messages = await get_history(request.session_id)
reply = await send_message(
[{"role": m["role"], "content": m["content"]} for m in messages]
session = await get_session(request.session_id)
llm_system = static_prompt
if session and session.get("rpg_enabled"):
rpg_settings = get_rpg_settings(session)
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
llm_system += build_rpg_runtime_suffix(session, rpg_settings, facts_block)
if persona_id != "default" or (session and session.get("rpg_enabled")):
llm_system += RP_OUTPUT_REMINDER
llm_messages = messages_for_llm(messages, llm_system)
reply = await send_message(llm_messages)
display = strip_ooc_from_reply(strip_image_prompt_tag(reply))
bundle = await generate_sd_prompt(
messages,
persona_id,
outfit_json=session.get("outfit_json", "[]") if session else "[]",
scene_json=session.get("scene_json", "{}") if session else "{}",
)
display = strip_image_prompt_tag(reply)
prompt_tuple = await generate_sd_prompt(messages, persona_id)
prompt_str = prompt_tuple[0] if prompt_tuple else extract_image_prompt_tag(reply)
prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(reply)
await add_message(request.session_id, "assistant", display, image_prompt=prompt_str)
@@ -497,6 +765,15 @@ async def chat(request: ChatRequest):
)
@router.delete("/messages/{message_id}")
async def remove_message(message_id: int):
msg = await get_message(message_id)
if not msg:
raise HTTPException(status_code=404, detail="Сообщение не найдено")
await delete_message_and_following(msg["session_id"], message_id)
return {"status": "deleted", "message_id": message_id}
@router.patch("/messages/{message_id}")
async def edit_message(message_id: int, req: MessageEditRequest):
msg = await get_message(message_id)
@@ -527,7 +804,6 @@ async def regenerate_chat(req: RegenerateRequest):
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)
+248
View File
@@ -0,0 +1,248 @@
import json
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import sdbackend as sd_service
from services.comfy_models import list_node_types, parse_model_lists
from services.llm import (
CHAT_MODEL,
LLM_FALLBACK_MODEL,
LLMError,
SYSTEM_MODEL,
send_message,
send_message_with_model,
)
from services.personas import get_all_personas
from services.sd_prompt import (
SD_PROMPT_MODEL,
anima_dual_enabled,
run_prompt_builder,
)
router = APIRouter(prefix="/debug", tags=["debug"])
class ChatMessage(BaseModel):
role: str
content: str
class SdPromptDebugRequest(BaseModel):
persona_id: str = "default"
chat_excerpt: str = ""
messages: list[ChatMessage] | None = None
outfit_json: str = "[]"
appearance_override: str | None = None
use_prose: bool = False
class LlmDebugRequest(BaseModel):
model: str = ""
system: str = ""
user: str = ""
messages: list[ChatMessage] | None = None
class ComfyRawRequest(BaseModel):
method: str = "GET"
path: str = "/system_stats"
params_json: str = "{}"
body_json: str = ""
class ComfyGenerateRequest(BaseModel):
positive: str
negative: str = ""
unet: str | None = None
clip: str | None = None
vae: str | None = None
checkpoint: str | None = None
@router.get("/config")
async def debug_config():
base = sd_service.SD_BASE_URL
return {
"chat_model": CHAT_MODEL,
"system_model": SYSTEM_MODEL,
"llm_fallback_model": LLM_FALLBACK_MODEL,
"sd_prompt_model": SD_PROMPT_MODEL or SYSTEM_MODEL,
"sd_base_url": base,
"sd_has_token": bool(sd_service.SD_QUERY_PARAMS.get("token")),
"sd_anima_dual": anima_dual_enabled(),
"sd_unet": sd_service.SD_UNET,
"sd_clip": sd_service.SD_CLIP,
"sd_vae": sd_service.SD_VAE,
"sd_checkpoint": sd_service.SD_CHECKPOINT,
"sd_steps": sd_service.SD_STEPS,
"sd_cfg": sd_service.SD_CFG,
"router_key_set": bool(os.getenv("ROUTER_KEY")),
}
@router.get("/personas")
async def debug_personas():
personas = await get_all_personas()
return [
{
"persona_id": pid,
"name": p.get("name", pid),
"appearance_tags": p.get("appearance_tags", ""),
}
for pid, p in personas.items()
]
@router.post("/sd-prompt")
async def debug_sd_prompt(req: SdPromptDebugRequest):
msgs = None
if req.messages:
msgs = [m.model_dump() for m in req.messages]
return await run_prompt_builder(
req.persona_id,
messages=msgs,
chat_excerpt=req.chat_excerpt,
outfit_json=req.outfit_json,
appearance_override=req.appearance_override,
use_prose=req.use_prose,
)
@router.post("/llm")
async def debug_llm(req: LlmDebugRequest):
if req.messages:
messages = [m.model_dump() for m in req.messages]
else:
messages = []
if req.system.strip():
messages.append({"role": "system", "content": req.system.strip()})
if req.user.strip():
messages.append({"role": "user", "content": req.user.strip()})
if not messages:
raise HTTPException(status_code=400, detail="Нужны messages или system/user")
model = (req.model or "").strip() or SD_PROMPT_MODEL or SYSTEM_MODEL
try:
if model in (SYSTEM_MODEL, "") and not req.model:
text = await send_message(messages)
else:
text = await send_message_with_model(messages, model)
return {"model": model, "response": text}
except LLMError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/comfy/ping")
async def debug_comfy_ping():
try:
status, body, headers = await sd_service.comfy_api_request("GET", "/system_stats")
return {"ok": status == 200, "status": status, "body": body, "headers": headers}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.get("/comfy/models")
async def debug_comfy_models():
try:
info = await sd_service.fetch_object_info()
return {
"models": parse_model_lists(info),
"configured": {
"unet": sd_service.SD_UNET,
"clip": sd_service.SD_CLIP,
"vae": sd_service.SD_VAE,
"checkpoint": sd_service.SD_CHECKPOINT,
},
"node_type_count": len(list_node_types(info)),
}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/comfy/object_info")
async def debug_comfy_object_info(node: str | None = None):
try:
info = await sd_service.fetch_object_info()
if node:
if node not in info:
raise HTTPException(status_code=404, detail=f"Unknown node: {node}")
return {node: info[node]}
return {
"node_types": list_node_types(info),
"models": parse_model_lists(info),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/comfy/raw")
async def debug_comfy_raw(req: ComfyRawRequest):
path = req.path.strip()
if not path.startswith("/"):
path = "/" + path
try:
params = json.loads(req.params_json or "{}")
if not isinstance(params, dict):
raise ValueError("params_json must be object")
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"params_json: {e}")
body = None
if req.body_json.strip():
try:
body = json.loads(req.body_json)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"body_json: {e}")
method = req.method.upper()
if method not in ("GET", "POST", "PUT", "DELETE"):
raise HTTPException(status_code=400, detail="method must be GET|POST|PUT|DELETE")
try:
status, resp_body, headers = await sd_service.comfy_api_request(
method,
path,
params=params or None,
json_body=body,
timeout=120,
)
return {"status": status, "headers": headers, "body": resp_body}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/comfy/generate")
async def debug_comfy_generate(req: ComfyGenerateRequest):
if not req.positive.strip():
raise HTTPException(status_code=400, detail="positive required")
overrides: dict[str, str] = {}
if req.unet:
overrides["unet"] = req.unet
if req.clip:
overrides["clip"] = req.clip
if req.vae:
overrides["vae"] = req.vae
if req.checkpoint:
overrides["checkpoint"] = req.checkpoint
full = req.positive.strip()
if req.negative.strip():
full += f"\n\nNegative prompt: {req.negative.strip()}"
try:
rel, err = await sd_service.generate_from_full_prompt(
full,
overrides=overrides or None,
)
if not rel:
raise HTTPException(status_code=502, detail=err or "generation failed")
return {"image_path": f"/static/{rel}", "status": "ok"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
+1
View File
@@ -57,6 +57,7 @@ class PersonaPatch(BaseModel):
lora_name: Optional[str] = None
lora_weight: Optional[float] = None
appearance_tags: Optional[str] = None
appearance_prose: Optional[str] = None
personality: Optional[str] = None
scenario: Optional[str] = None
first_mes: Optional[str] = None
+195 -2
View File
@@ -1,8 +1,11 @@
import json
from fastapi import APIRouter, HTTPException
from services.memory import (
get_all_sessions,
get_session,
get_or_create_session,
get_history,
delete_session,
update_session_title,
update_session_persona,
@@ -14,10 +17,27 @@ from services.memory import (
update_session_genre,
update_session_rpg_settings,
get_quests,
update_quest_by_id,
set_session_affinity,
update_session_narrative_stats,
update_session_outfit,
update_session_scene,
update_session_plot_arc,
get_last_message_preview,
fork_session,
)
from models.schemas import ForkSessionRequest
from models.schemas import (
ForkSessionRequest,
RebindPersonaRequest,
QuestStatusPatch,
RpgStateDebugPatch,
SessionContextPatch,
)
from services.rpg_plot import reconcile_plot_arc
from services.rpg_state import parse_stats_json
from services.chat_prompt import get_system_prompt
from services.memory import rebind_session_persona
from services.personas import get_persona
router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -35,9 +55,149 @@ async def list_sessions():
@router.get("/{session_id}/quests")
async def list_quests(session_id: str):
session = await get_session(session_id)
if session and session.get("rpg_enabled"):
persona = await get_persona(session.get("persona_id") or "default") or {}
await reconcile_plot_arc(
session_id,
persona_name=persona.get("name", session.get("persona_id") or "Character"),
recent_context=(session.get("status_quo") or "")[:2000],
genre=session.get("genre") or "adventure",
)
return await get_quests(session_id)
@router.patch("/{session_id}/context")
async def patch_session_context(session_id: str, body: SessionContextPatch):
"""Live-edit session context (outfit, scene, plot, facts, status quo)."""
from services.outfit_tags import parse_and_normalize_outfit_json
session = await get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Сессия не найдена")
if body.status_quo is not None:
await update_session_status_quo(session_id, body.status_quo)
if body.global_plot is not None:
await update_session_global_plot(session_id, body.global_plot)
if body.outfit_json is not None:
try:
normalized = parse_and_normalize_outfit_json(body.outfit_json)
json.loads(normalized)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"outfit_json: {e}") from e
await update_session_outfit(session_id, normalized)
if body.scene_json is not None:
try:
json.loads(body.scene_json or "{}")
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"scene_json: {e}") from e
await update_session_scene(session_id, body.scene_json)
if body.facts_json is not None:
from services.rpg_facts import (
parse_facts_list,
facts_list_to_json,
dedupe_facts_fuzzy,
compress_facts,
FACTS_DEDUP_THRESHOLD,
FACTS_COMPRESS_TARGET,
)
try:
facts = dedupe_facts_fuzzy(parse_facts_list(body.facts_json))
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"facts_json: {e}") from e
if len(facts) > FACTS_DEDUP_THRESHOLD:
facts = await compress_facts(
facts,
status_quo=(session.get("status_quo") or ""),
scene_context=session.get("scene_json") or "{}",
target=FACTS_COMPRESS_TARGET,
)
facts = dedupe_facts_fuzzy(facts)
normalized = facts_list_to_json(facts)
await update_session_facts(session_id, normalized)
if body.plot_arc_json is not None:
try:
json.loads(body.plot_arc_json or "{}")
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"plot_arc_json: {e}") from e
await update_session_plot_arc(session_id, body.plot_arc_json)
if body.affinity is not None:
await set_session_affinity(session_id, body.affinity)
stats_changed = any(
getattr(body, k) is not None for k in ("lust", "stamina", "tension")
)
if stats_changed:
stats = parse_stats_json(session.get("narrative_stats_json"))
for key in ("lust", "stamina", "tension"):
val = getattr(body, key, None)
if val is not None:
stats[key] = max(0, min(10, int(val)))
await update_session_narrative_stats(
session_id, json.dumps(stats, ensure_ascii=False)
)
updated = await get_session(session_id) or session
return {
"status": "updated",
"outfit_json": updated.get("outfit_json", "[]"),
"scene_json": updated.get("scene_json", "{}"),
"affinity": updated.get("affinity", 0),
"narrative_stats": parse_stats_json(updated.get("narrative_stats_json")),
}
@router.patch("/{session_id}/rpg-state")
async def patch_rpg_state(session_id: str, body: RpgStateDebugPatch):
"""Debug: set affinity and/or narrative stats (lust/stamina/tension 010)."""
session = await get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Сессия не найдена")
affinity = session.get("affinity", 0)
if body.affinity is not None:
affinity = await set_session_affinity(session_id, body.affinity)
stats = parse_stats_json(session.get("narrative_stats_json"))
changed_stats = False
for key in ("lust", "stamina", "tension"):
val = getattr(body, key, None)
if val is not None:
stats[key] = max(0, min(10, int(val)))
changed_stats = True
if changed_stats:
import json as _json
await update_session_narrative_stats(
session_id, _json.dumps(stats, ensure_ascii=False)
)
return {
"affinity": affinity,
"narrative_stats": stats,
"target": "current_player",
}
@router.patch("/{session_id}/quests/{quest_id}")
async def patch_quest(session_id: str, quest_id: int, body: QuestStatusPatch):
status = body.status.strip()
if status not in ("active", "done", "failed"):
raise HTTPException(status_code=400, detail="status must be active, done, or failed")
ok = await update_quest_by_id(quest_id, session_id, status)
if not ok:
raise HTTPException(status_code=404, detail="Quest not found")
if status in ("done", "failed"):
session = await get_session(session_id)
if session and session.get("rpg_enabled"):
persona = await get_persona(session.get("persona_id") or "default") or {}
await reconcile_plot_arc(
session_id,
persona_name=persona.get("name", session.get("persona_id") or "Character"),
recent_context=(session.get("status_quo") or "")[:2000],
genre=session.get("genre") or "adventure",
)
return {"status": "updated", "quest_id": quest_id, "new_status": status}
@router.get("/{session_id}")
async def get_session_route(session_id: str):
s = await get_session(session_id)
@@ -46,9 +206,42 @@ async def get_session_route(session_id: str):
return s
@router.post("/{session_id}/rebind-persona")
async def rebind_persona(session_id: str, body: RebindPersonaRequest):
session = await get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Сессия не найдена")
persona = await get_persona(body.persona_id)
if not persona:
raise HTTPException(status_code=400, detail="Персонаж не найден")
hist = [] if body.clear_history else await get_history(session_id)
static = await get_system_prompt(body.persona_id, hist, "")
first_mes = (persona.get("first_mes") or "").strip() if body.clear_history else None
try:
await rebind_session_persona(
session_id,
body.persona_id,
clear_history=body.clear_history,
static_prompt=static,
first_mes=first_mes or None,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"persona_id": body.persona_id,
"persona_name": persona.get("name", body.persona_id),
"system_prompt_preview": static[:500],
"clear_history": body.clear_history,
}
@router.patch("/{session_id}")
async def patch_session(session_id: str, data: dict):
await get_or_create_session(session_id, data.get("persona_id", "default"))
create_pid = data.get("persona_id") if "persona_id" in data else None
await get_or_create_session(session_id, create_pid)
if "title" in data:
await update_session_title(session_id, data["title"])
if "persona_id" in data:
+17 -11
View File
@@ -45,6 +45,7 @@ def parse_card_v2(data: dict, card_id: str | None = None) -> dict:
"first_mes": inner.get("first_mes", ""),
"mes_example": inner.get("mes_example", ""),
"appearance_tags": _extract_appearance(inner),
"appearance_prose": "",
"lorebook_json": json.dumps(entries, ensure_ascii=False),
"alternate_greetings": alternates,
"alternate_greetings_json": json.dumps(alternates, ensure_ascii=False),
@@ -120,6 +121,8 @@ def parse_png_card(file_bytes: bytes) -> dict | None:
def build_system_prompt(card: dict) -> str:
from services.chat_prompt import ROLEPLAY_GUARDRAILS
parts = [
f"You are {card['name']}. Stay in character.",
f"Description: {card['description']}",
@@ -129,6 +132,7 @@ def build_system_prompt(card: dict) -> str:
if card.get("mes_example"):
parts.append(f"Example dialogue:\n{card['mes_example']}")
parts.append("Reply only as the character. Do not add image tags.")
parts.append(ROLEPLAY_GUARDRAILS)
return "\n\n".join(p for p in parts if p.split(": ", 1)[-1].strip())
@@ -141,13 +145,13 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO characters
(card_id, name, description, personality, scenario, first_mes,
mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json,
avatar_path, alternate_greetings_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO characters
(card_id, name, description, personality, scenario, first_mes, mes_example,
raw_json, lora_name, lora_weight, appearance_tags, appearance_prose, lorebook_json, avatar_path,
alternate_greetings_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
card_id,
card["card_id"],
card["name"],
card["description"],
card["personality"],
@@ -157,10 +161,11 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
card["raw_json"],
lora_name,
lora_weight,
card.get("appearance_tags", ""),
card["appearance_tags"],
card.get("appearance_prose", ""),
card["lorebook_json"],
card.get("avatar_path", ""),
alt_json,
card.get("alternate_greetings_json", "[]"),
),
)
await db.commit()
@@ -199,8 +204,8 @@ async def delete_character(card_id: str) -> bool:
async def update_appearance_tags(card_id: str, appearance_tags: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE characters SET appearance_tags = ? WHERE card_id = ?",
(appearance_tags, card_id),
"UPDATE characters SET appearance_tags = ?, appearance_prose = ? WHERE card_id = ?",
(appearance_tags, "", card_id),
)
await db.commit()
@@ -228,7 +233,7 @@ async def preview_card_file(content: bytes, filename: str) -> dict:
async def update_character(card_id: str, fields: dict) -> bool:
allowed = {"name", "description", "personality", "scenario", "first_mes",
"mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path",
"mes_example", "appearance_tags", "appearance_prose", "lora_name", "lora_weight", "avatar_path",
"alternate_greetings_json"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
@@ -295,6 +300,7 @@ async def import_card_file(
"lora_name": lora_name,
"lora_weight": lora_weight,
"appearance_tags": saved.get("appearance_tags", ""),
"appearance_prose": saved.get("appearance_prose", ""),
"avatar_path": saved.get("avatar_path", ""),
"personality": saved.get("personality", ""),
"scenario": saved.get("scenario", ""),
+30
View File
@@ -0,0 +1,30 @@
from services.personas import get_persona
from services.lorebook import get_lorebook_context
from services.character_card import get_character
DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу."
from services.rp_sanitize import ROLEPLAY_GUARDRAILS # noqa: E402 — re-export for imports
async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str:
"""Static character prompt only (no RPG runtime blocks)."""
persona = await get_persona(persona_id)
if not persona:
return DEFAULT_PROMPT
prompt = persona["prompt"]
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
if persona.get("lorebook_json"):
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
if persona_id.startswith("card_"):
card = await get_character(persona_id[5:])
if card:
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
if persona_id != "default":
prompt += "\n\n" + ROLEPLAY_GUARDRAILS
return prompt
+40
View File
@@ -0,0 +1,40 @@
"""Parse ComfyUI /object_info into usable model lists."""
from __future__ import annotations
# Node types whose combo inputs we expose in the debug UI
_MODEL_NODES: dict[str, tuple[str, str]] = {
"checkpoints": ("CheckpointLoaderSimple", "ckpt_name"),
"unets": ("UNETLoader", "unet_name"),
"clips": ("CLIPLoader", "clip_name"),
"vaes": ("VAELoader", "vae_name"),
"loras": ("LoraLoader", "lora_name"),
}
def _combo_options(node_def: dict, input_name: str) -> list[str]:
if not isinstance(node_def, dict):
return []
required = (node_def.get("input") or {}).get("required") or {}
optional = (node_def.get("input") or {}).get("optional") or {}
spec = required.get(input_name) or optional.get(input_name)
if not spec or not isinstance(spec, (list, tuple)):
return []
first = spec[0]
if isinstance(first, list):
return [str(x) for x in first]
return []
def parse_model_lists(object_info: dict) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for key, (node_type, input_name) in _MODEL_NODES.items():
node_def = object_info.get(node_type) or {}
options = _combo_options(node_def, input_name)
if options:
out[key] = options
return out
def list_node_types(object_info: dict) -> list[str]:
return sorted(k for k in object_info.keys() if isinstance(object_info.get(k), dict))
+30
View File
@@ -0,0 +1,30 @@
import os
CHAT_CONTEXT_MAX = int(os.getenv("CHAT_CONTEXT_MAX", "128000"))
def estimate_tokens(text: str) -> int:
return max(0, len(text or "") // 4)
def compute_payload_usage(history: list, llm_system: str) -> dict:
"""Estimate context fill for the payload messages_for_llm would send."""
chars = len(llm_system or "")
for m in history:
if m.get("role") in ("user", "assistant"):
chars += len(m.get("content") or "")
tokens_est = chars // 4 if chars else 0
max_tokens = CHAT_CONTEXT_MAX
percent = round(100.0 * tokens_est / max_tokens, 1) if max_tokens else 0.0
return {
"chars": chars,
"tokens_est": tokens_est,
"max_tokens_est": max_tokens,
"percent": percent,
}
def context_warning_line(percent: float) -> str:
if percent <= 85:
return ""
return f"\n[Context: ~{int(percent)}% of budget — keep replies focused]"
+119 -6
View File
@@ -13,6 +13,8 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
CHAT_MODEL = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo")
SYSTEM_MODEL = os.getenv("SYSTEM_MODEL", "google/gemini-2.5-flash")
# Softer model when primary returns content_filter / empty / API errors (default: CHAT_MODEL).
LLM_FALLBACK_MODEL = (os.getenv("LLM_FALLBACK_MODEL") or "").strip() or CHAT_MODEL
HEADERS = {
"Authorization": f"Bearer {OPENROUTER_KEY}",
@@ -21,26 +23,128 @@ HEADERS = {
}
class LLMError(Exception):
"""OpenRouter returned an error or an unexpected response shape."""
def _parse_completion_body(data: dict) -> str:
if not isinstance(data, dict):
raise LLMError(f"Invalid API response: expected object, got {type(data).__name__}")
if data.get("error"):
err = data["error"]
if isinstance(err, dict):
msg = err.get("message") or str(err)
code = err.get("code")
else:
msg = str(err)
code = None
suffix = f" (code={code})" if code is not None else ""
raise LLMError(f"OpenRouter error{suffix}: {msg}")
choices = data.get("choices")
if not choices:
preview = str(data)[:400]
raise LLMError(f"OpenRouter response has no 'choices'. Body preview: {preview}")
first = choices[0] if isinstance(choices[0], dict) else {}
message = first.get("message") or {}
if not isinstance(message, dict):
raise LLMError("OpenRouter choice has no message object")
finish = first.get("finish_reason") or ""
native_finish = first.get("native_finish_reason") or ""
blocked_reasons = {"content_filter", "safety", "moderation"}
if finish in blocked_reasons or str(native_finish).upper() in (
"PROHIBITED_CONTENT",
"SAFETY",
"BLOCKED",
):
raise LLMError(
f"Content blocked by provider (finish_reason={finish}, native={native_finish})"
)
content = message.get("content")
if content is not None and str(content).strip():
return str(content)
refusal = message.get("refusal")
if refusal:
raise LLMError(f"Model refused the request: {refusal}")
if finish and finish not in ("stop", "length", "tool_calls", "function_call"):
raise LLMError(
f"OpenRouter finished without content (finish_reason={finish}, native={native_finish})"
)
raise LLMError("OpenRouter returned empty message content")
def _clean(messages: list) -> list:
"""Filter out messages with empty content."""
return [m for m in messages if (m.get("content") or "").strip()]
async def _post(model: str, messages: list, extra: dict | None = None) -> str:
async def _post_once(model: str, messages: list, extra: dict | None = None) -> str:
if not OPENROUTER_KEY:
raise LLMError("ROUTER_KEY is not set in environment")
payload = {"model": model, "messages": _clean(messages), **(extra or {})}
async with httpx.AsyncClient(timeout=90) as client:
r = await client.post(OPENROUTER_URL, headers=HEADERS, json=payload)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
try:
data = r.json()
except Exception as e:
raise LLMError(f"Non-JSON response (HTTP {r.status_code}): {r.text[:300]}") from e
if r.status_code >= 400:
try:
_parse_completion_body(data)
except LLMError:
raise
raise LLMError(f"HTTP {r.status_code}: {data}")
try:
return _parse_completion_body(data)
except LLMError:
logger.warning(
"OpenRouter completion failed model=%s status=%s body=%.500s",
model,
r.status_code,
data,
)
raise
async def _post(model: str, messages: list, extra: dict | None = None) -> str:
"""POST completion; on failure retries once with LLM_FALLBACK_MODEL (usually CHAT_MODEL)."""
try:
return await _post_once(model, messages, extra)
except LLMError as primary_err:
fallback = LLM_FALLBACK_MODEL
if not fallback or fallback == model:
raise
logger.info(
"LLM fallback: %s failed (%s) → retrying with %s",
model,
primary_err,
fallback,
)
try:
return await _post_once(fallback, messages, extra)
except LLMError as fallback_err:
raise LLMError(
f"{primary_err} (fallback {fallback} also failed: {fallback_err})"
) from fallback_err
async def send_message(messages: list) -> str:
"""System model — narrator, facts, SD prompt."""
"""SYSTEM_MODEL with automatic fallback to LLM_FALLBACK_MODEL."""
return await _post(SYSTEM_MODEL, messages)
async def send_message_with_model(messages: list, model: str) -> str:
"""Explicit model — plot arc, narrator override."""
"""Named model (RPG_*, SD_*) with automatic fallback to LLM_FALLBACK_MODEL."""
return await _post(model, messages)
@@ -73,10 +177,19 @@ async def stream_message(messages: list):
return
try:
chunk = json.loads(data)
content = chunk["choices"][0]["delta"].get("content", "")
if chunk.get("error"):
err = chunk["error"]
msg = err.get("message", err) if isinstance(err, dict) else err
raise LLMError(f"OpenRouter stream error: {msg}")
choices = chunk.get("choices") or []
if not choices:
continue
content = (choices[0].get("delta") or {}).get("content", "")
if content:
chunk_count += 1
yield content
except LLMError:
raise
except Exception:
continue
except Exception as e:
+308 -26
View File
@@ -1,8 +1,11 @@
import json
import aiosqlite
from database.db import DB_PATH
async def get_or_create_session(session_id: str, persona_id: str = "default") -> dict:
async def get_or_create_session(session_id: str, persona_id: str | None = None) -> dict:
"""Existing sessions keep their persona_id; persona_id applies only on INSERT."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
@@ -13,9 +16,10 @@ async def get_or_create_session(session_id: str, persona_id: str = "default") ->
if row:
return dict(row)
pid = (persona_id or "default").strip() or "default"
await db.execute(
"INSERT INTO sessions (session_id, persona_id) VALUES (?, ?)",
(session_id, persona_id),
(session_id, pid),
)
await db.commit()
@@ -71,24 +75,104 @@ async def update_session_persona(session_id: str, persona_id: str):
(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 _reset_persona_bound_state(db, session_id)
await db.commit()
async def _reset_persona_bound_state(db: aiosqlite.Connection, session_id: str) -> None:
from services.rpg_state import DEFAULT_NARRATIVE_STATS
stats_default = json.dumps(DEFAULT_NARRATIVE_STATS, ensure_ascii=False)
await db.execute(
"""UPDATE sessions
SET facts_json = '[]',
global_plot = '',
status_quo = '',
plot_arc_json = '{}',
outfit_json = '[]',
affinity = 0,
scene_json = '{}',
narrative_stats_json = ?
WHERE session_id = ?""",
(stats_default, session_id),
)
await db.execute("DELETE FROM action_resolutions WHERE session_id = ?", (session_id,))
await db.execute("DELETE FROM rpg_quests WHERE session_id = ?", (session_id,))
async def upsert_static_system_message(
session_id: str, static_prompt: str, history: list | None = None
) -> bool:
"""Store only static persona prompt in messages. Returns True if written."""
hist = history if history is not None else await get_history(session_id)
async with aiosqlite.connect(DB_PATH) as db:
if not hist:
await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, 'system', ?, NULL, NULL)""",
(session_id, static_prompt),
)
await db.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit()
return True
if hist[0]["role"] == "system":
if hist[0]["content"] == static_prompt:
return False
await db.execute(
"""UPDATE messages SET content = ?
WHERE session_id = ? AND role = 'system'
AND id = (SELECT MIN(id) FROM messages WHERE session_id = ?)""",
(static_prompt, session_id, session_id),
)
await db.commit()
return True
await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, 'system', ?, NULL, NULL)""",
(session_id, static_prompt),
)
await db.commit()
return True
async def delete_dialog_messages(session_id: str) -> None:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"DELETE FROM messages WHERE session_id = ? AND role IN ('user', 'assistant')",
(session_id,),
)
await db.commit()
async def rebind_session_persona(
session_id: str,
persona_id: str,
*,
clear_history: bool = False,
static_prompt: str,
first_mes: str | None = None,
) -> None:
session = await get_session(session_id)
if not session:
raise ValueError("Session not found")
await update_session_persona(session_id, persona_id)
if clear_history:
await delete_dialog_messages(session_id)
history = await get_history(session_id)
await upsert_static_system_message(session_id, static_prompt, history)
if clear_history and first_mes and first_mes.strip():
await add_message(session_id, "assistant", first_mes.strip())
async def update_session_rpg(session_id: str, rpg_enabled: bool):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
@@ -174,25 +258,116 @@ async def delete_session(session_id: str):
await db.commit()
async def get_history(session_id: str) -> list:
async def get_action_resolutions_map(session_id: str) -> dict[int, dict]:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT id, role, content, image_prompt, image_path
"""SELECT message_id, intent_text, roll, outcome, resolution_text
FROM action_resolutions
WHERE session_id = ? AND message_id IS NOT NULL
ORDER BY id""",
(session_id,),
) as cur:
rows = await cur.fetchall()
out: dict[int, dict] = {}
for r in rows:
mid = r["message_id"]
if mid is not None:
out[int(mid)] = {
"intent_text": r["intent_text"],
"roll": r["roll"],
"outcome": r["outcome"],
"resolution_text": r["resolution_text"],
}
return out
def narrator_message_content(narrator: dict) -> str:
return json.dumps(
{
"roll": narrator.get("roll"),
"outcome": narrator.get("outcome"),
"text": narrator.get("text", ""),
"original_intent": narrator.get("original_intent"),
},
ensure_ascii=False,
)
def parse_narrator_message(content: str) -> dict | None:
try:
data = json.loads(content or "{}")
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict) or not (data.get("text") or "").strip():
return None
return data
async def seed_quests_from_arc(session_id: str, arc: dict) -> int:
"""Create active quests for arc beats that are not already in rpg_quests."""
if not arc:
return 0
existing = {q["title"] for q in await get_quests(session_id)}
added = 0
for beat in arc.get("beats", []):
title = (beat.get("title") or beat.get("injection", "")).strip()[:120]
if title and title not in existing:
await upsert_quest(session_id, title, "active")
existing.add(title)
added += 1
return added
async def get_history(session_id: str) -> list:
resolutions = await get_action_resolutions_map(session_id)
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT id, role, content, image_prompt, image_path,
image_prompt_alt, image_path_alt, choices_json
FROM messages WHERE session_id = ? ORDER BY id""",
(session_id,),
) as cursor:
rows = await cursor.fetchall()
return [
{
result = []
for idx, r in enumerate(rows):
item = {
"id": r["id"],
"role": r["role"],
"content": r["content"],
"image_prompt": r["image_prompt"],
"image_path": r["image_path"],
"image_prompt_alt": r["image_prompt_alt"],
"image_path_alt": r["image_path_alt"],
"choices_json": r["choices_json"],
}
for r in rows
]
if r["role"] == "user" and r["id"] in resolutions:
item["action_resolution"] = resolutions[r["id"]]
result.append(item)
if r["role"] == "user" and r["id"] in resolutions:
nxt = rows[idx + 1] if idx + 1 < len(rows) else None
if not nxt or nxt["role"] != "narrator":
res = resolutions[r["id"]]
result.append(
{
"id": -int(r["id"]),
"role": "narrator",
"content": narrator_message_content(
{
"roll": res.get("roll"),
"outcome": res.get("outcome"),
"text": res.get("resolution_text", ""),
}
),
"image_prompt": None,
"image_path": None,
"image_prompt_alt": None,
"image_path_alt": None,
"choices_json": None,
}
)
return result
async def get_message(message_id: int) -> dict | None:
@@ -230,6 +405,38 @@ async def delete_message(message_id: int):
await db.commit()
async def delete_message_and_following(session_id: str, message_id: int) -> bool:
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.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit()
return True
async def update_message_choices(message_id: int, choices_json: str | None):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET choices_json = ? WHERE id = ?",
(choices_json, message_id),
)
await db.commit()
async def clear_choices_for_session(session_id: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET choices_json = NULL WHERE session_id = ?",
(session_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
@@ -261,8 +468,9 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
status_quo, plot_arc_json, genre, rpg_settings_json, affinity,
outfit_json, scene_json, narrative_stats_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
new_id,
source["persona_id"],
@@ -275,6 +483,9 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
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}'),
),
)
async with db.execute(
@@ -309,18 +520,20 @@ async def add_message(
content: str,
image_prompt: str | None = None,
image_path: str | None = None,
):
) -> int:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
cur = await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, ?, ?, ?, ?)""",
(session_id, role, content, image_prompt, image_path),
)
msg_id = cur.lastrowid
await db.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit()
return msg_id
async def update_message_image(message_id: int, image_path: str):
@@ -332,6 +545,33 @@ async def update_message_image(message_id: int, image_path: str):
await db.commit()
async def update_message_prompt(message_id: int, image_prompt: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET image_prompt = ? WHERE id = ?",
(image_prompt, message_id),
)
await db.commit()
async def update_message_prompt_alt(message_id: int, image_prompt_alt: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET image_prompt_alt = ? WHERE id = ?",
(image_prompt_alt, message_id),
)
await db.commit()
async def update_message_image_alt(message_id: int, image_path_alt: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET image_path_alt = ? WHERE id = ?",
(image_path_alt, message_id),
)
await db.commit()
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
@@ -362,6 +602,18 @@ async def update_session_affinity(session_id: str, delta: int):
await db.commit()
async def set_session_affinity(session_id: str, value: int):
"""Debug / admin: set absolute affinity (-30..30)."""
clamped = max(-30, min(30, int(value)))
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET affinity = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(clamped, session_id),
)
await db.commit()
return clamped
async def update_session_genre(session_id: str, genre: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
@@ -389,6 +641,24 @@ async def update_session_outfit(session_id: str, outfit_json: str):
await db.commit()
async def update_session_scene(session_id: str, scene_json: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET scene_json = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(scene_json, session_id),
)
await db.commit()
async def update_session_narrative_stats(session_id: str, stats_json: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE sessions SET narrative_stats_json = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(stats_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(
@@ -429,6 +699,18 @@ async def update_quest_status(session_id: str, title: str, status: str):
await db.commit()
async def update_quest_by_id(quest_id: int, session_id: str, status: str) -> bool:
if status not in ("active", "done", "failed"):
return False
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute(
"UPDATE rpg_quests SET status = ? WHERE id = ? AND session_id = ?",
(status, quest_id, session_id),
)
await db.commit()
return cur.rowcount > 0
async def get_message_count(session_id: str) -> int:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
+173
View File
@@ -0,0 +1,173 @@
import json
import logging
from services.memory import (
get_history,
get_session,
get_last_assistant_message_id,
update_session_plot_arc,
update_message_choices,
seed_quests_from_arc,
get_quests,
)
from services.rpg_state import apply_narrator_post
from services.personas import get_persona
from services.rpg_facts import facts_to_prompt
from services.rpg_plot import generate_plot_arc, choices_from_narrator
from services.rpg_context import format_narrator_context
from services.rpg_narrator import narrator_post
from services.sd_prompt import generate_sd_prompt
from services.sd_images import run_sd_for_message
logger = logging.getLogger(__name__)
DEFAULT_RPG_SETTINGS = {
"dice": True,
"narrator": True,
"quests": True,
"affinity": True,
"choices": True,
"stats": False,
}
def get_rpg_settings(session: dict) -> dict:
try:
return {**DEFAULT_RPG_SETTINGS, **json.loads(session.get("rpg_settings_json") or "{}")}
except Exception:
return DEFAULT_RPG_SETTINGS
async def resolve_greeting(session_id: str, persona: dict) -> str:
history = await get_history(session_id)
for m in reversed(history):
if m.get("role") == "assistant" and (m.get("content") or "").strip():
return m["content"].strip()
return (persona.get("first_mes") or "").strip()
async def ensure_plot_arc_and_quests(
session_id: str,
persona: dict,
greeting: str,
genre: str,
*,
seed_quests: bool = True,
) -> dict:
session = await get_session(session_id) or {}
arc_json = session.get("plot_arc_json") or "{}"
try:
arc = json.loads(arc_json) if isinstance(arc_json, str) else {}
except Exception:
arc = {}
if arc:
return arc
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
arc = await generate_plot_arc(
persona.get("name", "Character"),
persona.get("description", ""),
persona.get("scenario", ""),
greeting,
facts_block=facts_block,
genre=genre,
)
if not arc:
return {}
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
if seed_quests:
await seed_quests_from_arc(session_id, arc)
return arc
async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dict:
session = await get_session(session_id)
if not session:
raise ValueError("Session not found")
history = await get_history(session_id)
assistant_msgs = [m for m in history if m.get("role") == "assistant"]
if not assistant_msgs:
raise ValueError("No assistant message (first_mes) found")
first_mes_text = assistant_msgs[-1].get("content", "").strip()
if not first_mes_text:
raise ValueError("Empty first_mes")
msg_id = await get_last_assistant_message_id(session_id)
persona = await get_persona(persona_id) or {}
rpg_settings = get_rpg_settings(session)
arc: dict = {}
choices: list = []
status_quo = session.get("status_quo") or ""
outfit_json = session.get("outfit_json") or "[]"
if rpg:
genre = session.get("genre") or "adventure"
arc = await ensure_plot_arc_and_quests(
session_id,
persona,
first_mes_text,
genre,
seed_quests=rpg_settings.get("quests", True),
)
session = await get_session(session_id) or session
ctx_txt = f"assistant: {first_mes_text}"
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
quests_pre = await get_quests(session_id)
narr_ctx = format_narrator_context(arc, quests_pre, session.get("status_quo") or "")
post = await narrator_post(
persona.get("name", persona_id),
ctx_txt,
arc_json,
facts_block,
is_opening=True,
extra_context=narr_ctx,
)
if rpg_settings.get("choices", True):
choices = choices_from_narrator(post.get("choices") or [])
await apply_narrator_post(session_id, post, rpg_settings, session)
session = await get_session(session_id) or session
status_quo = session.get("status_quo") or status_quo
outfit_json = session.get("outfit_json") or outfit_json
quests = await get_quests(session_id)
messages = await get_history(session_id)
bundle = await generate_sd_prompt(
messages,
persona_id,
outfit_json=outfit_json,
scene_json=session.get("scene_json", "{}") if session else "{}",
)
sd_out = await run_sd_for_message(bundle, msg_id) if bundle else {}
updated = await get_session(session_id)
affinity = updated.get("affinity", 0) if updated else 0
if msg_id and choices:
await update_message_choices(
msg_id, json.dumps(choices, ensure_ascii=False)
)
return {
"plot_arc": arc,
"quests": quests,
"outfit_json": outfit_json,
"status_quo": status_quo,
"choices": choices,
"image_prompt": sd_out.get("image_prompt"),
"image_prompt_alt": sd_out.get("image_prompt_alt"),
"image_path": sd_out.get("image_path"),
"image_path_alt": sd_out.get("image_path_alt"),
"image_error": sd_out.get("image_error"),
"image_error_alt": sd_out.get("image_error_alt"),
"affinity": affinity,
}
+94
View File
@@ -0,0 +1,94 @@
"""Danbooru-style outfit tags with color enrichment for stable SD prompts."""
import json
import re
COLOR_TOKENS = frozenset({
"white", "black", "red", "blue", "green", "yellow", "pink", "purple",
"orange", "brown", "gray", "grey", "silver", "gold", "beige", "navy",
"blonde", "dark", "light", "cyan", "teal", "maroon", "crimson",
})
# garment substring -> default color when tag has no color word
_GARMENT_DEFAULT_COLOR: list[tuple[str, str]] = [
("championship_belt", "gold"),
("belt_collar", "gold"),
("belt", "brown"),
("sports_shorts", "black"),
("shorts", "black"),
("torn_tank_top", "white"),
("tank_top", "white"),
("crop_top", "white"),
("t_shirt", "white"),
("shirt", "white"),
("dress", "black"),
("skirt", "black"),
("jeans", "blue"),
("pants", "black"),
("hoodie", "gray"),
("jacket", "black"),
("ribbon", "red"),
("collar", "black"),
("boots", "black"),
("socks", "white"),
]
def _clean_tag(raw: str) -> str:
t = (raw or "").strip().lower()
t = re.sub(r"[^\w]+", "_", t)
t = re.sub(r"_+", "_", t).strip("_")
return t
def tag_has_color(tag: str) -> bool:
parts = tag.lower().split("_")
return any(p in COLOR_TOKENS for p in parts)
def enrich_outfit_tag(tag: str) -> str:
"""Add a color prefix when the tag names clothing but omits color."""
t = _clean_tag(tag)
if not t or tag_has_color(t):
return t
for needle, color in _GARMENT_DEFAULT_COLOR:
if needle in t:
return f"{color}_{t}"
return t
def normalize_outfit_list(raw: list | None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
if not isinstance(raw, list):
return out
for item in raw:
if isinstance(item, str):
t = enrich_outfit_tag(item)
elif isinstance(item, dict):
label = (item.get("tag") or item.get("label") or "").strip()
color = (item.get("color") or "").strip().lower()
base = _clean_tag(label)
if not base:
continue
t = f"{color}_{base}" if color and not tag_has_color(base) else enrich_outfit_tag(base)
else:
continue
if t and t not in seen:
seen.add(t)
out.append(t)
return out
def outfit_list_to_json(tags: list[str]) -> str:
return json.dumps(normalize_outfit_list(tags), ensure_ascii=False)
def parse_and_normalize_outfit_json(raw: str | None) -> str:
try:
data = json.loads(raw or "[]")
except (json.JSONDecodeError, TypeError):
data = []
if isinstance(data, str):
data = [x.strip() for x in data.split(",") if x.strip()]
return outfit_list_to_json(data if isinstance(data, list) else [])
+24 -4
View File
@@ -63,6 +63,7 @@ def _row_to_persona(row: dict) -> dict:
"lora_name": row["lora_name"] or "",
"lora_weight": row["lora_weight"] if row["lora_weight"] is not None else 0.8,
"appearance_tags": row["appearance_tags"] or "",
"appearance_prose": row.get("appearance_prose", "") or "",
"personality": row.get("personality", "") or "",
"scenario": row.get("scenario", "") or "",
"first_mes": row.get("first_mes", "") or "",
@@ -84,6 +85,9 @@ def build_persona_prompt(data: dict) -> str:
if ex:
parts.append(f"Example dialogue:\n{ex}")
parts.append("Stay in character. Reply as the character. Do not add image tags.")
from services.chat_prompt import ROLEPLAY_GUARDRAILS
parts.append(ROLEPLAY_GUARDRAILS)
return "\n\n".join(p for p in parts if p and p.split(": ", 1)[-1].strip())
@@ -117,6 +121,7 @@ async def create_persona(
lora_name: str = "",
lora_weight: float = 0.8,
appearance_tags: str = "",
appearance_prose: str = "",
personality: str = "",
scenario: str = "",
first_mes: str = "",
@@ -138,19 +143,19 @@ async def create_persona(
await db.execute(
"""INSERT INTO personas
(persona_id, name, emoji, description, prompt, custom,
sd_enabled, lora_name, lora_weight, appearance_tags,
sd_enabled, lora_name, lora_weight, appearance_tags, appearance_prose,
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
alternate_greetings_json)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
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, appearance_prose,
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
alternate_greetings_json,
),
)
await db.commit()
return {
return {
"name": name,
"emoji": emoji,
"description": description,
@@ -160,6 +165,7 @@ async def create_persona(
"lora_name": lora_name,
"lora_weight": lora_weight,
"appearance_tags": appearance_tags,
"appearance_prose": appearance_prose,
"personality": personality,
"scenario": scenario,
"first_mes": first_mes,
@@ -226,6 +232,7 @@ async def patch_persona(persona_id: str, fields: dict) -> bool:
"lora_name",
"lora_weight",
"appearance_tags",
"appearance_prose",
"personality",
"scenario",
"first_mes",
@@ -255,6 +262,19 @@ async def patch_persona(persona_id: str, fields: dict) -> bool:
merged = dict(existing)
merged.update(updates)
updates["prompt"] = build_persona_prompt(merged)
if "appearance_tags" in updates and "appearance_prose" not in updates:
tags = updates["appearance_tags"].strip()
if tags:
from services.llm import send_message
try:
prose = await send_message([
{"role": "system", "content": "Convert danbooru tags to natural English description. Output only the description, no markdown."},
{"role": "user", "content": f"Tags: {tags}"}
])
updates["appearance_prose"] = prose.strip()
except Exception:
pass
cols = ", ".join(f"{k} = ?" for k in updates)
cur2 = await db.execute(
+77
View File
@@ -0,0 +1,77 @@
"""Roleplay output guardrails and OOC stripping."""
import re
ROLEPLAY_GUARDRAILS = (
"[In-character rules — breaking these ruins immersion]\n"
"- Reply ONLY as the character in the present moment: spoken lines, visible actions, "
"thoughts they would actually show.\n"
"- NEVER write: P.S., PS, postscripts, footnotes, section headers, "
'"Статус кво", "Status quo", "To be continued", scene summaries, '
"editorial closings, or foreshadowing asides "
'(e.g. "Когда вы выйдете...", "Ты уже знаешь правду...").\n'
"- Do NOT explain subtext to the reader or predict future scenes. No narrator voice.\n"
"- End inside the scene; do not wrap up with meta commentary.\n"
"- Sections marked MANDATORY (relationship, state) are binding — obey without citing them."
)
RP_OUTPUT_REMINDER = (
"\n\n--- Reply format (MANDATORY) ---\n"
"Next message = in-character only. "
"Forbidden in output: P.S., Статус кво, Status quo, author notes, summaries, footnotes.\n"
"---"
)
def status_quo_prompt_block(status_quo: str) -> str:
sq = (status_quo or "").strip()
if not sq:
return ""
return (
"\n\n--- Current situation (INTERNAL — player never sees this block) ---\n"
+ sq
+ "\nBackground truth for you only. "
"Never echo this header, never open/close replies with 'Статус кво' or summaries. "
"Show the situation through dialogue and action.\n---"
)
_OOC_PARA_START = re.compile(
r"^(?:"
r"Статус\s*кво|Status\s*quo|"
r"P\.?\s*S\.?|PS:|"
r"Примечание:|Author'?s?\s*note:|"
r"OOC:|\\[OOC\\]|"
r"To be continued|Продолжение следует"
r")\b",
re.IGNORECASE,
)
def strip_ooc_from_reply(text: str) -> str:
"""Remove common OOC tails (P.S., Статус кво paragraphs, etc.)."""
if not text or not text.strip():
return text or ""
out = text.rstrip()
# Drop trailing P.S. block (often last paragraph).
out = re.sub(
r"(?is)\n\s*P\.?\s*S\.?\s*[:.\-—].*$",
"",
out,
).rstrip()
parts = re.split(r"\n\s*\n", out)
kept: list[str] = []
for part in parts:
lines = [ln for ln in part.splitlines() if ln.strip()]
if not lines:
continue
if _OOC_PARA_START.match(lines[0].strip()):
continue
kept.append(part)
if not kept:
return ""
return "\n\n".join(kept).strip()
+53
View File
@@ -0,0 +1,53 @@
"""Shared context blocks for RPG narrator / plot LLM calls."""
from services.rpg_plot import count_active_quests
def format_narrator_context(
arc: dict | None,
quests: list | None,
status_quo: str = "",
) -> str:
parts: list[str] = []
arc = arc or {}
beats = arc.get("beats") or []
if not isinstance(beats, list):
beats = []
parts.append(f"Plot phase: {arc.get('phase', 'opening')}. Scripted beats left: {len(beats)}.")
if not beats:
parts.append(
"IMPORTANT: Scripted beats are EXHAUSTED (quests may already be done). "
"The story must CONTINUE — do not stall. "
"Always return 2-4 meaningful choices for the player's next actions. "
"You may add quest_updates with status 'active' for NEW optional threads. "
"Do NOT re-activate quests the player already completed unless they explicitly revisit that thread."
)
elif count_active_quests(quests) == 0:
pending = [
(b.get("title") or b.get("id") or "beat")
for b in beats[:3]
if isinstance(b, dict)
]
parts.append(
"IMPORTANT: No active quests but scripted beats remain — arc was likely desynced. "
"The engine will inject the next beat; prefer choices that fit pending beats: "
+ ", ".join(pending)
+ ". Do NOT treat the arc as finished."
)
hint = (arc.get("next_beat_hint") or "").strip()
if hint:
parts.append(f"Arc hint: {hint}")
if quests:
parts.append("Quest log:")
for q in quests:
parts.append(f" [{q.get('status', 'active')}] {q.get('title', '')}")
else:
parts.append("Quest log: (empty)")
sq = (status_quo or "").strip()
if sq:
parts.append(f"Status quo: {sq[:400]}")
return "\n".join(parts)
+340 -46
View File
@@ -1,76 +1,370 @@
import json
import logging
import os
import re
from services.llm import send_message_with_model, send_message
from services.llm import LLMError, send_message_with_model, send_message
logger = logging.getLogger(__name__)
FACTS_MODEL = os.getenv("RPG_FACTS_MODEL", "").strip() or "deepseek/deepseek-chat-v3"
FACTS_STORE_LIMIT = int(os.getenv("FACTS_STORE_LIMIT", "100"))
FACTS_PROMPT_MAX = int(os.getenv("FACTS_PROMPT_MAX", "40"))
FACTS_DEDUP_THRESHOLD = int(os.getenv("FACTS_DEDUP_THRESHOLD", "30"))
FACTS_COMPRESS_TARGET = int(os.getenv("FACTS_COMPRESS_TARGET", "22"))
FACTS_SYSTEM = """Extract NEW stable facts from the conversation.
Return ONLY valid JSON (no markdown), as an array of objects:
[{"text": "short durable fact", "rp_day": "when this became true in story time"}]
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"]"""
- Return at most 5 NEW facts per turn. If nothing new, return [].
- Do NOT repeat or rephrase facts already listed under "Already known".
- Facts must be durable (names, relations, inventory, locations, lasting world state).
- Skip momentary emotions unless they permanently change a relationship.
- text <= 120 chars each.
- rp_day: in-world time label (день 1, второй день, та же ночь, через год). Use RP time hint when unclear."""
FACTS_COMPRESS_SYSTEM = """You consolidate RPG session memory for a long-running chat.
Return ONLY valid JSON (no markdown): an array of {"text": "...", "rp_day": "..."}.
Goals:
- Aggressively MERGE near-duplicates (same topic in RU/EN, Rin/Рин, Grigo/Григорий).
- Keep ONE best fact per topic; combine rp_day if needed (e.g. "день 12").
- DROP redundant, trivial, or superseded facts.
- Keep: names, relationships, key locations, lasting magic/rules, inventory, unresolved threads.
- Target at most {target} facts (fewer is better). Each text <= 120 chars.
- rp_day = in-world labels only."""
_NAME_ALIASES = (
("grigoriy", "григорий"),
("grigo", "григо"),
("grigory", "григорий"),
("rin", "рин"),
("player", "игрок"),
("user", "игрок"),
("glade", "полян"),
("flowers", "цвет"),
("flower", "цвет"),
("magical", "волшеб"),
("magic", "волшеб"),
("glow", "свет"),
("glowing", "свет"),
)
def merge_facts(existing_json: str, new_facts: list[str], limit: int = 80) -> str:
def parse_fact_entry(raw) -> dict | None:
if isinstance(raw, dict):
text = (raw.get("text") or raw.get("fact") or "").strip()
rp_day = (raw.get("rp_day") or raw.get("learned") or raw.get("day") or "").strip()
elif isinstance(raw, str):
text = raw.strip()
rp_day = ""
else:
return None
if not text:
return None
return {"text": text[:120], "rp_day": rp_day[:80]}
def parse_facts_list(facts_json: str | None) -> list[dict]:
try:
existing = json.loads(existing_json or "[]")
if not isinstance(existing, list):
existing = []
data = json.loads(facts_json or "[]")
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:
return []
if not isinstance(data, list):
return []
out: list[dict] = []
seen: set[str] = set()
for item in data:
entry = parse_fact_entry(item)
if not entry:
continue
seen.add(s)
merged.append(s)
if len(merged) > limit:
merged = merged[-limit:]
return json.dumps(merged, ensure_ascii=False)
key = entry["text"].lower()
if key in seen:
continue
seen.add(key)
out.append(entry)
return out
async def extract_facts(context_messages: list[dict]) -> list[str]:
# Build a compact transcript
def facts_list_to_json(facts: list[dict]) -> str:
return json.dumps(
[{"text": f["text"], "rp_day": f.get("rp_day", "")} for f in facts],
ensure_ascii=False,
)
def rp_day_from_scene(scene_json: str | None) -> str:
try:
scene = json.loads(scene_json or "{}")
if isinstance(scene, dict):
day = (scene.get("day") or "").strip()
if day:
return day[:80]
except (json.JSONDecodeError, TypeError):
pass
return ""
def _normalize_fact_text(text: str) -> str:
t = (text or "").lower()
for a, b in _NAME_ALIASES:
t = t.replace(a, b)
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
return re.sub(r"\s+", " ", t).strip()
def _fact_tokens(text: str) -> set[str]:
words = re.findall(r"[\w]+", _normalize_fact_text(text), flags=re.UNICODE)
stop = {"the", "and", "that", "with", "this", "have", "has", "was", "are", "для", "что", "как", "это", "на", "в", "и", "а"}
return {w for w in words if len(w) > 2 and w not in stop}
def facts_are_similar(a: str, b: str) -> bool:
al, bl = a.lower().strip(), b.lower().strip()
if al == bl:
return True
shorter, longer = (al, bl) if len(al) <= len(bl) else (bl, al)
if shorter in longer and len(shorter) / max(len(longer), 1) >= 0.35:
return True
ta, tb = _fact_tokens(a), _fact_tokens(b)
if not ta or not tb:
return False
overlap = len(ta & tb) / len(ta | tb)
return overlap >= 0.32
def dedupe_facts_fuzzy(facts: list[dict]) -> list[dict]:
out: list[dict] = []
for f in facts:
placed = False
for i, existing in enumerate(out):
if facts_are_similar(f["text"], existing["text"]):
if len(f["text"]) > len(existing["text"]):
out[i]["text"] = f["text"][:120]
if f.get("rp_day") and not existing.get("rp_day"):
out[i]["rp_day"] = f["rp_day"]
placed = True
break
if not placed:
out.append(dict(f))
return out
def merge_facts(
existing_json: str,
new_facts: list,
*,
rp_day_default: str = "",
) -> str:
merged = parse_facts_list(existing_json)
seen = {f["text"].lower() for f in merged}
default_day = (rp_day_default or "").strip()[:80]
for raw in new_facts or []:
entry = parse_fact_entry(raw)
if not entry:
continue
if not entry["rp_day"] and default_day:
entry["rp_day"] = default_day
key = entry["text"].lower()
if key in seen:
for i, existing in enumerate(merged):
if existing["text"].lower() == key:
if entry["rp_day"] and not existing.get("rp_day"):
merged[i]["rp_day"] = entry["rp_day"]
break
continue
dup = False
for i, existing in enumerate(merged):
if facts_are_similar(entry["text"], existing["text"]):
if len(entry["text"]) > len(existing["text"]):
merged[i]["text"] = entry["text"]
if entry["rp_day"] and not existing.get("rp_day"):
merged[i]["rp_day"] = entry["rp_day"]
dup = True
break
if dup:
continue
seen.add(key)
merged.append(entry)
return facts_list_to_json(merged)
async def compress_facts(
facts: list[dict],
*,
scene_context: str = "",
status_quo: str = "",
target: int = FACTS_COMPRESS_TARGET,
) -> list[dict]:
payload = json.dumps(facts, ensure_ascii=False, indent=2)
user = (
f"Current fact count: {len(facts)}. Target after merge: <= {target}.\n\n"
f"Facts JSON:\n{payload}\n"
)
if scene_context.strip():
user += f"\nCurrent scene:\n{scene_context.strip()[:1500]}\n"
if status_quo.strip():
user += f"\nStatus quo:\n{status_quo.strip()[:1500]}\n"
system = FACTS_COMPRESS_SYSTEM.format(target=target)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
raw = await (
send_message_with_model(messages, FACTS_MODEL)
if FACTS_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("compress_facts LLM failed: %s", e)
return dedupe_facts_fuzzy(facts)[-target:]
except Exception as e:
logger.warning("compress_facts unexpected: %s", e)
return dedupe_facts_fuzzy(facts)[-target:]
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, list):
out = []
for item in data:
entry = parse_fact_entry(item)
if entry:
out.append(entry)
if out:
logger.info("compress_facts: %d -> %d", len(facts), len(out))
return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT]
except json.JSONDecodeError:
logger.warning("compress_facts JSON parse failed. Raw=%.400s", raw)
return dedupe_facts_fuzzy(facts)[-target:]
async def merge_facts_persist(
existing_json: str,
new_facts: list,
*,
rp_day_default: str = "",
scene_context: str = "",
status_quo: str = "",
) -> str:
"""Merge, fuzzy-dedupe, LLM-compress when list grows too large."""
merged_json = merge_facts(
existing_json, new_facts, rp_day_default=rp_day_default
)
facts = dedupe_facts_fuzzy(parse_facts_list(merged_json))
if len(facts) > FACTS_DEDUP_THRESHOLD:
facts = await compress_facts(
facts,
scene_context=scene_context,
status_quo=status_quo,
target=FACTS_COMPRESS_TARGET,
)
facts = dedupe_facts_fuzzy(facts)
if len(facts) > FACTS_STORE_LIMIT:
facts = await compress_facts(
facts,
scene_context=scene_context,
status_quo=status_quo,
target=FACTS_STORE_LIMIT,
)
return facts_list_to_json(facts)
async def extract_facts(
context_messages: list[dict],
*,
rp_day_hint: str = "",
existing_json: str = "",
) -> list[dict]:
transcript = "\n".join(
f"{m.get('role')}: {m.get('content','')}".strip()
f"{m.get('role')}: {m.get('content', '')}".strip()
for m in context_messages
if m.get("role") in ("user", "assistant")
)[-6000:]
hint = (rp_day_hint or "").strip()
known = parse_facts_list(existing_json)
user_parts = []
if known:
known_lines = "\n".join(
f"- [{f.get('rp_day') or '?'}] {f['text']}" for f in known[-40:]
)
user_parts.append(f"Already known facts (do NOT repeat):\n{known_lines}\n")
if hint:
user_parts.append(f"RP time hint: {hint}\n")
user_parts.append(f"New transcript:\n{transcript}")
user = "\n".join(user_parts)
messages = [
{"role": "system", "content": FACTS_SYSTEM},
{"role": "user", "content": transcript},
{"role": "user", "content": user},
]
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:
raw = await (
send_message_with_model(messages, FACTS_MODEL)
if FACTS_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("extract_facts LLM failed (model=%s): %s", FACTS_MODEL or "SYSTEM", e)
return []
except Exception as e:
logger.warning("extract_facts unexpected: %s", e)
return []
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, list):
out: list[dict] = []
for item in data[:8]:
entry = parse_fact_entry(item)
if not entry:
continue
if any(facts_are_similar(entry["text"], k["text"]) for k in known):
continue
if not entry["rp_day"] and hint:
entry["rp_day"] = hint[:80]
out.append(entry)
return out
except json.JSONDecodeError:
logger.warning("extract_facts JSON parse failed. Raw=%.400s", raw)
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()]
def facts_to_prompt(facts_json: str, max_items: int = FACTS_PROMPT_MAX) -> str:
facts = dedupe_facts_fuzzy(parse_facts_list(facts_json))
if not facts:
return ""
block = "\n".join(f"- {x}" for x in facts[-max_items:])
return f"--- Facts (persistent memory) ---\n{block}\n---"
recent = facts[-max_items:]
lines = []
for f in recent:
day = (f.get("rp_day") or "").strip()
if day:
lines.append(f"- [{day}] {f['text']}")
else:
lines.append(f"- {f['text']}")
block = "\n".join(lines)
total = len(facts)
header = "--- Facts (persistent memory"
if total > len(recent):
header += f", showing {len(recent)} of {total}"
header += ") ---"
return f"{header}\n{block}\n---"
+63 -15
View File
@@ -2,7 +2,7 @@ import json
import os
import random
from services.llm import send_message_with_model
from services.llm import LLMError, send_message_with_model
import logging
logger = logging.getLogger(__name__)
@@ -18,8 +18,10 @@ Return ONLY valid JSON (no markdown):
"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"
"status_quo_update": "optional short update about the world state",
"scene_update": {"place": "", "time_of_day": "", "day": "", "weather": "", "exits": [], "layout_note": ""}
}
scene_update: only include keys that changed (partial). Omit scene_update if nothing changed.
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
@@ -32,17 +34,25 @@ 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"],
"facts": ["durable fact strings OR {\"text\":\"...\",\"rp_day\":\"день 1\"}"],
"choices": [{"id":"a","label":"..."}, ...],
"affinity_delta": 0,
"stats_delta": {"lust": 0, "stamina": 0, "tension": 0},
"scene_update": {"place": "", "place_id": "", "time_of_day": "", "day": "", "weather": "", "exits": [], "layout_note": ""},
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}],
"outfit_update": ["danbooru_tag", "danbooru_tag"]
}
Rules:
- status_quo_update: internal DM state only (facts, location, mood). Never address the player, never use headers like "Status quo"/"Статус кво", P.S., or author commentary.
- affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral.
- stats_delta: each lust/stamina/tension -2..+2 (0 if unchanged). lust=arousal, stamina=energy, tension=stress.
- scene_update: partial location/time schema; only keys that changed. Do not duplicate all of status_quo into scene_update.
- 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.
- outfit_update: ONLY include if the character's clothing visibly changed (put on, took off, changed outfit). Use exact danbooru-style underscore_tags (e.g. ["white_dress", "red_ribbon", "barefoot"]). Empty array if no change."""
- choices: 0-4 options for what the player can do next. REQUIRED when scripted beats are exhausted — never return an empty choices array unless the session truly ended.
- outfit_update: ONLY if clothing visibly changed. Use danbooru underscore_tags WITH COLOR when possible
(e.g. white_tank_top, black_sports_shorts, gold_championship_belt, blue_jeans, red_ribbon).
Every garment tag should include a color prefix unless the item is inherently colorless (barefoot, nude).
Never bare generic tags like sports_shorts or torn_tank_top without a color. Empty array if no change."""
async def narrator_pre(
@@ -53,6 +63,7 @@ async def narrator_pre(
user_message: str,
roll: int | None = None,
outcome: str | None = None,
extra_context: str = "",
) -> dict:
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
user = (
@@ -63,10 +74,20 @@ async def narrator_pre(
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,
)
if extra_context:
user += f"\n--- Session state ---\n{extra_context}\n---\n"
try:
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
except LLMError as e:
logger.warning("Narrator-pre LLM failed (model=%s): %s", NARRATOR_MODEL, e)
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": "", "_ok": False}
except Exception as e:
logger.warning("Narrator-pre unexpected error: %s", e)
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": "", "_ok": False}
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
@@ -76,10 +97,11 @@ async def narrator_pre(
try:
data = json.loads(cleaned)
if isinstance(data, dict):
data["_ok"] = True
return data
except Exception:
logger.warning("Narrator-pre JSON parse failed. Raw=%.500s", raw)
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""}
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": "", "_ok": False}
async def narrator_post(
@@ -87,17 +109,42 @@ async def narrator_post(
context: str,
global_plot: str,
facts_block: str,
is_opening: bool = False,
extra_context: str = "",
) -> dict:
opening_block = ""
if is_opening:
opening_block = (
"\n\nOPENING SCENE: This is the first greeting, not a mid-conversation reply. "
"Extract the character's INITIAL visible clothing from the greeting into outfit_update "
"(danbooru underscore tags WITH color prefixes: white_shirt, black_shorts, gold_belt), "
"even if clothing did not change during the scene. "
"Set status_quo to describe the opening situation. "
"Fill scene_update from greeting and scenario (place, time_of_day, day, layout_note). "
"If the greeting shows clear warmth or hostility toward the player, set affinity_delta "
"non-zero (-2..+2); use 0 only if truly neutral.\n"
)
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"
f"{opening_block}"
)
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
if extra_context:
user += f"\n--- Session state ---\n{extra_context}\n---\n"
try:
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
except LLMError as e:
logger.warning("Narrator-post LLM failed (model=%s): %s", NARRATOR_MODEL, e)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
except Exception as e:
logger.warning("Narrator-post unexpected error: %s", e)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
@@ -107,7 +154,8 @@ async def narrator_post(
try:
data = json.loads(cleaned)
if isinstance(data, dict):
data["_ok"] = True
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": []}
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
+405 -5
View File
@@ -1,7 +1,7 @@
import json
import os
from services.llm import send_message_with_model, send_message
from services.llm import LLMError, send_message_with_model, send_message
import logging
logger = logging.getLogger(__name__)
@@ -63,7 +63,19 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
{"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))
try:
raw = await (
send_message_with_model(messages, PLOT_MODEL)
if PLOT_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("generate_plot_arc LLM failed (model=%s): %s", PLOT_MODEL or "SYSTEM", e)
return {}
except Exception as e:
logger.warning("generate_plot_arc unexpected error: %s", e)
return {}
cleaned = raw.strip()
# common OpenRouter formatting: fenced json
if cleaned.startswith("```"):
@@ -79,17 +91,236 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
return {}
def should_advance_arc(user_text: str) -> str | None:
BEAT_MATCH_SYSTEM = """You decide whether the player's latest message should fire ONE scripted plot beat.
Return ONLY valid JSON (no markdown):
{"fire_beat_id": "id from list or null", "confidence": "high|low"}
Rules:
- Fire only if the message clearly matches that beat's narrative intent RIGHT NOW.
- event_driven:rest — stopping to rest, sleep, camp, sauna break, recuperate (not mere sitting still in scene).
- event_driven:travel — leaving, driving, journey, going to a new place, hitting the road.
- event_driven:help_request — explicit plea for help/rescue/assistance.
- event_driven:after_fail / after_success — follow-up to a recent failure/success beat.
- Casual talk, flirting, exploring the current place without leaving does NOT fire travel.
- If nothing fits well, return null.
- Pick at most ONE beat; prefer high confidence only."""
def dice_outcome_to_beat_trigger(outcome: str | None) -> str | None:
"""Map d20 outcome to event_driven beat trigger (after_fail / after_success)."""
o = (outcome or "").strip().lower()
if o in ("failure", "critical failure"):
return "event_driven:after_fail"
if o in ("success", "critical success"):
return "event_driven:after_success"
return None
def should_advance_arc_keywords(user_text: str) -> str | None:
"""Legacy keyword fallback when LLM match is unavailable."""
t = (user_text or "").lower()
if any(x in t for x in ["отдыха", "ночлег", "спим", "сон", "разбить лагерь", "лагерь", "отдохн"]):
if any(x in t for x in ["отдыха", "ночлег", "спим", "сон", "разбить лагерь", "лагерь", "отдохн", "привала", "заправк", "саун"]):
return "event_driven:rest"
if any(x in t for x in ["идем дальше", "пойдем дальше", "в путь", "продолжаем путь", "уходим", "возвращаемся", "переходим"]):
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 _parse_llm_json(raw: str) -> dict | list | None:
cleaned = (raw or "").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:
return json.loads(cleaned)
except json.JSONDecodeError:
return None
async def classify_plot_beat(
user_text: str,
beats: list[dict],
recent_context: str = "",
last_dice_outcome: str | None = None,
) -> str | None:
"""LLM: return beat id to fire, or None."""
pending = [b for b in beats if isinstance(b, dict) and b.get("id")]
if not pending or not (user_text or "").strip():
return None
lines = []
for b in pending[:8]:
lines.append(
json.dumps(
{
"id": b.get("id"),
"title": b.get("title", ""),
"trigger": b.get("trigger", ""),
"injection": (b.get("injection") or "")[:200],
},
ensure_ascii=False,
)
)
user = (
f"Player message:\n{user_text.strip()}\n\n"
f"Pending beats:\n" + "\n".join(lines)
)
if recent_context.strip():
user += f"\n\nRecent chat:\n{recent_context.strip()[-2500:]}\n"
if last_dice_outcome:
user += f"\nLast dice outcome this turn: {last_dice_outcome}\n"
messages = [
{"role": "system", "content": BEAT_MATCH_SYSTEM},
{"role": "user", "content": user},
]
try:
raw = await (
send_message_with_model(messages, PLOT_MODEL)
if PLOT_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("classify_plot_beat LLM failed: %s", e)
return None
except Exception as e:
logger.warning("classify_plot_beat unexpected: %s", e)
return None
data = _parse_llm_json(raw)
if not isinstance(data, dict):
return None
bid = data.get("fire_beat_id")
if bid in (None, "", "null", "none"):
return None
bid = str(bid).strip()
if data.get("confidence") == "low":
return None
valid_ids = {str(b.get("id")) for b in pending}
if bid in valid_ids:
logger.info("classify_plot_beat: fired %s", bid)
return bid
return None
def pop_beat_by_id(arc: dict, beat_id: str) -> tuple[dict, list[dict]]:
beats = arc.get("beats") or []
matched, remaining = [], []
for b in beats:
if isinstance(b, dict) and str(b.get("id")) == str(beat_id) and not matched:
matched.append(b)
else:
remaining.append(b)
arc["beats"] = remaining
return arc, matched
def beat_title(beat: dict) -> str:
return ((beat.get("title") or beat.get("injection") or "")[:120]).strip()
def count_active_quests(quests: list | None) -> int:
return sum(1 for q in (quests or []) if q.get("status") == "active")
def prune_beats_for_done_quests(arc: dict, quests: list | None) -> tuple[dict, list[dict]]:
"""Drop beats whose title already matches a done/failed quest (manual quest close desync)."""
done_titles = {
(q.get("title") or "").strip().lower()
for q in (quests or [])
if q.get("status") in ("done", "failed")
}
if not done_titles:
return arc, []
removed, kept = [], []
for b in arc.get("beats") or []:
if isinstance(b, dict) and beat_title(b).lower() in done_titles:
removed.append(b)
else:
kept.append(b)
arc["beats"] = kept
return arc, removed
def pop_next_beats(arc: dict, max_beats: int = 1) -> tuple[dict, list[dict]]:
beats = arc.get("beats") or []
if not isinstance(beats, list) or not beats:
return arc, []
n = min(max_beats, len(beats))
matched = [b for b in beats[:n] if isinstance(b, dict)]
arc["beats"] = beats[n:]
return arc, matched
async def process_arc_beats(
arc: dict,
quests: list | None,
user_text: str,
*,
recent_context: str = "",
last_dice_outcome: str | None = None,
allow_stuck_recovery: bool = True,
) -> tuple[dict, list[dict], list[dict], str]:
"""
Prune completed beats, then fire by dice outcome, LLM match, keywords, or stuck recovery.
Returns (arc, fired_beats, pruned_beats, mode).
mode: '' | 'after_dice' | 'llm' | 'trigger' | 'stuck_recovery' | 'pruned'
"""
if not arc:
return arc, [], [], ""
arc, pruned = prune_beats_for_done_quests(arc, quests)
beats_pending = arc.get("beats") or []
dice_trig = dice_outcome_to_beat_trigger(last_dice_outcome)
if dice_trig and beats_pending:
arc, fired = pop_matching_beats(arc, dice_trig, max_beats=1)
if fired:
logger.info(
"process_arc_beats: after_dice %s -> %s",
last_dice_outcome,
fired[0].get("id"),
)
return arc, fired, pruned, "after_dice"
if beats_pending:
beat_id = await classify_plot_beat(
user_text, beats_pending, recent_context, last_dice_outcome
)
if beat_id:
arc, fired = pop_beat_by_id(arc, beat_id)
if fired:
return arc, fired, pruned, "llm"
trig = should_advance_arc_keywords(user_text)
if trig:
arc, fired = pop_matching_beats(arc, trig, max_beats=1)
if fired:
return arc, fired, pruned, "trigger"
if allow_stuck_recovery and arc.get("beats") and count_active_quests(quests) == 0:
arc, fired = pop_next_beats(arc, 1)
if fired:
return arc, fired, pruned, "stuck_recovery"
if pruned:
return arc, [], pruned, "pruned"
return arc, [], [], ""
PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"]
@@ -108,6 +339,129 @@ def advance_phase(arc: dict) -> bool:
return True
BEATS_APPEND_SYSTEM = """You are a narrative designer for an RPG chat.
The plot arc has NO remaining scripted beats. Generate 2-3 NEW beats to continue play.
Return ONLY valid JSON (no markdown):
{
"beats": [
{"id":"b_new_1","title":"short quest title","trigger":"event_driven:rest|event_driven:travel|event_driven:help_request|event_driven:after_fail|event_driven:after_success",
"injection":"1-3 sentences in-world",
"choices":[{"id":"a","label":"..."},{"id":"b","label":"..."}]}
],
"next_beat_hint": "what to push next",
"phase": "hook|complication|reveal|climax|aftermath"
}
Match the current scene and completed quests. Do not restart finished storylines."""
async def replenish_arc_beats(
arc: dict,
persona_name: str,
recent_context: str,
quests: list,
genre: str = "adventure",
) -> dict:
"""Append new beats when arc.beats is empty so plot/quest engine can continue."""
if arc.get("beats"):
return arc
quest_lines = "\n".join(
f" [{q.get('status')}] {q.get('title')}" for q in (quests or [])
) or " (none)"
user = (
f"Character: {persona_name}\n"
f"Genre: {format_genres(genre)}\n"
f"Current arc title: {arc.get('title', '')}\n"
f"Phase: {arc.get('phase', 'aftermath')}\n"
f"Boundaries: {json.dumps(arc.get('boundaries', []), ensure_ascii=False)}\n"
f"Quests:\n{quest_lines}\n\n"
f"Recent chat:\n{recent_context[-4000:]}\n"
)
messages = [
{"role": "system", "content": BEATS_APPEND_SYSTEM},
{"role": "user", "content": user},
]
try:
raw = await (
send_message_with_model(messages, PLOT_MODEL)
if PLOT_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("replenish_arc_beats failed: %s", e)
return arc
except Exception as e:
logger.warning("replenish_arc_beats unexpected: %s", e)
return arc
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)
except Exception:
logger.warning("replenish_arc_beats JSON parse failed. Raw=%.400s", raw)
return arc
new_beats = data.get("beats") if isinstance(data, dict) else []
if isinstance(new_beats, list) and new_beats:
arc["beats"] = new_beats
logger.info("replenish_arc_beats: added %d beats", len(new_beats))
if isinstance(data, dict) and data.get("next_beat_hint"):
arc["next_beat_hint"] = data["next_beat_hint"]
if isinstance(data, dict) and data.get("phase"):
arc["phase"] = data["phase"]
return arc
async def reconcile_plot_arc(
session_id: str,
*,
replenish_if_empty: bool = True,
recent_context: str = "",
persona_name: str = "Character",
genre: str = "adventure",
) -> tuple[dict, bool]:
"""
Prune beats that match done quests; replenish if empty. Persists arc when changed.
Returns (arc, changed).
"""
from services.memory import get_session, get_quests, update_session_plot_arc, seed_quests_from_arc
session = await get_session(session_id)
if not session or not session.get("rpg_enabled"):
return {}, False
try:
arc = json.loads(session.get("plot_arc_json") or "{}")
except (json.JSONDecodeError, TypeError):
arc = {}
if not isinstance(arc, dict):
arc = {}
quests = await get_quests(session_id)
arc, pruned = prune_beats_for_done_quests(arc, quests)
changed = bool(pruned)
if replenish_if_empty and not arc.get("beats"):
arc = await replenish_arc_beats(
arc,
persona_name,
recent_context,
quests,
genre=session.get("genre") or genre,
)
if arc.get("beats"):
changed = True
await seed_quests_from_arc(session_id, arc)
if changed:
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
return arc, changed
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):
@@ -121,3 +475,49 @@ def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dic
arc["beats"] = remaining
return arc, matched
def normalize_choice(
raw: dict,
*,
source: str = "narrator",
beat: dict | None = None,
) -> dict | None:
"""Normalize a choice dict for storage/UI. Adds source and optional beat metadata."""
if not isinstance(raw, dict):
return None
label = (raw.get("label") or "").strip()
if not label:
return None
cid = (raw.get("id") or label[:1].lower() or "a").strip()
out = {"id": cid, "label": label, "source": source}
if beat and source == "plot_beat":
if beat.get("id"):
out["beat_id"] = beat["id"]
title = (beat.get("title") or "").strip()
if title:
out["beat_title"] = title
injection = (beat.get("injection") or "").strip()
if injection:
out["beat_injection"] = injection
return out
def choices_from_beat(beat: dict) -> list[dict]:
if not isinstance(beat, dict):
return []
return [
c for c in (
normalize_choice(item, source="plot_beat", beat=beat)
for item in (beat.get("choices") or [])
)
if c
]
def choices_from_narrator(raw_choices: list) -> list[dict]:
if not isinstance(raw_choices, list):
return []
return [
c for c in (normalize_choice(item, source="narrator") for item in raw_choices) if c
]
+321
View File
@@ -0,0 +1,321 @@
import json
DEFAULT_NARRATIVE_STATS = {"lust": 0, "stamina": 10, "tension": 0}
STAT_KEYS = ("lust", "stamina", "tension")
def parse_stats_json(raw: str | None) -> dict:
try:
data = json.loads(raw or "{}") if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
out = dict(DEFAULT_NARRATIVE_STATS)
for k in STAT_KEYS:
try:
out[k] = max(0, min(10, int(data.get(k, out[k]))))
except (TypeError, ValueError):
pass
return out
def parse_scene_json(raw: str | None) -> dict:
try:
data = json.loads(raw or "{}") if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
return data if isinstance(data, dict) else {}
def merge_scene(existing: dict, update: dict | None) -> dict:
if not update or not isinstance(update, dict):
return dict(existing)
merged = dict(existing)
for k, v in update.items():
if v is None:
continue
if k == "exits" and isinstance(v, list):
merged["exits"] = v
elif isinstance(v, str) and v.strip():
merged[k] = v.strip()
elif v != "":
merged[k] = v
return merged
def apply_stats_delta(stats: dict, delta: dict | None) -> dict:
out = parse_stats_json(json.dumps(stats, ensure_ascii=False))
if not delta or not isinstance(delta, dict):
return out
for k in STAT_KEYS:
try:
d = int(delta.get(k, 0))
except (TypeError, ValueError):
d = 0
d = max(-2, min(2, d))
if d:
out[k] = max(0, min(10, out[k] + d))
return out
def scene_prompt_block(scene: dict) -> str:
if not scene:
return ""
lines = []
for key, label in (
("place", "Location"),
("place_id", "Location ID"),
("time_of_day", "Time of day"),
("day", "Day"),
("weather", "Weather"),
("layout_note", "Layout"),
):
v = (scene.get(key) or "").strip()
if v:
lines.append(f"{label}: {v}")
exits = scene.get("exits")
if isinstance(exits, list) and exits:
lines.append("Exits: " + ", ".join(str(e) for e in exits))
if not lines:
return ""
return (
"\n\n--- Scene ---\n"
+ "\n".join(lines)
+ "\nUse this setting consistently. Do not recite scene labels verbatim.\n---"
)
# Scoped to current player until multi-user / multi-card relationships exist.
RELATIONSHIP_TARGET_LABEL = "current player (the human you are roleplaying with)"
def _mandatory_state_preamble() -> str:
return (
"MANDATORY ROLEPLAY DIRECTIVE (binding — overrides default cheerfulness or energy):\n"
f"- Target: {RELATIONSHIP_TARGET_LABEL} only.\n"
"- Your reply MUST visibly obey this state in tone, body language, pacing, and what the character attempts.\n"
"- Do NOT contradict a low stamina / high tension / low affinity reading with peppy or intimate behavior unless the text above explicitly allows recovery.\n"
"- Never name affinity, lust, stamina, tension, stats, meters, or numeric values in dialogue.\n"
)
def _band_instruction(value: int, bands: list[tuple[int, str]]) -> str:
"""bands: list of (min_inclusive, instruction) sorted by min descending."""
v = int(value)
for min_val, text in bands:
if v >= min_val:
return text
return bands[-1][1] if bands else ""
def affinity_prompt_block(affinity: int) -> str:
aff = max(-30, min(30, int(affinity)))
attitude = _band_instruction(
aff,
[
(10, "Devoted trust: openly affectionate, seeks closeness, defends the player, vulnerable honesty."),
(5, "Warm bond: friendly, teasing allowed, volunteers help, remembers small kindnesses."),
(1, "Slight fondness: polite-positive, rare soft moments, not yet intimate."),
(0, "Neutral professional distance: neither warm nor cold unless scene demands."),
(-1, "Cool and guarded: short answers, deflects personal topics, skeptical of motives."),
(-5, "Hostile or deeply distrustful: sarcasm, refusal, may threaten to leave or expose the player."),
(-30, "Open enmity: antagonistic, undermines, may sabotage or attack socially/physically if fitting genre."),
],
)
return (
"\n\n--- Relationship toward player (MANDATORY) ---\n"
+ _mandatory_state_preamble()
+ f"Affinity (internal, not spoken): level {aff}.\n"
f"Required attitude toward {RELATIONSHIP_TARGET_LABEL}: {attitude}\n"
"---"
)
def stats_prompt_block(stats: dict) -> str:
s = parse_stats_json(json.dumps(stats, ensure_ascii=False))
lust = s["lust"]
stamina = s["stamina"]
tension = s["tension"]
lust_line = _band_instruction(
lust,
[
(9, "Overwhelming arousal colors every beat: breathy, distracted, struggles to stay on task."),
(7, "Strong desire: flushed skin, lingering touch, voice unsteady, thoughts drift to intimacy."),
(5, "Clear attraction: meaningful glances, leaning in, playful double meanings if genre fits."),
(3, "Mild warmth: subtle flirt only when appropriate; easily redirected."),
(1, "Little romantic charge: platonic focus unless the player escalates."),
(0, "No romantic or sexual undertone in body language or subtext."),
],
)
stamina_line = _band_instruction(
stamina,
[
(9, "Peak energy: brisk movement, sharp focus, may offer to take strenuous actions."),
(7, "Well-rested: alert, steady pace, normal exertion."),
(5, "Average fatigue: fine for talk/light action; heavy labor needs justification."),
(4, "Tired: slower reactions, sits when possible, voice softer."),
(3, "Heavy fatigue: frequent pauses, avoids running/fighting, may ask to stop."),
(2, "Exhausted: barely moves, slumped posture, short sentences, needs support to walk."),
(1, "On the verge of collapse: eyelids heavy, may stumble or nearly pass out; minimal action only."),
(0, "Cannot sustain activity: collapse/immediate sleep/rest is imminent unless helped."),
],
)
tension_line = _band_instruction(
tension,
[
(9, "Breaking point: trembling, tears or rage close to surface, irrational snap decisions."),
(7, "High stress: clipped speech, hyper-vigilant, jumps at sounds, defensive."),
(5, "Uneasy: fidgeting, forced smiles, changes subject from danger."),
(3, "Mild edge: occasional sigh, watches exits, relaxes with reassurance."),
(1, "Mostly calm: normal breathing, open posture."),
(0, "Fully at ease in body and voice."),
],
)
return (
"\n\n--- Physical & emotional state (MANDATORY) ---\n"
+ _mandatory_state_preamble()
+ f"Internal scales (010, never spoken): lust/arousal={lust}, stamina/energy={stamina}, tension/stress={tension}.\n"
f"- Lust/arousal: {lust_line}\n"
f"- Stamina/energy: {stamina_line}\n"
f"- Tension/stress: {tension_line}\n"
"If multiple apply, combine them (e.g. low stamina + high tension = shaky exhaustion, not peppy panic).\n"
"---"
)
def format_narrator_outcome_for_llm(data: dict) -> str:
"""Turn stored narrator JSON into a binding user-turn for the character model."""
roll = data.get("roll")
outcome = (data.get("outcome") or "").strip().lower()
text = (data.get("text") or "").strip()
lines = [
"--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
f"Roll d20={roll}. Outcome: {outcome}.",
f"What ACTUALLY happened (canonical truth): {text}",
]
if outcome in ("failure", "critical failure"):
lines.append(
"The player's action FAILED as they imagined it. "
"Do NOT write a success version: no crowd fleeing, no intimidation working, "
"no effortless victory. Show the failure, embarrassment, or partial result above."
)
elif outcome == "critical success":
lines.append(
"The attempt succeeded dramatically. You may show amplified success aligned with the outcome above."
)
else:
lines.append(
"The attempt succeeded. Your reply must align with the narrator outcome above, not contradict it."
)
lines.append("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
lines.append("---")
return "\n".join(lines)
def format_user_message_for_llm(content: str, *, has_dice_resolution: bool) -> str:
if not has_dice_resolution:
return content
return (
"[Player stated intent — canonical outcome is in the narrator ruling immediately below]\n"
+ content
)
def scene_to_sd_hint(scene: dict) -> str:
if not scene:
return ""
parts = []
for k in ("place", "time_of_day", "day", "weather", "layout_note"):
v = (scene.get(k) or "").strip()
if v:
parts.append(f"{k}: {v}")
return "\n".join(parts)
async def apply_narrator_post(session_id: str, post: dict, rpg_settings: dict, session: dict | None = None) -> dict:
"""Persist narrator_post fields into session. Returns summary of what changed."""
from services.memory import (
get_session,
update_session_status_quo,
update_session_affinity,
update_session_outfit,
update_session_scene,
update_session_narrative_stats,
update_session_facts,
upsert_quest,
)
from services.rpg_facts import merge_facts_persist, rp_day_from_scene, parse_facts_list
if session is None:
session = await get_session(session_id) or {}
applied = {
"status_quo": False,
"facts_added": 0,
"affinity_delta": 0,
"quests_updated": 0,
"scene": False,
"outfit": False,
}
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(session_id, sq)
applied["status_quo"] = True
post_facts = post.get("facts") or []
if isinstance(post_facts, list) and post_facts:
rp_day = rp_day_from_scene(session.get("scene_json"))
before = len(parse_facts_list(session.get("facts_json") or "[]"))
merged = await merge_facts_persist(
session.get("facts_json", "[]"),
post_facts,
rp_day_default=rp_day,
scene_context=json.dumps(
parse_scene_json(session.get("scene_json")), ensure_ascii=False
),
status_quo=session.get("status_quo") or "",
)
await update_session_facts(session_id, merged)
after = len(parse_facts_list(merged))
applied["facts_added"] = max(0, after - before)
if rpg_settings.get("affinity", True):
delta = int(post.get("affinity_delta") or 0)
if delta:
await update_session_affinity(session_id, delta)
applied["affinity_delta"] = delta
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
from services.outfit_tags import outfit_list_to_json
await update_session_outfit(session_id, outfit_list_to_json(outfit_update))
applied["outfit"] = True
scene_update = post.get("scene_update")
if isinstance(scene_update, dict) and scene_update:
merged = merge_scene(parse_scene_json(session.get("scene_json")), scene_update)
await update_session_scene(session_id, json.dumps(merged, ensure_ascii=False))
applied["scene"] = True
if rpg_settings.get("stats", False):
stats_delta = post.get("stats_delta")
if isinstance(stats_delta, dict) and stats_delta:
current = parse_stats_json(session.get("narrative_stats_json"))
updated = apply_stats_delta(current, stats_delta)
await update_session_narrative_stats(
session_id, json.dumps(updated, ensure_ascii=False)
)
if rpg_settings.get("quests", True):
for qu in post.get("quest_updates") or []:
title = (qu.get("title") or "").strip()
if title:
await upsert_quest(session_id, title[:120], qu.get("status", "active"))
applied["quests_updated"] += 1
return applied
+48
View File
@@ -0,0 +1,48 @@
"""Run ComfyUI generation from SdPromptBundle (single hybrid prompt for Anima)."""
import logging
import os
from services import sdbackend as sd_service
from services.memory import update_message_image, update_message_prompt, update_message_prompt_alt
from services.sd_prompt import SdPromptBundle
logger = logging.getLogger(__name__)
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
async def run_sd_for_message(bundle: SdPromptBundle | None, msg_id: int | None) -> dict:
"""Generate image, persist prompts/paths on message. Returns fields for API/SSE."""
out = {
"image_prompt": None,
"image_prompt_alt": None,
"image_path": None,
"image_path_alt": None,
"image_error": None,
"image_error_alt": None,
}
if not bundle or not bundle.tag_full:
return out
out["image_prompt"] = bundle.tag_full
if bundle.desc_full and bundle.desc_full != bundle.tag_full:
out["image_prompt_alt"] = bundle.desc_full
if msg_id:
await update_message_prompt(msg_id, bundle.tag_full)
if out["image_prompt_alt"]:
await update_message_prompt_alt(msg_id, out["image_prompt_alt"])
if not SD_AUTO_GENERATE:
return out
rel, err = await sd_service.generate_from_full_prompt(bundle.tag_full)
if rel:
out["image_path"] = f"/static/{rel}"
if msg_id:
await update_message_image(msg_id, rel)
else:
out["image_error"] = err
return out
+650 -67
View File
@@ -2,26 +2,115 @@ import json
import logging
import os
import re
from dataclasses import dataclass
from services.llm import send_message, send_message_with_model
from services.personas import get_persona
logger = logging.getLogger(__name__)
NEGATIVE_PROMPT_SEPARATOR = "\n\n__NEGATIVE_PROMPT__\n\n"
PROMPT_BUILDER_SYSTEM = """You are a Stable Diffusion prompt engineer for anime illustration models.
Given a roleplay chat excerpt, output ONLY valid JSON (no markdown):
{
"should_generate": true,
"shot_type": "first_person_pov" | "landscape" | "third_person",
"action_tags": "booru-style tags for pose/action/expression, e.g. 'sitting, smiling, holding_cup'",
"environment_tags": "booru-style tags for location/lighting/time, e.g. 'indoors, kitchen, sunlight, daytime'"
"action_tags": "booru-style tags for pose/action/expression",
"environment_tags": "booru-style tags for location/lighting/time"
}
Rules:
- ONLY use real danbooru/e621 tags. Multi-word concepts MUST be underscore_joined: 'fox_ears' not 'fox ears'.
- ONLY use real danbooru/e621 tags. Multi-word concepts MUST be underscore_joined.
- Do NOT include appearance/character tags — those are provided separately.
- Do NOT include quality tags, model names, style words, 'pov', or category/metadata words.
- Do NOT invent tags. If unsure — omit.
- Keep each field to 3-6 tags."""
- Keep action_tags and environment_tags to 3-6 tags each.
- shot_type: default "first_person_pov" for dialogue/intimacy at arm's length. "third_person" only for wide action (fight, chase). "landscape" only when environment is the focus.
- should_generate: false for non-visual beats (pure internal monologue, time skips with no new pose, empty lines).
- NEVER use negative words in tag fields (not, without, naked, nsfw, etc.)."""
ANIMA_BUILDER_EXTRA = """
Anima hybrid mode — ALSO include:
"pov_cue": "face_to_face" | "walking_together" | "doorway_invite" | "reach_to_viewer" | "dialogue_close",
"viewer_body_visible": false,
"scene_description": "ONE short English sentence (max 40 words). Camera POV: what the viewer sees. Mood/atmosphere only — do NOT repeat tags from action_tags/environment_tags. Do NOT list comma-separated booru tags."
POV / interaction rules:
- Default viewer_body_visible: false. The viewer's body, hands, or face must NOT appear in the image — only the character toward the camera.
- For hugs, embraces: use arms_out, reaching_towards_viewer, inviting_hug — NOT holding_hands, lifting, carrying, nose_rub (these draw a second body in POV).
- For long messages with time skips ("About an hour later..."), illustrate ONLY the final visible beat (usually the last paragraph).
- scene_description: describe HER toward the camera only — NEVER "someone", "both", "with you", "hand in hand with", or another person's body.
- NEVER use tags: looking_at_each_other, couple, 2girls, 2boys, multiple_girls. For POV walking together omit holding_hands (use walking, smiling, reaching_towards_viewer instead).
- pov_cue: pick the framing that matches the CURRENT beat (walking_together for strolling side by side, doorway_invite for doorway with arms open, reach_to_viewer when she reaches toward camera, face_to_face for close dialogue).
- Illustrate ONLY the beat under === ILLUSTRATE ===; use === Context === for outfit/location hints only.
- Do NOT put English sentences in action_tags or environment_tags — tags only."""
POV_CUE_PHRASES: dict[str, str] = {
"face_to_face": "POV: close face-to-face, she looks directly at you",
"walking_together": "POV: walking beside you, profile and shared path visible",
"doorway_invite": "POV: she blocks the doorway, arms open toward you",
"reach_to_viewer": "POV: she reaches toward the camera",
"dialogue_close": "POV: close conversation, she faces you at arm's length",
}
POV_CUE_DEFAULT = "POV: she stands before you, facing the camera"
POV_INTERACTION_NEGATIVE = (
"duplicate, clone, multiple_girls, 2girls, extra_person, pov hands, "
"disembodied hands, extra arms, second person"
)
_CONTACT_ACTION_KEYWORDS = (
"hug", "holding_hands", "hand_holding", "arms_out", "embrace",
"reaching", "inviting_hug", "arm_around", "cuddling",
)
_JUNK_STANDALONE_TAGS = frozenset({
"white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat",
"short", "tall", "golden", "silver", "red", "blue", "green", "purple",
"pink", "brown", "eye", "eyes", "hair",
})
_INVALID_TAGS = frozenset({
"pumped_up", "pumped", "looking_at_each_other", "couple",
"2girls", "2boys", "multiple_girls", "multiple_boys", "duo",
})
_POV_DROP_ACTION_TAGS = frozenset({
"holding_hands", "hand_holding", "looking_at_each_other", "couple",
"lifting", "carry", "carrying", "princess_carry", "nose_rub", "nose_boop",
})
_TIME_SKIP_RE = re.compile(
r"(?i)\b(?:about an hour later|hours later|later that (?:day|evening|night)|"
r"the next (?:day|morning|evening)|meanwhile|after (?:some )?time)\b[.…\s]*",
)
_POV_MOOD_FALLBACK: dict[str, str] = {
"walking_together": "Easy warmth and quiet laughter in the afternoon light.",
"doorway_invite": "Cool air and playful tension as she waits in the doorway.",
"reach_to_viewer": "A charged moment as she reaches toward the camera.",
"face_to_face": "Her expression softens in close focus toward the camera.",
"dialogue_close": "Intimate calm in the space between you.",
}
_INDOOR_ENV_MARKERS = frozenset({"doorway", "indoors", "indoor", "apartment", "inside", "room"})
_OUTDOOR_ENV_MARKERS = frozenset({"outdoor", "outdoors", "outside", "street"})
_POV_PROSE_BANNED = re.compile(
r"\b(someone|both|together with|hand in hand with|another person|second person|"
r"your hands|your fingers|your embrace|your heat|intertwined|with you|"
r"demands your|before you)\b",
re.IGNORECASE,
)
SD_ANIMA_DUAL_COMPARE = os.getenv("SD_ANIMA_DUAL_COMPARE", "false").lower() in ("1", "true", "yes")
@dataclass
class SdPromptBundle:
tag_full: str
negative: str
desc_full: str | None = None
def extract_image_prompt_tag(text: str) -> str | None:
@@ -44,7 +133,7 @@ SD_UNET = os.getenv("SD_UNET", "")
SD_PROMPT_MODEL = os.getenv("SD_PROMPT_MODEL", "").strip()
PONY_CHECKPOINTS = {"ponyDiffusionV6XL_v6StartWithThisOne.safetensors"}
PONY_NEGATIVE = "score_1, score_2, score_3, score_4, worst quality, low quality, blurry, bad anatomy, watermark, text, censored"
PONY_NEGATIVE = "score_1, score_2, score_3, score_4, worst quality, low quality, blurry, bad anatomy, watermark, text, censored"
ANIMA_NEGATIVE = "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
@@ -56,37 +145,201 @@ def _is_anima() -> bool:
return bool(SD_UNET) and not SD_CHECKPOINT
def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
def anima_dual_enabled() -> bool:
return _is_anima() and SD_ANIMA_DUAL_COMPARE
def _builder_system() -> str:
if _is_anima():
return PROMPT_BUILDER_SYSTEM + ANIMA_BUILDER_EXTRA
return PROMPT_BUILDER_SYSTEM
def _normalize_shot_type(scene: dict) -> dict:
st = (scene.get("shot_type") or "").strip().lower()
if st == "landscape":
scene["shot_type"] = "landscape"
return _sanitize_scene_fields(scene)
if st == "third_person":
action = (scene.get("action_tags") or "").lower()
wide = ("battle", "fight", "chase", "running", "crowd", "wide_shot", "group_shot")
if any(w in action for w in wide):
scene["shot_type"] = "third_person"
return _sanitize_scene_fields(scene)
scene["shot_type"] = "first_person_pov"
if scene.get("viewer_body_visible") is None:
scene["viewer_body_visible"] = False
return _sanitize_scene_fields(scene)
def _split_tag_input(tag_str: str) -> list[str]:
return [t.strip() for t in (tag_str or "").split(",") if t.strip()]
def _is_sentence_like_tag(tag: str) -> bool:
t = tag.strip()
if len(t) > 45:
return True
if re.search(r"[.!?]", t):
return True
words = t.split()
return len(words) >= 5 and "_" not in t
def _filter_tag_field(tag_str: str, *, for_pov: bool, field: str) -> str:
kept: list[str] = []
for raw in _split_tag_input(tag_str):
key = raw.lower().replace(" ", "_")
if key in _INVALID_TAGS:
continue
if _is_sentence_like_tag(raw):
continue
if for_pov and field == "action" and key in _POV_DROP_ACTION_TAGS:
continue
kept.append(raw if "_" in raw else key)
return ", ".join(kept)
def _reconcile_environment_tags(env_str: str) -> str:
tags = _split_tag_input(env_str)
keys = {t.lower().replace(" ", "_") for t in tags}
has_indoor = bool(keys & _INDOOR_ENV_MARKERS) or any(
any(m in k for m in _INDOOR_ENV_MARKERS) for k in keys
)
has_outdoor = bool(keys & _OUTDOOR_ENV_MARKERS) or any(
any(m in k for m in _OUTDOOR_ENV_MARKERS) for k in keys
)
if has_indoor and has_outdoor:
tags = [t for t in tags if t.lower().replace(" ", "_") not in _OUTDOOR_ENV_MARKERS]
return ", ".join(tags)
def _sanitize_pov_prose(desc: str, scene: dict) -> str:
if not desc or not desc.strip():
return ""
if scene.get("shot_type") != "first_person_pov":
return desc.strip()
kept: list[str] = []
for sentence in re.split(r"(?<=[.!?])\s+", desc.strip()):
s = sentence.strip()
if not s:
continue
if _POV_PROSE_BANNED.search(s):
continue
if re.search(r"\bwolfgirl\b", s, re.I) and re.search(
r"\b(walks|walking|stands)\b", s, re.I
):
continue
kept.append(s)
out = " ".join(kept).strip()
return re.sub(r"\bat the viewer\b", "at the camera", out, flags=re.IGNORECASE)
def _sanitize_scene_fields(scene: dict) -> dict:
scene = dict(scene)
for_pov = scene.get("shot_type") == "first_person_pov"
scene["action_tags"] = _filter_tag_field(
scene.get("action_tags") or "", for_pov=for_pov, field="action"
)
env = _filter_tag_field(scene.get("environment_tags") or "", for_pov=False, field="env")
scene["environment_tags"] = _reconcile_environment_tags(env)
scene["scene_description"] = _sanitize_pov_prose(
(scene.get("scene_description") or "").strip(), scene
)
return scene
def _scene_should_generate(scene: dict) -> bool:
if scene.get("should_generate") is False:
return False
return True
def _sanitize_tags_string(tag_str: str) -> str:
if not tag_str:
return ""
out: list[str] = []
seen: set[str] = set()
for raw in tag_str.split(","):
t = raw.strip()
if not t:
continue
key = t.lower().replace(" ", "_")
if key in seen:
continue
if key in _INVALID_TAGS:
continue
if "_" not in key and key in _JUNK_STANDALONE_TAGS:
continue
if len(key) <= 2:
continue
seen.add(key)
out.append(t if "_" in t else key)
return ", ".join(out)
def _quality_prefix() -> str:
if _is_pony():
quality = "score_9, score_8_up, score_7_up, source_anime, highres"
elif _is_anima():
quality = "masterpiece, best quality, score_7, anime"
else:
quality = "masterpiece, best quality, highres"
return "score_9, score_8_up, score_7_up, source_anime, highres"
if _is_anima():
return "masterpiece, best quality, score_7, anime"
return "masterpiece, best quality, highres"
parts = [quality]
appearance = (persona or {}).get("appearance_tags", "")
if appearance:
parts.append(appearance)
if outfit_tags:
parts.append(outfit_tags)
def _appearance_for_persona(persona: dict | None) -> str:
"""Tag core uses appearance_tags only (prose is for LLM context, not Comfy tag line)."""
return _sanitize_tags_string((persona or {}).get("appearance_tags", ""))
if scene.get("shot_type") == "landscape":
parts.append(scene.get("environment_tags", ""))
else:
if scene.get("shot_type") == "first_person_pov":
parts.append("pov, first-person view, looking at viewer")
parts.append(scene.get("action_tags", ""))
parts.append(scene.get("environment_tags", ""))
def _dedupe_outfit_tags(outfit_tags: str) -> str:
tags = _split_tag_input(outfit_tags)
keys = {t.lower().replace(" ", "_") for t in tags}
if len(keys & {"jeans", "ripped_jeans", "black_jeans"}) > 1 and "jeans" in keys:
tags = [t for t in tags if t.lower().replace(" ", "_") != "jeans"]
return ", ".join(tags)
def _scene_has_physical_contact(scene: dict) -> bool:
action = (scene.get("action_tags") or "").lower()
return any(k in action for k in _CONTACT_ACTION_KEYWORDS)
def _infer_pov_cue_from_action(action_tags: str) -> str:
action = (action_tags or "").lower()
if any(k in action for k in ("holding_hands", "hand_holding", "walking", "strolling")):
return "walking_together"
if any(k in action for k in ("doorway", "door", "entry", "threshold")):
if any(k in action for k in ("arms_out", "hug", "embrace", "inviting")):
return "doorway_invite"
if any(k in action for k in ("arms_out", "reaching", "inviting_hug", "hug", "embrace")):
return "reach_to_viewer"
if any(k in action for k in ("sitting", "lying", "bed")):
return "dialogue_close"
return "face_to_face"
def _build_pov_phrase(scene: dict) -> str:
if scene.get("shot_type") != "first_person_pov":
return ""
cue = (scene.get("pov_cue") or "").strip().lower().replace("-", "_").replace(" ", "_")
if cue in POV_CUE_PHRASES:
return POV_CUE_PHRASES[cue]
inferred = _infer_pov_cue_from_action(scene.get("action_tags", ""))
return POV_CUE_PHRASES.get(inferred, POV_CUE_DEFAULT)
def _append_lora(parts: list[str], persona: dict | None) -> None:
lora = (persona or {}).get("lora_name", "")
weight = (persona or {}).get("lora_weight", 0.8)
if lora:
parts.append(f"<lora:{lora}:{weight}>")
def _dedupe_comma_join(parts: list[str]) -> str:
positive = ", ".join(p.strip() for p in parts if p and p.strip())
seen, deduped = set(), []
seen: set[str] = set()
deduped: list[str] = []
for tag in positive.split(", "):
t = tag.strip()
if t and t not in seen:
@@ -95,53 +348,152 @@ def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str =
return ", ".join(deduped)
async def generate_sd_prompt(
messages: list,
persona_id: str,
outfit_json: str = "[]",
) -> tuple[str | None, str | None]:
persona = await get_persona(persona_id)
# Generate only if persona has appearance tags
if not persona or not (persona.get("appearance_tags") or "").strip():
logger.debug("sd_prompt skip: persona=%s no appearance_tags", persona_id)
return None, None
def _build_tag_core(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Anchor + structure: quality, appearance, outfit, action/env tags, LoRA. No POV prose, no scene_description."""
parts = [_quality_prefix()]
appearance = _appearance_for_persona(persona)
if appearance:
parts.append(appearance)
if outfit_tags:
parts.append(_sanitize_tags_string(_dedupe_outfit_tags(outfit_tags)))
if scene.get("shot_type") == "landscape":
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
else:
if not _is_anima() and scene.get("shot_type") == "first_person_pov":
parts.append("pov, first-person view, looking at viewer")
parts.append(_sanitize_tags_string(scene.get("action_tags", "")))
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
_append_lora(parts, persona)
return _dedupe_comma_join(parts)
recent = [m for m in messages if m["role"] in ("user", "assistant")][-6:]
if not recent:
return None, None
excerpt = "\n".join(f"{m['role']}: {strip_image_prompt_tag(m['content'])}" for m in recent)
def build_positive_prompt_tags_only(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Tags + contextual POV phrase (Anima) or legacy Pony path."""
if not _is_anima():
return build_positive_prompt(scene, persona, outfit_tags)
core = _build_tag_core(scene, persona, outfit_tags)
pov = _build_pov_phrase(scene)
if pov:
return f"{core}, {pov}" if core else pov
return core
builder_messages = [
{"role": "system", "content": PROMPT_BUILDER_SYSTEM},
{"role": "user", "content": f"Chat:\n{excerpt}"},
]
try:
if SD_PROMPT_MODEL:
raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL)
else:
raw = await send_message(builder_messages)
raw = raw.strip()
if raw.startswith("```"):
raw = re.sub(r"^```\w*\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw)
scene = json.loads(raw)
if not isinstance(scene, dict):
logger.warning("sd_prompt: LLM returned non-dict: %.100s", raw)
return None, None
except Exception as e:
logger.warning("sd_prompt failed: %s raw=%.200s", e, locals().get("raw", ""))
return None, None
def _tag_tokens_for_dedupe(tag_line: str) -> set[str]:
tokens: set[str] = set()
for part in tag_line.replace("<lora:", " ").split(","):
for word in re.split(r"[\s_./]+", part.lower()):
w = word.strip()
if len(w) >= 4:
tokens.add(w)
return tokens
try:
outfit_list = json.loads(outfit_json or "[]")
outfit_tags = ", ".join(outfit_list) if isinstance(outfit_list, list) else ""
except Exception:
outfit_tags = ""
positive = build_positive_prompt(scene, persona, outfit_tags)
def _trim_redundant_scene_description(desc: str, tag_line: str) -> str:
tag_tokens = _tag_tokens_for_dedupe(tag_line)
if not tag_tokens or not desc.strip():
return desc.strip()
kept: list[str] = []
for sentence in re.split(r"(?<=[.!?])\s+", desc.strip()):
s = sentence.strip()
if not s:
continue
words = [w.lower() for w in re.findall(r"[a-zA-Z]{4,}", s)]
if not words:
kept.append(s)
continue
overlap = sum(1 for w in words if w in tag_tokens) / len(words)
if overlap < 0.62:
kept.append(s)
return " ".join(kept).strip()
def _extract_illustrate_content(content: str, max_chars: int = 1400) -> str:
"""Long assistant posts (first_mes): use final beat after time-skip, last paragraphs."""
text = strip_image_prompt_tag(content).strip()
if not text:
return ""
chunks = _TIME_SKIP_RE.split(text)
if len(chunks) > 1:
text = chunks[-1].strip()
if len(text) <= max_chars:
return text
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
if paragraphs:
for n in (1, 2, 3):
tail = "\n\n".join(paragraphs[-n:])
if len(tail) <= max_chars:
return tail
return paragraphs[-1][-max_chars:]
return text[-max_chars:]
def _fallback_mood_prose(scene: dict) -> str:
cue = (scene.get("pov_cue") or "").strip().lower().replace("-", "_").replace(" ", "_")
if cue in _POV_MOOD_FALLBACK:
return _POV_MOOD_FALLBACK[cue]
inferred = _infer_pov_cue_from_action(scene.get("action_tags", ""))
return _POV_MOOD_FALLBACK.get(inferred, "Soft atmosphere; her expression toward the camera.")
def _cap_scene_description(desc: str, max_words: int = 40, max_chars: int = 220) -> str:
words = desc.split()
if len(words) > max_words:
desc = " ".join(words[:max_words])
if len(desc) > max_chars:
desc = desc[: max_chars - 3] + "..."
return desc
def build_positive_prompt_hybrid(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Production Anima prompt: tag core + POV cue + short mood prose."""
if not _is_anima():
return build_positive_prompt(scene, persona, outfit_tags)
base = build_positive_prompt_tags_only(scene, persona, outfit_tags)
desc = _trim_redundant_scene_description(
(scene.get("scene_description") or "").strip(),
base,
)
desc = _cap_scene_description(desc)
if not desc:
desc = _cap_scene_description(_fallback_mood_prose(scene))
if not desc:
return base
lora = (persona or {}).get("lora_name", "")
weight = (persona or {}).get("lora_weight", 0.8)
lora_suffix = f" <lora:{lora}:{weight}>" if lora else ""
if lora_suffix and base.endswith(lora_suffix):
base = base[: -len(lora_suffix)]
return f"{base}. {desc}{lora_suffix}"
return f"{base}. {desc}"
def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Legacy entry: Pony/non-Anima full prompt; Anima delegates to tags-only."""
if _is_anima():
return build_positive_prompt_tags_only(scene, persona, outfit_tags)
parts = [_quality_prefix()]
appearance = _appearance_for_persona(persona)
if appearance:
parts.append(appearance)
if outfit_tags:
parts.append(_sanitize_tags_string(_dedupe_outfit_tags(outfit_tags)))
if scene.get("shot_type") == "landscape":
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
else:
if scene.get("shot_type") == "first_person_pov":
parts.append("pov, first-person view, looking at viewer")
parts.append(_sanitize_tags_string(scene.get("action_tags", "")))
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
_append_lora(parts, persona)
return _dedupe_comma_join(parts)
def _negative_for_scene(scene: dict) -> str:
if _is_pony():
negative = PONY_NEGATIVE
elif _is_anima():
@@ -151,6 +503,237 @@ async def generate_sd_prompt(
if scene.get("shot_type") == "first_person_pov":
negative += ", third person, over the shoulder"
viewer_visible = scene.get("viewer_body_visible") is True
if not viewer_visible or _scene_has_physical_contact(scene):
negative += ", " + POV_INTERACTION_NEGATIVE
full = positive + f"\n\nNegative prompt: {negative}"
return full, negative
return negative
def _format_builder_user_block(
persona: dict, messages: list[dict], outfit_json: str, scene_json: str = "{}"
) -> str:
lines: list[str] = []
tags = (persona.get("appearance_tags") or "").strip()
lines.append(f"Character appearance (tags): {tags}")
prose = (persona.get("appearance_prose") or "").strip()
if _is_anima() and prose and prose != tags:
snippet = prose[:300] + ("..." if len(prose) > 300 else "")
lines.append(f"Character notes (do not copy into tags or scene_description): {snippet}")
try:
outfit_list = json.loads(outfit_json or "[]")
outfit_ref = ", ".join(outfit_list) if isinstance(outfit_list, list) else ""
except Exception:
outfit_ref = ""
if outfit_ref:
lines.append(f"Current outfit (tags): {outfit_ref}")
from services.rpg_state import parse_scene_json, scene_to_sd_hint
scene_hint = scene_to_sd_hint(parse_scene_json(scene_json))
if scene_hint:
lines.append(f"Scene (location/time):\n{scene_hint}")
recent = [m for m in messages if m.get("role") in ("user", "assistant")][-6:]
if not recent:
lines.append("\nChat:\n(no messages — return should_generate=false)")
return "\n".join(lines)
illustrate: list[dict] = []
if recent[-1]["role"] == "assistant":
illustrate = [recent[-1]]
if len(recent) >= 2 and recent[-2]["role"] == "user":
illustrate.insert(0, recent[-2])
else:
illustrate = [recent[-1]]
if len(recent) >= 2 and recent[-2]["role"] == "assistant":
illustrate.insert(0, recent[-2])
context = [m for m in recent if m not in illustrate]
lines.append("\n=== ILLUSTRATE (draw THIS beat only) ===")
for m in illustrate:
raw = m.get("content", "")
content = _extract_illustrate_content(raw) if m.get("role") == "assistant" else strip_image_prompt_tag(raw)
lines.append(f"{m['role']}: {content}")
if context:
lines.append("\n=== Context (outfit/location hints only — do not illustrate old beats) ===")
for m in context:
content = strip_image_prompt_tag(m.get("content", ""))
if len(content) > 800:
content = content[:797] + "..."
lines.append(f"{m['role']}: {content}")
return "\n".join(lines)
def _parse_scene_json(raw: str) -> dict:
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```\w*\n?", "", cleaned)
cleaned = re.sub(r"\n?```$", "", cleaned)
scene = json.loads(cleaned)
if not isinstance(scene, dict):
raise ValueError("LLM returned non-object JSON")
return _normalize_shot_type(scene)
def _bundle_from_scene(scene: dict, persona: dict, outfit_tags: str) -> SdPromptBundle:
negative = _negative_for_scene(scene)
if _is_anima():
hybrid = build_positive_prompt_hybrid(scene, persona, outfit_tags)
tag_full = hybrid + NEGATIVE_PROMPT_SEPARATOR + negative
desc_full = None
if anima_dual_enabled():
tags_only = build_positive_prompt_tags_only(scene, persona, outfit_tags)
desc_full = tags_only + NEGATIVE_PROMPT_SEPARATOR + negative
return SdPromptBundle(tag_full=tag_full, negative=negative, desc_full=desc_full)
positive = build_positive_prompt(scene, persona, outfit_tags)
tag_full = positive + NEGATIVE_PROMPT_SEPARATOR + negative
return SdPromptBundle(tag_full=tag_full, negative=negative, desc_full=None)
def _parse_chat_excerpt(excerpt: str) -> list[dict]:
messages: list[dict] = []
for line in (excerpt or "").splitlines():
line = line.strip()
if not line:
continue
lower = line.lower()
if lower.startswith("user:"):
messages.append({"role": "user", "content": line[5:].strip()})
elif lower.startswith("assistant:"):
messages.append({"role": "assistant", "content": line[10:].strip()})
elif lower.startswith("system:"):
messages.append({"role": "system", "content": line[7:].strip()})
else:
messages.append({"role": "user", "content": line})
return messages
async def run_prompt_builder(
persona_id: str,
*,
messages: list[dict] | None = None,
chat_excerpt: str = "",
outfit_json: str = "[]",
appearance_override: str | None = None,
use_prose: bool = False,
) -> dict:
"""Debug: full SD prompt builder pipeline with LLM raw output."""
persona = await get_persona(persona_id) or {}
if appearance_override is not None:
persona = {**persona, "appearance_tags": appearance_override}
recent = messages if messages is not None else _parse_chat_excerpt(chat_excerpt)
recent = [m for m in recent if m.get("role") in ("user", "assistant")]
user_block = _format_builder_user_block(persona, recent, outfit_json)
builder_messages = [
{"role": "system", "content": _builder_system()},
{"role": "user", "content": user_block},
]
model_used = SD_PROMPT_MODEL or "SYSTEM_MODEL"
result: dict = {
"persona_id": persona_id,
"sd_prompt_model": model_used,
"builder_system": _builder_system(),
"builder_user": user_block,
"anima_dual": anima_dual_enabled(),
}
raw = ""
try:
if SD_PROMPT_MODEL:
raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL)
else:
raw = await send_message(builder_messages)
result["llm_raw"] = raw
scene = _parse_scene_json(raw)
result["scene"] = scene
if not _scene_should_generate(scene):
result["skipped"] = True
result["error"] = "should_generate=false"
return result
try:
outfit_tags = ", ".join(json.loads(outfit_json or "[]"))
except Exception:
outfit_tags = ""
negative = _negative_for_scene(scene)
if _is_anima():
tags_only = build_positive_prompt_tags_only(scene, persona, outfit_tags)
hybrid = build_positive_prompt_hybrid(scene, persona, outfit_tags)
result["tag_positive"] = tags_only
result["hybrid_positive"] = hybrid
result["negative"] = negative
result["tags_only_full"] = tags_only + NEGATIVE_PROMPT_SEPARATOR + negative
result["hybrid_full"] = hybrid + NEGATIVE_PROMPT_SEPARATOR + negative
result["tag_full"] = result["hybrid_full"]
else:
positive = build_positive_prompt(scene, persona, outfit_tags)
result["tag_positive"] = positive
result["negative"] = negative
result["tag_full"] = positive + NEGATIVE_PROMPT_SEPARATOR + negative
except Exception as e:
result["error"] = str(e)
result["llm_raw"] = raw or result.get("llm_raw", "")
return result
async def generate_sd_prompt(
messages: list,
persona_id: str,
outfit_json: str = "[]",
scene_json: str = "{}",
) -> SdPromptBundle | None:
persona = await get_persona(persona_id)
if not persona:
return None
recent = [m for m in messages if m["role"] in ("user", "assistant")]
if not recent:
return None
user_block = _format_builder_user_block(persona, recent, outfit_json, scene_json)
builder_messages = [
{"role": "system", "content": _builder_system()},
{"role": "user", "content": user_block},
]
raw = ""
try:
if SD_PROMPT_MODEL:
raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL)
else:
raw = await send_message(builder_messages)
scene = _parse_scene_json(raw)
except Exception as e:
logger.warning("sd_prompt failed: %s raw=%.200s", e, raw)
return None
if not _scene_should_generate(scene):
logger.info("sd_prompt: skipped (should_generate=false)")
return None
try:
outfit_list = json.loads(outfit_json or "[]")
outfit_tags = ", ".join(outfit_list) if isinstance(outfit_list, list) else ""
except Exception:
outfit_tags = ""
bundle = _bundle_from_scene(scene, persona, outfit_tags)
if anima_dual_enabled() and bundle.desc_full:
logger.info(
"Anima prompts: hybrid=%.80s | tags_only=%.80s",
bundle.tag_full.split(NEGATIVE_PROMPT_SEPARATOR)[0],
bundle.desc_full.split(NEGATIVE_PROMPT_SEPARATOR)[0],
)
return bundle
+323 -24
View File
@@ -3,6 +3,7 @@ import logging
import os
import uuid
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import httpx
from dotenv import load_dotenv
@@ -11,7 +12,178 @@ load_dotenv()
logger = logging.getLogger(__name__)
SD_BASE_URL = os.getenv("SD_BASE_URL", "http://127.0.0.1:8188").rstrip("/")
def _parse_basic_auth() -> httpx.BasicAuth | None:
"""
Vast Caddy on mapped ports often uses Basic realm=restricted.
Set SD_COMFY_HTTP_BASIC=user:password or SD_COMFY_USER + SD_COMFY_PASSWORD.
"""
raw = (os.getenv("SD_COMFY_HTTP_BASIC") or "").strip()
if raw:
if ":" in raw:
user, _, password = raw.partition(":")
else:
user, password = "", raw
return httpx.BasicAuth(user, password)
user = (os.getenv("SD_COMFY_USER") or "").strip()
password = (os.getenv("SD_COMFY_PASSWORD") or "").strip()
if user or password:
return httpx.BasicAuth(user, password)
return None
SD_BASIC_AUTH = _parse_basic_auth()
def _parse_comfy_config() -> tuple[str, dict[str, str]]:
"""
SD_BASE_URL may be pasted from Vast/Comfy UI with ?token=...
API paths must be base + /prompt, not ...?token=xxx/prompt
"""
raw = (os.getenv("SD_BASE_URL") or "http://127.0.0.1:8188").strip()
extra_token = (os.getenv("SD_COMFY_TOKEN") or "").strip()
parsed = urlparse(raw)
base = f"{parsed.scheme}://{parsed.netloc}"
path = (parsed.path or "").rstrip("/")
if path and path != "/":
base = f"{base}{path}"
query: dict[str, str] = {}
for key, values in parse_qs(parsed.query).items():
if values:
query[key] = values[-1]
if extra_token:
query["token"] = extra_token
base = base.rstrip("/")
# Cloudflare tunnel to localhost:8188 — direct Comfy API, Vast ?token= does not apply
if "trycloudflare.com" in base.lower():
if query.pop("token", None):
logger.info(
"SD_BASE_URL is trycloudflare tunnel: Vast token stripped. "
"Use tunnel for port 8188 only (see instance Port Mapping)."
)
return base, query
SD_BASE_URL, SD_QUERY_PARAMS = _parse_comfy_config()
def _comfy_url(path: str) -> str:
if not path.startswith("/"):
path = f"/{path}"
return f"{SD_BASE_URL}{path}"
def _log_comfy_target() -> str:
if SD_QUERY_PARAMS.get("token"):
return f"{SD_BASE_URL}?token=***"
return SD_BASE_URL
def _absolute_url(location: str, fallback_path: str = "/") -> str:
if not location:
return _comfy_url(fallback_path)
if location.startswith(("http://", "https://")):
return location
if location.startswith("/"):
return f"{SD_BASE_URL}{location}"
return f"{SD_BASE_URL}/{location}"
def _url_with_token(url: str) -> str:
"""Append gateway token to URL (Vast/Cloudflare often strip ?token on redirect)."""
if not SD_QUERY_PARAMS.get("token"):
return url
p = urlparse(url)
q: dict[str, str] = {}
for key, values in parse_qs(p.query).items():
if values:
q[key] = values[-1]
q.update(SD_QUERY_PARAMS)
return urlunparse((p.scheme, p.netloc, p.path, "", urlencode(q), ""))
def _merge_params(extra: dict | None) -> dict | None:
if not SD_QUERY_PARAMS and not extra:
return None
merged = dict(SD_QUERY_PARAMS)
if extra:
merged.update(extra)
return merged
def _is_vast_gateway() -> bool:
return "trycloudflare.com" not in SD_BASE_URL.lower()
def _make_comfy_client(*, timeout: float = 300) -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout=timeout,
follow_redirects=False,
auth=SD_BASIC_AUTH,
)
async def _prime_comfy_gateway(client: httpx.AsyncClient) -> None:
"""
Vast Caddy: browser opens /?token= and gets a session cookie; API then works.
Prime with redirects so Set-Cookie is collected, then merge into the API client.
"""
token = SD_QUERY_PARAMS.get("token")
if not token or not _is_vast_gateway():
return
try:
async with httpx.AsyncClient(
timeout=30,
follow_redirects=True,
auth=SD_BASIC_AUTH,
) as prime:
r = await prime.get(_comfy_url("/"), params={"token": token})
client.cookies.update(prime.cookies)
logger.info(
"Comfy gateway prime GET /?token=*** → %s, cookies=%s",
r.status_code,
list(prime.cookies.keys()) or "(none)",
)
except Exception as e:
logger.warning("Comfy gateway prime failed: %s", e)
async def _comfy_request(
client: httpx.AsyncClient,
method: str,
path: str,
*,
params: dict | None = None,
**kwargs,
) -> httpx.Response:
"""
Comfy API: trycloudflare tunnel = no token.
Vast IP:PORT gateway = ?token= + cookie prime; follow redirects with token re-attached.
"""
url = _comfy_url(path)
extra = params or {}
token = SD_QUERY_PARAMS.get("token")
use_vast_auth = _is_vast_gateway() and (bool(token) or SD_BASIC_AUTH is not None)
if token and _is_vast_gateway():
await _prime_comfy_gateway(client)
req_params: dict | None = _merge_params(extra) if use_vast_auth else (extra or None)
resp: httpx.Response | None = None
for hop in range(6):
resp = await client.request(method, url, params=req_params, **kwargs)
if resp.status_code not in (301, 302, 303, 307, 308):
return resp
loc = _absolute_url(resp.headers.get("location", ""), path)
url = _url_with_token(loc) if use_vast_auth else loc
req_params = extra or None
logger.info("Comfy redirect %s hop %s%s", resp.status_code, hop + 1, url.split("?")[0])
assert resp is not None
return resp
SD_STEPS = int(os.getenv("SD_STEPS", "28"))
SD_CFG = float(os.getenv("SD_CFG", "7"))
SD_SAMPLER = os.getenv("SD_SAMPLER", "euler")
@@ -26,6 +198,8 @@ SD_DEFAULT_NEGATIVE = os.getenv(
SD_UNET = os.getenv("SD_UNET", "anima-preview3-base.safetensors")
SD_CLIP = os.getenv("SD_CLIP", "qwen_3_06b_base.safetensors")
SD_VAE = os.getenv("SD_VAE", "qwen_image_vae.safetensors")
SD_STYLE_LORA = os.getenv("SD_STYLE_LORA", "")
SD_STYLE_LORA_WEIGHT = float(os.getenv("SD_STYLE_LORA_WEIGHT", "0.7"))
IMAGES_DIR = Path(os.getenv("IMAGES_DIR", "static/images"))
@@ -38,20 +212,38 @@ def _use_anima() -> bool:
def split_prompt_and_negative(full_prompt: str) -> tuple[str, str]:
# Try new separator first
sep = "__NEGATIVE_PROMPT__"
if f"\n{sep}\n" in full_prompt:
pos, _, neg = full_prompt.partition(f"\n{sep}\n")
return pos.strip(), neg.strip()
# Fallback to old format
if "\n\nNegative prompt:" in full_prompt:
pos, _, neg = full_prompt.partition("\n\nNegative prompt:")
return pos.strip(), neg.strip()
return full_prompt.strip(), SD_DEFAULT_NEGATIVE
def _build_workflow(positive: str, negative: str) -> dict:
def _workflow_uses_anima(overrides: dict | None) -> bool:
if overrides and overrides.get("checkpoint"):
return False
if overrides and overrides.get("unet"):
return True
return _use_anima()
def _build_workflow(positive: str, negative: str, overrides: dict | None = None) -> dict:
seed = int(uuid.uuid4().int % 2**32)
if _use_anima():
return {
"44": {"class_type": "UNETLoader", "inputs": {"unet_name": SD_UNET, "weight_dtype": "default"}},
"45": {"class_type": "CLIPLoader", "inputs": {"clip_name": SD_CLIP, "type": "stable_diffusion", "device": "default"}},
"15": {"class_type": "VAELoader", "inputs": {"vae_name": SD_VAE}},
"28": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
o = overrides or {}
if _workflow_uses_anima(o):
unet = o.get("unet") or SD_UNET
clip = o.get("clip") or SD_CLIP
vae = o.get("vae") or SD_VAE
workflow = {
"44": {"class_type": "UNETLoader", "inputs": {"unet_name": unet, "weight_dtype": "default"}},
"45": {"class_type": "CLIPLoader", "inputs": {"clip_name": clip, "type": "stable_diffusion", "device": "default"}},
"15": {"class_type": "VAELoader", "inputs": {"vae_name": vae}},
"28": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 720, "batch_size": 1}},
"11": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["45", 0]}},
"12": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["45", 0]}},
"19": {
@@ -68,9 +260,24 @@ def _build_workflow(positive: str, negative: str) -> dict:
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["19", 0], "vae": ["15", 0]}},
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "chatbot", "images": ["8", 0]}},
}
# Standard checkpoint workflow (Pony / SDXL)
if SD_STYLE_LORA:
workflow["46"] = {
"class_type": "LoraLoader",
"inputs": {
"lora_name": SD_STYLE_LORA,
"model": ["44", 0],
"clip": ["45", 0],
"strength_model": SD_STYLE_LORA_WEIGHT,
"strength_clip": SD_STYLE_LORA_WEIGHT,
},
}
workflow["19"]["inputs"]["model"] = ["46", 0]
workflow["11"]["inputs"]["clip"] = ["46", 1]
workflow["12"]["inputs"]["clip"] = ["46", 1]
return workflow
ckpt = o.get("checkpoint") or SD_CHECKPOINT
return {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": SD_CHECKPOINT}},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216, "batch_size": 1}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["4", 1]}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["4", 1]}},
@@ -89,24 +296,78 @@ def _build_workflow(positive: str, negative: str) -> dict:
}
async def comfy_api_request(
method: str,
path: str,
*,
params: dict | None = None,
json_body: dict | None = None,
timeout: float = 60,
) -> tuple[int, dict | str, dict]:
"""
Raw Comfy API call for debug. Returns (status_code, parsed_json_or_text, response_headers_subset).
"""
async with _make_comfy_client(timeout=timeout) as client:
await _prime_comfy_gateway(client)
token = SD_QUERY_PARAMS.get("token")
use_vast = _is_vast_gateway() and (bool(token) or SD_BASIC_AUTH is not None)
req_params = _merge_params(params) if use_vast else (params or None)
req_kwargs: dict = {}
if json_body is not None and method.upper() not in ("GET", "HEAD"):
req_kwargs["json"] = json_body
resp = await _comfy_request(
client,
method.upper(),
path,
params=req_params,
**req_kwargs,
)
headers = {
k: resp.headers.get(k)
for k in ("content-type", "location", "www-authenticate")
if resp.headers.get(k)
}
try:
body = resp.json()
except Exception:
body = resp.text[:8000]
return resp.status_code, body, headers
async def fetch_object_info() -> dict:
status, body, _ = await comfy_api_request("GET", "/object_info", timeout=120)
if status != 200 or not isinstance(body, dict):
raise RuntimeError(f"object_info failed: HTTP {status} {body!s:.300}")
return body
async def check_sd() -> bool:
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{SD_BASE_URL}/system_stats")
async with _make_comfy_client(timeout=15) as client:
await _prime_comfy_gateway(client)
r = await _comfy_request(client, "GET", "/system_stats")
return r.status_code == 200
except Exception:
return False
async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[bytes, str]:
async def txt2img(
prompt: str,
negative_prompt: str | None = None,
*,
overrides: dict | None = None,
) -> tuple[bytes, str]:
neg = negative_prompt or SD_DEFAULT_NEGATIVE
workflow = _build_workflow(prompt, neg)
workflow = _build_workflow(prompt, neg, overrides)
client_id = uuid.uuid4().hex
logger.info("ComfyUI request → %s prompt: %.120s", SD_BASE_URL, prompt)
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(
f"{SD_BASE_URL}/prompt",
logger.info("ComfyUI request → %s prompt: %.120s", _log_comfy_target(), prompt)
async with _make_comfy_client() as client:
await _prime_comfy_gateway(client)
resp = await _comfy_request(
client,
"POST",
"/prompt",
json={"prompt": workflow, "client_id": client_id},
)
resp.raise_for_status()
@@ -115,7 +376,7 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
for _ in range(300):
await asyncio.sleep(1)
hist = await client.get(f"{SD_BASE_URL}/history/{prompt_id}")
hist = await _comfy_request(client, "GET", f"/history/{prompt_id}")
data = hist.json()
if prompt_id in data:
entry = data[prompt_id]
@@ -127,9 +388,15 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
for node_output in outputs.values():
if "images" in node_output:
img_info = node_output["images"][0]
img_resp = await client.get(
f"{SD_BASE_URL}/view",
params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""), "type": img_info.get("type", "output")},
img_resp = await _comfy_request(
client,
"GET",
"/view",
params={
"filename": img_info["filename"],
"subfolder": img_info.get("subfolder", ""),
"type": img_info.get("type", "output"),
},
)
img_resp.raise_for_status()
image_bytes = img_resp.content
@@ -145,11 +412,43 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
raise RuntimeError("ComfyUI generation timed out or produced no output")
async def generate_from_full_prompt(full_prompt: str) -> tuple[str | None, str | None]:
async def generate_from_full_prompt(
full_prompt: str,
*,
overrides: dict | None = None,
) -> tuple[str | None, str | None]:
positive, negative = split_prompt_and_negative(full_prompt)
try:
_, rel_path = await txt2img(positive, negative)
_, rel_path = await txt2img(positive, negative, overrides=overrides)
return rel_path, None
except httpx.HTTPStatusError as e:
code = e.response.status_code
if code == 401:
logger.error(
"ComfyUI 401: Vast Caddy needs SD_COMFY_TOKEN (or ?token= in SD_BASE_URL) "
"and/or SD_COMFY_HTTP_BASIC=user:pass from the instance page. "
"Test: curl -u user:pass http://IP:PORT/system_stats "
"or open /?token=… in browser then curl with cookies. "
"Alternative: trycloudflare URL for localhost:8188 in Port Mapping."
)
elif code in (301, 302, 303, 307, 308):
logger.error(
"ComfyUI %s: wrong URL — use trycloudflare tunnel for 8188, not web UI link. "
"SD_BASE_URL=https://reviewer-relief-edmonton-specializing.trycloudflare.com "
"(no ?token=). Location: %s",
code,
e.response.headers.get("location"),
)
else:
logger.error("ComfyUI HTTP %s: %s", code, e)
return None, str(e)
except httpx.ConnectError as e:
logger.error(
"ComfyUI connect failed (%s): IP:8188 is often not exposed on Vast. "
"Use trycloudflare URL from Port Mapping for localhost:8188.",
e,
)
return None, str(e)
except Exception as e:
logger.error("ComfyUI error: %s", e)
return None, str(e)
+31
View File
@@ -0,0 +1,31 @@
import logging
from services.memory import get_session
logger = logging.getLogger(__name__)
async def resolve_session_persona(
session_id: str,
requested: str | None = None,
*,
create_persona: str | None = None,
) -> str:
"""
Session.persona_id is the source of truth.
requested is ignored when it disagrees (logged). create_persona used only if session missing.
"""
session = await get_session(session_id)
if not session:
return (create_persona or requested or "default").strip() or "default"
bound = (session.get("persona_id") or "default").strip() or "default"
req = (requested or "").strip()
if req and req != bound:
logger.warning(
"persona_id mismatch session=%s bound=%s requested=%s (using bound)",
session_id,
bound,
req,
)
return bound
+21
View File
@@ -0,0 +1,21 @@
import logging
from services.chat_prompt import get_system_prompt
from services.memory import get_all_sessions, get_history, upsert_static_system_message
logger = logging.getLogger(__name__)
async def migrate_static_system_messages() -> int:
"""Rebuild stored system rows from sessions.persona_id (strip legacy RPG text)."""
updated = 0
for session in await get_all_sessions():
sid = session["session_id"]
persona_id = session.get("persona_id") or "default"
history = await get_history(sid)
static = await get_system_prompt(persona_id, history, "")
if await upsert_static_system_message(sid, static, history):
updated += 1
if updated:
logger.info("Migrated %s session system message(s) to static persona prompt", updated)
return updated
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+268 -5
View File
@@ -156,6 +156,88 @@ header h1 { font-size: 1.1rem; color: #e94560; }
#systemBlobRefresh.spinning { animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.blob-changed { background: rgba(255, 200, 50, 0.15); border-radius: 3px; transition: background 2s ease; }
.blob-context-warn { color: #e9a045; }
.rp-dialogue { color: #e94560; }
.rp-action { font-style: italic; color: #a8a8b8; }
.rp-choice {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
background: rgba(155, 127, 212, 0.15);
border: 1px solid rgba(155, 127, 212, 0.35);
}
.rp-choice-tag {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9b7fd4;
font-weight: 600;
}
.rp-choice-label { color: #d4c8f0; }
.dice-user-override {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.12);
}
.dice-user-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
opacity: 0.9;
}
.dice-user-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
line-height: 1.45;
}
.dice-intent-struck {
text-decoration: line-through;
opacity: 0.55;
color: #888;
}
.dice-intent-arrow { color: #9b7fd4; font-weight: bold; }
.dice-intent-resolved { color: #c8e6c9; font-style: italic; }
.message.user.has-dice-override .bubble { border-color: rgba(155, 127, 212, 0.4); }
.quest-panel-actions {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid #333;
}
.quest-panel-actions button {
flex: 1;
font-size: 0.75rem;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #444;
background: #2a2a3e;
color: #ccc;
cursor: pointer;
}
.quest-panel-actions button:hover:not(:disabled) { background: #3a3a52; }
.quest-panel-actions button:disabled { opacity: 0.4; cursor: default; }
.quest-panel-actions .quest-btn-done:hover:not(:disabled) { border-color: #2ecc71; color: #2ecc71; }
.quest-panel-actions .quest-btn-fail:hover:not(:disabled) { border-color: #e74c3c; color: #e74c3c; }
.format-btn {
margin-top: 4px;
padding: 2px 8px;
font-size: 0.75rem;
border: 1px solid #444;
border-radius: 4px;
background: transparent;
color: #aaa;
cursor: pointer;
align-self: flex-start;
}
.format-btn:hover { background: #4a90d9; color: white; border-color: #4a90d9; }
.system-blob-content {
white-space: pre-wrap;
word-break: break-word;
@@ -283,7 +365,16 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.translate-btn:hover { background: #4a90d9; color: white; }
.translate-btn:disabled { opacity: 0.5; cursor: default; }
.chat-image { margin-top: 8px; max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; }
.chat-image-wrap { margin-top: 8px; }
.chat-image-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chat-image { max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; display: block; }
.image-prompt-blocks .image-prompt-block + .image-prompt-block { margin-top: 8px; }
.image-generating {
display: flex;
@@ -312,10 +403,41 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.image-error { margin-top: 6px; font-size: 0.75rem; color: #888; }
.choice-row {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
position: relative;
z-index: 1;
}
.choice-section-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.choice-section-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #c9a227;
margin-bottom: 4px;
}
.choice-section-label-generic {
color: #888;
}
.choice-section-plot {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(201, 162, 39, 0.45);
background: rgba(201, 162, 39, 0.08);
}
.choice-beat-teaser {
font-size: 0.75rem;
color: #b8a888;
font-style: italic;
line-height: 1.35;
margin-bottom: 6px;
white-space: pre-wrap;
}
.choice-btn {
background: #16213e;
@@ -326,10 +448,20 @@ header h1 { font-size: 1.1rem; color: #e94560; }
padding: 6px 10px;
cursor: pointer;
}
.choice-btn-plot {
border-color: rgba(201, 162, 39, 0.55);
background: rgba(201, 162, 39, 0.12);
color: #e8d5a3;
}
.choice-btn:hover {
border-color: #e94560;
color: #e94560;
}
.choice-btn-plot:hover {
border-color: #c9a227;
color: #f0e0b0;
background: rgba(201, 162, 39, 0.2);
}
.typing {
@@ -499,6 +631,14 @@ textarea:focus { border-color: #e94560; }
.outcome-crit-success .dice-outcome { color: #f1c40f; }
.narrator-text { white-space: pre-wrap; line-height: 1.5; }
.narrator-activity-hint {
margin-top: 6px;
font-size: 0.72rem;
color: #9b7fd4;
opacity: 0.9;
line-height: 1.35;
}
/* Affinity display in header */
.affinity-display {
font-size: 0.8rem; padding: 4px 10px;
@@ -508,21 +648,137 @@ textarea:focus { border-color: #e94560; }
.affinity-display.affinity-high { border-color: #e94560; color: #e94560; }
.affinity-display.affinity-low { border-color: #555; color: #666; }
.stats-display {
font-size: 0.72rem;
padding: 4px 8px;
border: 1px solid #0f3460;
border-radius: 10px;
color: #9b9bb8;
white-space: nowrap;
}
.stats-display.stats-warn { border-color: #e9a045; color: #e9a045; }
.stats-display.stats-critical { border-color: #e74c3c; color: #e74c3c; }
.rpg-debug-panel {
margin-top: 12px;
padding: 10px;
border: 1px dashed #3a3a52;
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.rpg-debug-panel summary {
cursor: pointer;
font-size: 0.85rem;
color: #9b7fd4;
}
.rpg-debug-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 10px 0;
}
.rpg-debug-grid label {
font-size: 0.78rem;
color: #aaa;
display: flex;
flex-direction: column;
gap: 4px;
}
.rpg-debug-grid input {
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 6px;
color: #e0e0e0;
padding: 6px 8px;
}
.rpg-debug-apply {
background: #3a3a52;
border: 1px solid #9b7fd4;
border-radius: 8px;
color: #e0e0e0;
padding: 6px 14px;
cursor: pointer;
font-size: 0.8rem;
}
.rpg-debug-apply:hover { background: #4a4a62; }
.rpg-debug-status {
margin-left: 8px;
font-size: 0.75rem;
color: #2ecc71;
}
.context-editor-modal { max-width: 640px; }
.context-editor-body label {
display: block;
font-size: 0.78rem;
color: #aaa;
margin-bottom: 10px;
}
.context-editor-body textarea,
.context-editor-body input[type="number"] {
width: 100%;
margin-top: 4px;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
color: #e0e0e0;
padding: 8px 10px;
font-family: Consolas, 'Segoe UI', monospace;
font-size: 0.78rem;
resize: vertical;
}
.context-editor-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
#contextEditorOpen {
background: transparent;
border: 1px solid #0f3460;
border-radius: 8px;
color: #888;
padding: 2px 8px;
cursor: pointer;
font-size: 0.9rem;
}
#contextEditorOpen:hover { border-color: #9b7fd4; color: #9b7fd4; }
/* Quest panel in sidebar */
.quest-panel {
border-top: 1px solid #0f3460;
padding: 10px 14px;
flex-shrink: 0;
display: flex;
flex-direction: column;
max-height: min(42vh, 280px);
min-height: 0;
}
#questList {
overflow-y: auto;
flex: 1;
min-height: 0;
}
.quest-panel-header {
font-size: 0.75rem; color: #888;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 6px;
margin-bottom: 4px;
}
.quest-panel-hint {
font-size: 0.68rem;
color: #555;
margin: 0 0 6px;
line-height: 1.3;
}
.quest-item {
font-size: 0.8rem; padding: 4px 0;
font-size: 0.8rem; padding: 4px 6px;
color: #bbb; line-height: 1.4;
border-bottom: 1px solid #0f3460;
cursor: pointer;
user-select: none;
}
.quest-item.quest-selected {
background: rgba(233, 69, 96, 0.12);
border-radius: 4px;
}
.quest-item:last-child { border-bottom: none; }
.quest-done { color: #555; text-decoration: line-through; }
@@ -587,6 +843,11 @@ textarea:focus { border-color: #e94560; }
flex-direction: row !important;
padding: 8px 0;
}
.hint-text {
font-size: 0.8rem;
color: #888;
margin: 0 0 8px;
}
.chat-settings-meta {
margin-top: 12px; padding: 10px;
background: #1a1a2e; border-radius: 8px;
@@ -610,7 +871,9 @@ textarea:focus { border-color: #e94560; }
outline: none; border-bottom: 1px dashed #e94560;
}
.message-actions {
display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;
display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap;
position: relative;
z-index: 2;
}
.message-actions button {
background: #0f3460; border: none; border-radius: 6px;
+209
View File
@@ -0,0 +1,209 @@
/* app.css sets body { overflow: hidden; height: 100vh } for chat layout */
html:has(body.debug-page),
body.debug-page {
height: auto;
min-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
}
.debug-page {
background: #0f0f1a;
color: #ddd;
min-height: 100vh;
padding-bottom: 48px;
}
.debug-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px solid #1a2744;
background: #16213e;
}
.debug-header a {
color: #9b7fd4;
text-decoration: none;
}
.debug-header h1 {
flex: 1;
margin: 0;
font-size: 1.1rem;
}
.debug-tabs {
display: flex;
gap: 4px;
padding: 8px 16px;
background: #12121f;
border-bottom: 1px solid #1a2744;
flex-wrap: wrap;
}
.debug-tabs button {
background: transparent;
border: 1px solid #2a3a5c;
color: #aaa;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
}
.debug-tabs button.active {
background: #1a2744;
color: #e94560;
border-color: #e94560;
}
.debug-main {
padding: 16px 20px 40px;
max-width: 1200px;
margin: 0 auto;
overflow: visible;
}
.debug-panel {
display: none;
}
.debug-panel.active {
display: block;
}
.debug-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.debug-grid label,
.debug-main > label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
color: #aaa;
margin-bottom: 10px;
}
.debug-grid input,
.debug-grid select,
.debug-main textarea,
.debug-main input,
.debug-main select {
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
border-radius: 6px;
padding: 8px;
font-family: inherit;
}
.debug-main textarea {
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.debug-btn {
background: #1a2744;
border: 1px solid #3a5080;
color: #ccc;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 12px;
}
.debug-btn.primary {
background: #e94560;
border-color: #e94560;
color: #fff;
}
.debug-btn:hover {
filter: brightness(1.1);
}
.debug-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.debug-out {
background: #0a0a14;
border: 1px solid #1a2744;
border-radius: 8px;
padding: 12px;
overflow: auto;
max-height: 420px;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
}
.debug-out.compact {
max-height: 160px;
}
.debug-out.small {
max-height: 240px;
}
.debug-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 800px) {
.debug-split {
grid-template-columns: 1fr;
}
}
.debug-split h3,
.debug-main h3 {
font-size: 0.9rem;
color: #9b7fd4;
margin: 16px 0 8px;
}
.debug-img-wrap {
margin: 12px 0;
}
.debug-img-wrap img {
max-width: 100%;
max-height: 512px;
border-radius: 8px;
border: 1px solid #333;
}
.debug-img-wrap.hidden {
display: none;
}
.model-list-block {
margin-bottom: 8px;
}
.model-list-block summary {
cursor: pointer;
color: #9b7fd4;
}
.model-list-block ul {
margin: 4px 0 0;
padding-left: 1.2rem;
font-size: 0.8rem;
max-height: 120px;
overflow: auto;
}
+134
View File
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug — AI ChatBot</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/debug.css">
</head>
<body class="debug-page">
<header class="debug-header">
<a href="/">← Чат</a>
<h1>Debug</h1>
<button type="button" id="btnReloadConfig">↻ Config</button>
</header>
<nav class="debug-tabs" id="debugTabs">
<button type="button" class="active" data-tab="config">Config</button>
<button type="button" data-tab="sdprompt">SD Prompt</button>
<button type="button" data-tab="llm">LLM</button>
<button type="button" data-tab="comfy">ComfyUI</button>
</nav>
<main class="debug-main">
<section class="debug-panel active" id="panel-config">
<pre id="configOut" class="debug-out">Загрузка…</pre>
</section>
<section class="debug-panel" id="panel-sdprompt">
<div class="debug-grid">
<label>Персонаж
<select id="sdPersona"></select>
</label>
<label>Outfit JSON
<input type="text" id="sdOutfit" value="[]" placeholder='["dress", "barefoot"]'>
</label>
<label>Appearance override (опц.)
<input type="text" id="sdAppearance" placeholder="оставьте пустым — из карточки">
</label>
<label>
<input type="checkbox" id="sdUseProse"> Использовать prose для Anima
</label>
</div>
<label>Чат (user: / assistant:)
<textarea id="sdChat" rows="8" placeholder="user: привет&#10;assistant: *улыбается*"></textarea>
</label>
<button type="button" class="debug-btn primary" id="btnSdPrompt">Собрать промпт (SD_PROMPT_MODEL)</button>
<div class="debug-split">
<div>
<h3>Scene JSON</h3>
<pre id="sdScene" class="debug-out"></pre>
</div>
<div>
<h3>Теги / гибрид</h3>
<pre id="sdPrompts" class="debug-out"></pre>
</div>
</div>
<details>
<summary>LLM raw + builder</summary>
<pre id="sdLlmRaw" class="debug-out small"></pre>
</details>
</section>
<section class="debug-panel" id="panel-llm">
<div class="debug-grid">
<label>Model
<input type="text" id="llmModel" placeholder="пусто = SD_PROMPT_MODEL / SYSTEM">
</label>
</div>
<label>System
<textarea id="llmSystem" rows="4"></textarea>
</label>
<label>User
<textarea id="llmUser" rows="6"></textarea>
</label>
<button type="button" class="debug-btn primary" id="btnLlm">Отправить</button>
<pre id="llmOut" class="debug-out"></pre>
</section>
<section class="debug-panel" id="panel-comfy">
<div class="debug-row">
<button type="button" class="debug-btn" id="btnComfyPing">Ping /system_stats</button>
<button type="button" class="debug-btn" id="btnComfyModels">Загрузить модели (/object_info)</button>
</div>
<pre id="comfyPingOut" class="debug-out compact"></pre>
<h3>Модели в Comfy</h3>
<div class="debug-grid" id="comfyModelLists"></div>
<h3>Генерация</h3>
<div class="debug-grid">
<label>UNET <select id="genUnet"><option value="">— env —</option></select></label>
<label>CLIP <select id="genClip"><option value="">— env —</option></select></label>
<label>VAE <select id="genVae"><option value="">— env —</option></select></label>
<label>Checkpoint <select id="genCkpt"><option value="">— env / Anima —</option></select></label>
</div>
<label>Positive
<textarea id="genPositive" rows="4"></textarea>
</label>
<label>Negative
<textarea id="genNegative" rows="2"></textarea>
</label>
<button type="button" class="debug-btn primary" id="btnComfyGen">Сгенерировать</button>
<div id="comfyImgWrap" class="debug-img-wrap hidden">
<img id="comfyImg" alt="result">
</div>
<pre id="comfyGenOut" class="debug-out compact"></pre>
<h3>Raw API</h3>
<div class="debug-grid">
<label>Method
<select id="rawMethod">
<option>GET</option>
<option>POST</option>
</select>
</label>
<label>Path
<input type="text" id="rawPath" value="/system_stats">
</label>
</div>
<label>Query JSON
<textarea id="rawParams" rows="2">{}</textarea>
</label>
<label>Body JSON (POST)
<textarea id="rawBody" rows="4" placeholder="{}"></textarea>
</label>
<button type="button" class="debug-btn" id="btnComfyRaw">Выполнить</button>
<pre id="comfyRawOut" class="debug-out"></pre>
</section>
</main>
<script type="module" src="/static/js/debug.js"></script>
</body>
</html>
+83 -2
View File
@@ -13,8 +13,10 @@
<h1>🤖 AI Chat</h1>
<span class="header-title" id="headerTitle">Новый чат</span>
<span id="rpgBadge" class="rpg-badge hidden" title="RPG режим">RPG</span>
<a href="/debug" class="header-icon-btn" title="Debug" style="text-decoration:none">🛠</a>
<button id="chatSettingsBtn" type="button" class="header-icon-btn" title="Настройки чата">⚙️</button>
<span id="affinityDisplay" class="affinity-display hidden"></span>
<span id="affinityDisplay" class="affinity-display hidden" title="Симпатия к игроку"></span>
<span id="statsDisplay" class="stats-display hidden" title="Шкалы lust / stamina / tension"></span>
</header>
<div class="app-body">
@@ -26,7 +28,12 @@
<div class="session-list" id="sessionList"></div>
<div class="quest-panel hidden" id="questPanel">
<div class="quest-panel-header">Квесты</div>
<p class="quest-panel-hint" id="questPanelHint">Клик по 🔸 — выбор, затем кнопка ниже</p>
<div id="questList"></div>
<div class="quest-panel-actions" id="questPanelActions">
<button type="button" id="questBtnDone" class="quest-btn-done" disabled>✓ Готово</button>
<button type="button" id="questBtnFail" class="quest-btn-fail" disabled>✗ Провал</button>
</div>
</div>
</aside>
@@ -36,6 +43,7 @@
<div class="system-blob-header">
<span>System</span>
<button type="button" id="systemBlobRefresh" title="Обновить"></button>
<button type="button" id="contextEditorOpen" title="Редактировать контекст"></button>
<button type="button" id="systemBlobToggle">Скрыть</button>
</div>
<pre class="system-blob-content" id="systemBlobContent"></pre>
@@ -289,6 +297,7 @@
<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="ncSettingStats"> 📊 Шкалы (lust/stamina/tension)</label>
<label><input type="checkbox" id="ncSettingChoices" checked> 🔘 Кнопки выбора</label>
</div>
</div>
@@ -314,6 +323,9 @@
<label>Название чата
<input type="text" id="chatSettingsTitle">
</label>
<p class="wizard-page-title">Персонаж чата</p>
<p class="hint-text">Смена персонажа перепривязывает этот чат. Историю можно сохранить или очистить.</p>
<div class="persona-pick-grid" id="chatSettingsPersonaGrid"></div>
<label class="rpg-mode-option">
<input type="checkbox" id="chatSettingsRpg"> RPG режим
</label>
@@ -334,9 +346,30 @@
<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="csSettingStats"> 📊 Шкалы (lust/stamina/tension)</label>
<label><input type="checkbox" id="csSettingChoices"> 🔘 Кнопки выбора</label>
</div>
<div class="chat-settings-meta" id="chatSettingsMeta"></div>
<details class="rpg-debug-panel" id="chatSettingsRpgDebug">
<summary>🧪 Отладка: симпатия и шкалы</summary>
<p class="hint-text">Жёстко задаёт состояние для следующих реплик. Цель — текущий игрок (позже: пары персонаж×игрок).</p>
<div class="rpg-debug-grid">
<label>💖 Симпатия (30…30)
<input type="number" id="debugAffinity" min="-30" max="30" step="1">
</label>
<label>🔥 Lust (010)
<input type="number" id="debugLust" min="0" max="10" step="1">
</label>
<label>⚡ Stamina (010)
<input type="number" id="debugStamina" min="0" max="10" step="1">
</label>
<label>😰 Tension (010)
<input type="number" id="debugTension" min="0" max="10" step="1">
</label>
</div>
<button type="button" id="debugRpgStateApply" class="rpg-debug-apply">Применить к сессии</button>
<span class="rpg-debug-status" id="debugRpgStateStatus"></span>
</details>
</div>
</div>
<div class="modal-buttons modal-wizard-footer">
@@ -346,6 +379,54 @@
</div>
</div>
<script type="module" src="/static/js/app.js?v=4"></script>
<div class="modal-overlay" id="contextEditorModal">
<div class="modal modal-wizard context-editor-modal">
<div class="modal-wizard-header">
<h2>✎ Контекст сессии</h2>
</div>
<div class="modal-wizard-body context-editor-body">
<p class="hint-text">Правки применяются сразу к следующим сообщениям и генерации картинок. JSON-поля — массив или объект.</p>
<label>Status quo
<textarea id="ctxStatusQuo" rows="3" spellcheck="false"></textarea>
</label>
<label>Global plot
<textarea id="ctxGlobalPlot" rows="2" spellcheck="false"></textarea>
</label>
<label>Outfit (JSON array, danbooru-теги с цветом)
<textarea id="ctxOutfit" rows="4" spellcheck="false" placeholder='["black_sports_shorts", "white_torn_tank_top"]'></textarea>
</label>
<label>Scene (JSON)
<textarea id="ctxScene" rows="4" spellcheck="false"></textarea>
</label>
<label>Facts (JSON array)
<textarea id="ctxFacts" rows="3" spellcheck="false" placeholder='[{"text":"...", "rp_day":"день 1"}]'></textarea>
</label>
<label>Plot arc (JSON)
<textarea id="ctxPlotArc" rows="6" spellcheck="false"></textarea>
</label>
<div class="context-editor-stats">
<label>💖 Affinity
<input type="number" id="ctxAffinity" min="-30" max="30" step="1">
</label>
<label>🔥 Lust
<input type="number" id="ctxLust" min="0" max="10" step="1">
</label>
<label>⚡ Stamina
<input type="number" id="ctxStamina" min="0" max="10" step="1">
</label>
<label>😰 Tension
<input type="number" id="ctxTension" min="0" max="10" step="1">
</label>
</div>
<span class="rpg-debug-status" id="contextEditorStatus"></span>
</div>
<div class="modal-buttons modal-wizard-footer">
<button id="contextEditorCancel" type="button">Отмена</button>
<button id="contextEditorSave" type="button" style="background:#9b7fd4;color:white">Сохранить</button>
</div>
</div>
</div>
<script type="module" src="/static/js/app.js?v=17"></script>
</body>
</html>
+4 -1
View File
@@ -2,8 +2,9 @@ import { toggleSidebar, dom } from './state.js';
import { initSessions } from './sessions.js';
import { openNewChatWizard, initNewChatWizard } from './newChatWizard.js';
import { openChatSettings, initChatSettings } from './chatSettings.js';
import { initContextEditor } from './contextEditor.js';
import { loadPersonas, initPersonaModals } from './personas.js';
import { sendMessage, clearHistory } from './chat.js';
import { sendMessage, clearHistory, initQuestPanel } from './chat.js';
document.getElementById('sidebarToggle').addEventListener('click', () => {
const open = toggleSidebar();
@@ -36,5 +37,7 @@ dom.systemBlobToggle?.addEventListener('click', () => {
initPersonaModals();
initNewChatWizard();
initChatSettings();
initContextEditor();
initQuestPanel();
await initSessions();
loadPersonas();
+392 -43
View File
@@ -1,9 +1,19 @@
import { sessionId, currentPersona, dom } from './state.js';
import { parseImagePromptFromContent, copyToClipboard } from './utils.js';
import { parseImagePromptFromContent, copyToClipboard, splitSdPromptForCopy } from './utils.js';
import {
attachFormatToggle,
initBubbleContent,
applyDiceOverrideToBubble,
isPlayerChoiceContent,
} from './rpFormat.js';
let _pendingUserBubble = null;
let _selectedQuestId = null;
let _questsCache = [];
export async function initChat(options = {}) {
if (!sessionId || !currentPersona) return;
const payload = { message: '', session_id: sessionId, persona_id: currentPersona };
if (!sessionId) return;
const payload = { message: '', session_id: sessionId };
if (options.first_mes_override?.trim()) payload.first_mes_override = options.first_mes_override.trim();
const res = await fetch('/chat/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!res.ok) return;
@@ -16,19 +26,22 @@ export function updateEmptyState() {
dom.emptyState?.classList.toggle('hidden', !!hasMessages);
}
export function createImagePromptBlock(promptText) {
function createImagePromptBlockSingle(label, promptText) {
const block = document.createElement('div');
block.className = 'image-prompt-block';
const header = document.createElement('div');
header.className = 'image-prompt-header';
header.innerHTML = '<span>🎨 SD prompt</span>';
header.innerHTML = `<span>🎨 ${label}</span>`;
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'copy-prompt-btn';
copyBtn.textContent = 'Копировать';
copyBtn.addEventListener('click', async () => {
const ok = await copyToClipboard(promptText);
copyBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const full = textEl.textContent?.trim() || promptText || '';
const ok = await copyToClipboard(splitSdPromptForCopy(full));
copyBtn.textContent = ok ? 'Скопировано' : 'Ошибка';
setTimeout(() => { copyBtn.textContent = 'Копировать'; }, 1500);
});
@@ -39,11 +52,10 @@ export function createImagePromptBlock(promptText) {
regenBtn.className = 'copy-prompt-btn';
regenBtn.textContent = '🖼 Перегенерировать';
regenBtn.addEventListener('click', async () => {
const wrapper = block.parentElement;
const wrapper = block.closest('.message');
regenBtn.disabled = true;
regenBtn.textContent = '⏳…';
wrapper?.querySelector('.chat-image')?.remove();
wrapper?.querySelector('.image-error')?.remove();
wrapper?.querySelectorAll('.chat-image-wrap, .chat-image, .image-error').forEach(el => el.remove());
showImageGenerating(wrapper);
try {
const res = await fetch('/images/generate', {
@@ -76,6 +88,26 @@ export function createImagePromptBlock(promptText) {
return block;
}
export function createImagePromptBlock(promptText, promptAlt = null) {
const wrap = document.createElement('div');
wrap.className = 'image-prompt-blocks';
wrap.appendChild(createImagePromptBlockSingle('SD prompt', promptText));
const alt = (promptAlt || '').trim();
const main = (promptText || '').trim();
if (alt && alt !== main) {
wrap.appendChild(createImagePromptBlockSingle('SD prompt (только теги)', promptAlt));
}
return wrap;
}
/** Replace or create tag + optional hybrid prompt blocks under a message. */
export function ensureImagePromptBlocks(wrapper, tagPrompt, altPrompt = null) {
if (!wrapper || !tagPrompt) return;
wrapper.querySelector('.image-prompt-blocks')?.remove();
wrapper.querySelectorAll('.image-prompt-block').forEach(el => el.remove());
wrapper.appendChild(createImagePromptBlock(tagPrompt, altPrompt || null));
}
const OUTCOME_CLASS = {
'critical failure': 'outcome-crit-fail',
'failure': 'outcome-fail',
@@ -113,26 +145,132 @@ function renderNarratorMessage(narrator) {
return el;
}
function renderChoices(wrapper, choices) {
if (!choices?.length) return;
export function removeChoiceRows(wrapper) {
wrapper?.querySelectorAll('.choice-row').forEach(el => el.remove());
}
export function ensureMessageActionsLast(wrapper) {
const actions = wrapper?.querySelector('.message-actions');
if (actions) wrapper.appendChild(actions);
}
export function renderChoices(wrapper, choices) {
if (!choices?.length || !wrapper) return;
removeChoiceRows(wrapper);
const plotChoices = choices.filter(c => c?.source === 'plot_beat');
const otherChoices = choices.filter(c => c?.source !== 'plot_beat');
const row = document.createElement('div');
row.className = 'choice-row';
for (const c of choices) {
const appendBtn = (container, c) => {
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);
}
btn.className = c.source === 'plot_beat' ? 'choice-btn choice-btn-plot' : 'choice-btn';
const label = c.label || '';
btn.textContent = label;
if (c.beat_title) btn.title = c.beat_title;
btn.addEventListener('click', () => {
removeChoiceRows(wrapper);
sendMessage(label, true);
});
container.appendChild(btn);
};
const appendSection = (items, { plot } = {}) => {
if (!items.length) return;
const section = document.createElement('div');
section.className = plot ? 'choice-section choice-section-plot' : 'choice-section';
if (plot) {
const hdr = document.createElement('div');
hdr.className = 'choice-section-label';
const title = items[0].beat_title || 'Сюжетный beat';
hdr.textContent = `📜 ${title}`;
section.appendChild(hdr);
const inj = (items[0].beat_injection || '').trim();
if (inj) {
const teaser = document.createElement('div');
teaser.className = 'choice-beat-teaser';
teaser.textContent = inj.length > 220 ? `${inj.slice(0, 217)}` : inj;
section.appendChild(teaser);
}
} else if (plotChoices.length) {
const hdr = document.createElement('div');
hdr.className = 'choice-section-label choice-section-label-generic';
hdr.textContent = '🔘 Дальше';
section.appendChild(hdr);
}
const btns = document.createElement('div');
btns.className = 'choice-section-btns';
for (const c of items) appendBtn(btns, c);
section.appendChild(btns);
row.appendChild(section);
};
appendSection(plotChoices, { plot: true });
appendSection(otherChoices);
wrapper.appendChild(row);
ensureMessageActionsLast(wrapper);
}
function restoreChoicesFromHistory(messages) {
const visible = messages.filter(m => m.role !== 'system');
if (!visible.length) return;
const last = visible[visible.length - 1];
if (last.role !== 'assistant' || !last.choices_json) return;
let choices = [];
try {
choices = JSON.parse(last.choices_json);
} catch { return; }
if (!choices?.length) return;
const wrapper = dom.messagesEl.querySelector(
`.message.assistant[data-message-id="${last.id}"]`,
);
if (wrapper) renderChoices(wrapper, choices);
}
function finalizeAssistantMessage(wrapper, messageId, choices) {
if (!wrapper || !messageId) return;
wrapper.dataset.messageId = String(messageId);
attachMessageActions(wrapper, Number(messageId), 'assistant');
if (choices?.length) renderChoices(wrapper, choices);
else ensureMessageActionsLast(wrapper);
}
function showNarratorActivityHint(wrapper, meta) {
if (!wrapper || !meta) return;
wrapper.querySelector('.narrator-activity-hint')?.remove();
const parts = [];
if (meta.post_ok === false && meta.pre_ok === false) {
parts.push('⚠️ Narrator LLM не ответил — проверьте ROUTER_KEY / RPG_NARRATOR_MODEL');
} else {
if (meta.dice) parts.push('🎲 бросок');
if (meta.directives_count > 0) parts.push(`📋 ${meta.directives_count} указаний`);
if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`);
if (meta.status_quo) parts.push('🌍 status_quo');
if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`);
if (meta.beat_mode === 'after_dice') parts.push('📜 beat (d20)');
if (meta.beat_mode === 'llm') parts.push('📜 beat (AI)');
if (meta.beat_mode === 'stuck_recovery') parts.push('📜 beat (recovery)');
if (meta.beat_mode === 'trigger') parts.push('📜 beat (keywords)');
if (meta.arc_pruned) parts.push(`🧹 ${meta.arc_pruned} beat`);
if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`);
}
if (!parts.length) return;
const el = document.createElement('div');
el.className = 'narrator-activity-hint';
el.textContent = `📖 Narrator: ${parts.join(' · ')}`;
wrapper.appendChild(el);
ensureMessageActionsLast(wrapper);
}
function renderDebugBlocks(wrapper, blocks) {
if (!blocks?.length) return;
for (const b of blocks) {
if (!b?.text) continue;
if (b.type === 'narrator_injection') {
if (b.type === 'narrator_injection' || b.type === 'status_quo') {
const w = document.createElement('div');
w.className = 'message narrator';
const lbl = document.createElement('div');
@@ -148,16 +286,106 @@ function renderDebugBlocks(wrapper, blocks) {
}
}
function questIdEq(a, b) {
return Number(a) === Number(b);
}
function syncQuestActionButtons() {
const doneBtn = document.getElementById('questBtnDone');
const failBtn = document.getElementById('questBtnFail');
const active = _selectedQuestId != null
&& _questsCache.some(q => questIdEq(q.id, _selectedQuestId) && q.status === 'active');
if (doneBtn) doneBtn.disabled = !active;
if (failBtn) failBtn.disabled = !active;
}
export function updateQuestPanel(quests) {
const list = document.getElementById('questList');
const actions = document.getElementById('questPanelActions');
if (!list) return;
_questsCache = quests || [];
list.innerHTML = '';
for (const q of quests) {
if (!_questsCache.length) {
_selectedQuestId = null;
syncQuestActionButtons();
return;
}
if (_selectedQuestId != null && !_questsCache.some(
q => questIdEq(q.id, _selectedQuestId) && q.status === 'active',
)) {
_selectedQuestId = null;
}
for (const q of _questsCache) {
const el = document.createElement('div');
el.className = `quest-item quest-${q.status}`;
if (questIdEq(q.id, _selectedQuestId)) el.classList.add('quest-selected');
if (q.status === 'done') el.classList.add('quest-done');
if (q.status === 'failed') el.classList.add('quest-failed');
el.dataset.questId = String(q.id);
el.textContent = (q.status === 'done' ? '✅ ' : q.status === 'failed' ? '❌ ' : '🔸 ') + q.title;
if (q.status === 'active') {
el.addEventListener('click', () => {
const qid = Number(q.id);
_selectedQuestId = questIdEq(_selectedQuestId, qid) ? null : qid;
updateQuestPanel(_questsCache);
});
}
list.appendChild(el);
}
syncQuestActionButtons();
}
async function patchSelectedQuest(status) {
if (!sessionId || _selectedQuestId == null) {
alert('Сначала выберите активный квест (🔸) в списке.');
return;
}
const q = _questsCache.find(x => questIdEq(x.id, _selectedQuestId));
if (!q || q.status !== 'active') return;
const verb = status === 'done' ? 'завершить' : 'отметить провалом';
if (!confirm(`${verb.charAt(0).toUpperCase() + verb.slice(1)} квест?\n\n«${q.title}»`)) return;
const doneBtn = document.getElementById('questBtnDone');
const failBtn = document.getElementById('questBtnFail');
doneBtn && (doneBtn.disabled = true);
failBtn && (failBtn.disabled = true);
try {
const res = await fetch(`/sessions/${sessionId}/quests/${Number(_selectedQuestId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.detail || res.statusText);
return;
}
_selectedQuestId = null;
const listRes = await fetch(`/sessions/${sessionId}/quests`);
if (listRes.ok) updateQuestPanel(await listRes.json());
} finally {
syncQuestActionButtons();
}
}
export function initQuestPanel() {
const panel = document.getElementById('questPanel');
if (!panel || panel.dataset.questBound === '1') return;
panel.dataset.questBound = '1';
panel.addEventListener('click', (e) => {
if (e.target.id === 'questBtnDone') {
e.preventDefault();
patchSelectedQuest('done');
} else if (e.target.id === 'questBtnFail') {
e.preventDefault();
patchSelectedQuest('failed');
}
});
}
export function updateAffinityDisplay(affinity) {
@@ -169,12 +397,38 @@ export function updateAffinityDisplay(affinity) {
el.className = `affinity-display ${affinity > 5 ? 'affinity-high' : affinity < -3 ? 'affinity-low' : ''}`;
}
export function appendChatImage(wrapper, imagePath) {
export function updateStatsDisplay(stats) {
const el = dom.statsDisplay;
if (!el || !stats) return;
const lust = Number(stats.lust ?? 0);
const stamina = Number(stats.stamina ?? 10);
const tension = Number(stats.tension ?? 0);
el.textContent = `🔥${lust}${stamina} 😰${tension}`;
el.className = 'stats-display';
if (stamina <= 2 || tension >= 8) el.classList.add('stats-critical');
else if (stamina <= 4 || tension >= 6) el.classList.add('stats-warn');
el.classList.remove('hidden');
}
export function hideStatsDisplay() {
dom.statsDisplay?.classList.add('hidden');
}
export function appendChatImage(wrapper, imagePath, label = '') {
if (!imagePath) return;
const figure = document.createElement('figure');
figure.className = 'chat-image-wrap';
if (label) {
const cap = document.createElement('figcaption');
cap.className = 'chat-image-label';
cap.textContent = label;
figure.appendChild(cap);
}
const img = document.createElement('img');
img.className = 'chat-image';
img.src = imagePath;
wrapper.appendChild(img);
figure.appendChild(img);
wrapper.appendChild(figure);
}
export function showImageGenerating(wrapper) {
@@ -194,6 +448,7 @@ export function removeImageGenerating(wrapper) {
function attachMessageActions(wrapper, messageId, role) {
if (!messageId) return;
wrapper.dataset.messageId = String(messageId);
wrapper.querySelector('.message-actions')?.remove();
const actions = document.createElement('div');
actions.className = 'message-actions';
const editBtn = document.createElement('button');
@@ -216,7 +471,28 @@ function attachMessageActions(wrapper, messageId, role) {
branchBtn.title = 'Ветка отсюда';
branchBtn.addEventListener('click', () => forkFromMessage(messageId));
actions.appendChild(branchBtn);
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.textContent = '🗑';
delBtn.title = 'Удалить сообщение и всё после него';
delBtn.addEventListener('click', () => deleteMessageAndAfter(messageId, wrapper));
actions.appendChild(delBtn);
wrapper.appendChild(actions);
ensureMessageActionsLast(wrapper);
}
async function deleteMessageAndAfter(messageId, wrapper) {
if (!sessionId || !messageId) return;
if (!confirm('Удалить это сообщение и всю переписку после него?')) return;
const res = await fetch(`/chat/messages/${messageId}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.detail || res.statusText);
return;
}
await reloadChatFromServer(sessionId);
const listRes = await fetch(`/sessions/${sessionId}/quests`);
if (listRes.ok) updateQuestPanel(await listRes.json());
}
async function startEditMessage(wrapper, messageId) {
@@ -262,7 +538,7 @@ async function regenerateMessage(messageId, wrapper) {
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 }),
body: JSON.stringify({ session_id: sessionId, message_id: messageId }),
});
if (!res.ok) throw new Error('Ошибка: ' + res.status);
removeTyping();
@@ -297,14 +573,25 @@ export async function reloadChatFromServer(id) {
const messages = await histRes.json();
clearMessages();
messages.filter(m => m.role !== 'system').forEach(m => {
if (m.role === 'narrator') {
try {
const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
if (data?.text) renderNarratorMessage(data);
} catch { /* ignore bad narrator payload */ }
return;
}
addMessage(
m.role === 'user' ? 'user' : 'assistant',
m.content,
m.image_prompt,
m.image_path ? `/static/${m.image_path}` : null,
m.id,
m.image_prompt_alt,
m.image_path_alt ? `/static/${m.image_path_alt}` : null,
m.role === 'user' ? m.action_resolution : null,
);
});
restoreChoicesFromHistory(messages);
}
const IMAGE_PROMPT_RE = /\[IMAGE_PROMPT:.*?\]/gs;
@@ -330,6 +617,22 @@ async function consumeStream(res) {
// Narrator arrives BEFORE chunks — render immediately
if (data.narrator) {
renderNarratorMessage(data.narrator);
if (
_pendingUserBubble
&& data.narrator.roll != null
&& data.narrator.original_intent
&& !isPlayerChoiceContent(_pendingUserBubble.dataset.raw || '')
) {
const resolution = {
intent_text: data.narrator.original_intent,
resolution_text: data.narrator.text,
roll: data.narrator.roll,
outcome: data.narrator.outcome,
};
_pendingUserBubble._diceResolution = resolution;
applyDiceOverrideToBubble(_pendingUserBubble, resolution);
_pendingUserBubble.closest('.message')?.classList.add('has-dice-override');
}
}
if (data.chunk !== undefined) {
@@ -344,8 +647,12 @@ async function consumeStream(res) {
if (data.image_generating && bubble) {
bubble.classList.remove('typing-active');
const wrapper = bubble.parentElement;
if (data.image_prompt && !wrapper.querySelector('.image-prompt-block')) {
wrapper.appendChild(createImagePromptBlock(data.image_prompt));
if (data.image_prompt) {
ensureImagePromptBlocks(
wrapper,
data.image_prompt,
data.image_prompt_alt || null,
);
}
showImageGenerating(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
@@ -356,19 +663,24 @@ async function consumeStream(res) {
removeImageGenerating(wrapper);
bubble?.classList.remove('typing-active');
// Strip IMAGE_PROMPT tag from final text
// Strip IMAGE_PROMPT tag; apply server-side OOC strip if provided
if (bubble) {
bubble.textContent = bubble.textContent.replace(IMAGE_PROMPT_RE, '').trim();
const fromServer = data.assistant_content;
const cleaned = (fromServer ?? bubble.textContent)
.replace(IMAGE_PROMPT_RE, '')
.trim();
initBubbleContent(bubble, cleaned, { formatted: true });
}
if (data.image_prompt && wrapper && !wrapper.querySelector('.image-prompt-block')) {
wrapper.appendChild(createImagePromptBlock(data.image_prompt));
if (data.image_prompt && wrapper) {
ensureImagePromptBlocks(
wrapper,
data.image_prompt,
data.image_prompt_alt || null,
);
}
if (data.image_path && wrapper) {
console.log('[image] appending', data.image_path, 'to', wrapper);
appendChatImage(wrapper, data.image_path);
} else {
console.log('[image] skip: image_path=', data.image_path, 'wrapper=', wrapper);
appendChatImage(wrapper, data.image_path, '');
}
if (data.image_error && wrapper) {
const err = document.createElement('div');
@@ -376,11 +688,25 @@ async function consumeStream(res) {
err.textContent = '🖼 ' + data.image_error;
wrapper.appendChild(err);
}
if (data.choices?.length && bubble) renderChoices(bubble.parentElement, data.choices);
if (bubble?.parentElement && data.assistant_message_id) {
finalizeAssistantMessage(
bubble.parentElement,
data.assistant_message_id,
data.choices,
);
} else if (data.choices?.length && bubble) {
renderChoices(bubble.parentElement, data.choices);
}
if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
if (data.narrator_meta && bubble?.parentElement) {
showNarratorActivityHint(bubble.parentElement, data.narrator_meta);
}
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (data.quests?.length) updateQuestPanel(data.quests);
_pendingUserBubble = null;
const { loadSessions } = await import('./sessions.js');
loadSessions();
}
@@ -388,7 +714,16 @@ async function consumeStream(res) {
}
}
export function addMessage(role, content = '', imagePrompt = null, imagePath = null, messageId = null) {
export function addMessage(
role,
content = '',
imagePrompt = null,
imagePath = null,
messageId = null,
imagePromptAlt = null,
imagePathAlt = null,
actionResolution = null,
) {
updateEmptyState();
const wrapper = document.createElement('div');
wrapper.className = `message ${role}`;
@@ -407,8 +742,18 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = displayContent;
initBubbleContent(bubble, displayContent, {
formatted: !!displayContent,
actionResolution: role === 'user' ? actionResolution : null,
});
wrapper.appendChild(bubble);
if (role === 'user' && actionResolution?.resolution_text) {
wrapper.classList.add('has-dice-override');
}
if (displayContent) {
attachFormatToggle(wrapper, bubble);
}
if (role === 'assistant') {
const translateBtn = document.createElement('button');
@@ -418,12 +763,12 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
let originalText = null;
translateBtn.addEventListener('click', async () => {
if (originalText !== null) {
bubble.textContent = originalText;
initBubbleContent(bubble, originalText, { formatted: true });
originalText = null;
translateBtn.textContent = '🌐 RU';
return;
}
originalText = bubble.textContent;
originalText = bubble.dataset.raw ?? bubble.textContent;
translateBtn.disabled = true;
translateBtn.textContent = '…';
try {
@@ -434,7 +779,8 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
bubble.textContent = data.translated;
initBubbleContent(bubble, data.translated, { formatted: true });
bubble.dataset.raw = data.translated;
translateBtn.textContent = '↩ Оригинал';
} catch {
originalText = null;
@@ -446,8 +792,9 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
wrapper.appendChild(translateBtn);
}
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt));
if (imagePath) appendChatImage(wrapper, imagePath);
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt, imagePromptAlt));
if (imagePath) appendChatImage(wrapper, imagePath, imagePathAlt ? 'Теги' : '');
if (imagePathAlt) appendChatImage(wrapper, imagePathAlt, 'Гибрид');
attachMessageActions(wrapper, messageId, role);
dom.messagesEl.appendChild(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
@@ -481,13 +828,15 @@ export async function sendMessage(text, isNarratorChoice = false) {
dom.inputEl.value = '';
dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true;
addMessage('user', isNarratorChoice ? `[${text}]` : text);
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text;
dom.messagesEl.querySelectorAll('.message.assistant').forEach(w => removeChoiceRows(w));
_pendingUserBubble = addMessage('user', userContent);
showTyping();
try {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId, persona_id: currentPersona, is_narrator_choice: isNarratorChoice }),
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }),
});
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
removeTyping();
+136 -3
View File
@@ -1,7 +1,10 @@
import { sessionId, currentPersona, dom } from './state.js';
import { sessionId, currentPersona, setCurrentPersona, dom } from './state.js';
import { GENRE_LABELS, bindGenreGrid, resetGenreGrid } from './utils.js';
import { personaIndex } from './personas.js';
const chatSettingsGenres = new Set();
let chatSettingsPersonaId = 'default';
let chatSettingsInitialPersonaId = 'default';
function updateChatSettingsGenresLabel() {
const el = document.getElementById('chatSettingsGenresLabel');
@@ -15,11 +18,32 @@ function updateChatSettingsGenresLabel() {
}
}
function fillChatSettingsPersonaGrid() {
const grid = document.getElementById('chatSettingsPersonaGrid');
if (!grid) return;
grid.innerHTML = '';
for (const p of personaIndex.values()) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'persona-pick-card' + (p.persona_id === chatSettingsPersonaId ? ' selected' : '');
card.dataset.id = p.persona_id;
card.innerHTML = `<span class="emoji">${p.emoji || '🤖'}</span>${p.name}`;
card.addEventListener('click', () => {
chatSettingsPersonaId = p.persona_id;
grid.querySelectorAll('.persona-pick-card').forEach(c => {
c.classList.toggle('selected', c.dataset.id === chatSettingsPersonaId);
});
});
grid.appendChild(card);
}
}
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}SettingStats`).checked = settings.stats === true;
document.getElementById(`${prefix}SettingChoices`).checked = settings.choices !== false;
}
@@ -29,6 +53,7 @@ function readRpgSettingsFromDom(prefix) {
narrator: document.getElementById(`${prefix}SettingNarrator`)?.checked ?? true,
quests: document.getElementById(`${prefix}SettingQuests`)?.checked ?? true,
affinity: document.getElementById(`${prefix}SettingAffinity`)?.checked ?? true,
stats: document.getElementById(`${prefix}SettingStats`)?.checked ?? false,
choices: document.getElementById(`${prefix}SettingChoices`)?.checked ?? true,
};
}
@@ -51,6 +76,10 @@ async function bootstrapRpg(sid, personaId, genreValue, settings) {
});
if (res.ok) {
const data = await res.json();
if (data.affinity !== undefined) {
const { updateAffinityDisplay } = await import('./chat.js');
updateAffinityDisplay(data.affinity);
}
if (data.quests) updateQuestPanel(data.quests);
if (data.plot_arc) {
const title = data.plot_arc.title || '';
@@ -67,6 +96,10 @@ export async function openChatSettings() {
const s = await res.json();
document.getElementById('chatSettingsTitle').value = s.title || '';
chatSettingsPersonaId = s.persona_id || 'default';
chatSettingsInitialPersonaId = chatSettingsPersonaId;
fillChatSettingsPersonaGrid();
const rpgOn = !!s.rpg_enabled;
document.getElementById('chatSettingsRpg').checked = rpgOn;
document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !rpgOn);
@@ -91,12 +124,33 @@ export async function openChatSettings() {
const arc = JSON.parse(s.plot_arc_json || '{}');
phase = arc.phase || '';
} catch { /* ignore */ }
let stats = { lust: 0, stamina: 10, tension: 0 };
try {
stats = { ...stats, ...JSON.parse(s.narrative_stats_json || '{}') };
} catch { /* ignore */ }
document.getElementById('chatSettingsMeta').innerHTML = [
`Симпатия: ${s.affinity ?? 0}`,
settings.stats
? `Шкалы: lust ${stats.lust ?? 0}, stamina ${stats.stamina ?? 10}, tension ${stats.tension ?? 0}`
: '',
s.genre ? `Жанр: ${(s.genre || '').split(',').map(g => GENRE_LABELS[g.trim()] || g).join(' + ')}` : '',
phase ? `Фаза арки: ${phase}` : '',
].filter(Boolean).join('<br>');
const dbg = document.getElementById('chatSettingsRpgDebug');
if (dbg) dbg.open = rpgOn;
const affIn = document.getElementById('debugAffinity');
const lustIn = document.getElementById('debugLust');
const stamIn = document.getElementById('debugStamina');
const tensIn = document.getElementById('debugTension');
if (affIn) affIn.value = String(s.affinity ?? 0);
if (lustIn) lustIn.value = String(stats.lust ?? 0);
if (stamIn) stamIn.value = String(stats.stamina ?? 10);
if (tensIn) tensIn.value = String(stats.tension ?? 0);
const st = document.getElementById('debugRpgStateStatus');
if (st) st.textContent = '';
document.getElementById('chatSettingsModal').classList.add('open');
}
@@ -115,15 +169,94 @@ export function initChatSettings() {
document.getElementById('chatSettingsModal').classList.remove('open');
});
document.getElementById('debugRpgStateApply')?.addEventListener('click', async () => {
if (!sessionId) return;
const statusEl = document.getElementById('debugRpgStateStatus');
const body = {};
const aff = document.getElementById('debugAffinity')?.value;
const lust = document.getElementById('debugLust')?.value;
const stam = document.getElementById('debugStamina')?.value;
const tens = document.getElementById('debugTension')?.value;
if (aff !== '' && aff != null) body.affinity = Number(aff);
if (lust !== '' && lust != null) body.lust = Number(lust);
if (stam !== '' && stam != null) body.stamina = Number(stam);
if (tens !== '' && tens != null) body.tension = Number(tens);
try {
const res = await fetch(`/sessions/${sessionId}/rpg-state`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (statusEl) {
statusEl.textContent = err.detail || 'Ошибка';
statusEl.style.color = '#e74c3c';
}
return;
}
const data = await res.json();
const { updateAffinityDisplay, updateStatsDisplay } = await import('./chat.js');
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (statusEl) {
statusEl.textContent = 'Сохранено';
statusEl.style.color = '#2ecc71';
}
const blobRes = await fetch(`/chat/system/${sessionId}`);
if (blobRes.ok) {
const { renderSystemBlob } = await import('./sessions.js');
renderSystemBlob(await blobRes.json());
}
} catch {
if (statusEl) {
statusEl.textContent = 'Сеть';
statusEl.style.color = '#e74c3c';
}
}
});
document.getElementById('chatSettingsSave')?.addEventListener('click', async () => {
if (!sessionId) return;
const { loadSessions, applySessionUi } = await import('./sessions.js');
const { loadSessions, applySessionUi, renderSystemBlob } = await import('./sessions.js');
const { reloadChatFromServer } = await import('./chat.js');
const { highlightPersonaBar } = await import('./personas.js');
const title = document.getElementById('chatSettingsTitle').value.trim();
const rpgOn = document.getElementById('chatSettingsRpg').checked;
const genreValue = [...chatSettingsGenres].join(',') || 'adventure';
const settings = readRpgSettingsFromDom('cs');
if (chatSettingsPersonaId !== chatSettingsInitialPersonaId) {
const pName = personaIndex.get(chatSettingsPersonaId)?.name || chatSettingsPersonaId;
const keepHistory = confirm(
`Перепривязать чат к «${pName}»?\n\n`
+ 'OK — сохранить историю сообщений (персонаж в старых репликах может не совпадать).\n'
+ 'Отмена — очистить историю и начать с приветствия нового персонажа.',
);
const clearHistory = !keepHistory;
const rebindRes = await fetch(`/sessions/${sessionId}/rebind-persona`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
persona_id: chatSettingsPersonaId,
clear_history: clearHistory,
}),
});
if (!rebindRes.ok) {
const err = await rebindRes.json().catch(() => ({}));
alert(err.detail || 'Не удалось сменить персонажа');
return;
}
setCurrentPersona(chatSettingsPersonaId);
chatSettingsInitialPersonaId = chatSettingsPersonaId;
highlightPersonaBar(chatSettingsPersonaId);
await reloadChatFromServer(sessionId);
const blobRes = await fetch(`/chat/system/${sessionId}`);
if (blobRes.ok) renderSystemBlob(await blobRes.json());
}
await fetch(`/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -141,7 +274,7 @@ export function initChatSettings() {
let arc = {};
try { arc = JSON.parse(s.plot_arc_json || '{}'); } catch { /* ignore */ }
if (!arc || !Object.keys(arc).length) {
await bootstrapRpg(sessionId, currentPersona, genreValue, settings);
await bootstrapRpg(sessionId, chatSettingsPersonaId, genreValue, settings);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { sessionId } from './state.js';
function fmtJson(raw, fallback) {
try {
return JSON.stringify(JSON.parse(raw || fallback), null, 2);
} catch {
return raw || fallback;
}
}
function parseStats(raw) {
try {
return { lust: 0, stamina: 10, tension: 0, ...JSON.parse(raw || '{}') };
} catch {
return { lust: 0, stamina: 10, tension: 0 };
}
}
export async function openContextEditor() {
if (!sessionId) return;
const res = await fetch(`/chat/system/${sessionId}`);
if (!res.ok) return;
const blob = await res.json();
document.getElementById('ctxStatusQuo').value = blob.status_quo || '';
document.getElementById('ctxGlobalPlot').value = blob.global_plot || '';
document.getElementById('ctxOutfit').value = fmtJson(blob.outfit_json, '[]');
document.getElementById('ctxScene').value = fmtJson(blob.scene_json, '{}');
document.getElementById('ctxFacts').value = fmtJson(blob.facts_json, '[]');
document.getElementById('ctxPlotArc').value = fmtJson(blob.plot_arc_json, '{}');
document.getElementById('ctxAffinity').value = String(blob.affinity ?? 0);
const stats = parseStats(blob.narrative_stats_json);
document.getElementById('ctxLust').value = String(stats.lust ?? 0);
document.getElementById('ctxStamina').value = String(stats.stamina ?? 10);
document.getElementById('ctxTension').value = String(stats.tension ?? 0);
const st = document.getElementById('contextEditorStatus');
if (st) {
st.textContent = '';
st.style.color = '';
}
document.getElementById('contextEditorModal')?.classList.add('open');
}
export function initContextEditor() {
document.getElementById('contextEditorOpen')?.addEventListener('click', () => {
openContextEditor();
});
document.getElementById('contextEditorCancel')?.addEventListener('click', () => {
document.getElementById('contextEditorModal')?.classList.remove('open');
});
document.getElementById('contextEditorSave')?.addEventListener('click', async () => {
if (!sessionId) return;
const statusEl = document.getElementById('contextEditorStatus');
const body = {
status_quo: document.getElementById('ctxStatusQuo')?.value ?? '',
global_plot: document.getElementById('ctxGlobalPlot')?.value ?? '',
outfit_json: document.getElementById('ctxOutfit')?.value ?? '[]',
scene_json: document.getElementById('ctxScene')?.value ?? '{}',
facts_json: document.getElementById('ctxFacts')?.value ?? '[]',
plot_arc_json: document.getElementById('ctxPlotArc')?.value ?? '{}',
affinity: Number(document.getElementById('ctxAffinity')?.value ?? 0),
lust: Number(document.getElementById('ctxLust')?.value ?? 0),
stamina: Number(document.getElementById('ctxStamina')?.value ?? 10),
tension: Number(document.getElementById('ctxTension')?.value ?? 0),
};
try {
const res = await fetch(`/sessions/${sessionId}/context`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (statusEl) {
statusEl.textContent = err.detail || res.statusText;
statusEl.style.color = '#e74c3c';
}
return;
}
const data = await res.json();
if (data.outfit_json) {
document.getElementById('ctxOutfit').value = fmtJson(data.outfit_json, '[]');
}
const { renderSystemBlob, applySessionUi } = await import('./sessions.js');
const { updateAffinityDisplay, updateStatsDisplay } = await import('./chat.js');
const blobRes = await fetch(`/chat/system/${sessionId}`);
if (blobRes.ok) renderSystemBlob(await blobRes.json());
const sessRes = await fetch(`/sessions/${sessionId}`);
if (sessRes.ok) applySessionUi(await sessRes.json());
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (statusEl) {
statusEl.textContent = 'Сохранено (outfit: цвета добавлены автоматически, если не указаны)';
statusEl.style.color = '#2ecc71';
}
} catch {
if (statusEl) {
statusEl.textContent = 'Ошибка сети';
statusEl.style.color = '#e74c3c';
}
}
});
}
+217
View File
@@ -0,0 +1,217 @@
const $ = (id) => document.getElementById(id);
function fmt(obj) {
return typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
}
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = text;
}
if (!res.ok) {
const detail = data?.detail || text || res.statusText;
throw new Error(`${res.status}: ${detail}`);
}
return data;
}
function initTabs() {
const tabs = document.querySelectorAll('#debugTabs button');
tabs.forEach((btn) => {
btn.addEventListener('click', () => {
tabs.forEach((t) => t.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.debug-panel').forEach((p) => p.classList.remove('active'));
$(`panel-${btn.dataset.tab}`).classList.add('active');
});
});
}
async function loadConfig() {
const c = await api('/debug/config');
$('configOut').textContent = fmt(c);
$('llmModel').placeholder = c.sd_prompt_model || c.system_model;
return c;
}
async function loadPersonas() {
const list = await api('/debug/personas');
const sel = $('sdPersona');
sel.innerHTML = '';
for (const p of list) {
const opt = document.createElement('option');
opt.value = p.persona_id;
opt.textContent = `${p.name} (${p.persona_id})`;
sel.appendChild(opt);
}
}
async function runSdPrompt() {
$('sdScene').textContent = '…';
$('sdPrompts').textContent = '…';
const body = {
persona_id: $('sdPersona').value,
chat_excerpt: $('sdChat').value,
outfit_json: $('sdOutfit').value || '[]',
use_prose: $('sdUseProse') ? $('sdUseProse').checked : false,
};
const app = $('sdAppearance').value.trim();
if (app) body.appearance_override = app;
const data = await api('/debug/sd-prompt', { method: 'POST', body: JSON.stringify(body) });
$('sdScene').textContent = data.scene ? fmt(data.scene) : (data.error || '—');
const prompts = [];
if (data.tags_only_full) prompts.push('=== TAGS + POV (no prose) ===\n' + data.tags_only_full);
if (data.hybrid_full) prompts.push('\n=== HYBRID (Comfy) ===\n' + data.hybrid_full);
if (!data.tags_only_full && data.tag_full) prompts.push('=== PROMPT ===\n' + data.tag_full);
$('sdPrompts').textContent = prompts.join('\n') || data.error || '—';
$('sdLlmRaw').textContent = [
`model: ${data.sd_prompt_model}`,
`dual: ${data.anima_dual}`,
'',
'--- system ---',
data.builder_system || '',
'',
'--- user ---',
data.builder_user || '',
'',
'--- raw ---',
data.llm_raw || data.error || '',
].join('\n');
if (data.tag_full || data.hybrid_full) {
const src = data.hybrid_full || data.tag_full;
const parts = src.includes('__NEGATIVE_PROMPT__')
? src.split('\n\n__NEGATIVE_PROMPT__\n\n')
: src.includes('\n\nNegative prompt:')
? src.split('\n\nNegative prompt:')
: [src, ''];
$('genPositive').value = parts[0] || '';
$('genNegative').value = parts[1] || '';
}
}
async function runLlm() {
$('llmOut').textContent = '…';
const data = await api('/debug/llm', {
method: 'POST',
body: JSON.stringify({
model: $('llmModel').value.trim(),
system: $('llmSystem').value,
user: $('llmUser').value,
}),
});
$('llmOut').textContent = `model: ${data.model}\n\n${data.response}`;
}
function fillModelSelect(sel, options, configured) {
const current = sel.querySelector('option')?.value ?? '';
sel.innerHTML = `<option value="">— env: ${configured || '—'} —</option>`;
for (const name of options || []) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (name === configured) opt.selected = true;
sel.appendChild(opt);
}
}
async function loadComfyModels() {
$('comfyModelLists').textContent = 'Загрузка object_info…';
const data = await api('/debug/comfy/models');
const { models, configured } = data;
fillModelSelect($('genUnet'), models.unets, configured.unet);
fillModelSelect($('genClip'), models.clips, configured.clip);
fillModelSelect($('genVae'), models.vaes, configured.vae);
fillModelSelect($('genCkpt'), models.checkpoints, configured.checkpoint);
const wrap = $('comfyModelLists');
wrap.innerHTML = '';
for (const [key, list] of Object.entries(models)) {
const block = document.createElement('details');
block.className = 'model-list-block';
block.open = key === 'unets' || key === 'checkpoints';
block.innerHTML = `<summary>${key} (${list.length})</summary>`;
const ul = document.createElement('ul');
for (const item of list) {
const li = document.createElement('li');
li.textContent = item;
ul.appendChild(li);
}
block.appendChild(ul);
wrap.appendChild(block);
}
}
async function comfyPing() {
$('comfyPingOut').textContent = '…';
const data = await api('/debug/comfy/ping');
$('comfyPingOut').textContent = fmt(data);
}
async function comfyGenerate() {
$('comfyGenOut').textContent = 'Генерация…';
$('comfyImgWrap').classList.add('hidden');
const body = {
positive: $('genPositive').value,
negative: $('genNegative').value,
};
const u = $('genUnet').value;
const c = $('genClip').value;
const v = $('genVae').value;
const ck = $('genCkpt').value;
if (u) body.unet = u;
if (c) body.clip = c;
if (v) body.vae = v;
if (ck) body.checkpoint = ck;
const data = await api('/debug/comfy/generate', {
method: 'POST',
body: JSON.stringify(body),
});
$('comfyGenOut').textContent = fmt(data);
if (data.image_path) {
$('comfyImg').src = data.image_path + '?t=' + Date.now();
$('comfyImgWrap').classList.remove('hidden');
}
}
async function comfyRaw() {
$('comfyRawOut').textContent = '…';
const data = await api('/debug/comfy/raw', {
method: 'POST',
body: JSON.stringify({
method: $('rawMethod').value,
path: $('rawPath').value,
params_json: $('rawParams').value || '{}',
body_json: $('rawBody').value || '',
}),
});
$('comfyRawOut').textContent = fmt(data);
}
function bind() {
initTabs();
$('btnReloadConfig').addEventListener('click', loadConfig);
$('btnSdPrompt').addEventListener('click', () => runSdPrompt().catch(showErr));
$('btnLlm').addEventListener('click', () => runLlm().catch(showErr));
$('btnComfyPing').addEventListener('click', () => comfyPing().catch(showErr));
$('btnComfyModels').addEventListener('click', () => loadComfyModels().catch(showErr));
$('btnComfyGen').addEventListener('click', () => comfyGenerate().catch(showErr));
$('btnComfyRaw').addEventListener('click', () => comfyRaw().catch(showErr));
}
function showErr(e) {
alert(e.message || String(e));
}
bind();
loadConfig().catch(showErr);
loadPersonas().catch(showErr);
+78 -45
View File
@@ -1,4 +1,9 @@
import { setSessionId, setCurrentPersona, currentPersona, dom } from './state.js';
import {
setSessionId,
setCurrentPersona,
getNewChatDefaultPersona,
dom,
} from './state.js';
import {
initWizard,
GENRE_LABELS,
@@ -7,9 +12,9 @@ import {
fillGreetingSelect,
getSelectedGreeting,
} from './utils.js';
import { personaIndex, highlightPersona } from './personas.js';
import { personaIndex } from './personas.js';
let newChatPersonaId = currentPersona;
let newChatPersonaId = getNewChatDefaultPersona();
let newChatGreetingCtx = null;
const newChatGenres = new Set();
const newChatModalEl = document.getElementById('newChatModal');
@@ -84,7 +89,7 @@ function fillNewChatPersonaGrid() {
const grid = document.getElementById('newChatPersonaGrid');
if (!grid) return;
grid.innerHTML = '';
newChatPersonaId = currentPersona;
newChatPersonaId = getNewChatDefaultPersona();
for (const p of personaIndex.values()) {
const card = document.createElement('button');
card.type = 'button';
@@ -121,34 +126,8 @@ function updateNewChatGenresLabel() {
}
}
async function bootstrapRpg(sid, personaId, genreValue, settings) {
const { updateQuestPanel, addMessage } = await import('./chat.js');
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 : ''}`);
}
}
}
export function openNewChatWizard() {
import('./personas.js').then(({ refreshPersonaBarHighlight }) => refreshPersonaBarHighlight());
fillNewChatPersonaGrid();
resetGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres);
updateNewChatGenresLabel();
@@ -161,8 +140,17 @@ export function openNewChatWizard() {
}
export async function createNewChatFromWizard() {
const { clearMessages, initChat, reloadChatFromServer } = await import('./chat.js');
const { loadSessions, applySessionUi } = await import('./sessions.js');
const {
clearMessages,
initChat,
reloadChatFromServer,
showImageGenerating,
removeImageGenerating,
updateQuestPanel,
updateAffinityDisplay,
renderChoices,
} = await import('./chat.js');
const { loadSessions, applySessionUi, renderSystemBlob } = await import('./sessions.js');
const sid = 'sess_' + Math.random().toString(36).slice(2, 10);
setSessionId(sid);
@@ -176,10 +164,23 @@ export async function createNewChatFromWizard() {
newChatWizard?.reset();
try {
const sessionPatch = { persona_id: newChatPersonaId, rpg_enabled: rpg };
if (rpg) {
sessionPatch.genre = [...newChatGenres].join(',') || 'adventure';
sessionPatch.rpg_settings_json = JSON.stringify({
dice: document.getElementById('ncSettingDice')?.checked ?? true,
narrator: document.getElementById('ncSettingNarrator')?.checked ?? true,
quests: document.getElementById('ncSettingQuests')?.checked ?? true,
affinity: document.getElementById('ncSettingAffinity')?.checked ?? true,
stats: document.getElementById('ncSettingStats')?.checked ?? false,
choices: document.getElementById('ncSettingChoices')?.checked ?? true,
});
}
await fetch(`/sessions/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persona_id: newChatPersonaId, rpg_enabled: rpg }),
body: JSON.stringify(sessionPatch),
});
if (customTitle) {
@@ -194,25 +195,57 @@ export async function createNewChatFromWizard() {
dom.headerTitle.textContent = rpg ? `${pName} — RPG` : `${pName} — новый чат`;
}
highlightPersona(newChatPersonaId);
const { highlightPersonaBar } = await import('./personas.js');
highlightPersonaBar(newChatPersonaId);
const greetingOverride = getNewChatFirstMesOverride();
await initChat(greetingOverride ? { first_mes_override: greetingOverride } : {});
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);
const assistantWrapper = dom.messagesEl.querySelector('.message.assistant');
showImageGenerating(assistantWrapper);
let openingData = null;
try {
const openingRes = await fetch('/chat/opening/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sid,
persona_id: newChatPersonaId,
rpg,
}),
});
openingData = await openingRes.json();
if (!openingRes.ok) {
console.error('opening/process failed:', openingData.detail || openingRes.statusText);
}
} finally {
removeImageGenerating(assistantWrapper);
}
await reloadChatFromServer(sid);
if (openingData?.quests?.length) {
updateQuestPanel(openingData.quests);
}
if (openingData?.affinity !== undefined) {
updateAffinityDisplay(openingData.affinity);
}
if (openingData?.image_error) {
const wrapper = dom.messagesEl.querySelector('.message.assistant');
if (wrapper) {
const err = document.createElement('div');
err.className = 'image-error';
err.textContent = '🖼 ' + openingData.image_error;
wrapper.appendChild(err);
}
}
const sessionRes = await fetch(`/sessions/${sid}`);
if (sessionRes.ok) applySessionUi(await sessionRes.json());
const blobRes = await fetch(`/chat/system/${sid}`);
if (blobRes.ok) renderSystemBlob(await blobRes.json());
await loadSessions();
} catch (e) {
console.error('createNewChat error:', e);
+18 -14
View File
@@ -1,5 +1,9 @@
import { currentPersona, setCurrentPersona, sessionId } from './state.js';
import { initChat } from './chat.js';
import {
currentPersona,
sessionId,
getNewChatDefaultPersona,
setNewChatDefaultPersona,
} from './state.js';
import { initWizard, fillGreetingSelect, getSelectedGreeting } from './utils.js';
export let personaIndex = new Map();
@@ -21,12 +25,18 @@ let cardImportWizard;
let cardPreview = null;
let cardImportFile = null;
export function highlightPersona(personaId) {
export function highlightPersonaBar(personaId) {
document.querySelectorAll('.persona-card').forEach(c => {
c.classList.toggle('active', c.dataset.id === personaId);
});
}
/** Active session → session persona; otherwise new-chat preset. */
export function refreshPersonaBarHighlight() {
const id = sessionId ? currentPersona : getNewChatDefaultPersona();
highlightPersonaBar(id);
}
export async function loadPersonas() {
const res = await fetch('/personas/');
const personas = await res.json();
@@ -37,9 +47,11 @@ export async function loadPersonas() {
const bar = document.getElementById('personaBar');
bar.innerHTML = '';
const barActiveId = sessionId ? currentPersona : getNewChatDefaultPersona();
personas.forEach(p => {
const card = document.createElement('div');
card.className = 'persona-card' + (p.persona_id === currentPersona ? ' active' : '');
card.className = 'persona-card' + (p.persona_id === barActiveId ? ' active' : '');
card.dataset.id = p.persona_id;
const isCard = p.persona_id.startsWith('card_');
const isCustomPersona = p.custom && !isCard;
@@ -131,16 +143,8 @@ export async function loadPersonas() {
}
export async function selectPersona(personaId) {
setCurrentPersona(personaId);
highlightPersona(personaId);
if (sessionId) {
await fetch(`/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persona_id: personaId }),
});
await initChat();
}
setNewChatDefaultPersona(personaId);
highlightPersonaBar(personaId);
}
function fillImpCardForm(preview) {
+209
View File
@@ -0,0 +1,209 @@
/** Escape for safe text nodes (not HTML injection). */
function appendText(parent, text) {
if (!text) return;
parent.appendChild(document.createTextNode(text));
}
const PLAYER_CHOSE_RE = /^\[Player chose:\s*(.+)\]$/s;
const RP_TOKEN_RE = /("(?:[^"\\]|\\.)*"|«[^»]*»|'(?:[^'\\]|\\.)*'|\*\*[^*]+\*\*|\*[^*]+\*)/g;
const OUTCOME_CLASS = {
'critical failure': 'outcome-crit-fail',
failure: 'outcome-fail',
success: 'outcome-success',
'critical success': 'outcome-crit-success',
};
export function isPlayerChoiceContent(text) {
return PLAYER_CHOSE_RE.test((text || '').trim());
}
export function parsePlayerChoice(text) {
const m = (text || '').trim().match(PLAYER_CHOSE_RE);
return m ? m[1].trim() : null;
}
function appendPlayerChoice(frag, label) {
const wrap = document.createElement('span');
wrap.className = 'rp-choice';
const tag = document.createElement('span');
tag.className = 'rp-choice-tag';
tag.textContent = '🔘 Выбор';
wrap.appendChild(tag);
appendText(wrap, ' ');
const lab = document.createElement('span');
lab.className = 'rp-choice-label';
lab.textContent = label;
wrap.appendChild(lab);
frag.appendChild(wrap);
}
/**
* Build DOM fragment for RP formatting (dialogue, action, player choice).
*/
export function buildRpFormatFragment(text) {
const frag = document.createDocumentFragment();
if (!text) return frag;
const trimmed = text.trim();
const choiceLabel = parsePlayerChoice(trimmed);
if (choiceLabel !== null) {
appendPlayerChoice(frag, choiceLabel);
return frag;
}
let last = 0;
for (const m of trimmed.matchAll(RP_TOKEN_RE)) {
const idx = m.index ?? 0;
if (idx > last) appendText(frag, trimmed.slice(last, idx));
const token = m[0];
if (
(token.startsWith('"') && token.endsWith('"'))
|| (token.startsWith('«') && token.endsWith('»'))
|| (token.startsWith("'") && token.endsWith("'"))
) {
const span = document.createElement('span');
span.className = 'rp-dialogue';
span.textContent = token;
frag.appendChild(span);
} else if (token.startsWith('**') && token.endsWith('**')) {
const em = document.createElement('em');
em.className = 'rp-action';
em.textContent = token.slice(2, -2);
frag.appendChild(em);
} else if (token.startsWith('*') && token.endsWith('*')) {
const em = document.createElement('em');
em.className = 'rp-action';
em.textContent = token.slice(1, -1);
frag.appendChild(em);
} else {
appendText(frag, token);
}
last = idx + token.length;
}
if (last < trimmed.length) appendText(frag, trimmed.slice(last));
return frag;
}
/**
* Dice override block on user bubble (intent struck through resolution).
*/
export function buildDiceOverrideFragment(resolution) {
const { intent_text, resolution_text, roll, outcome } = resolution;
const wrap = document.createElement('div');
wrap.className = 'dice-user-override';
const badge = document.createElement('div');
badge.className = `dice-user-badge ${OUTCOME_CLASS[outcome] || ''}`;
badge.textContent = `🎲 ${roll} · ${outcome || 'roll'}`;
wrap.appendChild(badge);
const row = document.createElement('div');
row.className = 'dice-user-row';
const struck = document.createElement('span');
struck.className = 'dice-intent-struck';
struck.textContent = intent_text || '';
row.appendChild(struck);
const arrow = document.createElement('span');
arrow.className = 'dice-intent-arrow';
arrow.textContent = '→';
row.appendChild(arrow);
const resolved = document.createElement('span');
resolved.className = 'dice-intent-resolved';
resolved.textContent = resolution_text || '';
row.appendChild(resolved);
wrap.appendChild(row);
return wrap;
}
export function applyDiceOverrideToBubble(bubble, resolution) {
if (!resolution?.resolution_text) return;
bubble.dataset.diceOverride = '1';
const raw = bubble.dataset.raw ?? '';
const frag = document.createDocumentFragment();
if (raw && !isPlayerChoiceContent(raw)) {
frag.appendChild(buildRpFormatFragment(raw));
frag.appendChild(document.createElement('br'));
} else if (raw) {
frag.appendChild(buildRpFormatFragment(raw));
frag.appendChild(document.createElement('br'));
}
frag.appendChild(buildDiceOverrideFragment(resolution));
bubble.innerHTML = '';
bubble.appendChild(frag);
bubble.classList.add('rp-formatted', 'has-dice-override');
}
export function applyRpFormatToBubble(bubble) {
if (bubble.dataset.diceOverride === '1' && bubble._diceResolution) {
applyDiceOverrideToBubble(bubble, bubble._diceResolution);
return;
}
const raw = bubble.dataset.raw ?? bubble.textContent ?? '';
bubble.dataset.raw = raw;
bubble.innerHTML = '';
bubble.appendChild(buildRpFormatFragment(raw));
bubble.classList.add('rp-formatted');
}
export function applyRpPlainToBubble(bubble) {
const raw = bubble.dataset.raw ?? bubble.textContent ?? '';
bubble.dataset.raw = raw;
bubble.textContent = raw;
bubble.classList.remove('rp-formatted');
}
export function attachFormatToggle(wrapper, bubble) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'format-btn';
btn.textContent = '↩ Текст';
let showingPlain = false;
const syncBtn = () => {
btn.textContent = showingPlain ? '✨' : '↩ Текст';
btn.title = showingPlain ? 'Форматировать' : 'Показать оригинал';
};
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (showingPlain) {
if (bubble._diceResolution) {
applyDiceOverrideToBubble(bubble, bubble._diceResolution);
} else {
applyRpFormatToBubble(bubble);
}
showingPlain = false;
} else {
applyRpPlainToBubble(bubble);
showingPlain = true;
}
syncBtn();
});
syncBtn();
wrapper.appendChild(btn);
return btn;
}
export function initBubbleContent(bubble, rawText, { formatted = true, actionResolution = null } = {}) {
bubble.dataset.raw = rawText ?? '';
bubble._diceResolution = actionResolution || null;
if (actionResolution?.resolution_text && formatted) {
bubble.dataset.diceOverride = '1';
applyDiceOverrideToBubble(bubble, actionResolution);
return;
}
if (formatted && rawText) {
applyRpFormatToBubble(bubble);
} else {
bubble.textContent = rawText ?? '';
bubble.classList.remove('rp-formatted', 'has-dice-override');
}
}
+33 -6
View File
@@ -1,8 +1,10 @@
import {
sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled,
} from './state.js';
import { updateQuestPanel, updateAffinityDisplay } from './chat.js';
import { highlightPersona, personaIndex } from './personas.js';
import {
updateQuestPanel, updateAffinityDisplay, updateStatsDisplay, hideStatsDisplay,
} from './chat.js';
import { highlightPersonaBar, personaIndex } from './personas.js';
import { formatSessionDate } from './utils.js';
import { openNewChatWizard } from './newChatWizard.js';
@@ -35,6 +37,16 @@ export function applySessionUi(session) {
dom.affinityDisplay?.classList.add('hidden');
}
if (rpgOn && settings.stats) {
try {
updateStatsDisplay(JSON.parse(session.narrative_stats_json || '{}'));
} catch {
hideStatsDisplay();
}
} else {
hideStatsDisplay();
}
if (rpgOn && settings.quests) {
fetch(`/sessions/${session.session_id}/quests`)
.then(r => r.ok ? r.json() : [])
@@ -114,7 +126,7 @@ export async function loadChatHistory(id) {
const s = await sessionRes.json();
if (s.persona_id) {
setCurrentPersona(s.persona_id);
highlightPersona(s.persona_id);
highlightPersonaBar(s.persona_id);
}
applySessionUi(s);
}
@@ -155,7 +167,7 @@ export async function initSessions() {
let _prevBlobSections = {};
function renderSystemBlob(blob) {
export function renderSystemBlob(blob) {
const tryFmt = (str, fallback = '') => {
try { return JSON.stringify(JSON.parse(str), null, 2); } catch { return str || fallback; }
};
@@ -165,13 +177,26 @@ function renderSystemBlob(blob) {
return ` ${icon} [${q.status}] ${q.title}`;
}).join('\n');
const personaLine = blob.persona_id
? `[persona] ${blob.persona_name || blob.persona_id} (${blob.persona_id})`
: '';
const ctx = blob.context_usage;
const ctxLine = ctx
? `[context] ~${ctx.tokens_est} / ${ctx.max_tokens_est} tokens (${ctx.percent}%) · ${ctx.chars} chars`
: '';
const sections = {
context: ctxLine,
persona: personaLine,
system_prompt: blob.system_prompt ? `[system_prompt]\n${blob.system_prompt}` : '',
status_quo: blob.status_quo ? `[status_quo]\n${blob.status_quo}` : '',
affinity: blob.affinity != null ? `[affinity] ${blob.affinity}` : '',
scene: blob.scene_json && blob.scene_json !== '{}' ? `[scene]\n${tryFmt(blob.scene_json)}` : '',
stats: blob.narrative_stats_json && blob.narrative_stats_json !== '{}' ? `[stats]\n${tryFmt(blob.narrative_stats_json)}` : '',
genre: blob.genre ? `[genre] ${blob.genre}` : '',
rpg_settings: blob.rpg_settings_json && blob.rpg_settings_json !== '{}' ? `[rpg_settings]\n${tryFmt(blob.rpg_settings_json)}` : '',
outfit: blob.outfit_json && blob.outfit_json !== '[]' ? `[outfit]\n${tryFmt(blob.outfit_json)}` : '',
outfit: `[outfit]\n${tryFmt(blob.outfit_json ?? '[]')}`,
facts: blob.facts_json && blob.facts_json !== '[]' ? `[facts]\n${tryFmt(blob.facts_json)}` : '',
plot_arc: blob.plot_arc_json && blob.plot_arc_json !== '{}' ? `[plot_arc]\n${tryFmt(blob.plot_arc_json)}` : '',
quests: questLines ? `[quests]\n${questLines}` : '',
@@ -184,7 +209,9 @@ function renderSystemBlob(blob) {
if (!text) continue;
const span = document.createElement('span');
span.textContent = text;
if (_prevBlobSections[key] && _prevBlobSections[key] !== text) {
if (key === 'context' && ctx && ctx.percent > 80) {
span.className = 'blob-context-warn';
} else if (_prevBlobSections[key] && _prevBlobSections[key] !== text) {
span.className = 'blob-changed';
setTimeout(() => span.classList.remove('blob-changed'), 3000);
}
+18 -3
View File
@@ -1,17 +1,31 @@
export let sessionId = localStorage.getItem('chat_session_id') || null;
export let currentPersona = localStorage.getItem('persona_id') || 'default';
/** Persona bound to the active session (from server, not global preset). */
export let currentPersona = 'default';
export let sidebarOpen = true;
export let rpgEnabled = false;
const NEW_CHAT_PERSONA_KEY = 'new_chat_persona_id';
export function toggleSidebar() { sidebarOpen = !sidebarOpen; return sidebarOpen; }
export function getNewChatDefaultPersona() {
return localStorage.getItem(NEW_CHAT_PERSONA_KEY)
|| localStorage.getItem('persona_id')
|| 'default';
}
export function setNewChatDefaultPersona(id) {
const pid = id || 'default';
localStorage.setItem(NEW_CHAT_PERSONA_KEY, pid);
}
export function setSessionId(id) {
sessionId = id;
if (id) localStorage.setItem('chat_session_id', id);
}
export function setCurrentPersona(id) {
currentPersona = id;
localStorage.setItem('persona_id', id);
currentPersona = id || 'default';
}
export function setRpgEnabled(v) { rpgEnabled = !!v; }
@@ -25,6 +39,7 @@ export const dom = {
headerTitle: document.getElementById('headerTitle'),
emptyState: document.getElementById('emptyState'),
affinityDisplay: document.getElementById('affinityDisplay'),
statsDisplay: document.getElementById('statsDisplay'),
rpgBadge: document.getElementById('rpgBadge'),
systemBlob: document.getElementById('systemBlob'),
systemBlobContent: document.getElementById('systemBlobContent'),
+21 -1
View File
@@ -6,12 +6,32 @@ export function parseImagePromptFromContent(content) {
return { text, prompt };
}
export function splitSdPromptForCopy(fullPrompt) {
if (!fullPrompt) return '';
const marker = '\n\nNegative prompt:';
const i = fullPrompt.indexOf(marker);
return (i >= 0 ? fullPrompt.slice(0, i) : fullPrompt).trim();
}
export async function copyToClipboard(text) {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import asyncio
from services.rpg_plot import (
prune_beats_for_done_quests,
process_arc_beats,
should_advance_arc_keywords,
pop_matching_beats,
dice_outcome_to_beat_trigger,
)
def test_prune_removes_beat_when_quest_done():
arc = {
"beats": [
{"id": "b2", "title": "Highway Howl", "trigger": "event_driven:travel", "choices": []},
]
}
quests = [{"title": "Highway Howl", "status": "done"}]
arc, removed = prune_beats_for_done_quests(arc, quests)
assert removed[0]["title"] == "Highway Howl"
assert arc["beats"] == []
def test_stuck_recovery_fires_when_no_active_quests():
arc = {
"beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
]
}
quests = [{"title": "Old", "status": "done"}]
async def run():
return await process_arc_beats(arc, quests, "hello")
arc2, fired, pruned, mode = asyncio.run(run())
assert mode == "stuck_recovery"
assert fired[0]["title"] == "New Beat"
assert arc2["beats"] == []
def test_dice_outcome_maps_to_after_fail():
assert dice_outcome_to_beat_trigger("failure") == "event_driven:after_fail"
assert dice_outcome_to_beat_trigger("critical failure") == "event_driven:after_fail"
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
def test_after_fail_beat_fires_on_dice_failure():
arc = {
"beats": [
{
"id": "b_fail",
"title": "Dust Yourself Off",
"trigger": "event_driven:after_fail",
"injection": "The stumble leaves you both shaken.",
"choices": [{"id": "a", "label": "Try again"}],
},
{
"id": "b_ok",
"title": "Victory Lap",
"trigger": "event_driven:after_success",
"choices": [],
},
]
}
async def run():
return await process_arc_beats(
arc, [], "продолжаем разговор", last_dice_outcome="failure"
)
arc2, fired, _, mode = asyncio.run(run())
assert mode == "after_dice"
assert fired[0]["id"] == "b_fail"
assert len(arc2["beats"]) == 1
assert arc2["beats"][0]["id"] == "b_ok"
def test_keyword_fallback_travel():
arc = {
"beats": [
{"id": "b2", "title": "Highway Howl", "trigger": "event_driven:travel", "choices": []},
]
}
trig = should_advance_arc_keywords("едем на стадион")
assert trig == "event_driven:travel"
arc, fired = pop_matching_beats(arc, trig, max_beats=1)
assert fired[0]["title"] == "Highway Howl"
+54
View File
@@ -0,0 +1,54 @@
from services.memory import narrator_message_content, parse_narrator_message
from routers.chat import messages_for_llm
from services.rpg_state import format_narrator_outcome_for_llm
def test_messages_for_llm_includes_narrator_ruling_not_original_only():
resolution = (
"Luna's attempt backfires; people roll their eyes and an elderly man pats her head."
)
narrator_json = narrator_message_content(
{
"roll": 5,
"outcome": "failure",
"text": resolution,
"original_intent": "bulldoze through the line",
}
)
history = [
{"role": "system", "content": "static"},
{
"role": "user",
"content": "Луна влезла без очереди, распугивая всех",
"action_resolution": {
"intent_text": "Луна влезла без очереди",
"roll": 5,
"outcome": "failure",
"resolution_text": resolution,
},
},
{"role": "narrator", "content": narrator_json},
]
llm = messages_for_llm(history, "system+runtime")
user_msgs = [m for m in llm if m["role"] == "user"]
assert len(user_msgs) == 2
assert "canonical outcome" in user_msgs[0]["content"].lower()
assert "Луна влезла" in user_msgs[0]["content"]
assert "MANDATORY" in user_msgs[1]["content"]
assert "backfires" in user_msgs[1]["content"]
assert "Do NOT write a success version" in user_msgs[1]["content"]
def test_format_narrator_failure_wording():
text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "It failed."}
)
assert "FAILED" in text
assert "Do NOT write a success version" in text
def test_parse_narrator_message_roundtrip():
raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"})
data = parse_narrator_message(raw)
assert data["roll"] == 12
assert data["text"] == "OK"
+18
View File
@@ -0,0 +1,18 @@
from services.outfit_tags import enrich_outfit_tag, normalize_outfit_list, parse_and_normalize_outfit_json
def test_enrich_shorts_and_tank():
out = normalize_outfit_list(["sports_shorts", "torn_tank_top", "championship_belt_collar"])
assert out == ["black_sports_shorts", "white_torn_tank_top", "gold_championship_belt_collar"]
def test_keeps_existing_color():
assert enrich_outfit_tag("red_dress") == "red_dress"
assert "red" in normalize_outfit_list(["red_dress"])[0]
def test_parse_json_string():
raw = '["sports_shorts", "torn_tank_top"]'
parsed = parse_and_normalize_outfit_json(raw)
assert "black_sports_shorts" in parsed
assert "white_torn_tank_top" in parsed
+29
View File
@@ -0,0 +1,29 @@
from services.rpg_plot import choices_from_beat, choices_from_narrator, normalize_choice
def test_choices_from_beat_tags_source():
beat = {
"id": "b_new_1",
"title": "Rest Stop Confession",
"injection": "GPS alerts you to a rest stop.",
"choices": [
{"id": "a", "label": "Tease her about snores"},
{"id": "b", "label": "Ask about snacks"},
],
}
out = choices_from_beat(beat)
assert len(out) == 2
assert out[0]["source"] == "plot_beat"
assert out[0]["beat_title"] == "Rest Stop Confession"
assert out[0]["beat_id"] == "b_new_1"
assert "beat_injection" in out[0]
def test_choices_from_narrator_tags_source():
out = choices_from_narrator([{"id": "a", "label": "Look around"}])
assert out[0]["source"] == "narrator"
assert "beat_title" not in out[0]
def test_normalize_choice_skips_empty_label():
assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None
+18
View File
@@ -0,0 +1,18 @@
from services.rp_sanitize import strip_ooc_from_reply
def test_strip_ps_block():
text = (
"Луна зевает и прижимается к тебе.\n\n"
"Статус кво? Она никогда не признает слабость.\n\n"
"P.S. Когда вы выйдете из сауны, она будет бурчать."
)
out = strip_ooc_from_reply(text)
assert "P.S." not in out
assert "Статус кво" not in out
assert "Луна зевает" in out
def test_strip_keeps_in_character_body():
text = "— «Щенок…» — она бурчит, не открывая глаз."
assert strip_ooc_from_reply(text) == text
+42
View File
@@ -0,0 +1,42 @@
from services.rpg_facts import (
merge_facts,
parse_facts_list,
facts_to_prompt,
facts_list_to_json,
dedupe_facts_fuzzy,
facts_are_similar,
)
def test_legacy_string_facts():
raw = '["Old fact", "Another"]'
facts = parse_facts_list(raw)
assert len(facts) == 2
assert facts[0]["text"] == "Old fact"
def test_fuzzy_similar_near_duplicate():
assert facts_are_similar(
"Rin and Grigo found a magical glade with glowing flowers",
"Rin and Grigo found magical glade glowing flowers",
)
def test_dedupe_collapses_duplicates():
raw = facts_list_to_json(
[
{"text": "Rin found a magical glade with glowing flowers", "rp_day": "day 1"},
{"text": "Rin found magical glade glowing flowers", "rp_day": "day 1"},
{"text": "Player name is Grigo", "rp_day": "day 1"},
]
)
out = dedupe_facts_fuzzy(parse_facts_list(raw))
assert len(out) == 2
def test_merge_with_rp_day():
existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}])
merged = parse_facts_list(
merge_facts(existing, [{"text": "B", "rp_day": ""}], rp_day_default="день 2")
)
assert merged[1]["rp_day"] == "день 2"
+17
View File
@@ -0,0 +1,17 @@
from services.rpg_state import affinity_prompt_block, stats_prompt_block
def test_affinity_mandatory_wording():
block = affinity_prompt_block(5)
assert "MANDATORY" in block
assert "current player" in block
def test_stamina_2_exhausted_instruction():
block = stats_prompt_block({"lust": 0, "stamina": 2, "tension": 0})
assert "barely moves" in block.lower() or "exhausted" in block.lower()
def test_stamina_1_collapse_instruction():
block = stats_prompt_block({"lust": 0, "stamina": 1, "tension": 0})
assert "collapse" in block.lower() or "pass out" in block.lower()
+243
View File
@@ -0,0 +1,243 @@
"""Unit tests for layered Anima prompt assembly (no LLM)."""
import os
import sys
from unittest.mock import patch
import pytest
# Ensure project root on path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from services import sd_prompt as sp
@pytest.fixture
def anima():
with patch.object(sp, "_is_anima", return_value=True), patch.object(sp, "_is_pony", return_value=False):
yield
PERSONA_WOLF = {
"appearance_tags": "wolfgirl, white_hair, golden_eyes, wolf_ears, tail, big_breast",
"appearance_prose": "",
"lora_name": "",
}
PERSONA_CARRIE = {
"appearance_tags": "short_hair, brown_hair, blue_eyes, skinny",
"appearance_prose": "",
"lora_name": "",
}
def test_walking_scene_includes_action_tags_and_contextual_pov(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"viewer_body_visible": False,
"action_tags": "holding_hands, walking, smiling, looking_at_each_other",
"environment_tags": "outdoors, sunlight, golden_hour",
"scene_description": "She walks beside you, laughter in the warm afternoon light.",
})
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
assert "walking" in hybrid
assert "smiling" in hybrid
assert "holding_hands" not in hybrid
assert "looking_at_each_other" not in hybrid
assert "outdoors" in hybrid
assert "threshold" not in hybrid.lower()
assert "POV: walking beside you" in hybrid
assert "someone" not in hybrid.lower()
assert "both " not in hybrid.lower()
def test_hybrid_differs_from_tags_only_when_prose_present(anima):
scene = {
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"viewer_body_visible": False,
"action_tags": "holding_hands, walking",
"environment_tags": "outdoors, sunlight",
"scene_description": "Shared laughter drifts through the golden afternoon.",
}
tags_only = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
assert tags_only != hybrid
assert "Shared laughter" in hybrid
assert "Shared laughter" not in tags_only
def test_carrie_doorway_scene(anima):
scene = {
"shot_type": "first_person_pov",
"pov_cue": "doorway_invite",
"viewer_body_visible": False,
"action_tags": "arms_out, inviting_hug, smirk, looking_at_viewer",
"environment_tags": "doorway, apartment, night, indoors",
"scene_description": "She waits in the doorway with playful hunger in half-lidded eyes.",
}
outfit = "crop_top, ripped_jeans, black_jeans"
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_CARRIE, outfit)
assert "arms_out" in hybrid
assert "doorway" in hybrid
assert "crop_top" in hybrid
assert "threshold" not in hybrid.lower()
assert "POV: she blocks the doorway" in hybrid
def test_pov_inferred_from_action_when_cue_missing(anima):
scene = {
"shot_type": "first_person_pov",
"action_tags": "holding_hands, walking, smiling",
"environment_tags": "outdoors, park",
"scene_description": "",
}
tags = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
assert "POV: walking beside you" in tags
def test_negative_includes_interaction_block_for_pov_contact(anima):
scene = {
"shot_type": "first_person_pov",
"viewer_body_visible": False,
"action_tags": "arms_out, hug, inviting_hug",
"environment_tags": "doorway",
}
neg = sp._negative_for_scene(scene)
assert "duplicate" in neg
assert "extra_person" in neg
assert "third person" in neg
def test_scene_should_generate_false():
assert sp._scene_should_generate({"should_generate": False}) is False
assert sp._scene_should_generate({"should_generate": True}) is True
assert sp._scene_should_generate({}) is True
def test_format_builder_user_block_illustrate_vs_context(anima):
messages = [
{"role": "assistant", "content": "Long old first_mes " + ("x" * 900)},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "*walks holding your hand*"},
]
block = sp._format_builder_user_block(PERSONA_WOLF, messages, "[]")
assert "=== ILLUSTRATE" in block
assert "=== Context" in block
assert "*walks holding your hand*" in block
assert "Long old first_mes" in block
assert len(block.split("Long old first_mes")[1].split("assistant:")[0]) < 900
def test_bundle_from_scene_anima_uses_hybrid_as_tag_full(anima):
scene = {
"should_generate": True,
"shot_type": "first_person_pov",
"pov_cue": "face_to_face",
"action_tags": "smiling",
"environment_tags": "indoors",
"scene_description": "A warm smile greets you.",
}
with patch.object(sp, "anima_dual_enabled", return_value=False):
bundle = sp._bundle_from_scene(scene, PERSONA_WOLF, "")
assert "A warm smile" in bundle.tag_full
assert bundle.desc_full is None
def test_user_example_walking_llm_output_cleaned(anima):
"""Regression: LLM prose/sentence leakage and second-person refs."""
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"action_tags": (
"holding_hands, walking, smiling, looking_at_each_other, "
"A wolfgirl walks hand in hand with someone, both smiling and chatting"
),
"environment_tags": "outdoor, daylight, path",
"scene_description": (
"A wolfgirl walks hand in hand with someone, both smiling and chatting under the daylight."
),
})
persona = {**PERSONA_WOLF, "appearance_tags": PERSONA_WOLF["appearance_tags"] + ", pumped_up"}
tags_only = sp.build_positive_prompt_tags_only(scene, persona, "")
hybrid = sp.build_positive_prompt_hybrid(scene, persona, "")
assert "pumped_up" not in tags_only
assert "someone" not in hybrid.lower()
assert "both " not in hybrid.lower()
assert ". A wolfgirl walks" not in tags_only
assert tags_only != hybrid or not scene.get("scene_description")
def test_user_example_carrie_env_reconciled(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "doorway_invite",
"action_tags": "arms_out, inviting_hug, smirk, half-lidded_eyes",
"environment_tags": "doorway, nighttime, outdoor",
"scene_description": (
"Carrie stands in her doorway at night, arms outstretched toward you with a mischievous smirk."
),
})
hybrid = sp.build_positive_prompt_hybrid(
scene, PERSONA_CARRIE, "crop_top, ripped_jeans, black_jeans, jeans"
)
assert "outdoor" not in hybrid.lower() or "doorway" in hybrid
assert ", jeans," not in f", {hybrid},"
assert "someone" not in hybrid.lower()
def test_long_first_mes_uses_final_beat(anima):
carrie_tail = (
"About an hour later...\n\n"
"Carrie stood at her front door, arms out, smirking. "
'"Come on, hug me. Now." It\'s getting cold out.'
)
long = ("She shops for clothes.\n\n" * 5) + carrie_tail
excerpt = sp._extract_illustrate_content(long)
assert "front door" in excerpt or "hug me" in excerpt
assert "shops for clothes" not in excerpt
def test_hybrid_gets_fallback_when_no_scene_description(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"action_tags": "walking, smiling",
"environment_tags": "outdoor, daylight",
"scene_description": "",
})
tags_only = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
assert hybrid != tags_only
assert "afternoon" in hybrid.lower() or "laughter" in hybrid.lower()
def test_yuki_pov_drops_lifting_and_nose_rub(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "face_to_face",
"action_tags": "arms_out, lifting, nose_rub, smiling",
"environment_tags": "indoors, warm_lighting",
"scene_description": "Her golden eyes soften with warmth toward the camera.",
})
hybrid = sp.build_positive_prompt_hybrid(scene, {**PERSONA_WOLF, "appearance_tags": "fox_girl, golden_eyes"}, "pink_sweater")
assert "lifting" not in hybrid
assert "nose_rub" not in hybrid
assert "golden" in hybrid.lower()
def test_bundle_tags_only_alt_when_dual_compare(anima):
scene = {
"shot_type": "first_person_pov",
"pov_cue": "dialogue_close",
"action_tags": "smiling",
"environment_tags": "indoors",
"scene_description": "Soft light on her face.",
}
with patch.object(sp, "anima_dual_enabled", return_value=True):
bundle = sp._bundle_from_scene(scene, PERSONA_WOLF, "")
assert bundle.desc_full is not None
assert bundle.desc_full != bundle.tag_full
assert "Soft light" in bundle.tag_full
assert "Soft light" not in bundle.desc_full.split(sp.NEGATIVE_PROMPT_SEPARATOR)[0]