Ship Assistent 0.15.3: user-owned Generate, SwarmUI aspects, HF import UX.

Chips and quick patches apply params without auto-Generate; aspect sizes match Swarm Side Length 1024. HF import shows drafts after switching filter from approved-only; training status and job polling improved.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 19:13:36 +03:00
co-authored by Cursor
parent e53f483a00
commit 65a0982b9d
24 changed files with 1088 additions and 275 deletions
+468 -134
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -343,6 +343,10 @@
min-height: 7.5rem; min-height: 7.5rem;
} }
.sa-slot.sa-has-image img {
cursor: zoom-in;
}
.sa-slot img { .sa-slot img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@@ -455,6 +459,16 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.sa-composer-params {
font-size: 0.72rem;
opacity: 0.82;
padding: 0.05rem 0 0.2rem;
letter-spacing: 0.01em;
line-height: 1.35;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, currentColor 88%, transparent);
}
.sa-more-wrap { .sa-more-wrap {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -1712,6 +1726,12 @@
gap: 0.4rem; gap: 0.4rem;
} }
.sa-btn-gen-primary {
font-weight: 650;
border-color: color-mix(in srgb, #3fb950 70%, currentColor);
background: color-mix(in srgb, #3fb950 20%, transparent);
}
.sa-btn-gen:disabled { .sa-btn-gen:disabled {
opacity: 0.65; opacity: 0.65;
pointer-events: none; pointer-events: none;
@@ -2482,6 +2502,13 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.sa-train-global-status {
display: block;
margin: 0.25rem 0.55rem 0.4rem;
min-height: 1.1em;
opacity: 0.9;
}
.sa-ttab { .sa-ttab {
border: 1px solid color-mix(in srgb, currentColor 22%, transparent); border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: transparent; background: transparent;
@@ -2620,6 +2647,12 @@
min-height: 1.1rem; min-height: 1.1rem;
} }
.sa-hf-status.sa-hf-busy {
opacity: 1;
color: var(--emphasis, #8ab4ff);
font-weight: 600;
}
.sa-hf-list { .sa-hf-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+8 -1
View File
@@ -506,7 +506,14 @@ public partial class SwarmAssistentExtension
{ {
return new JObject { ["success"] = true, ["imported"] = 0, ["runner_only"] = true, ["id"] = id, ["note"] = "Большой набор — используй HF id в QLoRA-раннере" }; return new JObject { ["success"] = true, ["imported"] = 0, ["runner_only"] = true, ["id"] = id, ["note"] = "Большой набор — используй HF id в QLoRA-раннере" };
} }
return new JObject { ["success"] = true, ["imported"] = imported, ["id"] = id }; return new JObject
{
["success"] = true,
["imported"] = imported,
["id"] = id,
["rows_fetched"] = rows.Count,
["status"] = "draft",
};
} }
static JArray ConvertHfRowToMessages(JObject row, JObject schema, JObject mapping) static JArray ConvertHfRowToMessages(JObject row, JObject schema, JObject mapping)
+1
View File
@@ -35,6 +35,7 @@ public partial class SwarmAssistentExtension
["success"] = true, ["success"] = true,
["samples"] = new JArray(list), ["samples"] = new JArray(list),
["approved"] = Memory.CountTrainSamples("approved"), ["approved"] = Memory.CountTrainSamples("approved"),
["draft"] = Memory.CountTrainSamples("draft"),
["total"] = Memory.CountTrainSamples(null), ["total"] = Memory.CountTrainSamples(null),
}; };
} }
+1 -1
View File
@@ -68,7 +68,7 @@ Several options (still one fence):
### Commands ### Commands
- `"generate": true` — merge this delta into the chat session and run Generate (new/updated frame). **Omit** for chat, opinions, remember/save, look-only. Legacy `actions:["generate"]` is accepted as the same. - `"generate": true` — you believe this turn is a draw request. The client **does not** start Swarm from this flag alone (small models often set it on chat questions). Set it **only** when the user asked to draw / generate / «нарисуй» / «сгенерируй». They can always click **Сгенерировать**. **Omit** the JSON for chat, opinions, Civitai/knowledge Q&A, remember/save, look-only. Legacy `actions:["generate"]` is the same advisory flag.
- If the user says not to generate / only remember / only answer — **omit** `generate` and do not look. - If the user says not to generate / only remember / only answer — **omit** `generate` and do not look.
- `"look_at": ["generate"|"ref1"|…]` — vision hop (JPEG on follow-up). Use when you need pixels; do not pair with `generate` on the same normal write turn. - `"look_at": ["generate"|"ref1"|…]` — vision hop (JPEG on follow-up). Use when you need pixels; do not pair with `generate` on the same normal write turn.
- `"ask": ["settings"]` — request full settings dump (Exact + all fields). - `"ask": ["settings"]` — request full settings dump (Exact + all fields).
+7 -6
View File
@@ -21,13 +21,14 @@
}, },
"aspect_table": { "aspect_table": {
"1:1": [1024, 1024], "1:1": [1024, 1024],
"4:3": [1184, 896], "4:3": [1152, 896],
"3:2": [1248, 832], "3:2": [1216, 832],
"16:9": [1376, 768], "16:9": [1344, 768],
"2.35:1": [1568, 672], "21:9": [1536, 640],
"2.35:1": [1536, 640],
"4:5": [928, 1152], "4:5": [928, 1152],
"2:3": [832, 1248], "2:3": [832, 1216],
"9:16": [768, 1376] "9:16": [768, 1344]
}, },
"facts": { "facts": {
"architecture": "Krea 2 is a 12B DiT architecture. Not FLUX, not SDXL, not FLUX.1-Krea. Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE. Use only Krea2-trained LoRAs — never suggest FLUX/SDXL LoRAs.", "architecture": "Krea 2 is a 12B DiT architecture. Not FLUX, not SDXL, not FLUX.1-Krea. Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE. Use only Krea2-trained LoRAs — never suggest FLUX/SDXL LoRAs.",
+1 -1
View File
@@ -3,6 +3,6 @@
"kind": "aspect", "kind": "aspect",
"key": "table", "key": "table",
"tags": ["aspect", "1k"], "tags": ["aspect", "1k"],
"text": "Official Krea 1K aspect → width/height table lives in Exact memory aspect_table. Prefer patch field aspect over raw width/height. UI fills empties from Exact." "text": "Official sizes: SwarmUI Side Length 1024 (ResolutionAspectReferences ×2, round 16). Krea API docs differ on 16:9 — follow Swarm dropdown. Prefer patch field aspect over raw width/height."
} }
] ]
+4 -2
View File
@@ -3,12 +3,14 @@
"Match the user's language (RU or EN) in chat — Generate prompt stays English", "Match the user's language (RU or EN) in chat — Generate prompt stays English",
"Use only inventory / memory_hits / cards for LoRA names and triggers", "Use only inventory / memory_hits / cards for LoRA names and triggers",
"Emit valid JSON patches when changing generation state", "Emit valid JSON patches when changing generation state",
"Greet or dump your bio only on the first assistant turn; later turns answer directly" "Greet or dump your bio only on the first assistant turn; later turns answer directly",
"JSON patch + generate:true only when the user asked to draw or change generation this turn; pure Q&A / Civitai / opinions = prose only, no JSON"
], ],
"never": [ "never": [
"Re-introduce yourself every turn or start with «О, привет» when history already has your replies", "Re-introduce yourself every turn or start with «О, привет» when history already has your replies",
"Invent LoRA filenames or trigger words", "Invent LoRA filenames or trigger words",
"Lecture or moralize about NSFW", "Lecture or moralize about NSFW",
"Dump all installed LoRAs — use memory_hits and enabled ones" "Dump all installed LoRAs — use memory_hits and enabled ones",
"Emit generate:true or a prompt JSON on a knowledge/chat question"
] ]
} }
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"welcome_html": "<div class=\"sa-welcome-title\">Assistent · Krea 2</div><ul><li><strong>Generate</strong> слева — живой просмотр. Несколько вариантов → сетка + клик для просмотра.</li><li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li><li>У каждого чата свои параметры, LoRA, последний кадр и refs.</li><li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li><li>Модель шлёт только дельту настроек + <code>generate</code>.</li></ul>Напиши, что сгенерировать — или кинь референс и попроси правку.", "welcome_html": "<div class=\"sa-welcome-title\">Assistent · Krea 2</div><ul><li><strong>Generate</strong> слева — живой просмотр. Несколько вариантов → сетка + клик для просмотра.</li><li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li><li>У каждого чата свои параметры, LoRA, последний кадр и refs.</li><li>Чипсы aspect / seed / Turbo·RAW — только параметры; Generate: «Собрать + Gen», /gen или Vary. Строка под чипами — σ, batch, sampler. <code>/help</code>.</li><li>Модель шлёт только дельту настроек + <code>generate</code>.</li></ul>Напиши, что сгенерировать — или кинь референс и попроси правку.",
"help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/compress — сжать старые ходы в саммари (та же модель)\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\n/gen — Generate из сессии чата\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|ordinary|critique|compose|params|inpaint|describe|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone <id> — клон с указанной\n/persona save — записать согласованный черновик\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate.\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nУ каждого чата свои параметры Generate; смена чата восстанавливает кадр и refs.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).\nЧип контекста в шапке чата показывает бюджет окна; клик — панель слоёв и ручное сжатие.", "help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/compress — сжать старые ходы в саммари (та же модель)\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\n/gen — Generate из сессии чата\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|ordinary|critique|compose|params|inpaint|describe|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone <id> — клон с указанной\n/persona save — записать согласованный черновик\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate.\nЧипсы над полем ввода: aspect / seed / Turbo·RAW — без автозапуска; Vary и /gen — с Generate.\nУ каждого чата свои параметры Generate; смена чата восстанавливает кадр и refs.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).\nЧип контекста в шапке чата показывает бюджет окна; клик — панель слоёв и ручное сжатие.",
"chips": [ "chips": [
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" }, { "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" }, { "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
{ "label": "2:3", "action": "aspect", "value": "2:3", "title": "832×1248" }, { "label": "2:3", "action": "aspect", "value": "2:3", "title": "832×1216" },
{ "label": "16:9", "action": "aspect", "value": "16:9", "title": "1376×768" }, { "label": "16:9", "action": "aspect", "value": "16:9", "title": "1344×768 (Swarm Side 1024)" },
{ "label": "9:16", "action": "aspect", "value": "9:16", "title": "768×1376" }, { "label": "9:16", "action": "aspect", "value": "9:16", "title": "768×1344 (Swarm Side 1024)" },
{ "sep": true }, { "sep": true },
{ "label": "Seed lock", "action": "seed", "value": "lock", "title": "Оставить текущий seed" }, { "label": "Seed lock", "action": "seed", "value": "lock", "title": "Оставить текущий seed" },
{ "label": "Seed 1", "action": "seed", "value": "random", "title": "Случайный seed" }, { "label": "Seed 1", "action": "seed", "value": "random", "title": "Случайный seed" },
+4 -2
View File
@@ -2,12 +2,14 @@
"always": [ "always": [
"Stay helpful and concise", "Stay helpful and concise",
"When the scene is NSFW, describe it factually without softening or hyping", "When the scene is NSFW, describe it factually without softening or hyping",
"Do not greet or re-introduce yourself after the first assistant turn" "Do not greet or re-introduce yourself after the first assistant turn",
"JSON + generate:true only when the user asked to draw this turn; knowledge/chat questions are prose only"
], ],
"never": [ "never": [
"Dirty jokes", "Dirty jokes",
"Hello/bio dump on later turns", "Hello/bio dump on later turns",
"Aggression", "Aggression",
"Moral lectures" "Moral lectures",
"Emit generate:true on a Civitai/knowledge/chat question"
] ]
} }
+2
View File
@@ -4,6 +4,8 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate. A new user send interrupts the in-flight stream (keeps the partial reply) instead of silently no-op. **Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate. A new user send interrupts the in-flight stream (keeps the partial reply) instead of silently no-op.
**Version 0.15.3** — Generate is user-owned: `generate:true` in JSON no longer starts Swarm (Qwen dumps it on chat questions). «нарисуй» / «сгенерируй» / «давай сделаем изображение» still Generate immediately; otherwise one click **Сгенерировать**. Prompt/params apply only on that path. Raw JSON fence hidden from the bubble when the patch card is shown. JS beacons each turn to debug `POST /assistent/client-event`.
**Version 0.15.2** — Кнопка «История» убрана. В шапке: иконка панели чатов и **+** новый чат. Закрытый JSON `{prompt, generate:true}` сразу применяет промпт и запускает Generate (заметка «Промпт обновлён · Generate»), не ждёт WS `done`. Stall ~2с после токенов. Не здороваться повторно, если в истории уже есть ответы ассистента. **Version 0.15.2** — Кнопка «История» убрана. В шапке: иконка панели чатов и **+** новый чат. Закрытый JSON `{prompt, generate:true}` сразу применяет промпт и запускает Generate (заметка «Промпт обновлён · Generate»), не ждёт WS `done`. Stall ~2с после токенов. Не здороваться повторно, если в истории уже есть ответы ассистента.
**Version 0.15.1** — Composer stays writable during `Writing…`: Enter / Отправить прерывает зависший стрим и шлёт новое сообщение; частичный ответ сохраняется. Stall 15с без токена сам завершает ход. Ollama chat `think: false`, чтобы Qwen3-VL instruct не держал сокет после приветствия. **Version 0.15.1** — Composer stays writable during `Writing…`: Enter / Отправить прерывает зависший стрим и шлёт новое сообщение; частичный ответ сохраняется. Stall 15с без токена сам завершает ход. Ollama chat `think: false`, чтобы Qwen3-VL instruct не держал сокет после приветствия.
+1 -1
View File
@@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid"; ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT"; License = "MIT";
Version = "0.15.2"; Version = "0.15.3";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"];
} }
+2 -1
View File
@@ -116,6 +116,7 @@
</div> </div>
<div class="sa-composer" id="sa_composer"> <div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div> <div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
<div class="sa-composer-params" id="sa_composer_params" title="Текущие параметры Generate. Чипы меняют их без автозапуска — Generate: «Собрать + Gen» или /gen."></div>
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div> <div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
<div class="sa-slash-wrap"> <div class="sa-slash-wrap">
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить (прервёт Writing…) · /help = команды" aria-label="Сообщение Assistent"></textarea> <textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить (прервёт Writing…) · /help = команды" aria-label="Сообщение Assistent"></textarea>
@@ -167,6 +168,7 @@
<button type="button" class="sa-ttab" data-ttab="train" role="tab" aria-selected="false">Тренировка</button> <button type="button" class="sa-ttab" data-ttab="train" role="tab" aria-selected="false">Тренировка</button>
<button type="button" class="sa-ttab" data-ttab="models" role="tab" aria-selected="false">Модели</button> <button type="button" class="sa-ttab" data-ttab="models" role="tab" aria-selected="false">Модели</button>
</div> </div>
<span class="sa-status sa-train-global-status" id="sa_train_status" role="status"></span>
<div class="sa-training-panes"> <div class="sa-training-panes">
<div class="sa-tpane" data-tpane="dataset"> <div class="sa-tpane" data-tpane="dataset">
<div class="sa-train-toolbar"> <div class="sa-train-toolbar">
@@ -275,7 +277,6 @@
<div class="sa-train-progress-bar"><div class="sa-train-progress-fill" id="sa_train_progress_fill"></div></div> <div class="sa-train-progress-bar"><div class="sa-train-progress-fill" id="sa_train_progress_fill"></div></div>
<pre class="sa-train-log" id="sa_train_log"></pre> <pre class="sa-train-log" id="sa_train_log"></pre>
</div> </div>
<span class="sa-status" id="sa_train_status"></span>
</div> </div>
<div class="sa-tpane" data-tpane="models" hidden> <div class="sa-tpane" data-tpane="models" hidden>
<p class="sa-settings-hint">Обученные и созданные модели (Ollama tags).</p> <p class="sa-settings-hint">Обученные и созданные модели (Ollama tags).</p>
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"build": "node scripts/build.mjs", "build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch", "watch": "node scripts/build.mjs --watch",
"test": "node --test test/intent.test.js test/patch.test.js test/context.test.js test/kreaProfile.test.js" "test": "node --test test/intent.test.js test/patch.test.js test/context.test.js test/kreaProfile.test.js test/aspect.test.js"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0" "esbuild": "^0.25.0"
+241 -99
View File
@@ -2,6 +2,8 @@
* Swarm Assistent Krea 2 collaborative chat (Ollama via Swarm API). * Swarm Assistent Krea 2 collaborative chat (Ollama via Swarm API).
* v0.8.0: Split assets SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js). * v0.8.0: Split assets SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
*/ */
import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } from './aspect.js';
(function () { (function () {
const LS_BASE = 'swarm_assistent_base_url'; const LS_BASE = 'swarm_assistent_base_url';
const LS_MODEL = 'swarm_assistent_model'; const LS_MODEL = 'swarm_assistent_model';
@@ -35,16 +37,7 @@
let CHARS_PER_TOKEN = 3.2; let CHARS_PER_TOKEN = 3.2;
let COMPRESS_AUTO = true; let COMPRESS_AUTO = true;
let ASPECT_TABLE = { let ASPECT_TABLE = { ...DEFAULT_ASPECT_TABLE };
'1:1': [1024, 1024],
'4:3': [1184, 896],
'3:2': [1248, 832],
'16:9': [1376, 768],
'2.35:1': [1568, 672],
'4:5': [928, 1152],
'2:3': [832, 1248],
'9:16': [768, 1376],
};
let PACK_ALIASES = { let PACK_ALIASES = {
ordinary: 'ordinary', ordinary: 'ordinary',
@@ -127,6 +120,8 @@
lastUserControlIntent: false, lastUserControlIntent: false,
lastPatch: null, lastPatch: null,
pendingSilentGen: false, pendingSilentGen: false,
/** This user turn asked to draw — hops inherit it; model generate:true does not. */
turnUserWantsGenerate: false,
pendingPromptEnMerge: null, pendingPromptEnMerge: null,
enabledSkills: [], enabledSkills: [],
kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } }, kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } },
@@ -698,6 +693,7 @@
let text = String(raw || '').replace(/\r\n/g, '\n'); let text = String(raw || '').replace(/\r\n/g, '\n');
text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n'); text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n');
text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n'); text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n');
text = text.replace(/```(?:json)?\s*[\s\S]*?```/gi, '');
text = text.replace(/\n{3,}/g, '\n\n').trim(); text = text.replace(/\n{3,}/g, '\n\n').trim();
if (!text) { if (!text) {
return ''; return '';
@@ -782,11 +778,32 @@
if (!el) { if (!el) {
return; return;
} }
el.value = value; const next = value == null ? '' : String(value);
if (el.value === next) {
return;
}
el.value = next;
// SwarmUI may auto-Generate on input/change — suppress during param-only chip edits.
if ((state._quietParamApply || 0) > 0) {
return;
}
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
} }
const QUICK_PARAM_KEYS = new Set([
'aspect', 'width', 'height', 'steps', 'cfg', 'sigma_shift', 'seed',
'vary', 'lock_seed', 'sampler', 'scheduler', 'batch', 'images',
]);
function isParamOnlyQuickPatch(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
const keys = Object.keys(patch).filter((k) => patch[k] != null && k !== 'notes');
return keys.length > 0 && keys.every((k) => QUICK_PARAM_KEYS.has(k));
}
function liveNegativePrompt() { function liveNegativePrompt() {
return String(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '').trim(); return String(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '').trim();
} }
@@ -1162,8 +1179,9 @@
return true; return true;
} }
return cyrTokenRe( return cyrTokenRe(
'сгенер[а-яё]*|нарисуй|нарисуйте|' 'сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|'
+ 'запусти\\s+генер[а-яё]*|сделай\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)', + 'запусти\\s+генер[а-яё]*|'
+ 'сдела(й|ем|йте)\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
).test(t); ).test(t);
} }
@@ -1178,23 +1196,26 @@
vetoFn: userAsksNoGenerate, vetoFn: userAsksNoGenerate,
askGenerateFn: userAsksGenerate, askGenerateFn: userAsksGenerate,
fromAutoCritique: !!opts.fromAutoCritique, fromAutoCritique: !!opts.fromAutoCritique,
sessionPrompt: opts.sessionPrompt || '',
userWantsGenerate: !!opts.userWantsGenerate,
}); });
} }
// generate:true / actions / user «сгенерируй» when a prompt already exists. // User «нарисуй» / hops — never model generate:true alone.
const vetoed = !isMachineTurn(opts) && userAsksNoGenerate(userText); const vetoed = !isMachineTurn(opts) && userAsksNoGenerate(userText);
const modelAsked = !!(patch && (patch.generate === true || patch.generate === 1 const modelAsked = !!(patch && (patch.generate === true || patch.generate === 1
|| (typeof patch.generate === 'string' && /^(true|1|yes|on)$/i.test(patch.generate)) || (typeof patch.generate === 'string' && /^(true|1|yes|on)$/i.test(patch.generate))
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')))); || (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'))));
const hasPrompt = !!(String(patch?.prompt || '').trim() || String(opts.sessionPrompt || '').trim());
const userAsked = !isMachineTurn(opts) && userAsksGenerate(userText) const userAsked = !isMachineTurn(opts) && userAsksGenerate(userText)
&& !!(modelAsked || String(patch?.prompt || '').trim()); && (modelAsked || hasPrompt);
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked); const generate = !vetoed && !opts.fromAutoCritique && (userAsked || !!opts.userWantsGenerate);
const hasLook = !!patch const hasLook = !!patch
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null); && (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const look = !!(hasLook && !vetoed && !generate); const look = !!(hasLook && !vetoed && !generate);
const ask = Array.isArray(patch?.ask) const ask = Array.isArray(patch?.ask)
? patch.ask.map(String) ? patch.ask.map(String)
: (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []); : (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []);
return { generate, look, vetoed, ask }; return { generate, look, vetoed, ask, modelAsked };
} }
function stripGenerateAction(patch) { function stripGenerateAction(patch) {
if (!patch || typeof patch !== 'object') { if (!patch || typeof patch !== 'object') {
@@ -1281,32 +1302,55 @@
badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit'); badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit');
} }
function syncLiveParamsBar() { function formatLiveParamsLine() {
const el = $('sa_live_params');
if (!el) {
return;
}
const w = parseInt(val('input_width') || '0', 10) || null; const w = parseInt(val('input_width') || '0', 10) || null;
const h = parseInt(val('input_height') || '0', 10) || null; const h = parseInt(val('input_height') || '0', 10) || null;
const aspect = guessAspectFromSize(w, h) || '—'; const aspect = guessAspectFromSize(w, h) || '—';
const steps = val('input_steps') || '—'; const steps = val('input_steps') || '—';
const cfg = val('input_cfgscale') || val('input_cfg') || '—'; const cfg = val('input_cfgscale') || val('input_cfg') || '—';
const sigma = val('input_sigmashift') || '';
const seed = val('input_seed') || '—'; const seed = val('input_seed') || '—';
const profile = detectKreaProfileName(); const profile = detectKreaProfileName();
el.textContent = `${aspect} · ${w || '?'}×${h || '?'} · steps ${steps} · cfg ${cfg} · ${profile} · seed ${seed}`; const batch = val('input_images') || val('input_batchsize') || '';
const sampler = val('input_sampler') || '';
const scheduler = val('input_scheduler') || '';
const parts = [
aspect,
`${w || '?'}×${h || '?'}`,
`steps ${steps}`,
`cfg ${cfg}`,
];
if (sigma) {
parts.push(`σ ${sigma}`);
}
parts.push(profile, `seed ${seed}`);
if (batch && batch !== '1') {
parts.push(`×${batch}`);
}
if (sampler) {
parts.push(sampler);
}
if (scheduler) {
parts.push(scheduler);
}
return parts.join(' · ');
}
function syncLiveParamsBar() {
const line = formatLiveParamsLine();
const boardEl = $('sa_live_params');
const composerEl = $('sa_composer_params');
if (boardEl) {
boardEl.textContent = line;
}
if (composerEl) {
composerEl.textContent = line;
}
} }
function applyAspectTableFrom(obj) { function applyAspectTableFrom(obj) {
if (!obj || typeof obj !== 'object') { const next = mergeAspectTable(obj);
return false; if (!next) {
}
const next = {};
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v) && v.length >= 2) {
next[k] = [Number(v[0]), Number(v[1])];
}
}
if (!Object.keys(next).length) {
return false; return false;
} }
ASPECT_TABLE = next; ASPECT_TABLE = next;
@@ -2464,7 +2508,7 @@
actions.appendChild(toSession); actions.appendChild(toSession);
const genBtn = document.createElement('button'); const genBtn = document.createElement('button');
genBtn.type = 'button'; genBtn.type = 'button';
genBtn.className = 'basic-button sa-btn-gen'; genBtn.className = 'basic-button sa-btn-gen sa-btn-gen-primary';
genBtn.textContent = 'Сгенерировать'; genBtn.textContent = 'Сгенерировать';
genBtn.addEventListener('click', async () => { genBtn.addEventListener('click', async () => {
if (isGenerateUnavailable()) { if (isGenerateUnavailable()) {
@@ -2477,6 +2521,9 @@
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), patch); state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), patch);
} }
await pushSessionToSwarm(state.chatSession); await pushSessionToSwarm(state.chatSession);
if (typeof appendSystemNote === 'function') {
appendSystemNote('Запускаю Generate');
}
await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true, fromSession: true }); await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true, fromSession: true });
}); });
actions.appendChild(genBtn); actions.appendChild(genBtn);
@@ -2617,6 +2664,24 @@
return el; return el;
} }
/** Images available in the gen lightbox (variant grid or single live Generate slot). */
function genLightboxList() {
const fromResults = (state.genResults || []).filter((r) => r && r.src);
if (fromResults.length) {
return fromResults;
}
const slot = generateSlot();
if (slot?.src) {
return [{
id: slot.id || GEN_ID,
label: slot.label || 'Generate',
src: slot.src,
patch: state.lastPatch || null,
}];
}
return [];
}
function ensureGenLightbox() { function ensureGenLightbox() {
let root = $('sa_gen_lightbox'); let root = $('sa_gen_lightbox');
if (root) { if (root) {
@@ -2682,7 +2747,7 @@
} }
function currentLightboxRow() { function currentLightboxRow() {
const list = (state.genResults || []).filter((r) => r.src); const list = genLightboxList();
if (!list.length || state.lightboxIndex < 0) { if (!list.length || state.lightboxIndex < 0) {
return null; return null;
} }
@@ -2691,7 +2756,7 @@
function syncGenLightbox() { function syncGenLightbox() {
const root = ensureGenLightbox(); const root = ensureGenLightbox();
const list = (state.genResults || []).filter((r) => r.src); const list = genLightboxList();
const row = list[state.lightboxIndex]; const row = list[state.lightboxIndex];
if (!row) { if (!row) {
root.hidden = true; root.hidden = true;
@@ -2714,7 +2779,7 @@
} }
function openGenLightbox(id) { function openGenLightbox(id) {
const list = (state.genResults || []).filter((r) => r.src); const list = genLightboxList();
let idx = list.findIndex((r) => r.id === id); let idx = list.findIndex((r) => r.id === id);
if (idx < 0) { if (idx < 0) {
idx = 0; idx = 0;
@@ -2737,7 +2802,7 @@
} }
function stepGenLightbox(delta) { function stepGenLightbox(delta) {
const list = (state.genResults || []).filter((r) => r.src); const list = genLightboxList();
if (list.length < 2) { if (list.length < 2) {
return; return;
} }
@@ -2864,10 +2929,22 @@
busy.innerHTML = '<span class="sa-spinner" aria-hidden="true"></span>'; busy.innerHTML = '<span class="sa-spinner" aria-hidden="true"></span>';
el.appendChild(busy); el.appendChild(busy);
el.addEventListener('click', () => { el.addEventListener('click', (e) => {
if (slot.type === 'generate' && slot.src && e.target?.tagName === 'IMG') {
openGenLightbox(slot.id || GEN_ID);
return;
}
state.selectedSlotId = slot.id; state.selectedSlotId = slot.id;
renderBoard(); renderBoard();
}); });
if (slot.type === 'generate') {
el.addEventListener('dblclick', (e) => {
if (slot.src && e.target?.tagName === 'IMG') {
e.preventDefault();
openGenLightbox(slot.id || GEN_ID);
}
});
}
el.addEventListener('dragover', (e) => { el.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -4218,6 +4295,14 @@
return { prose: text || '', patch: null }; return { prose: text || '', patch: null };
} }
function visibleAssistantProse(text) {
if (window.SA && typeof SA.visibleProse === 'function') {
return SA.visibleProse(text);
}
const { prose, patch } = extractPatch(text);
return patch ? (prose || '') : String(text || '');
}
function normalizeAspect(raw) { function normalizeAspect(raw) {
if (raw == null) { if (raw == null) {
return null; return null;
@@ -4926,7 +5011,7 @@
} }
const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral'; const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
const pack = $('sa_pack')?.value || defaultPackId(); const pack = $('sa_pack')?.value || defaultPackId();
const prose = (typeof extractPatch === 'function' ? (extractPatch(text).prose || text) : text); const prose = visibleAssistantProse(text);
if (state.streamEl) { if (state.streamEl) {
finalizeStreamMessage(text, []); finalizeStreamMessage(text, []);
} else { } else {
@@ -5500,7 +5585,7 @@
const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null }; const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null };
const finalPatch = patch || extracted; const finalPatch = patch || extracted;
if (role === 'assistant') { if (role === 'assistant') {
setAssistantBody(div, prose || text || ''); setAssistantBody(div, finalPatch ? (prose || '') : (prose || text || ''));
} else { } else {
div.textContent = prose || text || ''; div.textContent = prose || text || '';
} }
@@ -5509,7 +5594,7 @@
const silent = !!(meta && meta.silentPatch); const silent = !!(meta && meta.silentPatch);
mountPatchBlock(div, finalPatch, { silent }); mountPatchBlock(div, finalPatch, { silent });
} }
if (role === 'assistant' && !(meta && meta.historical)) { if (role === 'assistant' && !(meta && meta.historical)) {
mountCurateButtons(div, meta); mountCurateButtons(div, meta);
} }
box.appendChild(div); box.appendChild(div);
@@ -5611,7 +5696,7 @@ if (role === 'assistant' && !(meta && meta.historical)) {
if (streamHasClosedPatchFence(state.streamText)) { if (streamHasClosedPatchFence(state.streamText)) {
state.streamText = trimToClosedPatchFence(state.streamText); state.streamText = trimToClosedPatchFence(state.streamText);
state.streamFenceDone = true; state.streamFenceDone = true;
setAssistantBody(state.streamEl, state.streamText, { live: true }); setAssistantBody(state.streamEl, visibleAssistantProse(state.streamText), { live: true });
scrollMessagesToBottom(); scrollMessagesToBottom();
const fn = state.onClosedTerminalFence; const fn = state.onClosedTerminalFence;
state.onClosedTerminalFence = null; state.onClosedTerminalFence = null;
@@ -5646,13 +5731,13 @@ if (role === 'assistant' && !(meta && meta.historical)) {
el.classList.remove('sa-streaming', 'sa-typing'); el.classList.remove('sa-streaming', 'sa-typing');
mountAssistantMeta(el, meta || undefined); mountAssistantMeta(el, meta || undefined);
const { prose, patch } = extractPatch(fullReply); const { prose, patch } = extractPatch(fullReply);
setAssistantBody(el, prose || fullReply || ''); setAssistantBody(el, patch ? (prose || '') : (prose || fullReply || ''));
el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove()); el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove());
if (patch) { if (patch) {
const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen; const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
mountPatchBlock(el, patch, { silent }); mountPatchBlock(el, patch, { silent });
} }
if (!(meta && meta.historical)) { if (!(meta && meta.historical)) {
mountCurateButtons(el, meta); mountCurateButtons(el, meta);
} }
scrollMessagesToBottom(); scrollMessagesToBottom();
@@ -7812,8 +7897,32 @@ if (!(meta && meta.historical)) {
if (effective && act && typeof act.noteModelCommands === 'function') { if (effective && act && typeof act.noteModelCommands === 'function') {
act.noteModelCommands(effective); act.noteModelCommands(effective);
} }
const sessionPrompt = String(state.chatSession?.gen?.prompt || '').trim();
const userWantsGenerate = !!opts.userWantsGenerate || !!state.turnUserWantsGenerate;
let intent = resolveTurnIntent(effective, opts.userText || '', {
...opts,
sessionPrompt,
userWantsGenerate,
});
if (userWantsGenerate && !intent.vetoed && !fromAutoCritique) {
intent = { ...intent, generate: true };
}
if (!effective && intent.generate && sessionPrompt) {
effective = { generate: true };
}
const promptChanged = !!(effective && String(effective.prompt || '').trim()); const promptChanged = !!(effective && String(effective.prompt || '').trim());
if (effective && S) { if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
if (typeof doInterruptNow === 'function') doInterruptNow();
}
if (effective) {
if (intent.generate) {
const acts = Array.isArray(effective.actions) ? effective.actions.map(String) : [];
effective = { ...effective, actions: acts.includes('generate') ? acts : acts.concat('generate'), generate: true };
}
if (!intent.look && typeof stripLookAt === 'function') effective = stripLookAt(effective);
if (typeof rememberLastPatch === 'function') rememberLastPatch(effective);
}
if (intent.generate && effective && S) {
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective); state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
activityDone('delta', { activityDone('delta', {
kind: 'delta', kind: 'delta',
@@ -7828,28 +7937,6 @@ if (!(meta && meta.historical)) {
if (typeof persistChatsStore === 'function') persistChatsStore(); if (typeof persistChatsStore === 'function') persistChatsStore();
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
if (typeof rememberLastPatch === 'function') rememberLastPatch(effective);
}
if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
if (typeof doInterruptNow === 'function') doInterruptNow();
}
let intent = resolveTurnIntent(effective, opts.userText || '', opts);
if (opts.userWantsGenerate && effective && !intent.vetoed && !fromAutoCritique) {
intent = { ...intent, generate: true };
}
if (effective) {
if (intent.generate) {
const acts = Array.isArray(effective.actions) ? effective.actions.map(String) : [];
effective = { ...effective, actions: acts.includes('generate') ? acts : acts.concat('generate'), generate: true };
} else if (typeof stripGenerateAction === 'function') {
effective = stripGenerateAction(effective);
if (effective && effective.generate) {
effective = { ...effective };
delete effective.generate;
}
}
if (!intent.look && typeof stripLookAt === 'function') effective = stripLookAt(effective);
if (typeof rememberLastPatch === 'function') rememberLastPatch(effective);
} }
if (intent.vetoed) { if (intent.vetoed) {
state.pendingSilentGen = false; state.pendingSilentGen = false;
@@ -7962,44 +8049,82 @@ if (!(meta && meta.historical)) {
if (typeof maybeAutoVisionLook === 'function') await maybeAutoVisionLook(srcOut); if (typeof maybeAutoVisionLook === 'function') await maybeAutoVisionLook(srcOut);
} }
} else if (effective) { } else if (effective) {
if (S) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
if (promptChanged || $('sa_auto_apply')?.checked) {
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === 'function') syncLiveParamsBar();
if (promptChanged && typeof appendSystemNote === 'function') {
appendSystemNote('Промпт обновлён');
}
if (promptChanged) {
setStatus('Промпт обновлён');
}
}
if (!state.generating && typeof stopBusyUi === 'function') { if (!state.generating && typeof stopBusyUi === 'function') {
stopBusyUi(intent.vetoed ? 'Запомнил · без Generate' : (promptChanged ? 'Промпт обновлён' : '')); stopBusyUi(intent.vetoed ? 'Запомнил · без Generate' : '');
} }
} }
state.pendingSilentGen = false; state.pendingSilentGen = false;
reportDebugClientTurn({
user: opts.userText || '',
reply: String(reply || '').slice(0, 8000),
patch: effective,
intent,
generating: !!intent.generate,
swarm_prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
persona: $('sa_persona')?.value || '',
pack: $('sa_pack')?.value || '',
});
} }
async function applyQuickPatch(patch, note) {
function reportDebugClientTurn(payload) {
try {
const body = JSON.stringify({
ts: Date.now(),
...payload,
});
fetch('http://127.0.0.1:17821/assistent/client-event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
mode: 'cors',
}).catch(() => {});
} catch (e) { /* sidecar optional */ }
}
async function applyQuickPatch(patch, note, opts = {}) {
const wantGenerate = opts.generate === true;
let withActions = { ...patch }; let withActions = { ...patch };
if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) { delete withActions.generate;
withActions.actions = ['generate']; if (Array.isArray(withActions.actions)) {
withActions.actions = withActions.actions.filter((a) => String(a) !== 'generate');
if (!withActions.actions.length) {
delete withActions.actions;
}
}
if (wantGenerate) {
withActions.generate = true;
withActions.actions = [
...(Array.isArray(withActions.actions) ? withActions.actions : []),
'generate',
];
} }
const prevIntent = state.lastUserParamIntent; const prevIntent = state.lastUserParamIntent;
state.lastUserParamIntent = true; state.lastUserParamIntent = true;
const S = window.SA && window.SA.session; const S = window.SA && window.SA.session;
if (S && S.patchWantsGenerate && S.patchWantsGenerate(withActions)) { if (wantGenerate && S && S.patchWantsGenerate && S.patchWantsGenerate(withActions)) {
withActions = ensureExactParamsForGenerate(withActions); withActions = ensureExactParamsForGenerate(withActions);
} }
if (S) { if (S) {
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), withActions); state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), withActions);
} }
await pushSessionToSwarm(state.chatSession); state._quietParamApply = (state._quietParamApply || 0) + 1;
try {
if (wantGenerate) {
await pushSessionToSwarm(state.chatSession);
} else if (isParamOnlyQuickPatch(withActions)) {
await applyPatch(withActions, 'params');
} else {
await pushSessionToSwarm(state.chatSession);
}
} finally {
state._quietParamApply = Math.max(0, (state._quietParamApply || 1) - 1);
}
state.lastUserParamIntent = prevIntent; state.lastUserParamIntent = prevIntent;
setStatus(note || 'Applied'); setStatus(note || (wantGenerate ? 'Applied' : 'Параметры (без Generate)'));
if (patchHasGenTrigger(withActions)) { if (wantGenerate) {
await runGenerateFromPatch(withActions, { force: true, fromSession: true }); await runGenerateFromPatch(withActions, { force: true, fromSession: true });
} }
syncChipHighlight(); syncChipHighlight();
syncLiveParamsBar();
} }
function syncChipHighlight() { function syncChipHighlight() {
@@ -8275,7 +8400,7 @@ if (!(meta && meta.historical)) {
setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`); setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`);
return true; return true;
} }
await applyQuickPatch({ aspect: key, actions: ['generate'] }, `Aspect ${key}`); await applyQuickPatch({ aspect: key }, `Aspect ${key}`);
return true; return true;
} }
if (cmd === 'seed') { if (cmd === 'seed') {
@@ -8283,12 +8408,12 @@ if (!(meta && meta.historical)) {
if (mode === 'lock' || mode === 'keep') { if (mode === 'lock' || mode === 'keep') {
await applyQuickPatch({ lock_seed: true }, 'Seed locked'); await applyQuickPatch({ lock_seed: true }, 'Seed locked');
} else { } else {
await applyQuickPatch({ seed: -1, vary: true, actions: ['generate'] }, 'Seed random'); await applyQuickPatch({ seed: -1, vary: true }, 'Seed random');
} }
return true; return true;
} }
if (cmd === 'vary') { if (cmd === 'vary') {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)'); await applyQuickPatch({ vary: true, seed: -1 }, 'Vary (new seed)', { generate: true });
return true; return true;
} }
if (cmd === 'inventory' || cmd === 'inv') { if (cmd === 'inventory' || cmd === 'inv') {
@@ -8411,6 +8536,7 @@ if (!(meta && meta.historical)) {
state.lastUserParamIntent = userTextMentionsParams(text); state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text); state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = false; // 0.14 state.pendingSilentGen = false; // 0.14
state.turnUserWantsGenerate = userAsksGenerate(text);
} }
if (!isMachineTurn(opts) && !opts.skipSlash) { if (!isMachineTurn(opts) && !opts.skipSlash) {
@@ -8444,7 +8570,7 @@ if (!(meta && meta.historical)) {
patch.loras = state.lastPatch.loras; patch.loras = state.lastPatch.loras;
} }
appendSystemNote(`Ставлю ${aspect} и Generate (тот же промпт) — без повторной критики.`); appendSystemNote(`Ставлю ${aspect} и Generate (тот же промпт) — без повторной критики.`);
await applyQuickPatch(patch, `Aspect ${aspect}`); await applyQuickPatch(patch, `Aspect ${aspect}`, { generate: true });
return; return;
} }
} }
@@ -8657,7 +8783,7 @@ if (!(meta && meta.historical)) {
state.lastContextChars = 0; state.lastContextChars = 0;
} }
updateCtxChip(); updateCtxChip();
const prose = extractPatch(reply).prose || reply; const prose = visibleAssistantProse(reply);
state.history.push({ role: 'assistant', content: prose, persona, pack }); state.history.push({ role: 'assistant', content: prose, persona, pack });
persistHistory(); persistHistory();
setBusyPhase(state.pendingSilentGen ? 'silent_gen' : 'thinking'); setBusyPhase(state.pendingSilentGen ? 'silent_gen' : 'thinking');
@@ -8666,6 +8792,7 @@ if (!(meta && meta.historical)) {
...opts, ...opts,
userText: text, userText: text,
userWantsGenerate: !!opts.userWantsGenerate userWantsGenerate: !!opts.userWantsGenerate
|| !!state.turnUserWantsGenerate
|| (!isMachineTurn(opts) && state.pendingSilentGen), || (!isMachineTurn(opts) && state.pendingSilentGen),
attachedSlotIds: visionSlots.map((s) => s.id), attachedSlotIds: visionSlots.map((s) => s.id),
}); });
@@ -8866,8 +8993,14 @@ if (!(meta && meta.historical)) {
closeGenLightbox(); closeGenLightbox();
return; return;
} }
if ((e.key === 'Enter' || e.key === ' ') && state.boardTab === 'generate' && state.selectedGenResultId) { if ((e.key === 'Enter' || e.key === ' ') && state.boardTab === 'generate') {
const row = (state.genResults || []).find((r) => r.id === state.selectedGenResultId); let row = (state.genResults || []).find((r) => r.id === state.selectedGenResultId);
if (!row?.src) {
const slot = generateSlot();
if (slot?.src && (slot.id === state.selectedSlotId || !state.selectedGenResultId)) {
row = { id: slot.id || GEN_ID, src: slot.src };
}
}
if (row?.src) { if (row?.src) {
e.preventDefault(); e.preventDefault();
openGenLightbox(row.id); openGenLightbox(row.id);
@@ -9031,6 +9164,7 @@ if (!(meta && meta.historical)) {
window.__swarmAssistentWired = true; window.__swarmAssistentWired = true;
loadSettings(); loadSettings();
setView(state.view || 'chat'); setView(state.view || 'chat');
void window.SA?.training?.resumePolling?.();
updateGate(); updateGate();
ensureBoard(); ensureBoard();
setBoardTab(state.boardTab || 'generate', { persist: false }); setBoardTab(state.boardTab || 'generate', { persist: false });
@@ -9357,19 +9491,27 @@ if (!(meta && meta.historical)) {
const vary = btn.getAttribute('data-vary'); const vary = btn.getAttribute('data-vary');
const profile = btn.getAttribute('data-krea-profile'); const profile = btn.getAttribute('data-krea-profile');
if (aspect) { if (aspect) {
await applyQuickPatch({ aspect, actions: ['generate'] }, `Aspect ${aspect}`); await applyQuickPatch({ aspect }, `Aspect ${aspect}`);
} else if (seed === 'lock') { } else if (seed === 'lock') {
await applyQuickPatch({ lock_seed: true }, 'Seed locked'); await applyQuickPatch({ lock_seed: true }, 'Seed locked');
} else if (seed === 'random') { } else if (seed === 'random') {
await applyQuickPatch({ seed: -1, actions: ['generate'] }, 'Seed random'); await applyQuickPatch({ seed: -1 }, 'Seed random');
} else if (vary) { } else if (vary) {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary'); await applyQuickPatch({ vary: true, seed: -1 }, 'Vary', { generate: true });
} else if (profile === 'turbo') { } else if (profile === 'turbo') {
const p = state.kreaProfiles?.turbo || mergedGenerationDefaults('turbo'); const p = state.kreaProfiles?.turbo || mergedGenerationDefaults('turbo');
await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo'); await applyQuickPatch({
steps: p.steps ?? 8,
cfg: p.cfg ?? 1,
sigma_shift: p.sigma_shift ?? 1.15,
}, 'Turbo');
} else if (profile === 'raw') { } else if (profile === 'raw') {
const p = state.kreaProfiles?.raw || mergedGenerationDefaults('raw'); const p = state.kreaProfiles?.raw || mergedGenerationDefaults('raw');
await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, sigma_shift: p.sigma_shift, actions: ['generate'] }, 'RAW'); await applyQuickPatch({
steps: p.steps ?? 28,
cfg: p.cfg ?? 4.5,
sigma_shift: p.sigma_shift,
}, 'RAW');
} }
renderLoraChips(); renderLoraChips();
}); });
+108
View File
@@ -0,0 +1,108 @@
/**
* Aspect → pixel sizes aligned with SwarmUI (Side Length 1024).
* Ref sheet @ 512px from SwarmUI T2IParamInput.ResolutionAspectReferences;
* width/height = round(ref * (sideLen / 512), 16).
*/
export const SWARM_ASPECT_REF_512 = {
'1:1': [512, 512],
'4:3': [576, 448],
'3:2': [608, 416],
'8:5': [608, 384],
'16:9': [672, 384],
'21:9': [768, 320],
'2:3': [416, 608],
'5:8': [384, 608],
'9:16': [384, 672],
'9:21': [320, 768],
};
const ASPECT_ALIASES = {
'2.35:1': '21:9',
};
/** Krea 1K bucket; not in Swarm aspect dropdown (we keep 4:5 chip). */
const KREA_EXTRA = {
'4:5': [928, 1152],
};
function roundTo16(n) {
return Math.round(n / 16) * 16;
}
export function swarmSizeFromRef(aspectKey, sideLen = 1024) {
const key = ASPECT_ALIASES[aspectKey] || aspectKey;
if (KREA_EXTRA[key]) {
return [...KREA_EXTRA[key]];
}
const ref = SWARM_ASPECT_REF_512[key];
if (!ref) {
return null;
}
const scale = sideLen / 512;
return [roundTo16(ref[0] * scale), roundTo16(ref[1] * scale)];
}
export function buildDefaultAspectTable(sideLen = 1024) {
const table = {};
for (const key of Object.keys(SWARM_ASPECT_REF_512)) {
table[key] = swarmSizeFromRef(key, sideLen);
}
table['2.35:1'] = swarmSizeFromRef('21:9', sideLen);
for (const [key, size] of Object.entries(KREA_EXTRA)) {
table[key] = [...size];
}
return table;
}
export const DEFAULT_ASPECT_TABLE = buildDefaultAspectTable(1024);
export function normalizeAspectKey(raw, table = DEFAULT_ASPECT_TABLE) {
let s = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '');
if (!s) {
return null;
}
if (s === 'square') {
s = '1:1';
} else if (s === 'portrait' || s === 'vert') {
s = '2:3';
} else if (s === 'landscape' || s === 'horiz') {
s = '16:9';
} else if (s === 'cinematic' || s === 'ultrawide') {
s = '2.35:1';
}
return table[s] ? s : null;
}
export function guessAspectFromDimensions(w, h, table = DEFAULT_ASPECT_TABLE) {
const width = parseInt(w, 10);
const height = parseInt(h, 10);
if (!width || !height) {
return null;
}
let best = null;
let bestDist = Infinity;
for (const [key, [aw, ah]] of Object.entries(table)) {
const dist = Math.abs(width / height - aw / ah)
+ Math.abs(width - aw) / 4000
+ Math.abs(height - ah) / 4000;
if (dist < bestDist) {
bestDist = dist;
best = key;
}
}
return bestDist < 0.12 ? best : null;
}
export function applyAspectTableFromObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const next = {};
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v) && v.length >= 2) {
next[k] = [Number(v[0]), Number(v[1])];
}
}
return Object.keys(next).length ? next : false;
}
+12 -7
View File
@@ -1,4 +1,4 @@
/** Turn intent — veto + model generate; user «сгенерируй» also counts when a prompt/delta exists. */ /** Turn intent — user-owned Generate; model generate:true is advisory only. */
export function cyrTokenRe(alts) { export function cyrTokenRe(alts) {
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])'; const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
@@ -32,8 +32,9 @@ export function userAsksGenerate(text) {
return true; return true;
} }
return cyrTokenRe( return cyrTokenRe(
'сгенер[а-яё]*|нарисуй|нарисуйте|' 'сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|'
+ 'запусти\\s+генер[а-яё]*|сделай\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)', + 'запусти\\s+генер[а-яё]*|'
+ 'сдела(й|ем|йте)\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
).test(t); ).test(t);
} }
@@ -84,18 +85,22 @@ function generateFlagOn(patch) {
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'); return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
} }
/** Model generate flag, or user «сгенерируй» when a prompt/delta already exists. */ /**
* Generate is user-owned. Model `generate:true` is advisory (Qwen dumps it on Q&A).
* User «нарисуй»/«сгенерируй» (or opts.userWantsGenerate from hops/buttons) starts Generate.
*/
export function resolveTurnIntent(patch, userText, opts = {}) { export function resolveTurnIntent(patch, userText, opts = {}) {
const vetoed = !opts.machineTurn && userAsksNoGenerate(userText); const vetoed = !opts.machineTurn && userAsksNoGenerate(userText);
const modelAsked = generateFlagOn(patch); const modelAsked = generateFlagOn(patch);
const hasPrompt = !!(String(patch?.prompt || '').trim() || String(opts.sessionPrompt || '').trim());
const userAsked = !opts.machineTurn && userAsksGenerate(userText) const userAsked = !opts.machineTurn && userAsksGenerate(userText)
&& !!(modelAsked || String(patch?.prompt || '').trim()); && (modelAsked || hasPrompt);
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked); const generate = !vetoed && !opts.fromAutoCritique && (userAsked || !!opts.userWantsGenerate);
const hasLook = !!patch const hasLook = !!patch
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null); && (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const look = !!(hasLook && !vetoed && !generate); const look = !!(hasLook && !vetoed && !generate);
const ask = Array.isArray(patch?.ask) const ask = Array.isArray(patch?.ask)
? patch.ask.map(String) ? patch.ask.map(String)
: (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []); : (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []);
return { generate, look, vetoed, ask }; return { generate, look, vetoed, ask, modelAsked };
} }
+4
View File
@@ -1,3 +1,4 @@
import { userAsksGenerate, userAsksNoGenerate, resolveTurnIntent } from './intent.js';
import { attachApi } from './api.js'; import { attachApi } from './api.js';
import { attachPatch, setPatchKeys } from './patch.js'; import { attachPatch, setPatchKeys } from './patch.js';
import { attachPersist } from './persist.js'; import { attachPersist } from './persist.js';
@@ -7,6 +8,9 @@ import { attachActivity } from './activity.js';
import { attachKreaProfile } from './kreaProfile.js'; import { attachKreaProfile } from './kreaProfile.js';
window.SA = window.SA || {}; window.SA = window.SA || {};
window.SA.userAsksGenerate = userAsksGenerate;
window.SA.userAsksNoGenerate = userAsksNoGenerate;
window.SA.resolveTurnIntent = resolveTurnIntent;
attachApi(window.SA); attachApi(window.SA);
attachPatch(window.SA); attachPatch(window.SA);
attachPersist(window.SA); attachPersist(window.SA);
+10
View File
@@ -131,6 +131,15 @@ export function extractPatch(text) {
return { prose: text, patch: null }; return { prose: text, patch: null };
} }
/** Chat body text: never fall back to the raw fence when a patch was extracted. */
export function visibleProse(text) {
const { prose, patch } = extractPatch(text);
if (patch) {
return prose || '';
}
return String(text || '');
}
export function isTerminalStreamPatch(obj) { export function isTerminalStreamPatch(obj) {
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return false; return false;
@@ -170,4 +179,5 @@ export function attachPatch(SA) {
SA.normalizePatch = normalizePatch; SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch; SA.extractPatch = extractPatch;
SA.generateFlagOn = generateFlagOn; SA.generateFlagOn = generateFlagOn;
SA.visibleProse = visibleProse;
} }
+11 -5
View File
@@ -345,18 +345,24 @@ export function fullSettingsDump(session, extras = {}) {
}; };
} }
/** Model generate/actions, or askGenerateFn when a prompt/delta exists. vetoFn / fromAutoCritique cancel. */ /**
export function resolveTurnIntent(patch, userText, { vetoFn, askGenerateFn, fromAutoCritique } = {}) { * Generate is user-owned. Model generate:true is advisory (Qwen dumps it on chat questions).
* askGenerateFn / userWantsGenerate start Generate; vetoFn / fromAutoCritique cancel.
*/
export function resolveTurnIntent(patch, userText, {
vetoFn, askGenerateFn, fromAutoCritique, sessionPrompt, userWantsGenerate,
} = {}) {
const delta = normalizeDelta(patch) || {}; const delta = normalizeDelta(patch) || {};
const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false; const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false;
const modelAsked = patchWantsGenerate(delta); const modelAsked = patchWantsGenerate(delta);
const hasPrompt = !!(String(delta.prompt || '').trim() || String(sessionPrompt || '').trim());
const userAsked = typeof askGenerateFn === 'function' && !!askGenerateFn(userText) const userAsked = typeof askGenerateFn === 'function' && !!askGenerateFn(userText)
&& !!(modelAsked || String(delta.prompt || '').trim()); && (modelAsked || hasPrompt);
const generate = !vetoed && !fromAutoCritique && (modelAsked || userAsked); const generate = !vetoed && !fromAutoCritique && (userAsked || !!userWantsGenerate);
const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null; const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null;
const look = !!(hasLook && !generate && !vetoed); const look = !!(hasLook && !generate && !vetoed);
const ask = patchAskList(delta); const ask = patchAskList(delta);
return { generate, look, vetoed, ask }; return { generate, look, vetoed, ask, modelAsked };
} }
/** Params the client fills from Exact on Generate when the LLM omits them (sparse contract). */ /** Params the client fills from Exact on Generate when the LLM omits them (sparse contract). */
+98 -8
View File
@@ -101,6 +101,7 @@ export function attachTraining(SA) {
if (state.ttab === 'train') { if (state.ttab === 'train') {
syncModelfileModels(); syncModelfileModels();
syncQloraModels(); syncQloraModels();
void resumeTrainJobPolling();
} }
if (state.ttab === 'models') refreshTrainModels(); if (state.ttab === 'models') refreshTrainModels();
} }
@@ -112,7 +113,12 @@ export function attachTraining(SA) {
const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 }); const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 });
state.samples = data?.samples || []; state.samples = data?.samples || [];
const stats = $('sa_train_stats'); const stats = $('sa_train_stats');
if (stats) stats.textContent = `Одобрено: ${data?.approved ?? '—'} · всего: ${data?.total ?? '—'}`; if (stats) {
const appr = data?.approved ?? '—';
const draft = data?.draft ?? '—';
const total = data?.total ?? '—';
stats.textContent = `Одобрено: ${appr} · черновики: ${draft} · всего: ${total}`;
}
const personaSel = $('sa_train_filter_persona'); const personaSel = $('sa_train_filter_persona');
if (personaSel && $('sa_persona')) { if (personaSel && $('sa_persona')) {
const cur = personaSel.value || 'all'; const cur = personaSel.value || 'all';
@@ -134,8 +140,14 @@ export function attachTraining(SA) {
function renderSamples() { function renderSamples() {
const root = $('sa_train_samples'); const root = $('sa_train_samples');
if (!root) return; if (!root) return;
const filter = $('sa_train_filter_status')?.value || 'all';
if (!state.samples.length) { if (!state.samples.length) {
root.innerHTML = '<div class="sa-mem-empty">Нет примеров. Отметь ответы в чате или импортируй датасет.</div>'; const hint = filter === 'approved'
? 'Под фильтром «Одобренные» пусто. HF-импорт создаёт <strong>черновики</strong> — переключи на «Черновики» или «Все статусы».'
: filter === 'draft'
? 'Нет черновиков. Импортируй HF или отметь примеры в чате.'
: 'Нет примеров. Отметь ответы в чате или импортируй датасет (HF / файл).';
root.innerHTML = `<div class="sa-mem-empty">${hint}</div>`;
return; return;
} }
root.innerHTML = ''; root.innerHTML = '';
@@ -302,19 +314,63 @@ export function attachTraining(SA) {
} }
async function importHf() { async function importHf() {
const link = ($('sa_hf_link')?.value || '').trim();
if (!state.hfCheck?.id && link) {
await checkHfLink();
}
if (!state.hfSelected && !state.hfCheck?.id) { if (!state.hfSelected && !state.hfCheck?.id) {
setTrainStatus('Сначала проверь набор'); setTrainStatus('Сначала проверь набор');
const hfSt = $('sa_hf_status');
if (hfSt) hfSt.textContent = 'Вставь ссылку и нажми «Проверить»';
return; return;
} }
const id = state.hfSelected || state.hfCheck.id; const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200; const limit = Number($('sa_hf_import_limit')?.value) || 200;
const mapping = buildHfMappingPayload(); const mapping = buildHfMappingPayload();
const btn = $('sa_btn_hf_import');
const hfSt = $('sa_hf_status');
const busy = limit > 400
? `Импортирую до ${limit} строк… (12 мин)`
: `Импортирую до ${limit}`;
setTrainStatus(busy);
if (hfSt) {
hfSt.textContent = busy;
hfSt.classList.add('sa-hf-busy');
}
if (btn) {
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = '…';
}
try { try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping }); const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping });
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`); const n = Number(data?.imported) || 0;
let msg;
if (data?.runner_only) {
msg = `Runner-only: ${data.note || id} (в sqlite не импортировали)`;
} else if (n > 0) {
const filt = $('sa_train_filter_status');
if (filt && filt.value === 'approved') {
filt.value = 'draft';
}
msg = `Импортировано: ${n} черновик(ов) — список ниже (фильтр → черновики)`;
} else {
msg = 'Импортировано: 0 — HF token в User Settings, маппинг или gated-набор';
}
setTrainStatus(msg);
if (hfSt) hfSt.textContent = msg;
await refreshSamples(); await refreshSamples();
$('sa_train_samples')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) { } catch (e) {
setTrainStatus(String(e.message || e)); const err = String(e.message || e);
setTrainStatus(err);
if (hfSt) hfSt.textContent = err;
} finally {
if (hfSt) hfSt.classList.remove('sa-hf-busy');
if (btn) {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Импортировать';
}
} }
} }
@@ -345,7 +401,9 @@ export function attachTraining(SA) {
} }
async function createModelfile() { async function createModelfile() {
setTrainStatus('Создаю модель…'); const btn = $('sa_btn_modelfile_create');
btn?.setAttribute('disabled', 'disabled');
setTrainStatus('Создаю Modelfile в Ollama…');
try { try {
const data = await SA.request('AssistentCreateOllamaModel', { const data = await SA.request('AssistentCreateOllamaModel', {
base_url: $('sa_base_url')?.value, base_url: $('sa_base_url')?.value,
@@ -361,12 +419,16 @@ export function attachTraining(SA) {
SA.app?.refreshModels?.(); SA.app?.refreshModels?.();
} catch (e) { } catch (e) {
setTrainStatus(String(e.message || e)); setTrainStatus(String(e.message || e));
} finally {
btn?.removeAttribute('disabled');
} }
} }
function setTrainMode(mode) { function setTrainMode(mode) {
$('sa_train_form_modelfile').hidden = mode !== 'modelfile'; $('sa_train_form_modelfile').hidden = mode !== 'modelfile';
$('sa_train_form_qlora').hidden = mode !== 'qlora'; $('sa_train_form_qlora').hidden = mode !== 'qlora';
const radio = document.querySelector(`input[name="sa_train_mode"][value="${mode}"]`);
if (radio) radio.checked = true;
} }
function setTrainingLock(on, text) { function setTrainingLock(on, text) {
@@ -387,15 +449,26 @@ export function attachTraining(SA) {
const prog = data?.progress || (data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null); const prog = data?.progress || (data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null);
const active = data?.training_active || data?.job?.status === 'running'; const active = data?.training_active || data?.job?.status === 'running';
const status = data?.job?.status || prog?.status; const status = data?.job?.status || prog?.status;
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…'); const pct = prog?.percent;
const bannerText = active && pct != null
? `QLoRA · ${pct}%`
: active
? 'Идёт QLoRA…'
: 'Идёт тренировка…';
setTrainingLock(active, bannerText);
const logEl = $('sa_train_log'); const logEl = $('sa_train_log');
const bar = $('sa_train_progress_fill'); const bar = $('sa_train_progress_fill');
const box = $('sa_train_progress'); const box = $('sa_train_progress');
if (prog) { if (prog) {
if (box) box.hidden = false; if (box) box.hidden = false;
if (bar && prog.percent != null) bar.style.width = `${prog.percent}%`; if (bar && pct != null) bar.style.width = `${pct}%`;
if (logEl && prog.log) logEl.textContent = prog.log; if (logEl && prog.log) logEl.textContent = prog.log;
} }
if (active) {
setTrainStatus(pct != null
? `QLoRA · ${pct}% — полный лог на вкладке «Тренировка»`
: 'QLoRA запущена — полный лог на вкладке «Тренировка»');
}
if (!active) { if (!active) {
clearInterval(state.polling); clearInterval(state.polling);
state.polling = null; state.polling = null;
@@ -422,6 +495,19 @@ export function attachTraining(SA) {
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
async function resumeTrainJobPolling() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const active = data?.training_active || data?.job?.status === 'running';
await pollTrainJob();
if (!active) return;
setTrainMode('qlora');
$('sa_btn_qlora_cancel').hidden = false;
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
} catch (e) { /* ignore */ }
}
async function startQlora() { async function startQlora() {
setTrainStatus('Запуск…'); setTrainStatus('Запуск…');
try { try {
@@ -448,7 +534,9 @@ export function attachTraining(SA) {
if (state.polling) clearInterval(state.polling); if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500); state.polling = setInterval(pollTrainJob, 1500);
pollTrainJob(); pollTrainJob();
setTrainStatus('Тренировка запущена'); setTrainMode('qlora');
setTrainingTab('train');
setTrainStatus('QLoRA запущена — прогресс ниже');
} catch (e) { } catch (e) {
setTrainStatus(String(e.message || e)); setTrainStatus(String(e.message || e));
} }
@@ -614,6 +702,7 @@ export function attachTraining(SA) {
$('sa_btn_save_runner')?.addEventListener('click', saveRunner); $('sa_btn_save_runner')?.addEventListener('click', saveRunner);
loadRunner(); loadRunner();
setTrainMode('modelfile'); setTrainMode('modelfile');
void resumeTrainJobPolling();
} }
SA.training = { SA.training = {
@@ -621,6 +710,7 @@ export function attachTraining(SA) {
wireTraining(); wireTraining();
setTrainingTab(state.ttab); setTrainingTab(state.ttab);
}, },
resumePolling: resumeTrainJobPolling,
async curateFromChat(messages, meta) { async curateFromChat(messages, meta) {
try { try {
await SA.request('AssistentUpsertTrainSample', { await SA.request('AssistentUpsertTrainSample', {
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DEFAULT_ASPECT_TABLE,
buildDefaultAspectTable,
swarmSizeFromRef,
} from '../src/aspect.js';
describe('aspect.js (SwarmUI side 1024)', () => {
it('16:9 and 9:16 match Swarm Resolution dropdown', () => {
assert.deepEqual(swarmSizeFromRef('16:9'), [1344, 768]);
assert.deepEqual(swarmSizeFromRef('9:16'), [768, 1344]);
});
it('default table uses Swarm formula for landscape/portrait chips', () => {
assert.deepEqual(DEFAULT_ASPECT_TABLE['16:9'], [1344, 768]);
assert.deepEqual(DEFAULT_ASPECT_TABLE['9:16'], [768, 1344]);
assert.deepEqual(DEFAULT_ASPECT_TABLE['2:3'], [832, 1216]);
assert.deepEqual(DEFAULT_ASPECT_TABLE['1:1'], [1024, 1024]);
});
it('2.35:1 aliases 21:9', () => {
assert.deepEqual(DEFAULT_ASPECT_TABLE['2.35:1'], DEFAULT_ASPECT_TABLE['21:9']);
assert.deepEqual(DEFAULT_ASPECT_TABLE['21:9'], [1536, 640]);
});
it('scales with side length', () => {
const t = buildDefaultAspectTable(512);
assert.deepEqual(t['16:9'], [672, 384]);
});
});
+18 -1
View File
@@ -23,11 +23,13 @@ describe('intent.js', () => {
it('userAsksGenerate detects сгенерируй', () => { it('userAsksGenerate detects сгенерируй', () => {
assert.equal(userAsksGenerate('сгенерируй'), true); assert.equal(userAsksGenerate('сгенерируй'), true);
assert.equal(userAsksGenerate('нарисуй лису'), true); assert.equal(userAsksGenerate('нарисуй лису'), true);
assert.equal(userAsksGenerate('Давай сделаем изображение красивой рыжеволосой девушки'), true);
assert.equal(userAsksGenerate('а ты знаешь какие то промпты с civitai'), false);
assert.equal(userAsksGenerate('что ты умеешь'), false); assert.equal(userAsksGenerate('что ты умеешь'), false);
assert.equal(userAsksGenerate('не генерируй'), false); assert.equal(userAsksGenerate('не генерируй'), false);
}); });
it('resolveTurnIntent uses model generate + user generate ask + veto', () => { it('resolveTurnIntent uses user generate ask + veto, not model flag alone', () => {
const lookOnly = resolveTurnIntent({ look_at: ['generate'] }, 'что не так с кадром?', {}); const lookOnly = resolveTurnIntent({ look_at: ['generate'] }, 'что не так с кадром?', {});
assert.equal(lookOnly.generate, false); assert.equal(lookOnly.generate, false);
assert.equal(lookOnly.look, true); assert.equal(lookOnly.look, true);
@@ -44,6 +46,21 @@ describe('intent.js', () => {
const chatOnly = resolveTurnIntent({ prompt: 'fox' }, 'что ты умеешь про генерацию?', {}); const chatOnly = resolveTurnIntent({ prompt: 'fox' }, 'что ты умеешь про генерацию?', {});
assert.equal(chatOnly.generate, false); assert.equal(chatOnly.generate, false);
const falseDump = resolveTurnIntent(
{ prompt: 'civitai example', generate: true },
'а ты знаешь какие то промпты с civitai',
{},
);
assert.equal(falseDump.generate, false);
assert.equal(falseDump.modelAsked, true);
const sdelаем = resolveTurnIntent(
{ prompt: 'A 19-year-old redhead', generate: true },
'Давай сделаем изображение красивой рыжеволосой девушки',
{},
);
assert.equal(sdelаем.generate, true);
}); });
it('packWantsVision for critique pack', () => { it('packWantsVision for critique pack', () => {
+17
View File
@@ -5,6 +5,7 @@ import {
isPatchObject, isPatchObject,
normalizePatch, normalizePatch,
setPatchKeys, setPatchKeys,
visibleProse,
} from '../src/patch.js'; } from '../src/patch.js';
import { import {
mergeDelta, mergeDelta,
@@ -56,6 +57,12 @@ describe('patch.js', () => {
assert.equal(raw.prose, 'done'); assert.equal(raw.prose, 'done');
}); });
it('visibleProse hides the JSON fence when chat text is empty', () => {
assert.equal(visibleProse('```json\n{"prompt":"fox","generate":true}\n```'), '');
assert.equal(visibleProse('Ок, держи\n```json\n{"prompt":"fox"}\n```'), 'Ок, держи');
assert.equal(visibleProse('просто чат без патча'), 'просто чат без патча');
});
it('scheduler-only patch is detected with full key list', () => { it('scheduler-only patch is detected with full key list', () => {
setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']); setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']);
assert.equal(isPatchObject({ scheduler: 'euler' }), true); assert.equal(isPatchObject({ scheduler: 'euler' }), true);
@@ -112,6 +119,16 @@ describe('session.js', () => {
assert.equal(patchWantsGenerate({ generate: true }), true); assert.equal(patchWantsGenerate({ generate: true }), true);
}); });
it('resolveTurnIntent ignores model generate:true on a knowledge question', () => {
const intent = resolveTurnIntent(
{ prompt: 'civitai example prompt', generate: true },
'а ты знаешь какие то промпты с civitai',
{ askGenerateFn: (t) => /сгенер|нарисуй/i.test(t) },
);
assert.equal(intent.generate, false);
assert.equal(intent.modelAsked, true);
});
it('mergeExactParamsForGenerate fills turbo profile when LLM omits steps/cfg', () => { it('mergeExactParamsForGenerate fills turbo profile when LLM omits steps/cfg', () => {
const exact = { const exact = {
generation: { profile: 'turbo', steps: 8, cfg: 1, sigma_shift: 1.15 }, generation: { profile: 'turbo', steps: 8, cfg: 1, sigma_shift: 1.15 },