feat: pictures for the assistant - the models make and edit them by themselves through the Vertex API (a new picture, or the user's attached photo, or the last picture made), with an API key in Настройки and a switch for the prompt improver; folded descriptions of pictures and searches; the name of an attached file opens or saves it; a deleted chat is wiped from the database files, attachments included
This commit is contained in:
1 parent
c72fcfebf3
commit
dd1244a169
13 files changed
+925
-20
No files matched your search
@@ -110,8 +110,14 @@
|
||||
// The models to choose from come with the state, again on every opening
|
||||
// (an admin may have changed them).
|
||||
let models = [];
|
||||
// Pictures are on when the admin set the image service's key: the model then
|
||||
// asks for them in its answers (```image blocks) - no switch of the user's.
|
||||
let imageOn = false;
|
||||
// The photos of the user's last message that had any, and the last picture
|
||||
// made here: what an edit works on.
|
||||
let sentImages = [], lastPic = '';
|
||||
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
|
||||
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
|
||||
.then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; drawModels(); }).catch(() => {});
|
||||
loadState();
|
||||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||||
@@ -237,6 +243,7 @@
|
||||
setChat(id);
|
||||
drawChats();
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
sentImages = []; lastPic = '';
|
||||
list.scrollTop = 0;
|
||||
showJump(false);
|
||||
hideFileError();
|
||||
@@ -247,7 +254,7 @@
|
||||
if (chatId !== id) return;
|
||||
h.forEach(m => {
|
||||
if (m.role === 'result') showResult(m.content);
|
||||
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
||||
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; if (DONE[b.dataset.kind]) b.textContent = DONE[b.dataset.kind]; }); }
|
||||
});
|
||||
if (!h.length) q('.ai-hello').hidden = false;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
@@ -367,6 +374,7 @@
|
||||
const p = r.data;
|
||||
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
|
||||
}
|
||||
if (a.kind === 'image') return makeImage(a, signal);
|
||||
// A web search: through the server, only with «Поиск» on.
|
||||
if (a.kind === 'search') {
|
||||
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
|
||||
@@ -421,13 +429,16 @@
|
||||
// ---------- 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: 'поиск', fetch: 'страница'};
|
||||
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница', image: 'генерация картинки'};
|
||||
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"/>'),
|
||||
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: 'Открыть'};
|
||||
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"/>'),
|
||||
image: IC('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')};
|
||||
// What the button of a card that has been done says (else «Готово»).
|
||||
const DONE = {image: 'Создано'};
|
||||
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть', image: 'Создать'};
|
||||
// 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
|
||||
@@ -467,16 +478,42 @@
|
||||
// An action: a card with what and where on its title line, a small
|
||||
// button on the right of it, the command or text below.
|
||||
if (act) {
|
||||
const where = head.slice(kind.length).trim();
|
||||
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
|
||||
// A picture's title says what it is (a new one or an edit); its first line
|
||||
// (format, quality, file) is not shown - the words under it are.
|
||||
const where = kind === 'image' ? '' : head.slice(kind.length).trim(), idx = n++;
|
||||
const title = kind === 'image' && /^image\s+edit(\s|$)/.test(head) ? 'редактирование картинки' : KIND[kind];
|
||||
// The text of a picture or a search folds: a line, or open - up to
|
||||
// a few lines, then it scrolls. What the user left open stays so.
|
||||
const fold = (kind === 'image' || kind === 'search') && code;
|
||||
const open = fold && el._open && el._open.has(idx);
|
||||
html += '<div class="ai-code ai-action' + (fold ? ' foldable' + (open ? ' open' : '') : '') + '" data-n="' + idx + '"><div class="ai-code-h"><span class="ai-kind">' +
|
||||
(fold ? '<button type="button" class="ai-fold" aria-expanded="' + !!open + '" title="' + (open ? 'Свернуть' : 'Развернуть') + '">' + FOLD_CHEV + '</button>' : '') +
|
||||
ICONS[kind] + '<b>' + title + '</b>' +
|
||||
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
|
||||
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
|
||||
'<button type="button" class="ai-act" data-i="' + idx + '" data-kind="' + kind + '">' + LABEL[kind] + '</button></div>' +
|
||||
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
|
||||
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
el.dataset.text = text;
|
||||
// A text that fits one line has nothing to unfold: no arrow for it.
|
||||
requestAnimationFrame(() => body.querySelectorAll('.foldable:not(.open)').forEach(c => {
|
||||
const p = c.querySelector('pre');
|
||||
c.classList.toggle('short', p.scrollWidth <= p.clientWidth + 1 && !/\n/.test(p.textContent));
|
||||
}));
|
||||
}
|
||||
const FOLD_CHEV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
|
||||
list.addEventListener('click', ev => {
|
||||
const t = ev.target.closest('.foldable .ai-fold, .foldable pre');
|
||||
if (!t) return;
|
||||
const card = t.closest('.foldable');
|
||||
if (card.classList.contains('short') && !card.classList.contains('open')) return;
|
||||
const msg = card.closest('.ai-msg'), on = card.classList.toggle('open');
|
||||
const i = Number(card.dataset.n);
|
||||
(msg._open = msg._open || new Set())[on ? 'add' : 'delete'](i);
|
||||
const b = card.querySelector('.ai-fold');
|
||||
b.setAttribute('aria-expanded', String(on)); b.title = on ? 'Свернуть' : 'Развернуть';
|
||||
});
|
||||
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)$/;
|
||||
@@ -514,7 +551,14 @@
|
||||
fileErrTimer = setTimeout(hideFileError, 5000);
|
||||
}
|
||||
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
|
||||
// The file itself stays with its attachment (not sent anywhere): it is what
|
||||
// a click on its name opens or saves.
|
||||
async function makeAttachments(file, name = file.name) {
|
||||
const made = await makeAttachmentsOf(file, name);
|
||||
made.forEach(a => Object.defineProperty(a, 'blob', {value: file}));
|
||||
return made;
|
||||
}
|
||||
async function makeAttachmentsOf(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 === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
|
||||
@@ -550,7 +594,8 @@
|
||||
}
|
||||
}
|
||||
const FILE_KIND = file => {
|
||||
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
|
||||
// Of an earlier talk only the name is kept: the kind comes from it.
|
||||
const ext = fileExt(file.name), mime = (file.mime || MEDIA_MIME[ext] || '').toLowerCase();
|
||||
if (mime.startsWith('image/')) return ['image', 'Изображение'];
|
||||
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
|
||||
if (mime.startsWith('video/')) return ['video', 'Видео'];
|
||||
@@ -566,6 +611,30 @@
|
||||
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"/>'
|
||||
};
|
||||
// What a browser shows by itself: raster pictures, sound, video, PDF. Text
|
||||
// is shown as plain text, so it cannot run anything; the rest is saved.
|
||||
const SHOWN = /^(image\/(png|jpe?g|gif|webp|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf)$/;
|
||||
function openAttached(file) {
|
||||
let blob = file.blob || null, show = false;
|
||||
// The type of the file itself - not of its text (a Word file's attachment
|
||||
// is its text, plain, but the file is not).
|
||||
const ext = fileExt(file.name), own = MEDIA_MIME[ext] || (ext === 'pdf' ? 'application/pdf' : '');
|
||||
const type = ((blob ? blob.type || own : file.mime || own) || '').toLowerCase();
|
||||
if (!blob && file.data) {
|
||||
const bin = atob(file.data), bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
blob = new Blob([bytes], {type});
|
||||
}
|
||||
if (blob && SHOWN.test(type)) { blob = new Blob([blob], {type}); show = true; }
|
||||
else if (blob && (textExt.test(ext) || type.startsWith('text/'))) { blob = new Blob([blob], {type: 'text/plain;charset=utf-8'}); show = true; }
|
||||
else if (!blob && file.text) { blob = new Blob([file.text], {type: 'text/plain;charset=utf-8'}); show = true; }
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob), a = document.createElement('a');
|
||||
a.href = url;
|
||||
if (show) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = file.name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 120000);
|
||||
}
|
||||
function renderFileCards(host, files) {
|
||||
if (!files.length) return;
|
||||
const group = document.createElement('div'); group.className = 'ai-history-files';
|
||||
@@ -575,7 +644,13 @@
|
||||
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;
|
||||
// The name opens the file (or saves it) - when it is at hand: this
|
||||
// page's own attachments; of an earlier talk only a text file's text.
|
||||
const has = !!(file.blob || file.data || file.text);
|
||||
const name = document.createElement(has ? 'button' : 'span');
|
||||
name.className = 'ai-history-file-name' + (has ? ' link' : ''); name.textContent = file.name;
|
||||
name.title = has ? file.name : file.name + ' - файл недоступен после перезагрузки страницы';
|
||||
if (has) { name.type = 'button'; name.onclick = () => openAttached(file); }
|
||||
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) : '');
|
||||
@@ -623,6 +698,51 @@
|
||||
const el = add('result', '');
|
||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||
el.querySelector('pre').textContent = text;
|
||||
// A picture made: shown here, from its marker (kept with the talk, so
|
||||
// it comes back with it) - and what the next edit works on.
|
||||
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
|
||||
if (!m) return;
|
||||
lastPic = m[1];
|
||||
const follow = nearBottom();
|
||||
const link = document.createElement('a'), img = document.createElement('img');
|
||||
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
|
||||
img.alt = 'Картинка'; img.src = m[1];
|
||||
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
|
||||
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
|
||||
link.appendChild(img);
|
||||
el.classList.add('pic');
|
||||
el.insertBefore(link, el.firstChild);
|
||||
}
|
||||
// makeImage: a picture asked for in an answer - put at the image service
|
||||
// through the server, asked about until it is done. What comes back is
|
||||
// what the model is told: that it is made and shown (or why not).
|
||||
async function makeImage(a, signal) {
|
||||
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
|
||||
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
|
||||
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
|
||||
if (a.edit) {
|
||||
const want = (a.file || '').toLowerCase();
|
||||
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
|
||||
if (pick) body.image = {mime: pick.mime, data: pick.data};
|
||||
else if (lastPic) body.image_url = lastPic;
|
||||
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
|
||||
}
|
||||
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
||||
if (!r.ok) return 'Ошибка картинки: ' + r.error;
|
||||
let lost = 0;
|
||||
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
|
||||
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
|
||||
if (!s.ok) {
|
||||
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
|
||||
return 'Ошибка картинки: ' + s.error;
|
||||
}
|
||||
if (s.status === 'done') {
|
||||
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
|
||||
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
|
||||
}
|
||||
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
|
||||
}
|
||||
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
|
||||
}
|
||||
list.addEventListener('click', async ev => {
|
||||
const b = ev.target.closest('.ai-act');
|
||||
@@ -632,7 +752,7 @@
|
||||
b.disabled = true;
|
||||
b.textContent = '…';
|
||||
const res = await perform(a);
|
||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
|
||||
showResult(res);
|
||||
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
|
||||
});
|
||||
@@ -766,21 +886,24 @@
|
||||
// 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, files) {
|
||||
const imgs = (files || []).filter(f => f.data && /^image\//.test(f.mime || ''));
|
||||
if (imgs.length) sentImages = imgs;
|
||||
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;
|
||||
// Without «Агент» only searches go on by themselves, with «Поиск» on.
|
||||
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
|
||||
// Without «Агент» only searches go on by themselves, with «Поиск» on -
|
||||
// and pictures, whenever they are on: the model asks for them itself.
|
||||
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn()) && !(a.kind === 'image' && imageOn)) break;
|
||||
const b = turn.el.querySelector('.ai-act');
|
||||
if (needsYes(a) && !await confirmAction(a)) {
|
||||
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
||||
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
||||
continue;
|
||||
}
|
||||
if (b) { b.disabled = true; b.textContent = '…'; }
|
||||
if (b) { b.disabled = true; b.textContent = a.kind === 'image' ? 'Рисую…' : '…'; }
|
||||
const res = await perform(a, ac.signal);
|
||||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
|
||||
if (ac.signal.aborted) { showResult(res); break; }
|
||||
turn = await ask(null, res);
|
||||
}
|
||||
|
||||
@@ -885,6 +885,10 @@ body.touch.term-active .ai-fab { bottom:104px; }
|
||||
.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; }
|
||||
/* A button has a line height of its own, and the name clips at its box: same as the text around. */
|
||||
button.ai-history-file-name.link { padding:0; border:0; background:transparent; font-family:inherit; line-height:1.45; text-align:left; cursor:pointer; max-width:100%; justify-self:start; }
|
||||
button.ai-history-file-name.link:hover { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
|
||||
button.ai-history-file-name.link:focus-visible { outline:none; color:var(--accent); text-decoration:underline; }
|
||||
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
|
||||
|
||||
/* The model starting: three dots rising in turn. */
|
||||
@@ -954,6 +958,12 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
|
||||
.ai-agent input { width:auto; margin:0; }
|
||||
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
|
||||
.ai-msg.result summary { cursor:pointer; }
|
||||
/* A picture the model made: shown whole, the result folded small under it. */
|
||||
.ai-msg.result.pic { padding:0; border:0; display:grid; gap:6px; }
|
||||
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
|
||||
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
|
||||
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
|
||||
.ai-msg.result.pic details { padding:0 4px; }
|
||||
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
|
||||
.ai-cap { font-size:12px; margin-bottom:4px; }
|
||||
/* Modes as pills: on - lit in the accent, off - a quiet outline. */
|
||||
@@ -1091,7 +1101,7 @@ details[open]::details-content { block-size: auto; opacity: 1; }
|
||||
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
|
||||
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
|
||||
.ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; }
|
||||
.ai-kind { display:inline-flex; align-items:baseline; gap:6px; min-width:0; color:var(--muted); }
|
||||
.ai-kind { display:inline-flex; align-items:center; gap:6px; min-width:0; color:var(--muted); }
|
||||
.ai-kind svg { align-self:center; flex:none; color:var(--accent); }
|
||||
.ai-kind b { font-weight:500; color:var(--text); flex:none; }
|
||||
.ai-where-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:11.5px; }
|
||||
@@ -1100,6 +1110,18 @@ details[open]::details-content { block-size: auto; opacity: 1; }
|
||||
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
|
||||
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
|
||||
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
|
||||
/* A picture's or a search's text: a line folded, open up to --ai-fold-lines
|
||||
lines and then it scrolls. */
|
||||
.ai-code.foldable { --ai-fold-lines:6; }
|
||||
.ai-code.foldable pre { line-height:1.45; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; word-break:normal; overflow-wrap:anywhere; }
|
||||
.ai-code.foldable.open pre { white-space:pre-wrap; overflow:auto; max-height:calc(var(--ai-fold-lines) * 1.45em); text-overflow:clip; cursor:text; }
|
||||
.ai-code.foldable.short pre { cursor:default; }
|
||||
.ai-fold { flex:none; width:20px; height:20px; padding:0; margin:0 -2px 0 -4px; display:inline-flex; align-items:center; justify-content:center;
|
||||
color:var(--muted); background:transparent; border:0; border-radius:6px; transition:color .15s, background-color .15s; }
|
||||
.ai-fold:hover { color:var(--text); background:#1b222c; }
|
||||
.ai-fold svg { transition:transform .2s ease; }
|
||||
.ai-code.foldable.open .ai-fold svg { transform:rotate(90deg); }
|
||||
.ai-code.foldable.short .ai-fold { display:none; }
|
||||
/* Аккаунт › Вид. */
|
||||
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
|
||||
.acct-view summary { font-size:14px; color:var(--text); }
|
||||
|
||||
@@ -46,12 +46,25 @@
|
||||
|
||||
// parseActions finds what an assistant's answer asks the panel to do:
|
||||
// closed ``` blocks headed run [machine], read|ls [machine:]path or
|
||||
// write [machine:]path (its body the file's text).
|
||||
// write [machine:]path (its body the file's text); search, fetch, and image
|
||||
// (a picture to make or - with edit - to change; the first line may give
|
||||
// its ratio, quality and the photo's file name).
|
||||
function parseActions(text) {
|
||||
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
|
||||
const out = [], re = /```(run|read|ls|write|search|fetch|image)([^\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 === 'image') {
|
||||
// ```image [edit] [1:1|16:9|…|auto] [fast|artist|0.2plus] [noenhance] [file name]
|
||||
let rest = head, edit = false, enhance = true, ratio = '', model = '', file = '', t;
|
||||
if ((t = /^edit(?=\s|$)\s*/.exec(rest))) { edit = true; rest = rest.slice(t[0].length); }
|
||||
rest = rest.replace(/(?:^|\s)(\d+:\d+|auto)(?=\s|$)/, (_, v) => { ratio = v; return ' '; })
|
||||
.replace(/(?:^|\s)(fast|artist|0\.2plus)(?=\s|$)/, (_, v) => { model = v; return ' '; })
|
||||
.replace(/(?:^|\s)(noenhance|enhance)(?=\s|$)/, (_, v) => { enhance = v === 'enhance'; return ' '; });
|
||||
file = rest.trim();
|
||||
out.push({kind, machine: '', path: '', body, edit, enhance, ratio, model, file});
|
||||
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) : '';
|
||||
|
||||
@@ -85,3 +85,20 @@ 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'}]);
|
||||
});
|
||||
|
||||
test('parseActions: image blocks - a new picture, or an edit, with format, quality and file name in the first line', () => {
|
||||
assert.deepStrictEqual(U.parseActions('Рисую.\n```image\nкот в скафандре на луне\n```'),
|
||||
[{kind: 'image', machine: '', path: '', body: 'кот в скафандре на луне', edit: false, enhance: true, ratio: '', model: '', file: ''}]);
|
||||
assert.deepStrictEqual(U.parseActions('```image 16:9 artist\nзакат над морем\n```'),
|
||||
[{kind: 'image', machine: '', path: '', body: 'закат над морем', edit: false, enhance: true, ratio: '16:9', model: 'artist', file: ''}]);
|
||||
assert.deepStrictEqual(U.parseActions('```image edit my photo.jpg 0.2plus\nубери человека справа\n```'),
|
||||
[{kind: 'image', machine: '', path: '', body: 'убери человека справа', edit: true, enhance: true, ratio: '', model: '0.2plus', file: 'my photo.jpg'}]);
|
||||
assert.deepStrictEqual(U.parseActions('```image edit\nсделай ночь\n```')[0].file, '');
|
||||
assert.strictEqual(U.parseActions('```image\nне закрыт').length, 0);
|
||||
});
|
||||
|
||||
test('parseActions: image blocks - noenhance asks for the description as it is', () => {
|
||||
assert.deepStrictEqual(U.parseActions('```image noenhance 3:2\nflat vector logo, white background\n```'),
|
||||
[{kind: 'image', machine: '', path: '', body: 'flat vector logo, white background', edit: false, enhance: false, ratio: '3:2', model: '', file: ''}]);
|
||||
assert.strictEqual(U.parseActions('```image enhance\nx\n```')[0].enhance, true);
|
||||
});
|
||||
Reference in new issue
Block a user