- While a combo group follows the pointer, Esc removes it again; the activity log holds back until it is put down, so a cancelled group leaves no entry. - The positive and negative prompt nodes are always laid out (positive empty when the combo has no prompt); the negative takes Prompt Studio's common negative card (starter cards moved to prompt_composition.js as PROMPT_PRESETS). - The UNet "connect clip yourself" message shows once the group is down; it used to pop up while the group followed the pointer, and closing it put the group down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
9.6 KiB
JavaScript
229 lines
9.6 KiB
JavaScript
/**
|
||
* Prompt Studio's cards: three common ones and the prompts you saved (prompt_material_source.js),
|
||
* with search and a small form for a new card. A card is clicked (or added from its preview,
|
||
* ui_prompt_card_popover.js) to go into the prompt box of its role, dragged onto a box, or
|
||
* dragged out onto the canvas (prompt_card_drag.js); a saved one is renamed or deleted from its
|
||
* preview. The saved list is checked again every 30 seconds and when the window comes back.
|
||
*/
|
||
|
||
import { translate as t } from './locales.js';
|
||
import { bindPromptCardDrag } from './prompt_card_drag.js';
|
||
import { PROMPT_PRESETS, promptTitle } from './prompt_composition.js';
|
||
import { loadPromptSourceCards, savePromptCard } from './prompt_material_source.js';
|
||
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
|
||
import { jsonResponse } from './ui_dom.js';
|
||
import { createCardPopover } from './ui_prompt_card_popover.js';
|
||
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
||
|
||
const SYNC_MS = 30000;
|
||
|
||
function el(tag, className, content) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (content !== undefined) node.textContent = content;
|
||
return node;
|
||
}
|
||
|
||
function button(className, label, title, onClick) {
|
||
const node = el('button', className, label);
|
||
node.type = 'button';
|
||
if (title) node.title = title;
|
||
node.onclick = onClick;
|
||
return node;
|
||
}
|
||
|
||
/** Fills `parent`; `onPick(card)` takes a clicked card. Returns { sync() }. */
|
||
export function createPromptSourceDeck(parent, drawer, scope, onPick) {
|
||
const presets = PROMPT_PRESETS.map(card => ({ ...card, title: t(card.titleKey) }));
|
||
let saved = [];
|
||
let keyword = '';
|
||
const popover = createCardPopover({
|
||
drawer,
|
||
scope,
|
||
onAdd: card => onPick(card),
|
||
onRename: card => void rename(card),
|
||
onDelete: card => void remove(card),
|
||
});
|
||
|
||
const panel = el('section', 'anomalous-ps-deck');
|
||
const top = el('div', 'anomalous-ps-deck-top');
|
||
const search = el('input', 'anomalous-ps-search');
|
||
search.type = 'search';
|
||
search.placeholder = t('promptDeckSearch');
|
||
search.oninput = () => {
|
||
keyword = search.value.trim().toLowerCase();
|
||
renderList();
|
||
};
|
||
top.append(search, button('anomalous-ps-icon', '+', t('promptDeckNew'), () => toggleForm()));
|
||
const form = el('form', 'anomalous-ps-new');
|
||
form.hidden = true;
|
||
const list = el('div', 'anomalous-ps-cards');
|
||
const status = el('div', 'anomalous-ps-sync');
|
||
status.setAttribute('role', 'status');
|
||
panel.append(top, form, list, status);
|
||
parent.append(panel);
|
||
|
||
// Blank space of the list lets the preview go.
|
||
list.addEventListener('pointermove', (event) => {
|
||
if (event.target === list) popover.leave(event, true);
|
||
}, { passive: true });
|
||
list.addEventListener('pointerdown', (event) => {
|
||
if (event.target === list) popover.hide(true);
|
||
});
|
||
list.onscroll = () => popover.hide();
|
||
|
||
function toggleForm(open = form.hidden) {
|
||
form.hidden = !open;
|
||
form.replaceChildren();
|
||
if (!open) return;
|
||
const name = el('input', 'anomalous-ps-input');
|
||
name.placeholder = t('promptDeckNewName');
|
||
name.maxLength = 120;
|
||
const content = el('textarea', 'anomalous-ps-input');
|
||
content.placeholder = t('promptDeckNewText');
|
||
content.rows = 3;
|
||
const roles = el('div', 'anomalous-ps-new-roles');
|
||
for (const role of ['positive', 'negative']) {
|
||
const option = el('label', `anomalous-ps-new-role is-${role}`);
|
||
const radio = el('input');
|
||
radio.type = 'radio';
|
||
radio.name = 'anomalous-ps-new-role';
|
||
radio.value = role;
|
||
radio.checked = role === 'positive';
|
||
option.append(radio, el('span', '', t(role === 'negative' ? 'recipePromptRoleNegative' : 'recipePromptRolePositive')));
|
||
roles.append(option);
|
||
}
|
||
const submit = el('button', 'anomalous-ps-mini is-accent', t('promptStudioSave'));
|
||
submit.type = 'submit';
|
||
const row = el('div', 'anomalous-ps-new-row');
|
||
row.append(roles, submit, button('anomalous-ps-mini', t('dialogCancel'), '', () => toggleForm(false)));
|
||
form.append(name, content, row);
|
||
form.onsubmit = async (event) => {
|
||
event.preventDefault();
|
||
const text = content.value.trim();
|
||
if (!text) { content.focus(); return; }
|
||
const role = form.querySelector('input[type="radio"]:checked')?.value || 'positive';
|
||
const title = name.value.trim() || promptTitle(text) || text.slice(0, 40);
|
||
submit.disabled = true;
|
||
try {
|
||
const result = await savePromptCard({ name: title, content: text, role });
|
||
showWorkbenchToast(result.status === 'duplicate' ? t('promptStudioSavedBefore', { name: result.name }) : t('promptStudioSaved', { name: title }));
|
||
if (scope.signal.aborted) return;
|
||
toggleForm(false);
|
||
void sync();
|
||
} catch {
|
||
showWorkbenchToast(t('promptStudioSaveFailed'));
|
||
submit.disabled = false;
|
||
}
|
||
};
|
||
name.focus();
|
||
}
|
||
|
||
async function rename(card) {
|
||
const name = String(await anomalousPrompt(t('promptCardRenameAsk'), card.title, t('promptCardRename')) || '').trim();
|
||
if (!name || name === card.title) return;
|
||
try {
|
||
const detail = await jsonResponse(await fetch(`/anomalous/material_full?include_workflow=0&filename=${encodeURIComponent(card.filename)}`), 'read prompt');
|
||
const response = await fetch('/anomalous/update_material', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: card.filename, name, tags: detail.data?.tags || [] }),
|
||
});
|
||
await jsonResponse(response, 'rename prompt');
|
||
await sync();
|
||
} catch {
|
||
await anomalousAlert(t('promptCardRenameFailed'));
|
||
}
|
||
}
|
||
|
||
async function remove(card) {
|
||
if (!await anomalousConfirm(t('promptCardDeleteConfirm', { name: card.title }), t('promptCardDelete'), { okLabel: t('promptCardDelete') })) return;
|
||
try {
|
||
const response = await fetch('/anomalous/delete_material', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: card.filename }),
|
||
});
|
||
await jsonResponse(response, 'delete prompt');
|
||
await sync();
|
||
} catch {
|
||
await anomalousAlert(t('promptCardDeleteFailed'));
|
||
}
|
||
}
|
||
|
||
function cardElement(card) {
|
||
const item = el('div', `anomalous-ps-card is-${card.role}`);
|
||
item.append(el('span', 'anomalous-ps-card-title', card.title), el('span', 'anomalous-ps-card-text', card.content));
|
||
item.onmouseenter = event => popover.hover(card, item, event);
|
||
item.onmouseleave = event => popover.leave(event);
|
||
// Onto a prompt box in the studio (the card itself), or out onto the canvas (prompt_card_drag.js).
|
||
item.ondragstart = (event) => {
|
||
popover.hide(true);
|
||
event.dataTransfer.setData('application/json', JSON.stringify({ title: card.title, content: card.content, role: card.role }));
|
||
event.dataTransfer.setData('text/plain', card.content);
|
||
};
|
||
bindPromptCardDrag(item, card, drawer);
|
||
item.onclick = () => {
|
||
popover.hide(true);
|
||
onPick(card);
|
||
};
|
||
return item;
|
||
}
|
||
|
||
function group(title, cards, empty) {
|
||
if (!cards.length && !empty) return;
|
||
list.append(el('div', 'anomalous-ps-cards-head', title));
|
||
if (cards.length) list.append(...cards.map(cardElement));
|
||
else list.append(el('div', 'anomalous-ps-cards-empty', empty));
|
||
}
|
||
|
||
function renderList() {
|
||
popover.hide(true);
|
||
list.replaceChildren();
|
||
const matches = card => !keyword || card.title.toLowerCase().includes(keyword) || card.content.toLowerCase().includes(keyword);
|
||
group(t('promptDeckPresets'), presets.filter(matches));
|
||
group(t('promptDeckSaved'), saved.filter(matches), t(keyword ? 'promptDeckNoMatch' : 'promptDeckSavedEmpty'));
|
||
}
|
||
|
||
let controller = null;
|
||
let timer = null;
|
||
scope.onDispose(() => {
|
||
clearTimeout(timer);
|
||
controller?.abort();
|
||
});
|
||
function schedule() {
|
||
clearTimeout(timer);
|
||
if (scope.signal.aborted) return;
|
||
timer = setTimeout(() => (document.hidden ? schedule() : void sync()), SYNC_MS);
|
||
}
|
||
async function sync() {
|
||
if (scope.signal.aborted) return;
|
||
clearTimeout(timer);
|
||
controller?.abort();
|
||
const mine = new AbortController();
|
||
controller = mine;
|
||
try {
|
||
const cards = await loadPromptSourceCards(mine.signal);
|
||
if (scope.signal.aborted || mine.signal.aborted) return;
|
||
const changed = JSON.stringify(cards) !== JSON.stringify(saved);
|
||
saved = cards;
|
||
status.textContent = '';
|
||
if (changed) renderList();
|
||
} catch {
|
||
if (!scope.signal.aborted && !mine.signal.aborted) status.textContent = t('promptLibrarySyncFailed');
|
||
} finally {
|
||
if (controller === mine) {
|
||
controller = null;
|
||
schedule();
|
||
}
|
||
}
|
||
}
|
||
scope.listen(window, 'focus', () => { void sync(); });
|
||
scope.listen(document, 'visibilitychange', () => {
|
||
if (!document.hidden) void sync();
|
||
});
|
||
|
||
renderList();
|
||
void sync();
|
||
return { sync };
|
||
} |