From 5a400be7924f75181ca33de93570a6637d0966a2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:06:45 +0300 Subject: [PATCH] Add SwarmUI settings management and portrait generation enhancements - Introduced new API endpoints for managing SwarmUI settings, including fetching and saving presets and age rules. - Updated the portrait generation logic to utilize the new settings structure, allowing for dynamic preset selection based on age. - Enhanced UI components to support SwarmUI settings, including localization for new strings and improved styling for settings sections. - Added tests to verify the functionality of new settings endpoints and portrait generation behavior. This commit lays the groundwork for more flexible and user-friendly portrait generation options. --- AGENTS.md | 1 + docs/design/social.md | 299 ++++++++++++ docs/phases/41-opinions.md | 42 ++ docs/phases/42-talk-circles.md | 41 ++ docs/phases/43-whisper.md | 36 ++ docs/phases/44-quarrel-fight.md | 35 ++ docs/phases/45-presence-talk.md | 34 ++ docs/phases/46-speech-home.md | 37 ++ docs/phases/47-romance-pack.md | 46 ++ docs/phases/README.md | 40 ++ docs/protocol.md | 21 +- src/HSchool.Client/src/i18n/strings.ts | 80 +++- src/HSchool.Client/src/net/api.ts | 68 +++ src/HSchool.Client/src/style.css | 37 +- src/HSchool.Client/src/ui/mainMenu.ts | 9 +- src/HSchool.Client/src/ui/personCard.ts | 1 + .../src/ui/swarmUiSettingsDialog.ts | 435 +++++++++++++++++ src/HSchool.Server/Api/SchoolEndpoints.cs | 4 +- src/HSchool.Server/Api/SettingsEndpoints.cs | 45 ++ .../Game/PortraitPromptBuilder.cs | 34 +- src/HSchool.Server/Game/PortraitService.cs | 34 +- src/HSchool.Server/Game/SwarmUiClient.cs | 65 ++- src/HSchool.Server/Game/SwarmUiDiscovery.cs | 97 ++++ .../Game/SwarmUiLoraFormatter.cs | 50 ++ src/HSchool.Server/Game/SwarmUiSettings.cs | 75 --- .../Game/SwarmUiSettingsStore.cs | 443 ++++++++++++++++++ src/HSchool.Server/Program.cs | 10 +- src/HSchool.Server/swarmui.json | 87 +++- .../HSchool.AppHost.Tests/PortraitApiTests.cs | 24 +- .../PortraitPromptBuilderTests.cs | 59 ++- .../SwarmUiClientTests.cs | 30 +- .../SwarmUiSettingsStoreTests.cs | 43 ++ 32 files changed, 2178 insertions(+), 184 deletions(-) create mode 100644 docs/design/social.md create mode 100644 docs/phases/41-opinions.md create mode 100644 docs/phases/42-talk-circles.md create mode 100644 docs/phases/43-whisper.md create mode 100644 docs/phases/44-quarrel-fight.md create mode 100644 docs/phases/45-presence-talk.md create mode 100644 docs/phases/46-speech-home.md create mode 100644 docs/phases/47-romance-pack.md create mode 100644 src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts create mode 100644 src/HSchool.Server/Api/SettingsEndpoints.cs create mode 100644 src/HSchool.Server/Game/SwarmUiDiscovery.cs create mode 100644 src/HSchool.Server/Game/SwarmUiLoraFormatter.cs delete mode 100644 src/HSchool.Server/Game/SwarmUiSettings.cs create mode 100644 src/HSchool.Server/Game/SwarmUiSettingsStore.cs create mode 100644 tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs diff --git a/AGENTS.md b/AGENTS.md index c50acea..df500bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ way; this file is *how to work in them*. | clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 29–37; country and wardrobes are live, weather is not | | login, who owns a school, watching others | [`docs/design/session.md`](docs/design/session.md) — slice 8, phases 38–39 | | clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`docs/design/session.md`](docs/design/session.md) — phase 40 | +| talks, opinions, fights, romance pack | [`docs/design/social.md`](docs/design/social.md) — slice 9, phases 41–47 | ## Commands diff --git a/docs/design/social.md b/docs/design/social.md new file mode 100644 index 0000000..99c3886 --- /dev/null +++ b/docs/design/social.md @@ -0,0 +1,299 @@ +# Общение, отношения и романтический пак + +Договорённость на срез после сессии/темпа, не текущий код. +Люди: [`people.md`](people.md). ИИ: [`ai.md`](ai.md). Одежда (уместность как образец): +[`inventory.md`](inventory.md). Моды: [`defs.md`](defs.md), [`foundation.md`](foundation.md). + +У человека появляется, **с кем** он говорит и **что** о нём думают другие. Нужда «общение» уже +есть, действие `Chat` уже есть — но это соло в коридоре. Срез делает разговор кружком, тему — +дефом, мнение — числом, вражду — ссорой и редкой дракой. Игрок это **смотрит**. Романтика не +ваниль: отдельный пак, который ещё и проверяет, что контентный мод на живой дороге работает. + +Срез отвечает на вопросы «кто кому друг», «о чём болтают» и «что меняет пак». Настроения-нужды, +приказов директора и секса нет. + +## Что меняется в уже принятом + +| Было | Стало | Почему | +| --- | --- | --- | +| `Chat` — один человек стоит в коридоре | Кружок 2–4 в одном узле, одна тема | Иначе общение не общение | +| Семья на карточке — только родство | Плюс число мнения; вкладка «Связи» | Родители по умолчанию не ноль | +| Конфликтов нет (`ai.md`) | Ссора и редкая драка без травм | Вражда должна быть видна, не только −40 в JSON | +| `example` — единственный не-core пак | Рядом контентный `romance` | Скучный образец не доказывает темы, ориентацию и патч `Chat` | +| Присутствие: id, узел, идёт/стоит | Плюс кружок (id участников + тема) | «Говорит с Машей о футболе» в локации | +| «Правила» — только одежда | Плюс политика речи | Уместность тем — тот же жест, что дресс-код | + +## Этап A — мнения и темы + +### Мнение + +У упорядоченной пары `(A → B)` одно число **−100…100**. Несимметрично: A может любить B, B — +терпеть A. Ноль не храним. Пороги и подписи — в `BehaviorDef`, не в UI: + +знакомые / приятели / друзья / близкие друзья на плюсе; натянуто / неприязнь / враги на минусе. + +Семья даёт **стартовое** смещение при генерации (числа тоже в данных): + +| Связь | Типичный старт | +| --- | --- | +| Родитель → ребёнок и обратно | сильно плюс | +| Братья и сёстры | плюс слабее | +| Партнёры в семье (уже есть на карточке) | плюс, не обязательно потолок | + +Дальше число живёт: разговоры, дом, ссоры. Родство не переписывается. + +**Дрейф.** Раз в рабочее утро (и skip ночи) каждое ненулевое мнение чуть сдвигается к своему +базису: семейная связь — к стартовому семейному числу, остальные — к нулю. Без этого дружба +первой недели вечная. Скорость — в `BehaviorDef`. Ноль снова не храним: дошли до базиса-нуля — +запись выкидываем. + +Хранение — **на человеке**, разреженно, в `people.json`. Писать при содержательном сдвиге +(разговор кончился, драка, утреннее семейное, утренний дрейф), не каждый тик. Тот же шов, что у +одежды. + +### Темы + +Новый вид `TopicDef`: id, теги уместности, возраст, роли, можно ли на уроке (шёпот), какой +язык помогает (опционально), какой навык качается каплей сверх Communication. + +Ванильные теги, не витрина: + +- учёба, игры, еда, семья, спорт, сплетни, грубое, внешность. + +Тема «внешность / форма» стыкует срез 7: исход смотрит уместность надетого (фаза 35). Не по +форме — насмешка или неловкость; по форме / ярко в свободной школе — комплимент. Нет ещё +уместности на карточке — тема живёт, модификатор ноль. + +Уместность — **оценка**, как одежда: систему саму не заставит взять грубое на линейке с +директором; взяли — мнение может упасть, учитель может оборвать. + +### Политика речи + +Вкладка «Правила», рядом с одеждой. Два комбобокса (ученики / сотрудники), не четыре как форма +и цвет: + +| Кого | Речь | +| --- | --- | +| Ученики | свободно / без грубого / только учёба | +| Сотрудники | свободно / без грубого / только учёба | + +«Применить» — **с завтра**, как дресс-код. Сегодняшние кружки доигрывают тему. + +### Вкладка карточки + +Пятая: **Связи**. Семья с числами, топ друзей и врагов, поиск по ненулевым (иначе тихая вражда +не видна). Игрок ничего не назначает. Без романтического пака колонки симпатий нет. + +## Этап B — разговоры + +### Кружок + +В одном узле собирается **2–4** человека, одна тема на всех. Пятый начинает соседний кружок, +не влезает в этот. + +Кого зовут: сначала друзья и одноклассники в узле, потом остальные. Врагов сами не зовут — +столкновение может уйти в ссору. Тихоня реже инициатор, лидер чаще. Веса — поля на `TraitDef` +и `BehaviorDef`, не фамилии в коде. + +`Chat` перестаёт быть соло: действие занимает людей кружка. Совместимость ролей как сейчас +(ученик и сотрудник могут говорить — учитель после урока, вопрос в коридоре). + +Где: перемена, двор, обед, раздевалка, коридор. **Учительская** — отдельное действие +`StaffChat` (`room: TeachersRoom`, только сотрудники): взрослые не обязаны болтать в коридоре +с детьми, чтобы восполнить общение. На уроке — только **шёпот** (тема с тегом «можно шептать»). +Шёпот восполняет общение слабее, **режет рост навыка урока** у участников. + +**Вместе.** На перемене и в обед друзья тянутся в узел, где уже стоят свои, а не в случайный +коридор. Враги не садятся в один кружок (уже) и не выбирают тот же обеденный узел, если есть +другой. Это веса цели «досуг», не новая ходьба парой и не состояние присутствия: на карте +видно кучку в одном узле. + +**Телефон.** У кого в сумке или в руках есть телефон (срез 7), на перемене может быть **парный** +чат слабее живого кружка: оба с телефоном, тема из того же каталога, общение восполняется +меньше. Без телефона действия нет. На уроке телефоном не сидят — это шёпот голосом, не экран. + +### Язык и красноречие + +Отдельного навыка «красноречие» нет: это `Communication`. Языки — как сейчас. + +Общий язык: лучший общий навык среди участников выше порога из `BehaviorDef`. Нет общего — +кружок всё равно возможен (жесты), но сдвиг мнения почти нулевой и чаще отрицательный. + +`Communication` чуть растёт от разговора (меньше, чем предмет за урок). Язык, на котором +говорили, чуть растёт, если он ещё не у потолка. + +### Шёпот + +Если учитель **в том же кабинете**, шанс оборвать — из `BehaviorDef`, черты тише/громче +сдвигают. Не каждый шёпот ловят. Учитель, поймавший, начинает разговор-выговор (тот же +механизм кружка, тема «дисциплина»). + +После урока учитель может сам взять 2–4 учеников (вопрос по предмету, похвала, выговор) — +обязанность с весом, не приказ игрока. Сдвиг мнения ученика об учителе слегка масштабируется +**педагогикой** учителя: сильный педагог после похвалы плюсует больше, слабый выговор злит +сильнее. Оценок по-прежнему нет. + +### Дом + +Родители на карту не приходят. **Одно** семейное событие на человека за рабочее утро; skip +ночи его применяет, как утреннюю одежду. Тема из семейного набора, сдвиг небольшой. +Тем же утренним проходом — дрейф всех мнений к базису. Настроения-нужды нет. + +### Черты в `core` + +Уже крутят разговор: задира, тихоня, лидер, добрый, вспыльчивый. Добавляются две: + +- **сплетник** — чаще берёт темы «сплетни», сильнее двигает мнение; +- **общительный** — чаще инициатор, выше шанс большого кружка. + +Поля на `TraitDef` (вес инициативы, сдвиг к тегам тем), не списки id в коде. + +## Этап C — ссора и драка + +Отдельные действия, не только −N к мнению. + +**Ссора** — на перемене, двое (иногда трое из распавшегося кружка), общение восполняется +криком, мнения падают сильнее обычного грубого разговора. + +**Задира** не бросает кубик по школе каждый день: на человеке память жертвы (id), пока та в +ростере. Смена цели — если жертва выпустилась, ушла или мнение уже на полу. Поля на черте, не +`if (defName == "Bully")` в коде сверх проверки поля. + +**Заступничество.** Третий в том же узле с высоким плюсом к жертве может встать в ссору на её +стороне. Мнение к задире падает, к жертве растёт; кружок ссоры становится тройкой. + +**Извиниться** — отдельное действие после ссоры или выговора, пока оба в одном узле. Возвращает +часть (не всё) потерянного мнения. Задира извиняется реже (поле черты). Без этого единственный +путь вверх — «ещё поболтать» и утренний дрейф к нулю. + +**Драка** — редко, двор или физкультура. Короткая. Травм, медкабинета и вызова родителей нет. +Мнения резко падают, оба в логе. Дежурный учитель или физрук в узле **обрывает** и делает +выговор. Никого в кабинете директора насильно нет: игрока нет в приказе, отдельной очереди +«к директору» этот срез не заводит. + +Нет сотрудника рядом — драка кончается по длительности действия, лог всё равно пишется. + +## Этап D — что видит игрок + +Игрок **наблюдает**. Не мирит, не рассаживает, не запрещает дружить. + +- **Локация** — «говорит с Машей о футболе»: кадр присутствия несёт id участников и id темы, + клиент собирает строку через справочник имён и локаль темы. Имена по-прежнему не строкой в + сокете. Раскладка присутствия меняется, **версия протокола +1**. +- **Сейчас** на карточке — то же плюс история за сегодня (уже есть лог). +- **Связи** — числа и подписи. +- **Правила** — политика речи, с завтра. + +Список людей мнения не несёт. + +## Этап E — пак `romance` + +Первый **контентный** мод. `example` не раздуваем: он образец папки. `romance` выбирается в +create, `requires: ["core"]`, патчит `Chat`/темы (романтические темы появляются только с паком). + +Без пака генератор не пишет ориентацию и романтические состояния. Живой школе мод не добавить — +как все паки сейчас. + +### Ориентация + +Поле на человеке, только если пак в школе. Значения — данные пака (гетеро / би / гомо и веса +генератора), не черта «ради галочки» и не хардкод. Пак без поля ориентации романтику не крутит. + +### Симпатия и пара + +Симпатия — односторонний ярлык поверх мнения (порог из данных пака). «Тили-тили тесто» — +подпись и лог, не свидание. + +| Кто | Симпатия | Пара / свидание | +| --- | --- | --- | +| Ученик ↔ ученик близкого возраста | да | только если **обоим 18+** | +| Ученик → учитель | да, класс может дразнить; на карточке учителя видно | нет | +| Учитель → ученик | **нет**, взаимности нет, осаживает разговором | нет | +| Сотрудник ↔ сотрудник | да | да, оба взрослые | +| Сотрудник ↔ ученик как пара | нет | нет | + +Близкий возраст учеников: **±2 года или соседняя параллель**. К учителю — исключение из этого +фильтра (иначе взрослый никогда не попадает). + +Пара: только **взаимная**, только **одна**. Разрыв — событие (разговор или мнение ниже порога): +мнение падает, симпатия может остаться. Новая пара не раньше следующего дня. Неразделённая +симпатия живёт сколько угодно. Измену как механику не делаем: вторая симпатия просто висит. + +Никакого сексуального контента, портретов «для романтики» и действий кроме разговора / ссоры / +уже существующих перемещений. Детская линия — дразнилки и ярлык симпатии. + +Пак может добавить черты вроде «влюбчивый», «ревнивый» теми же полями `TraitDef`. + +## Поток данных + +Мнения и кружок — состояние мира, считает `HSchool.Ai` (выбор темы, состав, исход), пишет +симуляция. Карточка — HTTP через мейлбокс. Присутствие — сокет. Политика речи — снимок школы, +как будущий дресс-код. + +Детерминизм: свой поток на человека, обход по id, тема из сида кружка. Тот же сид и те же +паки — те же первые дружбы на той же неделе (золотой отпечаток мнений — в фазе, которая +начинает писать ненулевые пары сверх семьи). + +## Где живёт код + +Нового проекта нет. + +| Что | Куда | +| --- | --- | +| `TopicDef`, теги, поля черт, политика речи | `HSchool.Content` + JSONC | +| Стартовые мнения семьи, ориентация при паке | `HSchool.People` | +| Кружок, шёпот, ссора, драка, дом-утро | `HSchool.Ai` + адаптер в `HSchool.Simulation` | +| Кадр присутствия | `HSchool.Protocol` + `protocol.ts` + `docs/protocol.md` | +| Вкладка, правила, лог | `HSchool.Server` + клиент | +| Пак | `mods/romance/` рядом с `core` и `example` | + +## Советы, которые стоит принять сразу + +- Не заводить нужду «настроение»: мнение + общение уже две шкалы. +- Не класть имена в кадр присутствия — только id, как с людьми сейчас. +- Не лечить драку травмой: медкабинет тогда обязан стать больницей. +- Не делать «ходить вместе» отдельным состоянием присутствия: достаточно одного узла. +- `ActionDef` по-прежнему одна комната: учительская и телефон — отдельные действия, не массив комнат. +- Не вшивать романтику в `core`: ванильная школа должна собираться без пака. +- Не расширять `example`. Контент — другой пак. +- Не делать учительскую взаимность «опцией мода»: пак проверяет запрет тестом. + +## Заведомо не сейчас + +- Настроение как нужда, приказы, вызов родителей, рассадка класса. +- Секс, поцелуи как действие, свидания вне карты, ревность-сюжет. +- Пара учителя и ученика, даже 18+. +- Травмы, дневник дисциплины как журнал оценок. +- Смена модов у живой школы. +- Общая лента событий школы — по-прежнему лог человека. + +## Зафиксировано этим разговором + +| Тема | Решение | +| --- | --- | +| Мнение | −100…100, несимметрично, подписи по порогам, семья — старт | +| Дрейф | Каждое рабочее утро к семейному базису или к нулю; skip применяет | +| Хранение | Разреженно на человеке, писать при сдвиге | +| Темы | `TopicDef` + теги включая внешность; оценка, не запрет | +| Политика речи | Ученики/сотрудники: свободно / без грубого / только учёба; с завтра | +| Кружок | Сразу 2–4; пятый — другой кружок | +| Кого зовут | Друзья и класс в узле, врагов не зовут | +| Вместе | Друзья на перемене/обеде тянутся в один узел | +| Где | Перемена, двор, обед, после урока, учительская; на уроке — шёпот | +| Телефон | Парный чат на перемене, оба с телефоном, слабее живого | +| Шёпот | Режет урок; учитель в кабинете — шанс оборвать | +| Язык | Нет общего — слабый разговор, не отказ | +| Красноречие | `Communication`; растёт от разговора | +| Педагогика | Масштабирует мнение ученика об учителе после разговора | +| Игрок | Только смотрит | +| Экран | Вкладка «Связи»; локация из id в присутствии; протокол +1 | +| Ссора / драка | Действия; жертва задиры помнится; заступник; извинение; без травм | +| Дом | Одно семейное событие на рабочее утро; skip применяет | +| Настроение | Нет | +| Черты core | + сплетник, общительный | +| Романтика | Пак `romance`, патчит темы/`Chat` | +| Ориентация | Только с паком, поле и веса в данных | +| Симпатия учеников | Близкий возраст ±2 / соседняя параллель | +| Симпатия к учителю | Можно, видно, учитель осаждает, взаимности нет | +| Пара | Оба 18+, взаимно, одна; разрыв — событие; сотрудник↔ученик нет | +| Сотрудники | Симпатия и пара между собой — да | diff --git a/docs/phases/41-opinions.md b/docs/phases/41-opinions.md new file mode 100644 index 0000000..5984441 --- /dev/null +++ b/docs/phases/41-opinions.md @@ -0,0 +1,42 @@ +# Фаза 41. Мнения и вкладка «Связи» + +## Зависимости + +- [Фаза 8](08-people-browser.md) +- [Фаза 36](36-person-card-tabs.md) + +## Зачем + +У пары появляется число. Семья больше не «просто родственники». Карточка умеет это показать, +даже пока кружков ещё нет. + +## Задачи + +- [ ] Мнение A→B: −100…100, несимметрично, ноль не хранить +- [ ] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам +- [ ] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных +- [ ] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю; + дошли до нуля — запись выкинуть +- [ ] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи + после первой загрузки-переписи +- [ ] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым +- [ ] Игрок с вкладки ничего не меняет +- [ ] `docs/protocol.md` — HTTP карточки + +## Тесты, без которых фаза не закрыта + +- [ ] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи +- [ ] A→B и B→A могут различаться +- [ ] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось) + ближе к нулю, семейное — к старту, не к нулю +- [ ] Карточка без отдельного запроса «всех пар школы»: только этот человек +- [ ] Клиентский тест: вкладка «Связи» не монтирует лог + +## Критерий готовности + +- Открыть ученика, на «Связях» родители с подписью «близкие» или соседней, одноклассник без строки +- `dotnet test` и `npm test` / `build` проходят + +## Стоп + +Не собирать кружки. Не бампить сокет. Не заводить романтику. diff --git a/docs/phases/42-talk-circles.md b/docs/phases/42-talk-circles.md new file mode 100644 index 0000000..bd8a081 --- /dev/null +++ b/docs/phases/42-talk-circles.md @@ -0,0 +1,41 @@ +# Фаза 42. Темы и кружки + +## Зависимости + +- [Фаза 41](41-opinions.md) +- [Фаза 21](21-decisions.md) + +## Зачем + +`Chat` перестаёт быть соло. В узле говорят 2–4 человека об одной теме. Язык и +`Communication` влияют на исход. + +## Задачи + +- [ ] `TopicDef`: теги, возраст, роли, шёпот-на-уроке, опциональный язык +- [ ] Ванильный набор тем (учёба, игры, еда, семья, спорт, сплетни, грубое) в `core` +- [ ] Кружок 2–4 в одном узле; пятый не влезает +- [ ] Кого зовут: друзья и одноклассники в узле, врагов не зовут; тихоня/лидер — поля черты +- [ ] Нет общего языка — кружок можно, сдвиг мнения слабый и чаще вниз +- [ ] `Communication` растёт от разговора меньше, чем предмет за урок; язык разговора — капля, + если не у потолка +- [ ] Черты **сплетник** и **общительный** в `core` с полями, не списками в коде +- [ ] Исход пишет мнения всех пар кружка и строку лога +- [ ] На уроке обычный `Chat` не стартует (шёпот — фаза 43) + +## Тесты, без которых фаза не закрыта + +- [ ] Двое свободных друзей в коридоре на перемене собирают кружок; враги — нет +- [ ] Пятый в узле не входит в тот же кружок +- [ ] Без общего языка мнение почти не растёт при том же `Communication` +- [ ] Высокий `Communication` сильнее двигает число, чем низкий, на одной теме +- [ ] Каталог без `TopicDef` не стартует школу с кружками — или падает загрузка, не молча соло-Chat + +## Критерий готовности + +- На перемене в коридоре двое «разговаривают», на карточке мнение сдвинулось, в логе тема +- `dotnet test` проходит + +## Стоп + +Не шёпот на уроке. Не драка. Не менять кадр присутствия (локация может ещё писать «Разговор»). diff --git a/docs/phases/43-whisper.md b/docs/phases/43-whisper.md new file mode 100644 index 0000000..74de51f --- /dev/null +++ b/docs/phases/43-whisper.md @@ -0,0 +1,36 @@ +# Фаза 43. Шёпот и учитель + +## Зависимости + +- [Фаза 42](42-talk-circles.md) + +## Зачем + +На уроке можно шептаться — ценой учёбы. Учитель иногда слышит. После урока учитель сам +говорит с учениками. + +## Задачи + +- [ ] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса +- [ ] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`) +- [ ] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 % +- [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у + строгого разговора +- [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели +- [ ] Лог: «шептались», «учитель оборвал» + +## Тесты, без которых фаза не закрыта + +- [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка +- [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается +- [ ] Без учителя в узле шёпот не обрывается этим правилом +- [ ] Обычный не-шёпотный `Chat` на уроке не стартует + +## Критерий готовности + +- На математике двое шепчутся, иногда учитель обрывает, в логе это видно +- `dotnet test` проходит + +## Стоп + +Не драка. Не политика речи в UI. Не протокол. diff --git a/docs/phases/44-quarrel-fight.md b/docs/phases/44-quarrel-fight.md new file mode 100644 index 0000000..2140f4c --- /dev/null +++ b/docs/phases/44-quarrel-fight.md @@ -0,0 +1,35 @@ +# Фаза 44. Ссора и драка + +## Зависимости + +- [Фаза 42](42-talk-circles.md) + +## Зачем + +Вражда — не только минус в JSON. На перемене ссорятся, на дворе или физкультуре редко дерутся. +Травм нет. + +## Задачи + +- [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора +- [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта +- [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName +- [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается +- [ ] Лог обоих; кабинет директора не заводим +- [ ] Игрок не приказывает разнять + +## Тесты, без которых фаза не закрыта + +- [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей +- [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется +- [ ] Выговор после обрыва есть, если в узле был сотрудник +- [ ] Skip пустого времени не оставляет «вечную драку» в состоянии + +## Критерий готовности + +- Задира с врагом на дворе: в логе ссора или драка, мнения ниже, оба живы и на ногах +- `dotnet test` проходит + +## Стоп + +Не вызов родителей. Не травмы. Не романтика. diff --git a/docs/phases/45-presence-talk.md b/docs/phases/45-presence-talk.md new file mode 100644 index 0000000..ffa40d1 --- /dev/null +++ b/docs/phases/45-presence-talk.md @@ -0,0 +1,34 @@ +# Фаза 45. Кружок в присутствии + +## Зависимости + +- [Фаза 42](42-talk-circles.md) +- [Фаза 19](19-presence-screen.md) + +## Зачем + +В панели локации видно, с кем и о чём говорят, без открытия карточки. + +## Задачи + +- [ ] Кадр присутствия: у человека, который в кружке, список id участников и id темы. + Имена не строкой. Клиент собирает «говорит с Машей о футболе» из справочника и локали +- [ ] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите +- [ ] Не в кружке — пустой список, как сейчас только id/узел/состояние +- [ ] Вкладка «Сейчас» на карточке согласована с тем же составом +- [ ] Старый клиент отваливается Hello, как обычно + +## Тесты, без которых фаза не закрыта + +- [ ] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах +- [ ] Имя человека не встречается в кадре присутствия +- [ ] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра + +## Критерий готовности + +- Клик по коридору на перемене: в «персонажах» видно, кто с кем говорит +- `dotnet test` и `npm test` / `build` проходят + +## Стоп + +Не политика речи. Не пак. diff --git a/docs/phases/46-speech-home.md b/docs/phases/46-speech-home.md new file mode 100644 index 0000000..850142d --- /dev/null +++ b/docs/phases/46-speech-home.md @@ -0,0 +1,37 @@ +# Фаза 46. Политика речи и дом + +## Зависимости + +- [Фаза 42](42-talk-circles.md) +- [Фаза 37](37-school-rules.md) +- [Фаза 34](34-wear-home.md) + +## Зачем + +Школа задаёт, какие темы уместны — тем же жестом, что дресс-код. Родители двигают мнение утром, +не приходя на карту. + +## Задачи + +- [ ] В «Правилах» два комбобокса речи: ученики / сотрудники × свободно / без грубого / только учёба +- [ ] В силу с завтра; API сегодня не переодевает темы текущих кружков +- [ ] Система не стартует кружок с темой вне политики; начатый вчера доигрывает +- [ ] Одно семейное событие на человека за рабочее утро; skip ночи применяет +- [ ] Семейные темы из `TopicDef`; сдвиг меньше школьного разговора +- [ ] `docs/protocol.md` — HTTP правил речи + +## Тесты, без которых фаза не закрыта + +- [ ] POST «только учёба» сегодня не мешает текущему кружку «игры» +- [ ] На следующее рабочее утро грубая тема у учеников не стартует +- [ ] Skip с пятницы на понедельник применяет домашнее событие; мнение с родителем сдвинуто +- [ ] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра» + +## Критерий готовности + +- Сменить политику, пережить ночь кнопкой, на перемене нет грубых кружков у учеников +- `dotnet test` и `npm test` / `build` проходят + +## Стоп + +Не романтика. Не вызов родителей в школу. diff --git a/docs/phases/47-romance-pack.md b/docs/phases/47-romance-pack.md new file mode 100644 index 0000000..703332f --- /dev/null +++ b/docs/phases/47-romance-pack.md @@ -0,0 +1,46 @@ +# Фаза 47. Пак romance + +## Зависимости + +- [Фаза 42](42-talk-circles.md) +- [Фаза 23](23-example-pack.md) +- [Фаза 41](41-opinions.md) + +## Зачем + +Контентный мод, не скучный образец. Ваниль без романтики. Пак добавляет ориентацию, симпатию, +пару 18+ и романтические темы патчем. + +## Задачи + +- [ ] `mods/romance/`: `pack.jsonc` `requires: ["core"]`, локали, ориентация как данные, + черты влюбчивый/ревнивый полями, патч тем и при необходимости `Chat` +- [ ] Без пака в create ориентации и романтических состояний нет; ванильный каталог без + романтических тем +- [ ] Симпатия учеников: ±2 года или соседняя параллель +- [ ] Симпатия ученик → учитель можно, видна на обеих карточках; учитель → ученик никогда; + осадить — разговор; пары сотрудник↔ученик нет +- [ ] Пара: оба 18+, взаимно, одна; разрыв — событие, новая не раньше следующего дня +- [ ] Сотрудники между собой — симпатия и пара можно +- [ ] Никаких сексуальных действий и текстов. Детская линия — дразнилки и ярлык +- [ ] Тесты хоста: школа с паком и без; запрет учительской взаимности +- [ ] `example` не трогать + +## Тесты, без которых фаза не закрыта + +- [ ] `GET /api/catalog` без `romance` не отдаёт романтические темы +- [ ] Create с паком пишет ориентацию; без пака поля нет +- [ ] Двое 16-летних не получают статус пары при любом мнении +- [ ] Учитель не получает романтическое состояние на ученика +- [ ] Первоклассник не получает симпатию к одиннадцатому (возраст) +- [ ] Школа с `example` и без `romance` ведёт себя как ваниль в части тем + +## Критерий готовности + +- Создать школу с галкой пака, увидеть симпатию на «Связях» и тему пака в логе; снять галку на + новой школе — этого нет +- `dotnet test` проходит + +## Стоп + +Не секс. Не пара учителя и ученика. Не раздувать `example`. Не смена модов у живой школы. diff --git a/docs/phases/README.md b/docs/phases/README.md index 4521321..d364a4f 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -182,3 +182,43 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [40. Темп часов](40-clock-tempo.md) | ⬜ | ×1 = минута/с, кнопки ×½ ×1 ×2 ×5 ×10, страйд тяжёлых систем | + +## Срез 9. Общение и романтический пак + +Дизайн: [`../design/social.md`](../design/social.md). + +Разговор становится кружком, мнение — числом, вражда — ссорой. Романтика не ваниль: отдельный +пак. Срез можно остановить после этапа B — школа болтает, даже если драк и пака ещё нет. + +**Этап A — мнения.** Число и вкладка. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [41. Мнения и «Связи»](41-opinions.md) | ⬜ | −100…100, семья-старт, вкладка карточки | + +**Этап B — разговоры.** Кружок, язык, шёпот. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, язык, `Communication` | +| [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка | + +42 стоит на 41; 43 — на 42. + +**Этап C — конфликт и экран.** + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [44. Ссора и драка](44-quarrel-fight.md) | ⬜ | Перемена / двор / физкультура, выговор, без травм | +| [45. Кружок в присутствии](45-presence-talk.md) | ⬜ | Id участников и темы в кадре, протокол +1 | + +44 и 45 стоят на 42, можно параллельно. + +**Этап D — правила, дом, пак.** + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [46. Политика речи и дом](46-speech-home.md) | ⬜ | Комбобоксы речи с завтра; утреннее семейное событие | +| [47. Пак romance](47-romance-pack.md) | ⬜ | Ориентация, симпатия, пара 18+, патч тем | + +46 стоит на 37 и 42; 47 — на 42 и 23. `example` не трогать. diff --git a/docs/protocol.md b/docs/protocol.md index 69b866d..c7e95ab 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -389,13 +389,32 @@ is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `ki "kind": "avatar", "positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …", "negative": "(low quality, worst quality:1.4), …", - "promptExtra": null + "promptExtra": null, + "presetId": "default", + "presetLabel": "Default" } ``` `GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured) so the client can disable generate buttons and show reachability without trying POST first. +### `GET /api/settings/swarmui` + +Returns the editable SwarmUI preset file (`swarmui.json`): named presets (model, steps, sampler, +LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules` mapping age bands to presets. +Portrait generation resolves the preset from the person's age before building the prompt. + +### `PUT /api/settings/swarmui` + +Replaces the preset file after validation. Invalid preset ids, age rules or numeric ranges return +`400` `invalid-body`. Changes apply immediately to new portrait generations. + +### `GET /api/settings/swarmui/discovery` + +When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns +`{ connected, models, loras, samplers, schedulers }` for the settings UI comboboxes. When SwarmUI +is off or unreachable, `connected` is false and the lists are empty. + ### `GET /api/schools/{id}/dress-rules` Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index dca2acd..09c0bf5 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -11,6 +11,44 @@ const ru = { schoolsTitle: 'Школы', createSchool: 'Создать школу', + settings: 'Настройки', + save: 'Сохранить', + settingsTitle: 'SwarmUI — пресеты портретов', + settingsActivePreset: 'Пресет по умолчанию', + settingsEditPreset: 'Редактируемый пресет', + settingsAddPreset: 'Добавить пресет', + settingsPresetLabel: 'Название пресета', + settingsModel: 'Модель', + settingsSteps: 'Steps', + settingsCfg: 'CFG scale', + settingsClipSkip: 'CLIP skip', + settingsSampler: 'Sampler', + settingsScheduler: 'Scheduler', + settingsSeed: 'Seed', + settingsPositive: 'Базовый positive', + settingsNegative: 'Negative', + settingsPositiveLoras: 'Positive LoRA', + settingsNegativeLoras: 'Negative LoRA', + settingsAddLora: 'Добавить LoRA', + settingsAvatarPreset: 'Аватар', + settingsCustomPreset: 'Свой промпт (размеры)', + settingsFullBodyPreset: 'В полный рост', + settingsWidth: 'Ширина', + settingsHeight: 'Высота', + settingsKindPositive: 'Positive для вида', + settingsAgeRules: 'Правила по возрасту', + settingsAgeMin: 'От', + settingsAgeMax: 'До', + settingsAgePreset: 'Пресет', + settingsAddAgeRule: 'Добавить правило', + settingsRemoveRule: 'Удалить', + settingsRefreshLists: 'Обновить списки из SwarmUI', + settingsSwarmReady: 'SwarmUI подключён — списки моделей и семплеров доступны.', + settingsSwarmOffline: 'SwarmUI недоступен — поля можно заполнить вручную.', + settingsSwarmNotConfigured: 'SwarmUI не настроен на сервере (SwarmUi:BaseUrl).', + settingsLoadFailed: 'Не удалось загрузить настройки.', + settingsSaveFailed: 'Не удалось сохранить настройки.', + settingsDiscoveryFailed: 'Не удалось получить списки из SwarmUI.', emptySchools: 'Пока ни одной школы. Создайте первую.', loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.', createDisabledTitle: 'Удалите одну из школ, чтобы создать новую', @@ -152,7 +190,6 @@ const ru = { peopleLogEvent: 'Событие', peopleLogEmpty: 'Сегодня записей нет.', - peopleTabOverview: 'Обзор', peopleTabPortrait: 'Портрет', peoplePortraitAvatar: 'Аватар', peoplePortraitFull: 'В полный рост', @@ -178,6 +215,7 @@ const ru = { peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptLoading: 'Сборка промпта…', peoplePortraitPromptFailed: 'Не удалось получить промпт.', + peoplePortraitPromptPreset: 'Пресет: {label}', modeOverview: 'Обзор', modeManage: 'Управление', @@ -277,6 +315,44 @@ const en: Messages = { schoolsTitle: 'Schools', createSchool: 'Create school', + settings: 'Settings', + save: 'Save', + settingsTitle: 'SwarmUI portrait presets', + settingsActivePreset: 'Default preset', + settingsEditPreset: 'Preset to edit', + settingsAddPreset: 'Add preset', + settingsPresetLabel: 'Preset name', + settingsModel: 'Model', + settingsSteps: 'Steps', + settingsCfg: 'CFG scale', + settingsClipSkip: 'CLIP skip', + settingsSampler: 'Sampler', + settingsScheduler: 'Scheduler', + settingsSeed: 'Seed', + settingsPositive: 'Base positive', + settingsNegative: 'Negative', + settingsPositiveLoras: 'Positive LoRA', + settingsNegativeLoras: 'Negative LoRA', + settingsAddLora: 'Add LoRA', + settingsAvatarPreset: 'Avatar', + settingsCustomPreset: 'Custom prompt (size)', + settingsFullBodyPreset: 'Full body', + settingsWidth: 'Width', + settingsHeight: 'Height', + settingsKindPositive: 'Kind positive', + settingsAgeRules: 'Age rules', + settingsAgeMin: 'From', + settingsAgeMax: 'To', + settingsAgePreset: 'Preset', + settingsAddAgeRule: 'Add rule', + settingsRemoveRule: 'Remove', + settingsRefreshLists: 'Refresh lists from SwarmUI', + settingsSwarmReady: 'SwarmUI connected — model and sampler lists are available.', + settingsSwarmOffline: 'SwarmUI unreachable — you can still edit fields manually.', + settingsSwarmNotConfigured: 'SwarmUI is not configured on the server (SwarmUi:BaseUrl).', + settingsLoadFailed: 'Could not load settings.', + settingsSaveFailed: 'Could not save settings.', + settingsDiscoveryFailed: 'Could not fetch lists from SwarmUI.', emptySchools: 'No schools yet. Create the first one.', loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.', createDisabledTitle: 'Delete a school to create a new one', @@ -418,7 +494,6 @@ const en: Messages = { peopleLogEvent: 'Event', peopleLogEmpty: 'Nothing logged today.', - peopleTabOverview: 'Overview', peopleTabPortrait: 'Portrait', peoplePortraitAvatar: 'Avatar', peoplePortraitFull: 'Full body', @@ -444,6 +519,7 @@ const en: Messages = { peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptLoading: 'Building prompt…', peoplePortraitPromptFailed: 'Could not load the prompt.', + peoplePortraitPromptPreset: 'Preset: {label}', modeOverview: 'Overview', modeManage: 'Management', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index a6b8965..31fade4 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -402,6 +402,72 @@ export async function fetchGameStatus(): Promise { return request('/api/status'); } +export interface SwarmUiKindPreset { + width: number; + height: number; + positive: string; +} + +export interface SwarmUiLoraEntry { + name: string; + weight: number; +} + +export interface SwarmUiPresetDefinition { + id: string; + label: string; + model: string; + steps: number; + cfgScale: number; + clipSkip: number; + sampler: string; + scheduler: string; + seed: number; + positive: string; + negative: string; + positiveLoras: SwarmUiLoraEntry[]; + negativeLoras: SwarmUiLoraEntry[]; + avatar: SwarmUiKindPreset; + custom: SwarmUiKindPreset; + fullBody: SwarmUiKindPreset; +} + +export interface SwarmUiAgeRule { + minAge: number; + maxAge: number; + presetId: string; +} + +export interface SwarmUiSettingsFile { + activePresetId: string; + presets: SwarmUiPresetDefinition[]; + ageRules: SwarmUiAgeRule[]; +} + +export interface SwarmUiDiscovery { + connected: boolean; + models: readonly string[]; + loras: readonly string[]; + samplers: readonly string[]; + schedulers: readonly string[]; +} + +export async function fetchSwarmUiSettings(): Promise { + return request('/api/settings/swarmui'); +} + +export async function saveSwarmUiSettings(settings: SwarmUiSettingsFile): Promise { + return request('/api/settings/swarmui', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); +} + +export async function fetchSwarmUiDiscovery(): Promise { + return request('/api/settings/swarmui/discovery'); +} + export type PortraitKind = 'avatar' | 'full' | 'custom'; export interface PortraitResult { @@ -430,6 +496,8 @@ export interface PortraitPrompt { readonly positive: string; readonly negative: string; readonly promptExtra: string | null; + readonly presetId: string; + readonly presetLabel: string; } export async function fetchPortraitPrompt( diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 5fd3a0d..eb6674e 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -683,10 +683,45 @@ body { overflow-x: auto; } -.people__portrait-prompt-text--muted { +.form--settings { + max-height: min(90vh, 960px); + overflow: auto; +} + +.settings-section-title { + margin: 20px 0 8px; + font-size: 15px; +} + +.settings-subtitle { + margin: 16px 0 8px; + font-size: 14px; +} + +.settings-age-rule { + align-items: end; + margin-bottom: 8px; +} + +.settings-lora-row { + align-items: end; + margin-bottom: 8px; +} + +.settings-lora-row .field { + flex: 1; +} + +.people__portrait-prompt-meta { + margin: 0 0 8px; + font-size: 13px; color: var(--text-muted, #666); } +.field--grow { + flex: 1; +} + .people__swarm-status { margin: 0 0 12px; font-size: 13px; diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 913f758..50c94e5 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -11,6 +11,7 @@ import { schoolWord, t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { confirmDialog } from './confirmDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts'; +import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts'; import { SchoolCard } from './schoolCard.ts'; interface MainMenuOptions { @@ -35,6 +36,10 @@ export class MainMenu { class: 'button button--primary', type: 'button', }); + private readonly settingsButton = el('button', { + class: 'button', + type: 'button', + }); private readonly limitHint = el('p', { class: 'hint' }); private readonly status = el('p', { class: 'hint hint--error' }); @@ -47,6 +52,7 @@ export class MainMenu { constructor(private readonly options: MainMenuOptions) { this.createButton.addEventListener('click', () => void this.openCreateDialog()); + this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog()); this.status.hidden = true; this.root.append( @@ -54,7 +60,7 @@ export class MainMenu { 'header', { class: 'screen__header' }, this.title, - el('div', { class: 'screen__actions' }, this.createButton), + el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton), ), this.limitHint, this.status, @@ -73,6 +79,7 @@ export class MainMenu { localize(): void { this.title.textContent = t('schoolsTitle'); this.createButton.textContent = t('createSchool'); + this.settingsButton.textContent = t('settings'); this.emptyHint.textContent = t('emptySchools'); for (const card of this.cards.values()) { diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index f1d57ed..8fbbc72 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -489,6 +489,7 @@ function portraitPromptView( { class: 'people__portrait-prompt-wrap' }, toggle, el('div', { class: 'people__portrait-prompt-view' }, + el('p', { class: 'people__portrait-prompt-meta', text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }) }), el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }), el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }), el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }), diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts new file mode 100644 index 0000000..75cc64b --- /dev/null +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts @@ -0,0 +1,435 @@ +import { + ApiError, + fetchGameStatus, + fetchSwarmUiDiscovery, + fetchSwarmUiSettings, + saveSwarmUiSettings, + type SwarmUiDiscovery, + type SwarmUiLoraEntry, + type SwarmUiPresetDefinition, + type SwarmUiSettingsFile, +} from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; +import { Modal } from './modal.ts'; + +export function swarmUiSettingsDialog(): Promise { + const modal = new Modal(false); + const error = el('p', { class: 'dialog__error' }); + error.hidden = true; + + const statusLine = el('p', { class: 'hint' }); + const editPresetSelect = el('select', { class: 'input' }); + const activePresetSelect = el('select', { class: 'input' }); + const formHost = el('div', { class: 'settings-form' }); + const ageRulesHost = el('div', { class: 'settings-age-rules' }); + + let config: SwarmUiSettingsFile | null = null; + let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] }; + let editingPresetId = ''; + + const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) }); + const saveButton = el('button', { class: 'button button--primary', type: 'submit' }); + const addPresetButton = el('button', { class: 'button button--small', type: 'button' }); + const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' }); + const refreshDiscoveryButton = el('button', { class: 'button button--small', type: 'button' }); + + const form = el( + 'form', + { class: 'form form--settings' }, + el('h2', { class: 'dialog__title' }), + statusLine, + el('div', { class: 'field__row' }, refreshDiscoveryButton), + el('label', { class: 'field' }, el('span', { class: 'field__label' }), activePresetSelect), + el( + 'div', + { class: 'field field__row' }, + el('label', { class: 'field field--grow' }, el('span', { class: 'field__label' }), editPresetSelect), + addPresetButton, + ), + formHost, + el('h3', { class: 'settings-section-title' }), + ageRulesHost, + el('div', { class: 'field__row' }, addAgeRuleButton), + error, + el('div', { class: 'dialog__actions' }, cancelButton, saveButton), + ); + + form.addEventListener('submit', (event) => { + event.preventDefault(); + void submit(); + }); + + addPresetButton.addEventListener('click', () => { + if (config === null) { + return; + } + + const id = `preset-${config.presets.length + 1}`; + const source = currentPreset() ?? config.presets[0]; + config.presets.push({ + ...structuredClone(source), + id, + label: id, + }); + editingPresetId = id; + paintPresetSelectors(); + paintPresetForm(); + paintAgeRules(); + }); + + addAgeRuleButton.addEventListener('click', () => { + if (config === null) { + return; + } + + config.ageRules.push({ + minAge: 6, + maxAge: 11, + presetId: config.activePresetId, + }); + paintAgeRules(); + }); + + refreshDiscoveryButton.addEventListener('click', () => void reloadDiscovery()); + editPresetSelect.addEventListener('change', () => { + editingPresetId = editPresetSelect.value; + paintPresetForm(); + }); + activePresetSelect.addEventListener('change', () => { + if (config !== null) { + config.activePresetId = activePresetSelect.value; + } + + paintAgeRules(); + }); + + const dialog = modal.element; + dialog.classList.add('dialog--screen'); + dialog.append(form); + + void init(); + + return modal.open(saveButton); + + async function init(): Promise { + localize(); + try { + const [settings, gameStatus, lists] = await Promise.all([ + fetchSwarmUiSettings(), + fetchGameStatus(), + fetchSwarmUiDiscovery(), + ]); + config = settings; + discovery = lists; + editingPresetId = settings.presets[0]?.id ?? ''; + paintStatus(gameStatus.swarmUiConfigured, gameStatus.swarmUiConnected, lists.connected); + paintPresetSelectors(); + paintPresetForm(); + paintAgeRules(); + } catch { + showError(t('settingsLoadFailed')); + } + } + + async function reloadDiscovery(): Promise { + refreshDiscoveryButton.disabled = true; + try { + discovery = await fetchSwarmUiDiscovery(); + paintStatus(true, discovery.connected, discovery.connected); + paintPresetForm(); + } catch { + showError(t('settingsDiscoveryFailed')); + } finally { + refreshDiscoveryButton.disabled = false; + } + } + + async function submit(): Promise { + if (config === null) { + return; + } + + syncActivePresetFromForm(); + saveButton.disabled = true; + error.hidden = true; + try { + await saveSwarmUiSettings(config); + modal.close(true); + } catch (err) { + showError(err instanceof ApiError ? err.message : t('settingsSaveFailed')); + } finally { + saveButton.disabled = false; + } + } + + function showError(message: string): void { + error.textContent = message; + error.hidden = false; + } + + function paintStatus(configured: boolean, connected: boolean | null, discoveryConnected: boolean): void { + if (!configured) { + statusLine.textContent = t('settingsSwarmNotConfigured'); + return; + } + + if (connected === true && discoveryConnected) { + statusLine.textContent = t('settingsSwarmReady'); + return; + } + + statusLine.textContent = t('settingsSwarmOffline'); + } + + function paintPresetSelectors(): void { + if (config === null) { + return; + } + + editPresetSelect.replaceChildren( + ...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })), + ); + editPresetSelect.value = editingPresetId; + + activePresetSelect.replaceChildren( + ...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })), + ); + activePresetSelect.value = config.activePresetId; + } + + function currentPreset(): SwarmUiPresetDefinition | undefined { + return config?.presets.find((preset) => preset.id === editingPresetId); + } + + function paintPresetForm(): void { + formHost.replaceChildren(); + const preset = currentPreset(); + if (preset === null || preset === undefined) { + return; + } + + formHost.append( + textField(t('settingsPresetLabel'), preset.label, (value) => { + preset.label = value; + paintPresetSelectors(); + }), + choiceField(t('settingsModel'), preset.model, discovery.models, (value) => { + preset.model = value; + }), + numberField(t('settingsSteps'), preset.steps, (value) => { + preset.steps = value; + }), + numberField(t('settingsCfg'), preset.cfgScale, (value) => { + preset.cfgScale = value; + }, 0.1), + numberField(t('settingsClipSkip'), preset.clipSkip, (value) => { + preset.clipSkip = value; + }), + choiceField(t('settingsSampler'), preset.sampler, discovery.samplers, (value) => { + preset.sampler = value; + }), + choiceField(t('settingsScheduler'), preset.scheduler, discovery.schedulers, (value) => { + preset.scheduler = value; + }), + numberField(t('settingsSeed'), preset.seed, (value) => { + preset.seed = value; + }), + textareaField(t('settingsPositive'), preset.positive, (value) => { + preset.positive = value; + }), + textareaField(t('settingsNegative'), preset.negative, (value) => { + preset.negative = value; + }), + loraSection(t('settingsPositiveLoras'), preset.positiveLoras), + loraSection(t('settingsNegativeLoras'), preset.negativeLoras), + kindSection(t('settingsAvatarPreset'), preset.avatar), + kindSection(t('settingsCustomPreset'), preset.custom), + kindSection(t('settingsFullBodyPreset'), preset.fullBody), + ); + } + + function syncActivePresetFromForm(): void { + // Values are bound live through closures; nothing extra to scrape from DOM. + } + + function paintAgeRules(): void { + ageRulesHost.replaceChildren(); + if (config === null) { + return; + } + + for (const rule of config.ageRules) { + const minInput = el('input', { class: 'input', type: 'number', value: String(rule.minAge) }); + minInput.min = '0'; + minInput.max = '120'; + minInput.addEventListener('input', () => { + rule.minAge = Number(minInput.value); + }); + + const maxInput = el('input', { class: 'input', type: 'number', value: String(rule.maxAge) }); + maxInput.min = '0'; + maxInput.max = '120'; + maxInput.addEventListener('input', () => { + rule.maxAge = Number(maxInput.value); + }); + + const presetSelect = el( + 'select', + { class: 'input' }, + ...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })), + ); + presetSelect.value = rule.presetId; + presetSelect.addEventListener('change', () => { + rule.presetId = presetSelect.value; + }); + + const removeButton = el('button', { + class: 'button button--small button--danger', + type: 'button', + text: t('settingsRemoveRule'), + onClick: () => { + config!.ageRules = config!.ageRules.filter((entry) => entry !== rule); + paintAgeRules(); + }, + }); + + ageRulesHost.append( + el( + 'div', + { class: 'settings-age-rule field__row' }, + el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMin') }), minInput), + el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMax') }), maxInput), + el('label', { class: 'field field--grow' }, el('span', { class: 'field__label', text: t('settingsAgePreset') }), presetSelect), + removeButton, + ), + ); + } + } + + function loraSection(title: string, loras: SwarmUiLoraEntry[]): HTMLElement { + const host = el('div', { class: 'settings-loras' }); + const heading = el('h4', { class: 'settings-subtitle', text: title }); + const rows = el('div', { class: 'settings-lora-rows' }); + + const paintRows = (): void => { + rows.replaceChildren(); + for (const lora of loras) { + const nameField = choiceField('', lora.name, discovery.loras, (value) => { + lora.name = value; + }); + const weightInput = el('input', { class: 'input', type: 'number', value: String(lora.weight) }); + weightInput.step = '0.05'; + weightInput.min = '-4'; + weightInput.max = '4'; + weightInput.addEventListener('input', () => { + lora.weight = Number(weightInput.value); + }); + const remove = el('button', { + class: 'button button--small button--danger', + type: 'button', + text: '×', + onClick: () => { + const index = loras.indexOf(lora); + if (index >= 0) { + loras.splice(index, 1); + } + + paintRows(); + }, + }); + rows.append(el('div', { class: 'field__row settings-lora-row' }, nameField, weightInput, remove)); + } + }; + + paintRows(); + const add = el('button', { + class: 'button button--small', + type: 'button', + text: t('settingsAddLora'), + onClick: () => { + loras.push({ name: discovery.loras[0] ?? '', weight: 1 }); + paintRows(); + }, + }); + + host.append(heading, rows, add); + return host; + } + + function kindSection(title: string, preset: SwarmUiPresetDefinition['avatar']): HTMLElement { + return el( + 'div', + { class: 'settings-kind' }, + el('h4', { class: 'settings-subtitle', text: title }), + numberField(t('settingsWidth'), preset.width, (value) => { + preset.width = value; + }), + numberField(t('settingsHeight'), preset.height, (value) => { + preset.height = value; + }), + textareaField(t('settingsKindPositive'), preset.positive, (value) => { + preset.positive = value; + }), + ); + } + + function localize(): void { + form.querySelector('.dialog__title')!.textContent = t('settingsTitle'); + form.querySelectorAll('.field__label')[0]!.textContent = t('settingsActivePreset'); + form.querySelectorAll('.field__label')[1]!.textContent = t('settingsEditPreset'); + form.querySelector('.settings-section-title')!.textContent = t('settingsAgeRules'); + cancelButton.textContent = t('cancel'); + saveButton.textContent = t('save'); + addPresetButton.textContent = t('settingsAddPreset'); + addAgeRuleButton.textContent = t('settingsAddAgeRule'); + refreshDiscoveryButton.textContent = t('settingsRefreshLists'); + } +} + +function textField(label: string, value: string, onChange: (value: string) => void): HTMLElement { + const input = el('input', { class: 'input', type: 'text', value }); + input.addEventListener('input', () => onChange(input.value)); + return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input); +} + +function numberField( + label: string, + value: number, + onChange: (value: number) => void, + step = 1, +): HTMLElement { + const input = el('input', { class: 'input', type: 'number', value: String(value) }); + input.step = String(step); + input.addEventListener('input', () => onChange(Number(input.value))); + return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input); +} + +function textareaField(label: string, value: string, onChange: (value: string) => void): HTMLElement { + const input = el('textarea', { class: 'input', rows: 3, value }); + input.addEventListener('input', () => onChange(input.value)); + return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input); +} + +function choiceField( + label: string, + value: string, + options: readonly string[], + onChange: (value: string) => void, +): HTMLElement { + const values = options.includes(value) ? options : value.length > 0 ? [value, ...options] : options; + if (values.length === 0) { + const input = el('input', { class: 'input', type: 'text', value }); + input.addEventListener('input', () => onChange(input.value)); + return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input); + } + + const select = el( + 'select', + { class: 'input' }, + ...values.map((entry) => el('option', { value: entry, text: entry })), + ); + select.value = value; + select.addEventListener('change', () => onChange(select.value)); + return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select); +} diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 0626b5f..b269c1a 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -295,7 +295,9 @@ internal static class SchoolEndpoints PortraitKindParser.ToApiValue(result.Kind), result.Positive, result.Negative, - result.PromptExtra)), + result.PromptExtra, + result.PresetId, + result.PresetLabel)), PortraitPromptBuildOutcome.InvalidPrompt => Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."), PortraitPromptBuildOutcome.UnknownPerson => diff --git a/src/HSchool.Server/Api/SettingsEndpoints.cs b/src/HSchool.Server/Api/SettingsEndpoints.cs new file mode 100644 index 0000000..68b0a9e --- /dev/null +++ b/src/HSchool.Server/Api/SettingsEndpoints.cs @@ -0,0 +1,45 @@ +using HSchool.Server.Game; + +namespace HSchool.Server.Api; + +internal static class SettingsEndpoints +{ + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder endpoints) + { + var settings = endpoints.MapGroup("/api/settings"); + + settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current)) + .WithName("GetSwarmUiSettings"); + + settings.MapPut("/swarmui", (SwarmUiConfigFile body, SwarmUiSettingsStore store) => + { + try + { + body.NormalizeAfterLoad(); + return Results.Ok(store.Save(body)); + } + catch (InvalidOperationException ex) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-body", ex.Message); + } + }) + .WithName("PutSwarmUiSettings"); + + settings.MapGet("/swarmui/discovery", async (SwarmUiClient swarm, CancellationToken cancellationToken) => + { + if (!swarm.IsConfigured) + { + return Results.Ok(SwarmUiDiscovery.Offline); + } + + var discovery = await swarm.FetchDiscoveryAsync(cancellationToken); + return Results.Ok(discovery); + }) + .WithName("GetSwarmUiDiscovery"); + + return endpoints; + } + + private static IResult Problem(int status, string code, string detail) => + Results.Problem(detail, statusCode: status, extensions: new Dictionary { ["code"] = code }); +} diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index a87028c..bbd4b3e 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -7,16 +7,16 @@ internal static class PortraitPromptBuilder { public static (string Positive, string Negative) Build( PersonCardResponse card, - SwarmUiSettings settings, + SwarmUiResolvedProfile profile, PortraitKind kind, string? promptExtra = null) { - var preset = settings.PresetFor(kind); + var kindPreset = profile.KindPreset; var parts = new List(); - if (!string.IsNullOrWhiteSpace(settings.Positive)) + if (!string.IsNullOrWhiteSpace(profile.Positive)) { - parts.Add(settings.Positive.Trim()); + parts.Add(profile.Positive.Trim()); } if (kind == PortraitKind.Custom) @@ -26,12 +26,12 @@ internal static class PortraitPromptBuilder parts.Add(promptExtra.Trim()); } } - else if (!string.IsNullOrWhiteSpace(preset.Positive)) + else if (!string.IsNullOrWhiteSpace(kindPreset.Positive)) { - parts.Add(preset.Positive.Trim()); + parts.Add(kindPreset.Positive.Trim()); } - parts.Add(card.Female ? "A young woman" : "A young man"); + parts.Add(DescribeSubject(card)); parts.Add($"age {card.Age}"); foreach (var row in card.Body) @@ -53,7 +53,25 @@ internal static class PortraitPromptBuilder } var positive = string.Join(", ", parts.Where(part => part.Length > 0)); - var negative = settings.Negative?.Trim() ?? string.Empty; + var negative = SwarmUiLoraFormatter.AppendLoraTags( + profile.Negative.Trim(), + profile.NegativeLoras); + return (positive, negative); } + + private static string DescribeSubject(PersonCardResponse card) + { + if (card.Age <= 11) + { + return card.Female ? "A young girl" : "A young boy"; + } + + if (card.Age <= 17) + { + return card.Female ? "A teenage girl" : "A teenage boy"; + } + + return card.Female ? "A young woman" : "A young man"; + } } diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index 1063fcb..f4992a9 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -5,7 +5,7 @@ namespace HSchool.Server.Game; internal sealed class PortraitService( SchoolStore store, SwarmUiClient swarm, - SwarmUiSettings settings, + SwarmUiSettingsStore settingsStore, GameCommandQueue commands, ILogger logger) { @@ -74,12 +74,15 @@ internal sealed class PortraitService( return PortraitPromptBuildResult.UnknownSchool; } - var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved); + var profile = settingsStore.Resolve(outcome.Card.Age, kind); + var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved); return PortraitPromptBuildResult.Succeeded( kind, positive, negative, - kind == PortraitKind.Custom ? resolved : null); + kind == PortraitKind.Custom ? resolved : null, + profile.PresetId, + profile.PresetLabel); } public async Task GenerateAsync( @@ -119,11 +122,12 @@ internal sealed class PortraitService( return PortraitGenerationResult.UnknownSchool; } - var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra); + var profile = settingsStore.Resolve(outcome.Card.Age, kind); + var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra); try { - var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken); + var bytes = await swarm.GenerateAsync(positive, negative, profile, cancellationToken); store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null); var flags = Flags(schoolId, personId); var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId); @@ -203,30 +207,36 @@ internal sealed record PortraitPromptBuildResult( PortraitKind Kind, string Positive, string Negative, - string? PromptExtra) + string? PromptExtra, + string PresetId, + string PresetLabel) { public static PortraitPromptBuildResult UnknownSchool { get; } = - new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null); + new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null, "", ""); public static PortraitPromptBuildResult UnknownPerson { get; } = - new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null); + new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null, "", ""); public static PortraitPromptBuildResult InvalidPrompt { get; } = - new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null); + new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null, "", ""); public static PortraitPromptBuildResult Succeeded( PortraitKind kind, string positive, string negative, - string? promptExtra) => - new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra); + string? promptExtra, + string presetId, + string presetLabel) => + new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra, presetId, presetLabel); } internal sealed record PortraitPromptResponse( string Kind, string Positive, string Negative, - string? PromptExtra); + string? PromptExtra, + string PresetId, + string PresetLabel); internal enum PortraitGenerationOutcome { diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index bd5708e..ed51c3a 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -55,11 +55,39 @@ internal sealed class SwarmUiClient } } + public async Task FetchDiscoveryAsync(CancellationToken cancellationToken) + { + if (!IsConfigured) + { + return SwarmUiDiscovery.Offline; + } + + try + { + return await RunWithSessionAsync(async () => + { + using var content = new StringContent( + JsonSerializer.Serialize(new { session_id = _sessionId }), + Encoding.UTF8, + "application/json"); + using var response = await _http.PostAsync("/API/ListT2IParams", content, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(json); + return SwarmUiDiscoveryParser.Parse(document.RootElement); + }, cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "SwarmUI discovery failed."); + return SwarmUiDiscovery.Offline; + } + } + public async Task GenerateAsync( string prompt, string negativePrompt, - SwarmUiSettings settings, - PortraitKind kind, + SwarmUiResolvedProfile profile, CancellationToken cancellationToken) { if (!IsConfigured) @@ -69,7 +97,7 @@ internal sealed class SwarmUiClient return await RunWithSessionAsync(async () => { - var preset = settings.PresetFor(kind); + var kind = profile.KindPreset; var body = new Dictionary { ["session_id"] = _sessionId, @@ -77,28 +105,33 @@ internal sealed class SwarmUiClient ["donotsave"] = true, ["prompt"] = prompt, ["negativeprompt"] = negativePrompt, - ["model"] = settings.Model, - ["steps"] = settings.Steps, - ["cfgscale"] = settings.CfgScale, - ["width"] = preset.Width, - ["height"] = preset.Height, - ["seed"] = settings.Seed, + ["model"] = profile.Model, + ["steps"] = profile.Steps, + ["cfgscale"] = profile.CfgScale, + ["width"] = kind.Width, + ["height"] = kind.Height, + ["seed"] = profile.Seed, }; - if (!string.IsNullOrWhiteSpace(settings.Sampler)) + if (!string.IsNullOrWhiteSpace(profile.Sampler)) { - body["sampler"] = settings.Sampler; + body["sampler"] = profile.Sampler; } - if (!string.IsNullOrWhiteSpace(settings.Scheduler)) + if (!string.IsNullOrWhiteSpace(profile.Scheduler)) { - body["scheduler"] = settings.Scheduler; + body["scheduler"] = profile.Scheduler; } - if (settings.ClipSkip > 0) + if (profile.ClipSkip > 0) { - // SwarmUI "CLIP Stop At Layer" — clip skip N is layer -N from the end. - body["clipstopatlayer"] = -settings.ClipSkip; + body["clipstopatlayer"] = -profile.ClipSkip; + } + + var loras = SwarmUiLoraFormatter.FormatForApi(profile.PositiveLoras); + if (loras is not null) + { + body["loras"] = loras; } using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); diff --git a/src/HSchool.Server/Game/SwarmUiDiscovery.cs b/src/HSchool.Server/Game/SwarmUiDiscovery.cs new file mode 100644 index 0000000..69a76aa --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiDiscovery.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +namespace HSchool.Server.Game; + +internal sealed record SwarmUiDiscovery( + bool Connected, + IReadOnlyList Models, + IReadOnlyList Loras, + IReadOnlyList Samplers, + IReadOnlyList Schedulers) +{ + public static SwarmUiDiscovery Offline { get; } = new(false, [], [], [], []); +} + +internal static class SwarmUiDiscoveryParser +{ + public static SwarmUiDiscovery Parse(JsonElement root) + { + var models = ReadModelNames(root, "Stable-Diffusion"); + var loras = ReadModelNames(root, "LoRA"); + var samplers = ReadParamValues(root, "sampler"); + var schedulers = ReadParamValues(root, "scheduler"); + return new SwarmUiDiscovery(true, models, loras, samplers, schedulers); + } + + private static IReadOnlyList ReadModelNames(JsonElement root, string subtype) + { + if (!root.TryGetProperty("models", out var models) || models.ValueKind != JsonValueKind.Object) + { + return []; + } + + if (!models.TryGetProperty(subtype, out var list)) + { + return []; + } + + return ReadStringList(list); + } + + private static IReadOnlyList ReadParamValues(JsonElement root, string paramId) + { + if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) + { + return []; + } + + foreach (var entry in list.EnumerateArray()) + { + if (!entry.TryGetProperty("id", out var id) || id.GetString() != paramId) + { + continue; + } + + if (!entry.TryGetProperty("values", out var values) || values.ValueKind != JsonValueKind.Array) + { + return []; + } + + return ReadStringList(values); + } + + return []; + } + + private static IReadOnlyList ReadStringList(JsonElement list) + { + var names = new List(); + foreach (var item in list.EnumerateArray()) + { + switch (item.ValueKind) + { + case JsonValueKind.String: + AddName(names, item.GetString()); + break; + case JsonValueKind.Array when item.GetArrayLength() > 0: + AddName(names, item[0].GetString()); + break; + } + } + + return names; + } + + private static void AddName(List names, string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return; + } + + if (!names.Contains(name, StringComparer.Ordinal)) + { + names.Add(name); + } + } +} diff --git a/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs b/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs new file mode 100644 index 0000000..5c771ad --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs @@ -0,0 +1,50 @@ +namespace HSchool.Server.Game; + +internal static class SwarmUiLoraFormatter +{ + /// SwarmUI comma-separated lora list: name,weight,name,weight,… + public static string? FormatForApi(IReadOnlyList loras) + { + if (loras.Count == 0) + { + return null; + } + + var parts = new List(loras.Count * 2); + foreach (var lora in loras) + { + if (string.IsNullOrWhiteSpace(lora.Name)) + { + continue; + } + + parts.Add(lora.Name.Trim()); + parts.Add(lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + return parts.Count == 0 ? null : string.Join(',', parts); + } + + public static string AppendLoraTags(string prompt, IReadOnlyList loras) + { + if (loras.Count == 0) + { + return prompt; + } + + var tags = loras + .Where(lora => !string.IsNullOrWhiteSpace(lora.Name)) + .Select(lora => + $"") + .ToList(); + + if (tags.Count == 0) + { + return prompt; + } + + return string.IsNullOrWhiteSpace(prompt) + ? string.Join(' ', tags) + : $"{prompt.TrimEnd()} {string.Join(' ', tags)}"; + } +} diff --git a/src/HSchool.Server/Game/SwarmUiSettings.cs b/src/HSchool.Server/Game/SwarmUiSettings.cs deleted file mode 100644 index 6bbebfa..0000000 --- a/src/HSchool.Server/Game/SwarmUiSettings.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json; - -namespace HSchool.Server.Game; - -/// Generation defaults and prompt templates loaded from swarmui.json next to the server. -internal sealed class SwarmUiSettings -{ - public string Model { get; init; } = ""; - - public int Steps { get; init; } = 8; - - public double CfgScale { get; init; } = 1; - - public int ClipSkip { get; init; } = 1; - - public string Sampler { get; init; } = "euler"; - - public string Scheduler { get; init; } = ""; - - public long Seed { get; init; } = -1; - - public string Positive { get; init; } = ""; - - public string Negative { get; init; } = ""; - - public SwarmUiPreset Avatar { get; init; } = new(); - - public SwarmUiPreset Custom { get; init; } = new(); - - public SwarmUiPreset FullBody { get; init; } = new(); - - public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch - { - PortraitKind.Avatar => Avatar, - PortraitKind.Custom => Custom, - PortraitKind.Full => FullBody, - _ => throw new ArgumentOutOfRangeException(nameof(kind)), - }; - - internal sealed class SwarmUiPreset - { - public int Width { get; init; } = 512; - - public int Height { get; init; } = 512; - - public string Positive { get; init; } = ""; - } - - private static readonly JsonSerializerOptions Json = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - public static SwarmUiSettings Load(IHostEnvironment environment, ILogger logger) - { - var path = Path.Combine(environment.ContentRootPath, "swarmui.json"); - if (!File.Exists(path)) - { - logger.LogWarning("SwarmUI settings file {Path} is missing; portrait generation will use empty defaults.", path); - return new SwarmUiSettings(); - } - - try - { - var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, Json) ?? new SwarmUiSettings(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path); - return new SwarmUiSettings(); - } - } -} diff --git a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs new file mode 100644 index 0000000..d825ddd --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs @@ -0,0 +1,443 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSchool.Server.Game; + +/// Loads, saves and serves SwarmUI generation presets from swarmui.json. +internal sealed class SwarmUiSettingsStore(IHostEnvironment environment, ILogger logger) +{ + private readonly Lock _lock = new(); + private SwarmUiConfigFile _config = SwarmUiConfigFile.CreateDefault(); + + public string FilePath { get; } = Path.Combine(environment.ContentRootPath, "swarmui.json"); + + public SwarmUiConfigFile Current + { + get + { + lock (_lock) + { + return _config; + } + } + } + + public void Load() + { + lock (_lock) + { + _config = ReadFromDisk(); + } + } + + public SwarmUiConfigFile Save(SwarmUiConfigFile config) + { + config.Validate(); + var json = JsonSerializer.Serialize(config, JsonOptions); + var temp = FilePath + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, FilePath, overwrite: true); + lock (_lock) + { + _config = config; + } + + logger.LogInformation("SwarmUI settings saved to {Path}.", FilePath); + return config; + } + + public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind) + { + lock (_lock) + { + return _config.Resolve(age, kind); + } + } + + private SwarmUiConfigFile ReadFromDisk() + { + if (!File.Exists(FilePath)) + { + logger.LogWarning("SwarmUI settings file {Path} is missing; using built-in defaults.", FilePath); + return SwarmUiConfigFile.CreateDefault(); + } + + try + { + var json = File.ReadAllText(FilePath); + var loaded = JsonSerializer.Deserialize(json, JsonOptions) ?? SwarmUiConfigFile.CreateDefault(); + loaded.NormalizeAfterLoad(); + loaded.Validate(); + return loaded; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}; using built-in defaults.", FilePath); + return SwarmUiConfigFile.CreateDefault(); + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; +} + +internal sealed class SwarmUiConfigFile +{ + public string ActivePresetId { get; set; } = "default"; + + public List Presets { get; set; } = []; + + public List AgeRules { get; set; } = []; + + // Legacy flat fields — read for migration only. + public string? Model { get; set; } + public int? Steps { get; set; } + public double? CfgScale { get; set; } + public int? ClipSkip { get; set; } + public string? Sampler { get; set; } + public string? Scheduler { get; set; } + public long? Seed { get; set; } + public string? Positive { get; set; } + public string? Negative { get; set; } + public SwarmUiKindPreset? Avatar { get; set; } + public SwarmUiKindPreset? Custom { get; set; } + public SwarmUiKindPreset? FullBody { get; set; } + + public void NormalizeAfterLoad() + { + if (Presets.Count == 0 && !string.IsNullOrWhiteSpace(Model)) + { + Presets = + [ + new SwarmUiPresetDefinition + { + Id = "default", + Label = "Default", + Model = Model ?? "", + Steps = Steps ?? 8, + CfgScale = CfgScale ?? 1, + ClipSkip = ClipSkip ?? 1, + Sampler = Sampler ?? "", + Scheduler = Scheduler ?? "", + Seed = Seed ?? -1, + Positive = Positive ?? "", + Negative = Negative ?? "", + Avatar = Avatar ?? new SwarmUiKindPreset(), + Custom = Custom ?? new SwarmUiKindPreset(), + FullBody = FullBody ?? new SwarmUiKindPreset(), + }, + ]; + ActivePresetId = "default"; + } + + if (Presets.Count == 0) + { + Presets = [SwarmUiPresetDefinition.CreateDefault()]; + ActivePresetId = "default"; + } + + foreach (var preset in Presets) + { + preset.Avatar ??= new SwarmUiKindPreset(); + preset.Custom ??= new SwarmUiKindPreset(); + preset.FullBody ??= new SwarmUiKindPreset(); + preset.PositiveLoras ??= []; + preset.NegativeLoras ??= []; + } + + if (string.IsNullOrWhiteSpace(ActivePresetId) || FindPreset(ActivePresetId) is null) + { + ActivePresetId = Presets[0].Id; + } + } + + public void Validate() + { + if (Presets.Count == 0) + { + throw new InvalidOperationException("At least one preset is required."); + } + + var ids = new HashSet(StringComparer.Ordinal); + foreach (var preset in Presets) + { + if (string.IsNullOrWhiteSpace(preset.Id)) + { + throw new InvalidOperationException("Every preset needs a non-empty id."); + } + + if (!ids.Add(preset.Id)) + { + throw new InvalidOperationException($"Duplicate preset id '{preset.Id}'."); + } + + if (string.IsNullOrWhiteSpace(preset.Label)) + { + preset.Label = preset.Id; + } + + preset.Validate(); + } + + if (FindPreset(ActivePresetId) is null) + { + throw new InvalidOperationException($"Active preset '{ActivePresetId}' does not exist."); + } + + foreach (var rule in AgeRules) + { + if (rule.MinAge > rule.MaxAge) + { + throw new InvalidOperationException($"Age rule {rule.MinAge}-{rule.MaxAge} is inverted."); + } + + if (FindPreset(rule.PresetId) is null) + { + throw new InvalidOperationException($"Age rule references unknown preset '{rule.PresetId}'."); + } + } + } + + public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind) + { + var presetId = ResolvePresetId(age); + var preset = FindPreset(presetId) ?? FindPreset(ActivePresetId) ?? Presets[0]; + return preset.ToProfile(kind); + } + + private string ResolvePresetId(int age) + { + foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge)) + { + if (age >= rule.MinAge && age <= rule.MaxAge) + { + return rule.PresetId; + } + } + + return ActivePresetId; + } + + private SwarmUiPresetDefinition? FindPreset(string id) => + Presets.FirstOrDefault(preset => string.Equals(preset.Id, id, StringComparison.Ordinal)); + + public static SwarmUiConfigFile CreateDefault() => + new() + { + ActivePresetId = "default", + Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()], + AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }], + }; +} + +internal sealed class SwarmUiPresetDefinition +{ + public string Id { get; set; } = ""; + + public string Label { get; set; } = ""; + + public string Model { get; set; } = ""; + + public int Steps { get; set; } = 8; + + public double CfgScale { get; set; } = 1; + + public int ClipSkip { get; set; } = 1; + + public string Sampler { get; set; } = ""; + + public string Scheduler { get; set; } = ""; + + public long Seed { get; set; } = -1; + + public string Positive { get; set; } = ""; + + public string Negative { get; set; } = ""; + + public List? PositiveLoras { get; set; } + + public List? NegativeLoras { get; set; } + + public SwarmUiKindPreset? Avatar { get; set; } + + public SwarmUiKindPreset? Custom { get; set; } + + public SwarmUiKindPreset? FullBody { get; set; } + + public void Validate() + { + if (Steps is < 1 or > 200) + { + throw new InvalidOperationException($"Preset '{Id}' steps must be between 1 and 200."); + } + + if (CfgScale is < 0 or > 30) + { + throw new InvalidOperationException($"Preset '{Id}' cfgScale must be between 0 and 30."); + } + + if (ClipSkip is < 0 or > 12) + { + throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12."); + } + + ValidateLoras(PositiveLoras, "positive"); + ValidateLoras(NegativeLoras, "negative"); + } + + private void ValidateLoras(IReadOnlyList? loras, string side) + { + if (loras is null) + { + return; + } + + foreach (var lora in loras) + { + if (string.IsNullOrWhiteSpace(lora.Name)) + { + throw new InvalidOperationException($"Preset '{Id}' has an empty {side} LoRA name."); + } + + if (lora.Weight is < -4 or > 4) + { + throw new InvalidOperationException($"Preset '{Id}' LoRA '{lora.Name}' weight is out of range."); + } + } + } + + public SwarmUiResolvedProfile ToProfile(PortraitKind kind) => + new( + Id, + Label, + Model, + Steps, + CfgScale, + ClipSkip, + Sampler, + Scheduler, + Seed, + Positive, + Negative, + PositiveLoras ?? [], + NegativeLoras ?? [], + KindPresetFor(kind)); + + private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch + { + PortraitKind.Avatar => Avatar ?? new SwarmUiKindPreset(), + PortraitKind.Custom => Custom ?? new SwarmUiKindPreset(), + PortraitKind.Full => FullBody ?? new SwarmUiKindPreset(), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + public static SwarmUiPresetDefinition CreateDefault() => + new() + { + Id = "default", + Label = "Default", + Model = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", + Steps = 4, + CfgScale = 2, + ClipSkip = 2, + Sampler = "dpmpp_sde", + Scheduler = "karras", + Seed = 3346112079, + Positive = + "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", + Negative = + "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", + Avatar = new SwarmUiKindPreset + { + Width = 1024, + Height = 1024, + Positive = "close up, head and shoulders portrait, facing the camera, upper body visible.", + }, + Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 }, + FullBody = new SwarmUiKindPreset + { + Width = 896, + Height = 1152, + Positive = "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible.", + }, + }; + + public static SwarmUiPresetDefinition CreateChild() + { + var adult = CreateDefault(); + return new SwarmUiPresetDefinition + { + Id = "child", + Label = "Children", + Model = adult.Model, + Steps = adult.Steps, + CfgScale = adult.CfgScale, + ClipSkip = adult.ClipSkip, + Sampler = adult.Sampler, + Scheduler = adult.Scheduler, + Seed = adult.Seed, + Positive = + "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus", + Negative = adult.Negative, + Avatar = new SwarmUiKindPreset + { + Width = 1024, + Height = 1024, + Positive = "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features.", + }, + Custom = adult.Custom, + FullBody = new SwarmUiKindPreset + { + Width = 896, + Height = 1152, + Positive = "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible.", + }, + }; + } +} + +internal sealed class SwarmUiAgeRule +{ + public int MinAge { get; set; } + + public int MaxAge { get; set; } + + public string PresetId { get; set; } = ""; +} + +internal sealed class SwarmUiKindPreset +{ + public int Width { get; set; } = 512; + + public int Height { get; set; } = 512; + + public string Positive { get; set; } = ""; +} + +internal sealed class SwarmUiLoraEntry +{ + public string Name { get; set; } = ""; + + public double Weight { get; set; } = 1; +} + +internal sealed record SwarmUiResolvedProfile( + string PresetId, + string PresetLabel, + string Model, + int Steps, + double CfgScale, + int ClipSkip, + string Sampler, + string Scheduler, + long Seed, + string Positive, + string Negative, + IReadOnlyList PositiveLoras, + IReadOnlyList NegativeLoras, + SwarmUiKindPreset KindPreset); diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 6d5ab47..f80e7cd 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -38,7 +38,14 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSwarmUi(builder.Configuration); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService(), sp.GetRequiredService().CreateLogger("SwarmUiSettings"))); +builder.Services.AddSingleton(sp => +{ + var store = new SwarmUiSettingsStore( + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger()); + store.Load(); + return store; +}); builder.Services.AddSingleton(); builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName)); @@ -58,6 +65,7 @@ app.UseWebSockets(new WebSocketOptions }); app.MapSchoolEndpoints(); +app.MapSettingsEndpoints(); app.MapTimetableEndpoints(); app.MapModEndpoints(); diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index 9a900f2..aeb1e57 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -1,25 +1,66 @@ { - "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", - "steps": 4, - "cfgScale": 2, - "clipSkip": 2, - "sampler": "dpmpp_sde", - "scheduler": "karras", - "seed": 3346112079, - "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", - "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo", - "avatar": { - "width": 1024, - "height": 1024, - "positive": "close up, head and shoulders portrait, facing the camera, upper body visible." - }, - "custom": { - "width": 896, - "height": 1152 - }, - "fullBody": { - "width": 896, - "height": 1152, - "positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." - } + "activePresetId": "default", + "presets": [ + { + "id": "default", + "label": "Default", + "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", + "steps": 4, + "cfgScale": 2, + "clipSkip": 2, + "sampler": "dpmpp_sde", + "scheduler": "karras", + "seed": 3346112079, + "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", + "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", + "positiveLoras": [], + "negativeLoras": [], + "avatar": { + "width": 1024, + "height": 1024, + "positive": "close up, head and shoulders portrait, facing the camera, upper body visible." + }, + "custom": { + "width": 896, + "height": 1152 + }, + "fullBody": { + "width": 896, + "height": 1152, + "positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." + } + }, + { + "id": "child", + "label": "Children", + "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", + "steps": 4, + "cfgScale": 2, + "clipSkip": 2, + "sampler": "dpmpp_sde", + "scheduler": "karras", + "seed": 3346112079, + "positive": "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus", + "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", + "positiveLoras": [], + "negativeLoras": [], + "avatar": { + "width": 1024, + "height": 1024, + "positive": "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features." + }, + "custom": { + "width": 896, + "height": 1152 + }, + "fullBody": { + "width": 896, + "height": 1152, + "positive": "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible." + } + } + ], + "ageRules": [ + { "minAge": 6, "maxAge": 11, "presetId": "child" } + ] } diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 3dd7ab9..50070ea 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -86,6 +86,18 @@ public class PortraitApiTests(AppHostFixture fixture) Assert.Equal("avatar", payload!.Kind); Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase); Assert.False(string.IsNullOrWhiteSpace(payload.Negative)); + Assert.False(string.IsNullOrWhiteSpace(payload.PresetId)); + } + + [Fact] + public async Task GetSwarmUiSettings_ReturnsPresets() + { + using var client = fixture.App.CreateHttpClient("server"); + + var settings = await client.GetFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(settings); + Assert.NotEmpty(settings!.Presets); + Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); } [Fact] @@ -134,7 +146,17 @@ public class PortraitApiTests(AppHostFixture fixture) bool HasFullBody, string? CustomPortraitPrompt); - private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra); + private sealed record PortraitPromptPayload( + string Kind, + string Positive, + string Negative, + string? PromptExtra, + string PresetId, + string PresetLabel); + + private sealed record SwarmUiSettingsPayload(string ActivePresetId, IReadOnlyList Presets); + + private sealed record SwarmUiPresetPayload(string Id, string Label); private sealed record SavesDirectoryResponse(string Path); } diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 1cbd422..3a4d5b9 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -8,14 +8,10 @@ public class PortraitPromptBuilderTests [Fact] public void Build_IncludesAgeHairAndWornClothing() { - var settings = new SwarmUiSettings - { - Positive = "School photo.", - Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, - }; + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); var card = SampleCard(); - var (positive, _) = PortraitPromptBuilder.Build(card, settings, PortraitKind.Avatar); + var (positive, _) = PortraitPromptBuilder.Build(card, profile, PortraitKind.Avatar); Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase); Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase); @@ -27,15 +23,9 @@ public class PortraitPromptBuilderTests [Fact] public void Build_FullBody_AddsFullBodyPreset() { - var settings = new SwarmUiSettings - { - Positive = "School photo.", - Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, - FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, - }; - - var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Avatar); - var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Full); + var preset = SamplePreset(); + var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Avatar), PortraitKind.Avatar); + var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Full), PortraitKind.Full); Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal); Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal); @@ -45,17 +35,11 @@ public class PortraitPromptBuilderTests [Fact] public void Build_Custom_UsesPromptExtraInsteadOfPreset() { - var settings = new SwarmUiSettings - { - Positive = "School photo.", - Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, - FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, - }; - + var profile = SamplePreset().ToProfile(PortraitKind.Custom); const string extra = "standing in a school hallway, soft window light"; var (customPositive, _) = PortraitPromptBuilder.Build( SampleCard(), - settings, + profile, PortraitKind.Custom, extra); @@ -65,6 +49,35 @@ public class PortraitPromptBuilderTests Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal); } + [Fact] + public void Build_ChildAge_UsesChildSubjectDescriptor() + { + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); + var child = SampleCard() with { Age = 8 }; + var (positive, _) = PortraitPromptBuilder.Build(child, profile, PortraitKind.Avatar); + + Assert.Contains("young girl", positive, StringComparison.OrdinalIgnoreCase); + } + + private static SwarmUiPresetDefinition SamplePreset() + { + var preset = SwarmUiPresetDefinition.CreateDefault(); + preset.Positive = "School photo."; + preset.Avatar = new SwarmUiKindPreset + { + Width = 512, + Height = 512, + Positive = "Head and shoulders.", + }; + preset.FullBody = new SwarmUiKindPreset + { + Width = 512, + Height = 768, + Positive = "Standing full body.", + }; + return preset; + } + private static PersonCardResponse SampleCard() => new( "f0.c0", diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs index d5f357f..05cc26b 100644 --- a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -22,15 +22,10 @@ public class SwarmUiClientTests Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), NullLogger.Instance); - var settings = new SwarmUiSettings - { - Model = "model.safetensors", - Steps = 8, - CfgScale = 1, - Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 }, - }; + var preset = SwarmUiPresetDefinition.CreateDefault(); + var profile = preset.ToProfile(PortraitKind.Avatar); - var bytes = await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None); + var bytes = await client.GenerateAsync("a student", "bad", profile, CancellationToken.None); Assert.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4)); Assert.Contains("/API/GetNewSession", handler.Requests[0]); @@ -85,18 +80,15 @@ public class SwarmUiClientTests Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), NullLogger.Instance); - var settings = new SwarmUiSettings - { - Model = "model.safetensors", - Steps = 4, - CfgScale = 2, - ClipSkip = 2, - Sampler = "dpmpp_sde", - Scheduler = "karras", - Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 }, - }; + var preset = SwarmUiPresetDefinition.CreateDefault(); + preset.Steps = 4; + preset.CfgScale = 2; + preset.ClipSkip = 2; + preset.Sampler = "dpmpp_sde"; + preset.Scheduler = "karras"; + var profile = preset.ToProfile(PortraitKind.Avatar); - await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None); + await client.GenerateAsync("a student", "bad", profile, CancellationToken.None); using var document = JsonDocument.Parse(handler.GenerateBody!); Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32()); diff --git a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs new file mode 100644 index 0000000..463ff28 --- /dev/null +++ b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs @@ -0,0 +1,43 @@ +using HSchool.Server.Game; + +namespace HSchool.Server.Tests; + +public class SwarmUiSettingsStoreTests +{ + [Fact] + public void Resolve_UsesAgeRulePreset() + { + var config = SwarmUiConfigFile.CreateDefault(); + var profile = config.Resolve(8, PortraitKind.Avatar); + + Assert.Equal("child", profile.PresetId); + } + + [Fact] + public void Resolve_FallsBackToActivePresetForAdults() + { + var config = SwarmUiConfigFile.CreateDefault(); + var profile = config.Resolve(34, PortraitKind.Full); + + Assert.Equal("default", profile.PresetId); + } + + [Fact] + public void NormalizeAfterLoad_MigratesLegacyFlatFile() + { + var config = new SwarmUiConfigFile + { + Model = "legacy.safetensors", + Steps = 12, + Positive = "legacy prompt", + Avatar = new SwarmUiKindPreset { Width = 640, Height = 640 }, + }; + + config.NormalizeAfterLoad(); + + Assert.Single(config.Presets); + Assert.Equal("default", config.Presets[0].Id); + Assert.Equal("legacy.safetensors", config.Presets[0].Model); + Assert.Equal(12, config.Presets[0].Steps); + } +}