Add multimodal AI chat attachments
This commit is contained in:
1 parent
8aba30a093
commit
84b42c5369
6 files changed
+434
-39
No files matched your search
@@ -24,7 +24,8 @@
|
||||
'<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-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="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
|
||||
'<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>' +
|
||||
@@ -33,11 +34,23 @@
|
||||
'<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>' +
|
||||
'<form class="ai-form-in"><textarea rows="1" placeholder="Сообщение…"></textarea>' +
|
||||
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></form>';
|
||||
'<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>' +
|
||||
'<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);
|
||||
const q = s => box.querySelector(s);
|
||||
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
|
||||
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 nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80;
|
||||
function updateJump() { jump.hidden = nearBottom() || !isOpen(); }
|
||||
list.addEventListener('scroll', updateJump, {passive: true});
|
||||
jump.onclick = () => { list.scrollTo({top: list.scrollHeight, behavior: 'smooth'}); };
|
||||
function scrollIfFollowing(follow) { if (follow) list.scrollTop = list.scrollHeight; updateJump(); }
|
||||
// The talk lives on the server (the database); here only what is shown.
|
||||
let loaded = false;
|
||||
let ac = null;
|
||||
@@ -51,6 +64,7 @@
|
||||
placeBox();
|
||||
box.classList.add('on');
|
||||
fab.classList.add('away');
|
||||
requestAnimationFrame(updateJump);
|
||||
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
||||
where();
|
||||
if (!phone()) input.focus();
|
||||
@@ -202,8 +216,11 @@
|
||||
setChat(id);
|
||||
drawChats();
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
list.scrollTop = 0;
|
||||
jump.hidden = true;
|
||||
filePicker.hidden = true;
|
||||
q('.ai-hello').hidden = !!id;
|
||||
if (!id) return;
|
||||
if (!id) { updateJump(); return; }
|
||||
try {
|
||||
const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
|
||||
if (chatId !== id) return;
|
||||
@@ -212,6 +229,8 @@
|
||||
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
||||
});
|
||||
if (!h.length) q('.ai-hello').hidden = false;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
updateJump();
|
||||
} catch (e) { /* stays empty */ }
|
||||
}
|
||||
// The chat last open, when the window first opens.
|
||||
@@ -427,13 +446,74 @@
|
||||
body.innerHTML = html;
|
||||
el.dataset.text = text;
|
||||
}
|
||||
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 MAX_FILE = 8 * 1024 * 1024, MAX_ATTACHMENTS = 6, MAX_TOTAL = 16 * 1024 * 1024;
|
||||
function bytesToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf); let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(bin);
|
||||
}
|
||||
function previewText(html) {
|
||||
return new DOMParser().parseFromString(html, 'text/html').body.textContent.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
function renderAttachments() {
|
||||
attachList.innerHTML = '';
|
||||
attachList.hidden = !attachments.length;
|
||||
attachments.forEach((a, i) => {
|
||||
const chip = document.createElement('span'); chip.className = 'ai-file-chip';
|
||||
const name = document.createElement('span'); name.textContent = a.name; name.title = a.name;
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.title = 'Убрать вложение'; remove.setAttribute('aria-label', 'Убрать ' + a.name); remove.textContent = '×';
|
||||
remove.onclick = () => { attachments.splice(i, 1); renderAttachments(); };
|
||||
chip.append(name, remove); attachList.appendChild(chip);
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
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/')) {
|
||||
const buf = await file.arrayBuffer(); let text;
|
||||
if (ext === 'docx') text = previewText(await DocView.docx(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));
|
||||
else if (ext === 'doc') text = previewText(DocView.doc(buf));
|
||||
else if (ext === 'csv' || ext === 'tsv') text = previewText(DocView.csv(new TextDecoder().decode(buf), ext === 'tsv'));
|
||||
else text = new TextDecoder().decode(buf);
|
||||
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 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}];
|
||||
}
|
||||
async function addFiles(files) {
|
||||
for (const file of files) {
|
||||
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 МиБ.');
|
||||
attachments.push(...ready); renderAttachments();
|
||||
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл'); }
|
||||
}
|
||||
}
|
||||
function add(role, text, thinkMs) {
|
||||
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;
|
||||
const follow = nearBottom();
|
||||
list.appendChild(el);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
scrollIfFollowing(follow);
|
||||
return el;
|
||||
}
|
||||
// An action's result shows folded; it reaches the talk on the server with
|
||||
@@ -465,8 +545,11 @@
|
||||
return detail || 'Ошибка сервиса ИИ (HTTP ' + status + ').';
|
||||
}
|
||||
// ask sends the new words (or an action's result) and shows the answer.
|
||||
async function ask(text, res) {
|
||||
if (text) add('user', text);
|
||||
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);
|
||||
}
|
||||
if (res) showResult(res);
|
||||
const el = add('assistant', '');
|
||||
el.classList.add('wait');
|
||||
@@ -479,7 +562,7 @@
|
||||
await loadMachines();
|
||||
const r = await fetch('/api/panel/ai/chat', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId})
|
||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || []})
|
||||
});
|
||||
// A new chat gets its id (and its name) from the answer.
|
||||
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
|
||||
@@ -499,14 +582,17 @@
|
||||
if (done) break;
|
||||
answer += dec.decode(value, {stream: true});
|
||||
el.classList.remove('wait');
|
||||
const follow = nearBottom();
|
||||
render(el, answer, false);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
scrollIfFollowing(follow);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
return {el, answer: stripThink(answer)};
|
||||
}
|
||||
@@ -526,8 +612,8 @@
|
||||
// talkTurn: one question, and in agent mode the steps after it - the
|
||||
// first action of each answer done (asking first for commands and writes)
|
||||
// and its result sent back - until an answer asks for nothing.
|
||||
async function talkTurn(text) {
|
||||
let turn = await ask(text);
|
||||
async function talkTurn(text, files) {
|
||||
let turn = await ask(text, null, files);
|
||||
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
|
||||
const a = WSUtil.parseActions(turn.answer)[0];
|
||||
if (!a) break;
|
||||
@@ -548,16 +634,52 @@
|
||||
ac = null;
|
||||
send.innerHTML = PLANE; send.title = 'Отправить';
|
||||
}
|
||||
q('.ai-form-in').onsubmit = ev => {
|
||||
const form = q('.ai-form-in'), attachBtn = q('.ai-attach'), paneBtn = q('.ai-from-pane');
|
||||
attachBtn.onclick = () => attachInput.click();
|
||||
attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; };
|
||||
paneBtn.onclick = () => showPaneFiles();
|
||||
form.onsubmit = ev => {
|
||||
ev.preventDefault();
|
||||
if (ac) { ac.abort(); return; }
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
if (!text && !attachments.length) return;
|
||||
const files = attachments.slice(); attachments = []; renderAttachments();
|
||||
input.value = '';
|
||||
grow();
|
||||
where();
|
||||
talkTurn(text);
|
||||
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('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]);
|
||||
});
|
||||
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;
|
||||
}
|
||||
// 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);
|
||||
|
||||
Reference in new issue
Block a user