feat: folders and files the assistant cannot take, dropped from a file pane, go into the message as their paths; a file that fails shows a red note over the field, fading in and out like an error toast
This commit is contained in:
1 parent
2d6b3916c8
commit
70729ef63f
2 files changed
+36
-13
No files matched your search
@@ -25,7 +25,8 @@
|
||||
'<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"><div class="ai-hello"><span class="ai-hello-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg></span><span class="ai-hello-text">Спросите AI помощника</span></div></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>' +
|
||||
'<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 class="ai-file-error" role="alert"></div></div>' +
|
||||
'<div class="ai-pills">' +
|
||||
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
|
||||
'<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="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
|
||||
@@ -34,7 +35,6 @@
|
||||
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
|
||||
'<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,.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>' +
|
||||
@@ -43,7 +43,7 @@
|
||||
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'), filePicker = q('.ai-file-picker');
|
||||
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;
|
||||
@@ -218,7 +218,7 @@
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
list.scrollTop = 0;
|
||||
jump.hidden = true;
|
||||
filePicker.hidden = true;
|
||||
hideFileError();
|
||||
q('.ai-hello').hidden = !!id;
|
||||
if (!id) { updateJump(); return; }
|
||||
try {
|
||||
@@ -459,6 +459,9 @@
|
||||
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 = '';
|
||||
@@ -480,11 +483,16 @@
|
||||
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) {
|
||||
filePicker.hidden = false; filePicker.replaceChildren();
|
||||
const text = document.createElement('span'); text.textContent = message; filePicker.appendChild(text);
|
||||
setTimeout(() => { if (filePicker.firstChild === text) filePicker.hidden = true; }, 5000);
|
||||
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 МиБ на файл');
|
||||
@@ -501,7 +509,7 @@
|
||||
if (!text) throw new Error(name + ': текст не найден');
|
||||
return [{name, mime: 'text/plain', text: text.slice(0, 120000), size: file.size}];
|
||||
}
|
||||
const fallback = {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'}[ext] || '';
|
||||
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}];
|
||||
@@ -717,7 +725,7 @@
|
||||
input.value = '';
|
||||
grow();
|
||||
where();
|
||||
filePicker.hidden = true;
|
||||
hideFileError();
|
||||
talkTurn(text, files);
|
||||
};
|
||||
box.addEventListener('dragover', ev => {
|
||||
@@ -734,12 +742,13 @@
|
||||
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 = [];
|
||||
const errors = [], paths = [];
|
||||
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;
|
||||
// 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 + ': этот файл уже прикреплён');
|
||||
@@ -754,6 +763,7 @@
|
||||
} 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) {
|
||||
@@ -774,6 +784,16 @@
|
||||
}
|
||||
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.
|
||||
function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; }
|
||||
input.addEventListener('input', grow);
|
||||
|
||||
@@ -922,8 +922,11 @@ body.touch.term-active .ai-fab { bottom:104px; }
|
||||
.ai-file-chip span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ai-file-chip button { flex:none; width:18px; height:18px; padding:0; border:0; border-radius:50%; background:transparent; color:var(--muted); line-height:1; }
|
||||
.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-error { position:absolute; left:12px; right:12px; bottom:12px; z-index:3; padding:10px 14px; border:1px solid #6b2b2b; border-radius:8px;
|
||||
background:#3a1d1d; color:#ffb3b3; font-size:13px; line-height:1.4; text-align:center; overflow-wrap:anywhere;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.4); opacity:0; transform:translateY(8px); pointer-events:none;
|
||||
transition:opacity .2s ease, transform .2s ease; }
|
||||
.ai-file-error.show { opacity:1; transform:none; }
|
||||
.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) {
|
||||
|
||||
Reference in new issue
Block a user