@@ -210,3 +213,7 @@ } - return names.slice(0, 5); + // before v-tree-any-model-v0: return names.slice(0, 5); + // v-tree-any-model-v0: was names.slice(0, 5); every downloaded model is a choice. + var seenName = {}; + names = names.filter(function (n) { if (seenName[n]) return false; seenName[n] = true; return true; }); + return names.slice(0, MODEL_CAP); } @@ -275,3 +282,14 @@ return bridgeFirst.then(function (bridged) { - if (bridged) return bridged; + // v-tree-any-model-v0: the Bridge speaks for Ollama; other doors still get asked + if (bridged) { + var others = DOORS.filter(function (d) { return d.id !== 'ollama'; }); + return Promise.all(others.map(function (d) { + return fetchDoor(d.url, 2500).then(function (r) { r.id = d.id; r.name = d.name; return r; }); + })).then(function (results) { + results.forEach(function (r) { if (r.ok) bridged.foundList.push(r); }); + bridged.tried = 1 + results.length; + bridged.results = results; + return bridged; + }); + } var jobs = DOORS.map(function (d) { @@ -310,2 +328,11 @@ } + // v-tree-any-model-v0: 127.0.0.1 only. Nothing leaves this computer. + // A bare port ("1234" or ":1234") means this computer's own door. + if (/^:?\d{1,5}$/.test(text)) text = '127.0.0.1:' + text.replace(/^:/, ''); + var hostPart = text.replace(/^https?:\/\//i, '').split(/[\/?#]/)[0]; + var hostOnly = hostPart.replace(/:\d{1,5}$/, '').toLowerCase(); + if (hostOnly === 'localhost') text = text.replace(/localhost/i, '127.0.0.1'); + if (!(hostOnly === '127.0.0.1' || hostOnly === 'localhost' || hostOnly === '[::1]')) { + return Promise.resolve({ ok: false, refused: true, pasted: String(raw || '') }); + } if (!/^https?:\/\//i.test(text)) { @@ -392,3 +419,5 @@ }); - return out.slice(0, 12); + // before v-tree-any-model-v0: return out.slice(0, 12); + // v-tree-any-model-v0: every remembered model can take a chair + return out.slice(0, MODEL_CAP); } @@ -473,2 +502,17 @@ + // v-tree-any-model-v0: find a prior model at any found door (the Bridge and 11434 are the same Ollama) + function doorFamily(url) { + var u = String(url || ''); + if (/\/v1\/models$/i.test(u)) return 'openai:' + u.replace(/\/v1\/models$/i, ''); + return 'ollama'; + } + function findModelAnywhere(minds, name) { + if (!name) return null; + var i; + for (i = 0; i < minds.length; i++) { + if (modelOnList(minds[i].models || [], name)) return minds[i]; + } + return null; + } + function entryFromFoundList(foundList, fallbackName, fallbackUrl, prior) { @@ -520,3 +564,31 @@ var modelNote = ''; - if (priorChosen && !modelOnList(models, priorChosen)) { + // v-tree-any-model-v0: a chosen model that is still at a found door is never called gone. + var anyNote = ''; + var anyKept = false; + var priorDoor = priorPrimaryUrl(prior) || (prior && prior.url ? String(prior.url) : ''); + if (priorChosen && modelOnList(models, priorChosen) && model !== priorChosen) { + // the chosen model is at this door, but under a new address (Bridge <-> 11434) + primary.model = priorChosen; + model = priorChosen; + anyKept = true; + anyNote = 'Still speaking with ' + priorChosen + ', now through ' + (primary.name || 'a mind at home') + '.'; + } else if (priorChosen && !modelOnList(models, priorChosen)) { + var elsewhere = findModelAnywhere(minds, priorChosen); + if (elsewhere && doorFamily(elsewhere.url) === doorFamily(priorDoor)) { + for (i = 0; i < minds.length; i++) minds[i].primary = minds[i] === elsewhere; + elsewhere.model = priorChosen; + primary = elsewhere; + models = primary.models || []; + model = priorChosen; + anyKept = true; + anyNote = 'Still speaking with ' + priorChosen + ', now through ' + (elsewhere.name || 'a mind at home') + '.'; + } else if (elsewhere) { + anyKept = true; + anyNote = 'The chosen model ' + priorChosen + ' is at ' + (elsewhere.name || 'another door') + + '. Tap it to speak with it again.'; + } + } + if (anyKept) { + modelNote = anyNote; + } else if (priorChosen && !modelOnList(models, priorChosen)) { modelNote = speakModelGone(priorChosen, model); @@ -596,2 +668,36 @@ + // v-tree-any-model-v0: Connect and paste add to the sky; they never wipe it. + // found = { name, url, models: [names], model } + function mergeFound(found) { + if (!found || !found.url) return null; + var url = String(found.url); + var models = (found.models || []).map(function (m) { + return String((m && (m.name || m.model || m.id)) || m); + }).filter(Boolean).slice(0, MODEL_CAP); + var model = String(found.model || ''); + if (model && !modelOnList(models, model)) models.unshift(model); + var entry = getRemembered() || { name: '', url: '', model: '', models: [], minds: [], + roster: [], gatheringBinds: {}, speakingChair: '' }; + normalizeEntry(entry); + var minds = mindsFromEntry(entry); + var target = null; + var i; + for (i = 0; i < minds.length; i++) { + if (String(minds[i].url || '') === url) { target = minds[i]; break; } + } + if (!target) { + target = { name: String(found.name || 'a mind at home'), url: url, models: [], model: '', primary: false }; + minds.push(target); + } + if (found.name) target.name = String(found.name); + target.models = models.length ? models : (target.models || []); + target.model = model || target.model || (target.models && target.models[0]) || ''; + entry.minds = minds.slice(0, 7); + if (entry.minds.indexOf(target) < 0) entry.minds[entry.minds.length - 1] = target; + syncEntryFromMind(entry, target); + entry.foundAt = new Date().toISOString(); + remember(entry); + return entry; + } + function bindRadioKeys(cluster, radios, onPick) { @@ -668,3 +774,11 @@ } - var models = (m.models || []).slice(0, 5); + // before v-tree-any-model-v0: var models = (m.models || []).slice(0, 5); + // v-tree-any-model-v0: all models; fold after SKY_FOLD, chosen always visible + var allModels = (m.models || []).slice(0, MODEL_CAP); + var expanded = host.getAttribute('data-sky-open') === '1'; + var models = allModels; + if (!expanded && allModels.length > SKY_FOLD) { + models = allModels.slice(0, SKY_FOLD); + if (isPrimary && chosen && allModels.indexOf(chosen) >= SKY_FOLD) models.push(chosen); + } if (models.length) { @@ -703,2 +817,12 @@ star.appendChild(cluster); + if (!expanded && allModels.length > models.length) { + var more = el('button', 'settings-star-more', 'Show all ' + allModels.length + ' models'); + more.type = 'button'; + more.setAttribute('data-sky-more', '1'); + more.addEventListener('click', function () { + host.setAttribute('data-sky-open', '1'); + paintConstellation(host, mindsFromEntry(getRemembered())); + }); + star.appendChild(more); + } } @@ -827,2 +951,6 @@ )); + // v-tree-any-model-v0: one honest line for LM Studio on a secure page + root.appendChild(el('p', 'settings-muted settings-honesty', + 'LM Studio: turn on CORS in its server settings so this garden can hear it.' + ));