547 lines
20 KiB
JavaScript
547 lines
20 KiB
JavaScript
/**
|
|
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
|
|
*/
|
|
(function () {
|
|
const LS_BASE = 'swarm_assistent_base_url';
|
|
const LS_MODEL = 'swarm_assistent_model';
|
|
const LS_PACK = 'swarm_assistent_pack';
|
|
const LS_AUTO_VISION = 'swarm_assistent_auto_vision';
|
|
|
|
const state = {
|
|
history: [],
|
|
packsLoaded: false,
|
|
busy: false,
|
|
lastImageDataUrl: null,
|
|
};
|
|
|
|
function $(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function setStatus(text) {
|
|
const el = $('sa_status');
|
|
if (el) {
|
|
el.textContent = text || '';
|
|
}
|
|
}
|
|
|
|
function isKreaSelected() {
|
|
try {
|
|
const model = getCurrentModel && getCurrentModel();
|
|
if (!model) {
|
|
return false;
|
|
}
|
|
const arch = `${model.architecture || ''} ${model.title || ''} ${model.name || ''} ${model.class || ''}`;
|
|
return /krea\s*2|krea2/i.test(arch) || /krea/i.test(arch);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function updateGate() {
|
|
const ok = isKreaSelected();
|
|
const gate = $('sa_gate');
|
|
const layout = $('sa_layout');
|
|
if (gate) {
|
|
gate.hidden = ok;
|
|
}
|
|
if (layout) {
|
|
layout.classList.toggle('sa-disabled', !ok);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
function val(id) {
|
|
const el = document.getElementById(id);
|
|
return el ? el.value : '';
|
|
}
|
|
|
|
function setVal(id, value) {
|
|
const el = document.getElementById(id);
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.value = value;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
function collectLiveContext() {
|
|
const ctx = {
|
|
architecture_ok: isKreaSelected(),
|
|
checkpoint: null,
|
|
prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
|
|
negative: val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '',
|
|
width: parseInt(val('input_width') || '0', 10) || null,
|
|
height: parseInt(val('input_height') || '0', 10) || null,
|
|
steps: parseInt(val('input_steps') || '0', 10) || null,
|
|
cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
|
|
sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
|
|
seed: val('input_seed') || null,
|
|
selected_loras: [],
|
|
available_loras: [],
|
|
has_vision_image: !!state.lastImageDataUrl,
|
|
};
|
|
|
|
try {
|
|
const model = getCurrentModel && getCurrentModel();
|
|
if (model) {
|
|
ctx.checkpoint = {
|
|
name: model.name || model.title || null,
|
|
architecture: model.architecture || model.class || null,
|
|
title: model.title || null,
|
|
};
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
try {
|
|
if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
|
|
ctx.selected_loras = loraHelper.selected.map((l) => ({
|
|
name: l.name || l,
|
|
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
|
|
}));
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
try {
|
|
const models = (typeof allModels !== 'undefined' && allModels) || (typeof model_list !== 'undefined' && model_list) || [];
|
|
const list = Array.isArray(models) ? models : Object.values(models || {});
|
|
for (const m of list) {
|
|
if (!m) {
|
|
continue;
|
|
}
|
|
const arch = `${m.architecture || ''} ${m.class || ''} ${m.name || ''} ${m.title || ''}`;
|
|
const isLora = /lora/i.test(m.category || m.type || '') || (m.name && String(m.name).toLowerCase().includes('lora'));
|
|
const folder = `${m.folder || m.path || ''}`;
|
|
const inLoraFolder = /lora/i.test(folder);
|
|
if (!(isLora || inLoraFolder)) {
|
|
// Still include if metadata says lora
|
|
if (!/lora/i.test(JSON.stringify(m).slice(0, 200))) {
|
|
continue;
|
|
}
|
|
}
|
|
// Prefer Krea-tagged or unknown; skip obvious FLUX/SDXL-only names when tagged
|
|
ctx.available_loras.push({
|
|
name: m.name || m.title,
|
|
title: m.title || m.name,
|
|
trigger_phrase: m.trigger_phrase || m.trigger || (m.metadata && (m.metadata.trigger_phrase || m.metadata.trigger)) || null,
|
|
architecture: m.architecture || null,
|
|
});
|
|
}
|
|
// Cap list size for context window
|
|
if (ctx.available_loras.length > 80) {
|
|
ctx.available_loras = ctx.available_loras.slice(0, 80);
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
// Fallback: parse multi-select input_loras options as available names
|
|
try {
|
|
const sel = document.getElementById('input_loras');
|
|
if (sel && sel.options && ctx.available_loras.length === 0) {
|
|
for (const opt of sel.options) {
|
|
if (opt.value) {
|
|
ctx.available_loras.push({ name: opt.value, title: opt.text || opt.value, trigger_phrase: null });
|
|
}
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
return ctx;
|
|
}
|
|
|
|
function extractPatch(text) {
|
|
if (!text) {
|
|
return { prose: text || '', patch: null };
|
|
}
|
|
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
let match;
|
|
let lastPatch = null;
|
|
let prose = text;
|
|
while ((match = re.exec(text)) !== null) {
|
|
const raw = match[1].trim();
|
|
try {
|
|
const obj = JSON.parse(raw);
|
|
if (obj && typeof obj === 'object' && (obj.prompt != null || obj.loras || obj.width || obj.height || obj.steps || obj.cfg)) {
|
|
lastPatch = obj;
|
|
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
|
|
}
|
|
} catch (e) { /* not json */ }
|
|
}
|
|
return { prose, patch: lastPatch };
|
|
}
|
|
|
|
function applyPatch(patch, which) {
|
|
if (!patch) {
|
|
return;
|
|
}
|
|
const doPrompt = !which || which === 'all' || which === 'prompt';
|
|
const doLoras = !which || which === 'all' || which === 'loras';
|
|
const doSize = !which || which === 'all' || which === 'size';
|
|
|
|
if (doPrompt && patch.prompt != null) {
|
|
const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
|
|
if (box) {
|
|
box.value = patch.prompt;
|
|
box.dispatchEvent(new Event('input', { bubbles: true }));
|
|
box.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
if (patch.negative != null) {
|
|
setVal('input_negativeprompt', patch.negative);
|
|
}
|
|
// Ensure triggers present
|
|
if (Array.isArray(patch.loras)) {
|
|
for (const l of patch.loras) {
|
|
const triggers = l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []);
|
|
for (const t of triggers) {
|
|
if (t && box && box.value && !box.value.includes(t)) {
|
|
box.value = `${box.value.trim()}, ${t}`;
|
|
box.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (doLoras && Array.isArray(patch.loras) && typeof loraHelper !== 'undefined' && loraHelper) {
|
|
try {
|
|
if (typeof loraHelper.clearLoras === 'function') {
|
|
loraHelper.clearLoras();
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
for (const l of patch.loras) {
|
|
const name = l.name;
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
try {
|
|
if (typeof loraHelper.selectLora === 'function') {
|
|
loraHelper.selectLora(name);
|
|
}
|
|
if (loraHelper.loraWeightPref && l.weight != null) {
|
|
loraHelper.loraWeightPref[name] = l.weight;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Assistent: selectLora failed', name, e);
|
|
}
|
|
}
|
|
try {
|
|
if (typeof loraHelper.rebuildUI === 'function') {
|
|
loraHelper.rebuildUI();
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
if (doSize) {
|
|
if (patch.width != null) {
|
|
setVal('input_width', String(patch.width));
|
|
}
|
|
if (patch.height != null) {
|
|
setVal('input_height', String(patch.height));
|
|
}
|
|
if (patch.steps != null) {
|
|
setVal('input_steps', String(patch.steps));
|
|
}
|
|
if (patch.cfg != null) {
|
|
if (document.getElementById('input_cfgscale')) {
|
|
setVal('input_cfgscale', String(patch.cfg));
|
|
} else {
|
|
setVal('input_cfg', String(patch.cfg));
|
|
}
|
|
}
|
|
}
|
|
setStatus('Applied patch');
|
|
}
|
|
|
|
function appendMessage(role, text, patch) {
|
|
const box = $('sa_messages');
|
|
if (!box) {
|
|
return;
|
|
}
|
|
const div = document.createElement('div');
|
|
div.className = `sa-msg ${role}`;
|
|
const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null };
|
|
const finalPatch = patch || extracted;
|
|
div.textContent = prose || text || '';
|
|
if (finalPatch) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'sa-patch';
|
|
const pre = document.createElement('pre');
|
|
pre.textContent = JSON.stringify(finalPatch, null, 2);
|
|
wrap.appendChild(pre);
|
|
const actions = document.createElement('div');
|
|
actions.className = 'sa-patch-actions';
|
|
for (const [label, which] of [
|
|
['Apply all', 'all'],
|
|
['Prompt', 'prompt'],
|
|
['LoRAs', 'loras'],
|
|
['Size', 'size'],
|
|
]) {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'basic-button';
|
|
btn.textContent = label;
|
|
btn.addEventListener('click', () => applyPatch(finalPatch, which));
|
|
actions.appendChild(btn);
|
|
}
|
|
wrap.appendChild(actions);
|
|
div.appendChild(wrap);
|
|
}
|
|
box.appendChild(div);
|
|
box.scrollTop = box.scrollHeight;
|
|
}
|
|
|
|
function refreshImagePreview() {
|
|
let src = null;
|
|
try {
|
|
const cur = document.getElementById('current_image_img') || document.querySelector('#current_image img') || document.querySelector('.current-image img');
|
|
if (cur && cur.src) {
|
|
src = cur.src;
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
try {
|
|
if (!src && typeof currentMetadataMap !== 'undefined' && currentMetadataMap && currentMetadataMap.image) {
|
|
src = currentMetadataMap.image;
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
const img = $('sa_image_preview');
|
|
const empty = $('sa_image_empty');
|
|
if (src) {
|
|
state.lastImageDataUrl = src;
|
|
if (img) {
|
|
img.src = src;
|
|
img.hidden = false;
|
|
}
|
|
if (empty) {
|
|
empty.hidden = true;
|
|
}
|
|
} else {
|
|
state.lastImageDataUrl = null;
|
|
if (img) {
|
|
img.hidden = true;
|
|
}
|
|
if (empty) {
|
|
empty.hidden = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function imageToBase64ForOllama(src) {
|
|
if (!src) {
|
|
return null;
|
|
}
|
|
// Already data URL
|
|
if (src.startsWith('data:')) {
|
|
const i = src.indexOf(',');
|
|
return i >= 0 ? src.slice(i + 1) : null;
|
|
}
|
|
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.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
} catch (e) {
|
|
console.warn('Assistent: vision fetch failed', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadSettings() {
|
|
const base = localStorage.getItem(LS_BASE);
|
|
const model = localStorage.getItem(LS_MODEL);
|
|
const pack = localStorage.getItem(LS_PACK);
|
|
const auto = localStorage.getItem(LS_AUTO_VISION);
|
|
if (base && $('sa_base_url')) {
|
|
$('sa_base_url').value = base;
|
|
}
|
|
if (pack && $('sa_pack')) {
|
|
$('sa_pack').value = pack;
|
|
}
|
|
if (auto != null && $('sa_auto_vision')) {
|
|
$('sa_auto_vision').checked = auto === '1';
|
|
}
|
|
if (model) {
|
|
state.preferredModel = model;
|
|
}
|
|
}
|
|
|
|
function saveSettings() {
|
|
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
|
|
localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
|
|
localStorage.setItem(LS_PACK, $('sa_pack')?.value || 'write_prompt');
|
|
localStorage.setItem(LS_AUTO_VISION, $('sa_auto_vision')?.checked ? '1' : '0');
|
|
}
|
|
|
|
function refreshModels() {
|
|
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
|
|
setStatus('Loading models…');
|
|
genericRequest('AssistentListModels', { baseUrl }, (data) => {
|
|
if (data.error) {
|
|
setStatus(data.error);
|
|
appendMessage('error', data.error);
|
|
return;
|
|
}
|
|
const sel = $('sa_model');
|
|
if (!sel) {
|
|
return;
|
|
}
|
|
sel.innerHTML = '';
|
|
const models = data.models || [];
|
|
for (const name of models) {
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const opt = document.createElement('option');
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
sel.appendChild(opt);
|
|
}
|
|
const prefer = state.preferredModel || localStorage.getItem(LS_MODEL);
|
|
if (prefer && models.includes(prefer)) {
|
|
sel.value = prefer;
|
|
}
|
|
setStatus(models.length ? `${models.length} models` : 'No Ollama models');
|
|
saveSettings();
|
|
});
|
|
}
|
|
|
|
async function sendChat() {
|
|
if (state.busy) {
|
|
return;
|
|
}
|
|
if (!updateGate()) {
|
|
setStatus('Select a Krea 2 model');
|
|
return;
|
|
}
|
|
const text = ($('sa_input')?.value || '').trim();
|
|
if (!text) {
|
|
return;
|
|
}
|
|
const pack = $('sa_pack')?.value || 'write_prompt';
|
|
const model = $('sa_model')?.value;
|
|
if (!model) {
|
|
setStatus('Pick an Ollama model in ⚙');
|
|
refreshModels();
|
|
return;
|
|
}
|
|
|
|
refreshImagePreview();
|
|
const attach = ($('sa_attach_vision')?.checked || $('sa_auto_vision')?.checked) && state.lastImageDataUrl;
|
|
let images = null;
|
|
if (attach) {
|
|
setStatus('Encoding image…');
|
|
const b64 = await imageToBase64ForOllama(state.lastImageDataUrl);
|
|
if (b64) {
|
|
images = [b64];
|
|
}
|
|
}
|
|
|
|
const userMsg = { role: 'user', content: text };
|
|
if (images) {
|
|
userMsg.images = images;
|
|
}
|
|
state.history.push({ role: 'user', content: text });
|
|
appendMessage('user', text);
|
|
$('sa_input').value = '';
|
|
|
|
const context = collectLiveContext();
|
|
context.has_vision_image = !!images;
|
|
// Strip huge fields from history replay — only send recent turns without images in history payload
|
|
const messages = state.history.slice(-12).map((m) => ({ role: m.role, content: m.content }));
|
|
// Last user message may include images
|
|
if (images && messages.length) {
|
|
messages[messages.length - 1].images = images;
|
|
}
|
|
|
|
state.busy = true;
|
|
setStatus('Thinking…');
|
|
saveSettings();
|
|
|
|
const payload = {
|
|
baseUrl: $('sa_base_url')?.value || 'http://127.0.0.1:11434',
|
|
model,
|
|
pack,
|
|
includeBase: true,
|
|
raw: {
|
|
messages,
|
|
context_json: JSON.stringify(context),
|
|
pack,
|
|
base_url: $('sa_base_url')?.value || 'http://127.0.0.1:11434',
|
|
model,
|
|
},
|
|
};
|
|
|
|
genericRequest('AssistentChat', payload, (data) => {
|
|
state.busy = false;
|
|
if (data.error) {
|
|
setStatus(data.error);
|
|
appendMessage('error', data.error);
|
|
return;
|
|
}
|
|
const reply = data.reply || '';
|
|
state.history.push({ role: 'assistant', content: reply });
|
|
appendMessage('assistant', reply);
|
|
setStatus('Done');
|
|
});
|
|
}
|
|
|
|
function wire() {
|
|
if (!$('swarm_assistent_root')) {
|
|
return;
|
|
}
|
|
loadSettings();
|
|
updateGate();
|
|
refreshImagePreview();
|
|
refreshModels();
|
|
|
|
$('sa_btn_settings')?.addEventListener('click', () => {
|
|
const s = $('sa_settings');
|
|
if (s) {
|
|
s.hidden = !s.hidden;
|
|
}
|
|
});
|
|
$('sa_btn_refresh_models')?.addEventListener('click', () => {
|
|
saveSettings();
|
|
refreshModels();
|
|
});
|
|
$('sa_btn_refresh_image')?.addEventListener('click', refreshImagePreview);
|
|
$('sa_btn_send')?.addEventListener('click', () => sendChat());
|
|
$('sa_btn_clear')?.addEventListener('click', () => {
|
|
state.history = [];
|
|
const box = $('sa_messages');
|
|
if (box) {
|
|
box.innerHTML = '';
|
|
}
|
|
setStatus('');
|
|
});
|
|
$('sa_input')?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
sendChat();
|
|
}
|
|
});
|
|
$('sa_base_url')?.addEventListener('change', saveSettings);
|
|
$('sa_model')?.addEventListener('change', saveSettings);
|
|
$('sa_pack')?.addEventListener('change', saveSettings);
|
|
$('sa_auto_vision')?.addEventListener('change', saveSettings);
|
|
|
|
// Re-check Krea gate when user may swap models
|
|
setInterval(updateGate, 2000);
|
|
setInterval(refreshImagePreview, 4000);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', wire);
|
|
} else {
|
|
wire();
|
|
}
|
|
})();
|