// 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.hidden = true; 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; function setOn(on) { fab.hidden = !on; if (!on) box.hidden = true; } fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); document.addEventListener('lpra:ai', ev => setOn(ev.detail)); fab.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) { where(); input.focus(); } }; q('.ai-close').onclick = () => { box.hidden = true; }; q('.ai-clear').onclick = () => { if (ac) ac.abort(); talk.length = 0; list.querySelectorAll('.ai-msg').forEach(m => m.remove()); q('.ai-hello').hidden = false; }; // ---------- what the panel shows ---------- const W = () => window.Workspace; function termText(p, lines) { const b = p.term && p.term.buffer && p.term.buffer.active; if (!b) return ''; const out = []; for (let i = Math.max(0, b.length - lines); i < b.length; i++) { const l = b.getLine(i); if (l) out.push(l.translateToString(true)); } while (out.length && !out[out.length - 1].trim()) out.pop(); return out.join('\n'); } function paneLine(p) { const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind; return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')'; } function context() { const view = document.querySelector('main#app').dataset.view; const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)]; const ws = W(); if (!ws) return out.join('\n'); const panes = ws.panes(), act = ws.activePane(); out.push('Панели (' + panes.length + '):'); panes.forEach(p => out.push((p === act ? '* ' : '- ') + paneLine(p))); if (act && act.term) { out.push('', 'Активный терминал, последние строки:', '```', termText(act, 120), '```'); } else if (act && act.entries) { out.push('', 'Активная файловая панель: ' + act.cwd + (act.selected.size ? ' · выбрано: ' + [...act.selected].join(', ') : '')); out.push(act.entries.slice(0, 200).map(e => (e.is_dir ? e.name + '/' : e.name + ' (' + e.size + ' B)')).join('\n')); } return out.join('\n'); } function where() { const act = W() && W().activePane(); q('.ai-where').textContent = act ? '· ' + (act.agent ? act.agent.name : '') : ''; } // ---------- messages ---------- // render: text with ``` blocks; ```run``` ones get «Выполнить». function render(el, text, done) { const parts = text.split(/```/); let html = ''; parts.forEach((part, i) => { if (i % 2 === 0) { html += '
' + esc(part).replace(/\n/g, '
') + '
'; return; } const m = /^(\w*)\n?([\s\S]*)$/.exec(part); const lang = m[1], code = m[2].replace(/\n$/, ''); const closed = i < parts.length - 1 || done; html += '
' + esc(code) + '
' + (lang === 'run' && closed && code.trim() ? '' : '') + '
'; }); el.innerHTML = html; } function add(role, text) { q('.ai-hello').hidden = true; const el = document.createElement('div'); el.className = 'ai-msg ' + role; if (role === 'user') el.textContent = text; else render(el, text, true); list.appendChild(el); list.scrollTop = list.scrollHeight; return el; } list.addEventListener('click', ev => { const b = ev.target.closest('.ai-run'); if (!b) return; const ws = W(), act = ws && ws.activePane(); let t = act && act.term ? act : null; if (!t && ws) t = ws.panes().find(p => p.term && ws.tabOf(p) === ws.tabOf(act)) || null; if (!t) { FileViews.toast('Откройте терминал: команда выполняется в активном терминале', true); return; } t.send(b.dataset.cmd.replace(/\n/g, '\r') + '\r'); b.textContent = 'Выполнено в ' + (t.agent ? t.agent.name : 'терминале'); b.disabled = true; }); async function ask(text) { talk.push({role: 'user', content: text}); add('user', text); const el = add('assistant', ''); el.classList.add('wait'); ac = new AbortController(); send.innerHTML = STOP; send.title = 'Остановить'; let answer = ''; try { const r = await fetch('/api/panel/ai/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal, body: JSON.stringify({messages: talk, context: context()}) }); if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; } if (!r.ok && !r.body) throw new Error('ошибка ' + r.status); const rd = r.body.getReader(), dec = new TextDecoder(); for (;;) { const {done, value} = await rd.read(); if (done) break; answer += dec.decode(value, {stream: true}); el.classList.remove('wait'); render(el, answer, false); list.scrollTop = list.scrollHeight; } } catch (e) { if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']'; } finally { ac = null; send.innerHTML = PLANE; send.title = 'Отправить'; el.classList.remove('wait'); render(el, answer || '[нет ответа]', true); talk.push({role: 'assistant', content: answer}); } } q('.ai-form-in').onsubmit = ev => { ev.preventDefault(); if (ac) { ac.abort(); return; } const text = input.value.trim(); if (!text) return; input.value = ''; grow(); where(); ask(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(); } }); })();