' +
'
' +
@@ -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) {