Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_prompt_source_deck.js
T

517 lines
24 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.
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js';
import { translatePromptText } from './translation_service.js';
export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCardToMixer) {
const sourceCards = [...STARTER_SOURCE_PROMPTS];
let sourceFilterCategory = 'all';
let sourceFilterKeyword = '';
let isCreatingNewCard = false;
let activeCardPreviewPopover = null;
function hideCardPreviewPopover() {
activeCardPreviewPopover?.remove();
activeCardPreviewPopover = null;
}
scope.onDispose(hideCardPreviewPopover);
const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel');
const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header');
const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title');
text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library');
const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter');
// Action button group in leftHeader
const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions');
// Button 1: One-click Sync / Import from Material Library
const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
importMaterialsBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library';
// Button 2: Create New Custom Card
const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
newCardTriggerBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card';
// Search and category filters bar
const leftFilterBar = text(leftPanel, 'div', '', 'anomalous-workbench-filter-bar');
const leftSearch = text(leftFilterBar, 'input', '', 'anomalous-workbench-search-input');
leftSearch.placeholder = window.anomalous_browser_lang === 'zh' ? '搜索词卡...' : 'Search cards...';
leftSearch.oninput = () => {
sourceFilterKeyword = leftSearch.value.trim().toLowerCase();
renderSourceCardsList();
};
const leftCategoryPills = text(leftPanel, 'div', '', 'anomalous-workbench-category-pills');
const filterCats = [
{ id: 'all', label: window.anomalous_browser_lang === 'zh' ? '全部' : 'All' },
{ id: 'base', label: window.anomalous_browser_lang === 'zh' ? '底模' : 'Base' },
{ id: 'style', label: window.anomalous_browser_lang === 'zh' ? '风格' : 'Style' },
{ id: 'subject', label: window.anomalous_browser_lang === 'zh' ? '主体' : 'Subject' },
{ id: 'trigger', label: window.anomalous_browser_lang === 'zh' ? '触发' : 'Trigger' },
];
filterCats.forEach(cat => {
const pill = text(leftCategoryPills, 'button', cat.label, `anomalous-workbench-pill${sourceFilterCategory === cat.id ? ' is-active' : ''}`);
pill.onclick = () => {
sourceFilterCategory = cat.id;
leftCategoryPills.querySelectorAll('.anomalous-workbench-pill').forEach(el => el.classList.remove('is-active'));
pill.classList.add('is-active');
renderSourceCardsList();
};
});
// Inline New Card Form (Hidden by default, shown on demand)
const newCardForm = text(leftPanel, 'div', '', 'anomalous-workbench-new-card-form');
newCardForm.style.display = 'none';
// Source Cards List
const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list');
function detectNodePromptRole(node, widgetName = '') {
if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative';
if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive';
if (app?.graph && Array.isArray(node?.outputs)) {
for (const output of node.outputs) {
if (!Array.isArray(output.links)) continue;
for (const linkId of output.links) {
const link = app.graph.links?.[linkId];
if (!link) continue;
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode?.inputs && targetNode.inputs[link.target_slot]) {
const targetInput = targetNode.inputs[link.target_slot];
const inputName = String(targetInput.name || '').toLowerCase();
if (inputName.includes('neg') || inputName.includes('负')) return 'negative';
if (inputName.includes('pos') || inputName.includes('正')) return 'positive';
}
}
}
}
const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase();
if (/neg|negative|反向|负向/.test(nodeText)) return 'negative';
if (/pos|positive|正面|正向/.test(nodeText)) return 'positive';
return 'positive';
}
// Extract prompts from selected canvas node
function extractPromptsFromSelectedNode(intoRightMixer = false) {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`;
let targets = promptWidgetTargets(node);
// Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths
if (!targets.length && Array.isArray(node.widgets)) {
targets = node.widgets.flatMap((w, idx) => {
const name = String(w.name || '').toLowerCase();
if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) {
return [];
}
if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) {
if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') {
return [{ index: idx, name: w.name || 'text' }];
}
}
return [];
});
}
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!`
: `⚠️ No valid prompt text found in selected node [${heading}]!`);
return;
}
let extractedCount = 0;
targets.forEach(t => {
const rawVal = String(node.widgets[t.index]?.value || '').trim();
if (!rawVal) return;
const role = detectNodePromptRole(node, t.name);
const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal);
const cardTitle = `${heading} · ${t.name}`;
const newCard = {
id: `node_${node.id}_${t.index}_${Date.now()}`,
title: cardTitle,
content: rawVal,
role,
category: cat,
persisted: false,
};
// Add into left source deck
sourceCards.unshift(newCard);
extractedCount++;
// If user clicked right panel, also insert into mixer track directly
if (intoRightMixer) {
addSourceCardToMixer(newCard);
}
});
if (extractedCount > 0) {
renderSourceCardsList();
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!`
: `Successfully extracted ${extractedCount} prompts from [${heading}]!`);
} else {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `选中的节点【${heading}】文本内容为空!`
: `The text fields in node [${heading}] are empty!`);
}
}
let materialSyncController = null;
scope.onDispose(() => materialSyncController?.abort());
async function syncMaterialsIntoSourceDeck(notify = true) {
materialSyncController?.abort();
const controller = new AbortController();
materialSyncController = controller;
const icon = importMaterialsBtn.innerHTML;
importMaterialsBtn.disabled = true;
try {
const cards = await loadPromptSourceCards(controller.signal);
if (scope.signal.aborted || controller.signal.aborted) return;
const addedCount = mergePromptSourceCards(sourceCards, cards);
renderSourceCardsList();
if (notify) showWorkbenchToast(t('promptSourceSynced', { count: addedCount }));
} catch (error) {
if (!scope.signal.aborted && !controller.signal.aborted && notify) await anomalousAlert(t('materialLoadError'));
} finally {
if (materialSyncController === controller) {
materialSyncController = null;
importMaterialsBtn.disabled = false;
importMaterialsBtn.innerHTML = icon;
}
}
}
importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck();
// -------------------------------------------------------------------------
// RENDER: Left Panel Cards & Form
// -------------------------------------------------------------------------
function renderNewCardFormUI() {
if (!isCreatingNewCard) {
newCardForm.style.display = 'none';
newCardForm.replaceChildren();
return;
}
newCardForm.style.display = 'flex';
newCardForm.replaceChildren();
text(newCardForm, 'div', window.anomalous_browser_lang === 'zh' ? '✨ 新建提示词卡片' : '✨ New Prompt Card', 'anomalous-form-title');
const titleInput = text(newCardForm, 'input', '', 'anomalous-form-input');
titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '卡片名称(如:赛博光影、角色面部)...' : 'Card name...';
// Role & Category selector row
const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row');
// Role radio group
const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group');
let selectedRole = activeTab || 'positive';
const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const posRadio = text(posLabel, 'input', '');
posRadio.type = 'radio';
posRadio.name = 'new_card_role';
posRadio.value = 'positive';
posRadio.checked = selectedRole === 'positive';
text(posLabel, 'span', t('promptRolePositive'));
const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const negRadio = text(negLabel, 'input', '');
negRadio.type = 'radio';
negRadio.name = 'new_card_role';
negRadio.value = 'negative';
negRadio.checked = selectedRole === 'negative';
text(negLabel, 'span', t('promptRoleNegative'));
posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; };
negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; };
// Category dropdown
const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap');
text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `);
let selectedCat = 'style';
const catSelect = text(catWrap, 'select', '', 'anomalous-form-select');
['base', 'style', 'subject', 'trigger'].forEach(catKey => {
const meta = CATEGORY_META[catKey];
const opt = text(catSelect, 'option', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en);
opt.value = catKey;
if (catKey === selectedCat) opt.selected = true;
});
catSelect.onchange = () => { selectedCat = catSelect.value; };
const contentInput = text(newCardForm, 'textarea', '', 'anomalous-form-textarea');
contentInput.placeholder = window.anomalous_browser_lang === 'zh' ? '输入提示词内容,多个短语用逗号隔开...' : 'Enter prompt text...';
contentInput.rows = 3;
const formBtnRow = text(newCardForm, 'div', '', 'anomalous-form-btn-row');
const submitBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '✓ 保存并加入库' : '✓ Save to Library', 'anomalous-btn-primary anomalous-btn-sm');
const translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate');
translateBtn.type = 'button';
translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation';
translateBtn.onclick = async () => {
const raw = contentInput.value.trim();
if (!raw) return;
const originalText = translateBtn.textContent;
translateBtn.disabled = true;
translateBtn.textContent = '⏳ ...';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (scope.signal.aborted || !contentInput.isConnected || contentInput.value.trim() !== raw) return;
if (res.ok && res.translated) {
contentInput.value = res.translated;
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
}
} finally {
translateBtn.disabled = false;
translateBtn.textContent = originalText;
}
};
const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm');
submitBtn.onclick = async () => {
const rawContent = contentInput.value.trim();
if (!rawContent) {
contentInput.focus();
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '⏳ ...';
const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en);
const newCard = {
id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
title: cardTitle,
content: rawContent,
role: selectedRole,
category: selectedCat,
persisted: false,
};
try {
const body = {
name: cardTitle,
tags: ['prompt_card', selectedCat, selectedRole],
allow_duplicate: true,
plan: {
version: 2,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
parts: [{
id: newCard.id,
name: cardTitle,
category: selectedCat,
role: selectedRole,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
enabled: true,
}],
},
};
const res = await fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await jsonResponse(res, 'save prompt card');
if (scope.signal.aborted) return;
if (payload.status === 'success') {
newCard.persisted = true;
newCard.filename = payload.filename;
showWorkbenchToast(t('promptSaveCardSuccess'));
}
} catch (err) {
if (scope.signal.aborted) return;
showWorkbenchToast(t('promptSaveCardFailed'));
}
sourceCards.unshift(newCard);
isCreatingNewCard = false;
renderNewCardFormUI();
renderSourceCardsList();
};
cancelBtn.onclick = () => {
isCreatingNewCard = false;
renderNewCardFormUI();
};
titleInput.focus();
}
newCardTriggerBtn.onclick = () => {
isCreatingNewCard = !isCreatingNewCard;
renderNewCardFormUI();
};
function showCardPreviewPopover(card, anchorEl) {
hideCardPreviewPopover();
if (!anchorEl?.isConnected) return;
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const popover = document.createElement('div');
popover.className = 'anomalous-card-preview-popover';
const header = document.createElement('div');
header.className = 'anomalous-popover-header';
const tags = document.createElement('div');
tags.className = 'anomalous-popover-tags';
const catBadge = document.createElement('span');
catBadge.className = 'anomalous-popover-cat';
catBadge.style.color = catMeta.color;
catBadge.style.background = catMeta.bg;
catBadge.style.borderColor = catMeta.border;
catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en;
tags.appendChild(catBadge);
const roleBadge = document.createElement('span');
roleBadge.className = `anomalous-popover-role is-${card.role}`;
roleBadge.textContent = card.role === 'negative'
? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative')
: (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive');
tags.appendChild(roleBadge);
header.appendChild(tags);
const titleEl = document.createElement('div');
titleEl.className = 'anomalous-popover-title';
titleEl.textContent = card.title;
header.appendChild(titleEl);
popover.appendChild(header);
const body = document.createElement('div');
body.className = 'anomalous-popover-body';
const snippet = document.createElement('pre');
snippet.className = 'anomalous-popover-snippet';
snippet.textContent = card.content;
body.appendChild(snippet);
popover.appendChild(body);
const footer = document.createElement('div');
footer.className = 'anomalous-popover-footer';
footer.textContent = window.anomalous_browser_lang === 'zh'
? '💡 点击直接添加 · 拖拽自由调序'
: '💡 Click to add · Drag to assemble';
popover.appendChild(footer);
document.body.appendChild(popover);
activeCardPreviewPopover = popover;
// Smart Positioning
const rect = anchorEl.getBoundingClientRect();
const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true;
const popoverHeight = popover.offsetHeight || 160;
const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6));
popover.style.top = `${top}px`;
if (isDockLeft) {
popover.style.left = `${rect.right + 10}px`;
} else {
popover.style.left = `${Math.max(12, rect.left - 320)}px`;
}
}
function renderSourceCardsList() {
hideCardPreviewPopover();
sourceCardsList.replaceChildren();
sourceCardsList.onscroll = () => hideCardPreviewPopover();
const filtered = sourceCards.filter(card => {
if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false;
if (sourceFilterKeyword) {
const matchTitle = card.title.toLowerCase().includes(sourceFilterKeyword);
const matchContent = card.content.toLowerCase().includes(sourceFilterKeyword);
if (!matchTitle && !matchContent) return false;
}
return true;
});
leftCounter.textContent = `(${filtered.length})`;
if (!filtered.length) {
const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty');
empty.innerHTML = `
<div style="font-size: 24px; margin-bottom: 6px;">🔍</div>
<div>${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}</div>
`;
return;
}
filtered.forEach(card => {
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category}`);
cardEl.setAttribute('draggable', 'true');
// Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip)
cardEl.onmouseenter = () => {
showCardPreviewPopover(card, cardEl);
};
cardEl.onmouseleave = () => {
hideCardPreviewPopover();
};
// Drag Start
cardEl.ondragstart = (e) => {
hideCardPreviewPopover();
const payload = {
title: card.title,
content: card.content,
role: card.role,
category: card.category,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', card.content);
e.dataTransfer.effectAllowed = 'copyMove';
cardEl.classList.add('is-dragging-source');
workbenchGrid.classList.add('is-source-dragging');
};
cardEl.ondragend = () => {
cardEl.classList.remove('is-dragging-source');
workbenchGrid.classList.remove('is-source-dragging');
};
const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot');
dot.style.backgroundColor = catMeta.color;
const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name');
const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon');
cardEl.onclick = () => {
hideCardPreviewPopover();
addSourceCardToMixer(card);
};
});
}
renderNewCardFormUI();
renderSourceCardsList();
void syncMaterialsIntoSourceDeck(false);
return { extractSelected: extractPromptsFromSelectedNode, refresh: renderSourceCardsList, sync: syncMaterialsIntoSourceDeck };
}