898 lines
54 KiB
JavaScript
898 lines
54 KiB
JavaScript
// 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.
|
||
// On by default; the user can turn it off in the profile.
|
||
(function () {
|
||
'use strict';
|
||
const PLANE = '<svg class="ai-plane" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.4 20.85 12.9a1 1 0 0 0 0-1.8L3.4 3.6a1 1 0 0 0-1.39 1.17L4 11l9 1-9 1-1.99 6.23a1 1 0 0 0 1.39 1.17z"/></svg>';
|
||
const STOP = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
|
||
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2.5c.3 0 .5.2.6.5l1 3.7a5 5 0 0 0 3.6 3.6l3.7 1c.6.2.6 1 0 1.2l-3.7 1a5 5 0 0 0-3.6 3.6l-1 3.7c-.2.6-1 .6-1.2 0l-1-3.7a5 5 0 0 0-3.6-3.6l-3.7-1c-.6-.2-.6-1 0-1.2l3.7-1a5 5 0 0 0 3.6-3.6l1-3.7c.1-.3.3-.5.6-.5z"/><path d="M18.5 1.5c.2 0 .3.1.4.3l.4 1.4c.2.6.7 1.1 1.3 1.3l1.4.4c.4.1.4.6 0 .8l-1.4.4c-.6.2-1.1.7-1.3 1.3l-.4 1.4c-.1.4-.7.4-.8 0l-.4-1.4a2 2 0 0 0-1.3-1.3l-1.4-.4c-.4-.2-.4-.7 0-.8l1.4-.4c.6-.2 1.1-.7 1.3-1.3l.4-1.4c.1-.2.2-.3.4-.3z" opacity=".75"/></svg>';
|
||
const box = document.createElement('div');
|
||
box.className = 'ai-box';
|
||
box.innerHTML =
|
||
'<div class="ai-head"><button type="button" class="ai-chatsel" aria-expanded="false" title="Чаты"><span class="ai-chatname">Новый чат</span>' +
|
||
'<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></button>' +
|
||
'<span class="muted ai-where"></span><span class="spacer"></span>' +
|
||
'<button type="button" class="btn ghost small icon close x 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-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></div>' +
|
||
'<div class="ai-chat-body"><div class="ai-list"><div class="ai-hello"><span class="ai-hello-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg></span><span class="ai-hello-text">Спросите AI помощника</span></div></div>' +
|
||
'<button type="button" class="ai-jump" tabindex="-1" title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button>' +
|
||
'<div class="ai-file-error" role="alert"></div></div>' +
|
||
'<div class="ai-pills">' +
|
||
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
|
||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
|
||
'<button type="button" class="ai-pill" data-mode="search" aria-pressed="false" title="Поиск в интернете (DuckDuckGo): ассистент ищет сам, когда нужно">' +
|
||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
|
||
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
|
||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
|
||
// The model, when there is a choice: its name; a click opens the list.
|
||
'<div class="ai-mpick" hidden><button type="button" class="ai-mbtn" aria-haspopup="listbox" aria-expanded="false" title="Модель">' +
|
||
'<span class="ai-mname"></span><svg class="ai-mchev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>' +
|
||
'<div class="ai-mmenu" role="listbox" aria-label="Модель"></div></div>' +
|
||
'</div>' +
|
||
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.pdf,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
|
||
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
|
||
'<button type="button" class="ai-attach" title="Прикрепить файл" aria-label="Прикрепить файл"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-8.9 8.9a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg></button>' +
|
||
'<textarea rows="1" placeholder="Сообщение…"></textarea>' +
|
||
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></div></div></form>';
|
||
document.body.append(fab, box);
|
||
const q = s => box.querySelector(s);
|
||
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'), jump = q('.ai-jump');
|
||
const attachInput = q('.ai-file-input'), attachList = q('.ai-attachments'), fileErr = q('.ai-file-error');
|
||
let attachments = [];
|
||
const importingPaneFiles = new Set();
|
||
const nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80;
|
||
function updateJump() { showJump(!nearBottom() && isOpen()); }
|
||
// The arrow down fades in and out, midway between the right edge the
|
||
// answers reach (their max-width, 92% of the list) and the chat's border.
|
||
function showJump(on) {
|
||
if (on && !jump.classList.contains('show')) {
|
||
const body = list.parentNode.getBoundingClientRect(), l = list.getBoundingClientRect(), cs = getComputedStyle(list);
|
||
const pl = parseFloat(cs.paddingLeft), inner = list.clientWidth - pl - parseFloat(cs.paddingRight);
|
||
const gap = body.right - (l.left + list.clientLeft + pl + inner * 0.92);
|
||
jump.style.right = Math.max(2, (gap - jump.offsetWidth) / 2) + 'px';
|
||
}
|
||
jump.classList.toggle('show', on);
|
||
jump.tabIndex = on ? 0 : -1;
|
||
}
|
||
list.addEventListener('scroll', updateJump, {passive: true});
|
||
jump.onclick = () => { list.scrollTo({top: list.scrollHeight, behavior: 'smooth'}); };
|
||
function scrollIfFollowing(follow) { if (follow) list.scrollTop = list.scrollHeight; updateJump(); }
|
||
// The talk lives on the server (the database); here only what is shown.
|
||
let loaded = false;
|
||
let ac = null;
|
||
|
||
// Open and shut with a short slide. On a phone the chat takes the screen
|
||
// and is one step of history: «назад» shuts it.
|
||
const phone = () => matchMedia('(max-width: 640px)').matches;
|
||
const isOpen = () => box.classList.contains('on');
|
||
function show() {
|
||
loadTalk();
|
||
loadState();
|
||
placeBox();
|
||
box.classList.add('on');
|
||
fab.classList.add('away');
|
||
requestAnimationFrame(updateJump);
|
||
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
||
where();
|
||
if (!phone()) input.focus();
|
||
}
|
||
function hide() {
|
||
if (!isOpen()) return;
|
||
if (history.state && history.state.aiChat) { history.back(); return; }
|
||
shut();
|
||
}
|
||
// shut: the chat goes, and the ball comes back in its corner - where the
|
||
// chat was last moved to.
|
||
function shut() {
|
||
if (desk && !phone()) {
|
||
const b = box.getBoundingClientRect(), o = box.style.transformOrigin || 'bottom right';
|
||
const right = /right/.test(o) ? innerWidth - b.right : innerWidth - b.left - fab.offsetWidth;
|
||
const bottom = /bottom/.test(o) ? innerHeight - b.bottom : innerHeight - b.top - fab.offsetHeight;
|
||
const pos = placeFab(right, bottom);
|
||
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
|
||
}
|
||
box.classList.remove('on');
|
||
fab.classList.remove('away');
|
||
}
|
||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
|
||
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
||
const desk = !matchMedia('(pointer: coarse)').matches;
|
||
// The models to choose from come with the state, again on every opening
|
||
// (an admin may have changed them).
|
||
let models = [];
|
||
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
|
||
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
|
||
loadState();
|
||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||
|
||
// ---------- on a computer: the ball and the chat move ----------
|
||
// The ball goes where it is dragged and stays there (kept in this
|
||
// browser, as distances from the right and bottom edges); the chat opens
|
||
// beside it, toward the room, and moves by its title.
|
||
let moved = false;
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
function placeFab(right, bottom) {
|
||
right = clamp(right, 8, innerWidth - fab.offsetWidth - 8);
|
||
bottom = clamp(bottom, 8, innerHeight - fab.offsetHeight - 8);
|
||
fab.style.right = right + 'px'; fab.style.bottom = bottom + 'px';
|
||
return {right, bottom};
|
||
}
|
||
function placeBox() {
|
||
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
|
||
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
|
||
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
|
||
// The chat takes the ball's place, grown from its corner.
|
||
const x = toLeft ? f.right - w : f.left;
|
||
const y = up ? f.bottom - h : f.top;
|
||
box.style.right = box.style.bottom = 'auto';
|
||
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
|
||
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
|
||
box.style.transformOrigin = (up ? 'bottom ' : 'top ') + (toLeft ? 'right' : 'left');
|
||
}
|
||
if (desk) {
|
||
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) requestAnimationFrame(() => placeFab(p.right, p.bottom)); } catch (e) { /* default */ }
|
||
const drag = (el, ev, onMove, onEnd) => {
|
||
if (ev.button !== 0) return;
|
||
const x0 = ev.clientX, y0 = ev.clientY;
|
||
let on = false;
|
||
const mv = e => {
|
||
if (!on && Math.hypot(e.clientX - x0, e.clientY - y0) < 5) return;
|
||
on = true;
|
||
onMove(e.clientX - x0, e.clientY - y0);
|
||
};
|
||
const up = () => {
|
||
removeEventListener('pointermove', mv); removeEventListener('pointerup', up);
|
||
document.body.classList.remove('ai-dragging');
|
||
if (on) onEnd();
|
||
};
|
||
addEventListener('pointermove', mv); addEventListener('pointerup', up);
|
||
document.body.classList.add('ai-dragging');
|
||
};
|
||
fab.addEventListener('pointerdown', ev => {
|
||
const r0 = innerWidth - fab.getBoundingClientRect().right, b0 = innerHeight - fab.getBoundingClientRect().bottom;
|
||
let pos = {right: r0, bottom: b0};
|
||
drag(fab, ev, (dx, dy) => { moved = true; pos = placeFab(r0 - dx, b0 - dy); if (isOpen()) placeBox(); }, () => {
|
||
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
|
||
});
|
||
});
|
||
q('.ai-head').addEventListener('pointerdown', ev => {
|
||
if (ev.target.closest('button')) return;
|
||
const r = box.getBoundingClientRect();
|
||
drag(box, ev, (dx, dy) => {
|
||
box.style.left = clamp(r.left + dx, 8, innerWidth - r.width - 8) + 'px';
|
||
box.style.top = clamp(r.top + dy, 8, innerHeight - r.height - 8) + 'px';
|
||
}, () => {});
|
||
});
|
||
addEventListener('resize', () => {
|
||
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) placeFab(p.right, p.bottom); } catch (e) { /* default */ }
|
||
if (isOpen()) placeBox();
|
||
});
|
||
}
|
||
q('.ai-close').onclick = hide;
|
||
// ---------- chats ----------
|
||
// Several talks, kept on the server. The header names the current one;
|
||
// its arrow opens the list: a click opens a chat, the bin deletes it.
|
||
let chatId = 0;
|
||
try { chatId = Number(localStorage.getItem('lpra.ai.chat')) || 0; } catch (e) { /* none */ }
|
||
const TRASH = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>';
|
||
const chatsEl = q('.ai-chats'), chatSel = q('.ai-chatsel');
|
||
let chats = [];
|
||
function setChat(id) {
|
||
chatId = id || 0;
|
||
try { localStorage.setItem('lpra.ai.chat', String(chatId)); } catch (e) { /* not kept */ }
|
||
const c = chats.find(x => x.id === chatId);
|
||
q('.ai-chatname').textContent = c ? c.title : 'Новый чат';
|
||
q('.ai-chatname').title = c ? c.title : '';
|
||
}
|
||
async function loadChats() {
|
||
try { chats = await fetch('/api/panel/ai/chats', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { chats = []; }
|
||
if (chatId && !chats.some(c => c.id === chatId)) chatId = 0;
|
||
setChat(chatId);
|
||
drawChats();
|
||
}
|
||
function drawChats() {
|
||
const box = q('.ai-chatlist');
|
||
box.innerHTML = '';
|
||
if (!chats.length) box.innerHTML = '<div class="muted ai-chatnone">Пока нет чатов</div>';
|
||
chats.forEach(c => {
|
||
const row = document.createElement('div');
|
||
row.className = 'ai-chatrow' + (c.id === chatId ? ' on' : '');
|
||
row.innerHTML = '<button type="button" class="ai-chatopen"></button><button type="button" class="ai-chatdel" title="Удалить чат" aria-label="Удалить чат">' + TRASH + '</button>';
|
||
row.querySelector('.ai-chatopen').textContent = c.title;
|
||
row.querySelector('.ai-chatopen').title = c.title;
|
||
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
|
||
row.querySelector('.ai-chatdel').onclick = async ev => {
|
||
ev.stopPropagation();
|
||
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
|
||
if (c.id === chatId) openChat(0);
|
||
loadChats();
|
||
};
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
function toggleChats(on) {
|
||
on = on == null ? chatSel.getAttribute('aria-expanded') !== 'true' : on;
|
||
chatSel.setAttribute('aria-expanded', String(on));
|
||
chatsEl.classList.toggle('open', on);
|
||
chatsEl.inert = !on;
|
||
if (on) loadChats();
|
||
}
|
||
chatSel.onclick = () => toggleChats();
|
||
q('.ai-chatnew').onclick = () => { openChat(0); toggleChats(false); input.focus(); };
|
||
document.addEventListener('pointerdown', ev => { if (chatsEl.classList.contains('open') && !chatsEl.contains(ev.target) && !chatSel.contains(ev.target)) toggleChats(false); }, true);
|
||
// openChat shows a chat's talk (0: a new, empty one).
|
||
async function openChat(id) {
|
||
if (ac) ac.abort();
|
||
setChat(id);
|
||
drawChats();
|
||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||
list.scrollTop = 0;
|
||
showJump(false);
|
||
hideFileError();
|
||
q('.ai-hello').hidden = !!id;
|
||
if (!id) { updateJump(); return; }
|
||
try {
|
||
const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
|
||
if (chatId !== id) return;
|
||
h.forEach(m => {
|
||
if (m.role === 'result') showResult(m.content);
|
||
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
||
});
|
||
if (!h.length) q('.ai-hello').hidden = false;
|
||
list.scrollTop = list.scrollHeight;
|
||
updateJump();
|
||
} catch (e) { /* stays empty */ }
|
||
}
|
||
// The chat last open, when the window first opens.
|
||
async function loadTalk() {
|
||
if (loaded) return;
|
||
loaded = true;
|
||
await loadChats();
|
||
openChat(chatId);
|
||
}
|
||
|
||
// ---------- 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)];
|
||
out.push('Машины пользователя: ' + machines.map(m => m.name + ' (' + m.user + '@' + m.hostname + (m.online ? '' : ', офлайн') + ')').join('; '));
|
||
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();
|
||
const name = act && act.agent ? act.agent.name : '';
|
||
q('.ai-where').textContent = name;
|
||
q('.ai-where').title = name;
|
||
}
|
||
|
||
// ---------- machines ----------
|
||
let machines = [];
|
||
async function loadMachines() {
|
||
try { machines = await fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { /* keep */ }
|
||
return machines;
|
||
}
|
||
// machineOf: the machine an action names, else the one of the active pane.
|
||
function machineOf(name) {
|
||
const n = (name || '').trim().toLowerCase();
|
||
if (n) return machines.find(m => m.name.toLowerCase() === n || m.id === n || m.hostname.toLowerCase() === n) || null;
|
||
const act = W() && W().activePane();
|
||
if (act && act.agent) return machines.find(m => m.id === act.agent.id) || {id: act.agent.id, name: act.agent.name, online: true};
|
||
return machines.length === 1 ? machines[0] : null;
|
||
}
|
||
|
||
// ---------- actions ----------
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
const lineAt = (b, i) => { const l = b.getLine(i); return l ? l.translateToString(true) : ''; };
|
||
// terminalFor: a terminal of machine m - the active one if it is, else any
|
||
// open one of it, else a new tab.
|
||
async function terminalFor(m) {
|
||
const ws = W(), act = ws.activePane();
|
||
if (act && act.term && !act.ssh && act.agent.id === m.id) return act;
|
||
let t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
|
||
if (!t) {
|
||
if (document.querySelector('main#app').dataset.view !== 'terminal') App.go('/workspace');
|
||
ws.open('term', {id: m.id, name: m.name, host: m.hostname, user: m.user});
|
||
t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
|
||
for (let i = 0; i < 40 && t && t.state !== 'ok'; i++) await sleep(250);
|
||
await sleep(600);
|
||
}
|
||
return t;
|
||
}
|
||
// runIn sends cmd to terminal t and gives what it printed: until the
|
||
// output has been still for 1.5 s (up to 2 minutes).
|
||
async function runIn(t, cmd, signal) {
|
||
const b = t.term.buffer.active;
|
||
const start = b.baseY + b.cursorY;
|
||
t.send(cmd.replace(/\n/g, '\r') + '\r');
|
||
let last = '', still = 0;
|
||
for (let ms = 0; ms < 120000 && !(signal && signal.aborted); ms += 300) {
|
||
await sleep(300);
|
||
const end = b.baseY + b.cursorY;
|
||
const now = end + ':' + lineAt(b, end);
|
||
if (now === last) { if ((still += 300) >= 1500) break; } else { still = 0; last = now; }
|
||
}
|
||
const lines = [];
|
||
for (let i = start; i <= b.baseY + b.cursorY; i++) lines.push(lineAt(b, i));
|
||
let out = lines.join('\n').replace(/\s+$/, '');
|
||
if (out.length > 8000) out = '…' + out.slice(-8000);
|
||
return out;
|
||
}
|
||
const b64 = text => { const u = new TextEncoder().encode(text); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); };
|
||
// perform does one action with the user's rights and tells what came of it.
|
||
async function perform(a, signal) {
|
||
// A page by its address: its text, through the server, with «Поиск» on.
|
||
if (a.kind === 'fetch') {
|
||
if (!searchOn()) return 'Ошибка: чтение страниц выключено - включите плашку «Поиск»';
|
||
const r = await fetch('/api/panel/ai/fetch', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
|
||
body: JSON.stringify({url: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
|
||
if (!r.ok) return 'Ошибка чтения ' + a.body + ': ' + r.error;
|
||
const p = r.data;
|
||
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
|
||
}
|
||
// A web search: through the server, only with «Поиск» on.
|
||
if (a.kind === 'search') {
|
||
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
|
||
const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
|
||
body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
|
||
if (!r.ok) return 'Ошибка поиска «' + a.body + '»' + (r.engine ? ' (' + r.engine + ')' : '') + ': ' + r.error;
|
||
return 'Результаты поиска «' + a.body + '» через ' + (r.engine || 'поисковик') + ':\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n');
|
||
}
|
||
await loadMachines();
|
||
const m = machineOf(a.machine);
|
||
if (!m) return 'Ошибка: нет машины «' + (a.machine || '?') + '». Машины: ' + machines.map(x => x.name).join(', ');
|
||
if (m.online === false) return 'Ошибка: машина ' + m.name + ' офлайн';
|
||
const src = new MachineSource({id: m.id, name: m.name});
|
||
if (a.kind === 'run') {
|
||
const t = await terminalFor(m);
|
||
if (!t) return 'Ошибка: терминал на ' + m.name + ' не открылся';
|
||
return 'Вывод на ' + m.name + ':\n```\n' + await runIn(t, a.body, signal) + '\n```';
|
||
}
|
||
if (a.kind === 'read') {
|
||
const r = await src.op('read', {path: a.path});
|
||
if (!r.ok) return 'Ошибка чтения ' + m.name + ':' + a.path + ': ' + r.error;
|
||
if (r.data.binary) return m.name + ':' + a.path + ' - двоичный файл, ' + r.data.size + ' B';
|
||
let text = new TextDecoder().decode(FileViews.b64ToBytes(r.data.content));
|
||
if (text.length > 20000) text = text.slice(0, 20000) + '\n…';
|
||
return 'Файл ' + m.name + ':' + a.path + (r.data.truncated ? ' (начало)' : '') + ':\n```\n' + text + '\n```';
|
||
}
|
||
if (a.kind === 'ls') {
|
||
const r = await src.op('list', {path: a.path || '~'});
|
||
if (!r.ok) return 'Ошибка: ' + r.error;
|
||
return 'Папка ' + m.name + ':' + r.data.path + ':\n' + (r.data.entries || []).slice(0, 300)
|
||
.map(e => (e.is_dir ? e.name + '/' : e.name + ' ' + e.size + ' B')).join('\n');
|
||
}
|
||
if (a.kind === 'write') {
|
||
const r = await src.op('write', {path: a.path, data: b64(a.body + '\n')});
|
||
return r.ok ? 'Записано: ' + m.name + ':' + a.path : 'Ошибка записи: ' + r.error;
|
||
}
|
||
return 'Ошибка: неизвестное действие';
|
||
}
|
||
// ask first for what changes something: commands and writes.
|
||
function needsYes(a) { return a.kind === 'run' || a.kind === 'write'; }
|
||
// ask what a command or a write is about to do: what, where, the command
|
||
// (or the start of the text) in mono.
|
||
function confirmAction(a) {
|
||
const m = machineOf(a.machine), where = m ? m.name : (a.machine || 'активная машина');
|
||
if (a.kind === 'run') return UI.confirm('Выполнить команду на {chip}?', {ok: 'Выполнить', chip: where, code: a.body});
|
||
const lines = a.body.split('\n');
|
||
return UI.confirm('Записать файл {chip}?', {ok: 'Записать', chip: where + ':' + a.path,
|
||
code: lines.slice(0, 12).join('\n') + (lines.length > 12 ? '\n…' : ''),
|
||
note: 'Файл будет заменён целиком (' + a.body.length + ' символов).'});
|
||
}
|
||
|
||
// ---------- messages ----------
|
||
// stripThink: the answer without its thinking - what is acted on and kept.
|
||
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
|
||
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
|
||
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
|
||
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
|
||
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
|
||
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
|
||
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')};
|
||
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
|
||
// render: text with ``` blocks; action blocks get their button.
|
||
// render draws an answer: its thinking, folded, above; the text with
|
||
// Markdown; ``` blocks apart, action blocks with their button. The
|
||
// thinking block is made once and only its words change, so it stays open
|
||
// or shut as the user left it while the answer streams.
|
||
// The arrow of the + menu's groups: right shut, down open.
|
||
const CHEV = '<span class="chev">›</span>';
|
||
function render(el, text, done) {
|
||
let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
|
||
if (!body) {
|
||
el.innerHTML = '<div class="ai-think" hidden><button type="button" class="ai-think-h">' + CHEV + '<span class="lbl"></span></button>' +
|
||
'<div class="ai-think-b"><div class="ai-think-in"></div></div></div><div class="ai-body"></div>';
|
||
think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body');
|
||
think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open');
|
||
}
|
||
const th = /^\s*(?:<think>|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text);
|
||
if (th) {
|
||
const going = !th[2] && !done;
|
||
if (!el._t0) el._t0 = Date.now();
|
||
if (!going && !el._t1) el._t1 = Date.now();
|
||
think.hidden = false;
|
||
think.classList.toggle('going', going);
|
||
// How long: timed here as it streams, or kept with the answer.
|
||
const ms = el._thinkMs || (el._t1 - el._t0);
|
||
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' сек.';
|
||
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
|
||
text = text.slice(th[0].length);
|
||
}
|
||
let html = '', n = 0;
|
||
const parts = text.split(/```/);
|
||
parts.forEach((part, i) => {
|
||
if (i % 2 === 0) { if (part.trim()) html += '<div class="ai-text">' + WSUtil.md(part) + '</div>'; return; }
|
||
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
|
||
const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
|
||
const closed = i < parts.length - 1 || done;
|
||
const act = LABEL[kind] && closed;
|
||
// An action: a card with what and where on its title line, a small
|
||
// button on the right of it, the command or text below.
|
||
if (act) {
|
||
const where = head.slice(kind.length).trim();
|
||
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
|
||
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
|
||
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
|
||
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
|
||
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
|
||
});
|
||
body.innerHTML = html;
|
||
el.dataset.text = text;
|
||
}
|
||
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
|
||
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
|
||
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
|
||
const MEDIA_MIME = {png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',gif:'image/gif',webp:'image/webp',bmp:'image/bmp',tif:'image/tiff',tiff:'image/tiff',mp3:'audio/mpeg',wav:'audio/wav',m4a:'audio/mp4',aac:'audio/aac',ogg:'audio/ogg',oga:'audio/ogg',flac:'audio/flac',opus:'audio/ogg',mp4:'video/mp4',m4v:'video/mp4',webm:'video/webm',mov:'video/quicktime',ogv:'video/ogg',mkv:'video/x-matroska',avi:'video/x-msvideo','3gp':'video/3gpp'};
|
||
// attachable: a file the model can take, by its name alone.
|
||
const attachable = name => { const ext = fileExt(name); return officeExt.test(ext) || textExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || !!MEDIA_MIME[ext]; };
|
||
const MAX_FILE = 8 * 1024 * 1024, MAX_ATTACHMENTS = 6, MAX_TOTAL = 16 * 1024 * 1024;
|
||
function bytesToBase64(buf) {
|
||
const bytes = new Uint8Array(buf); let bin = '';
|
||
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||
return btoa(bin);
|
||
}
|
||
function previewText(html) {
|
||
return new DOMParser().parseFromString(html, 'text/html').body.textContent.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
function renderAttachments() {
|
||
attachList.innerHTML = '';
|
||
attachList.hidden = !attachments.length;
|
||
sendState();
|
||
attachments.forEach((a, i) => {
|
||
const chip = document.createElement('span'); chip.className = 'ai-file-chip';
|
||
const name = document.createElement('span'); name.textContent = a.name; name.title = a.name;
|
||
const remove = document.createElement('button'); remove.type = 'button'; remove.title = 'Убрать вложение'; remove.setAttribute('aria-label', 'Убрать ' + a.name); remove.textContent = '×';
|
||
remove.onclick = () => { attachments.splice(i, 1); renderAttachments(); };
|
||
chip.append(name, remove); attachList.appendChild(chip);
|
||
});
|
||
}
|
||
// A file that could not be attached: a red note over the field, like an
|
||
// error toast, fading in and out.
|
||
let fileErrTimer = 0;
|
||
function setFileError(message) {
|
||
fileErr.textContent = message;
|
||
fileErr.classList.add('show');
|
||
clearTimeout(fileErrTimer);
|
||
fileErrTimer = setTimeout(hideFileError, 5000);
|
||
}
|
||
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
|
||
async function makeAttachments(file, name = file.name) {
|
||
const ext = fileExt(name), type = (file.type || '').toLowerCase();
|
||
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
|
||
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
|
||
const buf = await file.arrayBuffer(); let text;
|
||
if (ext === 'docx') text = previewText(await DocView.docx(buf));
|
||
else if (ext === 'pdf') text = await DocView.pdf(buf);
|
||
else if (ext === 'xlsx') text = previewText(await DocView.xlsx(buf));
|
||
else if (ext === 'ods') text = previewText(await DocView.ods(buf));
|
||
else if (ext === 'odt') text = previewText(await DocView.odt(buf));
|
||
else if (ext === 'doc') text = previewText(DocView.doc(buf));
|
||
else if (ext === 'csv' || ext === 'tsv') text = previewText(DocView.csv(new TextDecoder().decode(buf), ext === 'tsv'));
|
||
else text = new TextDecoder().decode(buf);
|
||
if (!text) throw new Error(name + ': текст не найден');
|
||
return [{name, mime: 'text/plain', text: text.slice(0, 120000), size: file.size}];
|
||
}
|
||
const fallback = MEDIA_MIME[ext] || '';
|
||
const mime = !type || type === 'application/octet-stream' ? fallback : type;
|
||
if (!/^image\//.test(mime) && !/^audio\//.test(mime) && !/^video\//.test(mime)) throw new Error(name + ': поддерживаются изображения, аудио, видео, текст и документы Word/Excel');
|
||
return [{name, mime, data: bytesToBase64(await file.arrayBuffer()), size: file.size}];
|
||
}
|
||
async function addFiles(files, sourceIdentities = []) {
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i], sourceIdentity = sourceIdentities[i];
|
||
if (attachments.length >= MAX_ATTACHMENTS) { setFileError('Можно прикрепить не больше ' + MAX_ATTACHMENTS + ' файлов или кадров.'); break; }
|
||
try {
|
||
const ready = await makeAttachments(file);
|
||
const total = attachments.reduce((n, a) => n + (a.size || 0), 0) + ready.reduce((n, a) => n + (a.size || 0), 0);
|
||
if (attachments.length + ready.length > MAX_ATTACHMENTS) throw new Error('Слишком много вложений (максимум ' + MAX_ATTACHMENTS + ').');
|
||
if (total > MAX_TOTAL) throw new Error('Общий размер вложений не должен превышать 16 МиБ.');
|
||
if (sourceIdentity) ready.forEach(attachment => Object.defineProperty(attachment, 'sourceIdentity', {value: sourceIdentity}));
|
||
attachments.push(...ready); renderAttachments();
|
||
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл'); }
|
||
}
|
||
}
|
||
const FILE_KIND = file => {
|
||
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
|
||
if (mime.startsWith('image/')) return ['image', 'Изображение'];
|
||
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
|
||
if (mime.startsWith('video/')) return ['video', 'Видео'];
|
||
if (ext === 'pdf') return ['pdf', 'PDF-документ'];
|
||
if (/^(doc|docx|odt|xls|xlsx|ods)$/.test(ext)) return ['document', 'Документ'];
|
||
return ['text', 'Текстовый файл'];
|
||
};
|
||
const FILE_ICONS = {
|
||
image: '<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
|
||
audio: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||
video: '<rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/>',
|
||
pdf: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h6"/>',
|
||
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
|
||
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
|
||
};
|
||
function renderFileCards(host, files) {
|
||
if (!files.length) return;
|
||
const group = document.createElement('div'); group.className = 'ai-history-files';
|
||
files.forEach(file => {
|
||
const [kind, label] = FILE_KIND(file);
|
||
const card = document.createElement('div'); card.className = 'ai-history-file ' + kind;
|
||
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
|
||
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
|
||
const info = document.createElement('span'); info.className = 'ai-history-file-info';
|
||
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name;
|
||
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
|
||
const ext = fileExt(file.name).toUpperCase();
|
||
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
|
||
info.append(name, meta); card.append(icon, info); group.appendChild(card);
|
||
});
|
||
host.appendChild(group);
|
||
}
|
||
function splitStoredAttachments(text) {
|
||
const re = /(^|\n\n)\[Вложение: ([^\r\n]+)\]/g, marks = [...text.matchAll(re)];
|
||
if (!marks.length) return {text, files: []};
|
||
const files = [];
|
||
marks.forEach((mark, i) => {
|
||
const start = mark.index + mark[0].length;
|
||
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
|
||
const tail = text.slice(start, end).replace(/^\n+|\n+$/g, '');
|
||
const content = /^Содержимое файла:\n([\s\S]*)$/.exec(tail);
|
||
files.push({name: mark[2], text: content ? content[1] : ''});
|
||
});
|
||
return {text: text.slice(0, marks[0].index).trimEnd(), files};
|
||
}
|
||
function add(role, text, thinkMs, files) {
|
||
q('.ai-hello').hidden = true;
|
||
const el = document.createElement('div');
|
||
el.className = 'ai-msg ' + role;
|
||
if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); }
|
||
else if (role === 'user') {
|
||
const parsed = splitStoredAttachments(text), shownFiles = files && files.length ? files : parsed.files;
|
||
const message = files && files.length ? text : parsed.text;
|
||
if (message) { const copy = document.createElement('div'); copy.className = 'ai-user-copy'; copy.textContent = message; el.appendChild(copy); }
|
||
renderFileCards(el, shownFiles);
|
||
} else el.textContent = text;
|
||
const follow = nearBottom();
|
||
list.appendChild(el);
|
||
scrollIfFollowing(follow);
|
||
return el;
|
||
}
|
||
function formatFileSize(n) {
|
||
if (n < 1024) return n + ' Б';
|
||
if (n < 1048576) return (n / 1024).toFixed(1) + ' КБ';
|
||
return (n / 1048576).toFixed(1) + ' МБ';
|
||
}
|
||
// An action's result shows folded; it reaches the talk on the server with
|
||
// the next question (or by note, when done by hand).
|
||
function showResult(text) {
|
||
const el = add('result', '');
|
||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||
el.querySelector('pre').textContent = text;
|
||
}
|
||
list.addEventListener('click', async ev => {
|
||
const b = ev.target.closest('.ai-act');
|
||
if (!b || b.disabled) return;
|
||
const msg = b.closest('.ai-msg'), a = WSUtil.parseActions(msg.dataset.text || '')[Number(b.dataset.i)];
|
||
if (!a) return;
|
||
b.disabled = true;
|
||
b.textContent = '…';
|
||
const res = await perform(a);
|
||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||
showResult(res);
|
||
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
|
||
});
|
||
|
||
function responseError(status, contentType, body) {
|
||
const html = /text\/html/i.test(contentType) || /<!doctype\s+html|<html\b|<body\b/i.test(body);
|
||
if (html) return status === 502
|
||
? 'Сервис ИИ временно недоступен (502 Bad Gateway). Попробуйте позже.'
|
||
: 'Сервис ИИ вернул страницу ошибки (HTTP ' + status + '). Попробуйте позже.';
|
||
const detail = body.trim().slice(0, 500).replace(/^\[ошибка:\s*/i, '').replace(/\]$/, '').trim();
|
||
return detail || 'Ошибка сервиса ИИ (HTTP ' + status + ').';
|
||
}
|
||
// ask sends the new words (or an action's result) and shows the answer.
|
||
async function ask(text, res, files) {
|
||
if (text || (files && files.length)) {
|
||
add('user', text || '', 0, files || []);
|
||
}
|
||
if (res) showResult(res);
|
||
const el = add('assistant', '');
|
||
el.classList.add('wait');
|
||
// While the model starts: a typing mark, gone with the first words.
|
||
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
|
||
ac = new AbortController();
|
||
sendState();
|
||
let answer = '';
|
||
try {
|
||
await loadMachines();
|
||
const r = await fetch('/api/panel/ai/chat', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || [], model: modelId()})
|
||
});
|
||
// A new chat gets its id (and its name) from the answer.
|
||
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
|
||
if (cid && cid !== chatId) {
|
||
setChat(cid); loadChats();
|
||
// The model names the new chat a moment later.
|
||
setTimeout(loadChats, 4000); setTimeout(loadChats, 12000);
|
||
}
|
||
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
||
const contentType = r.headers.get('content-type') || '';
|
||
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
|
||
if (/text\/html/i.test(contentType)) throw new Error(responseError(r.status, contentType, ''));
|
||
if (!r.body) throw new Error('Ошибка сервиса ИИ (HTTP ' + 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);
|
||
updateJump();
|
||
}
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
|
||
} finally {
|
||
el.classList.remove('wait');
|
||
render(el, answer || '[нет ответа]', true);
|
||
updateJump();
|
||
}
|
||
return {el, answer: stripThink(answer)};
|
||
}
|
||
const pill = k => q('.ai-pill[data-mode="' + k + '"]');
|
||
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
|
||
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
|
||
const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true';
|
||
// ---------- the model ----------
|
||
// Shown when there are several models, or always if the user asked for it
|
||
// (Аккаунт › Вид); the choice is the user's, kept on the server.
|
||
const mpick = q('.ai-mpick'), mbtn = q('.ai-mbtn'), mmenu = q('.ai-mmenu');
|
||
const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||
function modelId() {
|
||
const want = window.Prefs ? Prefs.get().aiModel : '';
|
||
return (models.find(m => m.id === want) || models[0] || {}).id || '';
|
||
}
|
||
function drawModels() {
|
||
const always = window.Prefs && Prefs.get().aiModelPick === 'always';
|
||
mpick.hidden = !(models.length > 1 || (always && models.length));
|
||
const cur = modelId(), m = models.find(x => x.id === cur);
|
||
q('.ai-mname').textContent = m ? m.name : '';
|
||
mbtn.title = m ? 'Модель: ' + m.name : 'Модель';
|
||
mmenu.innerHTML = '';
|
||
models.forEach(x => {
|
||
const b = document.createElement('button');
|
||
b.type = 'button'; b.className = 'ai-mitem'; b.setAttribute('role', 'option');
|
||
b.setAttribute('aria-selected', String(x.id === cur));
|
||
b.innerHTML = '<span class="ai-mitem-name"></span><span class="ai-mcheck">' + CHECK + '</span>';
|
||
b.querySelector('.ai-mitem-name').textContent = x.name;
|
||
b.onclick = () => {
|
||
closeModels();
|
||
if (window.Prefs) Prefs.set('aiModel', x.id);
|
||
drawModels();
|
||
input.focus();
|
||
};
|
||
mmenu.appendChild(b);
|
||
});
|
||
}
|
||
function openModels() {
|
||
mpick.classList.add('open');
|
||
mbtn.setAttribute('aria-expanded', 'true');
|
||
const sel = mmenu.querySelector('[aria-selected="true"]') || mmenu.firstChild;
|
||
if (sel) sel.focus();
|
||
}
|
||
function closeModels() {
|
||
mpick.classList.remove('open');
|
||
mbtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
mbtn.onclick = () => mpick.classList.contains('open') ? closeModels() : openModels();
|
||
document.addEventListener('pointerdown', ev => { if (!mpick.contains(ev.target)) closeModels(); }, true);
|
||
mpick.addEventListener('keydown', ev => {
|
||
if (ev.key === 'Escape' && mpick.classList.contains('open')) { ev.stopPropagation(); closeModels(); mbtn.focus(); return; }
|
||
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
|
||
ev.preventDefault();
|
||
if (!mpick.classList.contains('open')) { openModels(); return; }
|
||
const items = [...mmenu.children], i = items.indexOf(document.activeElement);
|
||
const n = items[(i + (ev.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
|
||
if (n) n.focus();
|
||
});
|
||
document.addEventListener('lpra:prefs', drawModels);
|
||
// The modes are kept in this browser.
|
||
box.querySelectorAll('.ai-pill').forEach(b => {
|
||
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
|
||
b.onclick = () => {
|
||
const on = b.getAttribute('aria-pressed') !== 'true';
|
||
b.setAttribute('aria-pressed', String(on));
|
||
try { localStorage.setItem('lpra.ai.' + b.dataset.mode, on ? '1' : ''); } catch (e) { /* not kept */ }
|
||
};
|
||
});
|
||
// talkTurn: one question, and in agent mode the steps after it - the
|
||
// first action of each answer done (asking first for commands and writes)
|
||
// and its result sent back - until an answer asks for nothing.
|
||
async function talkTurn(text, files) {
|
||
let turn = await ask(text, null, files);
|
||
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
|
||
const a = WSUtil.parseActions(turn.answer)[0];
|
||
if (!a) break;
|
||
// Without «Агент» only searches go on by themselves, with «Поиск» on.
|
||
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
|
||
const b = turn.el.querySelector('.ai-act');
|
||
if (needsYes(a) && !await confirmAction(a)) {
|
||
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
||
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
||
continue;
|
||
}
|
||
if (b) { b.disabled = true; b.textContent = '…'; }
|
||
const res = await perform(a, ac.signal);
|
||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||
if (ac.signal.aborted) { showResult(res); break; }
|
||
turn = await ask(null, res);
|
||
}
|
||
ac = null;
|
||
sendState();
|
||
}
|
||
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
|
||
attachBtn.onclick = () => attachInput.click();
|
||
attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; };
|
||
form.onsubmit = ev => {
|
||
ev.preventDefault();
|
||
if (ac) { ac.abort(); return; }
|
||
const text = input.value.trim();
|
||
if (!text && !attachments.length) return;
|
||
const files = attachments.slice(); attachments = []; renderAttachments();
|
||
input.value = '';
|
||
grow();
|
||
where();
|
||
hideFileError();
|
||
talkTurn(text, files);
|
||
};
|
||
box.addEventListener('dragover', ev => {
|
||
if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return;
|
||
ev.preventDefault();
|
||
form.classList.toggle('drag', isDropTargetAt(ev.clientX, ev.clientY));
|
||
});
|
||
box.addEventListener('dragleave', ev => { if (!box.contains(ev.relatedTarget)) form.classList.remove('drag'); });
|
||
box.addEventListener('drop', ev => {
|
||
if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return;
|
||
ev.preventDefault(); form.classList.remove('drag');
|
||
if (isDropTargetAt(ev.clientX, ev.clientY)) addFiles([...ev.dataTransfer.files]);
|
||
});
|
||
async function addPaneFiles(pane, names) {
|
||
if (!pane || !/files$/.test(pane.kind)) return;
|
||
const entries = new Map((pane.entries || []).map(entry => [entry.name, entry]));
|
||
const errors = [], paths = [];
|
||
for (const name of names) {
|
||
const entry = entries.get(name);
|
||
if (!entry) { errors.push(name + ': элемент больше не отображается'); continue; }
|
||
const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + name;
|
||
// A folder, or a file the model cannot take: its path goes in the text.
|
||
if (entry.is_dir || !attachable(name)) { paths.push(path); continue; }
|
||
const identity = JSON.stringify([pane.source.id, path]);
|
||
if (importingPaneFiles.has(identity) || attachments.some(file => file.sourceIdentity === identity)) {
|
||
errors.push(name + ': этот файл уже прикреплён');
|
||
continue;
|
||
}
|
||
importingPaneFiles.add(identity);
|
||
try {
|
||
const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'});
|
||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||
const blob = await response.blob();
|
||
await addFiles([new File([blob], name, {type: blob.type})], [identity]);
|
||
} catch (e) { errors.push(name + ': ' + (e.message || 'не удалось скачать')); }
|
||
finally { importingPaneFiles.delete(identity); }
|
||
}
|
||
if (paths.length) insertText(paths.join(' '));
|
||
if (errors.length) setFileError(errors.join('; '));
|
||
}
|
||
function isDropTargetAt(x, y) {
|
||
if (!isOpen()) return false;
|
||
const r = q('.ai-inputbox').getBoundingClientRect();
|
||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||
}
|
||
window.AIChat = {
|
||
isDropTargetAt,
|
||
setDragging(on) { form.classList.toggle('drag', !!on); },
|
||
dropFiles: addPaneFiles
|
||
};
|
||
// Send shows only when there is something to send, or an answer to stop.
|
||
// While it fades away it keeps the look it had (a red stop stays a red
|
||
// stop) and becomes the plane only once hidden.
|
||
let sendLookTimer = 0;
|
||
function sendLook(stop) {
|
||
clearTimeout(sendLookTimer);
|
||
if (send.classList.contains('stop') === stop && send.dataset.look) return;
|
||
send.dataset.look = '1';
|
||
send.innerHTML = stop ? STOP : PLANE;
|
||
send.title = stop ? 'Остановить' : 'Отправить';
|
||
send.setAttribute('aria-label', send.title);
|
||
send.classList.toggle('stop', stop);
|
||
}
|
||
function sendState() {
|
||
const on = !!ac || !!input.value.trim() || attachments.length > 0;
|
||
if (on) sendLook(!!ac);
|
||
else { clearTimeout(sendLookTimer); sendLookTimer = setTimeout(() => sendLook(false), 260); }
|
||
send.classList.toggle('show', on);
|
||
send.tabIndex = on ? 0 : -1;
|
||
}
|
||
input.addEventListener('input', sendState);
|
||
sendState();
|
||
// insertText: text at the caret, set apart from its neighbours by spaces.
|
||
function insertText(t) {
|
||
const v = input.value, a = input.selectionStart ?? v.length, b = input.selectionEnd ?? v.length;
|
||
const pre = a && !/\s$/.test(v.slice(0, a)) ? ' ' : '', post = b < v.length && !/^\s/.test(v.slice(b)) ? ' ' : '';
|
||
input.value = v.slice(0, a) + pre + t + post + v.slice(b);
|
||
const at = a + pre.length + t.length + post.length;
|
||
input.focus();
|
||
input.setSelectionRange(at, at);
|
||
grow(); sendState();
|
||
}
|
||
// The field grows with the text, up to a few lines.
|
||
// Measured with no scrollbar: one showing in the squeezed field would take
|
||
// its width and wrap the last word a character too early.
|
||
function grow() {
|
||
input.style.overflowY = 'hidden';
|
||
input.style.height = '32px';
|
||
const h = input.scrollHeight;
|
||
input.style.height = Math.min(140, h) + 'px';
|
||
input.style.overflowY = h > 140 ? 'auto' : 'hidden';
|
||
}
|
||
input.addEventListener('input', grow);
|
||
input.addEventListener('keydown', ev => {
|
||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); }
|
||
});
|
||
})();
|