import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; import { text, jsonResponse } from './ui_dom.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, workbenchDraftToSavedPlan } from './prompt_composition.js'; import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js'; import { showMaterialApplication } from './ui_material_application.js'; import { showMaterialSaved } from './material_feedback.js'; import { translatePromptText } from './translation_service.js'; import { CATEGORY_META, newDraft, normalizeBlock, syncDraftSynthesizedText } from './prompt_studio_data.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; import { openPromptInspectorModal } from './ui_prompt_inspector.js'; import { createPromptSourceDeck } from './ui_prompt_source_deck.js'; export function createPromptWorkbench(owner, container, scope, options) { const view = text(container, 'section', '', 'anomalous-prompt-composer anomalous-prompt-workbench is-side-studio'); let draft = owner.promptPlanDraft ||= newDraft(); draft.plan ||= { version: 2, parts: [], positive: '', negative: '' }; draft.plan.parts ||= []; // Migrate existing text if parts are empty if (!draft.plan.parts.length) { if (draft.plan.positive?.trim()) { draft.plan.parts.push(normalizeBlock({ title: window.anomalous_browser_lang === 'zh' ? '基础正向词' : 'Positive Base', content: draft.plan.positive, role: 'positive', category: categorizePromptSnippet(draft.plan.positive), }, 0)); } if (draft.plan.negative?.trim()) { draft.plan.parts.push(normalizeBlock({ title: window.anomalous_browser_lang === 'zh' ? '通用负向过滤' : 'Negative Filter', content: draft.plan.negative, role: 'negative', category: 'base', }, 1)); } } syncDraftSynthesizedText(draft); let activeTab = 'positive'; // 'positive' | 'negative' let draggedBlockId = null; // Local in-memory source prompt cards (merged starters + materials + user custom) // 1. Studio Topbar (Streamlined with inline preset name input) const topbar = text(view, 'header', '', 'anomalous-prompt-topbar'); const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left'); text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词工坊' : '🎛️ Prompt Studio'); // Inline Preset Name Input const nameInput = text(topLeft, 'input', '', 'anomalous-prompt-name-input'); nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称...' : 'Preset name...'; nameInput.title = window.anomalous_browser_lang === 'zh' ? '输入组合方案名称' : 'Enter preset name'; nameInput.maxLength = 120; nameInput.value = draft.name || ''; nameInput.oninput = () => { draft.name = nameInput.value; }; const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions'); // Quick Extract from Selected Canvas Node const extractQuickBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); extractQuickBtn.innerHTML = '🎯'; extractQuickBtn.title = window.anomalous_browser_lang === 'zh' ? '从画布选中节点一键吸取提示词' : 'Extract prompt from selected node'; extractQuickBtn.onclick = () => sourceDeck.extractSelected(true); // Dock Side Switch (Left / Right) if (typeof options.onToggleDockSide === 'function') { const dockSideBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); const isLeft = () => container?.classList.contains('is-dock-left'); const updateDockBtn = () => { dockSideBtn.innerHTML = isLeft() ? `` : ``; dockSideBtn.title = isLeft() ? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right') : (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left'); }; updateDockBtn(); dockSideBtn.onclick = () => { options.onToggleDockSide(); updateDockBtn(); }; } // "More" Dropdown Menu (holds Save, New Draft, Tags, Export, Material Sync) const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap'); moreWrap.style.position = 'relative'; moreWrap.style.display = 'inline-flex'; const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm'); const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu'); moreMenu.style.display = 'none'; moreMenu.style.position = 'absolute'; moreMenu.style.top = 'calc(100% + 4px)'; moreMenu.style.right = '0'; moreMenu.style.zIndex = '1000'; moreMenu.style.minWidth = '150px'; moreMenu.style.background = 'var(--amb-bg-panel, #1C1E24)'; moreMenu.style.border = '1px solid var(--amb-border, rgba(255, 255, 255, 0.14))'; moreMenu.style.borderRadius = 'var(--amb-radius-control, 6px)'; moreMenu.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.6)'; moreMenu.style.padding = '4px'; const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-ghost anomalous-btn-sm'); saveBtn.style.width = '100%'; saveBtn.style.justifyContent = 'flex-start'; saveBtn.style.border = 'none'; saveBtn.title = t('promptSavePlan') || '保存方案至素材库'; const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost anomalous-btn-sm'); newBtn.style.width = '100%'; newBtn.style.justifyContent = 'flex-start'; newBtn.style.border = 'none'; const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-btn-ghost anomalous-btn-sm'); tagsBtn.style.width = '100%'; tagsBtn.style.justifyContent = 'flex-start'; tagsBtn.style.border = 'none'; tagsBtn.onclick = () => { closeMoreMenu(); const current = (draft.tags || []).join(', '); const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current); if (val !== null) { draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean); showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`); } }; const exportBtn = text(moreMenu, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost anomalous-btn-sm'); exportBtn.style.width = '100%'; exportBtn.style.justifyContent = 'flex-start'; exportBtn.style.border = 'none'; const syncLibBtn = text(moreMenu, 'button', `📥 ${window.anomalous_browser_lang === 'zh' ? '同步素材库' : 'Sync Library'}`, 'anomalous-btn-ghost anomalous-btn-sm'); syncLibBtn.style.width = '100%'; syncLibBtn.style.justifyContent = 'flex-start'; syncLibBtn.style.border = 'none'; syncLibBtn.onclick = () => { closeMoreMenu(); return sourceDeck.sync(); }; moreBtn.onclick = (e) => { e.stopPropagation(); const isHidden = moreMenu.style.display === 'none'; moreMenu.style.display = isHidden ? 'block' : 'none'; }; const closeMoreMenu = () => { moreMenu.style.display = 'none'; }; scope.listen(document, 'click', closeMoreMenu); const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn'); closeBtn.title = t('close') || '关闭'; closeBtn.onclick = () => { options.onClose(); }; // Insertion preference lives with the other secondary actions. const posWrap = document.createElement('label'); posWrap.className = 'anomalous-prompt-insert-pos'; const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select'); for (const value of ['after', 'before']) { text(posSelect, 'option', t(`promptInsert_${value}`)).value = value; } posSelect.value = owner.promptInsertPosition || 'after'; posSelect.setAttribute('aria-label', t('promptInsertPosition')); moreMenu.appendChild(posWrap); posSelect.onchange = () => { owner.promptInsertPosition = posSelect.value; }; // Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode) const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid'); // ========================================================================= // LEFT COLUMN: Ready-to-use Prompt Cards (词卡库) // ========================================================================= const sourceDeck = createPromptSourceDeck(workbenchGrid, container, scope, addSourceCardToMixer); // ========================================================================= // RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台) // ========================================================================= const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel'); const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header'); // Role switch tabs (Positive / Negative) const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs'); const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions'); // Right quick extract: suck into right mixer directly const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); rightSuckNodeBtn.innerHTML = `${t('promptReadSelectedNode')}`; rightSuckNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '直接将画布选中节点的提示词作为积木吸入当前拼装台' : 'Extract node prompt directly into current mixer track'; const smartSortBtn = text(rightActions, 'button', '', 'anomalous-mixer-smart-sort-btn'); smartSortBtn.innerHTML = `${t('promptSortByCategory')}`; smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]'; const clearRightBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost'); clearRightBtn.innerHTML = ''; clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track'; // Blocks Container & Dropzone const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track'); blocksContainer.ondragover = (e) => { e.preventDefault(); const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) { snapDock.classList.add('is-drag-over'); blocksContainer.classList.add('is-drag-over-end'); } }; blocksContainer.ondragleave = (e) => { if (!blocksContainer.contains(e.relatedTarget)) { const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); snapDock?.classList.remove('is-drag-over'); blocksContainer.classList.remove('is-drag-over-end'); } }; blocksContainer.ondrop = (e) => { if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) { e.preventDefault(); const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); snapDock?.classList.remove('is-drag-over'); blocksContainer.classList.remove('is-drag-over-end'); const jsonStr = e.dataTransfer.getData('application/json'); if (jsonStr) { try { addSourceCardToMixer(JSON.parse(jsonStr)); return; } catch (err) {} } if (draggedBlockId) { const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); if (fromIndex >= 0) { const [moved] = draft.plan.parts.splice(fromIndex, 1); draft.plan.parts.push(moved); syncDraftSynthesizedText(draft); renderBlocksList(); updateOutputPreview(); } } } }; // ------------------------------------------------------------------------- // Node Direct Write Handler // ------------------------------------------------------------------------- const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => { const node = selectedMaterialNode(app); if (!node) { anomalousAlert(window.anomalous_browser_lang === 'zh' ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!' : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); return; } const targets = promptWidgetTargets(node); if (!targets.length) { anomalousAlert(window.anomalous_browser_lang === 'zh' ? '⚠️ 选中的节点中未找到可写入的提示词文本输入!' : '⚠️ No writable prompt text widget found in selected node!'); return; } const widgetIndex = targets[0].index; try { const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value); showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node); if (triggerBtn) { const orig = triggerBtn.textContent; triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written'); setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200); } else { showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!'); } return true; } catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); } }; // ------------------------------------------------------------------------- // Floating Action Dock (Minimalist & Slides Up Only When Content Exists) // ------------------------------------------------------------------------- const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock'); floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left'); const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text'); const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right'); // 1. Write Node Button const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm'); writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node'; writeNodeBtn.onclick = async () => { syncDraftSynthesizedText(draft); const compiledText = (draft.plan[activeTab] || '').trim(); if (!compiledText) return; await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn); }; // 2. Quick Copy Button const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm'); copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text'; // 3. Popout Full Inspector Modal Button let closeInspector = null; scope.onDispose(() => closeInspector?.()); const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm'); popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal'; popoutBtn.onclick = () => { closeInspector?.(); closeInspector = openPromptInspectorModal(draft, activeTab, async (role, textVal) => { return applyPromptToCurrentNode(role, textVal); }, () => { updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); }); }; // ------------------------------------------------------------------------- // FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync // ------------------------------------------------------------------------- rightSuckNodeBtn.onclick = () => sourceDeck.extractSelected(true); function updateRightTabsUI() { rightTabs.replaceChildren(); const posCount = draft.plan.parts.filter(p => p.role === 'positive').length; const negCount = draft.plan.parts.filter(p => p.role === 'negative').length; const tabs = [ { key: 'positive', label: `${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})` }, { key: 'negative', label: `${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})` }, ]; for (const tab of tabs) { const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`); btn.onclick = () => { activeTab = tab.key; updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); }; } } function addSourceCardToMixer(cardData, targetIndex = null) { const cardRole = cardData.role || 'positive'; if (activeTab !== cardRole) { activeTab = cardRole; updateRightTabsUI(); } const newBlock = normalizeBlock({ title: cardData.title, content: cardData.content, role: cardRole, category: cardData.category, enabled: true, }, draft.plan.parts.length); newBlock._justAdded = true; if (targetIndex !== null && targetIndex >= 0) { draft.plan.parts.splice(targetIndex, 0, newBlock); } else { draft.plan.parts.push(newBlock); } syncDraftSynthesizedText(draft); updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); const count = draft.plan.parts.filter(p => p.role === activeTab).length; showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台(共 ${count} 块)` : `Added to ${activeTab} track (${count} blocks)`); } function renderBlocksList() { blocksContainer.replaceChildren(); const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab); if (!currentRoleParts.length) { const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone'); dropzoneNotice.innerHTML = `