Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_prompt_source_deck.js
T
DemonGatanjieuandClaude Opus 5.5 01dcdf37a9 Combo group on the pointer: Esc cancels, prompt nodes always, common negative
- 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>
2026-10-05 02:09:25 +08:00

229 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}