refactor: split prompt studio views and unify UI lifecycle
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 +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 {
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+33
-2092
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = '×';
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user