feat(prompt-workbench): implement dual-column prompt studio with source cards library and drag-to-assemble stage

This commit is contained in:
DemonGatanjieu
2026-09-10 00:23:01 +08:00
parent 888330de6a
commit 828c4d5488
4 changed files with 822 additions and 276 deletions
+10 -9
View File
@@ -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`).
+3 -3
View File
@@ -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) {
+447 -264
View File
@@ -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 = `📚 <strong>${window.anomalous_browser_lang === 'zh' ? '成型提示词库' : 'Prompt Library'}</strong> <span class="anomalous-sub-counter"></span>`;
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 = `🖐️ <strong>${window.anomalous_browser_lang === 'zh' ? '拖动此调音坞' : 'Drag this deck'}</strong> ${window.anomalous_browser_lang === 'zh' ? '直达 ComfyUI 画布节点' : 'onto canvas node'}`;
dragHint.innerHTML = `🖐️ <strong>${window.anomalous_browser_lang === 'zh' ? '按住此预览坞' : 'Drag this deck'}</strong> ${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 = `
<div style="font-size: 24px; margin-bottom: 6px;">🔍</div>
<div>${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}</div>
`;
return;
}
filtered.forEach(card => {
const 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 = `
<div style="font-size: 24px; margin-bottom: 8px;">🧩</div>
<div style="font-weight: 600; color: #94a3b8;">${window.anomalous_browser_lang === 'zh' ? '当前分类暂无提示词块' : 'No prompt blocks in this category'}</div>
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">${window.anomalous_browser_lang === 'zh' ? '点击右上角“添加词块”或在素材库点击“🎛️ 调音台”送入积木' : 'Click "Add Block" or pick from Materials to dock snippets.'}</div>
if (!currentRoleParts.length) {
const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone');
dropzoneNotice.innerHTML = `
<div style="font-size: 30px; margin-bottom: 8px;">📥</div>
<div style="font-size: 0.92rem; font-weight: 600; color: #e2e8f0;">${window.anomalous_browser_lang === 'zh' ? '将左侧的成型提示词拖放到这里摆放与拼装' : 'Drop prompt cards from the left panel here'}</div>
<div style="font-size: 0.76rem; color: #64748b; margin-top: 4px;">${window.anomalous_browser_lang === 'zh' ? '支持自由调换次序,点击【智能理顺】自动按画质底模➔风格➔主体➔LoRA排序' : 'Drag to reorder anytime, or click Smart Sort to auto-align.'}</div>
`;
// 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'
? `🚫 <strong>${window.anomalous_browser_lang === 'zh' ? '合成负向文本' : 'Assembled Negative'}</strong>`
: `✨ <strong>${window.anomalous_browser_lang === 'zh' ? '合成正向文本' : 'Assembled Positive'}</strong>`;
? `🚫 <strong>${window.anomalous_browser_lang === 'zh' ? '实时合成负向文本' : 'Assembled Negative'}</strong>`
: `✨ <strong>${window.anomalous_browser_lang === 'zh' ? '实时合成正向文本' : 'Assembled Positive'}</strong>`;
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();
}
+362
View File
@@ -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);
}