/** * The AI workbench's message box (ui_ai_workbench.js keeps it at the foot of a task's column, in * view as the timeline scrolls): where the user writes to the AI on that task. Over the input * what the message carries (ui_ai_tray.js: pictures to mark up or leave out, what "@" names); * beside it the gallery (ui_ai_gallery_pick.js) and the materials picker, each lit with how many * it gave; pictures dropped on it or pasted (ui_ai_drop.js); the input grows with what is typed * (Enter sends, Shift+Enter breaks the line), with "@" to point at things and "/" for commands * (ui_ai_mention.js). The words and what is attached * go to `onSend(text, refs)`, words optional when something is attached; the AI gets them the * next time it asks (get_answers), which the hint says, and says to ask in the AI app once the * task ended. Made once per task, so half-typed text survives the task's redraws. */ import { translate as t } from './locales.js'; import { mentions } from './ui_ai_mention.js'; import { galleryPicker } from './ui_ai_gallery_pick.js'; import { takePictures } from './ui_ai_drop.js'; import { iconButton } from './ui_ai_icons.js'; import { noteTyping } from './ui_ai_panel.js'; const MAX_REFS = 12; // api/ai_message_refs.py const MAX_PICTURES = 8; // a message's pictures, as ai_pictures.py hands them const MAX_HEIGHT = 168; // the input grows up to about seven lines, then scrolls function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } /** A button beside the input, with the number of what it gave on its corner. */ function sideTool(className, name, label, run) { const node = iconButton(`anomalous-ai-message-tool ${className}`, name, label, run, { iconOnly: true, size: 17 }); const count = el('span', 'anomalous-ai-message-tool-count'); node.append(count); node.setCount = (value) => { count.textContent = value ? String(value) : ''; node.classList.toggle('has-items', value > 0); }; return node; } /** * The box; `source`: {task(), commands(), mark(image, sent), openMaterials()} for "@", "/", the * tray's ✎ and the materials button. With them, `box.setMaterials(count)` shows how many materials are picked. */ export function messageBox(onSend, source) { const box = el('div', 'anomalous-ai-message'); const tray = el('div', 'anomalous-ai-tray'); tray.hidden = true; const line = el('div', 'anomalous-ai-message-line'); const field = el('textarea', 'anomalous-ai-message-input'); field.rows = 1; field.maxLength = 2000; field.placeholder = t('aiMessagePlaceholder'); const note = el('div', 'anomalous-ai-message-hint'); const send = el('button', 'anomalous-ai-panel-send', t('aiMessageSend')); send.type = 'button'; let at = null; // "@" and "/" (made once the box is in place) const fit = () => { field.style.height = 'auto'; field.style.height = `${Math.min(field.scrollHeight, MAX_HEIGHT)}px`; }; send.onclick = async () => { const text = field.value.trim(); const refs = at?.refs() || []; if (!text && !refs.length) return; send.disabled = true; try { await onSend(text, refs); field.value = ''; fit(); at?.clear(); } catch (error) { note.textContent = error.message; note.classList.add('is-error'); } finally { send.disabled = false; } }; field.addEventListener('keydown', (event) => { event.stopPropagation(); // typing here is not the browser's shortcuts if (at?.keydown(event)) return; if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); send.click(); } }); field.addEventListener('input', fit); field.addEventListener('input', () => noteTyping(source?.task?.()?.id)); line.append(field, send); box.append(tray, line, note); if (source) { at = mentions(field, box, source, tray); const room = () => Math.min(MAX_PICTURES - at.pictures(), MAX_REFS - at.count()); const pics = sideTool('anomalous-ai-message-pics', 'image', t('aiPicsButton'), () => picker.toggle()); pics.setAttribute('aria-expanded', 'false'); const picker = galleryPicker(box, room, (items) => { for (const item of items) at.add(item); field.focus(); }, pics); const materials = sideTool('anomalous-ai-message-materials', 'sparkle', t('aiMessageMaterials'), () => source.openMaterials()); const counted = () => pics.setCount(at.pictures()); box.addEventListener('anomalous-refs', counted); field.addEventListener('input', counted); line.prepend(pics, materials); takePictures(box, field, { add: item => at.add(item), room, say: (text) => { note.textContent = text; note.classList.add('is-error'); } }); box.setMaterials = count => materials.setCount(count); } box.note = note; box.field = field; return box; } /** The hint for where the task stands (the input's tooltip too: a short window shows the hint only * for a failure); quieter while the AI is not working on it. While a task waits for an AI * (`status` "waiting"), what is sent waits with it, and the input says it can be used for that. */ export function updateMessageBox(box, ended, status, materials = 0) { box.classList.toggle('is-quiet', !['live', 'asks', 'waiting'].includes(status)); box.note.textContent = t(ended ? 'aiMessageEnded' : status === 'waiting' ? 'aiMessageWaiting' : 'aiMessageHint'); box.note.classList.remove('is-error'); box.field.title = box.note.textContent; box.field.placeholder = t(status === 'waiting' ? 'aiMessagePlaceholderWaiting' : 'aiMessagePlaceholder'); box.setMaterials?.(materials); }