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);
+32 -18
View File
@@ -363,7 +363,7 @@ body.split1 .float-add { top:42px; right:16px; }
body.multi .float-add { opacity:0; pointer-events:none; }
.float-add { transition:opacity .25s ease, background .25s ease, top .3s ease, right .3s ease, width .3s ease, height .3s ease, border-radius .3s ease; }
/* In a file pane's top bar: a button like its neighbours. */
.float-add.in-files { width:34px; height:34px; font-size:19px; opacity:1; border-radius:8px; }
.float-add.in-files { position:static; z-index:auto; width:34px; height:34px; font-size:19px; opacity:1; border-radius:8px; }
.float-add.in-files:hover { background:#151a21; }
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
@@ -400,6 +400,8 @@ body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
body.dnd, body.dnd * { cursor:grabbing !important; }
.tab, .tab *, .pane-head, .pane-head *, .ai-head, .ai-head *, .ai-fab,
.fp-list tr, .fp-list tr * { -webkit-user-drag:none; }
/* Files dragged out of a pane: the target's outline, and the small window
where they were dropped. */
.hint.drophint { position:fixed; z-index:55; transition:none; }
@@ -814,9 +816,7 @@ p.mv-a { margin:0; font-size:13px; }
/* The pane: its bar and list scroll inside it. */
.fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; }
.tabview:not(.split) .fpane-body { padding:12px 16px 16px; }
/* The file pane under the corner "+" keeps its first bar clear of it,
while the "+" is there (one tab; with the tab strip it is hidden). */
body:not(.multi) .fpane.under-plus .fm-bar:first-child { margin-right:40px; }
/* The bars' square buttons, as tall as the path box beside them. */
.fm-bar > .btn.sq, .fp-sel .btn.sq { width:34px; height:34px; }
@@ -863,8 +863,20 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-jump:hover { background:#202833; }
.ai-jump[hidden] { display:none; }
.ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; }
.ai-msg.user { align-self:flex-end; background:#16324f; white-space:pre-wrap; }
.ai-msg.user { align-self:flex-end; background:#16324f; white-space:normal; }
.ai-user-copy { white-space:pre-wrap; }
.ai-msg.assistant { align-self:flex-start; background:#151a21; border:1px solid var(--border); }
.ai-history-files { display:grid; gap:6px; margin-top:8px; }
.ai-history-file { display:flex; align-items:center; gap:9px; min-width:0; padding:8px 10px; border:1px solid rgba(255,255,255,.12); border-radius:10px; background:rgba(8,13,20,.32); }
.ai-history-file-icon { flex:none; width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:9px; color:#a9c1ff; background:rgba(91,140,255,.14); }
.ai-history-file.audio .ai-history-file-icon { color:#d1aaff; background:rgba(139,92,246,.16); }
.ai-history-file.video .ai-history-file-icon { color:#ffb4a2; background:rgba(249,115,85,.14); }
.ai-history-file.pdf .ai-history-file-icon { color:#ffaaa5; background:rgba(239,68,68,.14); }
.ai-history-file.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); }
.ai-history-file-info { display:grid; gap:2px; min-width:0; }
.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; }
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
/* The model starting: three dots rising in turn. */
.ai-typing { display:inline-flex; gap:4px; padding:4px 2px; }
.ai-typing i { width:6px; height:6px; border-radius:50%; background:var(--muted); animation:ai-typing 1.2s ease-in-out infinite; }
@@ -874,13 +886,13 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; }
.ai-code .ai-run { margin-top:6px; }
.ai-form-in { display:flex; padding:8px 10px 10px; }
.ai-inputbox { min-width:0; flex:1; padding:5px; background:#0c0f13; border:1px solid var(--border); border-radius:13px; transition:border-color .15s, box-shadow .15s; }
.ai-inputbox { position:relative; min-width:0; flex:1; padding:5px; background:#0c0f13; border:1px solid var(--border); border-radius:13px; transition:border-color .15s, box-shadow .15s, background-color .15s; }
.ai-inputbox:focus-within { border-color:var(--accent); box-shadow:0 0 0 2px rgba(91,140,255,.12); }
.ai-entry { display:flex; align-items:flex-end; gap:5px; min-width:0; }
.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:14px; line-height:20px; padding:9px 6px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; }
.ai-entry { display:flex; align-items:center; gap:5px; min-width:0; }
.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:16px; line-height:24px; padding:8px 6px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; }
.ai-form-in textarea:focus { outline:none; border-color:transparent; }
.ai-attach, .ai-from-pane { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; }
.ai-attach:hover, .ai-from-pane:hover { color:var(--text); background:#1b222c; }
.ai-attach { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; }
.ai-attach:hover { color:var(--text); background:#1b222c; }
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
.ai-attachments[hidden] { display:none; }
.ai-file-chip { display:inline-flex; align-items:center; gap:4px; max-width:100%; padding:3px 7px 3px 9px; border:1px solid var(--border); border-radius:999px; background:#151a21; color:var(--muted); font-size:11.5px; }
@@ -889,16 +901,15 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-file-chip button:hover { color:var(--text); background:#303846; }
.ai-file-picker { position:absolute; right:10px; bottom:78px; z-index:5; display:flex; flex-direction:column; gap:3px; max-width:min(320px, calc(100% - 20px)); max-height:45%; overflow:auto; padding:9px; background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.45); }
.ai-file-picker[hidden] { display:none; }
.ai-file-picker strong { padding:3px 6px 6px; color:var(--muted); font-size:12px; }
.ai-file-option { width:100%; overflow:hidden; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:transparent; border-color:transparent; color:var(--text); font-size:12px; }
.ai-file-option:hover { background:#202833; }
.ai-form-in.drag .ai-inputbox { border-color:var(--accent); box-shadow:0 0 0 3px rgba(91,140,255,.18); }
.ai-form-in.drag .ai-inputbox { border-color:var(--accent); background:#111a28; box-shadow:0 0 0 3px rgba(91,140,255,.24), 0 0 18px rgba(91,140,255,.16); }
.ai-form-in.drag .ai-inputbox::after { content:'Отпустите файл, чтобы прикрепить'; position:absolute; inset:3px; z-index:4; display:grid; place-items:center; padding:8px; border:1px dashed rgba(157,184,255,.75); border-radius:9px; background:rgba(23,43,72,.88); color:#dce7ff; font-size:12px; font-weight:550; text-align:center; pointer-events:none; }
@media (max-width: 640px) {
/* A phone: the chat is a screen of its own, sliding up. */
.ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); }
.ai-box.on { transform:none; }
}
.ai-send { width:40px; height:40px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:8px; }
.ai-send { width:36px; height:36px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:9px; }
.ai-send .ai-plane { display:block; flex:none; transform:translateX(.5px); }
/* The panel's own confirm and prompt. */
dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560px, 92vw); }
.uidlg form { padding:18px; display:grid; gap:14px; }
@@ -928,8 +939,8 @@ body:not(.touch) .ai-head { cursor:grab; user-select:none; }
body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; }
body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
/* Send: the field's height, square. */
.ai-form-in textarea, .ai-send, .ai-attach, .ai-from-pane { box-sizing:border-box; }
.ai-send { width:40px; height:40px; align-self:flex-end; }
.ai-form-in textarea, .ai-send, .ai-attach { box-sizing:border-box; }
.ai-send { width:36px; height:36px; align-self:center; }
@media (max-width: 640px) {
.ai-fab { width:46px; height:46px; right:22px; bottom:24px; }
body.touch.term-active .ai-fab { bottom:110px; }
@@ -943,7 +954,7 @@ body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
/* The field and the send button: one height on every phone, the button
square whatever the text size. */
.ai-form-in textarea { min-height:40px; }
.ai-send { min-height:40px; max-height:40px; min-width:40px; max-width:40px; line-height:1; border-radius:10px; }
.ai-send { min-height:36px; max-height:36px; min-width:36px; max-width:36px; line-height:1; border-radius:9px; }
.ai-form-in textarea { border-radius:10px; }
/* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */
.ai-form .keybox { display:flex; gap:6px; align-items:center; }
@@ -1081,3 +1092,6 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
table.machines th, table.machines td { text-align:center; }
table.machines td.actions .row { justify-content:center; }
}
/* The + menu's roots (Терминал, Файловый менеджер) a little to the left of
what they open, so the nesting reads. */
.addmenu > .item { padding-left:7px; }
+18
View File
@@ -19,6 +19,9 @@
// start: a pointerdown on a row of pane; the drag begins past 6px.
function start(ev, pane, tr) {
if (ev.button !== 0 || ev.target.closest('input, button, a, .act')) return;
// Keep the file pane's pointer drag as the only drag source. Otherwise
// the browser can start a native text-selection drag beside our ghost.
ev.preventDefault();
const name = tr.dataset.name;
const names = pane.selected.has(name) ? [...pane.selected] : [name];
d = {pane, names, x0: ev.clientX, y0: ev.clientY, id: ev.pointerId, on: false,
@@ -56,6 +59,7 @@
d.target = null;
box.hidden = true;
d.ghost.dataset.hint = '';
if (window.AIChat) window.AIChat.setDragging(false);
// Over a tab's title: it opens after half a second.
const tab = Workspace.tabAt(ev.clientX, ev.clientY);
if (tab !== d.overTab) {
@@ -64,6 +68,11 @@
if (tab) d.tabTimer = setTimeout(() => { if (d && d.overTab === tab) Workspace.activate(tab); }, 500);
}
if (tab) { d.ghost.dataset.hint = 'открыть вкладку'; return; }
if (window.AIChat && window.AIChat.isDropTargetAt(ev.clientX, ev.clientY)) {
window.AIChat.setDragging(true);
d.target = {kind: 'ai'};
return;
}
const pane = Workspace.paneAt(ev.clientX, ev.clientY);
if (!pane || !pane.dropTarget) return;
const t = pane.dropTarget(ev.clientX, ev.clientY);
@@ -87,14 +96,23 @@
x.ghost.remove();
box.hidden = true;
document.body.classList.remove('dnd');
if (window.AIChat) window.AIChat.setDragging(false);
justDropped = true;
setTimeout(() => { justDropped = false; }, 0);
if (cancel || !x.target) return;
if (x.target.kind === 'ai') { window.AIChat.dropFiles(x.pane, x.names); return; }
const {pane, t, a} = x.target;
Workspace.setActivePane(pane);
if (a.kind === 'paste') pane.drop(x.item);
else if (a.kind === 'transfer') ask(x.item, x.pane, t, ev.clientX, ev.clientY);
}
// While FileDnD owns the pointer, never let the browser start a second,
// native drag (for example from the SVG icon or the filename under it).
document.addEventListener('dragstart', ev => {
if (!d) return;
ev.preventDefault();
ev.stopImmediatePropagation();
}, true);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', ev => end(ev));
window.addEventListener('pointercancel', ev => end(ev, true));
+148 -1
View File
@@ -524,5 +524,152 @@
return sheets(list);
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs};
// Extract text from PDF content streams without a heavyweight renderer or OCR.
// Flate-compressed page streams are inflated with the browser's native API.
async function pdf(buf) {
const input = new Uint8Array(buf);
let source = '';
for (let i = 0; i < input.length; i += 0x8000) source += String.fromCharCode(...input.subarray(i, i + 0x8000));
if (!source.startsWith('%PDF-')) throw new Error('это не PDF-файл');
const objects = /\b(\d+)\s+\d+\s+obj\b/g, pages = [];
let inflatedBytes = 0, textBytes = 0, match;
async function inflate(data) {
const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate')).getReader();
const chunks = []; let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.length;
if (total > 16 * 1024 * 1024 || inflatedBytes + total > 32 * 1024 * 1024) {
await reader.cancel(); throw new Error('PDF stream слишком велик');
}
chunks.push(part.value);
}
const result = new Uint8Array(total); let at = 0;
chunks.forEach(chunk => { result.set(chunk, at); at += chunk.length; });
inflatedBytes += total;
return result;
}
while ((match = objects.exec(source))) {
const bodyStart = objects.lastIndex, possibleEnd = source.indexOf('endobj', bodyStart);
if (possibleEnd < 0) break;
const probe = source.slice(bodyStart, possibleEnd), stream = /\bstream\r?\n/.exec(probe);
if (!stream) { objects.lastIndex = possibleEnd + 6; continue; }
const dict = probe.slice(0, stream.index);
let start = bodyStart + stream.index + stream[0].length;
const length = /\/Length\s+(\d+)\b/.exec(dict);
let end = length ? start + Number(length[1]) : source.indexOf('endstream', start);
if (end < start) { objects.lastIndex = possibleEnd + 6; continue; }
let endObj = source.indexOf('endobj', length ? end : end + 9);
if (endObj < 0) break;
if (/\/Subtype\s*\/Image|\/Type\s*\/(?:ObjStm|XObject|Font|FontFile)/.test(dict)) { objects.lastIndex = endObj + 6; continue; }
let raw = source.slice(start, end);
if (!length) raw = raw.replace(/[\r\n]+$/, '');
let data = Uint8Array.from(raw, c => c.charCodeAt(0) & 255);
if (dict.includes('/FlateDecode') && !dict.includes('/ASCII85Decode')) {
try { data = await inflate(data); }
catch (e) { objects.lastIndex = endObj + 6; continue; }
} else if (/\/Filter\b/.test(dict)) { objects.lastIndex = endObj + 6; continue; }
let content = '';
for (let i = 0; i < data.length; i += 0x8000) content += String.fromCharCode(...data.subarray(i, i + 0x8000));
if (content.includes('BT') && (content.includes('Tj') || content.includes('TJ'))) {
pages.push(content);
textBytes += content.length;
if (textBytes >= 2 * 1024 * 1024) break;
}
objects.lastIndex = endObj + 6;
}
const decode = bytes => {
if (bytes.length > 1 && bytes[0] === 0xfe && bytes[1] === 0xff) {
const swapped = new Uint8Array(bytes.length - 2);
for (let i = 2; i + 1 < bytes.length; i += 2) { swapped[i - 2] = bytes[i + 1]; swapped[i - 1] = bytes[i]; }
return new TextDecoder('utf-16le').decode(swapped);
}
return new TextDecoder('windows-1252').decode(bytes);
};
function readString(s, at, literal) {
const bytes = [];
if (literal) {
let depth = 1, i = at + 1;
for (; i < s.length && depth; i++) {
let c = s.charCodeAt(i);
if (c === 92) {
c = s.charCodeAt(++i);
if (c === 110) bytes.push(10); else if (c === 114) bytes.push(13); else if (c === 116) bytes.push(9);
else if (c === 98) bytes.push(8); else if (c === 102) bytes.push(12);
else if (c === 10) { /* escaped line break */ }
else if (c === 13) { if (s.charCodeAt(i + 1) === 10) i++; }
else if (c >= 48 && c <= 55) {
let oct = String.fromCharCode(c);
for (let n = 0; n < 2 && /[0-7]/.test(s.charAt(i + 1)); n++) oct += s.charAt(++i);
bytes.push(parseInt(oct, 8) & 255);
} else bytes.push(c & 255);
} else if (c === 40) { depth++; bytes.push(c); }
else if (c === 41) { if (--depth) bytes.push(c); }
else bytes.push(c & 255);
}
return {value: decode(Uint8Array.from(bytes)), end: i};
}
let hex = '', i = at + 1;
for (; i < s.length && s.charAt(i) !== '>'; i++) if (!/\s/.test(s.charAt(i))) hex += s.charAt(i);
if (hex.length % 2) hex += '0';
for (let i = 0; i + 1 < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16));
return {value: decode(Uint8Array.from(bytes)), end: i + 1};
}
function textFrom(content) {
let out = '', inText = false, i = 0, operands = [];
const token = () => {
while (i < content.length && /[\x00\t\n\f\r ]/.test(content.charAt(i))) i++;
if (content.charAt(i) === '%') { while (i < content.length && content.charAt(i) !== '\n') i++; return token(); }
if (i >= content.length) return null;
const c = content.charAt(i);
if (c === '(') { const v = readString(content, i, true); i = v.end; return {type: 'str', value: v.value}; }
if (c === '<' && content.charAt(i + 1) !== '<') { const v = readString(content, i, false); i = v.end; return {type: 'str', value: v.value}; }
if (c === '[') {
i++; const values = [];
while (i < content.length) {
while (/[\x00\t\n\f\r ]/.test(content.charAt(i))) i++;
if (content.charAt(i) === ']') { i++; break; }
if (content.charAt(i) === '(' || (content.charAt(i) === '<' && content.charAt(i + 1) !== '<')) {
const v = readString(content, i, content.charAt(i) === '('); i = v.end; values.push(v.value);
} else {
const number = /^[+-]?(?:\d+\.?\d*|\.\d+)/.exec(content.slice(i));
if (number) { values.push(Number(number[0])); i += number[0].length; } else i++;
}
}
return {type: 'array', value: values};
}
if (c === '/') { i++; while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++; return {type: 'name'}; }
const start = i++;
while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++;
return {type: 'word', value: content.slice(start, i)};
};
while (i < content.length) {
const t = token();
if (!t) break;
if (t.type !== 'word' || !/^[A-Za-z*'"]+$/.test(t.value)) { operands.push(t); continue; }
const op = t.value;
if (op === 'BT') { inText = true; operands = []; continue; }
if (op === 'ET') { inText = false; out += '\n'; operands = []; continue; }
if (inText && ['Tj', 'TJ', "'", '"'].includes(op)) {
if (op === "'" || op === '"') out += '\n';
const v = operands[operands.length - 1];
if (v && v.type === 'str') out += v.value;
else if (v && v.type === 'array') out += v.value.map(x => typeof x === 'number' ? (x < -120 ? ' ' : '') : x).join('');
} else if (inText && op === 'Tm') {
if (out && !out.endsWith('\n')) out += '\n';
} else if (inText && (op === 'T*' || op === 'Td' || op === 'TD')) {
if (op === 'T*' || Number(operands[operands.length - 1]?.value) !== 0) out += '\n';
else out += ' ';
}
operands = [];
}
return out;
}
const text = pages.map(textFrom).join('\n').replace(/[\t ]+\n/g, '\n').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if (!text) throw new Error('в PDF не найден извлекаемый текст (сканированные страницы без OCR не читаются)');
return text.slice(0, 120000);
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, pdf, sqlite, bindTabs};
})();
@@ -0,0 +1,32 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const zlib = require('node:zlib');
const runtime = {window: {}, Blob, Response, DecompressionStream, TextDecoder, Uint8Array};
vm.createContext(runtime);
vm.runInContext(fs.readFileSync(path.join(__dirname, 'docview.js'), 'utf8'), runtime);
function pdfWithStream(content, compressed = false) {
const stream = compressed ? zlib.deflateSync(Buffer.from(content, 'latin1')) : Buffer.from(content, 'latin1');
const filter = compressed ? ' /Filter /FlateDecode' : '';
const prefix = `%PDF-1.4\n1 0 obj\n<< /Length ${stream.length}${filter} >>\nstream\n`;
return Buffer.concat([Buffer.from(prefix, 'latin1'), stream, Buffer.from('\nendstream\nendobj\n%%EOF', 'latin1')]);
}
test('PDF text extraction handles literal strings and escaped parentheses', async () => {
const pdf = pdfWithStream('BT /F1 12 Tf 72 700 Td (Hello \\(PDF\\)) Tj ET');
assert.equal(await runtime.window.DocView.pdf(pdf), 'Hello (PDF)');
});
test('PDF text extraction inflates streams and keeps TJ word spacing', async () => {
const pdf = pdfWithStream('BT /F1 12 Tf [(lightweight) -250 ( parser)] TJ ET', true);
assert.equal(await runtime.window.DocView.pdf(pdf), 'lightweight parser');
});
test('PDF text extraction reports scanned or non-text PDFs without OCR', async () => {
const pdf = pdfWithStream('q 0 0 100 100 re f Q');
await assert.rejects(runtime.window.DocView.pdf(pdf), /без OCR/);
});
+5 -4
View File
@@ -38,8 +38,8 @@
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|conf|ovpn|env|properties)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '<span class="ic' + (k === 'dir' || k === 'dirlink' ? ' dir' : '') + '"' + (e.link ? ' title="ссылка"' : '') + '>' + FICONS[k] + '</span>';
}
@@ -219,7 +219,6 @@
const r = await this.api('list', {path});
if (!r.ok) {
if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true);
this.q('.fp-path').value = this.cwd;
return false;
}
this.show(r.data);
@@ -257,6 +256,8 @@
this.updateSel();
for (const e of this.sorted()) {
const tr = document.createElement('tr');
tr.draggable = false;
tr.addEventListener('dragstart', ev => { ev.preventDefault(); ev.stopPropagation(); });
if (e.is_dir) tr.dataset.dir = e.name;
tr.dataset.name = e.name;
const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name);
@@ -272,7 +273,7 @@
'<button class="btn danger small icon" data-act="del" title="Удалить">' + ICON_DEL + '</button>' +
'</div></td>';
const dl = tr.querySelector('a[download]');
if (dl) dl.href = this.src.url('download', join(this.cwd, e.name));
if (dl) { dl.draggable = false; dl.href = this.src.url('download', join(this.cwd, e.name)); }
tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e);
tr.querySelector('.ck').checked = this.selected.has(e.name);
tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); };
+1 -1
View File
@@ -110,7 +110,7 @@
js:'javascript', mjs:'javascript', cjs:'javascript', jsx:'javascript', ts:'text/typescript', tsx:'text/typescript', json:'application/json',
css:'css', scss:'text/x-scss', less:'text/x-less', html:'htmlmixed', htm:'htmlmixed', vue:'htmlmixed', xml:'xml', svg:'xml', plist:'xml', xsd:'xml',
php:'php', sh:'shell', bash:'shell', zsh:'shell', ksh:'shell', yaml:'yaml', yml:'yaml', md:'markdown', markdown:'markdown', sql:'sql',
toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties',
toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties', ovpn:'properties',
service:'properties', timer:'properties', socket:'properties', mount:'properties', lua:'lua', rs:'rust', diff:'diff', patch:'diff',
rb:'ruby', pl:'perl', pm:'perl', cmake:'cmake'
};
+22 -9
View File
@@ -147,20 +147,25 @@
placeFloat();
}
// With one tab and a file pane in the top right corner, the "+" joins
// that pane's top bar: right of "Обновить", above "Удалить выбранное",
// a button of the same size. It is positioned in .area, not the scrolling
// .fpane-body, so it stays beside the path field while the file list scrolls.
// Otherwise it is back in the corner.
// that pane's first toolbar after "Обновить". It belongs to the scrolling
// file pane, so it stays beside the toolbar when the pane is scrolled.
// Otherwise it returns to the corner of .area.
function placeFloat() {
const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null;
const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : [];
// A bar folded in two (a phone) keeps the corner "+".
// A bar folded in two (a phone or a narrow pane) keeps the corner "+".
const on = bars.length > 1 && bars[0].offsetParent !== null && bars[0].offsetHeight < 48;
floatAdd.classList.toggle('in-files', on);
if (!on) { floatAdd.style.top = floatAdd.style.right = ''; return; }
const a = area.getBoundingClientRect(), top = bars[0].getBoundingClientRect(), row = bars[1].getBoundingClientRect();
floatAdd.style.top = (top.top - a.top - area.clientTop) + 'px';
floatAdd.style.right = (a.left + area.clientLeft + area.clientWidth - row.right) + 'px';
if (on) {
bars[0].appendChild(floatAdd);
if (bars[0].offsetHeight >= 48) {
floatAdd.classList.remove('in-files');
area.insertBefore(floatAdd, area.firstChild);
}
} else {
area.insertBefore(floatAdd, area.firstChild);
}
floatAdd.style.top = floatAdd.style.right = '';
}
// One tab: a plain terminal, the strip folded into the corner "+".
function updateMode() {
@@ -344,6 +349,14 @@
// it. A terminal from a split, by its title bar: to the strip to make it a
// tab of its own, or onto another terminal of its tab to swap places.
let drag = null, dragJustEnded = false;
// Safari may start a native drag for the tab or header text while our
// pointer-based drag is active, producing a second image under the cursor.
// External files are unaffected: their dragstart originates outside this page.
document.addEventListener('dragstart', ev => {
if (!drag && !document.body.classList.contains('ai-dragging')) return;
ev.preventDefault();
ev.stopImmediatePropagation();
}, true);
function startDrag(ev, kind, obj) {
if (ev.button !== 0) return;
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
+2 -2
View File
@@ -48,11 +48,11 @@
// closed ``` blocks headed run [machine], read|ls [machine:]path or
// write [machine:]path (its body the file's text).
function parseActions(text) {
const out = [], re = /```(run|read|ls|write|search)([^\n]*)\n([\s\S]*?)```/g;
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
for (let m; (m = re.exec(text));) {
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
if (kind === 'search') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
const c = head.indexOf(':');
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
out.push({kind, machine, path: machine ? head.slice(c + 1) : head, body: kind === 'write' ? body : ''});
@@ -80,3 +80,8 @@ test('parseActions: search blocks', () => {
assert.deepStrictEqual(U.parseActions('Ищу:\n```search\nnginx 1.27 changelog\n```'),
[{kind: 'search', machine: '', path: '', body: 'nginx 1.27 changelog'}]);
});
test('parseActions: fetch blocks', () => {
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
});
+3 -8
View File
@@ -88,10 +88,7 @@
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
<label for="direct">Прямой (без Cloudflare) - необязательно</label>
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
<p class="muted">Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной
недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик
качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост.
Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).</p>
<p class="muted">Адрес сервера в обход прокси: агенты и установщик переходят на него, если основной не отвечает.</p>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
@@ -153,9 +150,7 @@
<div class="card" id="ai">
<h2>ИИ-ассистент</h2>
<p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере: его видят только администраторы здесь, в чат он не попадает.</p>
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Тип API<select name="ai_type">
<option value="gradio"{{if eq .AI.Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
@@ -167,7 +162,7 @@
<label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
<h3 class="ai-sub">Поиск в интернете</h3>
<p class="muted" style="margin:0">Для плашки «Поиск» в чате. Если выбранный поисковик не ответит, поиск пойдёт через DuckDuckGo.</p>
<p class="muted" style="margin:0">Запасной - DuckDuckGo.</p>
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>