// The assistant: a round button in the bottom right corner opens a chat. Each // question goes with what the panel shows - the tabs, the active pane, the // text of its terminal or its list of files - and the answer streams in. // Commands the model suggests come as ```run``` blocks with «Выполнить»: a // click sends the command to the active terminal, with the user's rights. // Off until the user turns it on in the profile. (function () { 'use strict'; const PLANE = ''; const STOP = ''; 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 = ''; const box = document.createElement('div'); box.className = 'ai-box'; box.innerHTML = '
Ассистент' + '' + '
' + '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.

' + '
' + '' + '' + '
' + '
' + '
'; document.body.append(fab, box); const q = s => box.querySelector(s); const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'); const talk = []; // {role, content} let ac = null; // 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() { placeBox(); box.classList.add('on'); 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; } box.classList.remove('on'); } window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) box.classList.remove('on'); }); function setOn(on) { fab.hidden = !on; if (!on) hide(); } fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); document.addEventListener('lpra:ai', ev => setOn(ev.detail)); fab.onclick = () => { 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. const desk = !matchMedia('(pointer: coarse)').matches; 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; let x = toLeft ? f.right - w : f.left; let y = up ? f.top - gap - h : f.bottom + gap; // No room above or below: beside the ball. if (y < 8 || y + h > innerHeight - 8) { y = clamp(f.top + f.height - h, 8, innerHeight - h - 8); x = toLeft ? f.left - gap - w : f.right + gap; } 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; q('.ai-clear').onclick = () => { if (ac) ac.abort(); talk.length = 0; list.querySelectorAll('.ai-msg').forEach(m => m.remove()); q('.ai-hello').hidden = false; }; // ---------- what the panel shows ---------- const W = () => window.Workspace; function termText(p, lines) { const b = p.term && p.term.buffer && p.term.buffer.active; if (!b) return ''; const out = []; for (let i = Math.max(0, b.length - lines); i < b.length; i++) { const l = b.getLine(i); if (l) out.push(l.translateToString(true)); } while (out.length && !out[out.length - 1].trim()) out.pop(); return out.join('\n'); } function paneLine(p) { const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind; return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')'; } function context() { const view = document.querySelector('main#app').dataset.view; const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)]; 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(); q('.ai-where').textContent = act ? '· ' + (act.agent ? act.agent.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('/terminal'); 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) { 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'; } function describe(a) { const where = a.machine ? a.machine + ': ' : ''; return a.kind === 'run' ? 'Выполнить на ' + (a.machine || 'активной машине') + ':\n\n' + a.body : 'Записать файл ' + where + a.path + ' (' + a.body.length + ' символов)?'; } // ---------- messages ---------- // stripThink: the answer without its thinking - what is acted on and kept. const stripThink = t => t.replace(/^\s*(?:|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'}; // render: text with ``` blocks; action blocks get their button. function render(el, text, done) { // Thinking first, folded: ::думаю:: … ::ответ:: (or ), open while // it is going on. let html = '', n = 0; const th = /^\s*(?:|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text); if (th) { const going = !th[2]; html += '
' + (going ? 'Думает…' : 'Размышления') + '
' + esc(th[1].trim()).replace(/\n/g, '
') + '
'; text = text.slice(th[0].length); } const parts = text.split(/```/); parts.forEach((part, i) => { if (i % 2 === 0) { html += '
' + esc(part).replace(/\n/g, '
') + '
'; 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; html += '
' + (act && head !== kind ? '
' + esc(head) + '
' : '') + (code || !act ? '
' + esc(code) + '
' : '') + (act ? '' : '') + '
'; }); el.innerHTML = html; el.dataset.text = text; } function add(role, text) { q('.ai-hello').hidden = true; const el = document.createElement('div'); el.className = 'ai-msg ' + role; if (role === 'assistant') render(el, text, true); else el.textContent = text; list.appendChild(el); list.scrollTop = list.scrollHeight; return el; } // A result goes into the talk as the user's message and shows folded. function result(text) { const el = add('result', ''); el.innerHTML = '
Результат
'; el.querySelector('pre').textContent = text; talk.push({role: 'user', content: 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) ? 'Ошибка' : 'Готово'; result(res); }); async function ask(text) { if (text != null) { talk.push({role: 'user', content: text}); add('user', text); } const el = add('assistant', ''); el.classList.add('wait'); ac = new AbortController(); send.innerHTML = STOP; send.title = 'Остановить'; 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({messages: talk, context: context(), agent: agentOn(), think: thinkOn()}) }); if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; } if (!r.body) throw new Error('ошибка ' + r.status); const rd = r.body.getReader(), dec = new TextDecoder(); for (;;) { const {done, value} = await rd.read(); if (done) break; answer += dec.decode(value, {stream: true}); el.classList.remove('wait'); render(el, answer, false); list.scrollTop = list.scrollHeight; } } catch (e) { if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']'; } finally { el.classList.remove('wait'); render(el, answer || '[нет ответа]', true); talk.push({role: 'assistant', content: stripThink(answer)}); } 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'; // 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) { let turn = await ask(text); for (let step = 0; agentOn() && ac && !ac.signal.aborted && step < 15; step++) { const a = WSUtil.parseActions(turn.answer)[0]; if (!a) break; const b = turn.el.querySelector('.ai-act'); if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) { if (b) { b.disabled = true; b.textContent = 'Отклонено'; } result('Пользователь отклонил это действие. Предложи другое или закончи.'); 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) ? 'Ошибка' : 'Готово'; result(res); if (ac.signal.aborted) break; turn = await ask(null); } ac = null; send.innerHTML = PLANE; send.title = 'Отправить'; } q('.ai-form-in').onsubmit = ev => { ev.preventDefault(); if (ac) { ac.abort(); return; } const text = input.value.trim(); if (!text) return; input.value = ''; grow(); where(); talkTurn(text); }; // The field grows with the text, up to a few lines. function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; } input.addEventListener('input', grow); input.addEventListener('keydown', ev => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); } }); })();