diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 576e96d..e1d74d1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -117,15 +117,16 @@ DOM or live LiteGraph state. floating quick actions, floating status toast feedback, and lazy DOM rendering; delegates image inspection to `ui_gallery_detail.js`; `node_material_actions.js` owns shared transactional node application and guarded undo; the library and Node Assistant use `ui_material_application.js` for the same receipt. -- `ui_prompt_composer.js` owns the Prompt Mixer & Lego Assembler Deck (提示词乐高调音台); - supports both full-modal composition and the docked split-screen side studio (`renderSidePromptComposer`), - compactly adapting to narrow sidebar viewports (320px-450px) with segmented role tabs (`✨ 正向调音坞`, `🚫 负向调音坞`, `👁️ 全部词块`); - features a modular block pool (`parts`) with HTML5 drag-and-drop reordering (drop indicator lines & micro move up/down controls), - A/B bypass checkboxes (greyscale dimmed state excluded from final prompt for instant contrast testing), - interactive category badges (Base Quality, Art Style, Subject, LoRA/Trigger with instant cycling), - dreamy-gradient Smart Sort (`🪄 智能理顺`, auto-ordering blocks from Base ➔ Style ➔ Subject ➔ Trigger), - live assembled output deck with real-time word/token counts, canvas whole-deck dragging (`bindMaterialDrag` straight onto CLIPTextEncode nodes), - direct target node injection toolbar, and slide-out material import drawer; +- `ui_prompt_composer.js` owns the Prompt Studio Dual-Column Workbench (提示词工坊左右双分栏工作台); + replaces the cramped 380px drawer with a dedicated full-width two-column workspace (`.anomalous-prompt-workbench`): + the Left Column hosts the Ready-to-use Prompt Cards Library (成型提示词库), aggregating user-saved prompts from Material Library + and curated starter presets, completely eliminating default empty textareas; displays an on-demand inline creation card only when + clicking `➕ 新建词卡`, and cards support native HTML5 drag-and-drop (`dragstart`); + the Right Column hosts the Assembler & Arranger Stage (顺序编排调音台), providing a high-visibility dropzone (`.anomalous-assembly-dropzone`) + that ingests dragged cards into positive/negative tracks, supports bidirectional drag-and-drop reordering with ghost indicator lines, + instant A/B bypass toggles (greyscale dimming without deleting), interactive category pills, one-click Smart Sort (`🪄 智能理顺`, Base ➔ Style ➔ Subject ➔ Trigger), + and a live assembled output deck with real-time word/token counts, whole-deck canvas dragging (`bindMaterialDrag` onto CLIPTextEncode nodes), + direct target node injection toolbar, and seamless return to Material Library; `prompt_composition.js` provides `categorizePromptSnippet`, `smartSortPromptBlocks`, `assemblePromptBlocks`, as well as backward-compatible text joining and fragment plan composition; `api/materials.py` validates prompt plans with extended categories (`general`, `specific`, `base`, `style`, `subject`, `trigger`). diff --git a/web/modules/ui_materials.js b/web/modules/ui_materials.js index cc62156..30e0f77 100644 --- a/web/modules/ui_materials.js +++ b/web/modules/ui_materials.js @@ -821,11 +821,11 @@ function buildStudioTopbar(owner) { const transfer = text(more, 'button', t('materialTransferCenter'), 'anomalous-btn-ghost'); transfer.onclick = () => showTransferCenter(owner); - // 提示词工坊抽屉开关 - const studioToggle = text(right, 'button', `✨ ${t('materialPromptStudio') || '提示词工坊'}`, 'anomalous-material-topbar-btn anomalous-material-studio-toggle'); + // 提示词工坊:单开全幅左右双分栏工作台 + const studioToggle = text(right, 'button', `🎛️ ${t('materialPromptStudio') || '提示词工坊'}`, 'anomalous-material-topbar-btn anomalous-material-studio-toggle'); studioToggle.type = 'button'; owner.materialStudioToggle = studioToggle; - studioToggle.onclick = () => owner.toggleSideStudio(); + studioToggle.onclick = () => showPromptComposer(owner); } function renderMaterialPagination(owner, payload) { diff --git a/web/modules/ui_prompt_composer.js b/web/modules/ui_prompt_composer.js index ca38240..861c525 100644 --- a/web/modules/ui_prompt_composer.js +++ b/web/modules/ui_prompt_composer.js @@ -20,13 +20,59 @@ const newDraft = () => ({ }, }); -const CATEGORY_META = { +export const CATEGORY_META = { base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' }, style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' }, subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' }, trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' }, }; +// Built-in starter prompt cards for left source deck +const STARTER_SOURCE_PROMPTS = [ + { + id: 'preset_base_quality', + title: '画质底模词 (Quality Base)', + content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres', + role: 'positive', + category: 'base', + }, + { + id: 'preset_real_detail', + title: '写实与材质增强 (Realistic Detail)', + content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph', + role: 'positive', + category: 'base', + }, + { + id: 'preset_cinematic_light', + title: '电影胶片光影 (Cinematic Lighting)', + content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric', + role: 'positive', + category: 'style', + }, + { + id: 'preset_anime_cyber', + title: '赛博霓虹风 (Cyberpunk Neon)', + content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop', + role: 'positive', + category: 'style', + }, + { + id: 'preset_girl_face', + title: '美少女面部特写 (Portrait 1girl)', + content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair', + role: 'positive', + category: 'subject', + }, + { + id: 'preset_neg_filter', + title: '通用负向过滤词 (Negative Base Filter)', + content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark', + role: 'negative', + category: 'base', + }, +]; + function normalizeBlock(part, index = 0) { const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`; const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive'); @@ -144,9 +190,6 @@ export function renderSidePromptComposer(owner, container, onClose) { export function appendPromptToStudio(owner, textSnippet, isPositive = true, noteTitle = '') { if (!textSnippet || !textSnippet.trim()) return; - if (typeof owner.openSideStudio === 'function') { - owner.openSideStudio(); - } const role = isPositive ? 'positive' : 'negative'; const cat = isPositive ? categorizePromptSnippet(textSnippet) : 'base'; const block = normalizeBlock({ @@ -158,6 +201,8 @@ export function appendPromptToStudio(owner, textSnippet, isPositive = true, note if (owner.sidePromptComposerControl?.addBlock) { owner.sidePromptComposerControl.addBlock(block); + } else if (owner.workbenchComposerControl?.addBlock) { + owner.workbenchComposerControl.addBlock(block); } else { owner.promptPlanDraft ||= newDraft(); owner.promptPlanDraft.plan.parts ||= []; @@ -169,12 +214,11 @@ export function appendPromptToStudio(owner, textSnippet, isPositive = true, note export function addBlockToMixer(owner, blockData) { if (!blockData || !blockData.content?.trim()) return; - if (typeof owner.openSideStudio === 'function') { - owner.openSideStudio(); - } const block = normalizeBlock(blockData); if (owner.sidePromptComposerControl?.addBlock) { owner.sidePromptComposerControl.addBlock(block); + } else if (owner.workbenchComposerControl?.addBlock) { + owner.workbenchComposerControl.addBlock(block); } else { owner.promptPlanDraft ||= newDraft(); owner.promptPlanDraft.plan.parts ||= []; @@ -185,7 +229,7 @@ export function addBlockToMixer(owner, blockData) { function buildPromptComposer(owner, container, options = {}) { const isSide = !!options.isSideStudio; - const view = text(container, 'section', '', `anomalous-prompt-composer${isSide ? ' is-side-studio' : ''}`); + const view = text(container, 'section', '', `anomalous-prompt-composer anomalous-prompt-workbench${isSide ? ' is-side-studio' : ''}`); if (isSide) owner.sidePromptComposerView = view; else owner.promptComposerView = view; @@ -212,34 +256,32 @@ function buildPromptComposer(owner, container, options = {}) { }, 1)); } if (!draft.plan.parts.length) { - draft.plan.parts.push(normalizeBlock({ - title: window.anomalous_browser_lang === 'zh' ? '画质底模词' : 'Quality Base', - content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr', - role: 'positive', - category: 'base', - }, 0)); + draft.plan.parts.push(normalizeBlock(STARTER_SOURCE_PROMPTS[0], 0)); } } syncDraftSynthesizedText(draft); - let activeTab = 'positive'; // 'positive' | 'negative' | 'all' + let activeTab = 'positive'; // 'positive' | 'negative' + let sourceFilterCategory = 'all'; + let sourceFilterKeyword = ''; let draggedBlockId = null; - // 1. Topbar - const topbar = text(view, 'div', '', 'anomalous-prompt-topbar'); + // Local in-memory source prompt cards (merged starters + materials) + let sourceCards = [...STARTER_SOURCE_PROMPTS]; + let isCreatingNewCard = false; + + // 1. Studio Topbar + const topbar = text(view, 'header', '', 'anomalous-prompt-topbar'); const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left'); if (!isSide) { const backBtn = text(topLeft, 'button', `← ${t('materialBackToLibrary')}`, 'anomalous-btn-ghost'); backBtn.onclick = () => owner.showMaterials(); - text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词乐高调音台' : '🎛️ Prompt Mixer Deck'); + text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词双栏调音工作台' : '🎛️ Prompt Studio Workbench'); } else { text(topLeft, 'h3', `🎛️ ${window.anomalous_browser_lang === 'zh' ? '提示词调音台' : 'Prompt Mixer'}`); } const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions'); - const importBtn = text(topActions, 'button', `📥 ${t('promptImportFromMaterials')}`, 'anomalous-btn-ghost'); - importBtn.title = t('promptDrawerTitle'); - const newBtn = text(topActions, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost'); const saveBtn = text(topActions, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-primary'); const exportBtn = text(topActions, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost'); @@ -250,7 +292,7 @@ function buildPromptComposer(owner, container, options = {}) { closeBtn.onclick = () => options.onClose(); } - // 2. Metadata strip (Name, Tags, Insert Position) + // 2. Metadata Strip (Preset Name, Tags, Target Insert Position) const metaStrip = text(view, 'div', '', 'anomalous-prompt-meta-strip'); const nameInput = text(metaStrip, 'input', '', 'anomalous-prompt-name-input'); nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称(如:赛博朋克光影组合)...' : 'Mixer preset name...'; @@ -278,23 +320,75 @@ function buildPromptComposer(owner, container, options = {}) { renderTargetBar(); }; - // 3. Mixer Controls Bar (Tabs & Smart Sort) - const mixerControls = text(view, 'div', '', 'anomalous-mixer-controls'); - const tabsWrapper = text(mixerControls, 'div', '', 'anomalous-mixer-tabs'); + // 3. Two-Column Split Grid + const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid'); - const actionWrapper = text(mixerControls, 'div', '', 'anomalous-mixer-actions'); - const smartSortBtn = text(actionWrapper, 'button', window.anomalous_browser_lang === 'zh' ? '🪄 智能理顺' : '🪄 Smart Sort', 'anomalous-mixer-smart-sort-btn'); - smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '根据词性智能理顺:[通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词]' : 'Smart sort: [Base Quality ➔ Style ➔ Subject ➔ LoRA/Trigger]'; + // ========================================================================= + // LEFT COLUMN: Ready-to-use Prompt Cards (成型提示词库) + // ========================================================================= + 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'); + leftTitleWrap.innerHTML = `📚 ${window.anomalous_browser_lang === 'zh' ? '成型提示词库' : 'Prompt Library'} `; - const addBlockBtn = text(actionWrapper, 'button', `➕ ${window.anomalous_browser_lang === 'zh' ? '添加词块' : 'Add Block'}`, 'anomalous-btn-ghost'); - const clearBlocksBtn = text(actionWrapper, 'button', '🧹', 'anomalous-btn-ghost'); - clearBlocksBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前分类词块' : 'Clear current blocks'; + const newCardTriggerBtn = text(leftHeader, 'button', `➕ ${window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'New Card'}`, 'anomalous-btn-primary anomalous-btn-sm'); + newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建并保存一张提示词卡片' : 'Create a new prompt card'; - // 4. Blocks Container - const blocksContainer = text(view, 'div', '', 'anomalous-mixer-blocks-container'); + // 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 prompt snippet...'; + leftSearch.oninput = () => { + sourceFilterKeyword = leftSearch.value.trim().toLowerCase(); + renderSourceCardsList(); + }; - // 5. Output Preview & Node Target Injection Bar - const outputDeck = text(view, 'div', '', 'anomalous-mixer-deck-output'); + 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' ? '⚡ 触发' : '⚡ LoRA' }, + ]; + 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'); + + // ========================================================================= + // 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'); + const smartSortBtn = text(rightActions, 'button', window.anomalous_browser_lang === 'zh' ? '🪄 智能理顺' : '🪄 Smart Sort', 'anomalous-mixer-smart-sort-btn'); + smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]'; + + const clearRightBtn = text(rightActions, 'button', '🧹', 'anomalous-btn-ghost'); + 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'); + + // Bottom Live Assembled Output Deck + const outputDeck = text(rightPanel, 'div', '', 'anomalous-mixer-deck-output'); const outputHeader = text(outputDeck, 'div', '', 'anomalous-mixer-output-header'); const outputTitle = text(outputHeader, 'div', '', 'anomalous-mixer-output-title'); const outputStats = text(outputHeader, 'div', '', 'anomalous-mixer-output-stats'); @@ -302,62 +396,243 @@ function buildPromptComposer(owner, container, options = {}) { const outputBody = text(outputDeck, 'div', '', 'anomalous-mixer-output-body'); const outputTextarea = text(outputBody, 'textarea', '', 'anomalous-mixer-output-textarea'); outputTextarea.readOnly = true; - outputTextarea.rows = isSide ? 3 : 4; + outputTextarea.rows = isSide ? 3 : 3; const outputFooter = text(outputDeck, 'div', '', 'anomalous-mixer-output-footer'); const dragHint = text(outputFooter, 'div', '', 'anomalous-mixer-drag-hint'); - dragHint.innerHTML = `🖐️ ${window.anomalous_browser_lang === 'zh' ? '拖动此调音坞' : 'Drag this deck'} ${window.anomalous_browser_lang === 'zh' ? '直达 ComfyUI 画布节点' : 'onto canvas node'}`; + dragHint.innerHTML = `🖐️ ${window.anomalous_browser_lang === 'zh' ? '按住此预览坞' : 'Drag this deck'} ${window.anomalous_browser_lang === 'zh' ? '直接丢入 ComfyUI 画布节点' : 'onto canvas node'}`; const outputActions = text(outputFooter, 'div', '', 'anomalous-mixer-output-actions'); const copyOutputBtn = text(outputActions, 'button', `📋 ${t('copy')}`, 'anomalous-btn-ghost'); - // Target node direct injection bar - const targetBar = text(view, 'div', '', 'anomalous-prompt-target-bar'); + // Target Node Direct Write Bar + const targetBar = text(rightPanel, 'div', '', 'anomalous-prompt-target-bar'); - // Render logic functions - function updateTabsUI() { - tabsWrapper.replaceChildren(); + // ------------------------------------------------------------------------- + // RENDER: Left Panel Cards & Form + // ------------------------------------------------------------------------- + function renderNewCardFormUI() { + if (!isCreatingNewCard) { + newCardForm.style.display = 'none'; + newCardForm.replaceChildren(); + return; + } + newCardForm.style.display = 'flex'; + newCardForm.replaceChildren(); + + const formTitle = 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...'; + + const catRow = text(newCardForm, 'div', '', 'anomalous-form-cat-row'); + text(catRow, 'span', `${window.anomalous_browser_lang === 'zh' ? '词性' : 'Type'}: `); + let selectedCat = 'style'; + const catSelect = text(catRow, '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 cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm'); + + submitBtn.onclick = () => { + const rawContent = contentInput.value.trim(); + if (!rawContent) { + contentInput.focus(); + return; + } + const newCard = { + id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + title: titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en), + content: rawContent, + role: selectedCat === 'base' && /worst|low quality|bad anatomy/i.test(rawContent) ? 'negative' : 'positive', + category: selectedCat, + }; + sourceCards.unshift(newCard); + isCreatingNewCard = false; + renderNewCardFormUI(); + renderSourceCardsList(); + }; + + cancelBtn.onclick = () => { + isCreatingNewCard = false; + renderNewCardFormUI(); + }; + + titleInput.focus(); + } + + newCardTriggerBtn.onclick = () => { + isCreatingNewCard = !isCreatingNewCard; + renderNewCardFormUI(); + }; + + function renderSourceCardsList() { + sourceCardsList.replaceChildren(); + 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; + }); + + leftTitleWrap.querySelector('.anomalous-sub-counter').textContent = `(${filtered.length})`; + + if (!filtered.length) { + const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty'); + empty.innerHTML = ` +
🔍
+
${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}
+ `; + return; + } + + filtered.forEach(card => { + const cardEl = text(sourceCardsList, 'article', '', `anomalous-source-prompt-card is-cat-${card.category}`); + cardEl.setAttribute('draggable', 'true'); + + // Drag Start -> transfer prompt card payload + cardEl.ondragstart = (e) => { + 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'); + }; + + cardEl.ondragend = () => { + cardEl.classList.remove('is-dragging-source'); + }; + + // Card Header + const header = text(cardEl, 'div', '', 'anomalous-source-card-header'); + const meta = CATEGORY_META[card.category] || CATEGORY_META.subject; + + const badge = text(header, 'span', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en, 'anomalous-source-card-badge'); + badge.style.color = meta.color; + badge.style.backgroundColor = meta.bg; + badge.style.borderColor = meta.border; + + const title = text(header, 'strong', card.title, 'anomalous-source-card-title'); + title.title = card.title; + + // Card Body snippet + const snippet = text(cardEl, 'div', card.content, 'anomalous-source-card-snippet'); + snippet.title = card.content; + + // Card Footer Actions + const footer = text(cardEl, 'div', '', 'anomalous-source-card-footer'); + const dragHint = text(footer, 'span', '🖐️ 抓取拖入右侧', 'anomalous-source-card-drag-hint'); + + const actions = text(footer, 'div', '', 'anomalous-source-card-actions'); + const copyBtn = text(actions, 'button', '📋', 'anomalous-source-action-btn'); + copyBtn.title = t('copy'); + copyBtn.onclick = async (e) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(card.content); + copyBtn.textContent = '✅'; + setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = '📋'; }, 1200); + } catch (err) { + await anomalousAlert(t('materialCopyError')); + } + }; + + const dockBtn = text(actions, 'button', `➕ ${window.anomalous_browser_lang === 'zh' ? '入坞' : 'Dock'}`, 'anomalous-source-action-btn is-dock'); + dockBtn.title = window.anomalous_browser_lang === 'zh' ? '直接放入右侧当前拼装台' : 'Add into mixer track'; + dockBtn.onclick = (e) => { + e.stopPropagation(); + addSourceCardToMixer(card); + }; + }); + } + + // ------------------------------------------------------------------------- + // RENDER: Right Assembler Stage & Lego Blocks + // ------------------------------------------------------------------------- + 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 allCount = draft.plan.parts.length; const tabs = [ - { key: 'positive', label: `✨ ${window.anomalous_browser_lang === 'zh' ? '正向调音坞' : 'Positive'} (${posCount})` }, - { key: 'negative', label: `🚫 ${window.anomalous_browser_lang === 'zh' ? '负向调音坞' : 'Negative'} (${negCount})` }, - { key: 'all', label: `👁️ ${window.anomalous_browser_lang === 'zh' ? '全部词块' : 'All'} (${allCount})` }, + { 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(tabsWrapper, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`); + const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`); btn.onclick = () => { activeTab = tab.key; - updateTabsUI(); + updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); }; } } + function addSourceCardToMixer(cardData, targetIndex = null) { + const targetRole = activeTab; + const newBlock = normalizeBlock({ + title: cardData.title, + content: cardData.content, + role: targetRole, + category: cardData.category, + enabled: true, + }, draft.plan.parts.length); + + if (targetIndex !== null && targetIndex >= 0) { + draft.plan.parts.splice(targetIndex, 0, newBlock); + } else { + draft.plan.parts.push(newBlock); + } + + syncDraftSynthesizedText(draft); + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + } + function renderBlocksList() { blocksContainer.replaceChildren(); - const parts = draft.plan.parts.filter(p => activeTab === 'all' || p.role === activeTab); + const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab); - if (!parts.length) { - const emptyNotice = text(blocksContainer, 'div', '', 'anomalous-mixer-empty-notice'); - emptyNotice.innerHTML = ` -
🧩
-
${window.anomalous_browser_lang === 'zh' ? '当前分类暂无提示词块' : 'No prompt blocks in this category'}
-
${window.anomalous_browser_lang === 'zh' ? '点击右上角“添加词块”或在素材库点击“🎛️ 调音台”送入积木' : 'Click "Add Block" or pick from Materials to dock snippets.'}
+ if (!currentRoleParts.length) { + const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone'); + dropzoneNotice.innerHTML = ` +
📥
+
${window.anomalous_browser_lang === 'zh' ? '将左侧的成型提示词拖放到这里摆放与拼装' : 'Drop prompt cards from the left panel here'}
+
${window.anomalous_browser_lang === 'zh' ? '支持自由调换次序,点击【智能理顺】自动按画质底模➔风格➔主体➔LoRA排序' : 'Drag to reorder anytime, or click Smart Sort to auto-align.'}
`; + // Accept drops on empty dropzone + setupDropzoneListeners(dropzoneNotice); return; } - parts.forEach((block, index) => { + currentRoleParts.forEach((block, index) => { const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.role}`); blockEl.setAttribute('draggable', 'true'); blockEl.dataset.blockId = block.id; - // Drag & Drop Reordering handlers + // Reorder Drag Listeners blockEl.ondragstart = (e) => { draggedBlockId = block.id; blockEl.classList.add('is-dragging'); @@ -375,7 +650,6 @@ function buildPromptComposer(owner, container, options = {}) { blockEl.ondragover = (e) => { e.preventDefault(); - if (!draggedBlockId || draggedBlockId === block.id) return; const rect = blockEl.getBoundingClientRect(); const mid = rect.top + rect.height / 2; if (e.clientY < mid) { @@ -394,8 +668,21 @@ function buildPromptComposer(owner, container, options = {}) { blockEl.ondrop = (e) => { e.preventDefault(); blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); - if (!draggedBlockId || draggedBlockId === block.id) return; + // Case 1: Drop from Left Source Card + const jsonStr = e.dataTransfer.getData('application/json'); + if (jsonStr) { + try { + const parsed = JSON.parse(jsonStr); + const toIndex = draft.plan.parts.findIndex(p => p.id === block.id); + const insertAfter = e.clientY >= (blockEl.getBoundingClientRect().top + blockEl.offsetHeight / 2); + addSourceCardToMixer(parsed, insertAfter ? toIndex + 1 : toIndex); + return; + } catch (err) {} + } + + // Case 2: Reorder inside Right Track + if (!draggedBlockId || draggedBlockId === block.id) return; const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); const toIndex = draft.plan.parts.findIndex(p => p.id === block.id); if (fromIndex < 0 || toIndex < 0) return; @@ -411,19 +698,19 @@ function buildPromptComposer(owner, container, options = {}) { updateOutputPreview(); }; - // Block Header + // Header const blockHeader = text(blockEl, 'div', '', 'anomalous-mixer-block-header'); const headerLeft = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-left'); - // Drag handle const dragHandle = text(headerLeft, 'span', '⠿', 'anomalous-mixer-drag-handle'); dragHandle.title = window.anomalous_browser_lang === 'zh' ? '抓取按住上下拖拽排序' : 'Drag to reorder'; - // Toggle Checkbox (A/B testing switch) + // A/B Bypass Checkbox const toggleWrap = text(headerLeft, 'label', '', 'anomalous-mixer-block-toggle'); const checkbox = text(toggleWrap, 'input', ''); checkbox.type = 'checkbox'; checkbox.checked = !!block.enabled; + checkbox.title = window.anomalous_browser_lang === 'zh' ? '勾选参与拼装,取消勾选即旁路跳过(做A/B对比)' : 'Include or bypass in assembly'; checkbox.onchange = () => { block.enabled = checkbox.checked; blockEl.classList.toggle('is-bypassed', !block.enabled); @@ -437,7 +724,7 @@ function buildPromptComposer(owner, container, options = {}) { catBadge.style.color = catMeta.color; catBadge.style.backgroundColor = catMeta.bg; catBadge.style.borderColor = catMeta.border; - catBadge.title = window.anomalous_browser_lang === 'zh' ? '点击切换词块类型(通用底模/风格/主体/LoRA触发词)' : 'Click to cycle category'; + catBadge.title = window.anomalous_browser_lang === 'zh' ? '点击切换词性分类' : 'Cycle category'; catBadge.onclick = () => { const cats = ['base', 'style', 'subject', 'trigger']; const nextIdx = (cats.indexOf(block.category) + 1) % cats.length; @@ -445,16 +732,14 @@ function buildPromptComposer(owner, container, options = {}) { renderBlocksList(); }; - // Title input + // Editable title const titleInput = text(headerLeft, 'input', '', 'anomalous-mixer-block-title-input'); titleInput.value = block.title || ''; - titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '词块标题...' : 'Block title...'; titleInput.oninput = () => { block.title = titleInput.value; }; - // Header Right Actions + // Right Action micro buttons const headerRight = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-right'); - // Move Up/Down Micro-buttons const upBtn = text(headerRight, 'button', '▲', 'anomalous-mixer-block-btn'); upBtn.title = window.anomalous_browser_lang === 'zh' ? '上移' : 'Move up'; upBtn.disabled = index === 0; @@ -472,7 +757,7 @@ function buildPromptComposer(owner, container, options = {}) { const downBtn = text(headerRight, 'button', '▼', 'anomalous-mixer-block-btn'); downBtn.title = window.anomalous_browser_lang === 'zh' ? '下移' : 'Move down'; - downBtn.disabled = index === parts.length - 1; + downBtn.disabled = index === currentRoleParts.length - 1; downBtn.onclick = () => { const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); if (realIdx < draft.plan.parts.length - 1) { @@ -485,42 +770,24 @@ function buildPromptComposer(owner, container, options = {}) { } }; - // Copy block content - const copyBtn = text(headerRight, 'button', '📋', 'anomalous-mixer-block-btn'); - copyBtn.title = window.anomalous_browser_lang === 'zh' ? '复制本块' : 'Copy block'; - copyBtn.onclick = async () => { - try { - await navigator.clipboard.writeText(block.content); - copyBtn.textContent = '✅'; - setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = '📋'; }, 1200); - } catch (e) { - await anomalousAlert(t('materialCopyError')); - } - }; - - // Delete block const delBtn = text(headerRight, 'button', '✕', 'anomalous-mixer-block-btn is-delete'); - delBtn.title = window.anomalous_browser_lang === 'zh' ? '删除词块' : 'Delete block'; + delBtn.title = window.anomalous_browser_lang === 'zh' ? '移除此块' : 'Remove block'; delBtn.onclick = () => { const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); if (realIdx >= 0) { draft.plan.parts.splice(realIdx, 1); syncDraftSynthesizedText(draft); - updateTabsUI(); + updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); } }; - // Block Body (Textarea) + // Body const blockBody = text(blockEl, 'div', '', 'anomalous-mixer-block-body'); const textarea = text(blockBody, 'textarea', '', 'anomalous-mixer-block-textarea'); textarea.value = block.content || ''; - textarea.placeholder = block.role === 'positive' - ? (window.anomalous_browser_lang === 'zh' ? '输入正面提示词,支持英文短语、权重与 LoRA...' : 'Enter positive prompts, tags, weights...') - : (window.anomalous_browser_lang === 'zh' ? '输入负面过滤词,如 low quality, bad hands...' : 'Enter negative prompts...'); textarea.rows = isSide ? 3 : 2; - textarea.oninput = () => { block.content = textarea.value; syncDraftSynthesizedText(draft); @@ -529,14 +796,33 @@ function buildPromptComposer(owner, container, options = {}) { }); } + function setupDropzoneListeners(el) { + el.ondragover = (e) => { + e.preventDefault(); + el.classList.add('is-drag-over'); + }; + el.ondragleave = () => { + el.classList.remove('is-drag-over'); + }; + el.ondrop = (e) => { + e.preventDefault(); + el.classList.remove('is-drag-over'); + const jsonStr = e.dataTransfer.getData('application/json'); + if (jsonStr) { + try { + addSourceCardToMixer(JSON.parse(jsonStr)); + } catch (err) {} + } + }; + } + function updateOutputPreview() { syncDraftSynthesizedText(draft); - const role = activeTab === 'negative' ? 'negative' : 'positive'; - const compiledText = draft.plan[role] || ''; + const compiledText = draft.plan[activeTab] || ''; outputTitle.innerHTML = activeTab === 'negative' - ? `🚫 ${window.anomalous_browser_lang === 'zh' ? '合成负向文本' : 'Assembled Negative'}` - : `✨ ${window.anomalous_browser_lang === 'zh' ? '合成正向文本' : 'Assembled Positive'}`; + ? `🚫 ${window.anomalous_browser_lang === 'zh' ? '实时合成负向文本' : 'Assembled Negative'}` + : `✨ ${window.anomalous_browser_lang === 'zh' ? '实时合成正向文本' : 'Assembled Positive'}`; const words = compiledText.trim() ? compiledText.split(/[\s,,\n\r]+/).filter(Boolean).length : 0; outputStats.textContent = window.anomalous_browser_lang === 'zh' @@ -545,12 +831,12 @@ function buildPromptComposer(owner, container, options = {}) { outputTextarea.value = compiledText; - // Bind Whole Deck Drag directly to canvas node + // Whole deck drag to ComfyUI canvas node bindMaterialDrag(outputDeck, owner, { payload: () => compiledText.trim() ? { content: compiledText, position: posSelect.value, - dragHint: role === 'positive' + dragHint: activeTab === 'positive' ? (window.anomalous_browser_lang === 'zh' ? '✨ 拖拽合成正面词至画布节点' : '✨ Drag Assembled Positive onto Node') : (window.anomalous_browser_lang === 'zh' ? '🚫 拖拽合成负面词至画布节点' : '🚫 Drag Assembled Negative onto Node'), } : null, @@ -561,67 +847,37 @@ function buildPromptComposer(owner, container, options = {}) { renderTargetBar(); } - // Smart Sort Execution + // Smart Sort smartSortBtn.onclick = () => { if (!draft.plan.parts.length) return; - const targetRole = activeTab === 'all' ? null : activeTab; - - if (targetRole) { - const roleParts = draft.plan.parts.filter(p => p.role === targetRole); - const otherParts = draft.plan.parts.filter(p => p.role !== targetRole); - const sorted = smartSortPromptBlocks(roleParts, targetRole); - draft.plan.parts = [...sorted, ...otherParts]; - } else { - const pos = smartSortPromptBlocks(draft.plan.parts.filter(p => p.role === 'positive'), 'positive'); - const neg = smartSortPromptBlocks(draft.plan.parts.filter(p => p.role === 'negative'), 'negative'); - draft.plan.parts = [...pos, ...neg]; - } + const roleParts = draft.plan.parts.filter(p => p.role === activeTab); + const otherParts = draft.plan.parts.filter(p => p.role !== activeTab); + const sorted = smartSortPromptBlocks(roleParts, activeTab); + draft.plan.parts = [...sorted, ...otherParts]; syncDraftSynthesizedText(draft); renderBlocksList(); updateOutputPreview(); - // Visual feedback smartSortBtn.classList.add('is-animating'); - setTimeout(() => smartSortBtn.classList.remove('is-animating'), 600); + setTimeout(() => smartSortBtn.classList.remove('is-animating'), 500); }; - // Add new blank block - addBlockBtn.onclick = () => { - const role = activeTab === 'negative' ? 'negative' : 'positive'; - const newBlock = normalizeBlock({ - title: window.anomalous_browser_lang === 'zh' ? (role === 'positive' ? '新建词块' : '新建负向块') : 'New Block', - content: '', - role, - category: role === 'positive' ? 'subject' : 'base', - }, draft.plan.parts.length); - draft.plan.parts.push(newBlock); - syncDraftSynthesizedText(draft); - updateTabsUI(); - renderBlocksList(); - updateOutputPreview(); - }; - - // Clear blocks - clearBlocksBtn.onclick = async () => { - const msg = activeTab === 'all' - ? (window.anomalous_browser_lang === 'zh' ? '确定清空调音台内的全部词块吗?' : 'Clear all blocks in mixer?') - : (window.anomalous_browser_lang === 'zh' ? `确定清空当前【${activeTab === 'positive' ? '正向' : '负向'}】词块吗?` : `Clear ${activeTab} blocks?`); - + // Clear track + clearRightBtn.onclick = async () => { + const msg = window.anomalous_browser_lang === 'zh' + ? `确定清空当前【${activeTab === 'positive' ? '正向' : '负向'}】拼装池吗?` + : `Clear ${activeTab} mixer track?`; if (await anomalousConfirm(msg)) { - if (activeTab === 'all') { - draft.plan.parts = []; - } else { - draft.plan.parts = draft.plan.parts.filter(p => p.role !== activeTab); - } + draft.plan.parts = draft.plan.parts.filter(p => p.role !== activeTab); syncDraftSynthesizedText(draft); - updateTabsUI(); + updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); } }; - // Copy Output + // Copy preview text copyOutputBtn.onclick = async () => { const textToCopy = outputTextarea.value; if (!textToCopy) return; @@ -634,7 +890,9 @@ function buildPromptComposer(owner, container, options = {}) { } }; - // 6. Target Node Bar & Direct Application + // ------------------------------------------------------------------------- + // Target Node Direct Injection Toolbar + // ------------------------------------------------------------------------- const renderTargetBar = () => { if (!view.isConnected) return; targetBar.replaceChildren(); @@ -681,17 +939,7 @@ function buildPromptComposer(owner, container, options = {}) { if (isSide) owner.refreshSidePromptTarget = renderTargetBar; else owner.refreshPromptTarget = renderTargetBar; - // Drawer Integration - importBtn.onclick = () => { - openMaterialImportDrawer(owner, draft, () => { - syncDraftSynthesizedText(draft); - updateTabsUI(); - renderBlocksList(); - updateOutputPreview(); - }); - }; - - // Save Plan + // Save Plan to Material Library saveBtn.onclick = async () => { if (!draft.name.trim()) { nameInput.focus(); @@ -767,12 +1015,11 @@ function buildPromptComposer(owner, container, options = {}) { // New Draft newBtn.onclick = async () => { if (await anomalousConfirm(t('promptReplaceDraft'))) { - owner.closePromptImportDrawer?.(); owner.promptPlanDraft = draft = newDraft(); if (!isSide) { showPromptComposer(owner); } else { - updateTabsUI(); + updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); nameInput.value = ''; @@ -781,29 +1028,63 @@ function buildPromptComposer(owner, container, options = {}) { } }; - // Initial render - updateTabsUI(); + // Async Fetch Materials into Left Panel + (async () => { + try { + const res = await fetch('/anomalous/materials?category=prompts&limit=100'); + const data = await jsonResponse(res, 'materials list'); + if (Array.isArray(data.materials)) { + for (const item of data.materials) { + try { + const prompts = await loadMaterialPrompts(item.filename); + if (prompts.positive) { + sourceCards.push({ + id: `mat_pos_${item.filename}`, + title: item.name ? `${item.name} (Pos)` : '素材正向', + content: prompts.positive, + role: 'positive', + category: categorizePromptSnippet(prompts.positive), + }); + } + if (prompts.negative) { + sourceCards.push({ + id: `mat_neg_${item.filename}`, + title: item.name ? `${item.name} (Neg)` : '素材负向', + content: prompts.negative, + role: 'negative', + category: 'base', + }); + } + } catch (e) {} + } + if (view.isConnected) { + renderSourceCardsList(); + } + } + } catch (e) {} + })(); + + // Initial render execution + renderNewCardFormUI(); + renderSourceCardsList(); + updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); - return { + const control = { updateAll: () => { - updateTabsUI(); + updateRightTabsUI(); renderBlocksList(); updateOutputPreview(); + renderSourceCardsList(); }, addBlock: (blockData) => { - const block = normalizeBlock(blockData, draft.plan.parts.length); - draft.plan.parts.push(block); - syncDraftSynthesizedText(draft); - if (block.role !== activeTab && activeTab !== 'all') { - activeTab = block.role; - } - updateTabsUI(); - renderBlocksList(); - updateOutputPreview(); + addSourceCardToMixer(blockData); }, }; + + if (!isSide) owner.workbenchComposerControl = control; + return control; } function applyPromptDrop(node, data, graph, parent) { @@ -841,101 +1122,3 @@ function applyPromptDrop(node, data, graph, parent) { dialog.onclose = () => dialog.remove(); dialog.showModal(); } - -/** - * Slide-out Drawer: Quick Prompt Importer from Material Library - */ -async function openMaterialImportDrawer(owner, draft, onUpdated) { - owner.closePromptImportDrawer?.(); - const drawer = text(document.body, 'div', '', 'anomalous-prompt-import-drawer'); - const header = text(drawer, 'div', '', 'anomalous-drawer-header'); - text(header, 'h4', `📥 ${t('promptDrawerTitle')}`); - let controller, timer; - const close = () => { - controller?.abort(); clearTimeout(timer); drawer.remove(); - window.removeEventListener('keydown', onKeydown); - if (owner.closePromptImportDrawer === close) owner.closePromptImportDrawer = null; - }; - const onKeydown = event => { if (event.key === 'Escape') close(); }; - owner.closePromptImportDrawer = close; - window.addEventListener('keydown', onKeydown); - text(header, 'button', '✕', 'anomalous-paper-note-btn').onclick = close; - const search = text(text(drawer, 'div', '', 'anomalous-drawer-search'), 'input', ''); - search.type = 'search'; search.placeholder = t('promptDrawerSearchPlaceholder'); - const list = text(drawer, 'div', '', 'anomalous-drawer-list'); - const pager = text(drawer, 'div', '', 'anomalous-prompt-actions'); - - const load = async (page = 1) => { - controller?.abort(); controller = new AbortController(); const current = controller; - list.replaceChildren(); pager.replaceChildren(); text(list, 'p', t('loading')); - try { - const query = new URLSearchParams({ category: 'prompts', q: search.value, page, limit: 48 }); - const response = await fetch(`/anomalous/materials?${query}`, { signal: current.signal }); - const payload = await jsonResponse(response, 'material list failed'); - if (current.signal.aborted || !drawer.isConnected) return; - list.replaceChildren(); - if (!payload.materials?.length) text(list, 'p', t('materialNoMatches')); - for (const item of payload.materials || []) { - const card = text(list, 'div', '', 'anomalous-drawer-item'); - text(card, 'strong', item.name || t('materialUntitled')); - text(card, 'small', (item.tags || []).join(' · ')); - const inspect = text(card, 'button', t('materialViewDetails'), 'anomalous-drawer-item-btn'); - inspect.onclick = async () => { - inspect.disabled = true; - try { - const prompts = await loadMaterialPrompts(item.filename, current.signal); - if (current.signal.aborted || !card.isConnected) return; - if (!prompts.positive && !prompts.negative) { - inspect.textContent = t('materialNoPromptContent'); inspect.disabled = false; return; - } - for (const role of ['positive', 'negative']) { - if (!prompts[role]) continue; - text(card, 'strong', t(`promptFinal_${role}`)); - text(card, 'pre', prompts[role], 'anomalous-material-note-text'); - const append = text(card, 'button', `➕ ${window.anomalous_browser_lang === 'zh' ? '入坞词块' : 'Add Block'}`, 'anomalous-drawer-item-btn'); - append.onclick = () => { - draft.plan.parts.push(normalizeBlock({ - title: item.name || (role === 'positive' ? '正向词块' : '负向词块'), - content: prompts[role], - role, - category: role === 'positive' ? categorizePromptSnippet(prompts[role]) : 'base', - }, draft.plan.parts.length)); - onUpdated(); - }; - } - const whole = text(card, 'button', `🎛️ ${window.anomalous_browser_lang === 'zh' ? '全部入坞' : 'Add Both to Mixer'}`, 'anomalous-drawer-item-btn'); - whole.onclick = () => { - if (prompts.positive) { - draft.plan.parts.push(normalizeBlock({ - title: `${item.name} (Pos)`, - content: prompts.positive, - role: 'positive', - category: categorizePromptSnippet(prompts.positive), - }, draft.plan.parts.length)); - } - if (prompts.negative) { - draft.plan.parts.push(normalizeBlock({ - title: `${item.name} (Neg)`, - content: prompts.negative, - role: 'negative', - category: 'base', - }, draft.plan.parts.length)); - } - if (!draft.name.trim()) draft.name = item.name; - draft.tags = [...new Set([...draft.tags, ...(item.tags || [])])].slice(0, 20); - onUpdated(); - }; - inspect.remove(); - } catch (error) { if (error.name !== 'AbortError') { inspect.textContent = t('materialDetailLoadError'); inspect.disabled = false; } } - }; - } - const previous = text(pager, 'button', t('materialPrevious'), 'anomalous-btn-ghost'); - previous.disabled = payload.page <= 1; previous.onclick = () => load(payload.page - 1); - text(pager, 'span', t('materialPageSummary', { page: payload.page, pages: payload.pages, count: payload.total })); - const next = text(pager, 'button', t('materialNext'), 'anomalous-btn-ghost'); - next.disabled = payload.page >= payload.pages; next.onclick = () => load(payload.page + 1); - } catch (error) { if (!current.signal.aborted) { list.replaceChildren(); text(list, 'p', t('materialLoadError')); } } - }; - search.oninput = () => { clearTimeout(timer); timer = setTimeout(() => load(), 250); }; - await load(); search.focus(); -} diff --git a/web/styles.css b/web/styles.css index dfbf21b..44fe21d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -9290,3 +9290,365 @@ dialog.anomalous-gallery-viewer::backdrop { opacity: 1; transform: translateY(0); } + +/* ========================================================================== + Prompt Studio Dual-Column Workbench (提示词工坊左右双分栏工作台) + ========================================================================== */ + +.anomalous-prompt-workbench { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + background: radial-gradient(circle at 15% 15%, rgba(30, 41, 59, 0.4) 0%, rgba(15, 23, 42, 0.85) 100%); +} + +.anomalous-prompt-workbench-grid { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(380px, 1.25fr); + gap: 16px; + flex: 1; + min-height: 0; + padding: 12px 18px 16px; + overflow: hidden; + box-sizing: border-box; +} + +@media (max-width: 860px) { + .anomalous-prompt-workbench-grid { + grid-template-columns: 1fr; + overflow-y: auto; + } +} + +/* Left & Right Panels Common */ +.anomalous-workbench-left-panel, +.anomalous-workbench-right-panel { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + border-radius: 12px; + background: rgba(18, 24, 38, 0.7); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(14px); + padding: 14px 14px 12px; + box-sizing: border-box; + gap: 10px; +} + +.anomalous-workbench-col-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.anomalous-workbench-col-title { + font-size: 0.94rem; + color: #f1f5f9; + display: flex; + align-items: center; + gap: 6px; +} + +.anomalous-sub-counter { + font-size: 0.76rem; + color: #64748b; + font-weight: normal; +} + +/* -------------------------------------------------------------------------- + Left Column: Source Cards & New Card Form + -------------------------------------------------------------------------- */ +.anomalous-workbench-filter-bar { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.anomalous-workbench-search-input { + width: 100%; + box-sizing: border-box; + padding: 6px 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #f1f5f9; + font-size: 0.78rem; + outline: none; + transition: border-color 0.15s ease; +} + +.anomalous-workbench-search-input:focus { + border-color: #38bdf8; + background: rgba(0, 0, 0, 0.45); +} + +.anomalous-workbench-category-pills { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.anomalous-workbench-pill { + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: #94a3b8; + font-size: 0.72rem; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; +} + +.anomalous-workbench-pill:hover { + color: #f1f5f9; + background: rgba(255, 255, 255, 0.09); +} + +.anomalous-workbench-pill.is-active { + background: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.4); + color: #38bdf8; + font-weight: 600; +} + +/* Inline New Card Creation Form */ +.anomalous-workbench-new-card-form { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.85); + border: 1px solid rgba(45, 212, 191, 0.35); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + animation: fadeInSlideDown 0.2s ease; + flex-shrink: 0; +} + +@keyframes fadeInSlideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.anomalous-form-title { + font-size: 0.82rem; + font-weight: 600; + color: #2dd4bf; +} + +.anomalous-form-input, +.anomalous-form-select, +.anomalous-form-textarea { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #f1f5f9; + font-size: 0.78rem; + outline: none; + font-family: inherit; +} + +.anomalous-form-cat-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: #94a3b8; +} + +.anomalous-form-btn-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-top: 2px; +} + +/* Source Cards List */ +.anomalous-source-cards-list { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 2px; +} + +.anomalous-source-empty { + padding: 36px 16px; + text-align: center; + color: #64748b; + font-size: 0.82rem; + background: rgba(0, 0, 0, 0.15); + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.08); +} + +/* Individual Source Prompt Card */ +.anomalous-source-prompt-card { + border-radius: 8px; + background: rgba(22, 29, 44, 0.65); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: grab; + user-select: none; + transition: all 0.16s ease; +} + +.anomalous-source-prompt-card:hover { + background: rgba(30, 41, 59, 0.85); + border-color: rgba(56, 189, 248, 0.35); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.anomalous-source-prompt-card:active { + cursor: grabbing; +} + +.anomalous-source-prompt-card.is-dragging-source { + opacity: 0.4; + border-style: dashed; +} + +.anomalous-source-card-header { + display: flex; + align-items: center; + gap: 6px; +} + +.anomalous-source-card-badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 0.66rem; + font-weight: 600; + border: 1px solid transparent; + white-space: nowrap; +} + +.anomalous-source-card-title { + font-size: 0.8rem; + color: #e2e8f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.anomalous-source-card-snippet { + font-size: 0.74rem; + color: #94a3b8; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + background: rgba(0, 0, 0, 0.2); + padding: 4px 6px; + border-radius: 4px; +} + +.anomalous-source-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 0.7rem; + margin-top: 2px; +} + +.anomalous-source-card-drag-hint { + color: #64748b; + font-size: 0.68rem; +} + +.anomalous-source-card-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.anomalous-source-action-btn { + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: #cbd5e1; + font-size: 0.68rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.anomalous-source-action-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +.anomalous-source-action-btn.is-dock { + background: rgba(45, 212, 191, 0.15); + border-color: rgba(45, 212, 191, 0.4); + color: #2dd4bf; + font-weight: 600; +} + +.anomalous-source-action-btn.is-dock:hover { + background: #2dd4bf; + color: #0f172a; +} + +/* -------------------------------------------------------------------------- + Right Column: Drop Zone & Arranger Stage + -------------------------------------------------------------------------- */ +.anomalous-assembly-track { + flex: 1; + min-height: 180px; + max-height: none !important; + overflow-y: auto; + padding-right: 2px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.anomalous-assembly-dropzone { + flex: 1; + min-height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 24px 16px; + border-radius: 10px; + border: 2px dashed rgba(255, 255, 255, 0.14); + background: rgba(15, 23, 42, 0.35); + transition: all 0.2s ease; +} + +.anomalous-assembly-dropzone.is-drag-over { + border-color: #38bdf8; + background: rgba(56, 189, 248, 0.08); + box-shadow: inset 0 0 24px rgba(56, 189, 248, 0.2); + transform: scale(0.995); +}