Fixed SD RPG
@@ -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).*
|
||||||
@@ -86,6 +86,12 @@ async def _migrate_messages_columns(db):
|
|||||||
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt TEXT")
|
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt TEXT")
|
||||||
if "image_path" not in cols:
|
if "image_path" not in cols:
|
||||||
await db.execute("ALTER TABLE messages ADD COLUMN image_path TEXT")
|
await db.execute("ALTER TABLE messages ADD COLUMN image_path TEXT")
|
||||||
|
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):
|
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 ''")
|
await db.execute("ALTER TABLE personas ADD COLUMN avatar_path TEXT DEFAULT ''")
|
||||||
if "alternate_greetings_json" not in cols:
|
if "alternate_greetings_json" not in cols:
|
||||||
await db.execute("ALTER TABLE personas ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'")
|
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):
|
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 '{}'")
|
await db.execute("ALTER TABLE sessions ADD COLUMN rpg_settings_json TEXT DEFAULT '{}'")
|
||||||
if "outfit_json" not in cols:
|
if "outfit_json" not in cols:
|
||||||
await db.execute("ALTER TABLE sessions ADD COLUMN outfit_json TEXT DEFAULT '[]'")
|
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):
|
async def _migrate_rpg_quests(db):
|
||||||
await db.executescript("""
|
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 ''")
|
await db.execute("ALTER TABLE characters ADD COLUMN avatar_path TEXT DEFAULT ''")
|
||||||
if "alternate_greetings_json" not in cols:
|
if "alternate_greetings_json" not in cols:
|
||||||
await db.execute("ALTER TABLE characters ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'")
|
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 ''")
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
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 database.db import init_db
|
||||||
from services.persona_seed import seed_default_personas
|
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")
|
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):
|
async def lifespan(app: FastAPI):
|
||||||
await init_db()
|
await init_db()
|
||||||
await seed_default_personas()
|
await seed_default_personas()
|
||||||
|
await migrate_static_system_messages()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ app.include_router(sessions.router)
|
|||||||
app.include_router(characters.router)
|
app.include_router(characters.router)
|
||||||
app.include_router(images.router)
|
app.include_router(images.router)
|
||||||
app.include_router(translate.router)
|
app.include_router(translate.router)
|
||||||
|
app.include_router(debug.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
@@ -34,6 +37,11 @@ async def root():
|
|||||||
return FileResponse("static/index.html")
|
return FileResponse("static/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/debug")
|
||||||
|
async def debug_page():
|
||||||
|
return FileResponse("static/debug.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
@@ -24,6 +24,37 @@ class RegenerateRequest(BaseModel):
|
|||||||
class ForkSessionRequest(BaseModel):
|
class ForkSessionRequest(BaseModel):
|
||||||
until_message_id: int
|
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):
|
class ChatResponse(BaseModel):
|
||||||
reply: str
|
reply: str
|
||||||
session_id: str
|
session_id: str
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class CardPatch(BaseModel):
|
|||||||
first_mes: Optional[str] = None
|
first_mes: Optional[str] = None
|
||||||
mes_example: Optional[str] = None
|
mes_example: Optional[str] = None
|
||||||
appearance_tags: Optional[str] = None
|
appearance_tags: Optional[str] = None
|
||||||
|
appearance_prose: Optional[str] = None
|
||||||
lora_name: Optional[str] = None
|
lora_name: Optional[str] = None
|
||||||
lora_weight: Optional[float] = None
|
lora_weight: Optional[float] = None
|
||||||
alternate_greetings_json: Optional[str] = None
|
alternate_greetings_json: Optional[str] = None
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
|
||||||
import aiosqlite
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from database.db import DB_PATH
|
|
||||||
from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest
|
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 (
|
from services.memory import (
|
||||||
get_history,
|
get_history,
|
||||||
add_message,
|
add_message,
|
||||||
@@ -18,41 +16,74 @@ from services.memory import (
|
|||||||
get_or_create_session,
|
get_or_create_session,
|
||||||
get_session,
|
get_session,
|
||||||
update_session_title,
|
update_session_title,
|
||||||
update_session_persona,
|
|
||||||
get_message_count,
|
get_message_count,
|
||||||
get_last_assistant_message_id,
|
get_last_assistant_message_id,
|
||||||
update_message_image,
|
update_message_image,
|
||||||
update_session_facts,
|
update_session_facts,
|
||||||
update_session_status_quo,
|
update_session_status_quo,
|
||||||
update_session_affinity,
|
|
||||||
update_session_genre,
|
update_session_genre,
|
||||||
update_session_rpg_settings,
|
|
||||||
update_session_outfit,
|
|
||||||
update_session_plot_arc,
|
update_session_plot_arc,
|
||||||
upsert_quest,
|
|
||||||
get_quests,
|
get_quests,
|
||||||
|
seed_quests_from_arc,
|
||||||
|
narrator_message_content,
|
||||||
|
parse_narrator_message,
|
||||||
add_action_resolution,
|
add_action_resolution,
|
||||||
get_message,
|
get_message,
|
||||||
update_message_content,
|
update_message_content,
|
||||||
delete_messages_after,
|
delete_messages_after,
|
||||||
delete_message,
|
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.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.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.character_card import get_character
|
||||||
from services import sdbackend as sd_service
|
from services import sdbackend as sd_service
|
||||||
from services.rpg_facts import extract_facts, merge_facts, facts_to_prompt
|
from services.rpg_facts import extract_facts, merge_facts_persist, facts_to_prompt, rp_day_from_scene
|
||||||
from services.rpg_plot import generate_plot_arc, should_advance_arc, pop_matching_beats, advance_phase
|
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.rpg_narrator import narrator_pre, narrator_post
|
||||||
|
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||||
|
|
||||||
DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу."
|
|
||||||
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
|
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
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:
|
def get_rpg_settings(session: dict) -> dict:
|
||||||
@@ -62,34 +93,61 @@ def get_rpg_settings(session: dict) -> dict:
|
|||||||
return DEFAULT_RPG_SETTINGS
|
return DEFAULT_RPG_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
def affinity_prompt_block(affinity: int) -> str:
|
def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str = "") -> str:
|
||||||
if affinity >= 10: tone = "very warm, trusting, affectionate"
|
runtime_suffix = ""
|
||||||
elif affinity >= 5: tone = "friendly and open"
|
if facts_block:
|
||||||
elif affinity >= 1: tone = "slightly positive"
|
runtime_suffix += "\n\n" + facts_block
|
||||||
elif affinity <= -5: tone = "hostile or deeply distrustful"
|
try:
|
||||||
elif affinity <= -1: tone = "cold and wary"
|
arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||||
else: tone = "neutral"
|
except Exception:
|
||||||
return f"\n\n--- Relationship ---\nAffinity toward player: {affinity} ({tone}). Reflect this in your attitude and word choice.\n---"
|
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:
|
def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
|
||||||
persona = await get_persona(persona_id)
|
"""Build LLM payload: one system message (static + runtime), no duplicate system rows."""
|
||||||
if not persona:
|
out: list[dict] = []
|
||||||
return DEFAULT_PROMPT
|
system_used = False
|
||||||
prompt = persona["prompt"]
|
for m in history:
|
||||||
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
|
if m["role"] == "system":
|
||||||
context = recent + [{"role": "user", "content": user_message}]
|
if not system_used:
|
||||||
if persona.get("lorebook_json"):
|
out.append({"role": "system", "content": llm_system_content})
|
||||||
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
|
system_used = True
|
||||||
if lore:
|
elif m["role"] == "narrator":
|
||||||
prompt += "\n\n" + lore
|
data = parse_narrator_message(m.get("content") or "")
|
||||||
if persona_id.startswith("card_"):
|
if data:
|
||||||
card = await get_character(persona_id[5:])
|
out.append({"role": "user", "content": format_narrator_outcome_for_llm(data)})
|
||||||
if card:
|
elif m["role"] == "user":
|
||||||
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
|
has_res = bool(m.get("action_resolution"))
|
||||||
if lore:
|
out.append({
|
||||||
prompt += "\n\n" + lore
|
"role": "user",
|
||||||
return prompt
|
"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}")
|
@router.get("/history/{session_id}")
|
||||||
@@ -100,33 +158,68 @@ async def get_chat_history(session_id: str):
|
|||||||
@router.get("/system/{session_id}")
|
@router.get("/system/{session_id}")
|
||||||
async def get_system_blob(session_id: str):
|
async def get_system_blob(session_id: str):
|
||||||
history = await get_history(session_id)
|
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)
|
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)
|
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 {
|
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 "",
|
"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 "[]",
|
"facts_json": session.get("facts_json") if session else "[]",
|
||||||
"plot_arc_json": session.get("plot_arc_json") if session else "{}",
|
"plot_arc_json": session.get("plot_arc_json") if session else "{}",
|
||||||
"outfit_json": session.get("outfit_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,
|
"affinity": session.get("affinity", 0) if session else 0,
|
||||||
"genre": session.get("genre", "") if session else "",
|
"genre": session.get("genre", "") if session else "",
|
||||||
"rpg_settings_json": session.get("rpg_settings_json") 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,
|
"rpg_enabled": bool(session.get("rpg_enabled")) if session else False,
|
||||||
"quests": quests,
|
"quests": quests,
|
||||||
|
"context_usage": context_usage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/init")
|
@router.post("/init")
|
||||||
async def init_chat(request: ChatRequest):
|
async def init_chat(request: ChatRequest):
|
||||||
persona_id = request.persona_id or "default"
|
await get_or_create_session(
|
||||||
await get_or_create_session(request.session_id, persona_id)
|
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)
|
history = await get_history(request.session_id)
|
||||||
if history:
|
if history:
|
||||||
return {"first_mes": None}
|
return {"first_mes": None}
|
||||||
|
|
||||||
system_prompt = await get_system_prompt(persona_id, [], "")
|
system_prompt = await get_system_prompt(persona_id, [], "")
|
||||||
await add_message(request.session_id, "system", system_prompt)
|
await upsert_static_system_message(request.session_id, system_prompt, [])
|
||||||
|
|
||||||
first_mes = None
|
first_mes = None
|
||||||
if request.first_mes_override and request.first_mes_override.strip():
|
if request.first_mes_override and request.first_mes_override.strip():
|
||||||
@@ -152,53 +245,67 @@ class RpgBootstrapRequest(BaseModel):
|
|||||||
genre: str = "adventure"
|
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")
|
@router.post("/rpg/bootstrap")
|
||||||
async def rpg_bootstrap(req: RpgBootstrapRequest):
|
async def rpg_bootstrap(req: RpgBootstrapRequest):
|
||||||
await get_or_create_session(req.session_id, req.persona_id)
|
await get_or_create_session(req.session_id, req.persona_id)
|
||||||
session = await get_session(req.session_id)
|
persona_id = await resolve_session_persona(req.session_id, req.persona_id)
|
||||||
persona = await get_persona(req.persona_id) or {}
|
|
||||||
|
|
||||||
# Save genre
|
|
||||||
await update_session_genre(req.session_id, req.genre)
|
await update_session_genre(req.session_id, req.genre)
|
||||||
|
persona = await get_persona(persona_id) or {}
|
||||||
arc_json = (session.get("plot_arc_json") or "{}") if session else "{}"
|
greeting = await resolve_greeting(req.session_id, persona)
|
||||||
try:
|
arc = await ensure_plot_arc_and_quests(req.session_id, persona, greeting, req.genre)
|
||||||
arc = json.loads(arc_json) if isinstance(arc_json, str) else {}
|
session = await get_session(req.session_id) or {}
|
||||||
except Exception:
|
rpg_settings = get_rpg_settings(session)
|
||||||
arc = {}
|
if rpg_settings.get("narrator", True) and greeting:
|
||||||
if not arc:
|
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
|
||||||
facts_block = facts_to_prompt((session or {}).get("facts_json", "[]"))
|
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
||||||
arc = await generate_plot_arc(
|
post = await narrator_post(
|
||||||
persona.get("name", req.persona_id),
|
persona.get("name", persona_id),
|
||||||
persona.get("description", ""),
|
f"assistant: {greeting}",
|
||||||
persona.get("scenario", ""),
|
arc_json,
|
||||||
persona.get("first_mes", ""),
|
facts_block,
|
||||||
facts_block=facts_block,
|
is_opening=True,
|
||||||
genre=req.genre,
|
|
||||||
)
|
)
|
||||||
if arc:
|
await apply_narrator_post(req.session_id, post, rpg_settings, session)
|
||||||
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])
|
|
||||||
|
|
||||||
quests = await get_quests(req.session_id)
|
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")
|
@router.post("/stream")
|
||||||
async def chat_stream(request: ChatRequest):
|
async def chat_stream(request: ChatRequest):
|
||||||
persona_id = request.persona_id or "default"
|
await get_or_create_session(request.session_id, request.persona_id)
|
||||||
|
persona_id = await resolve_session_persona(
|
||||||
await get_or_create_session(request.session_id, persona_id)
|
request.session_id,
|
||||||
|
request.persona_id,
|
||||||
|
create_persona=request.persona_id,
|
||||||
|
)
|
||||||
|
|
||||||
history = await get_history(request.session_id)
|
history = await get_history(request.session_id)
|
||||||
session = await get_session(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 = {}
|
arc = {}
|
||||||
roll = None
|
roll = None
|
||||||
@@ -206,26 +313,24 @@ async def chat_stream(request: ChatRequest):
|
|||||||
resolution_text = ""
|
resolution_text = ""
|
||||||
narrator_msg = None # shown as narrator bubble before assistant reply
|
narrator_msg = None # shown as narrator bubble before assistant reply
|
||||||
rpg_settings = {}
|
rpg_settings = {}
|
||||||
|
facts_block = ""
|
||||||
|
|
||||||
|
narrator_extra = ""
|
||||||
|
pre = {}
|
||||||
|
directives: list = []
|
||||||
|
pre_ok = False
|
||||||
if session and session.get("rpg_enabled"):
|
if session and session.get("rpg_enabled"):
|
||||||
rpg_settings = get_rpg_settings(session)
|
rpg_settings = get_rpg_settings(session)
|
||||||
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
||||||
if facts_block:
|
|
||||||
system_prompt = system_prompt + "\n\n" + facts_block
|
|
||||||
try:
|
try:
|
||||||
arc = json.loads(session.get("plot_arc_json") or "{}")
|
arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
arc = {}
|
arc = {}
|
||||||
if arc:
|
|
||||||
system_prompt = system_prompt + "\n\n--- PlotArc ---\n" + json.dumps(
|
quests_list = await get_quests(request.session_id)
|
||||||
{k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False
|
narr_ctx = format_narrator_context(
|
||||||
) + "\n---"
|
arc, quests_list, session.get("status_quo") or ""
|
||||||
status_quo = (session.get("status_quo") or "").strip()
|
)
|
||||||
if status_quo:
|
|
||||||
system_prompt = system_prompt + "\n\n--- Status quo ---\n" + status_quo + "\n---"
|
|
||||||
if rpg_settings.get("affinity", True):
|
|
||||||
aff = int(session.get("affinity") or 0)
|
|
||||||
system_prompt = system_prompt + affinity_prompt_block(aff)
|
|
||||||
|
|
||||||
if rpg_settings.get("narrator", True):
|
if rpg_settings.get("narrator", True):
|
||||||
persona = await get_persona(persona_id) or {}
|
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 "",
|
json.dumps(arc, ensure_ascii=False) if arc else "",
|
||||||
facts_block,
|
facts_block,
|
||||||
request.message,
|
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)
|
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,
|
request.message,
|
||||||
roll=roll,
|
roll=roll,
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
|
extra_context=narr_ctx,
|
||||||
)
|
)
|
||||||
resolution_text = (pre2.get("resolution_text") or "").strip()
|
resolution_text = (pre2.get("resolution_text") or "").strip()
|
||||||
directives = pre2.get("directives") or []
|
directives = pre2.get("directives") or []
|
||||||
@@ -274,66 +382,95 @@ async def chat_stream(request: ChatRequest):
|
|||||||
pre_sq = (pre.get("status_quo_update") or "").strip()
|
pre_sq = (pre.get("status_quo_update") or "").strip()
|
||||||
|
|
||||||
if directives:
|
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:
|
if pre_sq:
|
||||||
await update_session_status_quo(request.session_id, pre_sq)
|
await update_session_status_quo(request.session_id, pre_sq)
|
||||||
session["status_quo"] = 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:
|
if resolution_text:
|
||||||
await add_action_resolution(
|
narrator_msg = {
|
||||||
request.session_id,
|
"roll": roll,
|
||||||
intent_text=request.message,
|
"outcome": outcome,
|
||||||
roll=roll,
|
"text": resolution_text,
|
||||||
outcome=outcome,
|
"original_intent": request.message,
|
||||||
resolution_text=resolution_text,
|
}
|
||||||
message_id=None,
|
|
||||||
)
|
|
||||||
narrator_msg = {"roll": roll, "outcome": outcome, "text": resolution_text}
|
|
||||||
|
|
||||||
# Inject outcome into system prompt so character reply is consistent
|
if roll is not None and resolution_text:
|
||||||
if roll is not None:
|
narrator_extra += (
|
||||||
system_prompt = (
|
f"\n\n--- Mechanics (this turn) ---\n"
|
||||||
system_prompt
|
f"Roll d20={roll}. Outcome: {outcome}.\n"
|
||||||
+ f"\n\n--- Mechanics ---\n"
|
f"Narrator resolution: {resolution_text}\n"
|
||||||
+ f"Roll d20={roll}. Outcome: {outcome}.\n"
|
"The character's next reply MUST match the narrator ruling in the message history "
|
||||||
+ "Your reply MUST be consistent with this outcome. Do NOT contradict the narrator resolution.\n"
|
"(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
|
user_message_content = request.message
|
||||||
if request.is_narrator_choice:
|
if request.is_narrator_choice:
|
||||||
user_message_content = f"[Player chose: {request.message}]"
|
user_message_content = f"[Player chose: {request.message}]"
|
||||||
|
|
||||||
if not history:
|
await upsert_static_system_message(request.session_id, static_prompt, 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()
|
|
||||||
|
|
||||||
|
user_msg_id = None
|
||||||
if not request.skip_user_add:
|
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)
|
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 = []
|
full_reply = []
|
||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
nonlocal arc
|
nonlocal arc
|
||||||
|
|
||||||
# Send narrator BEFORE streaming so it appears above the reply
|
|
||||||
if narrator_msg:
|
if narrator_msg:
|
||||||
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
|
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_message(
|
async for chunk in stream_message(llm_messages):
|
||||||
[{"role": m["role"], "content": m["content"]} for m in messages]
|
|
||||||
):
|
|
||||||
full_reply.append(chunk)
|
full_reply.append(chunk)
|
||||||
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
|
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -342,25 +479,19 @@ async def chat_stream(request: ChatRequest):
|
|||||||
return
|
return
|
||||||
|
|
||||||
complete = "".join(full_reply)
|
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) + [
|
if (display_text or raw_display).strip():
|
||||||
{"role": "assistant", "content": display_text}
|
await add_message(request.session_id, "assistant", display_text or raw_display)
|
||||||
]
|
|
||||||
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)
|
|
||||||
|
|
||||||
choices = []
|
choices = []
|
||||||
debug_blocks = []
|
debug_blocks = []
|
||||||
quests_updated = []
|
quests_updated = []
|
||||||
|
narrator_meta = {}
|
||||||
|
|
||||||
if session and session.get("rpg_enabled"):
|
if session and session.get("rpg_enabled"):
|
||||||
|
try:
|
||||||
if not arc:
|
if not arc:
|
||||||
persona = await get_persona(persona_id) or {}
|
persona = await get_persona(persona_id) or {}
|
||||||
arc = await generate_plot_arc(
|
arc = await generate_plot_arc(
|
||||||
@@ -372,69 +503,152 @@ async def chat_stream(request: ChatRequest):
|
|||||||
genre=session.get("genre") or "adventure",
|
genre=session.get("genre") or "adventure",
|
||||||
)
|
)
|
||||||
if arc:
|
if arc:
|
||||||
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
|
await update_session_plot_arc(
|
||||||
debug_blocks.append({"type": "plot_arc", "text": json.dumps(arc, ensure_ascii=False, indent=2)})
|
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):
|
if rpg_settings.get("quests", True):
|
||||||
for beat in arc.get("beats", []):
|
await seed_quests_from_arc(request.session_id, arc)
|
||||||
t = (beat.get("title") or beat.get("injection", "")).strip()
|
|
||||||
if t:
|
|
||||||
await upsert_quest(request.session_id, t[:120])
|
|
||||||
|
|
||||||
trig = should_advance_arc(request.message)
|
quests_list = await get_quests(request.session_id)
|
||||||
if trig and arc:
|
if arc:
|
||||||
arc, beats = pop_matching_beats(arc, trig, max_beats=1)
|
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:
|
if beats:
|
||||||
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
|
|
||||||
inj = beats[0].get("injection", "")
|
inj = beats[0].get("injection", "")
|
||||||
if inj:
|
if inj:
|
||||||
debug_blocks.append({"type": "narrator_injection", "text": inj})
|
debug_blocks.append({"type": "narrator_injection", "text": inj})
|
||||||
if rpg_settings.get("choices", True):
|
if rpg_settings.get("choices", True):
|
||||||
choices += beats[0].get("choices") or []
|
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):
|
if advance_phase(arc):
|
||||||
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
|
await update_session_plot_arc(
|
||||||
|
request.session_id, json.dumps(arc, ensure_ascii=False)
|
||||||
|
)
|
||||||
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
|
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
|
||||||
|
|
||||||
ctx = [m for m in (await get_history(request.session_id)) if m["role"] in ("user", "assistant")][-10:]
|
ctx = [
|
||||||
new_facts = await extract_facts(ctx)
|
m for m in (await get_history(request.session_id))
|
||||||
|
if m["role"] in ("user", "assistant")
|
||||||
|
][-10:]
|
||||||
|
new_facts = await extract_facts(
|
||||||
|
ctx,
|
||||||
|
rp_day_hint=rp_day_from_scene(session.get("scene_json")),
|
||||||
|
existing_json=session.get("facts_json", "[]"),
|
||||||
|
)
|
||||||
if new_facts:
|
if new_facts:
|
||||||
merged = merge_facts(session.get("facts_json", "[]"), 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)
|
await update_session_facts(request.session_id, merged)
|
||||||
session["facts_json"] = merged
|
session["facts_json"] = merged
|
||||||
|
|
||||||
persona = await get_persona(persona_id) or {}
|
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"))
|
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(
|
post = await narrator_post(
|
||||||
persona.get("name", persona_id),
|
persona.get("name", persona_id),
|
||||||
ctx_txt,
|
ctx_txt,
|
||||||
json.dumps(arc, ensure_ascii=False) if arc else "",
|
json.dumps(arc, ensure_ascii=False) if arc else "",
|
||||||
facts_to_prompt(session.get("facts_json", "[]")),
|
facts_to_prompt(session.get("facts_json", "[]")),
|
||||||
|
extra_context=narr_ctx_post,
|
||||||
)
|
)
|
||||||
|
|
||||||
sq = (post.get("status_quo_update") or "").strip()
|
sq = (post.get("status_quo_update") or "").strip()
|
||||||
if sq:
|
if sq:
|
||||||
await update_session_status_quo(request.session_id, sq)
|
|
||||||
debug_blocks.append({"type": "status_quo", "text": sq})
|
debug_blocks.append({"type": "status_quo", "text": sq})
|
||||||
|
|
||||||
if rpg_settings.get("choices", True):
|
if rpg_settings.get("choices", True):
|
||||||
choices += post.get("choices") or []
|
choices += choices_from_narrator(post.get("choices") or [])
|
||||||
|
|
||||||
if rpg_settings.get("affinity", True):
|
applied = await apply_narrator_post(
|
||||||
delta = int(post.get("affinity_delta") or 0)
|
request.session_id, post, rpg_settings, session
|
||||||
if delta:
|
)
|
||||||
await update_session_affinity(request.session_id, delta)
|
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 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")
|
outfit_update = post.get("outfit_update")
|
||||||
if isinstance(outfit_update, list) and outfit_update:
|
if isinstance(outfit_update, list) and outfit_update:
|
||||||
outfit_str = json.dumps(outfit_update, ensure_ascii=False)
|
from services.outfit_tags import outfit_list_to_json
|
||||||
await update_session_outfit(request.session_id, outfit_str)
|
|
||||||
session["outfit_json"] = outfit_str
|
|
||||||
|
|
||||||
if rpg_settings.get("quests", True):
|
session["outfit_json"] = outfit_list_to_json(outfit_update)
|
||||||
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"))
|
|
||||||
quests_updated = await get_quests(request.session_id)
|
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)
|
count = await get_message_count(request.session_id)
|
||||||
if count == 2 and not request.skip_user_add:
|
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 ("", "Новый чат"):
|
if (session or {}).get("title", "Новый чат") in ("", "Новый чат"):
|
||||||
await update_session_title(request.session_id, f"{persona.get('name', persona_id)} — {preview}")
|
await update_session_title(request.session_id, f"{persona.get('name', persona_id)} — {preview}")
|
||||||
|
|
||||||
image_path = None
|
updated_session = await get_session(request.session_id) or session
|
||||||
image_error = None
|
hist = await get_history(request.session_id)
|
||||||
if prompt_str and SD_AUTO_GENERATE:
|
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"
|
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)
|
rel, err = await sd_service.generate_from_full_prompt(prompt_str)
|
||||||
if rel:
|
if rel:
|
||||||
image_path = rel
|
sd_out["image_path"] = f"/static/{rel}"
|
||||||
msg_id = await get_last_assistant_message_id(request.session_id)
|
|
||||||
if msg_id:
|
if msg_id:
|
||||||
await update_message_image(msg_id, rel)
|
await update_message_image(msg_id, rel)
|
||||||
else:
|
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
|
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(
|
return StreamingResponse(
|
||||||
generate(),
|
generate(),
|
||||||
@@ -470,23 +724,37 @@ async def chat_stream(request: ChatRequest):
|
|||||||
|
|
||||||
@router.post("/", response_model=ChatResponse)
|
@router.post("/", response_model=ChatResponse)
|
||||||
async def chat(request: ChatRequest):
|
async def chat(request: ChatRequest):
|
||||||
persona_id = request.persona_id or "default"
|
await get_or_create_session(request.session_id, request.persona_id)
|
||||||
await get_or_create_session(request.session_id, 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)
|
history = await get_history(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)
|
||||||
|
await upsert_static_system_message(request.session_id, static_prompt, history)
|
||||||
if not history:
|
|
||||||
await add_message(request.session_id, "system", system_prompt)
|
|
||||||
|
|
||||||
await add_message(request.session_id, "user", request.message)
|
await add_message(request.session_id, "user", request.message)
|
||||||
messages = await get_history(request.session_id)
|
messages = await get_history(request.session_id)
|
||||||
reply = await send_message(
|
session = await get_session(request.session_id)
|
||||||
[{"role": m["role"], "content": m["content"]} for m in messages]
|
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_str = bundle.tag_full if bundle else extract_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)
|
|
||||||
|
|
||||||
await add_message(request.session_id, "assistant", display, image_prompt=prompt_str)
|
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}")
|
@router.patch("/messages/{message_id}")
|
||||||
async def edit_message(message_id: int, req: MessageEditRequest):
|
async def edit_message(message_id: int, req: MessageEditRequest):
|
||||||
msg = await get_message(message_id)
|
msg = await get_message(message_id)
|
||||||
@@ -527,7 +804,6 @@ async def regenerate_chat(req: RegenerateRequest):
|
|||||||
stream_req = ChatRequest(
|
stream_req = ChatRequest(
|
||||||
message=user_text,
|
message=user_text,
|
||||||
session_id=req.session_id,
|
session_id=req.session_id,
|
||||||
persona_id=req.persona_id,
|
|
||||||
skip_user_add=True,
|
skip_user_add=True,
|
||||||
)
|
)
|
||||||
return await chat_stream(stream_req)
|
return await chat_stream(stream_req)
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -57,6 +57,7 @@ class PersonaPatch(BaseModel):
|
|||||||
lora_name: Optional[str] = None
|
lora_name: Optional[str] = None
|
||||||
lora_weight: Optional[float] = None
|
lora_weight: Optional[float] = None
|
||||||
appearance_tags: Optional[str] = None
|
appearance_tags: Optional[str] = None
|
||||||
|
appearance_prose: Optional[str] = None
|
||||||
personality: Optional[str] = None
|
personality: Optional[str] = None
|
||||||
scenario: Optional[str] = None
|
scenario: Optional[str] = None
|
||||||
first_mes: Optional[str] = None
|
first_mes: Optional[str] = None
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from services.memory import (
|
from services.memory import (
|
||||||
get_all_sessions,
|
get_all_sessions,
|
||||||
get_session,
|
get_session,
|
||||||
get_or_create_session,
|
get_or_create_session,
|
||||||
|
get_history,
|
||||||
delete_session,
|
delete_session,
|
||||||
update_session_title,
|
update_session_title,
|
||||||
update_session_persona,
|
update_session_persona,
|
||||||
@@ -14,10 +17,27 @@ from services.memory import (
|
|||||||
update_session_genre,
|
update_session_genre,
|
||||||
update_session_rpg_settings,
|
update_session_rpg_settings,
|
||||||
get_quests,
|
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,
|
get_last_message_preview,
|
||||||
fork_session,
|
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"])
|
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||||
|
|
||||||
@@ -35,9 +55,149 @@ async def list_sessions():
|
|||||||
|
|
||||||
@router.get("/{session_id}/quests")
|
@router.get("/{session_id}/quests")
|
||||||
async def list_quests(session_id: str):
|
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)
|
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}")
|
@router.get("/{session_id}")
|
||||||
async def get_session_route(session_id: str):
|
async def get_session_route(session_id: str):
|
||||||
s = await get_session(session_id)
|
s = await get_session(session_id)
|
||||||
@@ -46,9 +206,42 @@ async def get_session_route(session_id: str):
|
|||||||
return s
|
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}")
|
@router.patch("/{session_id}")
|
||||||
async def patch_session(session_id: str, data: dict):
|
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:
|
if "title" in data:
|
||||||
await update_session_title(session_id, data["title"])
|
await update_session_title(session_id, data["title"])
|
||||||
if "persona_id" in data:
|
if "persona_id" in data:
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def parse_card_v2(data: dict, card_id: str | None = None) -> dict:
|
|||||||
"first_mes": inner.get("first_mes", ""),
|
"first_mes": inner.get("first_mes", ""),
|
||||||
"mes_example": inner.get("mes_example", ""),
|
"mes_example": inner.get("mes_example", ""),
|
||||||
"appearance_tags": _extract_appearance(inner),
|
"appearance_tags": _extract_appearance(inner),
|
||||||
|
"appearance_prose": "",
|
||||||
"lorebook_json": json.dumps(entries, ensure_ascii=False),
|
"lorebook_json": json.dumps(entries, ensure_ascii=False),
|
||||||
"alternate_greetings": alternates,
|
"alternate_greetings": alternates,
|
||||||
"alternate_greetings_json": json.dumps(alternates, ensure_ascii=False),
|
"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:
|
def build_system_prompt(card: dict) -> str:
|
||||||
|
from services.chat_prompt import ROLEPLAY_GUARDRAILS
|
||||||
|
|
||||||
parts = [
|
parts = [
|
||||||
f"You are {card['name']}. Stay in character.",
|
f"You are {card['name']}. Stay in character.",
|
||||||
f"Description: {card['description']}",
|
f"Description: {card['description']}",
|
||||||
@@ -129,6 +132,7 @@ def build_system_prompt(card: dict) -> str:
|
|||||||
if card.get("mes_example"):
|
if card.get("mes_example"):
|
||||||
parts.append(f"Example dialogue:\n{card['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("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())
|
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:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT OR REPLACE INTO characters
|
"""INSERT INTO characters
|
||||||
(card_id, name, description, personality, scenario, first_mes,
|
(card_id, name, description, personality, scenario, first_mes, mes_example,
|
||||||
mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json,
|
raw_json, lora_name, lora_weight, appearance_tags, appearance_prose, lorebook_json, avatar_path,
|
||||||
avatar_path, alternate_greetings_json)
|
alternate_greetings_json)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
card_id,
|
card["card_id"],
|
||||||
card["name"],
|
card["name"],
|
||||||
card["description"],
|
card["description"],
|
||||||
card["personality"],
|
card["personality"],
|
||||||
@@ -157,10 +161,11 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
|
|||||||
card["raw_json"],
|
card["raw_json"],
|
||||||
lora_name,
|
lora_name,
|
||||||
lora_weight,
|
lora_weight,
|
||||||
card.get("appearance_tags", ""),
|
card["appearance_tags"],
|
||||||
|
card.get("appearance_prose", ""),
|
||||||
card["lorebook_json"],
|
card["lorebook_json"],
|
||||||
card.get("avatar_path", ""),
|
card.get("avatar_path", ""),
|
||||||
alt_json,
|
card.get("alternate_greetings_json", "[]"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await db.commit()
|
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 def update_appearance_tags(card_id: str, appearance_tags: str):
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE characters SET appearance_tags = ? WHERE card_id = ?",
|
"UPDATE characters SET appearance_tags = ?, appearance_prose = ? WHERE card_id = ?",
|
||||||
(appearance_tags, card_id),
|
(appearance_tags, "", card_id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
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:
|
async def update_character(card_id: str, fields: dict) -> bool:
|
||||||
allowed = {"name", "description", "personality", "scenario", "first_mes",
|
allowed = {"name", "description", "personality", "scenario", "first_mes",
|
||||||
"mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path",
|
"mes_example", "appearance_tags", "appearance_prose", "lora_name", "lora_weight", "avatar_path",
|
||||||
"alternate_greetings_json"}
|
"alternate_greetings_json"}
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -295,6 +300,7 @@ async def import_card_file(
|
|||||||
"lora_name": lora_name,
|
"lora_name": lora_name,
|
||||||
"lora_weight": lora_weight,
|
"lora_weight": lora_weight,
|
||||||
"appearance_tags": saved.get("appearance_tags", ""),
|
"appearance_tags": saved.get("appearance_tags", ""),
|
||||||
|
"appearance_prose": saved.get("appearance_prose", ""),
|
||||||
"avatar_path": saved.get("avatar_path", ""),
|
"avatar_path": saved.get("avatar_path", ""),
|
||||||
"personality": saved.get("personality", ""),
|
"personality": saved.get("personality", ""),
|
||||||
"scenario": saved.get("scenario", ""),
|
"scenario": saved.get("scenario", ""),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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]"
|
||||||
@@ -13,6 +13,8 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|||||||
|
|
||||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo")
|
CHAT_MODEL = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo")
|
||||||
SYSTEM_MODEL = os.getenv("SYSTEM_MODEL", "google/gemini-2.5-flash")
|
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 = {
|
HEADERS = {
|
||||||
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
"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:
|
def _clean(messages: list) -> list:
|
||||||
"""Filter out messages with empty content."""
|
"""Filter out messages with empty content."""
|
||||||
return [m for m in messages if (m.get("content") or "").strip()]
|
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 {})}
|
payload = {"model": model, "messages": _clean(messages), **(extra or {})}
|
||||||
async with httpx.AsyncClient(timeout=90) as client:
|
async with httpx.AsyncClient(timeout=90) as client:
|
||||||
r = await client.post(OPENROUTER_URL, headers=HEADERS, json=payload)
|
r = await client.post(OPENROUTER_URL, headers=HEADERS, json=payload)
|
||||||
r.raise_for_status()
|
try:
|
||||||
return r.json()["choices"][0]["message"]["content"]
|
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:
|
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)
|
return await _post(SYSTEM_MODEL, messages)
|
||||||
|
|
||||||
|
|
||||||
async def send_message_with_model(messages: list, model: str) -> str:
|
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)
|
return await _post(model, messages)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,10 +177,19 @@ async def stream_message(messages: list):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
chunk = json.loads(data)
|
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:
|
if content:
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
yield content
|
yield content
|
||||||
|
except LLMError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from database.db import DB_PATH
|
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:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
@@ -13,9 +16,10 @@ async def get_or_create_session(session_id: str, persona_id: str = "default") ->
|
|||||||
if row:
|
if row:
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
pid = (persona_id or "default").strip() or "default"
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO sessions (session_id, persona_id) VALUES (?, ?)",
|
"INSERT INTO sessions (session_id, persona_id) VALUES (?, ?)",
|
||||||
(session_id, persona_id),
|
(session_id, pid),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -71,22 +75,102 @@ async def update_session_persona(session_id: str, persona_id: str):
|
|||||||
(persona_id, session_id),
|
(persona_id, session_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If persona changed, reset RPG state bound to the persona/arc.
|
|
||||||
if prev is not None and prev != persona_id:
|
if prev is not None and prev != persona_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(
|
await db.execute(
|
||||||
"""UPDATE sessions
|
"""UPDATE sessions
|
||||||
SET facts_json = '[]',
|
SET facts_json = '[]',
|
||||||
global_plot = '',
|
global_plot = '',
|
||||||
status_quo = '',
|
status_quo = '',
|
||||||
plot_arc_json = '{}'
|
plot_arc_json = '{}',
|
||||||
|
outfit_json = '[]',
|
||||||
|
affinity = 0,
|
||||||
|
scene_json = '{}',
|
||||||
|
narrative_stats_json = ?
|
||||||
WHERE session_id = ?""",
|
WHERE session_id = ?""",
|
||||||
(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(
|
await db.execute(
|
||||||
"DELETE FROM action_resolutions WHERE session_id = ?",
|
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
)
|
)
|
||||||
await db.commit()
|
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 def update_session_rpg(session_id: str, rpg_enabled: bool):
|
||||||
@@ -174,25 +258,116 @@ async def delete_session(session_id: str):
|
|||||||
await db.commit()
|
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:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""SELECT 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""",
|
FROM messages WHERE session_id = ? ORDER BY id""",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
) as cursor:
|
) as cursor:
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [
|
result = []
|
||||||
{
|
for idx, r in enumerate(rows):
|
||||||
|
item = {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"role": r["role"],
|
"role": r["role"],
|
||||||
"content": r["content"],
|
"content": r["content"],
|
||||||
"image_prompt": r["image_prompt"],
|
"image_prompt": r["image_prompt"],
|
||||||
"image_path": r["image_path"],
|
"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:
|
async def get_message(message_id: int) -> dict | None:
|
||||||
@@ -230,6 +405,38 @@ async def delete_message(message_id: int):
|
|||||||
await db.commit()
|
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 def get_last_message_preview(session_id: str, max_len: int = 80) -> str:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
@@ -261,8 +468,9 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO sessions
|
"""INSERT INTO sessions
|
||||||
(session_id, persona_id, title, rpg_enabled, facts_json, global_plot,
|
(session_id, persona_id, title, rpg_enabled, facts_json, global_plot,
|
||||||
status_quo, plot_arc_json, genre, rpg_settings_json, affinity)
|
status_quo, plot_arc_json, genre, rpg_settings_json, affinity,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
outfit_json, scene_json, narrative_stats_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
new_id,
|
new_id,
|
||||||
source["persona_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("genre", "adventure"),
|
||||||
source.get("rpg_settings_json", "{}"),
|
source.get("rpg_settings_json", "{}"),
|
||||||
source.get("affinity", 0),
|
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(
|
async with db.execute(
|
||||||
@@ -309,18 +520,20 @@ async def add_message(
|
|||||||
content: str,
|
content: str,
|
||||||
image_prompt: str | None = None,
|
image_prompt: str | None = None,
|
||||||
image_path: str | None = None,
|
image_path: str | None = None,
|
||||||
):
|
) -> int:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
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)
|
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
|
||||||
VALUES (?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
(session_id, role, content, image_prompt, image_path),
|
(session_id, role, content, image_prompt, image_path),
|
||||||
)
|
)
|
||||||
|
msg_id = cur.lastrowid
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
return msg_id
|
||||||
|
|
||||||
|
|
||||||
async def update_message_image(message_id: int, image_path: str):
|
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()
|
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 def get_last_assistant_message_id(session_id: str) -> int | None:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
@@ -362,6 +602,18 @@ async def update_session_affinity(session_id: str, delta: int):
|
|||||||
await db.commit()
|
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 def update_session_genre(session_id: str, genre: str):
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -389,6 +641,24 @@ async def update_session_outfit(session_id: str, outfit_json: str):
|
|||||||
await db.commit()
|
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 def upsert_quest(session_id: str, title: str, status: str = "active"):
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
@@ -429,6 +699,18 @@ async def update_quest_status(session_id: str, title: str, status: str):
|
|||||||
await db.commit()
|
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 def get_message_count(session_id: str) -> int:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 [])
|
||||||
@@ -63,6 +63,7 @@ def _row_to_persona(row: dict) -> dict:
|
|||||||
"lora_name": row["lora_name"] or "",
|
"lora_name": row["lora_name"] or "",
|
||||||
"lora_weight": row["lora_weight"] if row["lora_weight"] is not None else 0.8,
|
"lora_weight": row["lora_weight"] if row["lora_weight"] is not None else 0.8,
|
||||||
"appearance_tags": row["appearance_tags"] or "",
|
"appearance_tags": row["appearance_tags"] or "",
|
||||||
|
"appearance_prose": row.get("appearance_prose", "") or "",
|
||||||
"personality": row.get("personality", "") or "",
|
"personality": row.get("personality", "") or "",
|
||||||
"scenario": row.get("scenario", "") or "",
|
"scenario": row.get("scenario", "") or "",
|
||||||
"first_mes": row.get("first_mes", "") or "",
|
"first_mes": row.get("first_mes", "") or "",
|
||||||
@@ -84,6 +85,9 @@ def build_persona_prompt(data: dict) -> str:
|
|||||||
if ex:
|
if ex:
|
||||||
parts.append(f"Example dialogue:\n{ex}")
|
parts.append(f"Example dialogue:\n{ex}")
|
||||||
parts.append("Stay in character. Reply as the character. Do not add image tags.")
|
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())
|
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_name: str = "",
|
||||||
lora_weight: float = 0.8,
|
lora_weight: float = 0.8,
|
||||||
appearance_tags: str = "",
|
appearance_tags: str = "",
|
||||||
|
appearance_prose: str = "",
|
||||||
personality: str = "",
|
personality: str = "",
|
||||||
scenario: str = "",
|
scenario: str = "",
|
||||||
first_mes: str = "",
|
first_mes: str = "",
|
||||||
@@ -138,13 +143,13 @@ async def create_persona(
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO personas
|
"""INSERT INTO personas
|
||||||
(persona_id, name, emoji, description, prompt, custom,
|
(persona_id, name, emoji, description, prompt, custom,
|
||||||
sd_enabled, lora_name, lora_weight, appearance_tags,
|
sd_enabled, lora_name, lora_weight, appearance_tags, appearance_prose,
|
||||||
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
|
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
|
||||||
alternate_greetings_json)
|
alternate_greetings_json)
|
||||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
persona_id, name, emoji, description, final_prompt,
|
persona_id, name, emoji, description, final_prompt,
|
||||||
1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags,
|
1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags, appearance_prose,
|
||||||
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
|
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
|
||||||
alternate_greetings_json,
|
alternate_greetings_json,
|
||||||
),
|
),
|
||||||
@@ -160,6 +165,7 @@ async def create_persona(
|
|||||||
"lora_name": lora_name,
|
"lora_name": lora_name,
|
||||||
"lora_weight": lora_weight,
|
"lora_weight": lora_weight,
|
||||||
"appearance_tags": appearance_tags,
|
"appearance_tags": appearance_tags,
|
||||||
|
"appearance_prose": appearance_prose,
|
||||||
"personality": personality,
|
"personality": personality,
|
||||||
"scenario": scenario,
|
"scenario": scenario,
|
||||||
"first_mes": first_mes,
|
"first_mes": first_mes,
|
||||||
@@ -226,6 +232,7 @@ async def patch_persona(persona_id: str, fields: dict) -> bool:
|
|||||||
"lora_name",
|
"lora_name",
|
||||||
"lora_weight",
|
"lora_weight",
|
||||||
"appearance_tags",
|
"appearance_tags",
|
||||||
|
"appearance_prose",
|
||||||
"personality",
|
"personality",
|
||||||
"scenario",
|
"scenario",
|
||||||
"first_mes",
|
"first_mes",
|
||||||
@@ -256,6 +263,19 @@ async def patch_persona(persona_id: str, fields: dict) -> bool:
|
|||||||
merged.update(updates)
|
merged.update(updates)
|
||||||
updates["prompt"] = build_persona_prompt(merged)
|
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)
|
cols = ", ".join(f"{k} = ?" for k in updates)
|
||||||
cur2 = await db.execute(
|
cur2 = await db.execute(
|
||||||
f"UPDATE personas SET {cols} WHERE persona_id = ?",
|
f"UPDATE personas SET {cols} WHERE persona_id = ?",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -1,76 +1,370 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
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_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:
|
Rules:
|
||||||
- Facts must be durable (names, relations, inventory, locations, world rules).
|
- Return at most 5 NEW facts per turn. If nothing new, return [].
|
||||||
- Do not include ephemeral actions unless they change state.
|
- Do NOT repeat or rephrase facts already listed under "Already known".
|
||||||
- Avoid duplicates.
|
- Facts must be durable (names, relations, inventory, locations, lasting world state).
|
||||||
- Keep each fact <= 120 chars.
|
- Skip momentary emotions unless they permanently change a relationship.
|
||||||
Example output:
|
- text <= 120 chars each.
|
||||||
["User name is Alex", "We are in a ruined castle", "NPC Mira distrusts the user"]"""
|
- 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:
|
try:
|
||||||
existing = json.loads(existing_json or "[]")
|
data = json.loads(facts_json or "[]")
|
||||||
if not isinstance(existing, list):
|
|
||||||
existing = []
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
existing = []
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
seen = {str(x).strip() for x in existing if str(x).strip()}
|
return []
|
||||||
merged = [str(x).strip() for x in existing if str(x).strip()]
|
out: list[dict] = []
|
||||||
for f in new_facts:
|
seen: set[str] = set()
|
||||||
s = str(f).strip()
|
for item in data:
|
||||||
if not s or s in seen:
|
entry = parse_fact_entry(item)
|
||||||
|
if not entry:
|
||||||
continue
|
continue
|
||||||
seen.add(s)
|
key = entry["text"].lower()
|
||||||
merged.append(s)
|
if key in seen:
|
||||||
|
continue
|
||||||
if len(merged) > limit:
|
seen.add(key)
|
||||||
merged = merged[-limit:]
|
out.append(entry)
|
||||||
return json.dumps(merged, ensure_ascii=False)
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def extract_facts(context_messages: list[dict]) -> list[str]:
|
def facts_list_to_json(facts: list[dict]) -> str:
|
||||||
# Build a compact transcript
|
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(
|
transcript = "\n".join(
|
||||||
f"{m.get('role')}: {m.get('content','')}".strip()
|
f"{m.get('role')}: {m.get('content', '')}".strip()
|
||||||
for m in context_messages
|
for m in context_messages
|
||||||
if m.get("role") in ("user", "assistant")
|
if m.get("role") in ("user", "assistant")
|
||||||
)[-6000:]
|
)[-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 = [
|
messages = [
|
||||||
{"role": "system", "content": FACTS_SYSTEM},
|
{"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:
|
try:
|
||||||
data = json.loads(raw.strip())
|
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):
|
if isinstance(data, list):
|
||||||
return [str(x) for x in data][:40]
|
out: list[dict] = []
|
||||||
except Exception:
|
for item in data[:8]:
|
||||||
return []
|
entry = parse_fact_entry(item)
|
||||||
return []
|
if not entry:
|
||||||
|
continue
|
||||||
|
if any(facts_are_similar(entry["text"], k["text"]) for k in known):
|
||||||
def facts_to_prompt(facts_json: str, max_items: int = 20) -> str:
|
continue
|
||||||
try:
|
if not entry["rp_day"] and hint:
|
||||||
facts = json.loads(facts_json or "[]")
|
entry["rp_day"] = hint[:80]
|
||||||
if not isinstance(facts, list):
|
out.append(entry)
|
||||||
return ""
|
return out
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return ""
|
logger.warning("extract_facts JSON parse failed. Raw=%.400s", raw)
|
||||||
facts = [str(x).strip() for x in facts if str(x).strip()]
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
if not facts:
|
||||||
return ""
|
return ""
|
||||||
block = "\n".join(f"- {x}" for x in facts[-max_items:])
|
recent = facts[-max_items:]
|
||||||
return f"--- Facts (persistent memory) ---\n{block}\n---"
|
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---"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from services.llm import send_message_with_model
|
from services.llm import LLMError, send_message_with_model
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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')",
|
"check_reason": "brief reason why a check is needed (e.g. 'jumping over a pit')",
|
||||||
"directives": ["short imperative rules for the next character reply"],
|
"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.",
|
"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=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.
|
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
|
- 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):
|
Return ONLY valid JSON (no markdown):
|
||||||
{
|
{
|
||||||
"status_quo_update": "what changed in the world/state (1-3 sentences)",
|
"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":"..."}, ...],
|
"choices": [{"id":"a","label":"..."}, ...],
|
||||||
"affinity_delta": 0,
|
"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"}],
|
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}],
|
||||||
"outfit_update": ["danbooru_tag", "danbooru_tag"]
|
"outfit_update": ["danbooru_tag", "danbooru_tag"]
|
||||||
}
|
}
|
||||||
Rules:
|
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.
|
- 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.
|
- 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.
|
- 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 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."""
|
- 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(
|
async def narrator_pre(
|
||||||
@@ -53,6 +63,7 @@ async def narrator_pre(
|
|||||||
user_message: str,
|
user_message: str,
|
||||||
roll: int | None = None,
|
roll: int | None = None,
|
||||||
outcome: str | None = None,
|
outcome: str | None = None,
|
||||||
|
extra_context: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
|
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
|
||||||
user = (
|
user = (
|
||||||
@@ -63,10 +74,20 @@ async def narrator_pre(
|
|||||||
f"Facts:\n{facts_block}\n\n"
|
f"Facts:\n{facts_block}\n\n"
|
||||||
f"Recent context:\n{context}\n"
|
f"Recent context:\n{context}\n"
|
||||||
)
|
)
|
||||||
|
if extra_context:
|
||||||
|
user += f"\n--- Session state ---\n{extra_context}\n---\n"
|
||||||
|
try:
|
||||||
raw = await send_message_with_model(
|
raw = await send_message_with_model(
|
||||||
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
|
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
|
||||||
NARRATOR_MODEL,
|
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()
|
cleaned = raw.strip()
|
||||||
if cleaned.startswith("```"):
|
if cleaned.startswith("```"):
|
||||||
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
|
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
|
||||||
@@ -76,10 +97,11 @@ async def narrator_pre(
|
|||||||
try:
|
try:
|
||||||
data = json.loads(cleaned)
|
data = json.loads(cleaned)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
data["_ok"] = True
|
||||||
return data
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Narrator-pre JSON parse failed. Raw=%.500s", raw)
|
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(
|
async def narrator_post(
|
||||||
@@ -87,17 +109,42 @@ async def narrator_post(
|
|||||||
context: str,
|
context: str,
|
||||||
global_plot: str,
|
global_plot: str,
|
||||||
facts_block: str,
|
facts_block: str,
|
||||||
|
is_opening: bool = False,
|
||||||
|
extra_context: str = "",
|
||||||
) -> dict:
|
) -> 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 = (
|
user = (
|
||||||
f"Persona: {persona_name}\n\n"
|
f"Persona: {persona_name}\n\n"
|
||||||
f"Global plot:\n{global_plot}\n\n"
|
f"Global plot:\n{global_plot}\n\n"
|
||||||
f"Facts:\n{facts_block}\n\n"
|
f"Facts:\n{facts_block}\n\n"
|
||||||
f"Recent context:\n{context}\n"
|
f"Recent context:\n{context}\n"
|
||||||
|
f"{opening_block}"
|
||||||
)
|
)
|
||||||
|
if extra_context:
|
||||||
|
user += f"\n--- Session state ---\n{extra_context}\n---\n"
|
||||||
|
try:
|
||||||
raw = await send_message_with_model(
|
raw = await send_message_with_model(
|
||||||
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
|
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
|
||||||
NARRATOR_MODEL,
|
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()
|
cleaned = raw.strip()
|
||||||
if cleaned.startswith("```"):
|
if cleaned.startswith("```"):
|
||||||
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
|
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
|
||||||
@@ -107,7 +154,8 @@ async def narrator_post(
|
|||||||
try:
|
try:
|
||||||
data = json.loads(cleaned)
|
data = json.loads(cleaned)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
data["_ok"] = True
|
||||||
return data
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Narrator-post JSON parse failed. Raw=%.500s", raw)
|
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}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
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
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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": "system", "content": ARC_SYSTEM},
|
||||||
{"role": "user", "content": user},
|
{"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()
|
cleaned = raw.strip()
|
||||||
# common OpenRouter formatting: fenced json
|
# common OpenRouter formatting: fenced json
|
||||||
if cleaned.startswith("```"):
|
if cleaned.startswith("```"):
|
||||||
@@ -79,17 +91,236 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
|
|||||||
return {}
|
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()
|
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"
|
return "event_driven:rest"
|
||||||
if any(x in t for x in ["идем дальше", "пойдем дальше", "в путь", "продолжаем путь", "уходим", "возвращаемся", "переходим"]):
|
if any(
|
||||||
|
x in t
|
||||||
|
for x in [
|
||||||
|
"идем дальше", "пойдем дальше", "пойдём дальше", "едем дальше", "едем",
|
||||||
|
"поехали", "выезжаем", "выезжаю", "в путь", "продолжаем путь",
|
||||||
|
"уходим", "возвращаемся", "переходим", "за рул", "машин", "автомоб",
|
||||||
|
"дорог", "трас", "шосс", "приех", "прибыва", "стади", "на стадион",
|
||||||
|
"отправляемся", "выдвигаемся", "в дорогу",
|
||||||
|
]
|
||||||
|
):
|
||||||
return "event_driven:travel"
|
return "event_driven:travel"
|
||||||
if any(x in t for x in ["помоги", "помочь", "нужна помощь", "спасите", "help"]):
|
if any(x in t for x in ["помоги", "помочь", "нужна помощь", "спасите", "help"]):
|
||||||
return "event_driven:help_request"
|
return "event_driven:help_request"
|
||||||
return None
|
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"]
|
PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"]
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +339,129 @@ def advance_phase(arc: dict) -> bool:
|
|||||||
return True
|
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]]:
|
def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]:
|
||||||
beats = arc.get("beats", [])
|
beats = arc.get("beats", [])
|
||||||
if not isinstance(beats, list):
|
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
|
arc["beats"] = remaining
|
||||||
return arc, matched
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -2,26 +2,115 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from services.llm import send_message, send_message_with_model
|
from services.llm import send_message, send_message_with_model
|
||||||
from services.personas import get_persona
|
from services.personas import get_persona
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
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):
|
Given a roleplay chat excerpt, output ONLY valid JSON (no markdown):
|
||||||
{
|
{
|
||||||
"should_generate": true,
|
"should_generate": true,
|
||||||
"shot_type": "first_person_pov" | "landscape" | "third_person",
|
"shot_type": "first_person_pov" | "landscape" | "third_person",
|
||||||
"action_tags": "booru-style tags for pose/action/expression, e.g. 'sitting, smiling, holding_cup'",
|
"action_tags": "booru-style tags for pose/action/expression",
|
||||||
"environment_tags": "booru-style tags for location/lighting/time, e.g. 'indoors, kitchen, sunlight, daytime'"
|
"environment_tags": "booru-style tags for location/lighting/time"
|
||||||
}
|
}
|
||||||
Rules:
|
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 appearance/character tags — those are provided separately.
|
||||||
- Do NOT include quality tags, model names, style words, 'pov', or category/metadata words.
|
- Do NOT include quality tags, model names, style words, 'pov', or category/metadata words.
|
||||||
- Do NOT invent tags. If unsure — omit.
|
- 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:
|
def extract_image_prompt_tag(text: str) -> str | None:
|
||||||
@@ -56,37 +145,201 @@ def _is_anima() -> bool:
|
|||||||
return bool(SD_UNET) and not SD_CHECKPOINT
|
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():
|
if _is_pony():
|
||||||
quality = "score_9, score_8_up, score_7_up, source_anime, highres"
|
return "score_9, score_8_up, score_7_up, source_anime, highres"
|
||||||
elif _is_anima():
|
if _is_anima():
|
||||||
quality = "masterpiece, best quality, score_7, anime"
|
return "masterpiece, best quality, score_7, anime"
|
||||||
else:
|
return "masterpiece, best quality, highres"
|
||||||
quality = "masterpiece, best quality, highres"
|
|
||||||
|
|
||||||
parts = [quality]
|
|
||||||
|
|
||||||
appearance = (persona or {}).get("appearance_tags", "")
|
def _appearance_for_persona(persona: dict | None) -> str:
|
||||||
if appearance:
|
"""Tag core uses appearance_tags only (prose is for LLM context, not Comfy tag line)."""
|
||||||
parts.append(appearance)
|
return _sanitize_tags_string((persona or {}).get("appearance_tags", ""))
|
||||||
if outfit_tags:
|
|
||||||
parts.append(outfit_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", "")
|
lora = (persona or {}).get("lora_name", "")
|
||||||
weight = (persona or {}).get("lora_weight", 0.8)
|
weight = (persona or {}).get("lora_weight", 0.8)
|
||||||
if lora:
|
if lora:
|
||||||
parts.append(f"<lora:{lora}:{weight}>")
|
parts.append(f"<lora:{lora}:{weight}>")
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_comma_join(parts: list[str]) -> str:
|
||||||
positive = ", ".join(p.strip() for p in parts if p and p.strip())
|
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(", "):
|
for tag in positive.split(", "):
|
||||||
t = tag.strip()
|
t = tag.strip()
|
||||||
if t and t not in seen:
|
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)
|
return ", ".join(deduped)
|
||||||
|
|
||||||
|
|
||||||
async def generate_sd_prompt(
|
def _build_tag_core(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
|
||||||
messages: list,
|
"""Anchor + structure: quality, appearance, outfit, action/env tags, LoRA. No POV prose, no scene_description."""
|
||||||
persona_id: str,
|
parts = [_quality_prefix()]
|
||||||
outfit_json: str = "[]",
|
appearance = _appearance_for_persona(persona)
|
||||||
) -> tuple[str | None, str | None]:
|
if appearance:
|
||||||
persona = await get_persona(persona_id)
|
parts.append(appearance)
|
||||||
# Generate only if persona has appearance tags
|
if outfit_tags:
|
||||||
if not persona or not (persona.get("appearance_tags") or "").strip():
|
parts.append(_sanitize_tags_string(_dedupe_outfit_tags(outfit_tags)))
|
||||||
logger.debug("sd_prompt skip: persona=%s no appearance_tags", persona_id)
|
if scene.get("shot_type") == "landscape":
|
||||||
return None, None
|
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
else:
|
||||||
raw = await send_message(builder_messages)
|
if not _is_anima() and scene.get("shot_type") == "first_person_pov":
|
||||||
raw = raw.strip()
|
parts.append("pov, first-person view, looking at viewer")
|
||||||
if raw.startswith("```"):
|
parts.append(_sanitize_tags_string(scene.get("action_tags", "")))
|
||||||
raw = re.sub(r"^```\w*\n?", "", raw)
|
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
|
||||||
raw = re.sub(r"\n?```$", "", raw)
|
_append_lora(parts, persona)
|
||||||
scene = json.loads(raw)
|
return _dedupe_comma_join(parts)
|
||||||
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
|
|
||||||
|
|
||||||
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 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
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_tokens_for_dedupe(tag_line: str) -> set[str]:
|
||||||
|
tokens: set[str] = set()
|
||||||
|
for part in tag_line.replace("<lora:", " ").split(","):
|
||||||
|
for word in re.split(r"[\s_./]+", part.lower()):
|
||||||
|
w = word.strip()
|
||||||
|
if len(w) >= 4:
|
||||||
|
tokens.add(w)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_redundant_scene_description(desc: str, tag_line: str) -> str:
|
||||||
|
tag_tokens = _tag_tokens_for_dedupe(tag_line)
|
||||||
|
if not tag_tokens or not desc.strip():
|
||||||
|
return desc.strip()
|
||||||
|
|
||||||
|
kept: list[str] = []
|
||||||
|
for sentence in re.split(r"(?<=[.!?])\s+", desc.strip()):
|
||||||
|
s = sentence.strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
words = [w.lower() for w in re.findall(r"[a-zA-Z]{4,}", s)]
|
||||||
|
if not words:
|
||||||
|
kept.append(s)
|
||||||
|
continue
|
||||||
|
overlap = sum(1 for w in words if w in tag_tokens) / len(words)
|
||||||
|
if overlap < 0.62:
|
||||||
|
kept.append(s)
|
||||||
|
|
||||||
|
return " ".join(kept).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_illustrate_content(content: str, max_chars: int = 1400) -> str:
|
||||||
|
"""Long assistant posts (first_mes): use final beat after time-skip, last paragraphs."""
|
||||||
|
text = strip_image_prompt_tag(content).strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
chunks = _TIME_SKIP_RE.split(text)
|
||||||
|
if len(chunks) > 1:
|
||||||
|
text = chunks[-1].strip()
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||||
|
if paragraphs:
|
||||||
|
for n in (1, 2, 3):
|
||||||
|
tail = "\n\n".join(paragraphs[-n:])
|
||||||
|
if len(tail) <= max_chars:
|
||||||
|
return tail
|
||||||
|
return paragraphs[-1][-max_chars:]
|
||||||
|
return text[-max_chars:]
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_mood_prose(scene: dict) -> str:
|
||||||
|
cue = (scene.get("pov_cue") or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||||
|
if cue in _POV_MOOD_FALLBACK:
|
||||||
|
return _POV_MOOD_FALLBACK[cue]
|
||||||
|
inferred = _infer_pov_cue_from_action(scene.get("action_tags", ""))
|
||||||
|
return _POV_MOOD_FALLBACK.get(inferred, "Soft atmosphere; her expression toward the camera.")
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_scene_description(desc: str, max_words: int = 40, max_chars: int = 220) -> str:
|
||||||
|
words = desc.split()
|
||||||
|
if len(words) > max_words:
|
||||||
|
desc = " ".join(words[:max_words])
|
||||||
|
if len(desc) > max_chars:
|
||||||
|
desc = desc[: max_chars - 3] + "..."
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
def build_positive_prompt_hybrid(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
|
||||||
|
"""Production Anima prompt: tag core + POV cue + short mood prose."""
|
||||||
|
if not _is_anima():
|
||||||
|
return build_positive_prompt(scene, persona, outfit_tags)
|
||||||
|
|
||||||
|
base = build_positive_prompt_tags_only(scene, persona, outfit_tags)
|
||||||
|
desc = _trim_redundant_scene_description(
|
||||||
|
(scene.get("scene_description") or "").strip(),
|
||||||
|
base,
|
||||||
|
)
|
||||||
|
desc = _cap_scene_description(desc)
|
||||||
|
if not desc:
|
||||||
|
desc = _cap_scene_description(_fallback_mood_prose(scene))
|
||||||
|
if not desc:
|
||||||
|
return base
|
||||||
|
|
||||||
|
lora = (persona or {}).get("lora_name", "")
|
||||||
|
weight = (persona or {}).get("lora_weight", 0.8)
|
||||||
|
lora_suffix = f" <lora:{lora}:{weight}>" if lora else ""
|
||||||
|
if lora_suffix and base.endswith(lora_suffix):
|
||||||
|
base = base[: -len(lora_suffix)]
|
||||||
|
return f"{base}. {desc}{lora_suffix}"
|
||||||
|
return f"{base}. {desc}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
|
||||||
|
"""Legacy entry: Pony/non-Anima full prompt; Anima delegates to tags-only."""
|
||||||
|
if _is_anima():
|
||||||
|
return build_positive_prompt_tags_only(scene, persona, outfit_tags)
|
||||||
|
|
||||||
|
parts = [_quality_prefix()]
|
||||||
|
appearance = _appearance_for_persona(persona)
|
||||||
|
if appearance:
|
||||||
|
parts.append(appearance)
|
||||||
|
if outfit_tags:
|
||||||
|
parts.append(_sanitize_tags_string(_dedupe_outfit_tags(outfit_tags)))
|
||||||
|
if scene.get("shot_type") == "landscape":
|
||||||
|
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
|
||||||
|
else:
|
||||||
|
if scene.get("shot_type") == "first_person_pov":
|
||||||
|
parts.append("pov, first-person view, looking at viewer")
|
||||||
|
parts.append(_sanitize_tags_string(scene.get("action_tags", "")))
|
||||||
|
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
|
||||||
|
_append_lora(parts, persona)
|
||||||
|
return _dedupe_comma_join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _negative_for_scene(scene: dict) -> str:
|
||||||
if _is_pony():
|
if _is_pony():
|
||||||
negative = PONY_NEGATIVE
|
negative = PONY_NEGATIVE
|
||||||
elif _is_anima():
|
elif _is_anima():
|
||||||
@@ -151,6 +503,237 @@ async def generate_sd_prompt(
|
|||||||
|
|
||||||
if scene.get("shot_type") == "first_person_pov":
|
if scene.get("shot_type") == "first_person_pov":
|
||||||
negative += ", third person, over the shoulder"
|
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 negative
|
||||||
return full, 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -11,7 +12,178 @@ load_dotenv()
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_STEPS = int(os.getenv("SD_STEPS", "28"))
|
||||||
SD_CFG = float(os.getenv("SD_CFG", "7"))
|
SD_CFG = float(os.getenv("SD_CFG", "7"))
|
||||||
SD_SAMPLER = os.getenv("SD_SAMPLER", "euler")
|
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_UNET = os.getenv("SD_UNET", "anima-preview3-base.safetensors")
|
||||||
SD_CLIP = os.getenv("SD_CLIP", "qwen_3_06b_base.safetensors")
|
SD_CLIP = os.getenv("SD_CLIP", "qwen_3_06b_base.safetensors")
|
||||||
SD_VAE = os.getenv("SD_VAE", "qwen_image_vae.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"))
|
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]:
|
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:
|
if "\n\nNegative prompt:" in full_prompt:
|
||||||
pos, _, neg = full_prompt.partition("\n\nNegative prompt:")
|
pos, _, neg = full_prompt.partition("\n\nNegative prompt:")
|
||||||
return pos.strip(), neg.strip()
|
return pos.strip(), neg.strip()
|
||||||
return full_prompt.strip(), SD_DEFAULT_NEGATIVE
|
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)
|
seed = int(uuid.uuid4().int % 2**32)
|
||||||
if _use_anima():
|
o = overrides or {}
|
||||||
return {
|
if _workflow_uses_anima(o):
|
||||||
"44": {"class_type": "UNETLoader", "inputs": {"unet_name": SD_UNET, "weight_dtype": "default"}},
|
unet = o.get("unet") or SD_UNET
|
||||||
"45": {"class_type": "CLIPLoader", "inputs": {"clip_name": SD_CLIP, "type": "stable_diffusion", "device": "default"}},
|
clip = o.get("clip") or SD_CLIP
|
||||||
"15": {"class_type": "VAELoader", "inputs": {"vae_name": SD_VAE}},
|
vae = o.get("vae") or SD_VAE
|
||||||
"28": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
|
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]}},
|
"11": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["45", 0]}},
|
||||||
"12": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["45", 0]}},
|
"12": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["45", 0]}},
|
||||||
"19": {
|
"19": {
|
||||||
@@ -68,9 +260,24 @@ def _build_workflow(positive: str, negative: str) -> dict:
|
|||||||
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["19", 0], "vae": ["15", 0]}},
|
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["19", 0], "vae": ["15", 0]}},
|
||||||
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "chatbot", "images": ["8", 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 {
|
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}},
|
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216, "batch_size": 1}},
|
||||||
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["4", 1]}},
|
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["4", 1]}},
|
||||||
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "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:
|
async def check_sd() -> bool:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
async with _make_comfy_client(timeout=15) as client:
|
||||||
r = await client.get(f"{SD_BASE_URL}/system_stats")
|
await _prime_comfy_gateway(client)
|
||||||
|
r = await _comfy_request(client, "GET", "/system_stats")
|
||||||
return r.status_code == 200
|
return r.status_code == 200
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
neg = negative_prompt or SD_DEFAULT_NEGATIVE
|
||||||
workflow = _build_workflow(prompt, neg)
|
workflow = _build_workflow(prompt, neg, overrides)
|
||||||
client_id = uuid.uuid4().hex
|
client_id = uuid.uuid4().hex
|
||||||
|
|
||||||
logger.info("ComfyUI request → %s prompt: %.120s", SD_BASE_URL, prompt)
|
logger.info("ComfyUI request → %s prompt: %.120s", _log_comfy_target(), prompt)
|
||||||
async with httpx.AsyncClient(timeout=300) as client:
|
async with _make_comfy_client() as client:
|
||||||
resp = await client.post(
|
await _prime_comfy_gateway(client)
|
||||||
f"{SD_BASE_URL}/prompt",
|
resp = await _comfy_request(
|
||||||
|
client,
|
||||||
|
"POST",
|
||||||
|
"/prompt",
|
||||||
json={"prompt": workflow, "client_id": client_id},
|
json={"prompt": workflow, "client_id": client_id},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
@@ -115,7 +376,7 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
|
|||||||
|
|
||||||
for _ in range(300):
|
for _ in range(300):
|
||||||
await asyncio.sleep(1)
|
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()
|
data = hist.json()
|
||||||
if prompt_id in data:
|
if prompt_id in data:
|
||||||
entry = data[prompt_id]
|
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():
|
for node_output in outputs.values():
|
||||||
if "images" in node_output:
|
if "images" in node_output:
|
||||||
img_info = node_output["images"][0]
|
img_info = node_output["images"][0]
|
||||||
img_resp = await client.get(
|
img_resp = await _comfy_request(
|
||||||
f"{SD_BASE_URL}/view",
|
client,
|
||||||
params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""), "type": img_info.get("type", "output")},
|
"GET",
|
||||||
|
"/view",
|
||||||
|
params={
|
||||||
|
"filename": img_info["filename"],
|
||||||
|
"subfolder": img_info.get("subfolder", ""),
|
||||||
|
"type": img_info.get("type", "output"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
img_resp.raise_for_status()
|
img_resp.raise_for_status()
|
||||||
image_bytes = img_resp.content
|
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")
|
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)
|
positive, negative = split_prompt_and_negative(full_prompt)
|
||||||
try:
|
try:
|
||||||
_, rel_path = await txt2img(positive, negative)
|
_, rel_path = await txt2img(positive, negative, overrides=overrides)
|
||||||
return rel_path, None
|
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:
|
except Exception as e:
|
||||||
logger.error("ComfyUI error: %s", e)
|
logger.error("ComfyUI error: %s", e)
|
||||||
return None, str(e)
|
return None, str(e)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 251 KiB |
@@ -156,6 +156,88 @@ header h1 { font-size: 1.1rem; color: #e94560; }
|
|||||||
#systemBlobRefresh.spinning { animation: spin 0.6s linear infinite; }
|
#systemBlobRefresh.spinning { animation: spin 0.6s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.blob-changed { background: rgba(255, 200, 50, 0.15); border-radius: 3px; transition: background 2s ease; }
|
.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 {
|
.system-blob-content {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
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:hover { background: #4a90d9; color: white; }
|
||||||
.translate-btn:disabled { opacity: 0.5; cursor: default; }
|
.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 {
|
.image-generating {
|
||||||
display: flex;
|
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; }
|
.image-error { margin-top: 6px; font-size: 0.75rem; color: #888; }
|
||||||
|
|
||||||
.choice-row {
|
.choice-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.choice-section-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
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 {
|
.choice-btn {
|
||||||
background: #16213e;
|
background: #16213e;
|
||||||
@@ -326,10 +448,20 @@ header h1 { font-size: 1.1rem; color: #e94560; }
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
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 {
|
.choice-btn:hover {
|
||||||
border-color: #e94560;
|
border-color: #e94560;
|
||||||
color: #e94560;
|
color: #e94560;
|
||||||
}
|
}
|
||||||
|
.choice-btn-plot:hover {
|
||||||
|
border-color: #c9a227;
|
||||||
|
color: #f0e0b0;
|
||||||
|
background: rgba(201, 162, 39, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.typing {
|
.typing {
|
||||||
@@ -499,6 +631,14 @@ textarea:focus { border-color: #e94560; }
|
|||||||
.outcome-crit-success .dice-outcome { color: #f1c40f; }
|
.outcome-crit-success .dice-outcome { color: #f1c40f; }
|
||||||
.narrator-text { white-space: pre-wrap; line-height: 1.5; }
|
.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 in header */
|
||||||
.affinity-display {
|
.affinity-display {
|
||||||
font-size: 0.8rem; padding: 4px 10px;
|
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-high { border-color: #e94560; color: #e94560; }
|
||||||
.affinity-display.affinity-low { border-color: #555; color: #666; }
|
.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 in sidebar */
|
||||||
.quest-panel {
|
.quest-panel {
|
||||||
border-top: 1px solid #0f3460;
|
border-top: 1px solid #0f3460;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
flex-shrink: 0;
|
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 {
|
.quest-panel-header {
|
||||||
font-size: 0.75rem; color: #888;
|
font-size: 0.75rem; color: #888;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
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 {
|
.quest-item {
|
||||||
font-size: 0.8rem; padding: 4px 0;
|
font-size: 0.8rem; padding: 4px 6px;
|
||||||
color: #bbb; line-height: 1.4;
|
color: #bbb; line-height: 1.4;
|
||||||
border-bottom: 1px solid #0f3460;
|
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-item:last-child { border-bottom: none; }
|
||||||
.quest-done { color: #555; text-decoration: line-through; }
|
.quest-done { color: #555; text-decoration: line-through; }
|
||||||
@@ -587,6 +843,11 @@ textarea:focus { border-color: #e94560; }
|
|||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
.hint-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
.chat-settings-meta {
|
.chat-settings-meta {
|
||||||
margin-top: 12px; padding: 10px;
|
margin-top: 12px; padding: 10px;
|
||||||
background: #1a1a2e; border-radius: 8px;
|
background: #1a1a2e; border-radius: 8px;
|
||||||
@@ -610,7 +871,9 @@ textarea:focus { border-color: #e94560; }
|
|||||||
outline: none; border-bottom: 1px dashed #e94560;
|
outline: none; border-bottom: 1px dashed #e94560;
|
||||||
}
|
}
|
||||||
.message-actions {
|
.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 {
|
.message-actions button {
|
||||||
background: #0f3460; border: none; border-radius: 6px;
|
background: #0f3460; border: none; border-radius: 6px;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Debug — AI ChatBot</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/debug.css">
|
||||||
|
</head>
|
||||||
|
<body class="debug-page">
|
||||||
|
<header class="debug-header">
|
||||||
|
<a href="/">← Чат</a>
|
||||||
|
<h1>Debug</h1>
|
||||||
|
<button type="button" id="btnReloadConfig">↻ Config</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="debug-tabs" id="debugTabs">
|
||||||
|
<button type="button" class="active" data-tab="config">Config</button>
|
||||||
|
<button type="button" data-tab="sdprompt">SD Prompt</button>
|
||||||
|
<button type="button" data-tab="llm">LLM</button>
|
||||||
|
<button type="button" data-tab="comfy">ComfyUI</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="debug-main">
|
||||||
|
<section class="debug-panel active" id="panel-config">
|
||||||
|
<pre id="configOut" class="debug-out">Загрузка…</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="debug-panel" id="panel-sdprompt">
|
||||||
|
<div class="debug-grid">
|
||||||
|
<label>Персонаж
|
||||||
|
<select id="sdPersona"></select>
|
||||||
|
</label>
|
||||||
|
<label>Outfit JSON
|
||||||
|
<input type="text" id="sdOutfit" value="[]" placeholder='["dress", "barefoot"]'>
|
||||||
|
</label>
|
||||||
|
<label>Appearance override (опц.)
|
||||||
|
<input type="text" id="sdAppearance" placeholder="оставьте пустым — из карточки">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="sdUseProse"> Использовать prose для Anima
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Чат (user: / assistant:)
|
||||||
|
<textarea id="sdChat" rows="8" placeholder="user: привет assistant: *улыбается*"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="debug-btn primary" id="btnSdPrompt">Собрать промпт (SD_PROMPT_MODEL)</button>
|
||||||
|
<div class="debug-split">
|
||||||
|
<div>
|
||||||
|
<h3>Scene JSON</h3>
|
||||||
|
<pre id="sdScene" class="debug-out">—</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Теги / гибрид</h3>
|
||||||
|
<pre id="sdPrompts" class="debug-out">—</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>LLM raw + builder</summary>
|
||||||
|
<pre id="sdLlmRaw" class="debug-out small">—</pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="debug-panel" id="panel-llm">
|
||||||
|
<div class="debug-grid">
|
||||||
|
<label>Model
|
||||||
|
<input type="text" id="llmModel" placeholder="пусто = SD_PROMPT_MODEL / SYSTEM">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>System
|
||||||
|
<textarea id="llmSystem" rows="4"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>User
|
||||||
|
<textarea id="llmUser" rows="6"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="debug-btn primary" id="btnLlm">Отправить</button>
|
||||||
|
<pre id="llmOut" class="debug-out">—</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="debug-panel" id="panel-comfy">
|
||||||
|
<div class="debug-row">
|
||||||
|
<button type="button" class="debug-btn" id="btnComfyPing">Ping /system_stats</button>
|
||||||
|
<button type="button" class="debug-btn" id="btnComfyModels">Загрузить модели (/object_info)</button>
|
||||||
|
</div>
|
||||||
|
<pre id="comfyPingOut" class="debug-out compact">—</pre>
|
||||||
|
|
||||||
|
<h3>Модели в Comfy</h3>
|
||||||
|
<div class="debug-grid" id="comfyModelLists">—</div>
|
||||||
|
|
||||||
|
<h3>Генерация</h3>
|
||||||
|
<div class="debug-grid">
|
||||||
|
<label>UNET <select id="genUnet"><option value="">— env —</option></select></label>
|
||||||
|
<label>CLIP <select id="genClip"><option value="">— env —</option></select></label>
|
||||||
|
<label>VAE <select id="genVae"><option value="">— env —</option></select></label>
|
||||||
|
<label>Checkpoint <select id="genCkpt"><option value="">— env / Anima —</option></select></label>
|
||||||
|
</div>
|
||||||
|
<label>Positive
|
||||||
|
<textarea id="genPositive" rows="4"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Negative
|
||||||
|
<textarea id="genNegative" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="debug-btn primary" id="btnComfyGen">Сгенерировать</button>
|
||||||
|
<div id="comfyImgWrap" class="debug-img-wrap hidden">
|
||||||
|
<img id="comfyImg" alt="result">
|
||||||
|
</div>
|
||||||
|
<pre id="comfyGenOut" class="debug-out compact">—</pre>
|
||||||
|
|
||||||
|
<h3>Raw API</h3>
|
||||||
|
<div class="debug-grid">
|
||||||
|
<label>Method
|
||||||
|
<select id="rawMethod">
|
||||||
|
<option>GET</option>
|
||||||
|
<option>POST</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Path
|
||||||
|
<input type="text" id="rawPath" value="/system_stats">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Query JSON
|
||||||
|
<textarea id="rawParams" rows="2">{}</textarea>
|
||||||
|
</label>
|
||||||
|
<label>Body JSON (POST)
|
||||||
|
<textarea id="rawBody" rows="4" placeholder="{}"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="debug-btn" id="btnComfyRaw">Выполнить</button>
|
||||||
|
<pre id="comfyRawOut" class="debug-out">—</pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/static/js/debug.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
<h1>🤖 AI Chat</h1>
|
<h1>🤖 AI Chat</h1>
|
||||||
<span class="header-title" id="headerTitle">Новый чат</span>
|
<span class="header-title" id="headerTitle">Новый чат</span>
|
||||||
<span id="rpgBadge" class="rpg-badge hidden" title="RPG режим">RPG</span>
|
<span id="rpgBadge" class="rpg-badge hidden" title="RPG режим">RPG</span>
|
||||||
|
<a href="/debug" class="header-icon-btn" title="Debug" style="text-decoration:none">🛠</a>
|
||||||
<button id="chatSettingsBtn" type="button" class="header-icon-btn" title="Настройки чата">⚙️</button>
|
<button id="chatSettingsBtn" type="button" class="header-icon-btn" title="Настройки чата">⚙️</button>
|
||||||
<span id="affinityDisplay" class="affinity-display hidden"></span>
|
<span id="affinityDisplay" class="affinity-display hidden" title="Симпатия к игроку"></span>
|
||||||
|
<span id="statsDisplay" class="stats-display hidden" title="Шкалы lust / stamina / tension"></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
@@ -26,7 +28,12 @@
|
|||||||
<div class="session-list" id="sessionList"></div>
|
<div class="session-list" id="sessionList"></div>
|
||||||
<div class="quest-panel hidden" id="questPanel">
|
<div class="quest-panel hidden" id="questPanel">
|
||||||
<div class="quest-panel-header">Квесты</div>
|
<div class="quest-panel-header">Квесты</div>
|
||||||
|
<p class="quest-panel-hint" id="questPanelHint">Клик по 🔸 — выбор, затем кнопка ниже</p>
|
||||||
<div id="questList"></div>
|
<div id="questList"></div>
|
||||||
|
<div class="quest-panel-actions" id="questPanelActions">
|
||||||
|
<button type="button" id="questBtnDone" class="quest-btn-done" disabled>✓ Готово</button>
|
||||||
|
<button type="button" id="questBtnFail" class="quest-btn-fail" disabled>✗ Провал</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -36,6 +43,7 @@
|
|||||||
<div class="system-blob-header">
|
<div class="system-blob-header">
|
||||||
<span>System</span>
|
<span>System</span>
|
||||||
<button type="button" id="systemBlobRefresh" title="Обновить">↻</button>
|
<button type="button" id="systemBlobRefresh" title="Обновить">↻</button>
|
||||||
|
<button type="button" id="contextEditorOpen" title="Редактировать контекст">✎</button>
|
||||||
<button type="button" id="systemBlobToggle">Скрыть</button>
|
<button type="button" id="systemBlobToggle">Скрыть</button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="system-blob-content" id="systemBlobContent">—</pre>
|
<pre class="system-blob-content" id="systemBlobContent">—</pre>
|
||||||
@@ -289,6 +297,7 @@
|
|||||||
<label><input type="checkbox" id="ncSettingNarrator" checked> 📖 Нарратор</label>
|
<label><input type="checkbox" id="ncSettingNarrator" checked> 📖 Нарратор</label>
|
||||||
<label><input type="checkbox" id="ncSettingQuests" checked> 📜 Квесты</label>
|
<label><input type="checkbox" id="ncSettingQuests" checked> 📜 Квесты</label>
|
||||||
<label><input type="checkbox" id="ncSettingAffinity" checked> 💖 Симпатия</label>
|
<label><input type="checkbox" id="ncSettingAffinity" checked> 💖 Симпатия</label>
|
||||||
|
<label><input type="checkbox" id="ncSettingStats"> 📊 Шкалы (lust/stamina/tension)</label>
|
||||||
<label><input type="checkbox" id="ncSettingChoices" checked> 🔘 Кнопки выбора</label>
|
<label><input type="checkbox" id="ncSettingChoices" checked> 🔘 Кнопки выбора</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +323,9 @@
|
|||||||
<label>Название чата
|
<label>Название чата
|
||||||
<input type="text" id="chatSettingsTitle">
|
<input type="text" id="chatSettingsTitle">
|
||||||
</label>
|
</label>
|
||||||
|
<p class="wizard-page-title">Персонаж чата</p>
|
||||||
|
<p class="hint-text">Смена персонажа перепривязывает этот чат. Историю можно сохранить или очистить.</p>
|
||||||
|
<div class="persona-pick-grid" id="chatSettingsPersonaGrid"></div>
|
||||||
<label class="rpg-mode-option">
|
<label class="rpg-mode-option">
|
||||||
<input type="checkbox" id="chatSettingsRpg"> RPG режим
|
<input type="checkbox" id="chatSettingsRpg"> RPG режим
|
||||||
</label>
|
</label>
|
||||||
@@ -334,9 +346,30 @@
|
|||||||
<label><input type="checkbox" id="csSettingNarrator"> 📖 Нарратор</label>
|
<label><input type="checkbox" id="csSettingNarrator"> 📖 Нарратор</label>
|
||||||
<label><input type="checkbox" id="csSettingQuests"> 📜 Квесты</label>
|
<label><input type="checkbox" id="csSettingQuests"> 📜 Квесты</label>
|
||||||
<label><input type="checkbox" id="csSettingAffinity"> 💖 Симпатия</label>
|
<label><input type="checkbox" id="csSettingAffinity"> 💖 Симпатия</label>
|
||||||
|
<label><input type="checkbox" id="csSettingStats"> 📊 Шкалы (lust/stamina/tension)</label>
|
||||||
<label><input type="checkbox" id="csSettingChoices"> 🔘 Кнопки выбора</label>
|
<label><input type="checkbox" id="csSettingChoices"> 🔘 Кнопки выбора</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-settings-meta" id="chatSettingsMeta"></div>
|
<div class="chat-settings-meta" id="chatSettingsMeta"></div>
|
||||||
|
<details class="rpg-debug-panel" id="chatSettingsRpgDebug">
|
||||||
|
<summary>🧪 Отладка: симпатия и шкалы</summary>
|
||||||
|
<p class="hint-text">Жёстко задаёт состояние для следующих реплик. Цель — текущий игрок (позже: пары персонаж×игрок).</p>
|
||||||
|
<div class="rpg-debug-grid">
|
||||||
|
<label>💖 Симпатия (−30…30)
|
||||||
|
<input type="number" id="debugAffinity" min="-30" max="30" step="1">
|
||||||
|
</label>
|
||||||
|
<label>🔥 Lust (0–10)
|
||||||
|
<input type="number" id="debugLust" min="0" max="10" step="1">
|
||||||
|
</label>
|
||||||
|
<label>⚡ Stamina (0–10)
|
||||||
|
<input type="number" id="debugStamina" min="0" max="10" step="1">
|
||||||
|
</label>
|
||||||
|
<label>😰 Tension (0–10)
|
||||||
|
<input type="number" id="debugTension" min="0" max="10" step="1">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="debugRpgStateApply" class="rpg-debug-apply">Применить к сессии</button>
|
||||||
|
<span class="rpg-debug-status" id="debugRpgStateStatus"></span>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-buttons modal-wizard-footer">
|
<div class="modal-buttons modal-wizard-footer">
|
||||||
@@ -346,6 +379,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/static/js/app.js?v=4"></script>
|
<div class="modal-overlay" id="contextEditorModal">
|
||||||
|
<div class="modal modal-wizard context-editor-modal">
|
||||||
|
<div class="modal-wizard-header">
|
||||||
|
<h2>✎ Контекст сессии</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-wizard-body context-editor-body">
|
||||||
|
<p class="hint-text">Правки применяются сразу к следующим сообщениям и генерации картинок. JSON-поля — массив или объект.</p>
|
||||||
|
<label>Status quo
|
||||||
|
<textarea id="ctxStatusQuo" rows="3" spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Global plot
|
||||||
|
<textarea id="ctxGlobalPlot" rows="2" spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Outfit (JSON array, danbooru-теги с цветом)
|
||||||
|
<textarea id="ctxOutfit" rows="4" spellcheck="false" placeholder='["black_sports_shorts", "white_torn_tank_top"]'></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Scene (JSON)
|
||||||
|
<textarea id="ctxScene" rows="4" spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Facts (JSON array)
|
||||||
|
<textarea id="ctxFacts" rows="3" spellcheck="false" placeholder='[{"text":"...", "rp_day":"день 1"}]'></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Plot arc (JSON)
|
||||||
|
<textarea id="ctxPlotArc" rows="6" spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="context-editor-stats">
|
||||||
|
<label>💖 Affinity
|
||||||
|
<input type="number" id="ctxAffinity" min="-30" max="30" step="1">
|
||||||
|
</label>
|
||||||
|
<label>🔥 Lust
|
||||||
|
<input type="number" id="ctxLust" min="0" max="10" step="1">
|
||||||
|
</label>
|
||||||
|
<label>⚡ Stamina
|
||||||
|
<input type="number" id="ctxStamina" min="0" max="10" step="1">
|
||||||
|
</label>
|
||||||
|
<label>😰 Tension
|
||||||
|
<input type="number" id="ctxTension" min="0" max="10" step="1">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="rpg-debug-status" id="contextEditorStatus"></span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-buttons modal-wizard-footer">
|
||||||
|
<button id="contextEditorCancel" type="button">Отмена</button>
|
||||||
|
<button id="contextEditorSave" type="button" style="background:#9b7fd4;color:white">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/static/js/app.js?v=17"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { toggleSidebar, dom } from './state.js';
|
|||||||
import { initSessions } from './sessions.js';
|
import { initSessions } from './sessions.js';
|
||||||
import { openNewChatWizard, initNewChatWizard } from './newChatWizard.js';
|
import { openNewChatWizard, initNewChatWizard } from './newChatWizard.js';
|
||||||
import { openChatSettings, initChatSettings } from './chatSettings.js';
|
import { openChatSettings, initChatSettings } from './chatSettings.js';
|
||||||
|
import { initContextEditor } from './contextEditor.js';
|
||||||
import { loadPersonas, initPersonaModals } from './personas.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', () => {
|
document.getElementById('sidebarToggle').addEventListener('click', () => {
|
||||||
const open = toggleSidebar();
|
const open = toggleSidebar();
|
||||||
@@ -36,5 +37,7 @@ dom.systemBlobToggle?.addEventListener('click', () => {
|
|||||||
initPersonaModals();
|
initPersonaModals();
|
||||||
initNewChatWizard();
|
initNewChatWizard();
|
||||||
initChatSettings();
|
initChatSettings();
|
||||||
|
initContextEditor();
|
||||||
|
initQuestPanel();
|
||||||
await initSessions();
|
await initSessions();
|
||||||
loadPersonas();
|
loadPersonas();
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { sessionId, currentPersona, dom } from './state.js';
|
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 = {}) {
|
export async function initChat(options = {}) {
|
||||||
if (!sessionId || !currentPersona) return;
|
if (!sessionId) return;
|
||||||
const payload = { message: '', session_id: sessionId, persona_id: currentPersona };
|
const payload = { message: '', session_id: sessionId };
|
||||||
if (options.first_mes_override?.trim()) payload.first_mes_override = options.first_mes_override.trim();
|
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) });
|
const res = await fetch('/chat/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -16,19 +26,22 @@ export function updateEmptyState() {
|
|||||||
dom.emptyState?.classList.toggle('hidden', !!hasMessages);
|
dom.emptyState?.classList.toggle('hidden', !!hasMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImagePromptBlock(promptText) {
|
function createImagePromptBlockSingle(label, promptText) {
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
block.className = 'image-prompt-block';
|
block.className = 'image-prompt-block';
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'image-prompt-header';
|
header.className = 'image-prompt-header';
|
||||||
header.innerHTML = '<span>🎨 SD prompt</span>';
|
header.innerHTML = `<span>🎨 ${label}</span>`;
|
||||||
|
|
||||||
const copyBtn = document.createElement('button');
|
const copyBtn = document.createElement('button');
|
||||||
copyBtn.type = 'button';
|
copyBtn.type = 'button';
|
||||||
copyBtn.className = 'copy-prompt-btn';
|
copyBtn.className = 'copy-prompt-btn';
|
||||||
copyBtn.textContent = 'Копировать';
|
copyBtn.textContent = 'Копировать';
|
||||||
copyBtn.addEventListener('click', async () => {
|
copyBtn.addEventListener('click', async (e) => {
|
||||||
const ok = await copyToClipboard(promptText);
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const full = textEl.textContent?.trim() || promptText || '';
|
||||||
|
const ok = await copyToClipboard(splitSdPromptForCopy(full));
|
||||||
copyBtn.textContent = ok ? 'Скопировано' : 'Ошибка';
|
copyBtn.textContent = ok ? 'Скопировано' : 'Ошибка';
|
||||||
setTimeout(() => { copyBtn.textContent = 'Копировать'; }, 1500);
|
setTimeout(() => { copyBtn.textContent = 'Копировать'; }, 1500);
|
||||||
});
|
});
|
||||||
@@ -39,11 +52,10 @@ export function createImagePromptBlock(promptText) {
|
|||||||
regenBtn.className = 'copy-prompt-btn';
|
regenBtn.className = 'copy-prompt-btn';
|
||||||
regenBtn.textContent = '🖼 Перегенерировать';
|
regenBtn.textContent = '🖼 Перегенерировать';
|
||||||
regenBtn.addEventListener('click', async () => {
|
regenBtn.addEventListener('click', async () => {
|
||||||
const wrapper = block.parentElement;
|
const wrapper = block.closest('.message');
|
||||||
regenBtn.disabled = true;
|
regenBtn.disabled = true;
|
||||||
regenBtn.textContent = '⏳…';
|
regenBtn.textContent = '⏳…';
|
||||||
wrapper?.querySelector('.chat-image')?.remove();
|
wrapper?.querySelectorAll('.chat-image-wrap, .chat-image, .image-error').forEach(el => el.remove());
|
||||||
wrapper?.querySelector('.image-error')?.remove();
|
|
||||||
showImageGenerating(wrapper);
|
showImageGenerating(wrapper);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/images/generate', {
|
const res = await fetch('/images/generate', {
|
||||||
@@ -76,6 +88,26 @@ export function createImagePromptBlock(promptText) {
|
|||||||
return block;
|
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 = {
|
const OUTCOME_CLASS = {
|
||||||
'critical failure': 'outcome-crit-fail',
|
'critical failure': 'outcome-crit-fail',
|
||||||
'failure': 'outcome-fail',
|
'failure': 'outcome-fail',
|
||||||
@@ -113,26 +145,132 @@ function renderNarratorMessage(narrator) {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChoices(wrapper, choices) {
|
export function removeChoiceRows(wrapper) {
|
||||||
if (!choices?.length) return;
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'choice-row';
|
row.className = 'choice-row';
|
||||||
for (const c of choices) {
|
|
||||||
|
const appendBtn = (container, c) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'choice-btn';
|
btn.className = c.source === 'plot_beat' ? 'choice-btn choice-btn-plot' : 'choice-btn';
|
||||||
btn.textContent = c.label;
|
const label = c.label || '';
|
||||||
btn.addEventListener('click', () => sendMessage(c.label, true));
|
btn.textContent = label;
|
||||||
row.appendChild(btn);
|
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);
|
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) {
|
function renderDebugBlocks(wrapper, blocks) {
|
||||||
if (!blocks?.length) return;
|
if (!blocks?.length) return;
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
if (!b?.text) continue;
|
if (!b?.text) continue;
|
||||||
if (b.type === 'narrator_injection') {
|
if (b.type === 'narrator_injection' || b.type === 'status_quo') {
|
||||||
const w = document.createElement('div');
|
const w = document.createElement('div');
|
||||||
w.className = 'message narrator';
|
w.className = 'message narrator';
|
||||||
const lbl = document.createElement('div');
|
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) {
|
export function updateQuestPanel(quests) {
|
||||||
const list = document.getElementById('questList');
|
const list = document.getElementById('questList');
|
||||||
|
const actions = document.getElementById('questPanelActions');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
_questsCache = quests || [];
|
||||||
list.innerHTML = '';
|
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');
|
const el = document.createElement('div');
|
||||||
el.className = `quest-item quest-${q.status}`;
|
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;
|
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);
|
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) {
|
export function updateAffinityDisplay(affinity) {
|
||||||
@@ -169,12 +397,38 @@ export function updateAffinityDisplay(affinity) {
|
|||||||
el.className = `affinity-display ${affinity > 5 ? 'affinity-high' : affinity < -3 ? 'affinity-low' : ''}`;
|
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;
|
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');
|
const img = document.createElement('img');
|
||||||
img.className = 'chat-image';
|
img.className = 'chat-image';
|
||||||
img.src = imagePath;
|
img.src = imagePath;
|
||||||
wrapper.appendChild(img);
|
figure.appendChild(img);
|
||||||
|
wrapper.appendChild(figure);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showImageGenerating(wrapper) {
|
export function showImageGenerating(wrapper) {
|
||||||
@@ -194,6 +448,7 @@ export function removeImageGenerating(wrapper) {
|
|||||||
function attachMessageActions(wrapper, messageId, role) {
|
function attachMessageActions(wrapper, messageId, role) {
|
||||||
if (!messageId) return;
|
if (!messageId) return;
|
||||||
wrapper.dataset.messageId = String(messageId);
|
wrapper.dataset.messageId = String(messageId);
|
||||||
|
wrapper.querySelector('.message-actions')?.remove();
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'message-actions';
|
actions.className = 'message-actions';
|
||||||
const editBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
@@ -216,7 +471,28 @@ function attachMessageActions(wrapper, messageId, role) {
|
|||||||
branchBtn.title = 'Ветка отсюда';
|
branchBtn.title = 'Ветка отсюда';
|
||||||
branchBtn.addEventListener('click', () => forkFromMessage(messageId));
|
branchBtn.addEventListener('click', () => forkFromMessage(messageId));
|
||||||
actions.appendChild(branchBtn);
|
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);
|
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) {
|
async function startEditMessage(wrapper, messageId) {
|
||||||
@@ -262,7 +538,7 @@ async function regenerateMessage(messageId, wrapper) {
|
|||||||
const res = await fetch('/chat/regenerate', {
|
const res = await fetch('/chat/regenerate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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);
|
if (!res.ok) throw new Error('Ошибка: ' + res.status);
|
||||||
removeTyping();
|
removeTyping();
|
||||||
@@ -297,14 +573,25 @@ export async function reloadChatFromServer(id) {
|
|||||||
const messages = await histRes.json();
|
const messages = await histRes.json();
|
||||||
clearMessages();
|
clearMessages();
|
||||||
messages.filter(m => m.role !== 'system').forEach(m => {
|
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(
|
addMessage(
|
||||||
m.role === 'user' ? 'user' : 'assistant',
|
m.role === 'user' ? 'user' : 'assistant',
|
||||||
m.content,
|
m.content,
|
||||||
m.image_prompt,
|
m.image_prompt,
|
||||||
m.image_path ? `/static/${m.image_path}` : null,
|
m.image_path ? `/static/${m.image_path}` : null,
|
||||||
m.id,
|
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;
|
const IMAGE_PROMPT_RE = /\[IMAGE_PROMPT:.*?\]/gs;
|
||||||
@@ -330,6 +617,22 @@ async function consumeStream(res) {
|
|||||||
// Narrator arrives BEFORE chunks — render immediately
|
// Narrator arrives BEFORE chunks — render immediately
|
||||||
if (data.narrator) {
|
if (data.narrator) {
|
||||||
renderNarratorMessage(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) {
|
if (data.chunk !== undefined) {
|
||||||
@@ -344,8 +647,12 @@ async function consumeStream(res) {
|
|||||||
if (data.image_generating && bubble) {
|
if (data.image_generating && bubble) {
|
||||||
bubble.classList.remove('typing-active');
|
bubble.classList.remove('typing-active');
|
||||||
const wrapper = bubble.parentElement;
|
const wrapper = bubble.parentElement;
|
||||||
if (data.image_prompt && !wrapper.querySelector('.image-prompt-block')) {
|
if (data.image_prompt) {
|
||||||
wrapper.appendChild(createImagePromptBlock(data.image_prompt));
|
ensureImagePromptBlocks(
|
||||||
|
wrapper,
|
||||||
|
data.image_prompt,
|
||||||
|
data.image_prompt_alt || null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
showImageGenerating(wrapper);
|
showImageGenerating(wrapper);
|
||||||
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
|
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
|
||||||
@@ -356,19 +663,24 @@ async function consumeStream(res) {
|
|||||||
removeImageGenerating(wrapper);
|
removeImageGenerating(wrapper);
|
||||||
bubble?.classList.remove('typing-active');
|
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) {
|
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')) {
|
if (data.image_prompt && wrapper) {
|
||||||
wrapper.appendChild(createImagePromptBlock(data.image_prompt));
|
ensureImagePromptBlocks(
|
||||||
|
wrapper,
|
||||||
|
data.image_prompt,
|
||||||
|
data.image_prompt_alt || null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (data.image_path && wrapper) {
|
if (data.image_path && wrapper) {
|
||||||
console.log('[image] appending', data.image_path, 'to', wrapper);
|
appendChatImage(wrapper, data.image_path, '');
|
||||||
appendChatImage(wrapper, data.image_path);
|
|
||||||
} else {
|
|
||||||
console.log('[image] skip: image_path=', data.image_path, 'wrapper=', wrapper);
|
|
||||||
}
|
}
|
||||||
if (data.image_error && wrapper) {
|
if (data.image_error && wrapper) {
|
||||||
const err = document.createElement('div');
|
const err = document.createElement('div');
|
||||||
@@ -376,11 +688,25 @@ async function consumeStream(res) {
|
|||||||
err.textContent = '🖼 ' + data.image_error;
|
err.textContent = '🖼 ' + data.image_error;
|
||||||
wrapper.appendChild(err);
|
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.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.affinity !== undefined) updateAffinityDisplay(data.affinity);
|
||||||
|
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
|
||||||
if (data.quests?.length) updateQuestPanel(data.quests);
|
if (data.quests?.length) updateQuestPanel(data.quests);
|
||||||
|
|
||||||
|
_pendingUserBubble = null;
|
||||||
|
|
||||||
const { loadSessions } = await import('./sessions.js');
|
const { loadSessions } = await import('./sessions.js');
|
||||||
loadSessions();
|
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();
|
updateEmptyState();
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = `message ${role}`;
|
wrapper.className = `message ${role}`;
|
||||||
@@ -407,8 +742,18 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
|
|||||||
|
|
||||||
const bubble = document.createElement('div');
|
const bubble = document.createElement('div');
|
||||||
bubble.className = 'bubble';
|
bubble.className = 'bubble';
|
||||||
bubble.textContent = displayContent;
|
initBubbleContent(bubble, displayContent, {
|
||||||
|
formatted: !!displayContent,
|
||||||
|
actionResolution: role === 'user' ? actionResolution : null,
|
||||||
|
});
|
||||||
wrapper.appendChild(bubble);
|
wrapper.appendChild(bubble);
|
||||||
|
if (role === 'user' && actionResolution?.resolution_text) {
|
||||||
|
wrapper.classList.add('has-dice-override');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayContent) {
|
||||||
|
attachFormatToggle(wrapper, bubble);
|
||||||
|
}
|
||||||
|
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
const translateBtn = document.createElement('button');
|
const translateBtn = document.createElement('button');
|
||||||
@@ -418,12 +763,12 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
|
|||||||
let originalText = null;
|
let originalText = null;
|
||||||
translateBtn.addEventListener('click', async () => {
|
translateBtn.addEventListener('click', async () => {
|
||||||
if (originalText !== null) {
|
if (originalText !== null) {
|
||||||
bubble.textContent = originalText;
|
initBubbleContent(bubble, originalText, { formatted: true });
|
||||||
originalText = null;
|
originalText = null;
|
||||||
translateBtn.textContent = '🌐 RU';
|
translateBtn.textContent = '🌐 RU';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
originalText = bubble.textContent;
|
originalText = bubble.dataset.raw ?? bubble.textContent;
|
||||||
translateBtn.disabled = true;
|
translateBtn.disabled = true;
|
||||||
translateBtn.textContent = '…';
|
translateBtn.textContent = '…';
|
||||||
try {
|
try {
|
||||||
@@ -434,7 +779,8 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.statusText);
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
bubble.textContent = data.translated;
|
initBubbleContent(bubble, data.translated, { formatted: true });
|
||||||
|
bubble.dataset.raw = data.translated;
|
||||||
translateBtn.textContent = '↩ Оригинал';
|
translateBtn.textContent = '↩ Оригинал';
|
||||||
} catch {
|
} catch {
|
||||||
originalText = null;
|
originalText = null;
|
||||||
@@ -446,8 +792,9 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
|
|||||||
wrapper.appendChild(translateBtn);
|
wrapper.appendChild(translateBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt));
|
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt, imagePromptAlt));
|
||||||
if (imagePath) appendChatImage(wrapper, imagePath);
|
if (imagePath) appendChatImage(wrapper, imagePath, imagePathAlt ? 'Теги' : '');
|
||||||
|
if (imagePathAlt) appendChatImage(wrapper, imagePathAlt, 'Гибрид');
|
||||||
attachMessageActions(wrapper, messageId, role);
|
attachMessageActions(wrapper, messageId, role);
|
||||||
dom.messagesEl.appendChild(wrapper);
|
dom.messagesEl.appendChild(wrapper);
|
||||||
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
|
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
|
||||||
@@ -481,13 +828,15 @@ export async function sendMessage(text, isNarratorChoice = false) {
|
|||||||
dom.inputEl.value = '';
|
dom.inputEl.value = '';
|
||||||
dom.inputEl.style.height = 'auto';
|
dom.inputEl.style.height = 'auto';
|
||||||
dom.sendBtn.disabled = true;
|
dom.sendBtn.disabled = true;
|
||||||
addMessage('user', isNarratorChoice ? `[${text}]` : text);
|
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text;
|
||||||
|
dom.messagesEl.querySelectorAll('.message.assistant').forEach(w => removeChoiceRows(w));
|
||||||
|
_pendingUserBubble = addMessage('user', userContent);
|
||||||
showTyping();
|
showTyping();
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/chat/stream', {
|
const res = await fetch('/chat/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message: text, session_id: sessionId, persona_id: currentPersona, is_narrator_choice: isNarratorChoice }),
|
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
|
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
|
||||||
removeTyping();
|
removeTyping();
|
||||||
|
|||||||
@@ -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 { GENRE_LABELS, bindGenreGrid, resetGenreGrid } from './utils.js';
|
||||||
|
import { personaIndex } from './personas.js';
|
||||||
|
|
||||||
const chatSettingsGenres = new Set();
|
const chatSettingsGenres = new Set();
|
||||||
|
let chatSettingsPersonaId = 'default';
|
||||||
|
let chatSettingsInitialPersonaId = 'default';
|
||||||
|
|
||||||
function updateChatSettingsGenresLabel() {
|
function updateChatSettingsGenresLabel() {
|
||||||
const el = document.getElementById('chatSettingsGenresLabel');
|
const el = document.getElementById('chatSettingsGenresLabel');
|
||||||
@@ -15,11 +18,32 @@ function updateChatSettingsGenresLabel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillChatSettingsPersonaGrid() {
|
||||||
|
const grid = document.getElementById('chatSettingsPersonaGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.innerHTML = '';
|
||||||
|
for (const p of personaIndex.values()) {
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.type = 'button';
|
||||||
|
card.className = 'persona-pick-card' + (p.persona_id === chatSettingsPersonaId ? ' selected' : '');
|
||||||
|
card.dataset.id = p.persona_id;
|
||||||
|
card.innerHTML = `<span class="emoji">${p.emoji || '🤖'}</span>${p.name}`;
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
chatSettingsPersonaId = p.persona_id;
|
||||||
|
grid.querySelectorAll('.persona-pick-card').forEach(c => {
|
||||||
|
c.classList.toggle('selected', c.dataset.id === chatSettingsPersonaId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadRpgSettingsToDom(prefix, settings) {
|
function loadRpgSettingsToDom(prefix, settings) {
|
||||||
document.getElementById(`${prefix}SettingDice`).checked = settings.dice !== false;
|
document.getElementById(`${prefix}SettingDice`).checked = settings.dice !== false;
|
||||||
document.getElementById(`${prefix}SettingNarrator`).checked = settings.narrator !== false;
|
document.getElementById(`${prefix}SettingNarrator`).checked = settings.narrator !== false;
|
||||||
document.getElementById(`${prefix}SettingQuests`).checked = settings.quests !== false;
|
document.getElementById(`${prefix}SettingQuests`).checked = settings.quests !== false;
|
||||||
document.getElementById(`${prefix}SettingAffinity`).checked = settings.affinity !== 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;
|
document.getElementById(`${prefix}SettingChoices`).checked = settings.choices !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +53,7 @@ function readRpgSettingsFromDom(prefix) {
|
|||||||
narrator: document.getElementById(`${prefix}SettingNarrator`)?.checked ?? true,
|
narrator: document.getElementById(`${prefix}SettingNarrator`)?.checked ?? true,
|
||||||
quests: document.getElementById(`${prefix}SettingQuests`)?.checked ?? true,
|
quests: document.getElementById(`${prefix}SettingQuests`)?.checked ?? true,
|
||||||
affinity: document.getElementById(`${prefix}SettingAffinity`)?.checked ?? true,
|
affinity: document.getElementById(`${prefix}SettingAffinity`)?.checked ?? true,
|
||||||
|
stats: document.getElementById(`${prefix}SettingStats`)?.checked ?? false,
|
||||||
choices: document.getElementById(`${prefix}SettingChoices`)?.checked ?? true,
|
choices: document.getElementById(`${prefix}SettingChoices`)?.checked ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -51,6 +76,10 @@ async function bootstrapRpg(sid, personaId, genreValue, settings) {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
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.quests) updateQuestPanel(data.quests);
|
||||||
if (data.plot_arc) {
|
if (data.plot_arc) {
|
||||||
const title = data.plot_arc.title || '';
|
const title = data.plot_arc.title || '';
|
||||||
@@ -67,6 +96,10 @@ export async function openChatSettings() {
|
|||||||
const s = await res.json();
|
const s = await res.json();
|
||||||
|
|
||||||
document.getElementById('chatSettingsTitle').value = s.title || '';
|
document.getElementById('chatSettingsTitle').value = s.title || '';
|
||||||
|
chatSettingsPersonaId = s.persona_id || 'default';
|
||||||
|
chatSettingsInitialPersonaId = chatSettingsPersonaId;
|
||||||
|
fillChatSettingsPersonaGrid();
|
||||||
|
|
||||||
const rpgOn = !!s.rpg_enabled;
|
const rpgOn = !!s.rpg_enabled;
|
||||||
document.getElementById('chatSettingsRpg').checked = rpgOn;
|
document.getElementById('chatSettingsRpg').checked = rpgOn;
|
||||||
document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !rpgOn);
|
document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !rpgOn);
|
||||||
@@ -91,12 +124,33 @@ export async function openChatSettings() {
|
|||||||
const arc = JSON.parse(s.plot_arc_json || '{}');
|
const arc = JSON.parse(s.plot_arc_json || '{}');
|
||||||
phase = arc.phase || '';
|
phase = arc.phase || '';
|
||||||
} catch { /* ignore */ }
|
} 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 = [
|
document.getElementById('chatSettingsMeta').innerHTML = [
|
||||||
`Симпатия: ${s.affinity ?? 0}`,
|
`Симпатия: ${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(' + ')}` : '',
|
s.genre ? `Жанр: ${(s.genre || '').split(',').map(g => GENRE_LABELS[g.trim()] || g).join(' + ')}` : '',
|
||||||
phase ? `Фаза арки: ${phase}` : '',
|
phase ? `Фаза арки: ${phase}` : '',
|
||||||
].filter(Boolean).join('<br>');
|
].filter(Boolean).join('<br>');
|
||||||
|
|
||||||
|
const dbg = document.getElementById('chatSettingsRpgDebug');
|
||||||
|
if (dbg) dbg.open = rpgOn;
|
||||||
|
const affIn = document.getElementById('debugAffinity');
|
||||||
|
const lustIn = document.getElementById('debugLust');
|
||||||
|
const stamIn = document.getElementById('debugStamina');
|
||||||
|
const tensIn = document.getElementById('debugTension');
|
||||||
|
if (affIn) affIn.value = String(s.affinity ?? 0);
|
||||||
|
if (lustIn) lustIn.value = String(stats.lust ?? 0);
|
||||||
|
if (stamIn) stamIn.value = String(stats.stamina ?? 10);
|
||||||
|
if (tensIn) tensIn.value = String(stats.tension ?? 0);
|
||||||
|
const st = document.getElementById('debugRpgStateStatus');
|
||||||
|
if (st) st.textContent = '';
|
||||||
|
|
||||||
document.getElementById('chatSettingsModal').classList.add('open');
|
document.getElementById('chatSettingsModal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +169,94 @@ export function initChatSettings() {
|
|||||||
document.getElementById('chatSettingsModal').classList.remove('open');
|
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 () => {
|
document.getElementById('chatSettingsSave')?.addEventListener('click', async () => {
|
||||||
if (!sessionId) return;
|
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 title = document.getElementById('chatSettingsTitle').value.trim();
|
||||||
const rpgOn = document.getElementById('chatSettingsRpg').checked;
|
const rpgOn = document.getElementById('chatSettingsRpg').checked;
|
||||||
const genreValue = [...chatSettingsGenres].join(',') || 'adventure';
|
const genreValue = [...chatSettingsGenres].join(',') || 'adventure';
|
||||||
const settings = readRpgSettingsFromDom('cs');
|
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}`, {
|
await fetch(`/sessions/${sessionId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -141,7 +274,7 @@ export function initChatSettings() {
|
|||||||
let arc = {};
|
let arc = {};
|
||||||
try { arc = JSON.parse(s.plot_arc_json || '{}'); } catch { /* ignore */ }
|
try { arc = JSON.parse(s.plot_arc_json || '{}'); } catch { /* ignore */ }
|
||||||
if (!arc || !Object.keys(arc).length) {
|
if (!arc || !Object.keys(arc).length) {
|
||||||
await bootstrapRpg(sessionId, currentPersona, genreValue, settings);
|
await bootstrapRpg(sessionId, chatSettingsPersonaId, genreValue, settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function fmt(obj) {
|
||||||
|
return typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = data?.detail || text || res.statusText;
|
||||||
|
throw new Error(`${res.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
const tabs = document.querySelectorAll('#debugTabs button');
|
||||||
|
tabs.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
tabs.forEach((t) => t.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.querySelectorAll('.debug-panel').forEach((p) => p.classList.remove('active'));
|
||||||
|
$(`panel-${btn.dataset.tab}`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const c = await api('/debug/config');
|
||||||
|
$('configOut').textContent = fmt(c);
|
||||||
|
$('llmModel').placeholder = c.sd_prompt_model || c.system_model;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonas() {
|
||||||
|
const list = await api('/debug/personas');
|
||||||
|
const sel = $('sdPersona');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
for (const p of list) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.persona_id;
|
||||||
|
opt.textContent = `${p.name} (${p.persona_id})`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSdPrompt() {
|
||||||
|
$('sdScene').textContent = '…';
|
||||||
|
$('sdPrompts').textContent = '…';
|
||||||
|
const body = {
|
||||||
|
persona_id: $('sdPersona').value,
|
||||||
|
chat_excerpt: $('sdChat').value,
|
||||||
|
outfit_json: $('sdOutfit').value || '[]',
|
||||||
|
use_prose: $('sdUseProse') ? $('sdUseProse').checked : false,
|
||||||
|
};
|
||||||
|
const app = $('sdAppearance').value.trim();
|
||||||
|
if (app) body.appearance_override = app;
|
||||||
|
|
||||||
|
const data = await api('/debug/sd-prompt', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
$('sdScene').textContent = data.scene ? fmt(data.scene) : (data.error || '—');
|
||||||
|
const prompts = [];
|
||||||
|
if (data.tags_only_full) prompts.push('=== TAGS + POV (no prose) ===\n' + data.tags_only_full);
|
||||||
|
if (data.hybrid_full) prompts.push('\n=== HYBRID (Comfy) ===\n' + data.hybrid_full);
|
||||||
|
if (!data.tags_only_full && data.tag_full) prompts.push('=== PROMPT ===\n' + data.tag_full);
|
||||||
|
$('sdPrompts').textContent = prompts.join('\n') || data.error || '—';
|
||||||
|
$('sdLlmRaw').textContent = [
|
||||||
|
`model: ${data.sd_prompt_model}`,
|
||||||
|
`dual: ${data.anima_dual}`,
|
||||||
|
'',
|
||||||
|
'--- system ---',
|
||||||
|
data.builder_system || '',
|
||||||
|
'',
|
||||||
|
'--- user ---',
|
||||||
|
data.builder_user || '',
|
||||||
|
'',
|
||||||
|
'--- raw ---',
|
||||||
|
data.llm_raw || data.error || '',
|
||||||
|
].join('\n');
|
||||||
|
if (data.tag_full || data.hybrid_full) {
|
||||||
|
const src = data.hybrid_full || data.tag_full;
|
||||||
|
const parts = src.includes('__NEGATIVE_PROMPT__')
|
||||||
|
? src.split('\n\n__NEGATIVE_PROMPT__\n\n')
|
||||||
|
: src.includes('\n\nNegative prompt:')
|
||||||
|
? src.split('\n\nNegative prompt:')
|
||||||
|
: [src, ''];
|
||||||
|
$('genPositive').value = parts[0] || '';
|
||||||
|
$('genNegative').value = parts[1] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLlm() {
|
||||||
|
$('llmOut').textContent = '…';
|
||||||
|
const data = await api('/debug/llm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: $('llmModel').value.trim(),
|
||||||
|
system: $('llmSystem').value,
|
||||||
|
user: $('llmUser').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
$('llmOut').textContent = `model: ${data.model}\n\n${data.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillModelSelect(sel, options, configured) {
|
||||||
|
const current = sel.querySelector('option')?.value ?? '';
|
||||||
|
sel.innerHTML = `<option value="">— env: ${configured || '—'} —</option>`;
|
||||||
|
for (const name of options || []) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = name;
|
||||||
|
if (name === configured) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComfyModels() {
|
||||||
|
$('comfyModelLists').textContent = 'Загрузка object_info…';
|
||||||
|
const data = await api('/debug/comfy/models');
|
||||||
|
const { models, configured } = data;
|
||||||
|
fillModelSelect($('genUnet'), models.unets, configured.unet);
|
||||||
|
fillModelSelect($('genClip'), models.clips, configured.clip);
|
||||||
|
fillModelSelect($('genVae'), models.vaes, configured.vae);
|
||||||
|
fillModelSelect($('genCkpt'), models.checkpoints, configured.checkpoint);
|
||||||
|
|
||||||
|
const wrap = $('comfyModelLists');
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
for (const [key, list] of Object.entries(models)) {
|
||||||
|
const block = document.createElement('details');
|
||||||
|
block.className = 'model-list-block';
|
||||||
|
block.open = key === 'unets' || key === 'checkpoints';
|
||||||
|
block.innerHTML = `<summary>${key} (${list.length})</summary>`;
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const item of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = item;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
block.appendChild(ul);
|
||||||
|
wrap.appendChild(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comfyPing() {
|
||||||
|
$('comfyPingOut').textContent = '…';
|
||||||
|
const data = await api('/debug/comfy/ping');
|
||||||
|
$('comfyPingOut').textContent = fmt(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comfyGenerate() {
|
||||||
|
$('comfyGenOut').textContent = 'Генерация…';
|
||||||
|
$('comfyImgWrap').classList.add('hidden');
|
||||||
|
const body = {
|
||||||
|
positive: $('genPositive').value,
|
||||||
|
negative: $('genNegative').value,
|
||||||
|
};
|
||||||
|
const u = $('genUnet').value;
|
||||||
|
const c = $('genClip').value;
|
||||||
|
const v = $('genVae').value;
|
||||||
|
const ck = $('genCkpt').value;
|
||||||
|
if (u) body.unet = u;
|
||||||
|
if (c) body.clip = c;
|
||||||
|
if (v) body.vae = v;
|
||||||
|
if (ck) body.checkpoint = ck;
|
||||||
|
|
||||||
|
const data = await api('/debug/comfy/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
$('comfyGenOut').textContent = fmt(data);
|
||||||
|
if (data.image_path) {
|
||||||
|
$('comfyImg').src = data.image_path + '?t=' + Date.now();
|
||||||
|
$('comfyImgWrap').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comfyRaw() {
|
||||||
|
$('comfyRawOut').textContent = '…';
|
||||||
|
const data = await api('/debug/comfy/raw', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: $('rawMethod').value,
|
||||||
|
path: $('rawPath').value,
|
||||||
|
params_json: $('rawParams').value || '{}',
|
||||||
|
body_json: $('rawBody').value || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
$('comfyRawOut').textContent = fmt(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
initTabs();
|
||||||
|
$('btnReloadConfig').addEventListener('click', loadConfig);
|
||||||
|
$('btnSdPrompt').addEventListener('click', () => runSdPrompt().catch(showErr));
|
||||||
|
$('btnLlm').addEventListener('click', () => runLlm().catch(showErr));
|
||||||
|
$('btnComfyPing').addEventListener('click', () => comfyPing().catch(showErr));
|
||||||
|
$('btnComfyModels').addEventListener('click', () => loadComfyModels().catch(showErr));
|
||||||
|
$('btnComfyGen').addEventListener('click', () => comfyGenerate().catch(showErr));
|
||||||
|
$('btnComfyRaw').addEventListener('click', () => comfyRaw().catch(showErr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErr(e) {
|
||||||
|
alert(e.message || String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
bind();
|
||||||
|
loadConfig().catch(showErr);
|
||||||
|
loadPersonas().catch(showErr);
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { setSessionId, setCurrentPersona, currentPersona, dom } from './state.js';
|
import {
|
||||||
|
setSessionId,
|
||||||
|
setCurrentPersona,
|
||||||
|
getNewChatDefaultPersona,
|
||||||
|
dom,
|
||||||
|
} from './state.js';
|
||||||
import {
|
import {
|
||||||
initWizard,
|
initWizard,
|
||||||
GENRE_LABELS,
|
GENRE_LABELS,
|
||||||
@@ -7,9 +12,9 @@ import {
|
|||||||
fillGreetingSelect,
|
fillGreetingSelect,
|
||||||
getSelectedGreeting,
|
getSelectedGreeting,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { personaIndex, highlightPersona } from './personas.js';
|
import { personaIndex } from './personas.js';
|
||||||
|
|
||||||
let newChatPersonaId = currentPersona;
|
let newChatPersonaId = getNewChatDefaultPersona();
|
||||||
let newChatGreetingCtx = null;
|
let newChatGreetingCtx = null;
|
||||||
const newChatGenres = new Set();
|
const newChatGenres = new Set();
|
||||||
const newChatModalEl = document.getElementById('newChatModal');
|
const newChatModalEl = document.getElementById('newChatModal');
|
||||||
@@ -84,7 +89,7 @@ function fillNewChatPersonaGrid() {
|
|||||||
const grid = document.getElementById('newChatPersonaGrid');
|
const grid = document.getElementById('newChatPersonaGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
newChatPersonaId = currentPersona;
|
newChatPersonaId = getNewChatDefaultPersona();
|
||||||
for (const p of personaIndex.values()) {
|
for (const p of personaIndex.values()) {
|
||||||
const card = document.createElement('button');
|
const card = document.createElement('button');
|
||||||
card.type = '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() {
|
export function openNewChatWizard() {
|
||||||
|
import('./personas.js').then(({ refreshPersonaBarHighlight }) => refreshPersonaBarHighlight());
|
||||||
fillNewChatPersonaGrid();
|
fillNewChatPersonaGrid();
|
||||||
resetGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres);
|
resetGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres);
|
||||||
updateNewChatGenresLabel();
|
updateNewChatGenresLabel();
|
||||||
@@ -161,8 +140,17 @@ export function openNewChatWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createNewChatFromWizard() {
|
export async function createNewChatFromWizard() {
|
||||||
const { clearMessages, initChat, reloadChatFromServer } = await import('./chat.js');
|
const {
|
||||||
const { loadSessions, applySessionUi } = await import('./sessions.js');
|
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);
|
const sid = 'sess_' + Math.random().toString(36).slice(2, 10);
|
||||||
setSessionId(sid);
|
setSessionId(sid);
|
||||||
@@ -176,10 +164,23 @@ export async function createNewChatFromWizard() {
|
|||||||
newChatWizard?.reset();
|
newChatWizard?.reset();
|
||||||
|
|
||||||
try {
|
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}`, {
|
await fetch(`/sessions/${sid}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ persona_id: newChatPersonaId, rpg_enabled: rpg }),
|
body: JSON.stringify(sessionPatch),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (customTitle) {
|
if (customTitle) {
|
||||||
@@ -194,25 +195,57 @@ export async function createNewChatFromWizard() {
|
|||||||
dom.headerTitle.textContent = rpg ? `${pName} — RPG` : `${pName} — новый чат`;
|
dom.headerTitle.textContent = rpg ? `${pName} — RPG` : `${pName} — новый чат`;
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightPersona(newChatPersonaId);
|
const { highlightPersonaBar } = await import('./personas.js');
|
||||||
|
highlightPersonaBar(newChatPersonaId);
|
||||||
const greetingOverride = getNewChatFirstMesOverride();
|
const greetingOverride = getNewChatFirstMesOverride();
|
||||||
await initChat(greetingOverride ? { first_mes_override: greetingOverride } : {});
|
await initChat(greetingOverride ? { first_mes_override: greetingOverride } : {});
|
||||||
|
|
||||||
if (rpg) {
|
const assistantWrapper = dom.messagesEl.querySelector('.message.assistant');
|
||||||
const genreValue = [...newChatGenres].join(',') || 'adventure';
|
showImageGenerating(assistantWrapper);
|
||||||
const settings = {
|
|
||||||
dice: document.getElementById('ncSettingDice')?.checked ?? true,
|
let openingData = null;
|
||||||
narrator: document.getElementById('ncSettingNarrator')?.checked ?? true,
|
try {
|
||||||
quests: document.getElementById('ncSettingQuests')?.checked ?? true,
|
const openingRes = await fetch('/chat/opening/process', {
|
||||||
affinity: document.getElementById('ncSettingAffinity')?.checked ?? true,
|
method: 'POST',
|
||||||
choices: document.getElementById('ncSettingChoices')?.checked ?? true,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
body: JSON.stringify({
|
||||||
await bootstrapRpg(sid, newChatPersonaId, genreValue, settings);
|
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);
|
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}`);
|
const sessionRes = await fetch(`/sessions/${sid}`);
|
||||||
if (sessionRes.ok) applySessionUi(await sessionRes.json());
|
if (sessionRes.ok) applySessionUi(await sessionRes.json());
|
||||||
|
|
||||||
|
const blobRes = await fetch(`/chat/system/${sid}`);
|
||||||
|
if (blobRes.ok) renderSystemBlob(await blobRes.json());
|
||||||
|
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('createNewChat error:', e);
|
console.error('createNewChat error:', e);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { currentPersona, setCurrentPersona, sessionId } from './state.js';
|
import {
|
||||||
import { initChat } from './chat.js';
|
currentPersona,
|
||||||
|
sessionId,
|
||||||
|
getNewChatDefaultPersona,
|
||||||
|
setNewChatDefaultPersona,
|
||||||
|
} from './state.js';
|
||||||
import { initWizard, fillGreetingSelect, getSelectedGreeting } from './utils.js';
|
import { initWizard, fillGreetingSelect, getSelectedGreeting } from './utils.js';
|
||||||
|
|
||||||
export let personaIndex = new Map();
|
export let personaIndex = new Map();
|
||||||
@@ -21,12 +25,18 @@ let cardImportWizard;
|
|||||||
let cardPreview = null;
|
let cardPreview = null;
|
||||||
let cardImportFile = null;
|
let cardImportFile = null;
|
||||||
|
|
||||||
export function highlightPersona(personaId) {
|
export function highlightPersonaBar(personaId) {
|
||||||
document.querySelectorAll('.persona-card').forEach(c => {
|
document.querySelectorAll('.persona-card').forEach(c => {
|
||||||
c.classList.toggle('active', c.dataset.id === personaId);
|
c.classList.toggle('active', c.dataset.id === personaId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active session → session persona; otherwise new-chat preset. */
|
||||||
|
export function refreshPersonaBarHighlight() {
|
||||||
|
const id = sessionId ? currentPersona : getNewChatDefaultPersona();
|
||||||
|
highlightPersonaBar(id);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadPersonas() {
|
export async function loadPersonas() {
|
||||||
const res = await fetch('/personas/');
|
const res = await fetch('/personas/');
|
||||||
const personas = await res.json();
|
const personas = await res.json();
|
||||||
@@ -37,9 +47,11 @@ export async function loadPersonas() {
|
|||||||
const bar = document.getElementById('personaBar');
|
const bar = document.getElementById('personaBar');
|
||||||
bar.innerHTML = '';
|
bar.innerHTML = '';
|
||||||
|
|
||||||
|
const barActiveId = sessionId ? currentPersona : getNewChatDefaultPersona();
|
||||||
|
|
||||||
personas.forEach(p => {
|
personas.forEach(p => {
|
||||||
const card = document.createElement('div');
|
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;
|
card.dataset.id = p.persona_id;
|
||||||
const isCard = p.persona_id.startsWith('card_');
|
const isCard = p.persona_id.startsWith('card_');
|
||||||
const isCustomPersona = p.custom && !isCard;
|
const isCustomPersona = p.custom && !isCard;
|
||||||
@@ -131,16 +143,8 @@ export async function loadPersonas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function selectPersona(personaId) {
|
export async function selectPersona(personaId) {
|
||||||
setCurrentPersona(personaId);
|
setNewChatDefaultPersona(personaId);
|
||||||
highlightPersona(personaId);
|
highlightPersonaBar(personaId);
|
||||||
if (sessionId) {
|
|
||||||
await fetch(`/sessions/${sessionId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ persona_id: personaId }),
|
|
||||||
});
|
|
||||||
await initChat();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillImpCardForm(preview) {
|
function fillImpCardForm(preview) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled,
|
sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
import { updateQuestPanel, updateAffinityDisplay } from './chat.js';
|
import {
|
||||||
import { highlightPersona, personaIndex } from './personas.js';
|
updateQuestPanel, updateAffinityDisplay, updateStatsDisplay, hideStatsDisplay,
|
||||||
|
} from './chat.js';
|
||||||
|
import { highlightPersonaBar, personaIndex } from './personas.js';
|
||||||
import { formatSessionDate } from './utils.js';
|
import { formatSessionDate } from './utils.js';
|
||||||
import { openNewChatWizard } from './newChatWizard.js';
|
import { openNewChatWizard } from './newChatWizard.js';
|
||||||
|
|
||||||
@@ -35,6 +37,16 @@ export function applySessionUi(session) {
|
|||||||
dom.affinityDisplay?.classList.add('hidden');
|
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) {
|
if (rpgOn && settings.quests) {
|
||||||
fetch(`/sessions/${session.session_id}/quests`)
|
fetch(`/sessions/${session.session_id}/quests`)
|
||||||
.then(r => r.ok ? r.json() : [])
|
.then(r => r.ok ? r.json() : [])
|
||||||
@@ -114,7 +126,7 @@ export async function loadChatHistory(id) {
|
|||||||
const s = await sessionRes.json();
|
const s = await sessionRes.json();
|
||||||
if (s.persona_id) {
|
if (s.persona_id) {
|
||||||
setCurrentPersona(s.persona_id);
|
setCurrentPersona(s.persona_id);
|
||||||
highlightPersona(s.persona_id);
|
highlightPersonaBar(s.persona_id);
|
||||||
}
|
}
|
||||||
applySessionUi(s);
|
applySessionUi(s);
|
||||||
}
|
}
|
||||||
@@ -155,7 +167,7 @@ export async function initSessions() {
|
|||||||
|
|
||||||
let _prevBlobSections = {};
|
let _prevBlobSections = {};
|
||||||
|
|
||||||
function renderSystemBlob(blob) {
|
export function renderSystemBlob(blob) {
|
||||||
const tryFmt = (str, fallback = '') => {
|
const tryFmt = (str, fallback = '') => {
|
||||||
try { return JSON.stringify(JSON.parse(str), null, 2); } catch { return 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}`;
|
return ` ${icon} [${q.status}] ${q.title}`;
|
||||||
}).join('\n');
|
}).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 = {
|
const sections = {
|
||||||
|
context: ctxLine,
|
||||||
|
persona: personaLine,
|
||||||
system_prompt: blob.system_prompt ? `[system_prompt]\n${blob.system_prompt}` : '',
|
system_prompt: blob.system_prompt ? `[system_prompt]\n${blob.system_prompt}` : '',
|
||||||
status_quo: blob.status_quo ? `[status_quo]\n${blob.status_quo}` : '',
|
status_quo: blob.status_quo ? `[status_quo]\n${blob.status_quo}` : '',
|
||||||
affinity: blob.affinity != null ? `[affinity] ${blob.affinity}` : '',
|
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}` : '',
|
genre: blob.genre ? `[genre] ${blob.genre}` : '',
|
||||||
rpg_settings: blob.rpg_settings_json && blob.rpg_settings_json !== '{}' ? `[rpg_settings]\n${tryFmt(blob.rpg_settings_json)}` : '',
|
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)}` : '',
|
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)}` : '',
|
plot_arc: blob.plot_arc_json && blob.plot_arc_json !== '{}' ? `[plot_arc]\n${tryFmt(blob.plot_arc_json)}` : '',
|
||||||
quests: questLines ? `[quests]\n${questLines}` : '',
|
quests: questLines ? `[quests]\n${questLines}` : '',
|
||||||
@@ -184,7 +209,9 @@ function renderSystemBlob(blob) {
|
|||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = text;
|
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';
|
span.className = 'blob-changed';
|
||||||
setTimeout(() => span.classList.remove('blob-changed'), 3000);
|
setTimeout(() => span.classList.remove('blob-changed'), 3000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
export let sessionId = localStorage.getItem('chat_session_id') || null;
|
export let sessionId = localStorage.getItem('chat_session_id') || null;
|
||||||
export let currentPersona = localStorage.getItem('persona_id') || 'default';
|
/** Persona bound to the active session (from server, not global preset). */
|
||||||
|
export let currentPersona = 'default';
|
||||||
export let sidebarOpen = true;
|
export let sidebarOpen = true;
|
||||||
export let rpgEnabled = false;
|
export let rpgEnabled = false;
|
||||||
|
|
||||||
|
const NEW_CHAT_PERSONA_KEY = 'new_chat_persona_id';
|
||||||
|
|
||||||
export function toggleSidebar() { sidebarOpen = !sidebarOpen; return sidebarOpen; }
|
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) {
|
export function setSessionId(id) {
|
||||||
sessionId = id;
|
sessionId = id;
|
||||||
if (id) localStorage.setItem('chat_session_id', id);
|
if (id) localStorage.setItem('chat_session_id', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCurrentPersona(id) {
|
export function setCurrentPersona(id) {
|
||||||
currentPersona = id;
|
currentPersona = id || 'default';
|
||||||
localStorage.setItem('persona_id', id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRpgEnabled(v) { rpgEnabled = !!v; }
|
export function setRpgEnabled(v) { rpgEnabled = !!v; }
|
||||||
@@ -25,6 +39,7 @@ export const dom = {
|
|||||||
headerTitle: document.getElementById('headerTitle'),
|
headerTitle: document.getElementById('headerTitle'),
|
||||||
emptyState: document.getElementById('emptyState'),
|
emptyState: document.getElementById('emptyState'),
|
||||||
affinityDisplay: document.getElementById('affinityDisplay'),
|
affinityDisplay: document.getElementById('affinityDisplay'),
|
||||||
|
statsDisplay: document.getElementById('statsDisplay'),
|
||||||
rpgBadge: document.getElementById('rpgBadge'),
|
rpgBadge: document.getElementById('rpgBadge'),
|
||||||
systemBlob: document.getElementById('systemBlob'),
|
systemBlob: document.getElementById('systemBlob'),
|
||||||
systemBlobContent: document.getElementById('systemBlobContent'),
|
systemBlobContent: document.getElementById('systemBlobContent'),
|
||||||
|
|||||||
@@ -6,13 +6,33 @@ export function parseImagePromptFromContent(content) {
|
|||||||
return { text, prompt };
|
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) {
|
export async function copyToClipboard(text) {
|
||||||
|
if (!text) return false;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return true;
|
return true;
|
||||||
|
} catch {
|
||||||
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GENRE_LABELS = {
|
export const GENRE_LABELS = {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||