-
Ollama и модель эмбеддингов для крафт-памяти.
+
Провайдер чата и модель эмбеддингов для крафт-памяти. Память всегда считается через Ollama.
+
+
diff --git a/src/app.js b/src/app.js
index 334bc91..aff4391 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();