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,
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user