Files
lpra/internal/server/web/static/ai.js
T

1023 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 => ({'&': '&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="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 = [];
// Pictures are on when the admin set the image service's key: the model then
// asks for them in its answers (```image blocks) - no switch of the user's.
let imageOn = false;
// The photos of the user's last message that had any, and the last picture
// made here: what an edit works on.
let sentImages = [], lastPic = '';
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
.then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; 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());
sentImages = []; lastPic = '';
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 (DONE[b.dataset.kind]) b.textContent = DONE[b.dataset.kind]; }); }
});
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;
}
if (a.kind === 'image') return makeImage(a, signal);
// 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: 'страница', image: 'генерация картинки'};
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"/>'),
image: IC('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')};
// What the button of a card that has been done says (else «Готово»).
const DONE = {image: 'Создано'};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть', image: 'Создать'};
// 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) {
// A picture's title says what it is (a new one or an edit); its first line
// (format, quality, file) is not shown - the words under it are.
const where = kind === 'image' ? '' : head.slice(kind.length).trim(), idx = n++;
const title = kind === 'image' && /^image\s+edit(\s|$)/.test(head) ? 'редактирование картинки' : KIND[kind];
// The text of a picture or a search folds: a line, or open - up to
// a few lines, then it scrolls. What the user left open stays so.
const fold = (kind === 'image' || kind === 'search') && code;
const open = fold && el._open && el._open.has(idx);
html += '<div class="ai-code ai-action' + (fold ? ' foldable' + (open ? ' open' : '') : '') + '" data-n="' + idx + '"><div class="ai-code-h"><span class="ai-kind">' +
(fold ? '<button type="button" class="ai-fold" aria-expanded="' + !!open + '" title="' + (open ? 'Свернуть' : 'Развернуть') + '">' + FOLD_CHEV + '</button>' : '') +
ICONS[kind] + '<b>' + title + '</b>' +
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
'<button type="button" class="ai-act" data-i="' + idx + '" data-kind="' + kind + '">' + 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;
// A text that fits one line has nothing to unfold: no arrow for it.
requestAnimationFrame(() => body.querySelectorAll('.foldable:not(.open)').forEach(c => {
const p = c.querySelector('pre');
c.classList.toggle('short', p.scrollWidth <= p.clientWidth + 1 && !/\n/.test(p.textContent));
}));
}
const FOLD_CHEV = '<svg width="14" height="14" 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>';
list.addEventListener('click', ev => {
const t = ev.target.closest('.foldable .ai-fold, .foldable pre');
if (!t) return;
const card = t.closest('.foldable');
if (card.classList.contains('short') && !card.classList.contains('open')) return;
const msg = card.closest('.ai-msg'), on = card.classList.toggle('open');
const i = Number(card.dataset.n);
(msg._open = msg._open || new Set())[on ? 'add' : 'delete'](i);
const b = card.querySelector('.ai-fold');
b.setAttribute('aria-expanded', String(on)); b.title = on ? 'Свернуть' : 'Развернуть';
});
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'); }
// The file itself stays with its attachment (not sent anywhere): it is what
// a click on its name opens or saves.
async function makeAttachments(file, name = file.name) {
const made = await makeAttachmentsOf(file, name);
made.forEach(a => Object.defineProperty(a, 'blob', {value: file}));
return made;
}
async function makeAttachmentsOf(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 => {
// Of an earlier talk only the name is kept: the kind comes from it.
const ext = fileExt(file.name), mime = (file.mime || MEDIA_MIME[ext] || '').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"/>'
};
// What a browser shows by itself: raster pictures, sound, video, PDF. Text
// is shown as plain text, so it cannot run anything; the rest is saved.
const SHOWN = /^(image\/(png|jpe?g|gif|webp|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf)$/;
function openAttached(file) {
let blob = file.blob || null, show = false;
// The type of the file itself - not of its text (a Word file's attachment
// is its text, plain, but the file is not).
const ext = fileExt(file.name), own = MEDIA_MIME[ext] || (ext === 'pdf' ? 'application/pdf' : '');
const type = ((blob ? blob.type || own : file.mime || own) || '').toLowerCase();
if (!blob && file.data) {
const bin = atob(file.data), bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
blob = new Blob([bytes], {type});
}
if (blob && SHOWN.test(type)) { blob = new Blob([blob], {type}); show = true; }
else if (blob && (textExt.test(ext) || type.startsWith('text/'))) { blob = new Blob([blob], {type: 'text/plain;charset=utf-8'}); show = true; }
else if (!blob && file.text) { blob = new Blob([file.text], {type: 'text/plain;charset=utf-8'}); show = true; }
if (!blob) return;
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url;
if (show) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = file.name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 120000);
}
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';
// The name opens the file (or saves it) - when it is at hand: this
// page's own attachments; of an earlier talk only a text file's text.
const has = !!(file.blob || file.data || file.text);
const name = document.createElement(has ? 'button' : 'span');
name.className = 'ai-history-file-name' + (has ? ' link' : ''); name.textContent = file.name;
name.title = has ? file.name : file.name + ' - файл недоступен после перезагрузки страницы';
if (has) { name.type = 'button'; name.onclick = () => openAttached(file); }
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;
// A picture made: shown here, from its marker (kept with the talk, so
// it comes back with it) - and what the next edit works on.
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
if (!m) return;
lastPic = m[1];
const follow = nearBottom();
const link = document.createElement('a'), img = document.createElement('img');
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
img.alt = 'Картинка'; img.src = m[1];
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
link.appendChild(img);
el.classList.add('pic');
el.insertBefore(link, el.firstChild);
}
// makeImage: a picture asked for in an answer - put at the image service
// through the server, asked about until it is done. What comes back is
// what the model is told: that it is made and shown (or why not).
async function makeImage(a, signal) {
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
if (a.edit) {
const want = (a.file || '').toLowerCase();
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
if (pick) body.image = {mime: pick.mime, data: pick.data};
else if (lastPic) body.image_url = lastPic;
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
}
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
if (!r.ok) return 'Ошибка картинки: ' + r.error;
let lost = 0;
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
if (!s.ok) {
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
return 'Ошибка картинки: ' + s.error;
}
if (s.status === 'done') {
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
}
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
}
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
}
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) ? 'Ошибка' : DONE[a.kind] || 'Готово';
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);
// The button: the short name if there is one; the list: the full names.
q('.ai-mname').textContent = m ? (m.short || m.name) : '';
mpick.classList.toggle('short', !!(m && m.short));
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) {
const imgs = (files || []).filter(f => f.data && /^image\//.test(f.mime || ''));
if (imgs.length) sentImages = imgs;
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 -
// and pictures, whenever they are on: the model asks for them itself.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn()) && !(a.kind === 'image' && imageOn)) 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 = a.kind === 'image' ? 'Рисую…' : '…'; }
const res = await perform(a, ac.signal);
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
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(); }
});
})();