feat: AI assistant (MVP) - chat button per user, sees the workspace, streams answers, runs suggested commands in the active terminal; Gradio, OpenAI and Anthropic models set in Настройки

This commit is contained in:
redtea committed 2026-10-15 22:10:47 +03:00
1 parent 9572a35ad5
commit 46948f5ddc
9 files changed
+811

No files matched your search

+10
View File
@@ -41,6 +41,8 @@
' <p class="acct-msg"></p>' +
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
' <input type="checkbox" class="sw" role="switch"></label>' +
' <label class="acct-pw acct-ai"><span><b>ИИ-ассистент</b><small class="muted">кнопка чата в правом нижнем углу</small></span>' +
' <input type="checkbox" class="sw" role="switch"></label>' +
' <details class="acct-chpw"><summary>Сменить пароль</summary>' +
' <form class="acct-pwform" autocomplete="on">' +
' <input type="text" name="username" autocomplete="username" hidden>' +
@@ -191,6 +193,14 @@
$('.acct-pindel').onclick = function () {
if (confirm('Убрать PIN-код?')) savePin('');
};
// The assistant: each user's own switch; the chat button follows it.
fetch('/api/panel/ai', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { $('.acct-ai .sw').checked = !!d.on; }).catch(function () {});
$('.acct-ai .sw').onchange = function (ev) {
var on = ev.target.checked;
fetch('/api/panel/ai/toggle', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({on: on})})
.then(function (r) { return r.json(); })
.then(function (r) { if (!r.ok) { ev.target.checked = !on; msg(r.error, true); } else document.dispatchEvent(new CustomEvent('lpra:ai', {detail: on})); });
};
$('.acct-pw .sw').onchange = function (ev) {
const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
+165
View File
@@ -0,0 +1,165 @@
// The assistant: a round button in the bottom right corner opens a chat. Each
// question goes with what the panel shows - the tabs, the active pane, the
// text of its terminal or its list of files - and the answer streams in.
// Commands the model suggests come as ```run``` blocks with «Выполнить»: a
// click sends the command to the active terminal, with the user's rights.
// Off until the user turns it on in the profile.
(function () {
'use strict';
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const fab = document.createElement('button');
fab.type = 'button';
fab.className = 'ai-fab';
fab.hidden = true;
fab.title = 'ИИ-ассистент';
fab.setAttribute('aria-label', 'ИИ-ассистент');
fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/><path d="M8.5 11h.01M12 11h.01M15.5 11h.01"/></svg>';
const box = document.createElement('div');
box.className = 'ai-box';
box.hidden = true;
box.innerHTML =
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' +
'<button type="button" class="btn ghost small icon ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить».</p></div>' +
'<form class="ai-form-in"><textarea rows="2" placeholder="Сообщение… (Enter - отправить, Shift+Enter - строка)"></textarea>' +
'<button type="submit" class="ai-send">Отправить</button></form>';
document.body.append(fab, box);
const q = s => box.querySelector(s);
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
const talk = []; // {role, content}
let ac = null;
function setOn(on) { fab.hidden = !on; if (!on) box.hidden = true; }
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) { where(); input.focus(); } };
q('.ai-close').onclick = () => { box.hidden = true; };
q('.ai-clear').onclick = () => {
if (ac) ac.abort();
talk.length = 0;
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
q('.ai-hello').hidden = false;
};
// ---------- what the panel shows ----------
const W = () => window.Workspace;
function termText(p, lines) {
const b = p.term && p.term.buffer && p.term.buffer.active;
if (!b) return '';
const out = [];
for (let i = Math.max(0, b.length - lines); i < b.length; i++) {
const l = b.getLine(i);
if (l) out.push(l.translateToString(true));
}
while (out.length && !out[out.length - 1].trim()) out.pop();
return out.join('\n');
}
function paneLine(p) {
const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind;
return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')';
}
function context() {
const view = document.querySelector('main#app').dataset.view;
const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)];
const ws = W();
if (!ws) return out.join('\n');
const panes = ws.panes(), act = ws.activePane();
out.push('Панели (' + panes.length + '):');
panes.forEach(p => out.push((p === act ? '* ' : '- ') + paneLine(p)));
if (act && act.term) {
out.push('', 'Активный терминал, последние строки:', '```', termText(act, 120), '```');
} else if (act && act.entries) {
out.push('', 'Активная файловая панель: ' + act.cwd + (act.selected.size ? ' · выбрано: ' + [...act.selected].join(', ') : ''));
out.push(act.entries.slice(0, 200).map(e => (e.is_dir ? e.name + '/' : e.name + ' (' + e.size + ' B)')).join('\n'));
}
return out.join('\n');
}
function where() {
const act = W() && W().activePane();
q('.ai-where').textContent = act ? '· ' + (act.agent ? act.agent.name : '') : '';
}
// ---------- messages ----------
// render: text with ``` blocks; ```run``` ones get «Выполнить».
function render(el, text, done) {
const parts = text.split(/```/);
let html = '';
parts.forEach((part, i) => {
if (i % 2 === 0) { html += '<div class="ai-text">' + esc(part).replace(/\n/g, '<br>') + '</div>'; return; }
const m = /^(\w*)\n?([\s\S]*)$/.exec(part);
const lang = m[1], code = m[2].replace(/\n$/, '');
const closed = i < parts.length - 1 || done;
html += '<div class="ai-code"><pre>' + esc(code) + '</pre>' +
(lang === 'run' && closed && code.trim() ? '<button type="button" class="btn small ai-run" data-cmd="' + esc(code) + '">Выполнить</button>' : '') + '</div>';
});
el.innerHTML = html;
}
function add(role, text) {
q('.ai-hello').hidden = true;
const el = document.createElement('div');
el.className = 'ai-msg ' + role;
if (role === 'user') el.textContent = text; else render(el, text, true);
list.appendChild(el);
list.scrollTop = list.scrollHeight;
return el;
}
list.addEventListener('click', ev => {
const b = ev.target.closest('.ai-run');
if (!b) return;
const ws = W(), act = ws && ws.activePane();
let t = act && act.term ? act : null;
if (!t && ws) t = ws.panes().find(p => p.term && ws.tabOf(p) === ws.tabOf(act)) || null;
if (!t) { FileViews.toast('Откройте терминал: команда выполняется в активном терминале', true); return; }
t.send(b.dataset.cmd.replace(/\n/g, '\r') + '\r');
b.textContent = 'Выполнено в ' + (t.agent ? t.agent.name : 'терминале');
b.disabled = true;
});
async function ask(text) {
talk.push({role: 'user', content: text});
add('user', text);
const el = add('assistant', '');
el.classList.add('wait');
ac = new AbortController();
send.textContent = 'Стоп';
let answer = '';
try {
const r = await fetch('/api/panel/ai/chat', {
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
body: JSON.stringify({messages: talk, context: context()})
});
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
if (!r.ok && !r.body) throw new Error('ошибка ' + r.status);
const rd = r.body.getReader(), dec = new TextDecoder();
for (;;) {
const {done, value} = await rd.read();
if (done) break;
answer += dec.decode(value, {stream: true});
el.classList.remove('wait');
render(el, answer, false);
list.scrollTop = list.scrollHeight;
}
} catch (e) {
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
} finally {
ac = null;
send.textContent = 'Отправить';
el.classList.remove('wait');
render(el, answer || '[нет ответа]', true);
talk.push({role: 'assistant', content: answer});
}
}
q('.ai-form-in').onsubmit = ev => {
ev.preventDefault();
if (ac) { ac.abort(); return; }
const text = input.value.trim();
if (!text) return;
input.value = '';
where();
ask(text);
};
input.addEventListener('keydown', ev => {
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); }
});
})();