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 = ``; 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 = ``; 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 = `