refactor: split prompt studio views and unify UI lifecycle

This commit is contained in:
DemonGatanjieu
2026-09-15 09:32:18 +08:00
parent 6dd7f94c0e
commit de02f5dd81
31 changed files with 2482 additions and 2597 deletions
+4
View File
@@ -1,5 +1,7 @@
export const i18n = {
zh: {
promptSourceSynced: '已同步 {count} 张新词卡',
promptFullTextEditHint: '修改全文会将当前方向已启用的词块合并为一个词块。',
materialNoPromptContent: '这份素材没有已明确分类的提示词,请先在详情中检查提示词角色。',
promptDrawerReplace: '替换这组文字',
promptDrawerAppend: '加入这组文字',
@@ -1106,6 +1108,8 @@ export const i18n = {
recipeCardSpecsResolution: '尺寸',
},
en: {
promptSourceSynced: 'Synced {count} new prompt cards',
promptFullTextEditHint: 'Editing the full text merges the enabled blocks in this direction into one block.',
materialNoPromptContent: 'This material has no classified prompts. Check its prompt roles in details first.',
promptDrawerReplace: 'Replace this text',
promptDrawerAppend: 'Add this text',
+2 -1
View File
@@ -1,6 +1,7 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { materialNodeHeading, text } from './material_inspector.js';
import { text } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
let activeDrag = null;
+1 -15
View File
@@ -1,15 +1,8 @@
import { text } from './ui_dom.js';
/** Shared image metadata and node-parameter inspection for the library and workbench. */
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export function text(parent, tag, value, className = '') {
const element = document.createElement(tag);
if (className) element.className = className;
element.textContent = value == null ? '' : String(value);
parent.appendChild(element);
return element;
}
export function sectionLabel(parent, value) {
return text(parent, 'div', value, 'anomalous-material-section-label');
}
@@ -75,13 +68,6 @@ export function lookupLocalModel(localModels, path) {
return localModels[path] || localModels[fileBaseName(path)] || null;
}
export async function jsonResponse(response, fallbackMessage) {
let payload = {};
try { payload = await response.json(); } catch (error) { /* non-JSON server failure */ }
if (!response.ok) throw new Error(payload.message || fallbackMessage);
return payload;
}
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
+2 -1
View File
@@ -1,5 +1,6 @@
import { composePromptPlan } from './prompt_composition.js';
import { jsonResponse } from './material_inspector.js';
import { jsonResponse } from './ui_dom.js';
export function materialPromptText(payload) {
const data = payload.data || {};
+50
View File
@@ -0,0 +1,50 @@
import { loadMaterialPrompts } from './material_prompt_data.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
// Load every page through the same path for initial and explicit synchronization.
export async function loadPromptSourceCards(signal) {
const cards = [];
const filenames = new Set();
let page = 1;
let pages = 1;
do {
signal.throwIfAborted();
const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal });
const payload = await jsonResponse(response, 'materialLoadError');
if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError');
pages = Number(payload.pages) || 1;
for (const item of payload.materials) {
signal.throwIfAborted();
if (filenames.has(item.filename)) continue;
filenames.add(item.filename);
const prompts = await loadMaterialPrompts(item.filename, signal);
for (const role of ['positive', 'negative']) {
const content = prompts[role]?.trim();
if (!content) continue;
cards.push({
id: `mat_${role}_${item.filename}`, filename: item.filename,
title: item.name || item.filename, content, role,
category: role === 'negative' ? 'base' : categorizePromptSnippet(content),
persisted: true,
});
}
}
page++;
} while (page <= pages);
signal.throwIfAborted();
return cards;
}
export function mergePromptSourceCards(existing, incoming) {
const keys = new Set(existing.map(card => `${card.role}\0${card.content.trim()}`));
let added = 0;
for (const card of incoming) {
const key = `${card.role}\0${card.content.trim()}`;
if (keys.has(key)) continue;
existing.push(card);
keys.add(key);
added++;
}
return added;
}
+109
View File
@@ -0,0 +1,109 @@
import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js';
export const newDraft = () => ({
name: '',
tags: [],
plan: {
version: 2,
parts: [],
positive: '',
negative: '',
},
});
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
export 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',
},
];
export 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');
const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim();
let category = part.category;
if (!CATEGORY_META[category]) {
category = categorizePromptSnippet(content);
}
const meta = CATEGORY_META[category] || CATEGORY_META.subject;
const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : '');
return {
id,
title: part.name || part.title || defaultTitle,
content,
role,
category,
enabled: part.enabled !== false,
};
}
export function syncDraftSynthesizedText(draft) {
if (!draft || !draft.plan) return;
draft.plan.parts ||= [];
const posParts = draft.plan.parts.filter(p => p.role === 'positive');
const negParts = draft.plan.parts.filter(p => p.role === 'negative');
draft.plan.positive = assemblePromptBlocks(posParts, 'positive');
draft.plan.negative = assemblePromptBlocks(negParts, 'negative');
}
// Full-text edits replace the enabled blocks of one role. Keep disabled blocks
// and the other role intact; synthesized fields are always derived from parts.
export function replaceDraftRoleText(draft, role, content) {
const parts = draft.plan.parts || [];
const first = parts.findIndex(part => part.role === role && part.enabled !== false);
const replacement = content.trim() ? normalizeBlock({ content, role }) : null;
const next = [];
parts.forEach((part, index) => {
if (index === first && replacement) next.push(replacement);
if (part.role !== role || part.enabled === false) next.push(part);
});
if (first < 0 && replacement) next.push(replacement);
draft.plan.parts = next;
syncDraftSynthesizedText(draft);
}
+6 -1
View File
@@ -52,10 +52,12 @@ export function normalizePromptFormatting(text, delimiter = ', ') {
* @param {string} text - Raw prompt text to translate
* @param {Object} [options]
* @param {string} [options.targetLang] - Target language ('en' | 'zh-CN' | 'ja' | etc.)
* @param {AbortSignal} [options.signal] - Cancels requests when their owning view closes
* @param {boolean} [options.bypassCache=false] - If true, ignores in-memory cache
* @returns {Promise<{ ok: boolean, translated: string, targetLang: string, error?: string }>}
*/
export async function translatePromptText(text, options = {}) {
if (options.signal?.aborted) return { ok: false, cancelled: true, translated: '', targetLang: options.targetLang || 'en' };
const raw = String(text || '').trim();
if (!raw) {
return { ok: true, translated: '', targetLang: options.targetLang || 'en' };
@@ -71,6 +73,7 @@ export async function translatePromptText(text, options = {}) {
try {
const response = await fetch('/anomalous/translate', {
method: 'POST',
signal: options.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: raw,
@@ -83,6 +86,7 @@ export async function translatePromptText(text, options = {}) {
}
const data = await response.json();
options.signal?.throwIfAborted();
if (data.status === 'error' || data.error) {
throw new Error(data.error || 'Translation service failed');
}
@@ -93,7 +97,7 @@ export async function translatePromptText(text, options = {}) {
}
// Cache the successful result (limit cache to 500 items)
if (translationCache.size > 500) {
if (translationCache.size >= 500 && !translationCache.has(cacheKey)) {
const oldestKey = translationCache.keys().next().value;
translationCache.delete(oldestKey);
}
@@ -101,6 +105,7 @@ export async function translatePromptText(text, options = {}) {
return { ok: true, translated, targetLang, engine: data.engine };
} catch (err) {
if (options.signal?.aborted || err.name === 'AbortError') return { ok: false, cancelled: true, translated: raw, targetLang };
console.warn('[Anomalous Translation] Translate failed:', err);
return {
ok: false,
+14
View File
@@ -0,0 +1,14 @@
export function text(parent, tag, value, className = '') {
const element = document.createElement(tag);
if (className) element.className = className;
element.textContent = value == null ? '' : String(value);
parent.appendChild(element);
return element;
}
export async function jsonResponse(response, fallbackMessage) {
let payload = {};
try { payload = await response.json(); } catch { /* A server error may not be JSON. */ }
if (!response.ok) throw new Error(payload.message || fallbackMessage);
return payload;
}
+1 -2
View File
@@ -15,13 +15,12 @@ import { showMaterialSaved } from './material_feedback.js';
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { text, jsonResponse } from './ui_dom.js';
import {
text,
sectionLabel,
fileBaseName,
appendLocalPreview,
modelCustomNotes,
jsonResponse,
parsePngMetadataFromUrl,
extractWorkflowDetails,
resolveLocalModels,
+57
View File
@@ -0,0 +1,57 @@
// Resources belong to the view that creates them, including every close path.
export function createViewScope() {
const controller = new AbortController();
const cleanups = new Set();
const onDispose = cleanup => {
if (controller.signal.aborted) cleanup();
else cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
};
return {
signal: controller.signal,
onDispose,
listen(target, event, handler, options) {
target.addEventListener(event, handler, options);
return onDispose(() => target.removeEventListener(event, handler, options));
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
for (const cleanup of cleanups) cleanup();
cleanups.clear();
},
};
}
export function bindDrawerResize(handle, panel, scope, { side, minWidth, setWidth, saveWidth, enabled = () => true }) {
let finish = null;
handle.onmousedown = event => {
if (event.button !== 0 || scope.signal.aborted || !enabled()) return;
finish?.();
event.preventDefault();
const startX = event.clientX;
const startWidth = panel.getBoundingClientRect().width;
const previousSelect = document.body.style.userSelect;
const previousCursor = document.body.style.cursor;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
handle.classList.add('is-resizing');
const move = moveEvent => {
const delta = (moveEvent.clientX - startX) * (side() === 'left' ? 1 : -1);
setWidth(Math.max(minWidth, Math.min(window.innerWidth * 0.85, startWidth + delta)));
};
const stop = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
document.body.style.userSelect = previousSelect;
document.body.style.cursor = previousCursor;
handle.classList.remove('is-resizing');
finish = null;
};
const up = () => { stop(); saveWidth(panel.getBoundingClientRect().width); };
finish = stop;
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
scope.onDispose(() => { finish?.(); handle.onmousedown = null; });
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, materialNodeHeading } from './material_inspector.js';
import { text } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { applyMaterialBlock, selectedMaterialNode } from './node_material_actions.js';
export function showMaterialApplication(parent, result, node) {
+4 -4
View File
@@ -7,7 +7,9 @@ import { app } from '../../../scripts/app.js';
import { translate, resolveLocale } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { text, sectionLabel, fileBaseName, jsonResponse, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
import { text, jsonResponse } from './ui_dom.js';
import { escapeHtml } from './safe_dom.js';
import { sectionLabel, fileBaseName, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
const t = (key, params) => translate(key, params);
const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material.kind);
@@ -32,7 +34,6 @@ function hideSiblingWorkspaceViews(owner) {
if (owner.notebookContainer) owner.notebookContainer.style.display = 'none';
if (owner.notebookBody) owner.notebookBody.style.display = 'none';
if (owner.recipeView) owner.recipeView.style.display = 'none';
if (owner.promptStudioContainer) owner.promptStudioContainer.style.display = 'none';
if (owner.recipeContainer) owner.recipeContainer.style.display = 'none';
owner.notebookNotesTab?.classList.remove('active');
owner.notebookRecipesTab?.classList.remove('active');
@@ -924,9 +925,8 @@ export async function refreshMaterials(page = this.materialPage || 1) {
}
export async function showMaterials() {
this.closePromptImportDrawer?.();
this.nbPanel.style.display = 'flex';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (!this.materialContainer) {
this.materialContainer = text(this.nbPanel, 'div', '', 'anomalous-nb-container anomalous-material-container');
const header = text(this.materialContainer, 'div', '', 'anomalous-nb-header');
-4
View File
@@ -31,7 +31,6 @@ function restoreWorkspaceReturnPanel(owner) {
}
export function closeWorkspace() {
this.closePromptImportDrawer?.();
clearTimeout(this.materialSearchTimer);
this.materialListController?.abort();
this.materialListController = null;
@@ -58,7 +57,6 @@ export function closeWorkspace() {
if (this.materialView) this.materialView.style.display = 'none';
if (this.notebookBody) this.notebookBody.style.display = 'none';
if (this.materialContainer) this.materialContainer.style.display = 'none';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (this.recipeContainer) this.recipeContainer.style.display = 'none';
if (this.nbPanel) this.nbPanel.style.display = 'none';
restoreWorkspaceReturnPanel(this);
@@ -67,9 +65,7 @@ export function closeWorkspace() {
export async function showNotebooks() {
this.closePromptImportDrawer?.();
if (this.materialContainer) this.materialContainer.style.display = 'none';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (this.recipeContainer) this.recipeContainer.style.display = 'none';
if (this.notebookContainer) this.notebookContainer.style.display = 'flex';
if (this.nbInitialized) {
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
import { createViewScope } from './ui_lifecycle.js';
import { syncDraftSynthesizedText, replaceDraftRoleText } from './prompt_studio_data.js';
import { translatePromptText } from './translation_service.js';
import { translate as t } from './locales.js';
let activeInspector = null;
export function openPromptInspectorModal(draft, defaultRole = 'positive', onApplyToNode = null, onChange = () => {}) {
activeInspector?.dispose();
const scope = createViewScope();
activeInspector = scope;
const overlay = document.createElement('div');
overlay.className = 'anomalous-prompt-inspector-overlay';
scope.onDispose(() => { overlay.remove(); if (activeInspector === scope) activeInspector = null; });
const modal = document.createElement('div');
modal.className = 'anomalous-prompt-inspector-modal';
overlay.appendChild(modal);
let currentRole = defaultRole || 'positive';
// Header
const header = document.createElement('header');
header.className = 'anomalous-inspector-header';
const h3 = document.createElement('h3');
h3.textContent = window.anomalous_browser_lang === 'zh' ? '🔍 提示词深度全览与精修' : '🔍 Prompt Inspector & Editor';
h3.style.margin = '0';
h3.style.fontSize = '0.92rem';
h3.style.color = '#e2e8f0';
header.appendChild(h3);
// Role Tabs
const tabsWrap = document.createElement('div');
tabsWrap.style.display = 'flex';
tabsWrap.style.gap = '6px';
const posTab = document.createElement('button');
posTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'positive' ? ' is-active' : ''}`;
posTab.textContent = `🔆 ${window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive'}`;
const negTab = document.createElement('button');
negTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'negative' ? ' is-active' : ''}`;
negTab.textContent = `🌙 ${window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative'}`;
tabsWrap.appendChild(posTab);
tabsWrap.appendChild(negTab);
header.appendChild(tabsWrap);
const closeBtn = document.createElement('button');
closeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
closeBtn.textContent = '✕';
closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭 (Esc)' : 'Close (Esc)';
closeBtn.onclick = () => scope.dispose();
header.appendChild(closeBtn);
// Body
const body = document.createElement('div');
body.className = 'anomalous-inspector-body';
const editHint = document.createElement('small');
editHint.textContent = t('promptFullTextEditHint');
body.appendChild(editHint);
const textarea = document.createElement('textarea');
textarea.className = 'anomalous-inspector-textarea';
textarea.spellcheck = false;
syncDraftSynthesizedText(draft);
textarea.value = draft?.plan?.[currentRole] || '';
const updateStats = () => {
const val = textarea.value;
const words = val.split(/[,,\s\n]+/).filter(Boolean).length;
statsEl.textContent = `${val.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
};
const updateTabStyles = () => {
posTab.classList.toggle('is-active', currentRole === 'positive');
negTab.classList.toggle('is-active', currentRole === 'negative');
textarea.value = draft?.plan?.[currentRole] || '';
updateStats();
};
posTab.onclick = () => { currentRole = 'positive'; updateTabStyles(); };
negTab.onclick = () => { currentRole = 'negative'; updateTabStyles(); };
body.appendChild(textarea);
// Footer
const footer = document.createElement('footer');
footer.className = 'anomalous-inspector-footer';
const statsEl = document.createElement('div');
statsEl.style.fontSize = '0.75rem';
statsEl.style.color = '#94a3b8';
statsEl.style.fontFamily = 'monospace';
const saveEdits = () => {
replaceDraftRoleText(draft, currentRole, textarea.value);
onChange();
updateStats();
};
textarea.oninput = saveEdits;
updateStats();
footer.appendChild(statsEl);
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.gap = '6px';
// Normalize formatting
const normBtn = document.createElement('button');
normBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
normBtn.textContent = `🧹 ${window.anomalous_browser_lang === 'zh' ? '规范化' : 'Normalize'}`;
normBtn.title = window.anomalous_browser_lang === 'zh' ? '清理多余顿号、分号与重复逗号' : 'Clean formatting and punctuation';
normBtn.onclick = () => {
const cleaned = textarea.value
.replace(/[,,、;;||\n\r]+/g, ', ')
.replace(/\s+,/g, ',')
.replace(/,\s*,/g, ', ')
.replace(/^\s*,\s*|\s*,\s*$/g, '')
.trim();
textarea.value = cleaned;
saveEdits();
};
actions.appendChild(normBtn);
// Translate
const transBtn = document.createElement('button');
transBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
transBtn.textContent = `🌐 ${window.anomalous_browser_lang === 'zh' ? '翻译' : 'Translate'}`;
transBtn.onclick = async () => {
const raw = textarea.value.trim();
const role = currentRole;
if (!raw) return;
const orig = transBtn.textContent;
transBtn.textContent = '⏳';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (!scope.signal.aborted && currentRole === role && textarea.value.trim() === raw && res.ok && res.translated) {
textarea.value = res.translated;
saveEdits();
}
} finally {
transBtn.textContent = orig;
}
};
actions.appendChild(transBtn);
// Copy
const copyBtn = document.createElement('button');
copyBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`;
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(textarea.value);
copyBtn.textContent = '✓';
setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; }, 1200);
};
actions.appendChild(copyBtn);
// Apply to node & close
if (typeof onApplyToNode === 'function') {
const applyBtn = document.createElement('button');
applyBtn.className = 'anomalous-btn-primary anomalous-btn-sm';
applyBtn.textContent = `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点并关闭' : 'Write Node & Close'}`;
applyBtn.onclick = async () => {
if (await onApplyToNode(currentRole, textarea.value)) scope.dispose();
};
actions.appendChild(applyBtn);
}
footer.appendChild(actions);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.onclick = (e) => {
if (e.target === overlay) scope.dispose();
};
const onKey = (e) => {
if (e.key === 'Escape') {
scope.dispose();
}
};
scope.listen(window, 'keydown', onKey);
document.body.appendChild(overlay);
return () => scope.dispose();
}
+516
View File
@@ -0,0 +1,516 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js';
import { translatePromptText } from './translation_service.js';
export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCardToMixer) {
const sourceCards = [...STARTER_SOURCE_PROMPTS];
let sourceFilterCategory = 'all';
let sourceFilterKeyword = '';
let isCreatingNewCard = false;
let activeCardPreviewPopover = null;
function hideCardPreviewPopover() {
activeCardPreviewPopover?.remove();
activeCardPreviewPopover = null;
}
scope.onDispose(hideCardPreviewPopover);
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');
text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library');
const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter');
// Action button group in leftHeader
const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions');
// Button 1: One-click Sync / Import from Material Library
const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
importMaterialsBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library';
// Button 2: Create New Custom Card
const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
newCardTriggerBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card';
// 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 cards...';
leftSearch.oninput = () => {
sourceFilterKeyword = leftSearch.value.trim().toLowerCase();
renderSourceCardsList();
};
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' ? '触发' : 'Trigger' },
];
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');
function detectNodePromptRole(node, widgetName = '') {
if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative';
if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive';
if (app?.graph && Array.isArray(node?.outputs)) {
for (const output of node.outputs) {
if (!Array.isArray(output.links)) continue;
for (const linkId of output.links) {
const link = app.graph.links?.[linkId];
if (!link) continue;
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode?.inputs && targetNode.inputs[link.target_slot]) {
const targetInput = targetNode.inputs[link.target_slot];
const inputName = String(targetInput.name || '').toLowerCase();
if (inputName.includes('neg') || inputName.includes('负')) return 'negative';
if (inputName.includes('pos') || inputName.includes('正')) return 'positive';
}
}
}
}
const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase();
if (/neg|negative|反向|负向/.test(nodeText)) return 'negative';
if (/pos|positive|正面|正向/.test(nodeText)) return 'positive';
return 'positive';
}
// Extract prompts from selected canvas node
function extractPromptsFromSelectedNode(intoRightMixer = false) {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`;
let targets = promptWidgetTargets(node);
// Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths
if (!targets.length && Array.isArray(node.widgets)) {
targets = node.widgets.flatMap((w, idx) => {
const name = String(w.name || '').toLowerCase();
if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) {
return [];
}
if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) {
if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') {
return [{ index: idx, name: w.name || 'text' }];
}
}
return [];
});
}
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!`
: `⚠️ No valid prompt text found in selected node [${heading}]!`);
return;
}
let extractedCount = 0;
targets.forEach(t => {
const rawVal = String(node.widgets[t.index]?.value || '').trim();
if (!rawVal) return;
const role = detectNodePromptRole(node, t.name);
const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal);
const cardTitle = `${heading} · ${t.name}`;
const newCard = {
id: `node_${node.id}_${t.index}_${Date.now()}`,
title: cardTitle,
content: rawVal,
role,
category: cat,
persisted: false,
};
// Add into left source deck
sourceCards.unshift(newCard);
extractedCount++;
// If user clicked right panel, also insert into mixer track directly
if (intoRightMixer) {
addSourceCardToMixer(newCard);
}
});
if (extractedCount > 0) {
renderSourceCardsList();
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!`
: `Successfully extracted ${extractedCount} prompts from [${heading}]!`);
} else {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `选中的节点【${heading}】文本内容为空!`
: `The text fields in node [${heading}] are empty!`);
}
}
let materialSyncController = null;
scope.onDispose(() => materialSyncController?.abort());
async function syncMaterialsIntoSourceDeck(notify = true) {
materialSyncController?.abort();
const controller = new AbortController();
materialSyncController = controller;
const icon = importMaterialsBtn.innerHTML;
importMaterialsBtn.disabled = true;
try {
const cards = await loadPromptSourceCards(controller.signal);
if (scope.signal.aborted || controller.signal.aborted) return;
const addedCount = mergePromptSourceCards(sourceCards, cards);
renderSourceCardsList();
if (notify) showWorkbenchToast(t('promptSourceSynced', { count: addedCount }));
} catch (error) {
if (!scope.signal.aborted && !controller.signal.aborted && notify) await anomalousAlert(t('materialLoadError'));
} finally {
if (materialSyncController === controller) {
materialSyncController = null;
importMaterialsBtn.disabled = false;
importMaterialsBtn.innerHTML = icon;
}
}
}
importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck();
// -------------------------------------------------------------------------
// RENDER: Left Panel Cards & Form
// -------------------------------------------------------------------------
function renderNewCardFormUI() {
if (!isCreatingNewCard) {
newCardForm.style.display = 'none';
newCardForm.replaceChildren();
return;
}
newCardForm.style.display = 'flex';
newCardForm.replaceChildren();
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...';
// Role & Category selector row
const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row');
// Role radio group
const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group');
let selectedRole = activeTab || 'positive';
const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const posRadio = text(posLabel, 'input', '');
posRadio.type = 'radio';
posRadio.name = 'new_card_role';
posRadio.value = 'positive';
posRadio.checked = selectedRole === 'positive';
text(posLabel, 'span', t('promptRolePositive'));
const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const negRadio = text(negLabel, 'input', '');
negRadio.type = 'radio';
negRadio.name = 'new_card_role';
negRadio.value = 'negative';
negRadio.checked = selectedRole === 'negative';
text(negLabel, 'span', t('promptRoleNegative'));
posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; };
negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; };
// Category dropdown
const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap');
text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `);
let selectedCat = 'style';
const catSelect = text(catWrap, '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 translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate');
translateBtn.type = 'button';
translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation';
translateBtn.onclick = async () => {
const raw = contentInput.value.trim();
if (!raw) return;
const originalText = translateBtn.textContent;
translateBtn.disabled = true;
translateBtn.textContent = '⏳ ...';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (scope.signal.aborted || !contentInput.isConnected || contentInput.value.trim() !== raw) return;
if (res.ok && res.translated) {
contentInput.value = res.translated;
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
}
} finally {
translateBtn.disabled = false;
translateBtn.textContent = originalText;
}
};
const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm');
submitBtn.onclick = async () => {
const rawContent = contentInput.value.trim();
if (!rawContent) {
contentInput.focus();
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '⏳ ...';
const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en);
const newCard = {
id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
title: cardTitle,
content: rawContent,
role: selectedRole,
category: selectedCat,
persisted: false,
};
try {
const body = {
name: cardTitle,
tags: ['prompt_card', selectedCat, selectedRole],
allow_duplicate: true,
plan: {
version: 2,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
parts: [{
id: newCard.id,
name: cardTitle,
category: selectedCat,
role: selectedRole,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
enabled: true,
}],
},
};
const res = await fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await jsonResponse(res, 'save prompt card');
if (scope.signal.aborted) return;
if (payload.status === 'success') {
newCard.persisted = true;
newCard.filename = payload.filename;
showWorkbenchToast(t('promptSaveCardSuccess'));
}
} catch (err) {
if (scope.signal.aborted) return;
showWorkbenchToast(t('promptSaveCardFailed'));
}
sourceCards.unshift(newCard);
isCreatingNewCard = false;
renderNewCardFormUI();
renderSourceCardsList();
};
cancelBtn.onclick = () => {
isCreatingNewCard = false;
renderNewCardFormUI();
};
titleInput.focus();
}
newCardTriggerBtn.onclick = () => {
isCreatingNewCard = !isCreatingNewCard;
renderNewCardFormUI();
};
function showCardPreviewPopover(card, anchorEl) {
hideCardPreviewPopover();
if (!anchorEl?.isConnected) return;
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const popover = document.createElement('div');
popover.className = 'anomalous-card-preview-popover';
const header = document.createElement('div');
header.className = 'anomalous-popover-header';
const tags = document.createElement('div');
tags.className = 'anomalous-popover-tags';
const catBadge = document.createElement('span');
catBadge.className = 'anomalous-popover-cat';
catBadge.style.color = catMeta.color;
catBadge.style.background = catMeta.bg;
catBadge.style.borderColor = catMeta.border;
catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en;
tags.appendChild(catBadge);
const roleBadge = document.createElement('span');
roleBadge.className = `anomalous-popover-role is-${card.role}`;
roleBadge.textContent = card.role === 'negative'
? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative')
: (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive');
tags.appendChild(roleBadge);
header.appendChild(tags);
const titleEl = document.createElement('div');
titleEl.className = 'anomalous-popover-title';
titleEl.textContent = card.title;
header.appendChild(titleEl);
popover.appendChild(header);
const body = document.createElement('div');
body.className = 'anomalous-popover-body';
const snippet = document.createElement('pre');
snippet.className = 'anomalous-popover-snippet';
snippet.textContent = card.content;
body.appendChild(snippet);
popover.appendChild(body);
const footer = document.createElement('div');
footer.className = 'anomalous-popover-footer';
footer.textContent = window.anomalous_browser_lang === 'zh'
? '💡 点击直接添加 · 拖拽自由调序'
: '💡 Click to add · Drag to assemble';
popover.appendChild(footer);
document.body.appendChild(popover);
activeCardPreviewPopover = popover;
// Smart Positioning
const rect = anchorEl.getBoundingClientRect();
const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true;
const popoverHeight = popover.offsetHeight || 160;
const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6));
popover.style.top = `${top}px`;
if (isDockLeft) {
popover.style.left = `${rect.right + 10}px`;
} else {
popover.style.left = `${Math.max(12, rect.left - 320)}px`;
}
}
function renderSourceCardsList() {
hideCardPreviewPopover();
sourceCardsList.replaceChildren();
sourceCardsList.onscroll = () => hideCardPreviewPopover();
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;
});
leftCounter.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 catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category}`);
cardEl.setAttribute('draggable', 'true');
// Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip)
cardEl.onmouseenter = () => {
showCardPreviewPopover(card, cardEl);
};
cardEl.onmouseleave = () => {
hideCardPreviewPopover();
};
// Drag Start
cardEl.ondragstart = (e) => {
hideCardPreviewPopover();
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');
workbenchGrid.classList.add('is-source-dragging');
};
cardEl.ondragend = () => {
cardEl.classList.remove('is-dragging-source');
workbenchGrid.classList.remove('is-source-dragging');
};
const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot');
dot.style.backgroundColor = catMeta.color;
const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name');
const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon');
cardEl.onclick = () => {
hideCardPreviewPopover();
addSourceCardToMixer(card);
};
});
}
renderNewCardFormUI();
renderSourceCardsList();
void syncMaterialsIntoSourceDeck(false);
return { extractSelected: extractPromptsFromSelectedNode, refresh: renderSourceCardsList, sync: syncMaterialsIntoSourceDeck };
}
+11
View File
@@ -0,0 +1,11 @@
export function showWorkbenchToast(message) {
const toast = document.createElement('div');
toast.className = 'anomalous-mixer-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('is-show'), 10);
setTimeout(() => {
toast.classList.remove('is-show');
setTimeout(() => toast.remove(), 300);
}, 2400);
}
+47 -71
View File
@@ -8,9 +8,10 @@
import { app } from '../../../scripts/app.js';
import { translatePromptText, splitPromptTags, normalizePromptFormatting, hasChinese } from './translation_service.js';
import { selectedMaterialNode, promptWidgetTargets, applyNodeMaterialValues } from './node_material_actions.js';
import { createViewScope, bindDrawerResize } from './ui_lifecycle.js';
import { appendPromptToStudio } from './ui_prompt_composer.js';
let activeTranslatorModal = null;
let activeTranslator = null;
function t(zh, en) {
return window.anomalous_browser_lang === 'zh' ? zh : en;
@@ -96,15 +97,17 @@ function writeToSelectedNode(text) {
* @param {Object} owner - AnomalousBrowser instance
*/
export function openPromptTranslator(owner) {
if (activeTranslatorModal) {
activeTranslatorModal.remove();
activeTranslatorModal = null;
}
activeTranslator?.dispose();
const scope = createViewScope();
activeTranslator = scope;
// Modal Overlay
const overlay = document.createElement('div');
overlay.className = 'anomalous-translator-overlay';
activeTranslatorModal = overlay;
scope.onDispose(() => {
overlay.remove();
if (activeTranslator === scope) activeTranslator = null;
});
// Modal Window
const modal = document.createElement('div');
@@ -125,48 +128,14 @@ export function openPromptTranslator(owner) {
resizeHandle.className = 'anomalous-translator-resize-handle';
resizeHandle.title = t('拖动调整侧边栏宽度,双击恢复默认', 'Drag to resize sidebar, double-click to reset');
let isResizing = false;
let startX = 0;
let startWidth = 380;
let currentSide = localStorage.getItem('anomalous_translator_dock_side') || 'right';
resizeHandle.onmousedown = (e) => {
if (!modal.classList.contains('is-sidebar')) return;
isResizing = true;
startX = e.clientX;
startWidth = modal.getBoundingClientRect().width;
resizeHandle.classList.add('is-resizing');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
const onMouseMove = (moveEvt) => {
if (!isResizing) return;
let newWidth;
if (currentSide === 'left') {
const deltaX = moveEvt.clientX - startX;
newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX));
} else {
const deltaX = startX - moveEvt.clientX;
newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX));
}
modal.style.setProperty('--amb-translator-width', `${newWidth}px`);
};
const onMouseUp = () => {
if (!isResizing) return;
isResizing = false;
resizeHandle.classList.remove('is-resizing');
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const finalWidth = modal.getBoundingClientRect().width;
localStorage.setItem('anomalous_translator_width', Math.round(finalWidth));
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
bindDrawerResize(resizeHandle, modal, scope, {
side: () => currentSide,
enabled: () => modal.classList.contains('is-sidebar'),
minWidth: 320,
setWidth: width => modal.style.setProperty('--amb-translator-width', `${width}px`),
saveWidth: width => localStorage.setItem('anomalous_translator_width', String(Math.round(width))),
});
resizeHandle.ondblclick = () => {
if (!modal.classList.contains('is-sidebar')) return;
@@ -280,8 +249,7 @@ export function openPromptTranslator(owner) {
closeBtn.innerHTML = '&times;';
closeBtn.title = t('关闭 (Esc)', 'Close (Esc)');
closeBtn.onclick = () => {
overlay.remove();
activeTranslatorModal = null;
scope.dispose();
};
headerRight.appendChild(closeBtn);
header.appendChild(headerRight);
@@ -494,8 +462,7 @@ export function openPromptTranslator(owner) {
if (/[,、;;||]/.test(out) && !hasChinese(out)) {
out = normalizePromptFormatting(out);
}
overlay.remove();
activeTranslatorModal = null;
scope.dispose();
if (typeof owner?.openPromptStudio === 'function') {
owner.openPromptStudio();
}
@@ -527,33 +494,40 @@ export function openPromptTranslator(owner) {
return;
}
if (!hasChinese(out)) {
out = normalizePromptFormatting(out);
targetTextarea.value = out;
updateTagChips(out);
const res = writeToSelectedNode(out);
showTranslatorToast(modal, res.message, !res.success);
if (translateAndWriteBtn.disabled || scope.signal.aborted) return;
// Capture the exact destination before awaiting a translation.
const graph = app.graph;
const node = selectedMaterialNode(app);
const target = node && promptWidgetTargets(node)[0];
if (!target) {
showTranslatorToast(modal, t('请先选中可写入的提示词节点', 'Select a writable prompt node first'), true);
return;
}
const widget = node.widgets[target.index];
const previousValue = widget.value;
translateAndWriteBtn.disabled = true;
translateAndWriteBtn.innerHTML = `⏳ ${t('反译写入中...', 'Translating & Writing...')}`;
try {
const res = await translatePromptText(out, { targetLang: 'en' });
const res = await translatePromptText(out, { targetLang: 'en', signal: scope.signal });
if (scope.signal.aborted) return;
if (res.ok && res.translated) {
const enTags = normalizePromptFormatting(res.translated);
const writeRes = writeToSelectedNode(enTags);
if (writeRes.success) {
showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!'));
sourceTextarea.value = enTags;
} else {
showTranslatorToast(modal, writeRes.message, true);
if (app.graph !== graph || selectedMaterialNode(app) !== node ||
graph.getNodeById(node.id) !== node || node.widgets[target.index] !== widget ||
widget.value !== previousValue || targetTextarea.value.trim() !== out) {
showTranslatorToast(modal, t('目标或文本已改变,请重新点击翻译写入', 'Target or text changed. Translate and write again.'), true);
return;
}
applyNodeMaterialValues(app, node, [{ index: target.index, value: enTags }]);
showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!'));
sourceTextarea.value = enTags;
} else {
showTranslatorToast(modal, t(`反译失败: ${res.error || '网络错误'}`, `Translation failed`), true);
}
} catch (err) {
if (scope.signal.aborted) return;
showTranslatorToast(modal, t(`反译异常: ${err.message}`, `Error: ${err.message}`), true);
} finally {
translateAndWriteBtn.disabled = false;
@@ -597,6 +571,7 @@ export function openPromptTranslator(owner) {
// Translation Handler
async function doTranslate() {
if (translateBtn.disabled || scope.signal.aborted) return;
const raw = sourceTextarea.value.trim();
if (!raw) {
sourceTextarea.focus();
@@ -604,7 +579,8 @@ export function openPromptTranslator(owner) {
}
const selectedLang = langSelect.value;
const options = {};
const previousOutput = targetTextarea.value;
const options = { signal: scope.signal };
if (selectedLang !== 'auto') {
options.targetLang = selectedLang;
}
@@ -614,6 +590,8 @@ export function openPromptTranslator(owner) {
try {
const res = await translatePromptText(raw, options);
if (scope.signal.aborted || sourceTextarea.value.trim() !== raw ||
langSelect.value !== selectedLang || targetTextarea.value !== previousOutput) return;
if (res.ok && res.translated) {
targetTextarea.value = res.translated;
updateTagChips(res.translated);
@@ -622,6 +600,7 @@ export function openPromptTranslator(owner) {
showTranslatorToast(modal, t(`翻译失败: ${res.error || '网络错误'}`, `Failed: ${res.error || 'Network error'}`), true);
}
} catch (err) {
if (scope.signal.aborted) return;
showTranslatorToast(modal, t(`翻译异常: ${err.message}`, `Error: ${err.message}`), true);
} finally {
translateBtn.disabled = false;
@@ -649,20 +628,17 @@ export function openPromptTranslator(owner) {
// Close on clicking backdrop (only active in centered modal mode)
overlay.onclick = (e) => {
if (e.target === overlay && !overlay.classList.contains('is-sidebar')) {
overlay.remove();
activeTranslatorModal = null;
scope.dispose();
}
};
// Close on Escape key
const onKeyDown = (e) => {
if (e.key === 'Escape') {
overlay.remove();
activeTranslatorModal = null;
document.removeEventListener('keydown', onKeyDown);
scope.dispose();
}
};
document.addEventListener('keydown', onKeyDown);
scope.listen(document, 'keydown', onKeyDown);
document.body.appendChild(overlay);
sourceTextarea.focus();
+895
View File
@@ -0,0 +1,895 @@
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()
? `<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>`
: `<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>`;
dockSideBtn.title = isLeft()
? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right')
: (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left');
};
updateDockBtn();
dockSideBtn.onclick = () => {
options.onToggleDockSide();
updateDockBtn();
};
}
// "More" Dropdown Menu (holds Save, New Draft, Tags, Export, Material Sync)
const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap');
moreWrap.style.position = 'relative';
moreWrap.style.display = 'inline-flex';
const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm');
const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu');
moreMenu.style.display = 'none';
moreMenu.style.position = 'absolute';
moreMenu.style.top = 'calc(100% + 4px)';
moreMenu.style.right = '0';
moreMenu.style.zIndex = '1000';
moreMenu.style.minWidth = '150px';
moreMenu.style.background = 'var(--amb-bg-panel, #1C1E24)';
moreMenu.style.border = '1px solid var(--amb-border, rgba(255, 255, 255, 0.14))';
moreMenu.style.borderRadius = 'var(--amb-radius-control, 6px)';
moreMenu.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.6)';
moreMenu.style.padding = '4px';
const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-ghost anomalous-btn-sm');
saveBtn.style.width = '100%';
saveBtn.style.justifyContent = 'flex-start';
saveBtn.style.border = 'none';
saveBtn.title = t('promptSavePlan') || '保存方案至素材库';
const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost anomalous-btn-sm');
newBtn.style.width = '100%';
newBtn.style.justifyContent = 'flex-start';
newBtn.style.border = 'none';
const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-btn-ghost anomalous-btn-sm');
tagsBtn.style.width = '100%';
tagsBtn.style.justifyContent = 'flex-start';
tagsBtn.style.border = 'none';
tagsBtn.onclick = () => {
closeMoreMenu();
const current = (draft.tags || []).join(', ');
const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current);
if (val !== null) {
draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean);
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`);
}
};
const exportBtn = text(moreMenu, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost anomalous-btn-sm');
exportBtn.style.width = '100%';
exportBtn.style.justifyContent = 'flex-start';
exportBtn.style.border = 'none';
const syncLibBtn = text(moreMenu, 'button', `📥 ${window.anomalous_browser_lang === 'zh' ? '同步素材库' : 'Sync Library'}`, 'anomalous-btn-ghost anomalous-btn-sm');
syncLibBtn.style.width = '100%';
syncLibBtn.style.justifyContent = 'flex-start';
syncLibBtn.style.border = 'none';
syncLibBtn.onclick = () => {
closeMoreMenu();
return sourceDeck.sync();
};
moreBtn.onclick = (e) => {
e.stopPropagation();
const isHidden = moreMenu.style.display === 'none';
moreMenu.style.display = isHidden ? 'block' : 'none';
};
const closeMoreMenu = () => { moreMenu.style.display = 'none'; };
scope.listen(document, 'click', closeMoreMenu);
const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn');
closeBtn.title = t('close') || '关闭';
closeBtn.onclick = () => {
options.onClose();
};
// Insertion preference lives with the other secondary actions.
const posWrap = document.createElement('label');
posWrap.className = 'anomalous-prompt-insert-pos';
const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select');
for (const value of ['after', 'before']) {
text(posSelect, 'option', t(`promptInsert_${value}`)).value = value;
}
posSelect.value = owner.promptInsertPosition || 'after';
posSelect.setAttribute('aria-label', t('promptInsertPosition'));
moreMenu.appendChild(posWrap);
posSelect.onchange = () => {
owner.promptInsertPosition = posSelect.value;
};
// Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode)
const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid');
// =========================================================================
// LEFT COLUMN: Ready-to-use Prompt Cards (词卡库)
// =========================================================================
const sourceDeck = createPromptSourceDeck(workbenchGrid, container, scope, addSourceCardToMixer);
// =========================================================================
// RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台)
// =========================================================================
const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel');
const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header');
// Role switch tabs (Positive / Negative)
const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs');
const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions');
// Right quick extract: suck into right mixer directly
const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
rightSuckNodeBtn.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>${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 = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>${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 = '<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track';
// Blocks Container & Dropzone
const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track');
blocksContainer.ondragover = (e) => {
e.preventDefault();
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) {
snapDock.classList.add('is-drag-over');
blocksContainer.classList.add('is-drag-over-end');
}
};
blocksContainer.ondragleave = (e) => {
if (!blocksContainer.contains(e.relatedTarget)) {
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
snapDock?.classList.remove('is-drag-over');
blocksContainer.classList.remove('is-drag-over-end');
}
};
blocksContainer.ondrop = (e) => {
if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) {
e.preventDefault();
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
snapDock?.classList.remove('is-drag-over');
blocksContainer.classList.remove('is-drag-over-end');
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
addSourceCardToMixer(JSON.parse(jsonStr));
return;
} catch (err) {}
}
if (draggedBlockId) {
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
if (fromIndex >= 0) {
const [moved] = draft.plan.parts.splice(fromIndex, 1);
draft.plan.parts.push(moved);
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
}
};
// -------------------------------------------------------------------------
// Node Direct Write Handler
// -------------------------------------------------------------------------
const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const targets = promptWidgetTargets(node);
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '⚠️ 选中的节点中未找到可写入的提示词文本输入!'
: '⚠️ No writable prompt text widget found in selected node!');
return;
}
const widgetIndex = targets[0].index;
try {
const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value);
showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node);
if (triggerBtn) {
const orig = triggerBtn.textContent;
triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written');
setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!');
}
return true;
} catch (error) {
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
}
};
// -------------------------------------------------------------------------
// Floating Action Dock (Minimalist & Slides Up Only When Content Exists)
// -------------------------------------------------------------------------
const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock');
floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added
const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left');
const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text');
const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right');
// 1. Write Node Button
const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm');
writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node';
writeNodeBtn.onclick = async () => {
syncDraftSynthesizedText(draft);
const compiledText = (draft.plan[activeTab] || '').trim();
if (!compiledText) return;
await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn);
};
// 2. Quick Copy Button
const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm');
copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text';
// 3. Popout Full Inspector Modal Button
let closeInspector = null;
scope.onDispose(() => closeInspector?.());
const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm');
popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal';
popoutBtn.onclick = () => {
closeInspector?.();
closeInspector = openPromptInspectorModal(draft, activeTab, async (role, textVal) => {
return applyPromptToCurrentNode(role, textVal);
}, () => {
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
});
};
// -------------------------------------------------------------------------
// FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync
// -------------------------------------------------------------------------
rightSuckNodeBtn.onclick = () => sourceDeck.extractSelected(true);
function updateRightTabsUI() {
rightTabs.replaceChildren();
const posCount = draft.plan.parts.filter(p => p.role === 'positive').length;
const negCount = draft.plan.parts.filter(p => p.role === 'negative').length;
const tabs = [
{ key: 'positive', label: `${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})` },
{ key: 'negative', label: `${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})` },
];
for (const tab of tabs) {
const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`);
btn.onclick = () => {
activeTab = tab.key;
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
};
}
}
function addSourceCardToMixer(cardData, targetIndex = null) {
const cardRole = cardData.role || 'positive';
if (activeTab !== cardRole) {
activeTab = cardRole;
updateRightTabsUI();
}
const newBlock = normalizeBlock({
title: cardData.title,
content: cardData.content,
role: cardRole,
category: cardData.category,
enabled: true,
}, draft.plan.parts.length);
newBlock._justAdded = true;
if (targetIndex !== null && targetIndex >= 0) {
draft.plan.parts.splice(targetIndex, 0, newBlock);
} else {
draft.plan.parts.push(newBlock);
}
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
const count = draft.plan.parts.filter(p => p.role === activeTab).length;
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台(共 ${count} 块)`
: `Added to ${activeTab} track (${count} blocks)`);
}
function renderBlocksList() {
blocksContainer.replaceChildren();
const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab);
if (!currentRoleParts.length) {
const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone');
dropzoneNotice.innerHTML = `
<div style="margin-bottom: 12px; opacity: 0.65;">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>
</svg>
</div>
<div style="font-size: 0.88rem; font-weight: 600; color: #f1f5f9;">${window.anomalous_browser_lang === 'zh' ? '点击词卡或拖拽到这里拼装' : 'Click cards or drag here to assemble'}</div>
<div style="font-size: 0.74rem; color: #64748b; margin-top: 5px;">${window.anomalous_browser_lang === 'zh' ? '支持自由拖拽调换次序,点击【智能排序】一键理顺' : 'Reorder freely anytime, or click Smart Sort.'}</div>
`;
// Accept drops on empty dropzone
setupDropzoneListeners(dropzoneNotice);
return;
}
currentRoleParts.forEach((block, index) => {
const isJustAdded = !!block._justAdded;
if (isJustAdded) delete block._justAdded;
const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.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();
};
// 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 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 realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
if (realIdx < 0) return;
const myRole = block.role || activeTab;
let prevIdx = -1;
for (let i = realIdx - 1; i >= 0; i--) {
if ((draft.plan.parts[i].role || 'positive') === myRole) {
prevIdx = i;
break;
}
}
if (prevIdx >= 0) {
const temp = draft.plan.parts[realIdx];
draft.plan.parts[realIdx] = draft.plan.parts[prevIdx];
draft.plan.parts[prevIdx] = 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 realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
if (realIdx < 0) return;
const myRole = block.role || activeTab;
let nextIdx = -1;
for (let i = realIdx + 1; i < draft.plan.parts.length; i++) {
if ((draft.plan.parts[i].role || 'positive') === myRole) {
nextIdx = i;
break;
}
}
if (nextIdx >= 0) {
const temp = draft.plan.parts[realIdx];
draft.plan.parts[realIdx] = draft.plan.parts[nextIdx];
draft.plan.parts[nextIdx] = 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 = `
<div class="anomalous-snap-dock-content">
<svg class="anomalous-snap-icon" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span class="anomalous-snap-dock-text">${window.anomalous_browser_lang === 'zh' ? '拖拽卡片至此自动贴合至末尾' : 'Drop card here to snap to bottom'}</span>
</div>
`;
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.role === activeTab);
if (!currentRoleParts.length && !compiledText) {
floatingDock.style.display = 'none';
} else {
floatingDock.style.display = 'flex';
const words = compiledText.split(/[,,\s\n]+/).filter(Boolean).length;
dockStats.innerHTML = `${activeTab === 'negative' ? '🌙' : '🔆'} <strong>${currentRoleParts.length}</strong> ${window.anomalous_browser_lang === 'zh' ? '块' : 'blocks'} · ~<strong>${words}</strong> ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
dockStats.title = activeTab === 'negative'
? `${window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`
: `${window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'}: ${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 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();
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.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 () => {
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);
} catch (error) {
if (!scope.signal.aborted) await anomalousAlert(t('materialSaveError'));
} finally {
saveBtn.disabled = false;
}
};
// Export Plan JSON
exportBtn.onclick = () => {
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;
}
syncDraftSynthesizedText(draft);
const saved = workbenchDraftToSavedPlan(draft);
const exportData = {
format: 'anomalous-prompt-plan-v1',
version: 2,
name: draft.name,
tags: draft.tags,
plan: saved.plan,
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
if (blob.size > 2 * 1024 * 1024 || draft.tags.length > 20 || draft.tags.some(tag => tag.length > 60)) {
return anomalousAlert(t('promptExportError'));
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${draft.name || 'prompt-mixer-preset'}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
// New Draft
newBtn.onclick = async () => {
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;
}
-2
View File
@@ -887,9 +887,7 @@ function buildRecipeStudioTopbar(owner) {
}
export async function showRecipes() {
this.closePromptImportDrawer?.();
if (this.materialContainer) this.materialContainer.style.display = 'none';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (this.notebookContainer) this.notebookContainer.style.display = 'none';
if (this.nbPanel) this.nbPanel.style.display = 'flex';