From 828c4d54880fad2f87bdb669ef22d2cc3eab9790 Mon Sep 17 00:00:00 2001
From: DemonGatanjieu <1027066412@qq.com>
Date: Thu, 10 Sep 2026 00:23:01 +0800
Subject: [PATCH] feat(prompt-workbench): implement dual-column prompt studio
with source cards library and drag-to-assemble stage
---
ARCHITECTURE.md | 19 +-
web/modules/ui_materials.js | 6 +-
web/modules/ui_prompt_composer.js | 711 +++++++++++++++++++-----------
web/styles.css | 362 +++++++++++++++
4 files changed, 822 insertions(+), 276 deletions(-)
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);
+}