Настройка из браузера: вкладка ⚙ вместо правки файла

Инсталлятор спрашивает адрес Ollama в консоли, но это можно пропустить
и задать всё с телефона: адрес, модель для разбора фраз, модель для поиска
по смыслу. «Проверить связь» показывает, отвечает ли Ollama и сколько там
моделей; сами модели выбираются из реально установленных, а не вводятся
руками. Если Ollama молчит, предупреждение наверху ведёт прямо в настройки.

Настройки применяются сразу: модули читают их через config.X, а не забирают
копию при импорте, поэтому перезапуск не нужен.

Из веба правятся только OLLAMA_HOST и две модели — по белому списку, потому
что .env читает приложение при запуске. Адрес проверяется на формат, имена
моделей — на недопустимые символы.

Попутно исправлены два дефекта, найденных тестами:
- .env в песочнице теперь тоже свой; до этого прогон тестов перезаписывал
  рабочий файл настроек;
- значения проверяются все до применения: раньше неверное имя модели
  оставляло уже подменённый адрес Ollama.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:40:15 +03:00
co-authored by Claude Opus 5
parent 17b7d49d2f
commit 4f298ac203
7 changed files with 353 additions and 31 deletions
+113 -10
View File
@@ -42,6 +42,8 @@
nav button { flex:1; padding:11px 4px; font-size:15px; font-weight:600; background:none;
border:none; border-bottom:3px solid transparent; color:var(--dim); cursor:pointer; }
nav button.on { color:var(--fg); border-bottom-color:var(--accent); }
nav button.narrowTab { flex:0 0 48px; font-size:18px; }
code { background:var(--card); padding:1px 5px; border-radius:5px; font-size:14px; }
main { padding:16px; max-width:720px; margin:0 auto; }
.tab { display:none; } .tab.on { display:block; }
@@ -131,6 +133,7 @@
<button class="on" data-tab="do">Действие</button>
<button data-tab="find">Найти</button>
<button data-tab="tree">Обзор</button>
<button data-tab="setup" class="narrowTab"></button>
</nav>
</header>
@@ -213,6 +216,34 @@
<div id="findCards"></div>
</section>
<!-- ================= Настройки ================= -->
<section class="tab" id="tab-setup">
<p class="hint" id="setupIntro">Здесь настраивается связь с Ollama — это «мозги»
помощника. Без неё всё остальное работает, но не будет разбора фраз,
автоматических тегов и поиска по смыслу.</p>
<label>Адрес Ollama</label>
<input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off"
inputmode="url">
<p class="hint">Если Ollama на этом же компьютере — <code>http://localhost:11434</code>.
Если на другой машине — её адрес в локальной сети.</p>
<div class="row mt">
<button class="act ghost" id="cfgProbe">Проверить связь</button>
</div>
<div id="cfgProbeResult"></div>
<label>Модель для разбора фраз</label>
<select id="cfgChat"></select>
<label>Модель для поиска по смыслу</label>
<select id="cfgEmbed"></select>
<p class="hint">Список заполняется моделями, которые реально установлены.
Нужных нет — на машине с Ollama: <code>ollama pull qwen2.5:7b</code>
и <code>ollama pull nomic-embed-text</code>.</p>
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
<p class="hint" id="cfgWhere"></p>
</section>
<!-- ================= Обзор ================= -->
<section class="tab" id="tab-tree">
<button class="act ghost" id="treeBtn">Обновить</button>
@@ -254,21 +285,93 @@ const LAST = {
function say(text) { $('#reply').style.display = 'block'; $('#reply').textContent = text || ''; }
// ---------- вкладки ----------
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.id === 'tab-' + b.dataset.tab));
if (b.dataset.tab === 'tree') loadTree();
});
function openTab(name) {
document.querySelectorAll('nav button').forEach(x =>
x.classList.toggle('on', x.dataset.tab === name));
document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('on', t.id === 'tab-' + name));
if (name === 'tree') loadTree();
if (name === 'setup') loadConfig();
window.scrollTo({top: 0});
}
document.querySelectorAll('nav button').forEach(b => b.onclick = () => openTab(b.dataset.tab));
// ---------- баннер ----------
api('/api/health').then(h => {
if (!h.ollama) {
$('#banner').style.display = 'block';
$('#banner').textContent = 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; '
+ 'не будет только синонимов и разбора свободных фраз.';
}
if (h.ollama) return;
const b = $('#banner');
b.style.display = 'block';
b.textContent = h.fresh
? 'Похоже, помощник только установлен, а Ollama по адресу ' + h.host + ' не отвечает. '
: 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; не будет только '
+ 'тегов и разбора свободных фраз. ';
const link = document.createElement('a');
link.href = '#';
link.textContent = 'Настроить →';
link.style.color = 'inherit';
link.style.fontWeight = '700';
link.onclick = e => { e.preventDefault(); openTab('setup'); };
b.appendChild(link);
}).catch(() => {});
// ---------- настройки ----------
async function loadConfig() {
const d = await api('/api/config');
$('#cfgHost').value = d.ollama_host || '';
fillModels(d.models || [], d.chat_model, d.embed_model);
$('#cfgWhere').textContent = 'Настройки хранятся в файле ' + d.env_file
+ ' — он не попадает в репозиторий.';
if (!d.available) {
$('#cfgProbeResult').innerHTML =
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
}
}
// Модель выбирается из установленных; текущее значение показываем даже если
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
function fillModels(models, chat, embed) {
const opts = extra => ['<option value="">— не задана —</option>']
.concat((models.length ? models : []).map(m =>
'<option value="' + esc(m) + '">' + esc(m) + '</option>'))
.concat(extra && !models.includes(extra)
? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : [])
.join('');
$('#cfgChat').innerHTML = opts(chat);
$('#cfgEmbed').innerHTML = opts(embed);
$('#cfgChat').value = chat || '';
$('#cfgEmbed').value = embed || '';
}
$('#cfgProbe').onclick = async () => {
const btn = $('#cfgProbe');
btn.disabled = true; btn.textContent = 'Проверяю…';
try {
const d = await api('/api/config/probe', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim()})});
$('#cfgProbeResult').innerHTML = '<div class="card"><div class="addr'
+ (d.ok ? '' : ' taken') + '">' + esc(d.reply || '') + '</div></div>';
if (d.ok) fillModels(d.models || [], $('#cfgChat').value, $('#cfgEmbed').value);
} finally { btn.disabled = false; btn.textContent = 'Проверить связь'; }
};
$('#cfgSave').onclick = async () => {
const btn = $('#cfgSave');
btn.disabled = true; btn.textContent = 'Сохраняю…';
try {
const d = await api('/api/config', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(),
chat_model: $('#cfgChat').value,
embed_model: $('#cfgEmbed').value})});
say(d.reply);
if (d.ok) {
$('#banner').style.display = d.available ? 'none' : 'block';
loadConfig();
}
} finally { btn.disabled = false; btn.textContent = 'Сохранить'; }
};
// ---------- режимы ----------
const LABEL = {put:'Положить', take:'Забрать', move:'Переместить', delete:'Удалить'};
document.querySelectorAll('.modes button').forEach(b => b.onclick = () => {