// 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 = ''; 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 = '
' + '' + '
' + '
' + '
Спросите AI помощника
' + '' + '
' + '
' + '' + '' + '' + '
' + '
' + '
' + '' + '' + '
'; 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(); 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; 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. 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 = ''; 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 = '
Пока нет чатов
'; chats.forEach(c => { const row = document.createElement('div'); row.className = 'ai-chatrow' + (c.id === chatId ? ' on' : ''); row.innerHTML = ''; 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(); if (!await UI.confirm('Удалить чат «' + c.title + '»?', {ok: 'Удалить', danger: true})) return; 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*(?:|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'}; const IC = d => '' + d + ''; const ICONS = {run: IC(''), read: IC(''), ls: IC(''), write: IC(''), search: IC(''), fetch: IC('')}; 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 = '›'; function render(el, text, done) { let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body'); if (!body) { el.innerHTML = '
'; think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body'); think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open'); } const th = /^\s*(?:|::думаю::)([\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 += '
' + WSUtil.md(part) + '
'; 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 += '
' + ICONS[kind] + '' + KIND[kind] + '' + (where ? '' + esc(where) + '' : '') + '' + '
' + (code ? '
' + esc(code) + '
' : '') + '
'; } else html += '
' + esc(code) + '
'; }); 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: '', audio: '', video: '', pdf: '', document: '', text: '' }; 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 = '' + FILE_ICONS[kind] + ''; 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 = '
Результат
'; 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) || /'; ac = new AbortController(); send.innerHTML = STOP; send.title = 'Остановить'; 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 || []}) }); // 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 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; send.innerHTML = PLANE; send.title = 'Отправить'; 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. function sendState() { const on = !!ac || !!input.value.trim() || attachments.length > 0; send.classList.toggle('show', on); send.classList.toggle('stop', !!ac); 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 = '40px'; const h = input.scrollHeight + 2; 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(); } }); })();