Add OpenRouter chat provider; fix Windows load errors (0.17.0).

- OpenRouter as an alternative chat provider: server-side API key
  (settings.json or OPENROUTER_API_KEY), model list with vision marks and
  filter, SSE streaming, images as data URLs. Memory embeddings stay on Ollama;
  park/warm LLM are no-ops for the remote provider.
- AssistentSaveKnowledgeAttach: JArray param is not supported by SwarmUI API
  reflection and aborted registration of every later API call; use string[].
- SQLite bootstrap: preload native e_sqlite3 for the current OS/arch
  (win/osx/linux) and resolve DllImport to it.
- settings.json: shared-read + atomic write with retry to avoid Windows
  sharing violations.
- csproj: fix MSB4012 in the native SQLite copy target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 06:18:47 +03:00
co-authored by Claude Opus 5
parent f2b92e96ca
commit d67f5e396e
13 changed files with 740 additions and 56 deletions
+139 -16
View File
@@ -1733,6 +1733,12 @@
waitImageTimer: null, waitImageTimer: null,
lastImageDataUrl: null, lastImageDataUrl: null,
preferredModel: null, preferredModel: null,
/** Chat provider mirrored from server settings: 'ollama' | 'openrouter'. */
provider: "ollama",
chatModels: [],
chatModelsPreferred: "",
visionModels: [],
openrouterModelSaved: "",
inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false }, inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
inventoryFetchedAt: 0, inventoryFetchedAt: 0,
streamEl: null, streamEl: null,
@@ -3479,7 +3485,7 @@ ${patch.prompt}`;
} }
const model = $("sa_model")?.value; const model = $("sa_model")?.value;
if (!model) { if (!model) {
setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C Ollama \u0432 \u2699"); setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C \u0447\u0430\u0442\u0430 \u0432 \u2699");
return false; return false;
} }
const { prompt, foldCount } = buildCompressUserPrompt(); const { prompt, foldCount } = buildCompressUserPrompt();
@@ -3642,7 +3648,7 @@ ${patch.prompt}`;
} }
const model = $("sa_model")?.value; const model = $("sa_model")?.value;
if (!model) { if (!model) {
setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C Ollama \u0432 \u2699"); setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C \u0447\u0430\u0442\u0430 \u0432 \u2699");
return; return;
} }
state.busy = true; state.busy = true;
@@ -7459,6 +7465,7 @@ ${patch.prompt}`;
localStorage.setItem(LS_AUTO_CRITIQUE, $("sa_auto_critique")?.checked ? "1" : "0"); localStorage.setItem(LS_AUTO_CRITIQUE, $("sa_auto_critique")?.checked ? "1" : "0");
localStorage.setItem(LS_AUTO_DOWNLOAD, $("sa_auto_download")?.checked ? "1" : "0"); localStorage.setItem(LS_AUTO_DOWNLOAD, $("sa_auto_download")?.checked ? "1" : "0");
localStorage.setItem(LS_PARK_LLM, $("sa_park_llm")?.checked ? "1" : "0"); localStorage.setItem(LS_PARK_LLM, $("sa_park_llm")?.checked ? "1" : "0");
persistOpenRouterModel();
persistServerSettings(); persistServerSettings();
saveUiStateToDisk(); saveUiStateToDisk();
} }
@@ -8023,22 +8030,44 @@ ${data.ui.help_extra}`.trim();
if (!list.length) { if (!list.length) {
return ""; return "";
} }
const preferred = apiPreferred && list.includes(apiPreferred) ? apiPreferred : pickSeniorChatModel(list);
const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || ""; const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || "";
if (isOpenRouter()) {
if (apiPreferred && list.includes(apiPreferred)) {
return apiPreferred;
}
return ls && list.includes(ls) ? ls : list[0];
}
const preferred = apiPreferred && list.includes(apiPreferred) ? apiPreferred : pickSeniorChatModel(list);
if (ls && list.includes(ls)) { if (ls && list.includes(ls)) {
return ls; return ls;
} }
return preferred || list[0]; return preferred || list[0];
} }
function isOpenRouter() {
return state.provider === "openrouter";
}
function providerLabel() {
return isOpenRouter() ? "OpenRouter" : "Ollama";
}
function renderChatModelOptions() {
const current = $("sa_model")?.value || "";
const filter = isOpenRouter() ? String($("sa_openrouter_filter")?.value || "").trim().toLowerCase() : "";
let names = state.chatModels;
if (filter) {
names = names.filter((n) => n.toLowerCase().includes(filter) || n === current);
}
setModelOptions(names, { preferred: current || state.chatModelsPreferred });
}
function setModelOptions(models, { error, preferred } = {}) { function setModelOptions(models, { error, preferred } = {}) {
const sel = $("sa_model"); const sel = $("sa_model");
const sel2 = $("sa_settings_chat_model"); const sel2 = $("sa_settings_chat_model");
const vision = new Set(state.visionModels || []);
const apply = (target) => { const apply = (target) => {
if (!target) { if (!target) {
return; return;
} }
let names = (models || []).map((n) => String(n || "").trim()).filter(Boolean); let names = (models || []).map((n) => String(n || "").trim()).filter(Boolean);
names = [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b)); names = isOpenRouter() ? [...names].sort((a, b) => a.localeCompare(b)) : [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b));
target.innerHTML = ""; target.innerHTML = "";
if (error) { if (error) {
const opt = document.createElement("option"); const opt = document.createElement("option");
@@ -8052,14 +8081,14 @@ ${data.ui.help_extra}`.trim();
if (!names.length) { if (!names.length) {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = ""; opt.value = "";
opt.textContent = "No Ollama models \u2014 pull / Refresh"; opt.textContent = isOpenRouter() ? "\u041D\u0435\u0442 \u043C\u043E\u0434\u0435\u043B\u0435\u0439 OpenRouter \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C \u043A\u043B\u044E\u0447 / \u0444\u0438\u043B\u044C\u0442\u0440" : "No Ollama models \u2014 pull / Refresh";
target.appendChild(opt); target.appendChild(opt);
return; return;
} }
for (const name of names) { for (const name of names) {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = name; opt.value = name;
opt.textContent = name; opt.textContent = vision.has(name) ? `${name} \u{1F441}` : name;
target.appendChild(opt); target.appendChild(opt);
} }
const pick = resolveChatModel(names, preferred); const pick = resolveChatModel(names, preferred);
@@ -8113,10 +8142,17 @@ ${data.ui.help_extra}`.trim();
"AssistentListModels", "AssistentListModels",
{ baseUrl }, { baseUrl },
(data) => { (data) => {
if (data.provider) {
setProviderUi(data.provider);
}
const models = data.models || []; const models = data.models || [];
const memoryModels = data.memory_models || []; const memoryModels = data.memory_models || [];
const preferred = (data.preferred || "").trim(); const preferred = (data.preferred || "").trim();
state.chatModels = models;
state.visionModels = data.vision_models || [];
state.chatModelsPreferred = preferred;
setModelOptions(models, { preferred }); setModelOptions(models, { preferred });
renderChatModelOptions();
setEmbedModelOptions(memoryModels); setEmbedModelOptions(memoryModels);
const pick = resolveChatModel(models, preferred); const pick = resolveChatModel(models, preferred);
if (pick && $("sa_model")) { if (pick && $("sa_model")) {
@@ -8127,24 +8163,91 @@ ${data.ui.help_extra}`.trim();
state.preferredModel = pick; state.preferredModel = pick;
localStorage.setItem(LS_MODEL, pick); localStorage.setItem(LS_MODEL, pick);
} }
setStatus(models.length ? `${models.length} chat \xB7 ${memoryModels.length} memory` : "No Ollama models (gpu-rent: ollama pull)"); const label = providerLabel();
setStatus(models.length ? `${label}: ${models.length} chat \xB7 ${memoryModels.length} memory` : isOpenRouter() ? "OpenRouter: \u043D\u0435\u0442 \u043C\u043E\u0434\u0435\u043B\u0435\u0439" : "No Ollama models (gpu-rent: ollama pull)");
if (models.length) { if (models.length) {
setOllamaHealth("ok", `Ollama \xB7 ${models.length}`, `\u0427\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439: ${models.length}, \u043F\u0430\u043C\u044F\u0442\u044C: ${memoryModels.length}`); setOllamaHealth("ok", `${label} \xB7 ${models.length}`, `\u0427\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439: ${models.length}, \u043F\u0430\u043C\u044F\u0442\u044C: ${memoryModels.length}${data.memory_error ? ` (Ollama: ${data.memory_error})` : ""}`);
} else { } else {
setOllamaHealth("warn", "Ollama \xB7 0 \u043C\u043E\u0434\u0435\u043B\u0435\u0439", "\u041D\u0435\u0442 \u0447\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u2014 \u0441\u0434\u0435\u043B\u0430\u0439 ollama pull"); setOllamaHealth("warn", `${label} \xB7 0 \u043C\u043E\u0434\u0435\u043B\u0435\u0439`, isOpenRouter() ? "\u041D\u0435\u0442 \u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C \u043A\u043B\u044E\u0447" : "\u041D\u0435\u0442 \u0447\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u2014 \u0441\u0434\u0435\u043B\u0430\u0439 ollama pull");
} }
saveSettings(); saveSettings();
}, },
0, 0,
(err) => { (err) => {
const msg = String(err || "Ollama unreachable"); const msg = String(err || `${providerLabel()} unreachable`);
state.chatModels = [];
setStatus(msg); setStatus(msg);
setModelOptions([], { error: msg }); setModelOptions([], { error: msg });
setOllamaHealth("down", "Ollama \u2715", msg); setOllamaHealth("down", `${providerLabel()} \u2715`, msg);
appendMessage("error", msg); appendMessage("error", msg);
} }
); );
} }
function setProviderUi(provider) {
state.provider = provider === "openrouter" ? "openrouter" : "ollama";
if ($("sa_provider")) {
$("sa_provider").value = state.provider;
}
const box = $("sa_openrouter_box");
if (box) {
box.hidden = !isOpenRouter();
}
}
function applyProviderInfo(data) {
if (!data || data.error) {
return;
}
setProviderUi(data.provider);
state.openrouterModelSaved = data.openrouter_model || "";
const status = $("sa_openrouter_key_status");
if (status) {
status.textContent = data.openrouter_key_set ? `\u041A\u043B\u044E\u0447 \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D \u043D\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0435 ${data.openrouter_key_hint || ""}`.trim() : "\u041A\u043B\u044E\u0447 \u043D\u0435 \u0437\u0430\u0434\u0430\u043D";
}
}
function providerRequest(name, body) {
return new Promise((resolve) => {
if (typeof genericRequest !== "function") {
resolve(null);
return;
}
genericRequest(name, body || {}, (data) => resolve(data), 0, (err) => {
setStatus(String(err || `${name} failed`));
resolve(null);
});
});
}
async function loadProviderState() {
applyProviderInfo(await providerRequest("AssistentGetProvider", {}));
}
async function setChatProvider(provider) {
const data = await providerRequest("AssistentSetProvider", { provider });
applyProviderInfo(data);
state.chatModels = [];
refreshModels();
probeOllamaHealth();
}
async function saveOpenRouterKey(key) {
const data = await providerRequest("AssistentSetProvider", { api_key: key });
if (!data) {
return;
}
applyProviderInfo(data);
if ($("sa_openrouter_key")) {
$("sa_openrouter_key").value = "";
}
setStatus(key ? "\u041A\u043B\u044E\u0447 OpenRouter \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D" : "\u041A\u043B\u044E\u0447 OpenRouter \u0443\u0434\u0430\u043B\u0451\u043D");
if (isOpenRouter()) {
refreshModels();
}
}
function persistOpenRouterModel() {
const model = $("sa_model")?.value || "";
if (!isOpenRouter() || !model || model === state.openrouterModelSaved) {
return;
}
state.openrouterModelSaved = model;
providerRequest("AssistentSetProvider", { model });
}
function refreshInventory(done, opts = {}) { function refreshInventory(done, opts = {}) {
if (typeof genericRequest !== "function") { if (typeof genericRequest !== "function") {
if (done) { if (done) {
@@ -8932,19 +9035,19 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
{ baseUrl }, { baseUrl },
(data) => { (data) => {
if (data?.error) { if (data?.error) {
setOllamaHealth("down", "Ollama \u2715", String(data.error)); setOllamaHealth("down", `${providerLabel()} \u2715`, String(data.error));
return; return;
} }
const chat = (data.models || []).length; const chat = (data.models || []).length;
const mem = (data.memory_models || []).length; const mem = (data.memory_models || []).length;
if (!chat) { if (!chat) {
setOllamaHealth("warn", "Ollama \xB7 0 \u043C\u043E\u0434\u0435\u043B\u0435\u0439", "\u041D\u0435\u0442 \u0447\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u2014 \u0441\u0434\u0435\u043B\u0430\u0439 ollama pull"); setOllamaHealth("warn", `${providerLabel()} \xB7 0 \u043C\u043E\u0434\u0435\u043B\u0435\u0439`, isOpenRouter() ? "\u041D\u0435\u0442 \u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C \u043A\u043B\u044E\u0447" : "\u041D\u0435\u0442 \u0447\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439 \u2014 \u0441\u0434\u0435\u043B\u0430\u0439 ollama pull");
return; return;
} }
setOllamaHealth("ok", `Ollama \xB7 ${chat}`, `\u0427\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439: ${chat}, \u043F\u0430\u043C\u044F\u0442\u044C: ${mem} \xB7 ${baseUrl}`); setOllamaHealth("ok", `${providerLabel()} \xB7 ${chat}`, `\u0427\u0430\u0442-\u043C\u043E\u0434\u0435\u043B\u0435\u0439: ${chat}, \u043F\u0430\u043C\u044F\u0442\u044C: ${mem} \xB7 ${data.base_url || baseUrl}`);
}, },
0, 0,
(err) => setOllamaHealth("down", "Ollama \u2715", `\u041D\u0435\u0442 \u0441\u0432\u044F\u0437\u0438: ${String(err || "")} \xB7 ${baseUrl}`) (err) => setOllamaHealth("down", `${providerLabel()} \u2715`, `\u041D\u0435\u0442 \u0441\u0432\u044F\u0437\u0438: ${String(err || "")}`)
); );
} }
function setView(view) { function setView(view) {
@@ -9963,7 +10066,7 @@ ${HELP_TEXT}`);
const persona = $("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral"; const persona = $("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral";
const model = $("sa_model")?.value; const model = $("sa_model")?.value;
if (!model) { if (!model) {
setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C Ollama \u0432 \u2699"); setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C \u0447\u0430\u0442\u0430 \u0432 \u2699");
refreshModels(); refreshModels();
return; return;
} }
@@ -10509,6 +10612,7 @@ ${HELP_TEXT}`);
} catch (e) { } catch (e) {
console.warn("Assistent: chat sessions failed", e); console.warn("Assistent: chat sessions failed", e);
} }
await loadProviderState();
loadConfig(localStorage.getItem(LS_PERSONA) || "neutral", () => { loadConfig(localStorage.getItem(LS_PERSONA) || "neutral", () => {
refreshModels(); refreshModels();
refreshInventory(() => { refreshInventory(() => {
@@ -10736,6 +10840,25 @@ ${HELP_TEXT}`);
document.getElementById(TAB_BUTTON_ID)?.addEventListener("click", () => { document.getElementById(TAB_BUTTON_ID)?.addEventListener("click", () => {
setTimeout(() => $("sa_input")?.focus(), 80); setTimeout(() => $("sa_input")?.focus(), 80);
}); });
$("sa_provider")?.addEventListener("change", () => {
setChatProvider($("sa_provider")?.value || "ollama");
});
$("sa_btn_openrouter_key_save")?.addEventListener("click", () => {
const key = String($("sa_openrouter_key")?.value || "").trim();
if (!key) {
setStatus("\u0412\u0441\u0442\u0430\u0432\u044C \u043A\u043B\u044E\u0447 OpenRouter");
return;
}
saveOpenRouterKey(key);
});
$("sa_openrouter_key")?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
$("sa_btn_openrouter_key_save")?.click();
}
});
$("sa_btn_openrouter_key_clear")?.addEventListener("click", () => saveOpenRouterKey(""));
$("sa_openrouter_filter")?.addEventListener("input", () => renderChatModelOptions());
$("sa_btn_refresh_models")?.addEventListener("click", () => { $("sa_btn_refresh_models")?.addEventListener("click", () => {
saveSettings(); saveSettings();
refreshModels(); refreshModels();
+9
View File
@@ -1110,6 +1110,15 @@
opacity: 0.8; opacity: 0.8;
} }
.sa-openrouter-box {
display: grid;
gap: 0.45rem;
}
.sa-openrouter-box[hidden] {
display: none;
}
.sa-danger-btn { .sa-danger-btn {
color: #e06c75 !important; color: #e06c75 !important;
} }
+3 -1
View File
@@ -253,7 +253,9 @@ public partial class SwarmAssistentExtension
{ {
await onHopStart(hop); await onHopStart(hop);
} }
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid); (reply, lastRaw) = UseOpenRouter()
? await CallOpenRouterChat(modelName, messages, stream: onDelta is not null, onDelta, pid)
: await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
if (slimUtility) if (slimUtility)
{ {
break; break;
+30 -2
View File
@@ -117,7 +117,10 @@ public sealed class AssistentConfig
} }
try try
{ {
return JObject.Parse(File.ReadAllText(path, Encoding.UTF8)); // Share read/write/delete: Windows otherwise fails a concurrent SaveSettings with a sharing violation.
using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using StreamReader reader = new(fs, Encoding.UTF8);
return JObject.Parse(reader.ReadToEnd());
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1671,7 +1674,32 @@ public sealed class AssistentConfig
Directory.CreateDirectory(_overlayRoot); Directory.CreateDirectory(_overlayRoot);
string path = Path.Combine(_overlayRoot, "settings.json"); string path = Path.Combine(_overlayRoot, "settings.json");
JObject merged = DeepMerge(LoadSettings(), settings ?? new JObject()); JObject merged = DeepMerge(LoadSettings(), settings ?? new JObject());
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); WriteFileAtomic(path, merged.ToString(Newtonsoft.Json.Formatting.Indented));
}
}
/// <summary>Temp file + rename, retried briefly: readers never see a half-written file and a
/// transient lock (antivirus, editor, parallel read) does not fail the API call.</summary>
static void WriteFileAtomic(string path, string text)
{
string tmp = $"{path}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tmp, text, Encoding.UTF8);
for (int attempt = 0; ; attempt++)
{
try
{
File.Move(tmp, path, overwrite: true);
return;
}
catch (IOException) when (attempt < 10)
{
System.Threading.Thread.Sleep(25 * (attempt + 1));
}
catch
{
try { File.Delete(tmp); } catch { }
throw;
}
} }
} }
+4 -3
View File
@@ -83,7 +83,8 @@ public partial class SwarmAssistentExtension
}; };
} }
public async Task<JObject> AssistentSaveKnowledgeAttach(Session session, string persona, JArray attach) /// <remarks>SwarmUI's API reflection has no JArray coercer — <c>string[]</c> is the supported list type.</remarks>
public async Task<JObject> AssistentSaveKnowledgeAttach(Session session, string persona, string[] attach = null)
{ {
await Task.CompletedTask; await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona); string pid = AssistentConfig.SafeId(persona);
@@ -104,9 +105,9 @@ public partial class SwarmAssistentExtension
JArray cleaned = []; JArray cleaned = [];
if (attach is not null) if (attach is not null)
{ {
foreach (JToken t in attach) foreach (string t in attach)
{ {
string id = AssistentConfig.SafeId(t?.ToString()); string id = AssistentConfig.SafeId(t);
if (id is not null) if (id is not null)
{ {
cleaned.Add(id); cleaned.Add(id);
+9 -3
View File
@@ -21,6 +21,11 @@ public partial class SwarmAssistentExtension
public async Task<JObject> AssistentListModels(Session session, string baseUrl) public async Task<JObject> AssistentListModels(Session session, string baseUrl)
{ {
string root = NormalizeBaseUrl(baseUrl); string root = NormalizeBaseUrl(baseUrl);
return UseOpenRouter() ? await ListOpenRouterModels(root) : await ListOllamaModels(root);
}
async Task<JObject> ListOllamaModels(string root)
{
try try
{ {
using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags"); using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags");
@@ -104,6 +109,7 @@ public partial class SwarmAssistentExtension
return new JObject return new JObject
{ {
["success"] = true, ["success"] = true,
["provider"] = ProviderOllama,
["base_url"] = root, ["base_url"] = root,
["models"] = models, ["models"] = models,
["memory_models"] = memoryModels, ["memory_models"] = memoryModels,
@@ -363,7 +369,7 @@ public partial class SwarmAssistentExtension
} }
catch (Exception ex) catch (Exception ex)
{ {
return new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" }; return new JObject { ["error"] = $"{ProviderLabel()} chat failed: {ex.Message}" };
} }
} }
@@ -388,7 +394,7 @@ public partial class SwarmAssistentExtension
await ws.SendJson(new JObject await ws.SendJson(new JObject
{ {
["phase"] = "waiting_ollama", ["phase"] = "waiting_ollama",
["notice"] = "Waiting for Ollama…", ["notice"] = $"Waiting for {ProviderLabel()}…",
}, API.WebsocketTimeout); }, API.WebsocketTimeout);
} }
async Task OnDelta(string delta) async Task OnDelta(string delta)
@@ -435,7 +441,7 @@ public partial class SwarmAssistentExtension
} }
catch (Exception ex) catch (Exception ex)
{ {
await ws.SendJson(new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" }, API.WebsocketTimeout); await ws.SendJson(new JObject { ["error"] = $"{ProviderLabel()} chat failed: {ex.Message}" }, API.WebsocketTimeout);
} }
return null; return null;
} }
+307
View File
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>OpenRouter chat transport (OpenAI-compatible /chat/completions). Chat only —
/// memory embeddings stay on Ollama. The API key lives server-side in settings.json
/// (or OPENROUTER_API_KEY) and is never sent back to the browser.</summary>
public partial class SwarmAssistentExtension
{
const string OpenRouterRoot = "https://openrouter.ai/api/v1";
const string ProviderOllama = "ollama";
const string ProviderOpenRouter = "openrouter";
string ChatProvider()
{
string p = Config?.LoadSettings()["provider"]?.ToString()?.Trim().ToLowerInvariant();
return p == ProviderOpenRouter ? ProviderOpenRouter : ProviderOllama;
}
bool UseOpenRouter() => ChatProvider() == ProviderOpenRouter;
string ProviderLabel() => UseOpenRouter() ? "OpenRouter" : "Ollama";
string OpenRouterKey()
{
string key = Config?.LoadSettings()["openrouter_api_key"]?.ToString()?.Trim();
return string.IsNullOrWhiteSpace(key) ? Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")?.Trim() ?? "" : key;
}
/// <summary>Provider state for the settings pane. Only a masked tail of the key is exposed.</summary>
public async Task<JObject> AssistentGetProvider(Session session)
{
await Task.CompletedTask;
string key = OpenRouterKey();
return new JObject
{
["success"] = true,
["provider"] = ChatProvider(),
["openrouter_key_set"] = key.Length > 0,
["openrouter_key_hint"] = key.Length > 8 ? $"…{key[^4..]}" : "",
["openrouter_model"] = Config.LoadSettings()["openrouter_model"]?.ToString() ?? "",
};
}
/// <summary>Sets provider / key / model. Null fields are left untouched; an empty <paramref name="api_key"/> clears the key.</summary>
public async Task<JObject> AssistentSetProvider(Session session, string provider = null, string api_key = null, string model = null)
{
await Task.CompletedTask;
JObject patch = [];
if (provider is not null)
{
string p = provider.Trim().ToLowerInvariant();
if (p != ProviderOllama && p != ProviderOpenRouter)
{
return new JObject { ["error"] = "provider must be ollama or openrouter" };
}
patch["provider"] = p;
}
if (api_key is not null)
{
patch["openrouter_api_key"] = api_key.Trim();
}
if (model is not null)
{
patch["openrouter_model"] = model.Trim();
}
Config.SaveSettings(patch);
return await AssistentGetProvider(session);
}
HttpRequestMessage OpenRouterRequest(HttpMethod method, string path, HttpContent content = null)
{
HttpRequestMessage req = new(method, $"{OpenRouterRoot}{path}") { Content = content };
string key = OpenRouterKey();
if (key.Length > 0)
{
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}");
}
req.Headers.TryAddWithoutValidation("HTTP-Referer", "https://github.com/mcmonkeyprojects/SwarmUI");
req.Headers.TryAddWithoutValidation("X-Title", "SwarmUI Assistent");
return req;
}
/// <summary>OpenRouter chat models + Ollama embed models (best effort) for memory.</summary>
async Task<JObject> ListOpenRouterModels(string ollamaRoot)
{
if (OpenRouterKey().Length == 0)
{
return new JObject { ["error"] = "OpenRouter: API key not set" };
}
JArray models = [];
JArray visionModels = [];
try
{
using HttpRequestMessage req = OpenRouterRequest(HttpMethod.Get, "/models");
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
return new JObject { ["error"] = $"OpenRouter /models HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
}
IEnumerable<JObject> entries = (JObject.Parse(body)["data"] as JArray ?? []).OfType<JObject>()
.Where(m => !string.IsNullOrWhiteSpace(m["id"]?.ToString()))
.Where(m => (m["architecture"]?["output_modalities"] as JArray)?.Any(t => t.ToString() == "text") ?? true)
.OrderBy(m => m["id"].ToString(), StringComparer.OrdinalIgnoreCase);
foreach (JObject m in entries)
{
string id = m["id"].ToString();
models.Add(id);
if ((m["architecture"]?["input_modalities"] as JArray)?.Any(t => t.ToString() == "image") == true)
{
visionModels.Add(id);
}
}
}
catch (Exception ex)
{
return new JObject { ["error"] = $"OpenRouter unreachable: {ex.Message}" };
}
JArray memoryModels = [];
JObject ollama = await ListOllamaModels(ollamaRoot);
if (ollama["memory_models"] is JArray mem)
{
memoryModels = mem;
}
string preferred = Config.LoadSettings()["openrouter_model"]?.ToString()?.Trim() ?? "";
return new JObject
{
["success"] = true,
["provider"] = ProviderOpenRouter,
["base_url"] = OpenRouterRoot,
["models"] = models,
["vision_models"] = visionModels,
["memory_models"] = memoryModels,
["memory_error"] = ollama["error"],
["preferred"] = models.Any(t => t.ToString() == preferred) ? preferred : "",
};
}
/// <summary>Ollama-style messages (<c>images</c> = raw base64) → OpenAI content parts.</summary>
static JArray ToOpenAiMessages(List<JObject> ollamaMessages)
{
JArray result = [];
foreach (JObject m in ollamaMessages)
{
string role = m["role"]?.ToString() ?? "user";
string text = m["content"]?.ToString() ?? "";
if (m["images"] is JArray images && images.Count > 0)
{
JArray parts = [];
if (text.Length > 0)
{
parts.Add(new JObject { ["type"] = "text", ["text"] = text });
}
foreach (JToken img in images)
{
string s = img.ToString();
string url = s.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || s.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? s
: $"data:image/jpeg;base64,{s}";
parts.Add(new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = url } });
}
result.Add(new JObject { ["role"] = role, ["content"] = parts });
}
else
{
result.Add(new JObject { ["role"] = role, ["content"] = text });
}
}
return result;
}
/// <summary>Copies OpenAI usage into the Ollama field names the pipeline / UI already read.</summary>
static void MapUsage(JObject raw, JToken usage)
{
if (usage is JObject u && u["prompt_tokens"]?.Value<int?>() is int prompt && prompt > 0)
{
raw["prompt_eval_count"] = prompt;
}
}
async Task<(string reply, JObject raw)> CallOpenRouterChat(
string modelName,
List<JObject> ollamaMessages,
bool stream,
Func<string, Task> onDelta,
string personaId = null)
{
if (OpenRouterKey().Length == 0)
{
throw new Exception("OpenRouter API key not set (⚙ → Модели)");
}
int numPredict = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_predict"]?.Value<int?>()
?? 3072;
if (numPredict < 512)
{
numPredict = 512;
}
JObject payload = new()
{
["model"] = modelName,
["stream"] = stream,
["messages"] = ToOpenAiMessages(ollamaMessages),
["max_tokens"] = numPredict,
["usage"] = new JObject { ["include"] = true },
};
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
using HttpRequestMessage req = OpenRouterRequest(HttpMethod.Post, "/chat/completions", content);
using HttpResponseMessage resp = await HttpClient.SendAsync(req, stream
? HttpCompletionOption.ResponseHeadersRead
: HttpCompletionOption.ResponseContentRead);
if (!resp.IsSuccessStatusCode)
{
string errBody = await resp.Content.ReadAsStringAsync();
throw new Exception($"OpenRouter HTTP {(int)resp.StatusCode}: {Clip(errBody, 800)}");
}
if (!stream)
{
string body = await resp.Content.ReadAsStringAsync();
JObject parsed = JObject.Parse(body);
if (parsed["error"] is JToken err)
{
throw new Exception($"OpenRouter: {Clip(err["message"]?.ToString() ?? err.ToString(), 800)}");
}
MapUsage(parsed, parsed["usage"]);
string reply = parsed["choices"]?[0]?["message"]?["content"]?.ToString() ?? "";
if (TryTruncateAtCompleteFence(reply, out string cut))
{
reply = cut;
}
return (reply, parsed);
}
StringBuilder full = new();
JObject last = [];
await using Stream streamBody = await resp.Content.ReadAsStreamAsync();
using StreamReader reader = new(streamBody, Encoding.UTF8);
while (true)
{
string line = await reader.ReadLineAsync();
if (line is null)
{
break;
}
// SSE: skip blanks and ": OPENROUTER PROCESSING" keep-alive comments.
if (!line.StartsWith("data:", StringComparison.Ordinal))
{
continue;
}
string data = line[5..].Trim();
if (data == "[DONE]")
{
break;
}
JObject chunk;
try
{
chunk = JObject.Parse(data);
}
catch (Exception)
{
continue;
}
if (chunk["error"] is JToken err)
{
throw new Exception($"OpenRouter: {Clip(err["message"]?.ToString() ?? err.ToString(), 800)}");
}
last["id"] = chunk["id"];
last["model"] = chunk["model"];
MapUsage(last, chunk["usage"]);
string delta = chunk["choices"]?[0]?["delta"]?["content"]?.ToString() ?? "";
if (string.IsNullOrEmpty(delta))
{
continue;
}
full.Append(delta);
// Same early stop as the Ollama path: closed ```json``` patch → stop reading.
if (TryTruncateAtCompleteFence(full.ToString(), out string cut))
{
string extra = full.Length > cut.Length ? full.ToString(cut.Length, full.Length - cut.Length) : "";
full.Clear();
full.Append(cut);
int keep = delta.Length - extra.Length;
if (onDelta is not null && keep > 0)
{
await onDelta(delta[..keep]);
}
break;
}
if (onDelta is not null)
{
await onDelta(delta);
}
}
return (full.ToString(), last);
}
}
+44 -9
View File
@@ -35,14 +35,7 @@ internal static class AssistentSqliteBootstrap
{ {
return; return;
} }
string[] relPaths = foreach (string rel in CandidatePaths())
[
Path.Combine("runtimes", "linux-x64", "native", "libe_sqlite3.so"),
Path.Combine("runtimes", "linux-x64", "native", "e_sqlite3.so"),
"libe_sqlite3.so",
"e_sqlite3.so",
];
foreach (string rel in relPaths)
{ {
string path = Path.Combine(extDir, rel); string path = Path.Combine(extDir, rel);
if (!File.Exists(path)) if (!File.Exists(path))
@@ -51,7 +44,8 @@ internal static class AssistentSqliteBootstrap
} }
try try
{ {
NativeLibrary.Load(path); IntPtr handle = NativeLibrary.Load(path);
RegisterResolver(handle);
return; return;
} }
catch (Exception ex) catch (Exception ex)
@@ -60,4 +54,45 @@ internal static class AssistentSqliteBootstrap
} }
} }
} }
/// <summary>Native e_sqlite3 locations for the current OS / arch, most specific first.</summary>
static string[] CandidatePaths()
{
string arch = RuntimeInformation.ProcessArchitecture switch
{
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
Architecture.Arm => "arm",
_ => "x64",
};
if (OperatingSystem.IsWindows())
{
return [Path.Combine("runtimes", $"win-{arch}", "native", "e_sqlite3.dll"), "e_sqlite3.dll"];
}
if (OperatingSystem.IsMacOS())
{
return [Path.Combine("runtimes", $"osx-{arch}", "native", "libe_sqlite3.dylib"), "libe_sqlite3.dylib"];
}
return
[
Path.Combine("runtimes", $"linux-{arch}", "native", "libe_sqlite3.so"),
Path.Combine("runtimes", $"linux-{arch}", "native", "e_sqlite3.so"),
"libe_sqlite3.so",
"e_sqlite3.so",
];
}
/// <summary>The provider's [DllImport("e_sqlite3")] would otherwise probe the host dir, not the extension dir.</summary>
static void RegisterResolver(IntPtr handle)
{
try
{
NativeLibrary.SetDllImportResolver(typeof(SQLite3Provider_e_sqlite3).Assembly,
(name, _, _) => name.Contains("e_sqlite3", StringComparison.OrdinalIgnoreCase) ? handle : IntPtr.Zero);
}
catch (InvalidOperationException)
{
// A resolver is already set for this assembly — the preloaded handle still helps on Windows.
}
}
} }
+9
View File
@@ -26,6 +26,10 @@ public partial class SwarmAssistentExtension
{ {
return new JObject { ["error"] = "model is required" }; return new JObject { ["error"] = "model is required" };
} }
if (UseOpenRouter())
{
return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "remote provider" };
}
if (LooksLikeEmbedModel(name)) if (LooksLikeEmbedModel(name))
{ {
return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "memory model — never parked" }; return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "memory model — never parked" };
@@ -80,6 +84,11 @@ public partial class SwarmAssistentExtension
{ {
return new JObject { ["error"] = "model is required" }; return new JObject { ["error"] = "model is required" };
} }
if (UseOpenRouter())
{
// Remote model is always "resident" — lets the UI clear its cold-load flag.
return new JObject { ["success"] = true, ["warmed"] = true, ["skipped"] = "already_resident", ["model"] = name };
}
if (LooksLikeEmbedModel(name)) if (LooksLikeEmbedModel(name))
{ {
return new JObject { ["success"] = true, ["warmed"] = false, ["skipped"] = "memory model" }; return new JObject { ["success"] = true, ["warmed"] = false, ["skipped"] = "memory model" };
+4 -2
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.16.1"; Version = "0.17.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "knowledge", "heard", "books"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "knowledge", "heard", "books"];
} }
@@ -46,6 +46,8 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentListPersonas, false, PermUse); API.RegisterAPICall(AssistentListPersonas, false, PermUse);
API.RegisterAPICall(AssistentGetConfig, false, PermUse); API.RegisterAPICall(AssistentGetConfig, false, PermUse);
API.RegisterAPICall(AssistentSaveSettings, true, PermUse); API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
API.RegisterAPICall(AssistentGetProvider, false, PermUse);
API.RegisterAPICall(AssistentSetProvider, true, PermUse);
API.RegisterAPICall(AssistentListInventory, false, PermUse); API.RegisterAPICall(AssistentListInventory, false, PermUse);
API.RegisterAPICall(AssistentChat, true, PermUse); API.RegisterAPICall(AssistentChat, true, PermUse);
API.RegisterAPICall(AssistentChatWS, true, PermUse); API.RegisterAPICall(AssistentChatWS, true, PermUse);
@@ -81,7 +83,7 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentForgetUserPref, true, PermUse); API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse); API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
API.RegisterAPICall(AssistentClearMemory, true, PermUse); API.RegisterAPICall(AssistentClearMemory, true, PermUse);
Logs.Init("Swarm Assistent extension loaded (0.16.1 knowledge books hub)"); Logs.Init("Swarm Assistent extension loaded (0.17.0 OpenRouter provider)");
} }
int CfgInt(string key, int fallback) int CfgInt(string key, int fallback)
+1 -1
View File
@@ -29,7 +29,7 @@
Condition="$([System.String]::Copy('%(RuntimeCopyLocalItems.DestinationSubPath)').Contains('e_sqlite3'))" /> Condition="$([System.String]::Copy('%(RuntimeCopyLocalItems.DestinationSubPath)').Contains('e_sqlite3'))" />
</ItemGroup> </ItemGroup>
<Copy SourceFiles="@(_SqliteNative)" <Copy SourceFiles="@(_SqliteNative)"
DestinationFiles="$(OutputPath)@(_SqliteNative->'%(RuntimeCopyLocalItems.DestinationSubPath)')" DestinationFiles="@(_SqliteNative->'$(OutputPath)%(DestinationSubPath)')"
SkipUnchangedFiles="true" SkipUnchangedFiles="true"
Condition="'@(_SqliteNative)' != ''" /> Condition="'@(_SqliteNative)' != ''" />
<Copy SourceFiles="@(_SqliteNative)" <Copy SourceFiles="@(_SqliteNative)"
+22 -3
View File
@@ -92,7 +92,7 @@
<option value="write_prompt">Написать промпт</option> <option value="write_prompt">Написать промпт</option>
</select> </select>
<span class="sa-mode-badge" id="sa_mode_badge" title="Активный pack">обычный</span> <span class="sa-mode-badge" id="sa_mode_badge" title="Активный pack">обычный</span>
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama (чат)" aria-label="Модель Ollama"> <select id="sa_model" class="sa-select sa-model-select" title="Модель чата" aria-label="Модель чата">
<option value="">Загрузка моделей…</option> <option value="">Загрузка моделей…</option>
</select> </select>
</div> </div>
@@ -179,7 +179,26 @@
<div class="sa-skills-box" id="sa_skills_box"></div> <div class="sa-skills-box" id="sa_skills_box"></div>
</div> </div>
<div class="sa-spane" data-spane="models" hidden> <div class="sa-spane" data-spane="models" hidden>
<p class="sa-settings-hint">Ollama и модель эмбеддингов для крафт-памяти.</p> <p class="sa-settings-hint">Провайдер чата и модель эмбеддингов для крафт-памяти. Память всегда считается через Ollama.</p>
<label>Провайдер чата
<select id="sa_provider" class="sa-select">
<option value="ollama">Ollama (локально)</option>
<option value="openrouter">OpenRouter</option>
</select>
</label>
<div class="sa-openrouter-box" id="sa_openrouter_box" hidden>
<label>OpenRouter API key
<input type="password" id="sa_openrouter_key" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…" />
</label>
<div class="sa-settings-row">
<button type="button" class="basic-button sa-primary" id="sa_btn_openrouter_key_save">Сохранить ключ</button>
<button type="button" class="basic-button sa-danger-btn" id="sa_btn_openrouter_key_clear">Удалить ключ</button>
<span class="sa-settings-health" id="sa_openrouter_key_status">Ключ не задан</span>
</div>
<label>Фильтр моделей
<input type="text" id="sa_openrouter_filter" autocomplete="off" spellcheck="false" placeholder="например: claude, gpt, qwen, :free" />
</label>
</div>
<label>Ollama URL <input type="text" id="sa_base_url" value="http://127.0.0.1:11434" /></label> <label>Ollama URL <input type="text" id="sa_base_url" value="http://127.0.0.1:11434" /></label>
<label>Модель чата <label>Модель чата
<select id="sa_settings_chat_model" class="sa-select" title="Синхрон с шапкой"> <select id="sa_settings_chat_model" class="sa-select" title="Синхрон с шапкой">
@@ -194,7 +213,7 @@
<div class="sa-settings-row"> <div class="sa-settings-row">
<button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button> <button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button>
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button> <button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
<button type="button" class="basic-button" id="sa_btn_settings_health">Проверить Ollama</button> <button type="button" class="basic-button" id="sa_btn_settings_health">Проверить связь</button>
</div> </div>
<div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div> <div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div>
</div> </div>
+159 -16
View File
@@ -132,6 +132,12 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
waitImageTimer: null, waitImageTimer: null,
lastImageDataUrl: null, lastImageDataUrl: null,
preferredModel: null, preferredModel: null,
/** Chat provider mirrored from server settings: 'ollama' | 'openrouter'. */
provider: 'ollama',
chatModels: [],
chatModelsPreferred: '',
visionModels: [],
openrouterModelSaved: '',
inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false }, inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
inventoryFetchedAt: 0, inventoryFetchedAt: 0,
streamEl: null, streamEl: null,
@@ -2066,7 +2072,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
} }
const model = $('sa_model')?.value; const model = $('sa_model')?.value;
if (!model) { if (!model) {
setStatus('Выбери модель Ollama в ⚙'); setStatus('Выбери модель чата в ⚙');
return false; return false;
} }
const { prompt, foldCount } = buildCompressUserPrompt(); const { prompt, foldCount } = buildCompressUserPrompt();
@@ -2240,7 +2246,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
} }
const model = $('sa_model')?.value; const model = $('sa_model')?.value;
if (!model) { if (!model) {
setStatus('Выбери модель Ollama в ⚙'); setStatus('Выбери модель чата в ⚙');
return; return;
} }
state.busy = true; state.busy = true;
@@ -6373,6 +6379,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
localStorage.setItem(LS_PARK_LLM, $('sa_park_llm')?.checked ? '1' : '0'); localStorage.setItem(LS_PARK_LLM, $('sa_park_llm')?.checked ? '1' : '0');
persistOpenRouterModel();
persistServerSettings(); persistServerSettings();
saveUiStateToDisk(); saveUiStateToDisk();
} }
@@ -6966,25 +6973,54 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
if (!list.length) { if (!list.length) {
return ''; return '';
} }
const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
if (isOpenRouter()) {
// Server-side openrouter_model wins; no size heuristic over hundreds of remote ids.
if (apiPreferred && list.includes(apiPreferred)) {
return apiPreferred;
}
return ls && list.includes(ls) ? ls : list[0];
}
const preferred = apiPreferred && list.includes(apiPreferred) const preferred = apiPreferred && list.includes(apiPreferred)
? apiPreferred ? apiPreferred
: pickSeniorChatModel(list); : pickSeniorChatModel(list);
const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
if (ls && list.includes(ls)) { if (ls && list.includes(ls)) {
return ls; return ls;
} }
return preferred || list[0]; return preferred || list[0];
} }
function isOpenRouter() {
return state.provider === 'openrouter';
}
function providerLabel() {
return isOpenRouter() ? 'OpenRouter' : 'Ollama';
}
/** Re-renders both model selects from state.chatModels with the OpenRouter text filter applied. */
function renderChatModelOptions() {
const current = $('sa_model')?.value || '';
const filter = isOpenRouter() ? String($('sa_openrouter_filter')?.value || '').trim().toLowerCase() : '';
let names = state.chatModels;
if (filter) {
names = names.filter((n) => n.toLowerCase().includes(filter) || n === current);
}
setModelOptions(names, { preferred: current || state.chatModelsPreferred });
}
function setModelOptions(models, { error, preferred } = {}) { function setModelOptions(models, { error, preferred } = {}) {
const sel = $('sa_model'); const sel = $('sa_model');
const sel2 = $('sa_settings_chat_model'); const sel2 = $('sa_settings_chat_model');
const vision = new Set(state.visionModels || []);
const apply = (target) => { const apply = (target) => {
if (!target) { if (!target) {
return; return;
} }
let names = (models || []).map((n) => String(n || '').trim()).filter(Boolean); let names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
names = [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b)); names = isOpenRouter()
? [...names].sort((a, b) => a.localeCompare(b))
: [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b));
target.innerHTML = ''; target.innerHTML = '';
if (error) { if (error) {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -6998,14 +7034,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
if (!names.length) { if (!names.length) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ''; opt.value = '';
opt.textContent = 'No Ollama models — pull / Refresh'; opt.textContent = isOpenRouter() ? 'Нет моделей OpenRouter — проверь ключ / фильтр' : 'No Ollama models — pull / Refresh';
target.appendChild(opt); target.appendChild(opt);
return; return;
} }
for (const name of names) { for (const name of names) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = name; opt.value = name;
opt.textContent = name; opt.textContent = vision.has(name) ? `${name} 👁` : name;
target.appendChild(opt); target.appendChild(opt);
} }
const pick = resolveChatModel(names, preferred); const pick = resolveChatModel(names, preferred);
@@ -7061,10 +7097,17 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
'AssistentListModels', 'AssistentListModels',
{ baseUrl }, { baseUrl },
(data) => { (data) => {
if (data.provider) {
setProviderUi(data.provider);
}
const models = data.models || []; const models = data.models || [];
const memoryModels = data.memory_models || []; const memoryModels = data.memory_models || [];
const preferred = (data.preferred || '').trim(); const preferred = (data.preferred || '').trim();
state.chatModels = models;
state.visionModels = data.vision_models || [];
state.chatModelsPreferred = preferred;
setModelOptions(models, { preferred }); setModelOptions(models, { preferred });
renderChatModelOptions();
setEmbedModelOptions(memoryModels); setEmbedModelOptions(memoryModels);
const pick = resolveChatModel(models, preferred); const pick = resolveChatModel(models, preferred);
if (pick && $('sa_model')) { if (pick && $('sa_model')) {
@@ -7075,25 +7118,105 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
state.preferredModel = pick; state.preferredModel = pick;
localStorage.setItem(LS_MODEL, pick); localStorage.setItem(LS_MODEL, pick);
} }
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)'); const label = providerLabel();
setStatus(models.length
? `${label}: ${models.length} chat · ${memoryModels.length} memory`
: (isOpenRouter() ? 'OpenRouter: нет моделей' : 'No Ollama models (gpu-rent: ollama pull)'));
if (models.length) { if (models.length) {
setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`); setOllamaHealth('ok', `${label} · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}${data.memory_error ? ` (Ollama: ${data.memory_error})` : ''}`);
} else { } else {
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull'); setOllamaHealth('warn', `${label} · 0 моделей`, isOpenRouter() ? 'Нет моделей — проверь ключ' : 'Нет чат-моделей — сделай ollama pull');
} }
saveSettings(); saveSettings();
}, },
0, 0,
(err) => { (err) => {
const msg = String(err || 'Ollama unreachable'); const msg = String(err || `${providerLabel()} unreachable`);
state.chatModels = [];
setStatus(msg); setStatus(msg);
setModelOptions([], { error: msg }); setModelOptions([], { error: msg });
setOllamaHealth('down', 'Ollama ✕', msg); setOllamaHealth('down', `${providerLabel()}`, msg);
appendMessage('error', msg); appendMessage('error', msg);
}, },
); );
} }
/** Syncs the provider select / OpenRouter box with a provider id; no requests. */
function setProviderUi(provider) {
state.provider = provider === 'openrouter' ? 'openrouter' : 'ollama';
if ($('sa_provider')) {
$('sa_provider').value = state.provider;
}
const box = $('sa_openrouter_box');
if (box) {
box.hidden = !isOpenRouter();
}
}
function applyProviderInfo(data) {
if (!data || data.error) {
return;
}
setProviderUi(data.provider);
state.openrouterModelSaved = data.openrouter_model || '';
const status = $('sa_openrouter_key_status');
if (status) {
status.textContent = data.openrouter_key_set
? `Ключ сохранён на сервере ${data.openrouter_key_hint || ''}`.trim()
: 'Ключ не задан';
}
}
function providerRequest(name, body) {
return new Promise((resolve) => {
if (typeof genericRequest !== 'function') {
resolve(null);
return;
}
genericRequest(name, body || {}, (data) => resolve(data), 0, (err) => {
setStatus(String(err || `${name} failed`));
resolve(null);
});
});
}
async function loadProviderState() {
applyProviderInfo(await providerRequest('AssistentGetProvider', {}));
}
async function setChatProvider(provider) {
const data = await providerRequest('AssistentSetProvider', { provider });
applyProviderInfo(data);
state.chatModels = [];
refreshModels();
probeOllamaHealth();
}
async function saveOpenRouterKey(key) {
const data = await providerRequest('AssistentSetProvider', { api_key: key });
if (!data) {
return;
}
applyProviderInfo(data);
if ($('sa_openrouter_key')) {
$('sa_openrouter_key').value = '';
}
setStatus(key ? 'Ключ OpenRouter сохранён' : 'Ключ OpenRouter удалён');
if (isOpenRouter()) {
refreshModels();
}
}
/** Persists the selected OpenRouter model server-side (only when it actually changed). */
function persistOpenRouterModel() {
const model = $('sa_model')?.value || '';
if (!isOpenRouter() || !model || model === state.openrouterModelSaved) {
return;
}
state.openrouterModelSaved = model;
providerRequest('AssistentSetProvider', { model });
}
function refreshInventory(done, opts = {}) { function refreshInventory(done, opts = {}) {
if (typeof genericRequest !== 'function') { if (typeof genericRequest !== 'function') {
if (done) { if (done) {
@@ -7922,19 +8045,19 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
{ baseUrl }, { baseUrl },
(data) => { (data) => {
if (data?.error) { if (data?.error) {
setOllamaHealth('down', 'Ollama ✕', String(data.error)); setOllamaHealth('down', `${providerLabel()}`, String(data.error));
return; return;
} }
const chat = (data.models || []).length; const chat = (data.models || []).length;
const mem = (data.memory_models || []).length; const mem = (data.memory_models || []).length;
if (!chat) { if (!chat) {
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull'); setOllamaHealth('warn', `${providerLabel()} · 0 моделей`, isOpenRouter() ? 'Нет моделей — проверь ключ' : 'Нет чат-моделей — сделай ollama pull');
return; return;
} }
setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`); setOllamaHealth('ok', `${providerLabel()} · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${data.base_url || baseUrl}`);
}, },
0, 0,
(err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`), (err) => setOllamaHealth('down', `${providerLabel()}`, `Нет связи: ${String(err || '')}`),
); );
} }
@@ -9005,7 +9128,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral'; const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
const model = $('sa_model')?.value; const model = $('sa_model')?.value;
if (!model) { if (!model) {
setStatus('Выбери модель Ollama в ⚙'); setStatus('Выбери модель чата в ⚙');
refreshModels(); refreshModels();
return; return;
} }
@@ -9594,6 +9717,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
} catch (e) { } catch (e) {
console.warn('Assistent: chat sessions failed', e); console.warn('Assistent: chat sessions failed', e);
} }
await loadProviderState();
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => { loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
refreshModels(); refreshModels();
refreshInventory(() => { refreshInventory(() => {
@@ -9830,6 +9954,25 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => { document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => {
setTimeout(() => $('sa_input')?.focus(), 80); setTimeout(() => $('sa_input')?.focus(), 80);
}); });
$('sa_provider')?.addEventListener('change', () => {
setChatProvider($('sa_provider')?.value || 'ollama');
});
$('sa_btn_openrouter_key_save')?.addEventListener('click', () => {
const key = String($('sa_openrouter_key')?.value || '').trim();
if (!key) {
setStatus('Вставь ключ OpenRouter');
return;
}
saveOpenRouterKey(key);
});
$('sa_openrouter_key')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
$('sa_btn_openrouter_key_save')?.click();
}
});
$('sa_btn_openrouter_key_clear')?.addEventListener('click', () => saveOpenRouterKey(''));
$('sa_openrouter_filter')?.addEventListener('input', () => renderChatModelOptions());
$('sa_btn_refresh_models')?.addEventListener('click', () => { $('sa_btn_refresh_models')?.addEventListener('click', () => {
saveSettings(); saveSettings();
refreshModels(); refreshModels();