diff --git a/RPG_FLOW.md b/RPG_FLOW.md new file mode 100644 index 0000000..c552495 --- /dev/null +++ b/RPG_FLOW.md @@ -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: нет C6–C7; 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 (0–10). +- post: `stats_delta` каждый ключ −2..+2, clamp 0–10. +- Дефолт 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).* diff --git a/database/db.py b/database/db.py index 38f7a49..d753ebb 100644 --- a/database/db.py +++ b/database/db.py @@ -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 ''") diff --git a/main.py b/main.py index 802a95a..869840c 100644 --- a/main.py +++ b/main.py @@ -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"} diff --git a/main_carrie-just-a-friendly-hug-3ccb4b5342bb_spec_v2.png b/main_carrie-just-a-friendly-hug-3ccb4b5342bb_spec_v2.png new file mode 100644 index 0000000..8ef6eb0 Binary files /dev/null and b/main_carrie-just-a-friendly-hug-3ccb4b5342bb_spec_v2.png differ diff --git a/main_clingy-obsessive-girlfriend-af26ead7_spec_v2.png b/main_clingy-obsessive-girlfriend-af26ead7_spec_v2.png new file mode 100644 index 0000000..058ce48 Binary files /dev/null and b/main_clingy-obsessive-girlfriend-af26ead7_spec_v2.png differ diff --git a/main_delta-125aa7a6_spec_v2.png b/main_delta-125aa7a6_spec_v2.png new file mode 100644 index 0000000..8f3f8b4 Binary files /dev/null and b/main_delta-125aa7a6_spec_v2.png differ diff --git a/main_violet-merino-d2e9f62b5d77_spec_v2.png b/main_violet-merino-d2e9f62b5d77_spec_v2.png new file mode 100644 index 0000000..da57117 Binary files /dev/null and b/main_violet-merino-d2e9f62b5d77_spec_v2.png differ diff --git a/main_vulpisfoglia-e0a6befda921_spec_v2.png b/main_vulpisfoglia-e0a6befda921_spec_v2.png new file mode 100644 index 0000000..9b52d48 Binary files /dev/null and b/main_vulpisfoglia-e0a6befda921_spec_v2.png differ diff --git a/main_your-scumbag-superheroine-friend-c65bf1fe881c_spec_v2.png b/main_your-scumbag-superheroine-friend-c65bf1fe881c_spec_v2.png new file mode 100644 index 0000000..fcd461e Binary files /dev/null and b/main_your-scumbag-superheroine-friend-c65bf1fe881c_spec_v2.png differ diff --git a/models/schemas.py b/models/schemas.py index 6ed48a4..648241c 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -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 diff --git a/routers/characters.py b/routers/characters.py index 22c2036..27e349b 100644 --- a/routers/characters.py +++ b/routers/characters.py @@ -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 diff --git a/routers/chat.py b/routers/chat.py index 02988a6..0d83d10 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -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) diff --git a/routers/debug.py b/routers/debug.py new file mode 100644 index 0000000..af4532f --- /dev/null +++ b/routers/debug.py @@ -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)) diff --git a/routers/personas.py b/routers/personas.py index 750a90b..1ce2e7a 100644 --- a/routers/personas.py +++ b/routers/personas.py @@ -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 diff --git a/routers/sessions.py b/routers/sessions.py index c2a38b5..cdbcdd6 100644 --- a/routers/sessions.py +++ b/routers/sessions.py @@ -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 0–10).""" + 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: diff --git a/services/character_card.py b/services/character_card.py index 2d0e398..8424505 100644 --- a/services/character_card.py +++ b/services/character_card.py @@ -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", ""), diff --git a/services/chat_prompt.py b/services/chat_prompt.py new file mode 100644 index 0000000..f634e85 --- /dev/null +++ b/services/chat_prompt.py @@ -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 diff --git a/services/comfy_models.py b/services/comfy_models.py new file mode 100644 index 0000000..24e5cac --- /dev/null +++ b/services/comfy_models.py @@ -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)) diff --git a/services/context_budget.py b/services/context_budget.py new file mode 100644 index 0000000..9e31524 --- /dev/null +++ b/services/context_budget.py @@ -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]" diff --git a/services/llm.py b/services/llm.py index 2b4ae89..4a1a796 100644 --- a/services/llm.py +++ b/services/llm.py @@ -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: diff --git a/services/memory.py b/services/memory.py index 25f5980..b90ebc5 100644 --- a/services/memory.py +++ b/services/memory.py @@ -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 diff --git a/services/opening.py b/services/opening.py new file mode 100644 index 0000000..6a175fd --- /dev/null +++ b/services/opening.py @@ -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, + } diff --git a/services/outfit_tags.py b/services/outfit_tags.py new file mode 100644 index 0000000..0e907a6 --- /dev/null +++ b/services/outfit_tags.py @@ -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 []) diff --git a/services/personas.py b/services/personas.py index e77d3a7..9e54183 100644 --- a/services/personas.py +++ b/services/personas.py @@ -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( diff --git a/services/rp_sanitize.py b/services/rp_sanitize.py new file mode 100644 index 0000000..9ec739b --- /dev/null +++ b/services/rp_sanitize.py @@ -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() diff --git a/services/rpg_context.py b/services/rpg_context.py new file mode 100644 index 0000000..27afcf8 --- /dev/null +++ b/services/rpg_context.py @@ -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) diff --git a/services/rpg_facts.py b/services/rpg_facts.py index 253a95c..d535202 100644 --- a/services/rpg_facts.py +++ b/services/rpg_facts.py @@ -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. "день 1–2"). +- 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---" diff --git a/services/rpg_narrator.py b/services/rpg_narrator.py index a611baf..12398f5 100644 --- a/services/rpg_narrator.py +++ b/services/rpg_narrator.py @@ -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} diff --git a/services/rpg_plot.py b/services/rpg_plot.py index c964813..d8a5d85 100644 --- a/services/rpg_plot.py +++ b/services/rpg_plot.py @@ -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 + ] + diff --git a/services/rpg_state.py b/services/rpg_state.py new file mode 100644 index 0000000..ffc42b9 --- /dev/null +++ b/services/rpg_state.py @@ -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 (0–10, 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 diff --git a/services/sd_images.py b/services/sd_images.py new file mode 100644 index 0000000..3374b13 --- /dev/null +++ b/services/sd_images.py @@ -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 diff --git a/services/sd_prompt.py b/services/sd_prompt.py index 9e0b31b..aa5c7b9 100644 --- a/services/sd_prompt.py +++ b/services/sd_prompt.py @@ -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"") + +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("= 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" " 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 diff --git a/services/sdbackend.py b/services/sdbackend.py index aa3874f..962755e 100644 --- a/services/sdbackend.py +++ b/services/sdbackend.py @@ -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) diff --git a/services/session_identity.py b/services/session_identity.py new file mode 100644 index 0000000..671ae6f --- /dev/null +++ b/services/session_identity.py @@ -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 diff --git a/services/system_message_migration.py b/services/system_message_migration.py new file mode 100644 index 0000000..4eef6fd --- /dev/null +++ b/services/system_message_migration.py @@ -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 diff --git a/static/avatars/card_carrie_0578318a_dbee17bc.png b/static/avatars/card_carrie_0578318a_dbee17bc.png new file mode 100644 index 0000000..8ef6eb0 Binary files /dev/null and b/static/avatars/card_carrie_0578318a_dbee17bc.png differ diff --git a/static/avatars/card_carrie_926629e6_09f02497.png b/static/avatars/card_carrie_926629e6_09f02497.png new file mode 100644 index 0000000..8ef6eb0 Binary files /dev/null and b/static/avatars/card_carrie_926629e6_09f02497.png differ diff --git a/static/avatars/card_carrie_926629e6_8b9b8891.png b/static/avatars/card_carrie_926629e6_8b9b8891.png new file mode 100644 index 0000000..8ef6eb0 Binary files /dev/null and b/static/avatars/card_carrie_926629e6_8b9b8891.png differ diff --git a/static/avatars/card_carrie_926629e6_e01546a5.png b/static/avatars/card_carrie_926629e6_e01546a5.png new file mode 100644 index 0000000..8ef6eb0 Binary files /dev/null and b/static/avatars/card_carrie_926629e6_e01546a5.png differ diff --git a/static/css/app.css b/static/css/app.css index 6930b96..03af736 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/css/debug.css b/static/css/debug.css new file mode 100644 index 0000000..e0bcf19 --- /dev/null +++ b/static/css/debug.css @@ -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; +} diff --git a/static/debug.html b/static/debug.html new file mode 100644 index 0000000..6fff947 --- /dev/null +++ b/static/debug.html @@ -0,0 +1,134 @@ + + + + + + Debug — AI ChatBot + + + + +
+ ← Чат +

Debug

+ +
+ + + +
+
+
Загрузка…
+
+ +
+
+ + + + +
+ + +
+
+

Scene JSON

+
+
+
+

Теги / гибрид

+
+
+
+
+ LLM raw + builder +
+
+
+ +
+
+ +
+ + + +
+
+ +
+
+ + +
+
+ +

Модели в Comfy

+
+ +

Генерация

+
+ + + + +
+ + + + +
+ +

Raw API

+
+ + +
+ + + +
+
+
+ + + + diff --git a/static/index.html b/static/index.html index 98b9646..d84b585 100644 --- a/static/index.html +++ b/static/index.html @@ -13,8 +13,10 @@

🤖 AI Chat

Новый чат + 🛠 - + +
@@ -26,7 +28,12 @@
@@ -36,6 +43,7 @@
System +
@@ -289,6 +297,7 @@ +
@@ -314,6 +323,9 @@ +

Персонаж чата

+

Смена персонажа перепривязывает этот чат. Историю можно сохранить или очистить.

+
@@ -334,9 +346,30 @@ +
+
+ 🧪 Отладка: симпатия и шкалы +

Жёстко задаёт состояние для следующих реплик. Цель — текущий игрок (позже: пары персонаж×игрок).

+
+ + + + +
+ + +
- + + + diff --git a/static/js/app.js b/static/js/app.js index 4945c8f..6faaa1c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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(); diff --git a/static/js/chat.js b/static/js/chat.js index 02266d8..cdb28ad 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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 = '🎨 SD prompt'; + header.innerHTML = `🎨 ${label}`; 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(); diff --git a/static/js/chatSettings.js b/static/js/chatSettings.js index f907422..2c20878 100644 --- a/static/js/chatSettings.js +++ b/static/js/chatSettings.js @@ -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 = `${p.emoji || '🤖'}${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('
'); + 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); } } diff --git a/static/js/contextEditor.js b/static/js/contextEditor.js new file mode 100644 index 0000000..1f2b1d5 --- /dev/null +++ b/static/js/contextEditor.js @@ -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'; + } + } + }); +} diff --git a/static/js/debug.js b/static/js/debug.js new file mode 100644 index 0000000..4815a01 --- /dev/null +++ b/static/js/debug.js @@ -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 = ``; + 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 = `${key} (${list.length})`; + 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); diff --git a/static/js/newChatWizard.js b/static/js/newChatWizard.js index 37035f6..778bac3 100644 --- a/static/js/newChatWizard.js +++ b/static/js/newChatWizard.js @@ -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); diff --git a/static/js/personas.js b/static/js/personas.js index 3b5628b..8546132 100644 --- a/static/js/personas.js +++ b/static/js/personas.js @@ -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) { diff --git a/static/js/rpFormat.js b/static/js/rpFormat.js new file mode 100644 index 0000000..4ab63b4 --- /dev/null +++ b/static/js/rpFormat.js @@ -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'); + } +} diff --git a/static/js/sessions.js b/static/js/sessions.js index 1cf88af..ff200e9 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -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); } diff --git a/static/js/state.js b/static/js/state.js index 6818f22..e94ad55 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -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'), diff --git a/static/js/utils.js b/static/js/utils.js index c169608..c4fcd79 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -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; + } } } diff --git a/tests/test_arc_stuck_recovery.py b/tests/test_arc_stuck_recovery.py new file mode 100644 index 0000000..ed94eca --- /dev/null +++ b/tests/test_arc_stuck_recovery.py @@ -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" diff --git a/tests/test_dice_llm_payload.py b/tests/test_dice_llm_payload.py new file mode 100644 index 0000000..6060352 --- /dev/null +++ b/tests/test_dice_llm_payload.py @@ -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" diff --git a/tests/test_outfit_tags.py b/tests/test_outfit_tags.py new file mode 100644 index 0000000..450da17 --- /dev/null +++ b/tests/test_outfit_tags.py @@ -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 diff --git a/tests/test_plot_choices.py b/tests/test_plot_choices.py new file mode 100644 index 0000000..045150c --- /dev/null +++ b/tests/test_plot_choices.py @@ -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 diff --git a/tests/test_rp_sanitize.py b/tests/test_rp_sanitize.py new file mode 100644 index 0000000..6e13a1f --- /dev/null +++ b/tests/test_rp_sanitize.py @@ -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 diff --git a/tests/test_rpg_facts.py b/tests/test_rpg_facts.py new file mode 100644 index 0000000..7ba5e4e --- /dev/null +++ b/tests/test_rpg_facts.py @@ -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" diff --git a/tests/test_rpg_state_prompts.py b/tests/test_rpg_state_prompts.py new file mode 100644 index 0000000..72118cc --- /dev/null +++ b/tests/test_rpg_state_prompts.py @@ -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() diff --git a/tests/test_sd_prompt.py b/tests/test_sd_prompt.py new file mode 100644 index 0000000..6a28f20 --- /dev/null +++ b/tests/test_sd_prompt.py @@ -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]