diff --git a/Assets/assistent.css b/Assets/assistent.css index 8774d96..3c24deb 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -160,6 +160,149 @@ .sa-chat-title { font-weight: 650; letter-spacing: 0.03em; + display: flex; + align-items: center; + gap: 0.45rem; +} + +.sa-live-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: #6ee7a8; + box-shadow: 0 0 0 0 color-mix(in srgb, #6ee7a8 70%, transparent); + animation: sa-pulse 1.4s ease-out infinite; +} + +.sa-chat-empty { + margin: auto; + padding: 1.5rem 1.1rem; + text-align: center; + opacity: 0.72; + max-width: 22rem; +} + +.sa-chat-empty-title { + font-weight: 650; + letter-spacing: 0.03em; + margin-bottom: 0.4rem; +} + +.sa-chat-empty-hint { + font-size: 0.9rem; + line-height: 1.45; + opacity: 0.9; +} + +.sa-livebar { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.45rem 0.85rem; + border-top: 1px solid color-mix(in srgb, currentColor 16%, transparent); + background: color-mix(in srgb, currentColor 6%, transparent); + font-size: 0.88rem; + min-height: 2.1rem; +} + +.sa-livebar[hidden] { + display: none !important; +} + +.sa-livebar-text { + flex: 1; + min-width: 0; + opacity: 0.92; +} + +.sa-elapsed { + font-variant-numeric: tabular-nums; + opacity: 0.65; + font-size: 0.8rem; +} + +.sa-spinner { + width: 0.85rem; + height: 0.85rem; + border-radius: 50%; + border: 2px solid color-mix(in srgb, currentColor 22%, transparent); + border-top-color: currentColor; + animation: sa-spin 0.7s linear infinite; + flex: 0 0 auto; +} + +.sa-msg { + animation: sa-msg-in 0.28s ease; +} + +.sa-msg.sa-typing { + display: flex; + align-items: center; + gap: 0.55rem; + font-style: normal; + opacity: 0.85; + min-height: 2.4rem; +} + +.sa-typing-label { + opacity: 0.75; + font-size: 0.9rem; +} + +.sa-dots { + display: inline-flex; + gap: 0.22rem; + align-items: center; +} + +.sa-dots i { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: currentColor; + opacity: 0.35; + animation: sa-dot 1.1s ease-in-out infinite; +} + +.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 { + opacity: 0.55; + pointer-events: none; +} + +.sa-input-busy { + opacity: 0.7; +} + +.sa-composer-busy { + box-shadow: inset 0 1px 0 color-mix(in srgb, currentColor 12%, transparent); +} + +.sa-status.sa-status-busy { + opacity: 0.9; + font-weight: 560; +} + +@keyframes sa-spin { + to { transform: rotate(360deg); } +} + +@keyframes sa-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, #6ee7a8 55%, transparent); } + 70% { box-shadow: 0 0 0 0.45rem transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +@keyframes sa-dot { + 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); } + 40% { opacity: 1; transform: translateY(-0.18rem); } +} + +@keyframes sa-msg-in { + from { opacity: 0; transform: translateY(0.35rem); } + to { opacity: 1; transform: none; } } .sa-header-right { diff --git a/Assets/assistent.js b/Assets/assistent.js index 1eec03d..d52b92d 100644 --- a/Assets/assistent.js +++ b/Assets/assistent.js @@ -1,6 +1,6 @@ /** * Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API). - * v0.3: inventory, streaming, auto-apply/generate, Civitai Confirm. + * v0.3.4: do not auto-fill Vision from the checkpoint preview. */ (function () { const LS_BASE = 'swarm_assistent_base_url'; @@ -25,12 +25,151 @@ inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false }, streamEl: null, critiqueHopUsed: false, + busyPhase: 'idle', + busyStarted: 0, + gotDelta: false, + busyTimer: null, }; function $(id) { return document.getElementById(id); } + function modelShort(name) { + const s = String(name || ''); + const slash = s.lastIndexOf('/'); + return (slash >= 0 ? s.slice(slash + 1) : s) || 'model'; + } + + function fmtElapsed(ms) { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) { + return `${s}s`; + } + return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`; + } + + function hideChatEmpty() { + const empty = $('sa_chat_empty'); + if (empty) { + empty.hidden = true; + } + } + + function showChatEmptyIfIdle() { + const box = $('sa_messages'); + const empty = $('sa_chat_empty'); + if (!box || !empty) { + return; + } + const hasMsg = [...box.children].some((el) => el.id !== 'sa_chat_empty'); + empty.hidden = hasMsg; + } + + function setBusyPhase(phase) { + state.busyPhase = phase || 'thinking'; + tickBusyUi(); + } + + function tickBusyUi() { + if (state.busyPhase === 'idle') { + return; + } + const elapsed = Date.now() - (state.busyStarted || Date.now()); + if (!state.gotDelta && (state.busyPhase === 'thinking' || state.busyPhase === 'waiting') && elapsed > 1600) { + state.busyPhase = 'loading'; + } + const model = modelShort($('sa_model')?.value); + const labels = { + encoding: 'Encoding image…', + waiting: 'Waiting for Ollama…', + loading: `Loading ${model} into GPU… first load can take a minute`, + thinking: 'Thinking…', + streaming: 'Writing…', + generating: 'Generating image…', + applying: 'Applying patch…', + refining: 'Civitai search done — refining…', + }; + const text = labels[state.busyPhase] || 'Working…'; + const barText = $('sa_livebar_text'); + if (barText) { + barText.textContent = text; + } + const elapsedEl = $('sa_elapsed'); + if (elapsedEl) { + elapsedEl.textContent = fmtElapsed(elapsed); + } + const status = $('sa_status'); + if (status) { + status.textContent = text; + status.classList.add('sa-status-busy'); + } + } + + function startBusyUi(phase) { + state.busyStarted = Date.now(); + state.gotDelta = false; + state.busyPhase = phase || 'thinking'; + $('swarm_assistent_root')?.classList.add('sa-is-busy'); + $('sa_composer')?.classList.add('sa-composer-busy'); + const send = $('sa_btn_send'); + if (send) { + send.disabled = true; + } + const input = $('sa_input'); + if (input) { + input.classList.add('sa-input-busy'); + } + const bar = $('sa_livebar'); + if (bar) { + bar.hidden = false; + } + const dot = $('sa_live_dot'); + if (dot) { + dot.hidden = false; + } + tickBusyUi(); + if (state.busyTimer) { + clearInterval(state.busyTimer); + } + state.busyTimer = setInterval(tickBusyUi, 400); + } + + function stopBusyUi(finalStatus) { + if (state.busyTimer) { + clearInterval(state.busyTimer); + state.busyTimer = null; + } + const elapsed = Date.now() - (state.busyStarted || Date.now()); + state.busyPhase = 'idle'; + $('swarm_assistent_root')?.classList.remove('sa-is-busy'); + $('sa_composer')?.classList.remove('sa-composer-busy'); + const send = $('sa_btn_send'); + if (send) { + send.disabled = false; + } + const input = $('sa_input'); + if (input) { + input.classList.remove('sa-input-busy'); + } + const bar = $('sa_livebar'); + if (bar) { + bar.hidden = true; + } + const dot = $('sa_live_dot'); + if (dot) { + dot.hidden = true; + } + const status = $('sa_status'); + if (status) { + status.classList.remove('sa-status-busy'); + } + if (finalStatus != null) { + const suffix = elapsed >= 1000 ? ` · ${fmtElapsed(elapsed)}` : ''; + setStatus(finalStatus + suffix); + } + } + function setStatus(text) { const el = $('sa_status'); if (el) { @@ -773,7 +912,7 @@ const start = Date.now(); const timer = setInterval(() => { const src = findCurrentGenerateSrc(); - if (src && src !== prevSrc) { + if (src && src !== prevSrc && !looksLikeModelPreview(src)) { clearInterval(timer); resolve(src); } else if (Date.now() - start > timeoutMs) { @@ -790,6 +929,7 @@ } const prev = findCurrentGenerateSrc(); setStatus('Generating…'); + startBusyUi('generating'); state.generating = true; setInterruptVisible(true); const ok = triggerGenerate(); @@ -802,11 +942,16 @@ const src = await waitForNewImage(prev); state.generating = false; setInterruptVisible(state.busy); + if (!state.busy) { + stopBusyUi(src ? 'Generate done' : 'Generate finished (no new image detected)'); + } if (src) { setImageFromSrc(src, { note: 'Result → vision' }); return src; } - setStatus('Generate finished (no new image detected)'); + if (state.busy) { + setStatus('Generate finished (no new image detected)'); + } return null; } @@ -832,6 +977,7 @@ if (!box) { return null; } + hideChatEmpty(); const div = document.createElement('div'); div.className = `sa-msg ${role}`; const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null }; @@ -883,9 +1029,10 @@ if (!box) { return null; } + hideChatEmpty(); const div = document.createElement('div'); - div.className = 'sa-msg assistant sa-streaming'; - div.textContent = ''; + div.className = 'sa-msg assistant sa-streaming sa-typing'; + div.innerHTML = 'Waiting for the model…'; box.appendChild(div); box.scrollTop = box.scrollHeight; state.streamEl = div; @@ -897,6 +1044,14 @@ beginStreamMessage(); } if (state.streamEl) { + if (state.streamEl.classList.contains('sa-typing')) { + state.streamEl.classList.remove('sa-typing'); + state.streamEl.textContent = ''; + } + state.gotDelta = true; + if (state.busyPhase !== 'refining') { + setBusyPhase('streaming'); + } state.streamEl.textContent += delta; const box = $('sa_messages'); if (box) { @@ -912,7 +1067,7 @@ appendMessage('assistant', fullReply, null, civitaiResults); return; } - el.classList.remove('sa-streaming'); + el.classList.remove('sa-streaming', 'sa-typing'); const { prose, patch } = extractPatch(fullReply); el.textContent = prose || fullReply || ''; if (patch) { @@ -1063,33 +1218,57 @@ } } - function findCurrentGenerateSrc() { + function wantsAutoVision() { + return !!$('sa_auto_vision')?.checked; + } + + function looksLikeModelPreview(src) { + const s = String(src || '').toLowerCase(); + if (!s) { + return false; + } + return s.includes('.preview.') + || s.includes('placeholder') + || /\/models\//i.test(s); + } + + function findCurrentGenerateSrc({ allowPreview = false } = {}) { + let src = null; try { const cur = document.getElementById('current_image_img') || document.querySelector('#current_image img') || document.querySelector('.current-image img') || document.querySelector('#current_image_batch img'); if (cur) { - return cur.dataset?.src || cur.src || null; + src = cur.dataset?.src || cur.src || null; } } catch (e) { /* ignore */ } - try { - if (typeof currentMetadataMap !== 'undefined' && currentMetadataMap && currentMetadataMap.image) { - return currentMetadataMap.image; - } - } catch (e) { /* ignore */ } - return null; + if (!src) { + try { + if (typeof currentMetadataMap !== 'undefined' && currentMetadataMap && currentMetadataMap.image) { + src = currentMetadataMap.image; + } + } catch (e) { /* ignore */ } + } + if (!src) { + return null; + } + if (!allowPreview && looksLikeModelPreview(src)) { + return null; + } + return src; } function refreshImagePreview({ onlyIfEmpty = false } = {}) { + if (!wantsAutoVision()) { + return; + } if (onlyIfEmpty && state.lastImageDataUrl) { return; } const src = findCurrentGenerateSrc(); if (src) { setImageFromSrc(src); - } else if (!state.lastImageDataUrl) { - clearVisionImage({ silent: true }); } } @@ -1137,24 +1316,55 @@ return false; } - async function imageToBase64ForOllama(src) { + async function imageToBase64ForOllama(src, maxEdge = 1024) { if (!src) { return null; } + const dataUrl = await srcToDataUrl(src); + if (!dataUrl) { + return null; + } + try { + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.onload = () => resolve(el); + el.onerror = reject; + el.src = dataUrl; + }); + const w = img.naturalWidth || img.width || 0; + const h = img.naturalHeight || img.height || 0; + const edge = Math.max(w, h); + const canvas = document.createElement('canvas'); + if (!edge || edge <= maxEdge) { + canvas.width = Math.max(w, 1); + canvas.height = Math.max(h, 1); + canvas.getContext('2d').drawImage(img, 0, 0); + } else { + const scale = maxEdge / edge; + canvas.width = Math.max(1, Math.round(w * scale)); + canvas.height = Math.max(1, Math.round(h * scale)); + canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + } + const jpeg = canvas.toDataURL('image/jpeg', 0.85); + const i = jpeg.indexOf(','); + return i >= 0 ? jpeg.slice(i + 1) : null; + } catch (e) { + console.warn('Assistent: vision resize failed', e); + const i = dataUrl.indexOf(','); + return i >= 0 ? dataUrl.slice(i + 1) : null; + } + } + + async function srcToDataUrl(src) { if (src.startsWith('data:')) { - const i = src.indexOf(','); - return i >= 0 ? src.slice(i + 1) : null; + return src; } try { const resp = await fetch(src); const blob = await resp.blob(); return await new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => { - const data = String(reader.result || ''); - const i = data.indexOf(','); - resolve(i >= 0 ? data.slice(i + 1) : null); - }; + reader.onload = () => resolve(String(reader.result || '')); reader.onerror = reject; reader.readAsDataURL(blob); }); @@ -1355,12 +1565,13 @@ return; } - if (!state.lastImageDataUrl) { + if (!state.lastImageDataUrl && wantsAutoVision()) { refreshImagePreview(); } - const attach = ($('sa_attach_vision')?.checked || $('sa_auto_vision')?.checked) && state.lastImageDataUrl; + const attach = ($('sa_attach_vision')?.checked || wantsAutoVision()) && state.lastImageDataUrl; let images = null; if (attach) { + startBusyUi('encoding'); setStatus('Encoding image…'); const b64 = await imageToBase64ForOllama(state.lastImageDataUrl); if (b64) { @@ -1385,7 +1596,7 @@ state.busy = true; setInterruptVisible(true); - setStatus('Thinking…'); + startBusyUi('thinking'); saveSettings(); const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434'; @@ -1410,16 +1621,16 @@ state.busy = false; setInterruptVisible(state.generating); state.history.push({ role: 'assistant', content: reply }); - setStatus('Done'); + stopBusyUi('Done'); await handleReplySideEffects(reply, civitaiResults, opts); }; const finishErr = (msg) => { state.busy = false; setInterruptVisible(state.generating); - setStatus(msg); + stopBusyUi(msg); if (state.streamEl) { - state.streamEl.classList.remove('sa-streaming'); + state.streamEl.classList.remove('sa-streaming', 'sa-typing'); state.streamEl.classList.add('error'); state.streamEl.textContent = msg; state.streamEl = null; @@ -1434,20 +1645,28 @@ 'AssistentChatWS', payload, (data) => { + if (data.phase === 'waiting_ollama') { + setBusyPhase('loading'); + const label = state.streamEl?.querySelector('.sa-typing-label'); + if (label) { + label.textContent = `Loading ${modelShort(model)} into GPU…`; + } + return; + } if (data.error) { finishErr(String(data.error)); return; } if (data.clear_stream) { if (state.streamEl) { - state.streamEl.textContent = ''; + state.streamEl.classList.add('sa-typing'); + state.streamEl.innerHTML = 'Refining…'; } - setStatus(data.notice || 'Refining…'); + setBusyPhase('refining'); return; } if (data.delta) { appendStreamDelta(data.delta); - setStatus('Thinking…'); return; } if (data.done || data.reply != null) { @@ -1673,7 +1892,9 @@ window.__swarmAssistentWired = true; loadSettings(); updateGate(); - refreshImagePreview(); + if (wantsAutoVision()) { + refreshImagePreview(); + } refreshModels(); refreshInventory(); wireDropZone(); @@ -1692,7 +1913,7 @@ }); $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory()); $('sa_btn_use_current')?.addEventListener('click', () => { - const src = findCurrentGenerateSrc(); + const src = findCurrentGenerateSrc({ allowPreview: true }); if (src) { setImageFromSrc(src, { note: 'Using current Generate image' }); } else { @@ -1733,10 +1954,14 @@ $('sa_btn_send')?.addEventListener('click', () => sendChat()); $('sa_btn_interrupt')?.addEventListener('click', () => { doInterruptNow(); - setStatus('Interrupted'); state.busy = false; state.generating = false; setInterruptVisible(false); + if (state.streamEl?.classList.contains('sa-typing')) { + state.streamEl.remove(); + state.streamEl = null; + } + stopBusyUi('Interrupted'); }); $('sa_btn_clear')?.addEventListener('click', () => { state.history = []; @@ -1744,7 +1969,13 @@ const box = $('sa_messages'); if (box) { box.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'sa-chat-empty'; + empty.id = 'sa_chat_empty'; + empty.innerHTML = '
Collaborative Krea 2
Write a prompt, drop a reference on the left, or send the current Generate image.
'; + box.appendChild(empty); } + stopBusyUi(''); setStatus(''); }); $('sa_input')?.addEventListener('keydown', (e) => { diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 4efa5e3..f296d99 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -43,6 +43,8 @@ public class SwarmAssistentExtension : Extension const int MaxCivitaiHops = 2; const int MaxLorasInInventory = 120; const int MaxWildcardsInInventory = 80; + /// Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that. + const int DefaultNumCtx = 16384; static readonly Regex JsonFenceRe = new(@"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -53,7 +55,7 @@ public class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant via Ollama: chat, vision, img2img/inpaint, Generate loop, Civitai Confirm."; License = "MIT"; - Version = "0.3.1"; + Version = "0.3.4"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"]; } @@ -581,6 +583,10 @@ public class SwarmAssistentExtension : Extension ["model"] = modelName, ["stream"] = stream, ["messages"] = new JArray(ollamaMessages), + ["options"] = new JObject + { + ["num_ctx"] = DefaultNumCtx, + }, }; 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 }; @@ -718,6 +724,14 @@ public class SwarmAssistentExtension : Extension string packName = (pack ?? "write_prompt").Trim(); try { + if (ws.State == WebSocketState.Open) + { + await ws.SendJson(new JObject + { + ["phase"] = "waiting_ollama", + ["notice"] = "Loading model into GPU…", + }, API.WebsocketTimeout); + } async Task OnDelta(string delta) { if (ws.State == WebSocketState.Open) diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index dc256af..4c2ec72 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -25,7 +25,10 @@
-
Assistent
+
+ Assistent + +
Auto-critique after generate
-
-
+
+
+
Collaborative Krea 2
+
Write a prompt, drop a reference on the left, or send the current Generate image.
+
+
+ +