diff --git a/Assets/assistent.bundle.js b/Assets/assistent.bundle.js index 5260ad0..72ae7e6 100644 --- a/Assets/assistent.bundle.js +++ b/Assets/assistent.bundle.js @@ -1316,6 +1316,10 @@ busyStarted: 0, gotDelta: false, busyTimer: null, + lastDeltaAt: 0, + streamStallTimer: null, + turnSettled: false, + lastBusyPhaseShown: "", slots: [], selectedSlotId: "ref1", refSeq: 1, @@ -1511,16 +1515,22 @@ refining: "prep", compressing: "compress" }; - activityStep(`phase:${state.busyPhase}`, { - kind: phaseKind[state.busyPhase] || "think", - label: text, - status: "running" - }); - const a = getActivity(); - if (a && Array.isArray(a.steps)) { - for (const s of a.steps) { - if (s.id.startsWith("phase:") && s.id !== `phase:${state.busyPhase}` && s.status === "running") { - a.done(s.id); + if (state.lastBusyPhaseShown !== state.busyPhase) { + state.lastBusyPhaseShown = state.busyPhase; + if (state.busyPhase === "streaming" || state.busyPhase === "refining") { + activityDone("think"); + } + activityStep(`phase:${state.busyPhase}`, { + kind: phaseKind[state.busyPhase] || "think", + label: text, + status: "running" + }); + const a = getActivity(); + if (a && Array.isArray(a.steps)) { + for (const s of a.steps) { + if (s.id.startsWith("phase:") && s.id !== `phase:${state.busyPhase}` && s.status === "running") { + a.done(s.id); + } } } } @@ -1544,12 +1554,15 @@ state.busyPhase = phase || "thinking"; $2("swarm_assistent_root")?.classList.add("sa-is-busy"); $2("sa_composer")?.classList.add("sa-composer-busy"); + state.lastBusyPhaseShown = ""; const send = $2("sa_btn_send"); if (send) { - send.disabled = true; + send.disabled = false; } const input = $2("sa_input"); if (input) { + input.readOnly = false; + input.disabled = false; input.classList.add("sa-input-busy"); } const bar = $2("sa_livebar"); @@ -1573,8 +1586,10 @@ clearInterval(state.busyTimer); state.busyTimer = null; } + clearStreamStall(); const elapsed = Date.now() - (state.busyStarted || Date.now()); state.busyPhase = "idle"; + state.lastBusyPhaseShown = ""; activityFinish(finalStatus || "\u0413\u043E\u0442\u043E\u0432\u043E"); $2("swarm_assistent_root")?.classList.remove("sa-is-busy"); $2("sa_composer")?.classList.remove("sa-composer-busy"); @@ -5545,6 +5560,53 @@ ${patch.prompt}`; state.chatEpoch = (state.chatEpoch || 0) + 1; return state.chatEpoch; } + function clearStreamStall() { + if (state.streamStallTimer) { + clearInterval(state.streamStallTimer); + state.streamStallTimer = null; + } + } + function armStreamStall(chatEpoch, onStall) { + clearStreamStall(); + state.lastDeltaAt = Date.now(); + state.streamStallTimer = setInterval(() => { + if (chatEpoch !== state.chatEpoch) { + clearStreamStall(); + return; + } + if (!state.gotDelta || !state.busy || state.generating || state.turnSettled) { + return; + } + const waitMs = state.streamFenceDone ? 4e3 : 15e3; + if (Date.now() - (state.lastDeltaAt || 0) < waitMs) { + return; + } + clearStreamStall(); + const reply = state.streamText || state.streamEl?.querySelector(".sa-msg-body")?.textContent || ""; + try { + onStall(String(reply || "")); + } catch (e) { + console.warn("Assistent stream stall", e); + } + }, 800); + } + function settlePartialStream() { + const text = String(state.streamText || "").trim(); + if (!text) { + return; + } + const persona = $2("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral"; + const pack = $2("sa_pack")?.value || defaultPackId(); + const prose = typeof extractPatch2 === "function" ? extractPatch2(text).prose || text : text; + if (state.streamEl) { + finalizeStreamMessage(text, []); + } else { + appendMessage("assistant", prose); + } + state.history.push({ role: "assistant", content: prose, persona, pack }); + persistHistory(); + state.turnSettled = true; + } function clearInFlightUi({ status } = {}) { state.busy = false; state.generating = false; @@ -5567,6 +5629,7 @@ ${patch.prompt}`; } } function abortInFlightWork({ status, interruptSwarm = false } = {}) { + settlePartialStream(); bumpChatEpoch(); cancelWaitForNewImage(); if (interruptSwarm) { @@ -5584,6 +5647,7 @@ ${patch.prompt}`; clearInFlightUi({ status: status != null ? status : "" }); } function doInterruptNow() { + settlePartialStream(); bumpChatEpoch(); cancelWaitForNewImage(); try { @@ -6145,6 +6209,7 @@ ${patch.prompt}`; setAssistantBody(state.streamEl, "", { live: true }); } state.gotDelta = true; + state.lastDeltaAt = Date.now(); state.expectColdLoad = false; if (state.busyPhase !== "refining") { setBusyPhase("streaming"); @@ -6166,6 +6231,9 @@ ${patch.prompt}`; state.streamText = ""; state.streamFenceDone = false; if (!el) { + if (state.turnSettled) { + return; + } appendMessage("assistant", fullReply, null, civitaiResults, meta || void 0); return; } @@ -8790,9 +8858,6 @@ ${HELP_TEXT}`); return true; } async function sendChat(opts = {}) { - if ((state.busy || state.generating) && !isContinuationTurn(opts)) { - return; - } if (isTrainingLocked() && !isContinuationTurn(opts)) { setStatus("\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430 \u2014 \u0447\u0430\u0442 \u0437\u0430\u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u0430\u043D"); return; @@ -8802,6 +8867,13 @@ ${HELP_TEXT}`); if (!text) { return; } + if (state.generating && !isContinuationTurn(opts)) { + setStatus("\u0418\u0434\u0451\u0442 Generate \u2014 \u043D\u0430\u0436\u043C\u0438 \u0421\u0442\u043E\u043F, \u043F\u043E\u0442\u043E\u043C \u043E\u0442\u043F\u0440\u0430\u0432\u044C"); + return; + } + if (state.busy && !isContinuationTurn(opts)) { + abortInFlightWork({ status: "\u041D\u043E\u0432\u043E\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435", interruptSwarm: false }); + } if (!isMachineTurn(opts)) { state.lastUserParamIntent = userTextMentionsParams(text); state.lastUserControlIntent = userTextMentionsControls(text); @@ -8863,6 +8935,7 @@ ${HELP_TEXT}`); } const chatEpoch = bumpChatEpoch(); state.busy = true; + state.turnSettled = false; state.llmParked = false; setInterruptVisible(true); if (!isContinuationTurn(opts) && !opts.fromAskHop) { @@ -8997,6 +9070,11 @@ ${HELP_TEXT}`); if (chatEpoch !== state.chatEpoch) { return; } + if (state.turnSettled) { + return; + } + state.turnSettled = true; + clearStreamStall(); if (meta.system_chars != null) { state.lastSystemChars = Number(meta.system_chars) || 0; } @@ -9046,6 +9124,11 @@ ${HELP_TEXT}`); if (chatEpoch !== state.chatEpoch) { return; } + if (state.turnSettled) { + return; + } + state.turnSettled = true; + clearStreamStall(); state.busy = false; setInterruptVisible(state.generating); stopBusyUi(msg); @@ -9061,6 +9144,15 @@ ${HELP_TEXT}`); }; if (typeof makeWSRequest === "function") { beginStreamMessage(msgMeta); + armStreamStall(chatEpoch, (reply) => { + if (state.turnSettled || chatEpoch !== state.chatEpoch) { + return; + } + if (state.streamEl) { + finalizeStreamMessage(reply, []); + } + finishOk(reply, [], {}); + }); makeWSRequest( "AssistentChatWS", payload, @@ -9091,12 +9183,16 @@ ${HELP_TEXT}`); } if (data.delta) { appendStreamDelta(data.delta); - return; } if (data.done || data.reply != null) { + if (state.turnSettled) { + return; + } const reply = data.reply || state.streamEl?.querySelector(".sa-msg-body")?.textContent || ""; const civitai = data.civitai_results || []; - finalizeStreamMessage(reply, civitai); + if (state.streamEl) { + finalizeStreamMessage(reply, civitai); + } finishOk(reply, civitai, { system_chars: data.system_chars, system_layers: data.system_layers, @@ -9607,7 +9703,6 @@ ${HELP_TEXT}`); $2("sa_btn_build_gen")?.addEventListener("click", () => buildCurrentAndGenerate()); $2("sa_btn_interrupt")?.addEventListener("click", () => { doInterruptNow(); - clearInFlightUi({ status: "\u041F\u0440\u0435\u0440\u0432\u0430\u043D\u043E" }); }); $2("sa_btn_clear")?.addEventListener("click", () => { if (window.confirm("\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C \u0432\u0435\u0441\u044C \u0447\u0430\u0442 Assistent?")) { diff --git a/Assets/assistent.css b/Assets/assistent.css index 8c6f635..5745655 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -870,13 +870,15 @@ .sa-dots i:nth-child(2) { animation-delay: 0.15s; } .sa-dots i:nth-child(3) { animation-delay: 0.3s; } -.sa-is-busy .sa-primary { +.sa-is-busy .sa-primary:not(#sa_btn_send) { opacity: 0.55; pointer-events: none; } +.sa-is-busy #sa_input, .sa-input-busy { - opacity: 0.7; + opacity: 1; + pointer-events: auto; } .sa-composer-busy { diff --git a/AssistentOllama.cs b/AssistentOllama.cs index 35f4f4c..ff0afdc 100644 --- a/AssistentOllama.cs +++ b/AssistentOllama.cs @@ -184,6 +184,9 @@ public partial class SwarmAssistentExtension ["num_predict"] = numPredict, }, ["keep_alive"] = "15m", + // Qwen3-VL instruct still thinks by default; that holds the WS open after + // the visible greeting and leaves the composer looking locked. + ["think"] = false, }; using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); using HttpRequestMessage req = new(HttpMethod.Post, $"{root}/api/chat") { Content = content }; diff --git a/README.md b/README.md index 05eee16..e091211 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate. -**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 that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both. +**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.1** — Composer stays writable during `Writing…`: Enter / Отправить прерывает зависший стрим и шлёт новое сообщение; частичный ответ сохраняется. Stall 15с без токена сам завершает ход. Ollama chat `think: false`, чтобы Qwen3-VL instruct не держал сокет после приветствия. **Version 0.15.0** — Personas as installable git packs under `Assistent/extensions/` (`assistent-pack.yaml`). Bundled set: `neutral` (Нормальный), `aggressive`, `dreamer`. Removed bundled `cinema` / `lewd` / private personas. `/остынь` and `/horny-game` work for any persona with a `horny` control. Pack source: `pack` / `overlay+pack`. diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 2f10396..0fb36c4 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.15.0"; + Version = "0.15.1"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"]; } diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index 9b8dff8..287d931 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -112,7 +112,7 @@
- +
diff --git a/src/app.js b/src/app.js index 5fbc223..127260d 100644 --- a/src/app.js +++ b/src/app.js @@ -155,6 +155,10 @@ busyStarted: 0, gotDelta: false, busyTimer: null, + lastDeltaAt: 0, + streamStallTimer: null, + turnSettled: false, + lastBusyPhaseShown: '', slots: [], selectedSlotId: 'ref1', refSeq: 1, @@ -379,17 +383,22 @@ warming: 'warm', parking: 'park', encoding: 'look', generating: 'generate', applying: 'merge', silent_gen: 'generate', refining: 'prep', compressing: 'compress', }; - activityStep(`phase:${state.busyPhase}`, { - kind: phaseKind[state.busyPhase] || 'think', - label: text, - status: 'running', - }); - // Mark prior phase:* steps done when switching phase - const a = getActivity(); - if (a && Array.isArray(a.steps)) { - for (const s of a.steps) { - if (s.id.startsWith('phase:') && s.id !== `phase:${state.busyPhase}` && s.status === 'running') { - a.done(s.id); + if (state.lastBusyPhaseShown !== state.busyPhase) { + state.lastBusyPhaseShown = state.busyPhase; + if (state.busyPhase === 'streaming' || state.busyPhase === 'refining') { + activityDone('think'); + } + activityStep(`phase:${state.busyPhase}`, { + kind: phaseKind[state.busyPhase] || 'think', + label: text, + status: 'running', + }); + const a = getActivity(); + if (a && Array.isArray(a.steps)) { + for (const s of a.steps) { + if (s.id.startsWith('phase:') && s.id !== `phase:${state.busyPhase}` && s.status === 'running') { + a.done(s.id); + } } } } @@ -414,12 +423,16 @@ state.busyPhase = phase || 'thinking'; $('swarm_assistent_root')?.classList.add('sa-is-busy'); $('sa_composer')?.classList.add('sa-composer-busy'); + state.lastBusyPhaseShown = ''; + // Keep composer typed/sendable: Enter or Отправить interrupts the hung stream. const send = $('sa_btn_send'); if (send) { - send.disabled = true; + send.disabled = false; } const input = $('sa_input'); if (input) { + input.readOnly = false; + input.disabled = false; input.classList.add('sa-input-busy'); } const bar = $('sa_livebar'); @@ -444,8 +457,10 @@ clearInterval(state.busyTimer); state.busyTimer = null; } + clearStreamStall(); const elapsed = Date.now() - (state.busyStarted || Date.now()); state.busyPhase = 'idle'; + state.lastBusyPhaseShown = ''; activityFinish(finalStatus || 'Готово'); $('swarm_assistent_root')?.classList.remove('sa-is-busy'); $('sa_composer')?.classList.remove('sa-composer-busy'); @@ -4807,6 +4822,60 @@ return state.chatEpoch; } + function clearStreamStall() { + if (state.streamStallTimer) { + clearInterval(state.streamStallTimer); + state.streamStallTimer = null; + } + } + + /** If Ollama never sends `done` after the last token, unlock the composer. */ + function armStreamStall(chatEpoch, onStall) { + clearStreamStall(); + state.lastDeltaAt = Date.now(); + state.streamStallTimer = setInterval(() => { + if (chatEpoch !== state.chatEpoch) { + clearStreamStall(); + return; + } + if (!state.gotDelta || !state.busy || state.generating || state.turnSettled) { + return; + } + const waitMs = state.streamFenceDone ? 4000 : 15000; + if (Date.now() - (state.lastDeltaAt || 0) < waitMs) { + return; + } + clearStreamStall(); + const reply = state.streamText + || state.streamEl?.querySelector('.sa-msg-body')?.textContent + || ''; + try { + onStall(String(reply || '')); + } catch (e) { + console.warn('Assistent stream stall', e); + } + }, 800); + } + + /** Keep a visible partial reply when Stop / new send cuts the stream. */ + function settlePartialStream() { + const text = String(state.streamText || '').trim(); + if (!text) { + return; + } + const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral'; + const pack = $('sa_pack')?.value || defaultPackId(); + const prose = (typeof extractPatch === 'function' ? (extractPatch(text).prose || text) : text); + if (state.streamEl) { + finalizeStreamMessage(text, []); + } else { + appendMessage('assistant', prose); + } + state.history.push({ role: 'assistant', content: prose, persona, pack }); + persistHistory(); + state.turnSettled = true; + } + function clearInFlightUi({ status } = {}) { state.busy = false; state.generating = false; @@ -4828,6 +4897,7 @@ /** Invalidate in-flight Assistent WS/wait; optionally also interrupt Swarm Generate. */ function abortInFlightWork({ status, interruptSwarm = false } = {}) { + settlePartialStream(); bumpChatEpoch(); cancelWaitForNewImage(); if (interruptSwarm) { @@ -4843,6 +4913,7 @@ } function doInterruptNow() { + settlePartialStream(); bumpChatEpoch(); cancelWaitForNewImage(); try { @@ -5460,6 +5531,7 @@ if (role === 'assistant' && !(meta && meta.historical)) { setAssistantBody(state.streamEl, '', { live: true }); } state.gotDelta = true; + state.lastDeltaAt = Date.now(); state.expectColdLoad = false; if (state.busyPhase !== 'refining') { setBusyPhase('streaming'); @@ -5482,6 +5554,9 @@ if (role === 'assistant' && !(meta && meta.historical)) { state.streamText = ''; state.streamFenceDone = false; if (!el) { + if (state.turnSettled) { + return; + } appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined); return; } @@ -8226,9 +8301,6 @@ if (!(meta && meta.historical)) { async function sendChat(opts = {}) { // Continuations run inside the parent turn, which still holds `busy` // (finishOk only clears it after handleReplySideEffects returns). - if ((state.busy || state.generating) && !isContinuationTurn(opts)) { - return; - } if (isTrainingLocked() && !isContinuationTurn(opts)) { setStatus('Идёт тренировка — чат заблокирован'); return; @@ -8238,6 +8310,13 @@ if (!(meta && meta.historical)) { if (!text) { return; } + if (state.generating && !isContinuationTurn(opts)) { + setStatus('Идёт Generate — нажми Стоп, потом отправь'); + return; + } + if (state.busy && !isContinuationTurn(opts)) { + abortInFlightWork({ status: 'Новое сообщение', interruptSwarm: false }); + } if (!isMachineTurn(opts)) { state.lastUserParamIntent = userTextMentionsParams(text); state.lastUserControlIntent = userTextMentionsControls(text); @@ -8306,6 +8385,7 @@ if (!(meta && meta.historical)) { const chatEpoch = bumpChatEpoch(); state.busy = true; + state.turnSettled = false; // If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here. state.llmParked = false; setInterruptVisible(true); @@ -8460,6 +8540,11 @@ if (!(meta && meta.historical)) { if (chatEpoch !== state.chatEpoch) { return; } + if (state.turnSettled) { + return; + } + state.turnSettled = true; + clearStreamStall(); if (meta.system_chars != null) { state.lastSystemChars = Number(meta.system_chars) || 0; } @@ -8512,6 +8597,11 @@ if (!(meta && meta.historical)) { if (chatEpoch !== state.chatEpoch) { return; } + if (state.turnSettled) { + return; + } + state.turnSettled = true; + clearStreamStall(); state.busy = false; setInterruptVisible(state.generating); stopBusyUi(msg); @@ -8528,6 +8618,15 @@ if (!(meta && meta.historical)) { if (typeof makeWSRequest === 'function') { beginStreamMessage(msgMeta); + armStreamStall(chatEpoch, (reply) => { + if (state.turnSettled || chatEpoch !== state.chatEpoch) { + return; + } + if (state.streamEl) { + finalizeStreamMessage(reply, []); + } + finishOk(reply, [], {}); + }); makeWSRequest( 'AssistentChatWS', payload, @@ -8561,12 +8660,16 @@ if (!(meta && meta.historical)) { } if (data.delta) { appendStreamDelta(data.delta); - return; } if (data.done || data.reply != null) { + if (state.turnSettled) { + return; + } const reply = data.reply || (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || ''; const civitai = data.civitai_results || []; - finalizeStreamMessage(reply, civitai); + if (state.streamEl) { + finalizeStreamMessage(reply, civitai); + } finishOk(reply, civitai, { system_chars: data.system_chars, system_layers: data.system_layers, @@ -9095,7 +9198,6 @@ if (!(meta && meta.historical)) { $('sa_btn_build_gen')?.addEventListener('click', () => buildCurrentAndGenerate()); $('sa_btn_interrupt')?.addEventListener('click', () => { doInterruptNow(); - clearInFlightUi({ status: 'Прервано' }); }); $('sa_btn_clear')?.addEventListener('click', () => { if (window.confirm('Очистить весь чат Assistent?')) {