The gist of the prompt box and a message of a private task went to the AI whole or not at all. The Hide button marks the selected words; seal_marked turns the marked words into tokens when the text is handed in, and the page shows them again as the words they stand for. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
135 lines
6.2 KiB
JavaScript
135 lines
6.2 KiB
JavaScript
/**
|
|
* 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);
|
|
const hide = hideButton(field, (text) => { note.textContent = text; }); // a private task: words the AI gets as a token
|
|
const shown = () => { hide.hidden = !source.task?.()?.private; };
|
|
field.addEventListener('focus', shown);
|
|
shown();
|
|
line.insertBefore(hide, send);
|
|
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);
|
|
}
|