From 70729ef63fc644a0c0afa78dec1cec8f17dc2d94 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 18:02:43 +0300 Subject: [PATCH] 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 --- internal/server/web/static/ai.js | 42 ++++++++++++++++++++++-------- internal/server/web/static/app.css | 7 +++-- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index ef17327..9439b4b 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -25,7 +25,8 @@ '' + '
' + '
Спросите AI помощника
' + - '
' + + '' + + '' + '
' + '' + @@ -34,7 +35,6 @@ '' + '
' + - '' + '
' + '
' + '' + @@ -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); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 6516147..d210a09 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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) {