Stack portrait LoRAs and embeddings from model, preset, and shot kind.

Swarm loads LoRAs via loras/loraweights; embeddings go in the prompt as embed tags, not lora tags.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 14:58:26 +03:00
co-authored by Cursor
parent 5d46550f12
commit 9e217e93eb
5 changed files with 33 additions and 20 deletions
@@ -11,21 +11,21 @@ LoRA и embeddings можно поставить на модель, на пре
## Задачи ## Задачи
- [ ] Discovery: список `embeddings` из Swarm (`models.Embedding`) - [x] Discovery: список `embeddings` из Swarm (`models.Embedding`)
- [ ] Списки LoRA и embeddings (name + weight) на модели, пресете и avatar/custom/full. - [x] Списки LoRA и embeddings (name + weight) на модели, пресете и avatar/custom/full.
Склейка модель → пресет → кадр. Пустые списки в шаблоне Склейка модель → пресет → кадр. Пустые списки в шаблоне
- [ ] Генерация: все LoRA в `loras` и `loraweights`; embeddings в positive/negative как - [x] Генерация: все LoRA в `loras` и `loraweights`; embeddings в positive/negative как
`<embed:name>`. Не писать `<lora:>` в промпт `<embed:name>`. Не писать `<lora:>` в промпт
- [ ] UI: комбобоксы из discovery на модели, в спойлере пресета и в секции кадра. `t(...)` - [x] UI: комбобоксы из discovery на модели, в спойлере пресета и в секции кадра. `t(...)`
- [ ] HTTP в [`protocol.md`](../../protocol.md), сокет не бампить - [x] HTTP в [`protocol.md`](../../protocol.md), сокет не бампить
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Discovery парсит embeddings; офлайн — пустой список - [x] Discovery парсит embeddings; офлайн — пустой список
- [ ] Склейка LoRA/embed: модель + пресет + kind - [x] Склейка LoRA/embed: модель + пресет + kind
- [ ] Промпт содержит `<embed:…>` и не содержит `<lora:` - [x] Промпт содержит `<embed:…>` и не содержит `<lora:`
- [ ] Generate шлёт `loras` и `loraweights` раздельно - [x] Generate шлёт `loras` и `loraweights` раздельно
- [ ] Диалог: поля embeddings у модели и у вида кадра - [x] Диалог: поля embeddings у модели и у вида кадра
## Критерий готовности ## Критерий готовности
+12 -9
View File
@@ -507,9 +507,10 @@ land under `saves/{id}.portraits/` and survive until the school is deleted.
Returns the positive and negative prompts SwarmUI would receive, without generating an image. Returns the positive and negative prompts SwarmUI would receive, without generating an image.
Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended after Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended after
the shot type; when omitted, the last saved custom prompt is used if one exists. Unknown school the shot type; when omitted, the last saved custom prompt is used if one exists. Configured
is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `kind` is embeddings appear as `<embed:name>` (weight ≠ 1: `<embed:name:w>`); LoRA is not written into the
`400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`. prompt. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid
`kind` is `400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`.
```json ```json
{ {
@@ -528,8 +529,10 @@ so the client can disable generate buttons and show reachability without trying
### `GET /api/settings/swarmui` ### `GET /api/settings/swarmui`
Returns the **default** SwarmUI template (`swarmui.json`): a `models` catalog (id, default Returns the **default** SwarmUI template (`swarmui.json`): a `models` catalog (id, default
generation knobs, usually empty base positive/negative), named presets (model id, `style`, extra generation knobs, usually empty base positive/negative, LoRA and embedding lists), named presets
negative, optional generation overrides, per-kind size/`shotType`), `activePresetId` and `ageRules`. (model id, `style`, extra negative, optional generation overrides, per-kind size/`shotType`, LoRA
and embedding lists), `activePresetId` and `ageRules`. LoRA and embeddings on the model, preset and
shot kind concatenate at generate time.
The model picker in the UI is Swarm's list intersected with `models`. A new school copies this into The model picker in the UI is Swarm's list intersected with `models`. A new school copies this into
its save as `portraitSettings`. Living schools generate from that copy, not from this file. Older its save as `portraitSettings`. Living schools generate from that copy, not from this file. Older
saves without `models` lift on load (`positive``style`, kind `positive``shotType`). saves without `models` lift on load (`positive``style`, kind `positive``shotType`).
@@ -543,10 +546,10 @@ return `400` `invalid-body`. Already-created schools keep the copy they were cre
When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns
`{ connected, models, loras, embeddings, samplers, schedulers }` for the settings UI. LoRA and `{ connected, models, loras, embeddings, samplers, schedulers }` for the settings UI. LoRA and
embeddings can be set on the model, the preset, and each shot kind; layers concatenate. embeddings can be set on the model, the preset, and each shot kind; layers concatenate. The client
The client hides Swarm models that are missing from the template catalog. When SwarmUI is off or unreachable, `connected` hides Swarm models that are missing from the template catalog. When SwarmUI is off or unreachable,
is false and the lists are empty; the picker then shows the catalog ids so fields can be filled `connected` is false and the lists are empty; the picker then shows the catalog ids so fields can
manually. be filled manually.
### `GET /api/schools/{id}/dress-rules` ### `GET /api/schools/{id}/dress-rules`
@@ -25,7 +25,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
const ageRulesHost = el('div', { class: 'settings-age-rules' }); const ageRulesHost = el('div', { class: 'settings-age-rules' });
let config: SwarmUiSettingsFile | null = null; let config: SwarmUiSettingsFile | null = null;
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] }; let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], embeddings: [], samplers: [], schedulers: [] };
let editingPresetId = ''; let editingPresetId = '';
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) }); const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) });
@@ -239,6 +239,11 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
return host; return host;
} }
model.positiveLoras ??= [];
model.negativeLoras ??= [];
model.positiveEmbeddings ??= [];
model.negativeEmbeddings ??= [];
host.append( host.append(
textareaField(t('settingsModelPositive'), model.positive, (value) => { textareaField(t('settingsModelPositive'), model.positive, (value) => {
model.positive = value; model.positive = value;
@@ -550,6 +550,10 @@ internal sealed class SwarmUiModelDefinition
Sampler = sampler, Sampler = sampler,
Scheduler = scheduler, Scheduler = scheduler,
Seed = seed, Seed = seed,
PositiveLoras = [],
NegativeLoras = [],
PositiveEmbeddings = [],
NegativeEmbeddings = [],
}; };
} }
@@ -63,6 +63,7 @@ public class SwarmUiLoraFormatterTests
Assert.Equal(["pos.safetensors", "neg.safetensors"], discovery.Embeddings); Assert.Equal(["pos.safetensors", "neg.safetensors"], discovery.Embeddings);
Assert.Equal(["style.safetensors"], discovery.Loras); Assert.Equal(["style.safetensors"], discovery.Loras);
Assert.Empty(SwarmUiDiscovery.Offline.Embeddings);
} }
[Fact] [Fact]