From d67f5e396ea3929f1d1c1b7c0396c2580489ebce Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:18:47 +0300 Subject: [PATCH] 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 --- Assets/assistent.bundle.js | 155 +++++++++++++++-- Assets/assistent.css | 9 + AssistentChatPipeline.cs | 4 +- AssistentConfig.cs | 32 +++- AssistentKnowledgeApi.cs | 7 +- AssistentOllama.cs | 12 +- AssistentOpenRouter.cs | 307 +++++++++++++++++++++++++++++++++ AssistentSqliteBootstrap.cs | 53 +++++- AssistentVram.cs | 9 + SwarmAssistentExtension.cs | 6 +- SwarmAssistentExtension.csproj | 2 +- Tabs/Text2Image/Assistent.html | 25 ++- src/app.js | 175 +++++++++++++++++-- 13 files changed, 740 insertions(+), 56 deletions(-) create mode 100644 AssistentOpenRouter.cs diff --git a/Assets/assistent.bundle.js b/Assets/assistent.bundle.js index 66a9c95..5ebd911 100644 --- a/Assets/assistent.bundle.js +++ b/Assets/assistent.bundle.js @@ -1733,6 +1733,12 @@ waitImageTimer: null, lastImageDataUrl: 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 }, inventoryFetchedAt: 0, streamEl: null, @@ -3479,7 +3485,7 @@ ${patch.prompt}`; } const model = $("sa_model")?.value; 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; } const { prompt, foldCount } = buildCompressUserPrompt(); @@ -3642,7 +3648,7 @@ ${patch.prompt}`; } const model = $("sa_model")?.value; 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; } state.busy = true; @@ -7459,6 +7465,7 @@ ${patch.prompt}`; 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_PARK_LLM, $("sa_park_llm")?.checked ? "1" : "0"); + persistOpenRouterModel(); persistServerSettings(); saveUiStateToDisk(); } @@ -8023,22 +8030,44 @@ ${data.ui.help_extra}`.trim(); if (!list.length) { return ""; } - const preferred = apiPreferred && list.includes(apiPreferred) ? apiPreferred : pickSeniorChatModel(list); 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)) { return ls; } 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 } = {}) { const sel = $("sa_model"); const sel2 = $("sa_settings_chat_model"); + const vision = new Set(state.visionModels || []); const apply = (target) => { if (!target) { return; } 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 = ""; if (error) { const opt = document.createElement("option"); @@ -8052,14 +8081,14 @@ ${data.ui.help_extra}`.trim(); if (!names.length) { const opt = document.createElement("option"); 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); return; } for (const name of names) { const opt = document.createElement("option"); opt.value = name; - opt.textContent = name; + opt.textContent = vision.has(name) ? `${name} \u{1F441}` : name; target.appendChild(opt); } const pick = resolveChatModel(names, preferred); @@ -8113,10 +8142,17 @@ ${data.ui.help_extra}`.trim(); "AssistentListModels", { baseUrl }, (data) => { + if (data.provider) { + setProviderUi(data.provider); + } const models = data.models || []; const memoryModels = data.memory_models || []; const preferred = (data.preferred || "").trim(); + state.chatModels = models; + state.visionModels = data.vision_models || []; + state.chatModelsPreferred = preferred; setModelOptions(models, { preferred }); + renderChatModelOptions(); setEmbedModelOptions(memoryModels); const pick = resolveChatModel(models, preferred); if (pick && $("sa_model")) { @@ -8127,24 +8163,91 @@ ${data.ui.help_extra}`.trim(); state.preferredModel = 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) { - 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 { - 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(); }, 0, (err) => { - const msg = String(err || "Ollama unreachable"); + const msg = String(err || `${providerLabel()} unreachable`); + state.chatModels = []; setStatus(msg); setModelOptions([], { error: msg }); - setOllamaHealth("down", "Ollama \u2715", msg); + setOllamaHealth("down", `${providerLabel()} \u2715`, 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 = {}) { if (typeof genericRequest !== "function") { if (done) { @@ -8932,19 +9035,19 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; { baseUrl }, (data) => { if (data?.error) { - setOllamaHealth("down", "Ollama \u2715", String(data.error)); + setOllamaHealth("down", `${providerLabel()} \u2715`, String(data.error)); return; } const chat = (data.models || []).length; const mem = (data.memory_models || []).length; 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; } - 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, - (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) { @@ -9963,7 +10066,7 @@ ${HELP_TEXT}`); const persona = $("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral"; const model = $("sa_model")?.value; 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(); return; } @@ -10509,6 +10612,7 @@ ${HELP_TEXT}`); } catch (e) { console.warn("Assistent: chat sessions failed", e); } + await loadProviderState(); loadConfig(localStorage.getItem(LS_PERSONA) || "neutral", () => { refreshModels(); refreshInventory(() => { @@ -10736,6 +10840,25 @@ ${HELP_TEXT}`); document.getElementById(TAB_BUTTON_ID)?.addEventListener("click", () => { 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", () => { saveSettings(); refreshModels(); diff --git a/Assets/assistent.css b/Assets/assistent.css index 57d5de9..9f0178c 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -1110,6 +1110,15 @@ opacity: 0.8; } +.sa-openrouter-box { + display: grid; + gap: 0.45rem; +} + +.sa-openrouter-box[hidden] { + display: none; +} + .sa-danger-btn { color: #e06c75 !important; } diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs index f5d1625..ca864b2 100644 --- a/AssistentChatPipeline.cs +++ b/AssistentChatPipeline.cs @@ -253,7 +253,9 @@ public partial class SwarmAssistentExtension { 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) { break; diff --git a/AssistentConfig.cs b/AssistentConfig.cs index cbdac56..e5e1bab 100644 --- a/AssistentConfig.cs +++ b/AssistentConfig.cs @@ -117,7 +117,10 @@ public sealed class AssistentConfig } 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) { @@ -1671,7 +1674,32 @@ public sealed class AssistentConfig Directory.CreateDirectory(_overlayRoot); string path = Path.Combine(_overlayRoot, "settings.json"); 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)); + } + } + + /// 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. + 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; + } } } diff --git a/AssistentKnowledgeApi.cs b/AssistentKnowledgeApi.cs index b49dda5..f2b5864 100644 --- a/AssistentKnowledgeApi.cs +++ b/AssistentKnowledgeApi.cs @@ -83,7 +83,8 @@ public partial class SwarmAssistentExtension }; } - public async Task AssistentSaveKnowledgeAttach(Session session, string persona, JArray attach) + /// SwarmUI's API reflection has no JArray coercer — string[] is the supported list type. + public async Task AssistentSaveKnowledgeAttach(Session session, string persona, string[] attach = null) { await Task.CompletedTask; string pid = AssistentConfig.SafeId(persona); @@ -104,9 +105,9 @@ public partial class SwarmAssistentExtension JArray cleaned = []; 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) { cleaned.Add(id); diff --git a/AssistentOllama.cs b/AssistentOllama.cs index 8278157..415c6a3 100644 --- a/AssistentOllama.cs +++ b/AssistentOllama.cs @@ -21,6 +21,11 @@ public partial class SwarmAssistentExtension public async Task AssistentListModels(Session session, string baseUrl) { string root = NormalizeBaseUrl(baseUrl); + return UseOpenRouter() ? await ListOpenRouterModels(root) : await ListOllamaModels(root); + } + + async Task ListOllamaModels(string root) + { try { using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags"); @@ -104,6 +109,7 @@ public partial class SwarmAssistentExtension return new JObject { ["success"] = true, + ["provider"] = ProviderOllama, ["base_url"] = root, ["models"] = models, ["memory_models"] = memoryModels, @@ -363,7 +369,7 @@ public partial class SwarmAssistentExtension } 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 { ["phase"] = "waiting_ollama", - ["notice"] = "Waiting for Ollama…", + ["notice"] = $"Waiting for {ProviderLabel()}…", }, API.WebsocketTimeout); } async Task OnDelta(string delta) @@ -435,7 +441,7 @@ public partial class SwarmAssistentExtension } 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; } diff --git a/AssistentOpenRouter.cs b/AssistentOpenRouter.cs new file mode 100644 index 0000000..c8a3c29 --- /dev/null +++ b/AssistentOpenRouter.cs @@ -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; + +/// 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. +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; + } + + /// Provider state for the settings pane. Only a masked tail of the key is exposed. + public async Task 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() ?? "", + }; + } + + /// Sets provider / key / model. Null fields are left untouched; an empty clears the key. + public async Task 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; + } + + /// OpenRouter chat models + Ollama embed models (best effort) for memory. + async Task 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 entries = (JObject.Parse(body)["data"] as JArray ?? []).OfType() + .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 : "", + }; + } + + /// Ollama-style messages (images = raw base64) → OpenAI content parts. + static JArray ToOpenAiMessages(List 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; + } + + /// Copies OpenAI usage into the Ollama field names the pipeline / UI already read. + static void MapUsage(JObject raw, JToken usage) + { + if (usage is JObject u && u["prompt_tokens"]?.Value() is int prompt && prompt > 0) + { + raw["prompt_eval_count"] = prompt; + } + } + + async Task<(string reply, JObject raw)> CallOpenRouterChat( + string modelName, + List ollamaMessages, + bool stream, + Func 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() + ?? 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); + } +} diff --git a/AssistentSqliteBootstrap.cs b/AssistentSqliteBootstrap.cs index 5cc9bcc..c9bda7b 100644 --- a/AssistentSqliteBootstrap.cs +++ b/AssistentSqliteBootstrap.cs @@ -35,14 +35,7 @@ internal static class AssistentSqliteBootstrap { return; } - string[] relPaths = - [ - 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) + foreach (string rel in CandidatePaths()) { string path = Path.Combine(extDir, rel); if (!File.Exists(path)) @@ -51,7 +44,8 @@ internal static class AssistentSqliteBootstrap } try { - NativeLibrary.Load(path); + IntPtr handle = NativeLibrary.Load(path); + RegisterResolver(handle); return; } catch (Exception ex) @@ -60,4 +54,45 @@ internal static class AssistentSqliteBootstrap } } } + + /// Native e_sqlite3 locations for the current OS / arch, most specific first. + 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", + ]; + } + + /// The provider's [DllImport("e_sqlite3")] would otherwise probe the host dir, not the extension dir. + 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. + } + } } diff --git a/AssistentVram.cs b/AssistentVram.cs index 6b5f5a2..63cb616 100644 --- a/AssistentVram.cs +++ b/AssistentVram.cs @@ -26,6 +26,10 @@ public partial class SwarmAssistentExtension { return new JObject { ["error"] = "model is required" }; } + if (UseOpenRouter()) + { + return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "remote provider" }; + } if (LooksLikeEmbedModel(name)) { 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" }; } + 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)) { return new JObject { ["success"] = true, ["warmed"] = false, ["skipped"] = "memory model" }; diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index ede3e42..8acde7e 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.16.1"; + Version = "0.17.0"; 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(AssistentGetConfig, false, PermUse); API.RegisterAPICall(AssistentSaveSettings, true, PermUse); + API.RegisterAPICall(AssistentGetProvider, false, PermUse); + API.RegisterAPICall(AssistentSetProvider, true, PermUse); API.RegisterAPICall(AssistentListInventory, false, PermUse); API.RegisterAPICall(AssistentChat, true, PermUse); API.RegisterAPICall(AssistentChatWS, true, PermUse); @@ -81,7 +83,7 @@ public partial class SwarmAssistentExtension : Extension API.RegisterAPICall(AssistentForgetUserPref, true, PermUse); API.RegisterAPICall(AssistentClearUserPrefs, 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) diff --git a/SwarmAssistentExtension.csproj b/SwarmAssistentExtension.csproj index 4989f6c..0b7b001 100644 --- a/SwarmAssistentExtension.csproj +++ b/SwarmAssistentExtension.csproj @@ -29,7 +29,7 @@ Condition="$([System.String]::Copy('%(RuntimeCopyLocalItems.DestinationSubPath)').Contains('e_sqlite3'))" /> Написать промпт обычный - @@ -179,7 +179,26 @@