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:
co-authored by
Claude Opus 5
parent
f2b92e96ca
commit
d67f5e396e
+139
-16
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
|
||||
+9
-3
@@ -21,6 +21,11 @@ public partial class SwarmAssistentExtension
|
||||
public async Task<JObject> AssistentListModels(Session session, string baseUrl)
|
||||
{
|
||||
string root = NormalizeBaseUrl(baseUrl);
|
||||
return UseOpenRouter() ? await ListOpenRouterModels(root) : await ListOllamaModels(root);
|
||||
}
|
||||
|
||||
async Task<JObject> 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
Condition="$([System.String]::Copy('%(RuntimeCopyLocalItems.DestinationSubPath)').Contains('e_sqlite3'))" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_SqliteNative)"
|
||||
DestinationFiles="$(OutputPath)@(_SqliteNative->'%(RuntimeCopyLocalItems.DestinationSubPath)')"
|
||||
DestinationFiles="@(_SqliteNative->'$(OutputPath)%(DestinationSubPath)')"
|
||||
SkipUnchangedFiles="true"
|
||||
Condition="'@(_SqliteNative)' != ''" />
|
||||
<Copy SourceFiles="@(_SqliteNative)"
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<option value="write_prompt">Написать промпт</option>
|
||||
</select>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
@@ -179,7 +179,26 @@
|
||||
<div class="sa-skills-box" id="sa_skills_box"></div>
|
||||
</div>
|
||||
<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>Модель чата
|
||||
<select id="sa_settings_chat_model" class="sa-select" title="Синхрон с шапкой">
|
||||
@@ -194,7 +213,7 @@
|
||||
<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_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 class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div>
|
||||
</div>
|
||||
|
||||
+159
-16
@@ -132,6 +132,12 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
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,
|
||||
@@ -2066,7 +2072,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
const model = $('sa_model')?.value;
|
||||
if (!model) {
|
||||
setStatus('Выбери модель Ollama в ⚙');
|
||||
setStatus('Выбери модель чата в ⚙');
|
||||
return false;
|
||||
}
|
||||
const { prompt, foldCount } = buildCompressUserPrompt();
|
||||
@@ -2240,7 +2246,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
const model = $('sa_model')?.value;
|
||||
if (!model) {
|
||||
setStatus('Выбери модель Ollama в ⚙');
|
||||
setStatus('Выбери модель чата в ⚙');
|
||||
return;
|
||||
}
|
||||
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_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
|
||||
localStorage.setItem(LS_PARK_LLM, $('sa_park_llm')?.checked ? '1' : '0');
|
||||
persistOpenRouterModel();
|
||||
persistServerSettings();
|
||||
saveUiStateToDisk();
|
||||
}
|
||||
@@ -6966,25 +6973,54 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
if (!list.length) {
|
||||
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)
|
||||
? apiPreferred
|
||||
: pickSeniorChatModel(list);
|
||||
const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
|
||||
if (ls && list.includes(ls)) {
|
||||
return ls;
|
||||
}
|
||||
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 } = {}) {
|
||||
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');
|
||||
@@ -6998,14 +7034,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
if (!names.length) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'No Ollama models — pull / Refresh';
|
||||
opt.textContent = isOpenRouter() ? 'Нет моделей OpenRouter — проверь ключ / фильтр' : 'No Ollama models — 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} 👁` : name;
|
||||
target.appendChild(opt);
|
||||
}
|
||||
const pick = resolveChatModel(names, preferred);
|
||||
@@ -7061,10 +7097,17 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
'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')) {
|
||||
@@ -7075,25 +7118,105 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
state.preferredModel = 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) {
|
||||
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 {
|
||||
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
|
||||
setOllamaHealth('warn', `${label} · 0 моделей`, isOpenRouter() ? 'Нет моделей — проверь ключ' : 'Нет чат-моделей — сделай 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 ✕', msg);
|
||||
setOllamaHealth('down', `${providerLabel()} ✕`, 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 = {}) {
|
||||
if (typeof genericRequest !== 'function') {
|
||||
if (done) {
|
||||
@@ -7922,19 +8045,19 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
{ baseUrl },
|
||||
(data) => {
|
||||
if (data?.error) {
|
||||
setOllamaHealth('down', 'Ollama ✕', String(data.error));
|
||||
setOllamaHealth('down', `${providerLabel()} ✕`, String(data.error));
|
||||
return;
|
||||
}
|
||||
const chat = (data.models || []).length;
|
||||
const mem = (data.memory_models || []).length;
|
||||
if (!chat) {
|
||||
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
|
||||
setOllamaHealth('warn', `${providerLabel()} · 0 моделей`, isOpenRouter() ? 'Нет моделей — проверь ключ' : 'Нет чат-моделей — сделай ollama pull');
|
||||
return;
|
||||
}
|
||||
setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
|
||||
setOllamaHealth('ok', `${providerLabel()} · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${data.base_url || baseUrl}`);
|
||||
},
|
||||
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 model = $('sa_model')?.value;
|
||||
if (!model) {
|
||||
setStatus('Выбери модель Ollama в ⚙');
|
||||
setStatus('Выбери модель чата в ⚙');
|
||||
refreshModels();
|
||||
return;
|
||||
}
|
||||
@@ -9594,6 +9717,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
} catch (e) {
|
||||
console.warn('Assistent: chat sessions failed', e);
|
||||
}
|
||||
await loadProviderState();
|
||||
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
|
||||
refreshModels();
|
||||
refreshInventory(() => {
|
||||
@@ -9830,6 +9954,25 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
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('Вставь ключ 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();
|
||||
|
||||
Reference in New Issue
Block a user