feat: fetch for the assistant with Поиск on - a page's main text for the model, the server's own network out of reach; shorter words in Настройки and the chat; the + menu's roots a step left. Also files from panes into the chat, docview and drag fixes (by the user)

This commit is contained in:
redtea committed 2026-10-16 17:32:52 +03:00
1 parent 84b42c5369
commit ae8fffe7d8
17 files changed
+712 -102

No files matched your search

+131 -48
View File
@@ -3,10 +3,10 @@
// 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.
// On by default; the user can turn it off in the profile.
(function () {
'use strict';
const PLANE = '<svg width="20" height="20" 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 PLANE = '<svg class="ai-plane" width="20" height="20" 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="14" height="14" 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');
@@ -24,7 +24,7 @@
'<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"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
'<div class="ai-chat-body"><div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели.</p></div>' +
'<button type="button" class="ai-jump" hidden title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="18" height="18" 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>' +
'<div class="ai-pills">' +
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
@@ -35,10 +35,9 @@
'<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>' +
'</div>' +
'<div class="ai-file-picker" hidden></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,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
'<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>' +
'<button type="button" class="ai-from-pane" title="Выбрать файл из файлового окна" aria-label="Выбрать файл из файлового окна"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v1M3 7v11a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2z"/></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);
@@ -46,6 +45,7 @@
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'), filePicker = q('.ai-file-picker');
let attachments = [];
const importingPaneFiles = new Set();
const nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80;
function updateJump() { jump.hidden = nearBottom() || !isOpen(); }
list.addEventListener('scroll', updateJump, {passive: true});
@@ -337,6 +337,15 @@
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 'Ошибка: поиск выключен - включите плашку «Поиск»';
@@ -391,12 +400,13 @@
// ---------- 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: 'поиск'};
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти'};
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
// render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The
@@ -477,9 +487,10 @@
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 === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
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));
@@ -494,28 +505,88 @@
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) {
for (const file of files) {
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 || 'Не удалось прочитать файл'); }
}
}
function add(role, text, thinkMs) {
const FILE_KIND = file => {
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
if (mime.startsWith('image/')) return ['image', 'Изображение'];
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
if (mime.startsWith('video/')) return ['video', 'Видео'];
if (ext === 'pdf') return ['pdf', 'PDF-документ'];
if (/^(doc|docx|odt|xls|xlsx|ods)$/.test(ext)) return ['document', 'Документ'];
return ['text', 'Текстовый файл'];
};
const FILE_ICONS = {
image: '<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
audio: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
video: '<rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/>',
pdf: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h6"/>',
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
};
function renderFileCards(host, files) {
if (!files.length) return;
const group = document.createElement('div'); group.className = 'ai-history-files';
files.forEach(file => {
const [kind, label] = FILE_KIND(file);
const card = document.createElement('div'); card.className = 'ai-history-file ' + kind;
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
const info = document.createElement('span'); info.className = 'ai-history-file-info';
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name;
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
const ext = fileExt(file.name).toUpperCase();
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
info.append(name, meta); card.append(icon, info); group.appendChild(card);
});
host.appendChild(group);
}
function splitStoredAttachments(text) {
const re = /(^|\n\n)\[Вложение: ([^\r\n]+)\]/g, marks = [...text.matchAll(re)];
if (!marks.length) return {text, files: []};
const files = [];
marks.forEach((mark, i) => {
const start = mark.index + mark[0].length;
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
const tail = text.slice(start, end).replace(/^\n+|\n+$/g, '');
const content = /^Содержимое файла:\n([\s\S]*)$/.exec(tail);
files.push({name: mark[2], text: content ? content[1] : ''});
});
return {text: text.slice(0, marks[0].index).trimEnd(), files};
}
function add(role, text, thinkMs, files) {
q('.ai-hello').hidden = true;
const el = document.createElement('div');
el.className = 'ai-msg ' + role;
if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); } else el.textContent = text;
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) {
@@ -547,8 +618,7 @@
// 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)) {
const labels = files && files.length ? '\n\n📎 ' + files.map(f => f.name).join(', ') : '';
add('user', (text || '') + labels);
add('user', text || '', 0, files || []);
}
if (res) showResult(res);
const el = add('assistant', '');
@@ -582,17 +652,15 @@
if (done) break;
answer += dec.decode(value, {stream: true});
el.classList.remove('wait');
const follow = nearBottom();
render(el, answer, false);
scrollIfFollowing(follow);
updateJump();
}
} catch (e) {
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
} finally {
el.classList.remove('wait');
const follow = nearBottom();
render(el, answer || '[нет ответа]', true);
scrollIfFollowing(follow);
updateJump();
}
return {el, answer: stripThink(answer)};
}
@@ -618,7 +686,7 @@
const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on.
if (!agentOn() && !(a.kind === 'search' && searchOn())) break;
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 = 'Отклонено'; }
@@ -634,10 +702,9 @@
ac = null;
send.innerHTML = PLANE; send.title = 'Отправить';
}
const form = q('.ai-form-in'), attachBtn = q('.ai-attach'), paneBtn = q('.ai-from-pane');
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
attachBtn.onclick = () => attachInput.click();
attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; };
paneBtn.onclick = () => showPaneFiles();
form.onsubmit = ev => {
ev.preventDefault();
if (ac) { ac.abort(); return; }
@@ -650,36 +717,52 @@
filePicker.hidden = true;
talkTurn(text, files);
};
box.addEventListener('dragover', ev => { if (ev.dataTransfer && [...ev.dataTransfer.types].includes('Files')) { ev.preventDefault(); form.classList.add('drag'); } });
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.types].includes('Files')) return;
ev.preventDefault(); form.classList.remove('drag'); addFiles([...ev.dataTransfer.files]);
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 showPaneFiles() {
filePicker.replaceChildren();
const pane = window.Workspace && Workspace.activePane();
if (!pane || !/files$/.test(pane.kind)) { setFileError('Сначала откройте файловое окно подключённой машины.'); return; }
const files = (pane.entries || []).filter(e => !e.is_dir);
if (!files.length) { setFileError('В текущей папке нет файлов.'); return; }
const title = document.createElement('strong'); title.textContent = 'Файлы · ' + pane.cwd; filePicker.appendChild(title);
files.forEach(entry => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'ai-file-option'; b.textContent = entry.name;
b.onclick = async () => {
b.disabled = true; b.textContent += ' · загрузка…';
try {
const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + entry.name;
const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'});
if (!response.ok) throw new Error('Не удалось скачать файл (' + response.status + ')');
const blob = await response.blob();
await addFiles([new File([blob], entry.name, {type: blob.type})]);
filePicker.hidden = true;
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл машины'); }
};
filePicker.appendChild(b);
});
filePicker.hidden = false;
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 = [];
for (const name of names) {
const entry = entries.get(name);
if (!entry) { errors.push(name + ': элемент больше не отображается'); continue; }
if (entry.is_dir) { errors.push(name + ': папки прикрепить нельзя'); continue; }
const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + name;
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 (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
};
// 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);