import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, workbenchDraftToSavedPlan } from './prompt_composition.js';
import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import { showMaterialApplication } from './ui_material_application.js';
import { showMaterialSaved } from './material_feedback.js';
import { translatePromptText } from './translation_service.js';
import { CATEGORY_META, newDraft, normalizeBlock, syncDraftSynthesizedText } from './prompt_studio_data.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { openPromptInspectorModal } from './ui_prompt_inspector.js';
import { createPromptSourceDeck } from './ui_prompt_source_deck.js';
export function createPromptWorkbench(owner, container, scope, options) {
const view = text(container, 'section', '', 'anomalous-prompt-composer anomalous-prompt-workbench is-side-studio');
let draft = owner.promptPlanDraft ||= newDraft();
draft.plan ||= { version: 2, parts: [], positive: '', negative: '' };
draft.plan.parts ||= [];
// Migrate existing text if parts are empty
if (!draft.plan.parts.length) {
if (draft.plan.positive?.trim()) {
draft.plan.parts.push(normalizeBlock({
title: window.anomalous_browser_lang === 'zh' ? '基础正向词' : 'Positive Base',
content: draft.plan.positive,
role: 'positive',
category: categorizePromptSnippet(draft.plan.positive),
}, 0));
}
if (draft.plan.negative?.trim()) {
draft.plan.parts.push(normalizeBlock({
title: window.anomalous_browser_lang === 'zh' ? '通用负向过滤' : 'Negative Filter',
content: draft.plan.negative,
role: 'negative',
category: 'base',
}, 1));
}
}
syncDraftSynthesizedText(draft);
let activeTab = 'positive'; // 'positive' | 'negative'
let draggedBlockId = null;
// Local in-memory source prompt cards (merged starters + materials + user custom)
// 1. Studio Topbar (Streamlined with inline preset name input)
const topbar = text(view, 'header', '', 'anomalous-prompt-topbar');
const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left');
text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词工坊' : '🎛️ Prompt Studio');
// Inline Preset Name Input
const nameInput = text(topLeft, 'input', '', 'anomalous-prompt-name-input');
nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称...' : 'Preset name...';
nameInput.title = window.anomalous_browser_lang === 'zh' ? '输入组合方案名称' : 'Enter preset name';
nameInput.maxLength = 120;
nameInput.value = draft.name || '';
nameInput.oninput = () => { draft.name = nameInput.value; };
const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions');
// Quick Extract from Selected Canvas Node
const extractQuickBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
extractQuickBtn.innerHTML = '🎯';
extractQuickBtn.title = window.anomalous_browser_lang === 'zh'
? '从画布选中节点一键吸取提示词'
: 'Extract prompt from selected node';
extractQuickBtn.onclick = () => sourceDeck.extractSelected(true);
// Dock Side Switch (Left / Right)
if (typeof options.onToggleDockSide === 'function') {
const dockSideBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
const isLeft = () => container?.classList.contains('is-dock-left');
const updateDockBtn = () => {
dockSideBtn.innerHTML = isLeft()
? ``
: ``;
dockSideBtn.title = isLeft()
? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right')
: (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left');
};
updateDockBtn();
dockSideBtn.onclick = () => {
options.onToggleDockSide();
updateDockBtn();
};
}
// "More" Dropdown Menu (holds Save, New Draft, Tags)
const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap');
const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-more-btn');
const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu');
const closeMoreMenu = () => {
moreMenu.classList.remove('is-open');
moreBtn.classList.remove('is-active');
};
scope.listen(document, 'click', closeMoreMenu);
moreWrap.onclick = (e) => e.stopPropagation();
moreBtn.onclick = (e) => {
e.stopPropagation();
const willOpen = !moreMenu.classList.contains('is-open');
moreMenu.classList.toggle('is-open', willOpen);
moreBtn.classList.toggle('is-active', willOpen);
};
const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-prompt-more-item');
saveBtn.title = t('promptSavePlan') || '保存方案至素材库';
const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-prompt-more-item');
const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-prompt-more-item');
tagsBtn.onclick = () => {
closeMoreMenu();
const current = (draft.tags || []).join(', ');
const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current);
if (val !== null) {
draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean);
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`);
}
};
const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn');
closeBtn.title = t('close') || '关闭';
closeBtn.onclick = () => {
options.onClose();
};
// Insertion preference lives with the other secondary actions.
const posWrap = document.createElement('label');
posWrap.className = 'anomalous-prompt-insert-pos anomalous-prompt-more-pos';
const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select');
for (const value of ['after', 'before']) {
text(posSelect, 'option', t(`promptInsert_${value}`)).value = value;
}
posSelect.value = owner.promptInsertPosition || 'after';
posSelect.setAttribute('aria-label', t('promptInsertPosition'));
moreMenu.appendChild(posWrap);
posSelect.onchange = () => {
owner.promptInsertPosition = posSelect.value;
closeMoreMenu();
};
// Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode)
const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid');
// =========================================================================
// LEFT COLUMN: Ready-to-use Prompt Cards (词卡库)
// =========================================================================
const sourceDeck = createPromptSourceDeck(workbenchGrid, container, scope, addSourceCardToMixer, () => activeTab);
// =========================================================================
// RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台)
// =========================================================================
const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel');
const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header');
// Role switch tabs (Positive / Negative)
const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs');
const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions');
// Right quick extract: suck into right mixer directly
const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
rightSuckNodeBtn.innerHTML = `${t('promptReadSelectedNode')}`;
rightSuckNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '直接将画布选中节点的提示词作为积木吸入当前拼装台' : 'Extract node prompt directly into current mixer track';
const smartSortBtn = text(rightActions, 'button', '', 'anomalous-mixer-smart-sort-btn');
smartSortBtn.innerHTML = `${t('promptSortByCategory')}`;
smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]';
const clearRightBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost');
clearRightBtn.innerHTML = '';
clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track';
// Blocks Container & Dropzone
const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track');
blocksContainer.ondragover = (e) => {
e.preventDefault();
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) {
snapDock.classList.add('is-drag-over');
blocksContainer.classList.add('is-drag-over-end');
}
};
blocksContainer.ondragleave = (e) => {
if (!blocksContainer.contains(e.relatedTarget)) {
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
snapDock?.classList.remove('is-drag-over');
blocksContainer.classList.remove('is-drag-over-end');
}
};
blocksContainer.ondrop = (e) => {
if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) {
e.preventDefault();
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
snapDock?.classList.remove('is-drag-over');
blocksContainer.classList.remove('is-drag-over-end');
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
addSourceCardToMixer(JSON.parse(jsonStr));
return;
} catch (err) {}
}
if (draggedBlockId) {
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
if (fromIndex >= 0) {
const [moved] = draft.plan.parts.splice(fromIndex, 1);
draft.plan.parts.push(moved);
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
}
};
// -------------------------------------------------------------------------
// Node Direct Write Handler
// -------------------------------------------------------------------------
const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const targets = promptWidgetTargets(node);
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '⚠️ 选中的节点中未找到可写入的提示词文本输入!'
: '⚠️ No writable prompt text widget found in selected node!');
return;
}
const widgetIndex = targets[0].index;
try {
const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value);
showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node);
if (triggerBtn) {
const orig = triggerBtn.textContent;
triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written');
setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!');
}
return true;
} catch (error) {
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
}
};
// -------------------------------------------------------------------------
// Floating Action Dock (Minimalist & Slides Up Only When Content Exists)
// -------------------------------------------------------------------------
const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock');
floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added
const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left');
const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text');
const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right');
// 1. Write Node Button
const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm');
writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node';
writeNodeBtn.onclick = async () => {
syncDraftSynthesizedText(draft);
const compiledText = (draft.plan[activeTab] || '').trim();
if (!compiledText) return;
await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn);
};
// 2. Quick Copy Button
const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm');
copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text';
// 3. Popout Full Inspector Modal Button
let closeInspector = null;
scope.onDispose(() => closeInspector?.());
const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm');
popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal';
popoutBtn.onclick = () => {
closeInspector?.();
closeInspector = openPromptInspectorModal(draft, activeTab, async (role, textVal) => {
return applyPromptToCurrentNode(role, textVal);
}, () => {
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
});
};
// -------------------------------------------------------------------------
// FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync
// -------------------------------------------------------------------------
rightSuckNodeBtn.onclick = () => sourceDeck.extractSelected(true);
function updateRightTabsUI() {
rightTabs.replaceChildren();
const posCount = draft.plan.parts.filter(p => (p.track || p.role) === 'positive').length;
const negCount = draft.plan.parts.filter(p => (p.track || p.role) === 'negative').length;
const tabs = [
{
key: 'positive',
label: `⊕ ${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})`,
cls: 'is-role-positive',
},
{
key: 'negative',
label: `⊖ ${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})`,
cls: 'is-role-negative',
},
];
for (const tab of tabs) {
const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn ${tab.cls}${activeTab === tab.key ? ' is-active' : ''}`);
btn.onclick = () => {
activeTab = tab.key;
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
};
}
}
function addSourceCardToMixer(cardData, targetIndex = null) {
const cardRole = cardData.role || 'positive';
const isCrossRole = cardRole !== activeTab;
const newBlock = normalizeBlock({
title: cardData.title,
content: cardData.content,
role: cardRole,
track: activeTab,
category: cardData.category,
enabled: true,
}, draft.plan.parts.length);
newBlock._justAdded = true;
if (targetIndex !== null && targetIndex >= 0) {
draft.plan.parts.splice(targetIndex, 0, newBlock);
} else if (isCrossRole) {
// Cross-role cards (e.g. negative prompt on positive track) default to the tail ("扔到后面")
draft.plan.parts.push(newBlock);
} else {
// Same-role cards: insert before any tail cross-role blocks on this track, or at the end
const firstCrossIdx = draft.plan.parts.findIndex(p => (p.track || p.role) === activeTab && (p.role || 'positive') !== activeTab);
if (firstCrossIdx >= 0) {
draft.plan.parts.splice(firstCrossIdx, 0, newBlock);
} else {
draft.plan.parts.push(newBlock);
}
}
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
const count = draft.plan.parts.filter(p => (p.track || p.role) === activeTab).length;
const crossNotice = isCrossRole
? (window.anomalous_browser_lang === 'zh'
? `(${cardRole === 'negative' ? '负向' : '正向'}词已默认置于末尾)`
: ` (${cardRole} prompt placed at tail)`)
: '';
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台${crossNotice}(共 ${count} 块)`
: `Added to ${activeTab} track${crossNotice} (${count} blocks)`);
}
function renderBlocksList() {
blocksContainer.replaceChildren();
const currentRoleParts = draft.plan.parts.filter(p => (p.track || p.role) === activeTab);
if (!currentRoleParts.length) {
const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone');
dropzoneNotice.innerHTML = `
${window.anomalous_browser_lang === 'zh' ? '点击词卡或拖拽到这里拼装' : 'Click cards or drag here to assemble'}
${window.anomalous_browser_lang === 'zh' ? '支持自由拖拽调换次序,点击【智能排序】一键理顺' : 'Reorder freely anytime, or click Smart Sort.'}
`;
// Accept drops on empty dropzone
setupDropzoneListeners(dropzoneNotice);
return;
}
currentRoleParts.forEach((block, index) => {
const isJustAdded = !!block._justAdded;
if (isJustAdded) delete block._justAdded;
const isCrossRole = (block.role || 'positive') !== activeTab;
const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.role}${isCrossRole ? ' is-cross-role' : ''}${isJustAdded ? ' is-just-added' : ''}`);
blockEl.setAttribute('draggable', 'true');
blockEl.dataset ||= {};
blockEl.dataset.blockId = block.id;
if (isJustAdded) {
requestAnimationFrame(() => {
blockEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
// Reorder Drag Listeners
blockEl.ondragstart = (e) => {
draggedBlockId = block.id;
blockEl.classList.add('is-dragging');
e.dataTransfer.setData('text/plain', block.content);
e.dataTransfer.effectAllowed = 'move';
};
blockEl.ondragend = () => {
draggedBlockId = null;
blockEl.classList.remove('is-dragging');
blocksContainer.querySelectorAll('.anomalous-mixer-block').forEach(el => {
el.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
});
};
blockEl.ondragover = (e) => {
e.preventDefault();
const rect = blockEl.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
blockEl.classList.add('is-drag-over-top');
blockEl.classList.remove('is-drag-over-bottom');
} else {
blockEl.classList.add('is-drag-over-bottom');
blockEl.classList.remove('is-drag-over-top');
}
};
blockEl.ondragleave = () => {
blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
};
blockEl.ondrop = (e) => {
e.preventDefault();
blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
// 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);
if (fromIndex < 0) return;
const [moved] = draft.plan.parts.splice(fromIndex, 1);
const currentTargetIndex = draft.plan.parts.findIndex(p => p.id === block.id);
if (currentTargetIndex < 0) {
draft.plan.parts.push(moved);
} else {
const rect = blockEl.getBoundingClientRect();
const insertAfter = e.clientY >= (rect.top + rect.height / 2);
const finalIndex = insertAfter ? currentTargetIndex + 1 : currentTargetIndex;
draft.plan.parts.splice(finalIndex, 0, moved);
}
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
};
// Header
const blockHeader = text(blockEl, 'div', '', 'anomalous-mixer-block-header');
const headerLeft = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-left');
const dragHandle = text(headerLeft, 'span', '⠿', 'anomalous-mixer-drag-handle');
dragHandle.title = window.anomalous_browser_lang === 'zh' ? '抓取按住上下拖拽排序' : 'Drag to reorder';
// 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);
syncDraftSynthesizedText(draft);
updateOutputPreview();
};
// Unified Role Badge with Prominent Indicator and Click-to-toggle
const roleBadge = text(headerLeft, 'span', '', `anomalous-mixer-role-badge is-${block.role}${isCrossRole ? ' is-cross-role' : ''}`);
const roleText = block.role === 'negative'
? (isCrossRole
? (window.anomalous_browser_lang === 'zh' ? '⊖ 负向 (末尾)' : '⊖ Neg (Tail)')
: (window.anomalous_browser_lang === 'zh' ? '⊖ 负向' : '⊖ Negative'))
: (isCrossRole
? (window.anomalous_browser_lang === 'zh' ? '⊕ 正向 (末尾)' : '⊕ Pos (Tail)')
: (window.anomalous_browser_lang === 'zh' ? '⊕ 正向' : '⊕ Positive'));
roleBadge.textContent = roleText;
roleBadge.title = window.anomalous_browser_lang === 'zh'
? `当前属性:${block.role === 'negative' ? '负向' : '正向'}${isCrossRole ? '(跨角色默认置于末尾)' : ''}。点击切换正负属性。`
: `Role: ${block.role}${isCrossRole ? ' (Cross-role at tail)' : ''}. Click to toggle.`;
roleBadge.onclick = () => {
block.role = block.role === 'negative' ? 'positive' : 'negative';
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已将词块属性切换为【${block.role === 'positive' ? '正向' : '负向'}】`
: `Role changed to ${block.role}`);
};
// Category Badge
const catMeta = CATEGORY_META[block.category] || CATEGORY_META.subject;
const catBadge = text(headerLeft, 'span', window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en, 'anomalous-mixer-cat-badge');
catBadge.style.color = catMeta.color;
catBadge.style.backgroundColor = catMeta.bg;
catBadge.style.borderColor = catMeta.border;
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;
block.category = cats[nextIdx];
renderBlocksList();
};
// Editable title
const titleInput = text(headerLeft, 'input', '', 'anomalous-mixer-block-title-input');
titleInput.value = block.title || '';
titleInput.oninput = () => { block.title = titleInput.value; };
// Weight Adjustment Pill with Mouse Wheel & Click
let currentWeight = block.weight !== undefined ? block.weight : 1.0;
const weightPill = text(headerLeft, 'span', '', 'anomalous-block-weight-pill');
weightPill.title = window.anomalous_browser_lang === 'zh'
? '滚轮上下滑动调节权重,或点击加减'
: 'Scroll wheel or click +/- to adjust weight';
const decBtn = text(weightPill, 'button', '-', 'anomalous-block-weight-btn');
const weightVal = text(weightPill, 'span', currentWeight.toFixed(2));
const incBtn = text(weightPill, 'button', '+', 'anomalous-block-weight-btn');
const applyBlockWeight = (newW) => {
newW = Math.round(Math.max(0.1, Math.min(3.0, newW)) * 100) / 100;
currentWeight = newW;
block.weight = newW;
weightVal.textContent = newW.toFixed(2);
let clean = block.content.replace(/^\((.+):[0-9.]+\)$/, '$1').replace(/^\((.+)\)$/, '$1').trim();
if (newW !== 1.0) {
block.content = `(${clean}:${newW.toFixed(2)})`;
} else {
block.content = clean;
}
const txtArea = blockEl.querySelector('.anomalous-mixer-block-textarea');
if (txtArea) txtArea.value = block.content;
syncDraftSynthesizedText(draft);
updateOutputPreview();
};
decBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight - 0.05); };
incBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight + 0.05); };
weightPill.onwheel = (e) => {
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY < 0 ? 0.05 : -0.05;
applyBlockWeight(currentWeight + delta);
};
// Right Action micro buttons
const headerRight = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-right');
const transferBtn = text(headerRight, 'button', '⇄', 'anomalous-mixer-block-btn is-transfer');
const targetTrack = activeTab === 'positive' ? 'negative' : 'positive';
transferBtn.title = window.anomalous_browser_lang === 'zh'
? `移至${targetTrack === 'positive' ? '正向' : '负向'}拼装台`
: `Move to ${targetTrack} track`;
transferBtn.onclick = () => {
block.track = targetTrack;
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已将词块移至【${targetTrack === 'positive' ? '正向' : '负向'}】拼装台`
: `Moved block to ${targetTrack} track`);
};
const transBtn = text(headerRight, 'button', '🌐', 'anomalous-mixer-block-btn is-translate');
transBtn.title = window.anomalous_browser_lang === 'zh' ? '一键翻译此块提示词' : 'Translate this block';
transBtn.onclick = async () => {
const raw = block.content || '';
if (!raw.trim()) return;
const orig = transBtn.textContent;
transBtn.textContent = '⏳';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (scope.signal.aborted || !draft.plan.parts.includes(block) || block.content !== raw) return;
if (res.ok && res.translated) {
block.content = res.translated;
textarea.value = res.translated;
syncDraftSynthesizedText(draft);
updateOutputPreview();
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 词块已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Block translated to ${res.targetLang}`);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
}
} finally {
transBtn.textContent = orig;
}
};
const upBtn = text(headerRight, 'button', '▲', 'anomalous-mixer-block-btn');
upBtn.title = window.anomalous_browser_lang === 'zh' ? '上移' : 'Move up';
upBtn.disabled = index === 0;
upBtn.onclick = () => {
const trackBlocks = draft.plan.parts.filter(p => (p.track || p.role) === activeTab);
const myTrackIdx = trackBlocks.findIndex(p => p.id === block.id);
if (myTrackIdx > 0) {
const prevBlock = trackBlocks[myTrackIdx - 1];
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
const prevRealIdx = draft.plan.parts.findIndex(p => p.id === prevBlock.id);
if (realIdx >= 0 && prevRealIdx >= 0) {
const temp = draft.plan.parts[realIdx];
draft.plan.parts[realIdx] = draft.plan.parts[prevRealIdx];
draft.plan.parts[prevRealIdx] = temp;
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
};
const downBtn = text(headerRight, 'button', '▼', 'anomalous-mixer-block-btn');
downBtn.title = window.anomalous_browser_lang === 'zh' ? '下移' : 'Move down';
downBtn.disabled = index === currentRoleParts.length - 1;
downBtn.onclick = () => {
const trackBlocks = draft.plan.parts.filter(p => (p.track || p.role) === activeTab);
const myTrackIdx = trackBlocks.findIndex(p => p.id === block.id);
if (myTrackIdx >= 0 && myTrackIdx < trackBlocks.length - 1) {
const nextBlock = trackBlocks[myTrackIdx + 1];
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
const nextRealIdx = draft.plan.parts.findIndex(p => p.id === nextBlock.id);
if (realIdx >= 0 && nextRealIdx >= 0) {
const temp = draft.plan.parts[realIdx];
draft.plan.parts[realIdx] = draft.plan.parts[nextRealIdx];
draft.plan.parts[nextRealIdx] = temp;
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
};
const delBtn = text(headerRight, 'button', '✕', 'anomalous-mixer-block-btn is-delete');
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);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
}
};
// Body
const blockBody = text(blockEl, 'div', '', 'anomalous-mixer-block-body');
const textarea = text(blockBody, 'textarea', '', 'anomalous-mixer-block-textarea');
textarea.value = block.content || '';
textarea.rows = 1;
textarea.oninput = () => {
block.content = textarea.value;
syncDraftSynthesizedText(draft);
updateOutputPreview();
};
});
// Interactive Auto-snap Dock at end of track
const snapDock = text(blocksContainer, 'div', '', 'anomalous-assembly-snap-dock');
snapDock.innerHTML = `
${window.anomalous_browser_lang === 'zh' ? '拖拽卡片至此自动贴合至末尾' : 'Drop card here to snap to bottom'}
`;
setupDropzoneListeners(snapDock);
snapDock.onclick = () => {
leftSearch?.focus?.();
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '点击左侧词卡即可直接加入此处' : 'Click any card on left to add here');
};
}
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));
return;
} catch (err) {}
}
if (draggedBlockId) {
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
if (fromIndex >= 0) {
const [moved] = draft.plan.parts.splice(fromIndex, 1);
draft.plan.parts.push(moved);
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
};
}
function updateOutputPreview() {
syncDraftSynthesizedText(draft);
const compiledText = (draft.plan[activeTab] || '').trim();
const currentRoleParts = draft.plan.parts.filter(p => (p.track || p.role) === activeTab);
if (!currentRoleParts.length && !compiledText) {
floatingDock.style.display = 'none';
} else {
floatingDock.style.display = 'flex';
const words = compiledText.split(/[,,\s\n]+/).filter(Boolean).length;
const roleIcon = activeTab === 'negative' ? '⊖' : '⊕';
const roleName = activeTab === 'negative' ? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative') : (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive');
dockStats.innerHTML = `${roleIcon} ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块' : 'blocks'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
dockStats.title = `${roleIcon} ${roleName}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`;
}
}
// Smart Sort
smartSortBtn.onclick = () => {
if (!draft.plan.parts.length) return;
const trackParts = draft.plan.parts.filter(p => (p.track || p.role) === activeTab);
const otherParts = draft.plan.parts.filter(p => (p.track || p.role) !== activeTab);
const sorted = smartSortPromptBlocks(trackParts, activeTab);
draft.plan.parts = [...sorted, ...otherParts];
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
smartSortBtn.classList.add('is-animating');
setTimeout(() => smartSortBtn.classList.remove('is-animating'), 500);
};
// Clear track
clearRightBtn.onclick = async () => {
const msg = window.anomalous_browser_lang === 'zh'
? `确定清空当前【${activeTab === 'positive' ? '⊕ 正向' : '⊖ 负向'}】拼装池吗?`
: `Clear ${activeTab} mixer track?`;
if (await anomalousConfirm(msg) && !scope.signal.aborted) {
draft.plan.parts = draft.plan.parts.filter(p => (p.track || p.role) !== activeTab);
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
}
};
// Copy preview text
copyOutputBtn.onclick = async (e) => {
e?.stopPropagation?.();
const textToCopy = (draft.plan[activeTab] || '').trim();
if (!textToCopy) return;
try {
await navigator.clipboard.writeText(textToCopy);
copyOutputBtn.textContent = '✅';
setTimeout(() => { if (copyOutputBtn.isConnected) copyOutputBtn.textContent = `📋`; }, 1500);
} catch (err) {
await anomalousAlert(t('materialCopyError'));
}
};
// -------------------------------------------------------------------------
// Target Node Direct Injection Toolbar (Compact sticky bar integrated into Action Dock)
// -------------------------------------------------------------------------
// Save Plan to Material Library
saveBtn.onclick = async () => {
closeMoreMenu();
if (!draft.name?.trim()) {
nameInput.focus();
nameInput.classList.add('is-invalid');
setTimeout(() => nameInput.classList.remove('is-invalid'), 1200);
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!');
return;
}
saveBtn.disabled = true;
syncDraftSynthesizedText(draft);
const saved = workbenchDraftToSavedPlan(draft);
const body = {
name: draft.name.trim(),
tags: draft.tags || [],
plan: saved.plan,
};
const send = () => fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
try {
let response = await send();
if (response.status === 409) {
const duplicate = await response.json();
if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name })) || scope.signal.aborted) return;
body.allow_duplicate = true;
response = await send();
}
const payload = await jsonResponse(response, 'prompt save failed');
if (payload.status !== 'success') throw new Error('prompt save failed');
if (!scope.signal.aborted) {
showMaterialSaved(owner, payload.material);
void sourceDeck.sync();
}
} catch (error) {
if (!scope.signal.aborted) await anomalousAlert(t('materialSaveError'));
} finally {
saveBtn.disabled = false;
}
};
// New Draft
newBtn.onclick = async () => {
closeMoreMenu();
if (await anomalousConfirm(t('promptReplaceDraft')) && !scope.signal.aborted) {
owner.promptPlanDraft = draft = newDraft();
draft.plan.parts.push(normalizeBlock({
title: window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt',
content: '',
role: 'positive',
category: 'base',
}, 0));
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
nameInput.value = '';
draft.tags = [];
}
};
// Initial render execution
sourceDeck.refresh();
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
const control = {
updateAll: () => {
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
sourceDeck.refresh();
},
addBlock: (blockData) => {
addSourceCardToMixer(blockData);
},
};
return control;
}