refactor: split prompt studio views and unify UI lifecycle

This commit is contained in:
DemonGatanjieu
2026-09-15 09:32:18 +08:00
parent 6dd7f94c0e
commit de02f5dd81
31 changed files with 2482 additions and 2597 deletions
+8 -1
View File
@@ -69,7 +69,14 @@ AI_SYSTEM_TEMPLATE.md
# Test files
test_*.js
test*.mjs
tests/
tests/*
!tests/package.json
!tests/ui_fixture.mjs
!tests/prompt_ui_lifecycle.mjs
!tests/prompt_material_source.mjs
!tests/translation_service_contracts.mjs
!tests/studio_contracts.mjs
!tests/material_workspace_flow.mjs
# Local diagnostic/test artifacts
grep_doctor.txt
+22 -138
View File
@@ -74,144 +74,28 @@ DOM or live LiteGraph state.
### Frontend
- `web/main.js` is the extension entry and shared browser-instance owner.
- `web/modules/entry_controls.js` and `shortcut_controls.js` own entry modes and
native command/keybinding integration.
- `ui_sidebar.js`, `ui_grid.js`, `ui_detail.js`, and `ui_gallery.js` own the
primary model-browser surfaces, including the frosted-glass capsule parameter inspection
button with lightweight vector SVG icons and GPU-accelerated micro-interactions;
the sidebar bottom action bar (`#anomalous-sidebar-actions`) features intuitive, highly restrained glyphic vector SVG icons (`SIDEBAR_ICONS`):
(1) Toolbox Hub (`#anomalous-toolbox-btn`): Precision 18px vector toolbox icon (`TOOLBOX`) anchoring the leftmost action slot, triggering an elevated 236px zero-scrollbar micro-utility drawer popover (`#anomalous-toolbox-modal`, z-index 999999, `overflow: hidden`) anchored directly above the action bar in the bottom-left corner (`bottom: 50px; left: 10px;`, collapsed `left: 68px`), presenting a high-density 3-column app/utility icon grid palette (`.anomalous-toolbox-grid`, `.anomalous-toolbox-tile`) hosting the Prompt Studio (`🎛️ 提示词工坊`, `openPromptStudio`) as the primary active online tool alongside utility helpers, with instant tooltips, emerald ready indicator dots (`.is-ready`), amber planned status dots, extensible tool registry (`this.registerToolboxItem`), zero obstruction to the central model grid, and mutual exclusivity against Settings Hub;
(2) Scan Wizard (`#anomalous-scan-btn`): Clean circular radar scope with range ring, crosshairs, and sweep beacon (`SCAN_RADAR_ICON_SVG`), animated only during active scanning;
(3) Model Doctor (`#anomalous-doctor-btn`): Sleek diagnostic medical stethoscope, instantly conveying health check semantics;
(4) AI Assistant (`#anomalous-assistant-btn`): Radiant AI copilot sparkles (`✦`), representing copilot intelligence;
(5) Material Library (`#anomalous-materials-btn`): 3D layered preset stack, distinguishing from individual model boxes;
(6) Settings Hub (`#anomalous-global-settings-btn`): Precision 8-tooth mechanical engineering gear with elevated popover (`#anomalous-settings-hub-modal`) mounted directly to container with `z-index: 999999` and dynamic responsive offset; hosts a clean 3-way Display Mode segmented controller (`#anomalous-view-mode-container` for Standard, Compact, and Aesthetic modes), UI scale controls, dynamic background atmosphere controls, folder manager, language toggles, a restrained inline 13px glyph question icon for Help (decoupling from `.anomalous-btn-icon` 32px box trap), and an intuitive reload arrow (`↺`) for window layout reset replacing esoteric drafting ruler emojis;
(7) Models & Floating Trigger: Precision 3D Isometric Asset Model Cube SVG (`box`), universally conveying 3D model, mesh, and asset package semantics with zero visual ambiguity;
adhering to a "low-stimulus idle, subtle-illumination on hover" philosophy where icons use calm `currentColor` in idle state and gently illuminate on interaction;
critical tool workbenches (`#anomalous-doctor-panel`, `#anomalous-assistant-panel`): Node Assistant automatically collapses the left folder tree sidebar (`.anomalous-sidebar-closed`) upon entry to expand the available horizontal workspace for node diagnostics, model replacements, and parameter notebooks, while preserving and cleanly restoring the user's preferred sidebar open/closed state when switching back to model browsing; strictly enforce 100% opaque, solid backgrounds (`background: var(--amb-bg-page, #131315) !important`) to eliminate background distraction during diagnostics;
the UI palette strictly adheres to a true neutral dark charcoal / obsidian studio aesthetic (`#131315`, `#1a1a1d`, `#202024`, `#28282d`) with zero cool-blue/navy cast, matching ComfyUI, Blender, and DaVinci Resolve workstation standards;
the sidebar action bar's circular astrological runic compass watermark (`#anomalous-sidebar-actions::before`) is completely disabled (`display: none !important`), and any backdrop textures are desaturated with `grayscale(100%)` to prevent blue color bleeding;
all primary action highlights, scan wizard controls, and switches use titanium white (`#e5e7eb` / `#ffffff`) with warm studio amber (`#f59e0b`) accents instead of cold blue/cyan;
the modal adopts a disciplined "workshop / archive" aesthetic: removing exaggerated `clip-path` chamfers in favor of clean 8px architectural engineering geometry (`border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1)`);
the master header (`#anomalous-header`) and sidebar brand bar (`#anomalous-brand-bar`) are tightly calibrated to a 48px slim industrial profile; header navigation tabs (`#anomalous-models-btn`, `#anomalous-gallery-btn`, `#anomalous-notebook-btn`) and window control buttons (`#anomalous-dock-btn`, `#anomalous-close`) are unified to a compact 28px height, 6px border radius, 13px vector icons, active tab tracking, and persistent bilingual text labels (`[ 📦 模型 ]`, `[ 🖼️ 图库 ]`, `[ 🔗 工作流 ]` - simplified from "工作流配方" to balance width and match ComfyUI workflow mental model), perfectly harmonizing with adjacent dock and close controls;
cards feature restored and refined top-right micro-action buttons (`.anomalous-card-action-btn`: Apply ➕, Edit ✏️, Scan 🎯) with 22px frosted glass styling, smooth hover fade-in, and auto-clipping avoidance against the base model badge (`.anomalous-card-badge`);
supports 3 unified Display Modes sharing identical DOM structure and UX logic:
- Compact Mode (紧凑模式, primary default): 140px min-width, 220px card height, tight 10px gaps/14px rows, 5px sidebar folder rows, optimized for high-density scanning and instant visual discovery (Blender Asset Browser inspired);
- Standard Mode (标准模式, comfortable): Balanced 200px cards, 340px height, comfortable whitespace rhythm;
- Aesthetic Mode (沉浸模式): Subtle 8% grayscale ambient archive watermark with frosted glass borders;
cards feature unified 6px/8px geometry, restrained elevation (`translateY(-2px)`, `scale(1.02)`), spatial depth shadows (`0 4px 14px rgba(0,0,0,0.35)`),
seamless docking fallbacks, and a micro-floating capsule tooltip system
(`.anomalous-tooltip-target`) providing unambiguous, bilingual action semantics across all vector tools.
- `ui_gallery_detail.js` owns the studio modal Image Detail Workbench,
multi-dimensional image navigation (keyboard shortcuts, floating glass arrows,
left vertical thumbnail rail), streamlined workflow/material action buttons,
seamless non-shifting click-to-copy Bento spec tiles with error retry state,
keyboard accessible focus indicators, full-width generation parameter saving to Material Library,
and segmented Bento parameter inspection with ComfyUI canvas injection (`app.loadGraphData`).
- `ui_recipes.js` and `ui_recipe_detail.js` own Workflow Recipes (工作流配方)
presentation, completely decoupled from the legacy dual-tab Workspace container (`owner.recipeContainer`),
eliminating the redundant outer "工作区 / 提示词笔记 / 工作流配方" tabs; features an integrated Studio topbar (`.anomalous-recipe-topbar`) with responsive `flex-wrap: wrap`,
a subtle 1px border-bottom (`rgba(255,255,255,0.05)`), scope filter micro-pills (`all`, `complete`, `partial`) with high-transparency default backgrounds,
unified search & tag dropdown, dual view modes (uniform 264px Bento grid & compact list view), and an inline topbar close button (`[✕]`);
cards feature 136px golden-ratio top covers, clean titles without badge clutter (relocating isolated readiness dots into a sleek frosted-glass
`anomalous-recipe-readiness-pill` chip at the bottom-left of the cover alongside base model pills), single-row ellipsis text tags (`white-space: nowrap; text-overflow: ellipsis`),
top-right scope pills, and one-click canvas load (`🚀 载入画布` / `🧩 追加画布`, powered by quantum energy fluid micro-textures `btn_energy_core.webp` with sharp text drop-shadows);
filter pills (`.anomalous-recipe-pill.is-active`, `.anomalous-workbench-pill.is-active`) feature micro-crystalline quantum glow underlays (`pill_active_glow.webp`);
cards without custom thumbnails automatically receive deterministic AI-generated abstract material blind-box artwork (`web/assets/default_cover_1.webp` ~ `default_cover_6.webp`),
eliminating dry grey placeholder boxes. Empty state displays (`.anomalous-recipe-empty`) seamlessly blend with the futuristic cyber library concept art (`empty_library_concept.webp`)
using vertical gradient alpha masks (`mask-image: linear-gradient(...)`).
Direct canvas drag-drop ("一拖直达画布") allows dragging any recipe card across the translucent
modal directly onto the ComfyUI canvas to immediately load the workflow into a fresh canvas
(or append subgraphs), mimicking native ComfyUI image-drop behavior.
Recipe Detail Studio View (`showRecipeDetail`) is completely overhauled: dynamically hides
outer `.anomalous-recipe-topbar` on entry and cleanly restores it on exit to reclaim 100% of the
vertical workspace; replaces clumsy text copy buttons with sleek SVG micro-copy icons with instant
green checkmark feedback; rebuilds the Parameters Tab (`renderRecipeParameters`) into a modern
Bento Matrix (`.anomalous-recipe-bento-grid`, steps/CFG/sampler/resolution/denoise/seed tiles,
independent base model card, and structured LoRA pill stacks); prompts section (`renderPromptSection`)
is upgraded into full-width streamlined cards eliminating the cramped two-column split, clumsy
"调整用途" buttons, and isolated square copy boxes, moving actions into a top-right action tray
(save to material + micro copy) and making the role badge interactively clickable; snapshot rail
replaces legacy `<details>` and vertical text badges with smooth hover-reveal micro-actions and a glowing
green active pulse dot (`.anomalous-preset-active-dot`); enforces 48px bottom safety padding to eliminate
clipping and overlap.
- `ui_notebooks.js` owns Prompt Notes (提示词笔记) presentation; eliminated intermediate dual tabs
(`.anomalous-nb-section-tabs`); resolves the long-text input bottleneck through 300ms Debounce on `rawArea.oninput`,
`DocumentFragment` batch mounting in `updateVisualTags`, and modern CSS Grid layout (`.anomalous-nb-tag-row`)
with hover-revealed copy buttons (`.anomalous-nb-copy-btn`); LoRA gallery performance is hardened via
`content-visibility: auto`, `contain-intrinsic-size: 70px`, and fixed `aspect-ratio: 1/1` preventing Layout Shift.
- `ui_materials.js` owns the unified Material Library (素材库) view and its lifecycle;
features a top-level unified toolbar with capsule category filter tabs (`[全部 | 工作流 | 参数方案 | 提示词]`),
integrated search bar with live tag filter dropdown, dedicated view switcher (`[网格 | 列表]`),
refresh and transfer center; cards feature strict uniform height (248px) with rich micro-skeuomorphic fallbacks for non-image assets (prompt code snippets
and 3D asset parameter previews) eliminating visual bumpiness, direct inline title editing,
floating quick actions, and lazy DOM rendering; clicking any material card safely opens the detail inspection view;
in node apply mode or when a canvas node is selected, cards and the detail header expose explicit `[⚡ 应用到节点]` action buttons (`.anomalous-material-card-apply-btn`)
to prevent accidental node parameter overwriting; multi-text drag-and-drop onto nodes presents an interactive selection dialog with role badges,
truncated text snippets, and `[展开全部 ▾]` toggles; default naming for gallery saves is streamlined to `[原图名] · 快照` (or `[原图名] · [节点名]`);
completely decoupled from Prompt Studio (no studio toggle, no docked side panel, and no card-level mixer buttons); delegates image inspection to `ui_gallery_detail.js`;
`node_material_actions.js` owns shared transactional node application and guarded undo;
the library and Node Assistant use `ui_material_application.js` for the same receipt.
- `translation_service.js` owns atomic prompt translation logic and language intelligence;
bridges directly to the ComfyUI backend `POST /anomalous/translate` with multi-engine fallback (DeepL API Key ➔ Google Chrome Dictionary Client (`dict-chrome-ex`, bypasses 429 rate limits) ➔ MyMemory Translation Engine);
features `hasChinese` heuristic detection, automatic target language deduction (Chinese ➔ English, English ➔ Simplified Chinese),
- `ui_prompt_translator.js` owns the standalone Quick Prompt Translator (提示词翻译助手);
mounted directly to `document.body` as an elevated sidebar drawer (`.anomalous-translator-modal.is-sidebar`, z-index 999999); registered in the Toolbox Hub (`openPromptTranslator`);
features live language selection, one-click ComfyUI canvas node prompt reading (`readSelectedNodePrompt`),
instant bilingual translation, interactive tag breakdown pill chips (`.anomalous-translator-chip`),
one-click copy, direct write to selected canvas text node (`writeToSelectedNode` via `applyNodeMaterialValues`), dock-side toggling (`[⇤ / ⇥]`), width resizing, and one-click dispatch into Prompt Studio.
- `ui_prompt_composer.js` owns the Prompt Studio Minimalist Workbench (提示词工坊极简侧边栏抽屉与拼装台):
an independent, high-efficiency companion tool launched from the Toolbox Hub (`openPromptStudio`);
rendered directly into `document.body` as an elevated sidebar drawer (`.anomalous-prompt-studio-drawer`, z-index 999991) with canvas passthrough (`pointer-events: none` backdrop, allowing uninterrupted graph navigation);
features Canvas Focus Mode: opening the studio drawer automatically collapses the heavy master browser to preserve 80%+ canvas workspace;
features dock-side switching (`[⇤ / ⇥]`, persisted to `localStorage.anomalous_studio_dock_side`), width resize handle (400px~800px, persisted to `localStorage.anomalous_studio_sidebar_width`), and ESC key dismissal;
features a resilient Minimalist Dual-Column Layout: Left Column (145px~155px) hosts high-density compact prompt pills (`.anomalous-source-card-compact`) with category dot accents and instant 1-tap track injection; Right Column expands to take all remaining width as the primary Assembly Stage (`.anomalous-assembly-track`);
each assembled block card features category pills, title editing, a quick translate button, and an interactive **Weight Wheel Pill** (`.anomalous-block-weight-pill`) supporting instant mouse wheel scrolling (+/- 0.05 step) and +/- click adjustments;
eliminates cumbersome static textareas: Assembled output uses a zero-footprint **Floating Action Dock** (`.anomalous-floating-action-dock`) that is hidden (0px height) when empty and smoothly slides up (38px height) when blocks exist, offering real-time token/block stats, `[🚀 写入节点]`, `[📋 复制]`, and `[⛶ 展开大窗]`;
clicking `[⛶]` launches an independent centered **Prompt Inspector Modal** (`.anomalous-prompt-inspector-modal`, 780x520px, z-index 999999) for deep full-text inspection, bilingual translation, tag normalization (`[🧹 规范化]`), and direct write-back to canvas text nodes;
`prompt_composition.js` provides bidirectional schema mapping (`planToWorkbenchDraft` and `workbenchDraftToSavedPlan`) ensuring
100% roundtrip data integrity across `anomalous-prompt-plan-v1` and `version: 2`, lossless legacy dual-role splitting and trailing text retention,
`categorizePromptSnippet`, `smartSortPromptBlocks`, `assemblePromptBlocks`, as well as backward-compatible text joining;
`api/materials.py` validates prompt plans with extended categories (`general`, `specific`, `base`, `style`, `subject`, `trigger`)
while preserving schema version, part role, and card id.
- `material_drag.js` owns temporary canvas drop listeners, target hit testing and
window restoration. During active dragging, the main modal smoothly transitions to
full transparency (`opacity: 0; pointer-events: none`) to fully reveal the underlying
ComfyUI canvas, restoring on drop/cancel. Supports both node-targeted drops (materials replace,
prompt notes insert text) and whole-canvas drops via `dropOnCanvas` (recipes load new workflows
or append subgraphs).
- `material_inspector.js` owns shared image-metadata parsing and node-parameter
rendering used by the library and Image Detail Workbench. Node cards feature leading
aligned frosted-glass checkboxes with cyan micro-interactions for batch selection.
The workbench does not import the library UI.
- `recipe_parser.js`, `recipe_identity.js`, `recipe_diff.js`, and
`recipe_actions.js` own pure or transactional recipe behavior.
- `ui_doctor.js`, `model_picker.js`, and `graph_splice.js` own assistant and
explicit graph-edit behavior. Node Assistant parameter schemes are structured into distinct
scheme cards with parameter pills (steps, cfg, sampler, scheduler, denoise, prompt roles);
hovering over any parameter scheme card triggers a glassmorphic floating popover (`.anomalous-param-popover`)
displaying the complete key-value parameter table, prompt text box, one-click copy, and apply action;
parameter application requires an explicit `[⚡ 应用方案]` button click with async feedback (`⏳ 应用中...` -> `✅ 已应用`),
preventing unintentional canvas graph overwrites.
- Dual-Mode Theme Architecture (双形态主题架构与材质光影精细化):
- **Base Design System Tokens (`--amb-*`)**: Scoped on `:root` and overridden under `.theme-abyssal-scarlet`. Defines unified background, panel, card, text, border, and control radii tokens (`--amb-radius-control: 6px`, `--amb-radius-card: 10px`, `--amb-radius-panel: 12px`).
- **Normal Mode (Default / 标准中性黑曜石图书档案馆模式)**: Restrained, clean, neutral dark obsidian (`#0b0d13` / `#141519` / `#1c1e24`) styling; overlaid with the "Anime Celestial Library Archive" AI Concept Art with glowing holographic magic circles, golden blueprint wireframes, and constellation starmap textures (`assets/archive_library_bg.webp`) across `#anomalous-container::before` at `0.65` opacity with radial dark vignette; **全界面毛玻璃一体化融合 (Unified Frosted Glass Architecture)**: 侧边栏 (`#anomalous-sidebar-wrapper`, 24px 模糊,52% 透光) 与顶部导航栏 (`#anomalous-header`, 20px 模糊,48% 透光) 全面采用磨砂毛玻璃透光,品牌徽章与搜索框微晶质感化,底图星轨、宏大书阁与发光符文在全窗口贯通流淌,与透明网格主视图形成高度沉浸式二次元魔法图书馆终端界面;银白高对比主操作按钮 (`#D7D9E0` 搭配 `#15171C` 字色)。
- **Abyssal Scarlet Easter Egg Mode (`.theme-abyssal-scarlet` / 深海血族彩蛋领域 - 丝绒与暗血轻量质感)**: Strictly scoped under `.theme-abyssal-scarlet`; eliminates all cheap neon outer glows; deep gothic velvet backgrounds (`#161014` / `#21171d` / `#2b1e26`), razor-sharp crimson (`#dc143c`) and antique gold (`#b38728`) borders, crimson primary buttons (`#87384E` with `#FFF5F7` text), and concept art backdrops `assets/abyssal_scarlet_mansion.webp` and `assets/abyssal_bg_concept.webp`.
- **Three-Tier Button Architecture**: Unifies button morphology across primary, secondary/ghost, and auxiliary actions. Primary buttons (`.anomalous-btn-primary`, `.anomalous-recipe-btn-primary-action`) feature solid fills, 32px height, 6px border-radius, clean SVG icons, and zero pseudo-element clutter (purged legacy `::before` diamonds, `::after` gradient lightbars, text-shadows, and heavy glows); secondary ghost buttons (`.anomalous-btn-ghost`) feature subtle borders and panel backgrounds; auxiliary action buttons provide clear, non-distracting tool semantics.
- **Floating Trigger Button & Anomalous Hypercube Core (悬浮入口魔晶重构)**:
- 重绘旧版扁平纸箱立方体为 **「异象超维魔方核 (Anomalous Hypercube Core)」**:外层等轴测立体超维立方体轮廓,内嵌悬浮发光微晶棱镜(`--amb-accent` 随主题自适应为天青/猩红),核心凝聚一枚四芒星辉奇点(`✦`),兼具 3D 模型容器特征与魔法档案馆星辰意象。
- 悬浮球体容器 (`#anomalous-trigger-btn`) 升级为亚克力磨砂深色毛玻璃材质 (`backdrop-filter: blur(16px)`),悬停微移提亮,图标伴随 12° 优雅微旋与天青/猩红柔和微光,保持高度克制的同时极具辨识度与高级质感。
- **Prompt Studio Dual-Column Workbench (提示词工坊侧边抽屉与双向自适应停靠)**:
- **Default Left Docking & Canvas-Facing Flow**: Defaults to left dock (`is-dock-left`), with Source Cards (190px) on the outer screen edge and Assembly Track on the inner side directly facing the ComfyUI canvas.
- **Bidirectional Column Flipping**: When toggled to right dock, the layout seamlessly swaps column visual order (`order: 1` for Assembly Track facing canvas on left, `order: 2` for Source Cards along right screen edge) so that the Assembly Track is ALWAYS adjacent to the ComfyUI canvas regardless of docking side.
- **Single-Line Unbroken Topbar**: Topbar strictly enforces `flex-wrap: nowrap`, keeping Title, Preset Name input (constrained max-width 140px), Node Extraction (`🎯`), Dock Toggle (`→`/`←`), More dropdown (`··· 更多`), and Close (`✕`) in a unified single row under any drawer width.
- **De-cluttered Left Panel**: Expanded left column from 145px to 190px, purged redundant text buttons into compact 24x24 icon buttons (`[📥]` and `[+]`), shortened category filter pills to clean 2-character chips (`[全部]`, `[底模]`, `[风格]`, `[主体]`, `[触发]`) fitting into a single row, and removed bulky instructional tip banners.
- **Dynamic Floating Action Dock & Popout Modal**: Floating Action Dock slides up only when blocks exist; provides single-click Node Direct Write and popout full-screen Prompt Inspector (`openPromptInspectorModal`) for deep multi-line tuning.
- **Trigger Button Lifecycle Suppression**: When Prompt Studio opens, the global floating trigger button (`#anomalous-trigger-btn`, z-index 2147483000) is automatically suppressed via `owner.setTriggerVisible(false)` and `body.anomalous-prompt-studio-open` (`display: none !important;`), preventing it from overlaying the workbench track, and cleanly restores when the studio drawer is closed.
- **Eye-Catching Card Preview Popover (词卡专属悬浮预览浮框)**: Completely eliminated unstyled native OS `title` tooltips. Implemented `.anomalous-card-preview-popover` (fixed positioning, dark obsidian glass, z-index 1000005, neon accent borders, category/role badges, monospace formatted prompt snippet container, and action hints). Compact source cards feature category-color left accent borders (`border-left: 3px solid`) for instant visual scanning.
- **Uniform Card Skeletons**: Recipe and material cards enforce "Preview -> 2-Line Clamped Title -> Key Specs -> Footer Action Bar" order, preventing Chinese title clipping; Model cards display titles and filenames at rest without `translateY` collapse and without `backdrop-filter: blur`, fading in action buttons on hover.
- **Custom Tooltip System (`[data-tooltip]`)**: Solid dark capsules (`rgba(18, 20, 26, 0.98)`), 350ms standard hover delay, and keyboard focus-visible support.
- `web/main.js` registers the extension and owns the shared browser instance.
- `ui_sidebar.js` creates the browser shell, navigation and settings. Its large
`createDOM` method remains a maintenance hotspot; new tool implementations
should live in their own modules rather than expanding that method.
- `ui_grid.js` and model-detail modules own model presentation; `ui_gallery.js`
and `ui_gallery_detail.js` own generated-image browsing and inspection.
- `ui_recipes.js` / `ui_recipe_detail.js`, `ui_notebooks.js`, and `ui_materials.js`
own their respective workspace surfaces and persistence flows.
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer. Its child
views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and
`ui_prompt_inspector.js`; see the frontend topic for state ownership.
- `ui_prompt_translator.js` owns the standalone translator. Both translator and
studio use `ui_lifecycle.js` for global listeners, request cancellation and
resize cleanup. Translation requests go through `translation_service.js`.
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns
material-specific metadata and parameter rendering.
- `ui_doctor.js`, `model_picker.js`, `node_material_actions.js`, and
`graph_splice.js` own diagnostics and explicit graph changes.
- `locales.js` is the shared runtime string catalog. Existing inline bilingual
UI strings remain migration debt; new strings belong in the catalog.
- `styles.css` owns presentation and theme overrides. Color values, dimensions
and visual design descriptions are not duplicated as architectural contracts.
## Cross-system invariants
+66 -16
View File
@@ -28,6 +28,7 @@ The main surfaces are:
- Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter
presets, and missing-model recovery.
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
`material_inspector.js` shares image metadata helpers and node-parameter rendering
between `ui_materials.js` and `ui_gallery_detail.js`. The workbench does not import
the library UI, keeping this dependency chain acyclic. Library discovery uses
@@ -146,24 +147,73 @@ Network-backed enrichment is explicit and recoverable. An unavailable Civitai
or translation service may produce a local error state; it must not block local
browsing, editing, or already stored data.
## Prompt Studio ownership
The studio has one standalone drawer; the former embedded side/full composer
and material import drawer are removed. Existing browser integration continues
to call `openPromptStudio(owner)`, and external prompt dispatch uses
`appendPromptToStudio(owner, text, isPositive, title)`.
| Module | Owned state and responsibilities |
| --- | --- |
| `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request |
| `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save/export |
| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request |
| `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime |
| `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis |
| `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership |
| `prompt_material_source.js` | All-page prompt discovery, detail loading and source-card deduplication |
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
Child views receive their container and narrow callbacks. The workbench keeps
`owner.promptPlanDraft` for reopening and exposes only `updateAll` / `addBlock`
through `owner.sidePromptComposerControl`. The source deck keeps its state local
and returns extraction/refresh/sync actions instead of assigning new owner fields.
Draft `plan.parts` is the authoritative editable representation. Full-text
inspector edits merge the enabled blocks in the edited role into one block,
preserving disabled blocks and the opposite role. The inspector explains this
behavior before editing. Copy, save and reopen derive text from the same parts.
Every close route (button, Escape, backdrop, replacement and parent close) must
dispose the same view scope. Parent close also closes its inspector. An Escape
handled by an inspector must not close its parent in the same event dispatch.
Closing during resize releases move/up listeners and restores body cursor and
selection styles. UI reads are cancelled on close; a save already sent to the
server may still complete, but must not reopen or repaint a disposed view.
Initial and explicit source synchronization share a paginated loader. List
summaries are identifiers, not prompt bodies: each unique filename is resolved
through `material_prompt_data.js`. Deduplication uses prompt role and text, so
the same text can still appear once in each role. A newer sync cancels the old
one and only the current result updates the deck.
The translator captures graph, node, widget and widget value before an async
write. It revalidates that destination and the input after translation; close,
selection changes and intervening widget edits invalidate the pending write.
Explicit English translation applies to all input languages, including kana
and Korean. Transport errors and rejected bridge responses remain local errors;
provider-specific validation belongs to the backend translation route.
## Visual styling and theme architecture
The extension enforces a strict **Dual-Mode Theme Architecture (双形态主题架构)** to guarantee default aesthetic restraint while enabling deep immersive customization:
`styles.css` is the source of truth for current visual values. Shared `--amb-*`
tokens express surfaces, text, borders and control shapes; theme overrides must
be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces.
1. **Normal Mode (Default / 标准中性黑曜石模式 - 暗物质材质与水印底纹)**:
- Base surfaces use clean, restrained dark obsidian tones (`#0a0a0c` / `#0d0d11`) with subtle, neutral glassmorphism borders (`rgba(255, 255, 255, 0.08)`).
- Introduces the "Dark Matter Material" AI Concept Art Watermark (`assets/normal_bg_concept.webp`) overlaid on `#anomalous-content::before` and `.anomalous-gallery-container::before` at ultra-low opacity (`0.04 ~ 0.05`) with `mix-blend-mode: luminosity`, banishing dead-black voids while strictly preserving foreground thumbnail readability.
- Sidebar active folder indicators and icons use neutral silver-slate (`#94a3b8`); the radar scan sweep uses clean cyan/blue.
- Model cards use neutral dark glass (`rgba(20, 20, 26, 0.85)`) with neutral silver plate badges (`#cbd5e1`).
- Tooltips utilize neutral dark slate glass (`rgba(18, 20, 28, 0.95)`) with neutral borders and directional positioning (`data-tooltip-pos="top|right|bottom|left"`).
- Prompt Mixer, Arranger, and Workbench controls utilize cyber cyan/blue (`#38bdf8`) accents for active pills, smart sort, dropzone highlights, and node extraction.
2. **Abyssal Scarlet Easter Egg Mode (`.theme-abyssal-scarlet` / 深海血族彩蛋领域 - 丝绒与暗血轻量质感)**:
- Strictly scoped under `.theme-abyssal-scarlet` or `html.theme-abyssal-scarlet`; never leaks into default rules.
- **Zero Outer Neon Glows**: Complete elimination of aggressive `box-shadow: 0 0 xxpx` outer halos, `filter: drop-shadow`, and text glows across buttons, cards, pills, and sidebar actions.
- **Light Absorption Aesthetic**: Replaced cheap neon effects with deep sunken inner shadows (`box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.8)`), heavy velvet wine backgrounds (`rgba(50, 12, 22, 0.95)`), razor-sharp 1px crimson (`#dc143c`) or antique gold (`#b38728`) borders, and subtle red-blush silver text (`#e0d8db`).
- Activates deep abyssal palettes (`#0a0510` / `#0d080c`), submerged scarlet mansion concept overlays (`assets/abyssal_scarlet_mansion.webp`), gallery lattice backdrop (`assets/abyssal_bg_concept.webp`), and ambient crimson/purple radial gradient illumination.
- Model card badges shift to the Gothic Sovereign palette (Dark Gold, Amber, Abyssal Purple/Flux, Rose Crimson) with sunken inner shadows and sharp borders.
- Header drag interaction safely guards `.closest('button')`, `.closest('input')`, etc., ensuring that button child SVG icons and label spans do not swallow clicks via `e.preventDefault()`.
Studio drawer rules keep the source deck at the screen edge and the assembly
track next to the canvas. Common geometry is shared between dock directions;
direction-specific rules set column order and separators. The removed embedded
composer's `#anomalous-container.anomalous-docked` overrides must not return.
Before adding an override or `!important`, locate and edit the owning rule.
Older component and theme overrides elsewhere in this file still need a
separate, visually verified consolidation.
## Verification
Use `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` for actual
module open/close, nested Escape, resize cleanup, block insertion and delayed
node writes. `tests/prompt_material_source.mjs` covers pagination, deduplication
and cancellation; `tests/translation_service_contracts.mjs` covers the HTTP
bridge. The UI fixture simulates DOM and ComfyUI APIs and does not replace
checking the real host, layout, focus, drag gestures and theme rendering.
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { fixture, Element, all } from './ui_fixture.mjs';
const f = fixture();
const record = { filename: 'parameters.json', kind: 'image_node_selection', name: 'Parameters', node_types: ['CLIPTextEncode'], selection: { scope: 'nodes' } };
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('material_full')
? { status: 'success', data: record, node_blocks: [2, 3].map(id => ({ node_id: id, type: 'CLIPTextEncode', widgets_values: [`text ${id}`] })) }
: { status: 'success', materials: [record], page: 1, pages: 1, total: 1, tags: [] } });
const materials = await f.module('ui_materials.js');
const owner = { nbPanel: new Element('div') };
owner.nbPanel.style.display = 'flex';
f.document.body.appendChild(owner.nbPanel);
for (const key of ['showMaterials', 'refreshMaterials', 'openSavedMaterial', 'openMaterialLibrary']) owner[key] = materials[key].bind(owner);
await owner.openMaterialLibrary();
assert.equal(owner.notebookContainer, undefined);
assert.equal(owner.materialApplyMode, true);
assert.equal(new URL(f.requests.at(-1)[0], 'http://test').searchParams.get('node_type'), 'CLIPTextEncode');
assert.deepEqual(f.errors, []);
const card = owner.materialList.querySelector('article');
assert.ok(card, 'current material card renders');
await card.click();
assert.equal(f.node.widgets[0].value, 'original', 'opening details must not write to canvas');
assert.ok(owner.materialDetailView);
await card.querySelector('.anomalous-material-card-apply-btn').click();
assert.deepEqual(f.errors, []);
assert.ok(owner.materialBlockDialog);
let choices = owner.materialBlockDialog.querySelectorAll('.anomalous-material-choice-card');
await choices[1].querySelector('button').click();
assert.equal(f.node.widgets[0].value, 'text 3');
await f.button(owner.materialContext, 'Undo').click();
assert.equal(f.node.widgets[0].value, 'original');
f.runTimers();
await card.querySelector('.anomalous-material-card-apply-btn').click();
const dialog = owner.materialBlockDialog;
f.app.canvas.selected_nodes = {};
await dialog.querySelector('.anomalous-material-choice-card').querySelector('button').click();
assert.equal(f.node.widgets[0].value, 'original');
assert.ok(dialog.textContent.includes((await f.module('locales.js')).translate('materialTargetChanged')));
dialog.close();
f.app.canvas.selected_nodes = { 1: f.node };
const notes = await f.module('ui_notebooks.js');
owner.showNotebooks = notes.showNotebooks.bind(owner);
owner.refreshNotebooks = () => {};
await owner.showNotebooks();
assert.equal(owner.materialContainer.style.display, 'none');
assert.ok(owner.notebookContainer);
assert.equal(owner.notebookMaterialsTab, undefined);
await owner.showMaterials();
assert.equal(owner.notebookContainer.style.display, 'none');
assert.equal(owner.materialContainer.style.display, 'flex');
assert.equal(all(owner.materialContainer).some(el => el.className === 'anomalous-nb-section-tabs'), false);
assert.deepEqual(f.errors, []);
console.log('material workspace: current cards, explicit apply, undo, stale selection and notes isolation passed');
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { loadPromptSourceCards, mergePromptSourceCards } from '../web/modules/prompt_material_source.js';
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push([url, options]);
const parsed = new URL(url, 'http://test');
const page = Number(parsed.searchParams.get('page'));
const filename = parsed.searchParams.get('filename');
return { ok: true, json: async () => filename
? { status: 'success', data: { kind: 'prompt_text', note: { promptEn: filename } } }
: { status: 'success', pages: 2, materials: page === 1 ? [{ filename: 'first.json' }] : [{ filename: 'first.json' }, { filename: 'last.json' }] } };
};
const controller = new AbortController();
const cards = await loadPromptSourceCards(controller.signal);
assert.deepEqual(cards.map(card => card.content), ['first.json', 'last.json']);
assert.equal(requests.length, 4, 'two pages and two unique details');
assert.equal(requests.every(([, options]) => options.signal === controller.signal), true);
const deck = [];
assert.equal(mergePromptSourceCards(deck, cards), 2);
assert.equal(mergePromptSourceCards(deck, cards), 0);
controller.abort();
await assert.rejects(loadPromptSourceCards(controller.signal), { name: 'AbortError' });
assert.equal(requests.length, 4);
console.log('prompt material source: later pages, deduplication, counts and cancellation passed');
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { fixture, all } from './ui_fixture.mjs';
const f = fixture();
const studio = await f.module('ui_prompt_composer.js');
const translator = await f.module('ui_prompt_translator.js');
const owner = { setTriggerVisible(value) { this.triggerVisible = value; } };
const listeners = () => f.window.listenerCount() + f.document.listenerCount();
const clean = () => {
assert.equal(listeners(), 0, 'all global listeners must be released');
assert.equal(f.document.querySelector('.anomalous-prompt-studio-overlay'), null);
assert.equal(f.document.querySelector('.anomalous-translator-overlay'), null);
assert.equal(owner.sidePromptComposerControl, null);
assert.equal(owner.triggerVisible, true);
};
for (let i = 0; i < 3; i++) {
await studio.openPromptStudio(owner);
await f.flush();
assert.equal(f.document.querySelectorAll('.anomalous-source-card-compact').length, 6);
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive', category: 'base' });
assert.equal(owner.promptPlanDraft.plan.positive, 'quality');
f.window.dispatch('keydown', { key: 'Escape' });
clean();
owner.promptPlanDraft = null;
}
await studio.openPromptStudio(owner);
await studio.openPromptStudio(owner);
assert.equal(f.document.querySelectorAll('.anomalous-prompt-studio-overlay').length, 1);
const beforeDrag = listeners();
const handle = f.document.querySelector('.anomalous-studio-resize-handle');
f.document.body.style.cursor = 'crosshair';
handle.onmousedown({ button: 0, clientX: 100, preventDefault() {} });
assert.equal(listeners(), beforeDrag + 2);
f.window.dispatch('mousemove', { clientX: 160 });
assert.equal(f.document.querySelector('.anomalous-prompt-studio-drawer').style.width, '640px');
studio.closePromptStudio(owner);
assert.equal(f.document.body.style.cursor, 'crosshair');
clean();
await studio.openPromptStudio(owner);
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive' });
await f.button(f.document.body, '⛶').click();
assert.ok(f.document.querySelector('.anomalous-prompt-inspector-overlay'));
f.window.dispatch('keydown', { key: 'Escape' });
assert.equal(f.document.querySelector('.anomalous-prompt-inspector-overlay'), null);
assert.ok(f.document.querySelector('.anomalous-prompt-studio-overlay'), 'first Escape closes only inspector');
f.window.dispatch('keydown', { key: 'Escape' });
clean();
// Full-text edits remain authoritative after recomposition, save and reopening.
await studio.openPromptStudio(owner);
owner.sidePromptComposerControl.addBlock({ content: 'scene', role: 'positive' });
owner.sidePromptComposerControl.addBlock({ content: 'bad anatomy', role: 'negative' });
await f.button(f.document.body, 'Positive (').click();
await f.button(f.document.body, '⛶').click();
const inspectorText = f.document.querySelector('.anomalous-inspector-textarea');
inspectorText.value = 'edited whole prompt';
inspectorText.oninput();
f.window.dispatch('keydown', { key: 'Escape' });
await f.button(f.document.body, '📋').click();
assert.equal(f.clipboard.at(-1), 'edited whole prompt');
assert.equal(owner.promptPlanDraft.plan.negative, 'bad anatomy');
const name = f.document.querySelector('.anomalous-prompt-name-input');
name.value = 'round trip'; name.oninput();
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('/materials?')
? { status: 'success', materials: [], pages: 1 }
: { status: 'success', material: { filename: 'saved.json' } } });
await f.button(f.document.body, '💾').click();
const savedRequest = f.requests.findLast(([url]) => url.includes('save_prompt_plan'));
const saved = JSON.parse(savedRequest[1].body);
assert.equal(saved.name, 'round trip');
assert.equal(saved.plan.positive, 'edited whole prompt');
assert.equal(saved.plan.negative, 'bad anatomy');
await f.button(f.document.body, 'Sync Library').click();
assert.ok(f.requests.at(-1)[0].includes('category=prompts'));
studio.closePromptStudio(owner);
await studio.openPromptStudio(owner);
assert.equal(owner.promptPlanDraft.plan.positive, 'edited whole prompt');
await f.button(f.document.body, '✨').click();
assert.equal(owner.promptPlanDraft.plan.positive, '');
assert.equal(owner.promptPlanDraft.plan.negative, '');
studio.closePromptStudio(owner);
clean();
// Closing while a saved plan is loading must not replace the draft or reopen.
const keptDraft = owner.promptPlanDraft;
let releasePlan;
f.fetch = async () => new Promise(resolve => { releasePlan = () => resolve({ ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_plan', name: 'late', plan: { positive: 'late text' } } }) }); });
const loadingPlan = studio.showPromptComposer(owner, { filename: 'late.json' });
await f.flush();
studio.closePromptStudio(owner);
releasePlan(); await loadingPlan;
assert.equal(owner.promptPlanDraft, keptDraft);
clean();
for (const path of ['button', 'escape', 'backdrop']) {
translator.openPromptTranslator(owner);
translator.openPromptTranslator(owner);
assert.equal(f.document.querySelectorAll('.anomalous-translator-overlay').length, 1);
const overlay = f.document.querySelector('.anomalous-translator-overlay');
if (path === 'button') f.document.querySelector('.anomalous-translator-close').click();
if (path === 'escape') f.document.dispatch('keydown', { key: 'Escape' });
if (path === 'backdrop') { overlay.classList.remove('is-sidebar'); overlay.onclick({ target: overlay }); }
clean();
}
// A delayed translation may not write into a new selection, overwrite an edited
// widget, or write after the translator closes. Kana must use the EN service too.
for (const change of ['selection', 'widget', 'close', 'none']) {
translator.openPromptTranslator(owner);
const target = f.document.querySelector('.is-target');
target.value = 'かわいい';
let release;
f.fetch = async (url, options) => {
assert.equal(JSON.parse(options.body).target_lang, 'en');
return new Promise(resolve => { release = () => resolve({ ok: true, json: async () => ({ translated: 'cute' }) }); });
};
const pending = f.button(f.document.body, 'Translate to EN & Write').click();
await f.flush();
if (change === 'selection') f.app.canvas.selected_nodes = {};
if (change === 'widget') f.node.widgets[0].value = 'edited';
if (change === 'close') f.document.querySelector('.anomalous-translator-close').click();
release?.(); await pending;
assert.equal(f.node.widgets[0].value, change === 'widget' ? 'edited' : change === 'none' ? 'cute' : 'original');
f.document.querySelector('.anomalous-translator-close')?.click();
f.app.canvas.selected_nodes = { 1: f.node };
f.node.widgets[0].value = 'original';
(await f.module('translation_service.js')).clearTranslationCache();
clean();
}
assert.deepEqual(f.errors, []);
console.log('prompt UI: reopen/close, Escape nesting, resize cleanup, real card insertion and delayed node writes passed');
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { materialPromptText, loadMaterialPrompts } from '../web/modules/material_prompt_data.js';
import { composePromptPlan, joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan } from '../web/modules/prompt_composition.js';
import { translate } from '../web/modules/locales.js';
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_text', note: { promptEn: ' exact ', promptZh: 'translation' } } }), { positive: ' exact ', negative: '' });
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_plan', plan: { parts: [{ positive: 'base', negative: 'bad' }, { positive: 'skip', enabled: false }], positive: 'scene' } } }), { positive: 'base\nscene', negative: 'bad' });
assert.deepEqual(materialPromptText({ data: { kind: 'image_node_selection' }, prompt_groups: { positive: ['yes'], negative: ['no'] }, node_blocks: [{ type: 'CheckpointLoaderSimple', widgets_values: ['not-prompt'] }] }), { positive: 'yes', negative: 'no' });
assert.deepEqual(materialPromptText({ data: {}, node_blocks: [{ widgets_values: ['unknown role'] }] }), { positive: '', negative: '' });
let lastUrl;
globalThis.fetch = async url => { lastUrl = url; return { ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_note_bundle', note: { promptEn: 'real detail' } } }) }; };
assert.equal((await loadMaterialPrompts('note.json')).positive, 'real detail'); assert.ok(lastUrl.includes('include_workflow=0'));
class Element {
constructor(tag) { this.tagName = tag; this.children = []; this.style = {}; this.value = ''; this.attrs = {}; this.className = ''; this.classList = { add() {}, remove() {}, toggle() {} }; this.dataset = {}; }
appendChild(child) { child.parent = this; this.children.push(child); return child; }
append(...children) { children.forEach(c => this.appendChild(c)); }
replaceChildren() { this.children.forEach(c => c.parent = null); this.children = []; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); this.parent = null; }
setAttribute(k, v) { this.attrs[k] = v; }
addEventListener(key, fn) { (this.listeners ||= {})[key] = fn; }
focus() {}
get isConnected() { return this.tagName === 'body' || !!this.parent?.isConnected; }
}
const document = { body: new Element('body'), createElement: tag => new Element(tag), addEventListener: (key, fn) => events[key] = fn };
const text = (parent, tag, value, cls = '') => { const el = document.createElement(tag); el.textContent = value; el.className = cls; parent.appendChild(el); return el; };
const all = parent => [parent, ...parent.children.flatMap(all)];
const events = {};
let sent;
const context = vm.createContext({ console, document, app: {}, translate, t: translate, text, composePromptPlan, joinPromptText,
categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan,
window: { addEventListener: (key, fn) => events[key] = fn, removeEventListener: (key, fn) => { if (events[key] === fn) delete events[key]; } },
selectedMaterialNode: () => null, promptWidgetTargets: () => [], bindMaterialDrag() {},
anomalousConfirm: async () => true, anomalousAlert: async msg => { throw new Error(msg); }, showMaterialSaved() {},
jsonResponse: response => response.json(), loadMaterialPrompts: async () => ({ positive: 'imported', negative: '' }),
URL, URLSearchParams, AbortController, setTimeout, clearTimeout,
fetch: async (url, options) => {
sent = [url, options];
return { ok: true, json: async () => url.includes('save_prompt_plan') ? { status: 'success' } : { status: 'success', materials: [{ filename: 'p.json', name: 'Real prompt' }], total: 49, page: 1, pages: 2 } };
},
});
async function moduleFor(file, extra) {
const source = fs.readFileSync(`web/modules/${file}`, 'utf8').replace(/^import[\s\S]*?;\s*/gm, '');
const mod = new vm.SourceTextModule(source + `\nexport { ${extra} };`, {
context,
identifier: `file:///${process.cwd().replace(/\\/g, '/')}/web/modules/${file}`,
initializeImportMeta(meta, module) { meta.url = module.identifier; }
}); await mod.link(() => { throw new Error('unexpected import'); }); await mod.evaluate(); return mod.namespace;
}
const recipes = await moduleFor('ui_recipes.js', 'recipeMatchesFilter, updateRecipeFilterControls, getRecipeReadiness');
assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'complete'), false);
assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'partial'), true);
assert.equal(recipes.recipeMatchesFilter({ name: 'old' }, '', new Set(), 'complete'), true);
const recipeOwner = { recipeTagSelect: new Element('select'), recipeSelectedTags: new Set(['B']) };
recipes.updateRecipeFilterControls(recipeOwner, [{ data: { tags: ['A', 'B'] } }]);
assert.equal(recipeOwner.recipeTagSelect.children.length, 3); assert.equal(recipeOwner.recipeTagSelect.value, 'B');
assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' }, currentAvailability: 'missing' }] } }).status, 'missing');
assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' } }] } }).status, 'warning');
const drags = await moduleFor('material_drag.js', '');
const graph = { getNodeOnPos: () => null };
const surface = new Element('canvas'); surface.getBoundingClientRect = () => ({ left: 0, top: 0, right: 100, bottom: 100 });
context.app.graph = graph; context.app.canvas = { graph, canvas: surface, convertEventToCanvasOffset: () => [50, 50] };
const source = new Element('article'); let drops = 0;
drags.bindMaterialDrag(source, {}, { payload: () => ({}), dropOnCanvas: () => { drops++; } });
const event = target => ({ target, clientX: 50, clientY: 50, dataTransfer: { setData() {} }, preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {} });
source.listeners.dragstart(event(source)); await events.drop(event(new Element('button'))); assert.equal(drops, 0);
source.listeners.dragstart(event(source)); context.app.graph = {}; await events.drop(event(surface)); assert.equal(drops, 0); context.app.graph = graph;
source.listeners.dragstart(event(source)); await events.drop(event(surface)); assert.equal(drops, 1); assert.equal(events.drop, undefined);
console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup passed');
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
export const all = node => [node, ...node.children.flatMap(all)];
export class Events {
listeners = new Map();
addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); }
removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); }
dispatch(type, event = {}) { for (const fn of [...(this.listeners.get(type) || [])]) fn(event); }
listenerCount() { return [...this.listeners.values()].reduce((sum, set) => sum + set.size, 0); }
}
export class Element extends Events {
constructor(tag) {
super();
this.tagName = tag; this.children = []; this.dataset = {}; this.attrs = {};
this.className = ''; this.value = ''; this._text = ''; this.disabled = false;
this.style = { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } };
this.classList = {
contains: name => this.className.split(/\s+/).includes(name),
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
remove: (...names) => { this.className = this.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); },
toggle: (name, force) => { const enabled = force ?? !this.classList.contains(name); this.classList[enabled ? 'add' : 'remove'](name); return enabled; },
};
}
set textContent(value) { this.replaceChildren(); this._text = String(value); }
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set innerHTML(value) { this.textContent = value; }
get innerHTML() { return this.textContent; }
appendChild(child) { child.remove(); child.parentNode = this; this.children.push(child); return child; }
append(...children) { children.forEach(child => this.appendChild(child)); }
insertBefore(child, before) { if (!before) return this.appendChild(child); child.remove(); child.parentNode = this; this.children.splice(this.children.indexOf(before), 0, child); return child; }
prepend(child) { child.remove(); child.parentNode = this; this.children.unshift(child); }
replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this._text = ''; this.append(...children); }
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
get parentElement() { return this.parentNode; }
get isConnected() { return this.tagName === 'body' || !!this.parentNode?.isConnected; }
get firstChild() { return this.children[0] || null; }
get options() { return this.children; }
cloneNode(deep = false) {
const copy = new Element(this.tagName);
copy.className = this.className; copy.value = this.value; copy._text = this._text;
copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.append(...this.children.map(child => child.cloneNode(true)));
return copy;
}
setAttribute(name, value) { this.attrs[name] = value; }
removeAttribute(name) { delete this.attrs[name]; }
getAttribute(name) { return this.attrs[name] ?? null; }
matches(selector) { return selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); }
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; }
contains(child) { return all(this).includes(child); }
hasChildNodes() { return !!this.children.length; }
getBoundingClientRect() { return { width: parseFloat(this.style.width || this.style['--amb-translator-width']) || 580, height: 600, top: 0, left: 0, right: 580, bottom: 600 }; }
click() { if (!this.disabled) return this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
showModal() { this.open = true; }
close() { this.open = false; this.onclose?.(); }
focus() {}
scrollIntoView() {}
}
export function fixture() {
const document = new Events();
document.body = new Element('body');
document.createElement = tag => new Element(tag);
document.createDocumentFragment = () => new Element('fragment');
document.querySelector = selector => document.body.querySelector(selector);
document.querySelectorAll = selector => document.body.querySelectorAll(selector);
const window = new Events();
Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' });
const storage = new Map();
const timers = new Map();
let timerId = 0;
const errors = [], requests = [], clipboard = [];
const node = { id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', type: 'customtext', value: 'original' }], widgets_values: ['original'] };
const app = { graph: { getNodeById: id => id === node.id ? node : null }, canvas: { selected_nodes: { 1: node } } };
const state = {
app, node, window, document, errors, requests, clipboard,
async fetch(url) { return { ok: true, json: async () => String(url).includes('/materials?') ? { status: 'success', materials: [], pages: 1 } : { status: 'success', translated: 'translated' } }; },
};
const context = vm.createContext({ console, URL, URLSearchParams, Blob, AbortController, DOMException, structuredClone,
document, window, app, errors, anomalous_browser_lang: 'en',
setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id),
requestAnimationFrame: callback => callback(), queueMicrotask,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true,
fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); },
});
const modules = new Map();
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/modules');
const load = filename => {
if (modules.has(filename)) return modules.get(filename);
let source;
if (filename.endsWith('/scripts/app.js')) source = 'export const app = globalThis.app;';
else if (filename.endsWith('/ui_dialog.js')) source = 'export async function anomalousAlert(message) { errors.push(message); } export async function anomalousConfirm() { return true; }';
else if (filename.endsWith('/material_feedback.js')) source = 'export function showMaterialSaved() {}';
else if (filename.endsWith('/ui_gallery_detail.js')) source = 'export function showImageWorkbench() {}';
else source = fs.readFileSync(filename, 'utf8');
const mod = new vm.SourceTextModule(source, { context, identifier: filename });
modules.set(filename, mod); return mod;
};
state.module = async name => {
const mod = load(path.resolve(root, name).replaceAll('\\', '/'));
if (mod.status === 'unlinked') await mod.link((specifier, parent) => load(path.resolve(path.dirname(parent.identifier), specifier).replaceAll('\\', '/')));
if (mod.status === 'linked') await mod.evaluate();
return mod.namespace;
};
state.button = (rootNode, label) => all(rootNode).find(el => el.tagName === 'button' && el.textContent.includes(label));
state.flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
state.runTimers = () => { const pending = [...timers.values()]; timers.clear(); pending.forEach(callback => callback()); };
return state;
}
-35
View File
@@ -269,14 +269,6 @@ class AnomalousBrowser {
: 'balanced';
this.createDOM();
}
// [EXTRACTED] createDOM
// [EXTRACTED] showHelp
// [EXTRACTED] loadFolders
// [EXTRACTED] renderSidebar
// [EXTRACTED] loadModels
// [EXTRACTED] applyModelToCanvas
// [EXTRACTED] stopMediaInContainer
// [EXTRACTED] showDetail
show() {
if (this._idleReleaseTimer) {
@@ -314,33 +306,7 @@ class AnomalousBrowser {
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
trigger?.classList.toggle('anomalous-trigger-hidden', !visible || entryMode !== 'floating');
}
// [EXTRACTED] showNotebooks
async translateText(text) {
if (!text || !text.trim()) return "";
try {
const res = await fetch('/anomalous/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, target_lang: window.anomalous_browser_lang === 'zh' ? 'zh-CN' : 'en' })
});
const data = await res.json();
return data.translated || text;
} catch (e) { return text; }
}
// [EXTRACTED] loadGalleryImages
// [EXTRACTED] showEditModal
// [EXTRACTED] showGeneratedGallery
// [EXTRACTED] showGallerySelectMode
// [EXTRACTED] showGalleryViewer
// [EXTRACTED] refreshNotebooks
// [EXTRACTED] saveCurrentNotebook
// [EXTRACTED] deleteCurrentNotebook
// [EXTRACTED] renderNotebookEditor
// [EXTRACTED] fillNotebookGalleries
// [EXTRACTED] sendNotebookToCanvas
// [EXTRACTED] setWidgetValuePath
// [EXTRACTED] hideAllPanels
// [Doctor & Assistant Panel methods extracted to ui_doctor.js]
@@ -634,7 +600,6 @@ class AnomalousBrowser {
overlay.style.display = 'flex';
}
// [EXTRACTED] _openAdvancedModelSelector
}
+4
View File
@@ -1,5 +1,7 @@
export const i18n = {
zh: {
promptSourceSynced: '已同步 {count} 张新词卡',
promptFullTextEditHint: '修改全文会将当前方向已启用的词块合并为一个词块。',
materialNoPromptContent: '这份素材没有已明确分类的提示词,请先在详情中检查提示词角色。',
promptDrawerReplace: '替换这组文字',
promptDrawerAppend: '加入这组文字',
@@ -1106,6 +1108,8 @@ export const i18n = {
recipeCardSpecsResolution: '尺寸',
},
en: {
promptSourceSynced: 'Synced {count} new prompt cards',
promptFullTextEditHint: 'Editing the full text merges the enabled blocks in this direction into one block.',
materialNoPromptContent: 'This material has no classified prompts. Check its prompt roles in details first.',
promptDrawerReplace: 'Replace this text',
promptDrawerAppend: 'Add this text',
+2 -1
View File
@@ -1,6 +1,7 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { materialNodeHeading, text } from './material_inspector.js';
import { text } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
let activeDrag = null;
+1 -15
View File
@@ -1,15 +1,8 @@
import { text } from './ui_dom.js';
/** Shared image metadata and node-parameter inspection for the library and workbench. */
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export function text(parent, tag, value, className = '') {
const element = document.createElement(tag);
if (className) element.className = className;
element.textContent = value == null ? '' : String(value);
parent.appendChild(element);
return element;
}
export function sectionLabel(parent, value) {
return text(parent, 'div', value, 'anomalous-material-section-label');
}
@@ -75,13 +68,6 @@ export function lookupLocalModel(localModels, path) {
return localModels[path] || localModels[fileBaseName(path)] || null;
}
export async function jsonResponse(response, fallbackMessage) {
let payload = {};
try { payload = await response.json(); } catch (error) { /* non-JSON server failure */ }
if (!response.ok) throw new Error(payload.message || fallbackMessage);
return payload;
}
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
+2 -1
View File
@@ -1,5 +1,6 @@
import { composePromptPlan } from './prompt_composition.js';
import { jsonResponse } from './material_inspector.js';
import { jsonResponse } from './ui_dom.js';
export function materialPromptText(payload) {
const data = payload.data || {};
+50
View File
@@ -0,0 +1,50 @@
import { loadMaterialPrompts } from './material_prompt_data.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
// Load every page through the same path for initial and explicit synchronization.
export async function loadPromptSourceCards(signal) {
const cards = [];
const filenames = new Set();
let page = 1;
let pages = 1;
do {
signal.throwIfAborted();
const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal });
const payload = await jsonResponse(response, 'materialLoadError');
if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError');
pages = Number(payload.pages) || 1;
for (const item of payload.materials) {
signal.throwIfAborted();
if (filenames.has(item.filename)) continue;
filenames.add(item.filename);
const prompts = await loadMaterialPrompts(item.filename, signal);
for (const role of ['positive', 'negative']) {
const content = prompts[role]?.trim();
if (!content) continue;
cards.push({
id: `mat_${role}_${item.filename}`, filename: item.filename,
title: item.name || item.filename, content, role,
category: role === 'negative' ? 'base' : categorizePromptSnippet(content),
persisted: true,
});
}
}
page++;
} while (page <= pages);
signal.throwIfAborted();
return cards;
}
export function mergePromptSourceCards(existing, incoming) {
const keys = new Set(existing.map(card => `${card.role}\0${card.content.trim()}`));
let added = 0;
for (const card of incoming) {
const key = `${card.role}\0${card.content.trim()}`;
if (keys.has(key)) continue;
existing.push(card);
keys.add(key);
added++;
}
return added;
}
+109
View File
@@ -0,0 +1,109 @@
import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js';
export const newDraft = () => ({
name: '',
tags: [],
plan: {
version: 2,
parts: [],
positive: '',
negative: '',
},
});
export const CATEGORY_META = {
base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' },
style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' },
subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' },
trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' },
};
// Built-in starter prompt cards for left source deck
export const STARTER_SOURCE_PROMPTS = [
{
id: 'preset_base_quality',
title: '画质底模词 (Quality Base)',
content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres',
role: 'positive',
category: 'base',
},
{
id: 'preset_real_detail',
title: '写实与材质增强 (Realistic Detail)',
content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph',
role: 'positive',
category: 'base',
},
{
id: 'preset_cinematic_light',
title: '电影胶片光影 (Cinematic Lighting)',
content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric',
role: 'positive',
category: 'style',
},
{
id: 'preset_anime_cyber',
title: '赛博霓虹风 (Cyberpunk Neon)',
content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop',
role: 'positive',
category: 'style',
},
{
id: 'preset_girl_face',
title: '美少女面部特写 (Portrait 1girl)',
content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair',
role: 'positive',
category: 'subject',
},
{
id: 'preset_neg_filter',
title: '通用负向过滤词 (Negative Base Filter)',
content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark',
role: 'negative',
category: 'base',
},
];
export function normalizeBlock(part, index = 0) {
const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`;
const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive');
const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim();
let category = part.category;
if (!CATEGORY_META[category]) {
category = categorizePromptSnippet(content);
}
const meta = CATEGORY_META[category] || CATEGORY_META.subject;
const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : '');
return {
id,
title: part.name || part.title || defaultTitle,
content,
role,
category,
enabled: part.enabled !== false,
};
}
export function syncDraftSynthesizedText(draft) {
if (!draft || !draft.plan) return;
draft.plan.parts ||= [];
const posParts = draft.plan.parts.filter(p => p.role === 'positive');
const negParts = draft.plan.parts.filter(p => p.role === 'negative');
draft.plan.positive = assemblePromptBlocks(posParts, 'positive');
draft.plan.negative = assemblePromptBlocks(negParts, 'negative');
}
// Full-text edits replace the enabled blocks of one role. Keep disabled blocks
// and the other role intact; synthesized fields are always derived from parts.
export function replaceDraftRoleText(draft, role, content) {
const parts = draft.plan.parts || [];
const first = parts.findIndex(part => part.role === role && part.enabled !== false);
const replacement = content.trim() ? normalizeBlock({ content, role }) : null;
const next = [];
parts.forEach((part, index) => {
if (index === first && replacement) next.push(replacement);
if (part.role !== role || part.enabled === false) next.push(part);
});
if (first < 0 && replacement) next.push(replacement);
draft.plan.parts = next;
syncDraftSynthesizedText(draft);
}
+6 -1
View File
@@ -52,10 +52,12 @@ export function normalizePromptFormatting(text, delimiter = ', ') {
* @param {string} text - Raw prompt text to translate
* @param {Object} [options]
* @param {string} [options.targetLang] - Target language ('en' | 'zh-CN' | 'ja' | etc.)
* @param {AbortSignal} [options.signal] - Cancels requests when their owning view closes
* @param {boolean} [options.bypassCache=false] - If true, ignores in-memory cache
* @returns {Promise<{ ok: boolean, translated: string, targetLang: string, error?: string }>}
*/
export async function translatePromptText(text, options = {}) {
if (options.signal?.aborted) return { ok: false, cancelled: true, translated: '', targetLang: options.targetLang || 'en' };
const raw = String(text || '').trim();
if (!raw) {
return { ok: true, translated: '', targetLang: options.targetLang || 'en' };
@@ -71,6 +73,7 @@ export async function translatePromptText(text, options = {}) {
try {
const response = await fetch('/anomalous/translate', {
method: 'POST',
signal: options.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: raw,
@@ -83,6 +86,7 @@ export async function translatePromptText(text, options = {}) {
}
const data = await response.json();
options.signal?.throwIfAborted();
if (data.status === 'error' || data.error) {
throw new Error(data.error || 'Translation service failed');
}
@@ -93,7 +97,7 @@ export async function translatePromptText(text, options = {}) {
}
// Cache the successful result (limit cache to 500 items)
if (translationCache.size > 500) {
if (translationCache.size >= 500 && !translationCache.has(cacheKey)) {
const oldestKey = translationCache.keys().next().value;
translationCache.delete(oldestKey);
}
@@ -101,6 +105,7 @@ export async function translatePromptText(text, options = {}) {
return { ok: true, translated, targetLang, engine: data.engine };
} catch (err) {
if (options.signal?.aborted || err.name === 'AbortError') return { ok: false, cancelled: true, translated: raw, targetLang };
console.warn('[Anomalous Translation] Translate failed:', err);
return {
ok: false,
+14
View File
@@ -0,0 +1,14 @@
export function text(parent, tag, value, className = '') {
const element = document.createElement(tag);
if (className) element.className = className;
element.textContent = value == null ? '' : String(value);
parent.appendChild(element);
return element;
}
export async function jsonResponse(response, fallbackMessage) {
let payload = {};
try { payload = await response.json(); } catch { /* A server error may not be JSON. */ }
if (!response.ok) throw new Error(payload.message || fallbackMessage);
return payload;
}
+1 -2
View File
@@ -15,13 +15,12 @@ import { showMaterialSaved } from './material_feedback.js';
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { text, jsonResponse } from './ui_dom.js';
import {
text,
sectionLabel,
fileBaseName,
appendLocalPreview,
modelCustomNotes,
jsonResponse,
parsePngMetadataFromUrl,
extractWorkflowDetails,
resolveLocalModels,
+57
View File
@@ -0,0 +1,57 @@
// Resources belong to the view that creates them, including every close path.
export function createViewScope() {
const controller = new AbortController();
const cleanups = new Set();
const onDispose = cleanup => {
if (controller.signal.aborted) cleanup();
else cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
};
return {
signal: controller.signal,
onDispose,
listen(target, event, handler, options) {
target.addEventListener(event, handler, options);
return onDispose(() => target.removeEventListener(event, handler, options));
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
for (const cleanup of cleanups) cleanup();
cleanups.clear();
},
};
}
export function bindDrawerResize(handle, panel, scope, { side, minWidth, setWidth, saveWidth, enabled = () => true }) {
let finish = null;
handle.onmousedown = event => {
if (event.button !== 0 || scope.signal.aborted || !enabled()) return;
finish?.();
event.preventDefault();
const startX = event.clientX;
const startWidth = panel.getBoundingClientRect().width;
const previousSelect = document.body.style.userSelect;
const previousCursor = document.body.style.cursor;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
handle.classList.add('is-resizing');
const move = moveEvent => {
const delta = (moveEvent.clientX - startX) * (side() === 'left' ? 1 : -1);
setWidth(Math.max(minWidth, Math.min(window.innerWidth * 0.85, startWidth + delta)));
};
const stop = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
document.body.style.userSelect = previousSelect;
document.body.style.cursor = previousCursor;
handle.classList.remove('is-resizing');
finish = null;
};
const up = () => { stop(); saveWidth(panel.getBoundingClientRect().width); };
finish = stop;
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
scope.onDispose(() => { finish?.(); handle.onmousedown = null; });
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, materialNodeHeading } from './material_inspector.js';
import { text } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { applyMaterialBlock, selectedMaterialNode } from './node_material_actions.js';
export function showMaterialApplication(parent, result, node) {
+4 -4
View File
@@ -7,7 +7,9 @@ import { app } from '../../../scripts/app.js';
import { translate, resolveLocale } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { text, sectionLabel, fileBaseName, jsonResponse, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
import { text, jsonResponse } from './ui_dom.js';
import { escapeHtml } from './safe_dom.js';
import { sectionLabel, fileBaseName, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
const t = (key, params) => translate(key, params);
const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material.kind);
@@ -32,7 +34,6 @@ function hideSiblingWorkspaceViews(owner) {
if (owner.notebookContainer) owner.notebookContainer.style.display = 'none';
if (owner.notebookBody) owner.notebookBody.style.display = 'none';
if (owner.recipeView) owner.recipeView.style.display = 'none';
if (owner.promptStudioContainer) owner.promptStudioContainer.style.display = 'none';
if (owner.recipeContainer) owner.recipeContainer.style.display = 'none';
owner.notebookNotesTab?.classList.remove('active');
owner.notebookRecipesTab?.classList.remove('active');
@@ -924,9 +925,8 @@ export async function refreshMaterials(page = this.materialPage || 1) {
}
export async function showMaterials() {
this.closePromptImportDrawer?.();
this.nbPanel.style.display = 'flex';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (!this.materialContainer) {
this.materialContainer = text(this.nbPanel, 'div', '', 'anomalous-nb-container anomalous-material-container');
const header = text(this.materialContainer, 'div', '', 'anomalous-nb-header');
-4
View File
@@ -31,7 +31,6 @@ function restoreWorkspaceReturnPanel(owner) {
}
export function closeWorkspace() {
this.closePromptImportDrawer?.();
clearTimeout(this.materialSearchTimer);
this.materialListController?.abort();
this.materialListController = null;
@@ -58,7 +57,6 @@ export function closeWorkspace() {
if (this.materialView) this.materialView.style.display = 'none';
if (this.notebookBody) this.notebookBody.style.display = 'none';
if (this.materialContainer) this.materialContainer.style.display = 'none';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (this.recipeContainer) this.recipeContainer.style.display = 'none';
if (this.nbPanel) this.nbPanel.style.display = 'none';
restoreWorkspaceReturnPanel(this);
@@ -67,9 +65,7 @@ export function closeWorkspace() {
export async function showNotebooks() {
this.closePromptImportDrawer?.();
if (this.materialContainer) this.materialContainer.style.display = 'none';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (this.recipeContainer) this.recipeContainer.style.display = 'none';
if (this.notebookContainer) this.notebookContainer.style.display = 'flex';
if (this.nbInitialized) {
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
import { createViewScope } from './ui_lifecycle.js';
import { syncDraftSynthesizedText, replaceDraftRoleText } from './prompt_studio_data.js';
import { translatePromptText } from './translation_service.js';
import { translate as t } from './locales.js';
let activeInspector = null;
export function openPromptInspectorModal(draft, defaultRole = 'positive', onApplyToNode = null, onChange = () => {}) {
activeInspector?.dispose();
const scope = createViewScope();
activeInspector = scope;
const overlay = document.createElement('div');
overlay.className = 'anomalous-prompt-inspector-overlay';
scope.onDispose(() => { overlay.remove(); if (activeInspector === scope) activeInspector = null; });
const modal = document.createElement('div');
modal.className = 'anomalous-prompt-inspector-modal';
overlay.appendChild(modal);
let currentRole = defaultRole || 'positive';
// Header
const header = document.createElement('header');
header.className = 'anomalous-inspector-header';
const h3 = document.createElement('h3');
h3.textContent = window.anomalous_browser_lang === 'zh' ? '🔍 提示词深度全览与精修' : '🔍 Prompt Inspector & Editor';
h3.style.margin = '0';
h3.style.fontSize = '0.92rem';
h3.style.color = '#e2e8f0';
header.appendChild(h3);
// Role Tabs
const tabsWrap = document.createElement('div');
tabsWrap.style.display = 'flex';
tabsWrap.style.gap = '6px';
const posTab = document.createElement('button');
posTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'positive' ? ' is-active' : ''}`;
posTab.textContent = `🔆 ${window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive'}`;
const negTab = document.createElement('button');
negTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'negative' ? ' is-active' : ''}`;
negTab.textContent = `🌙 ${window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative'}`;
tabsWrap.appendChild(posTab);
tabsWrap.appendChild(negTab);
header.appendChild(tabsWrap);
const closeBtn = document.createElement('button');
closeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
closeBtn.textContent = '✕';
closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭 (Esc)' : 'Close (Esc)';
closeBtn.onclick = () => scope.dispose();
header.appendChild(closeBtn);
// Body
const body = document.createElement('div');
body.className = 'anomalous-inspector-body';
const editHint = document.createElement('small');
editHint.textContent = t('promptFullTextEditHint');
body.appendChild(editHint);
const textarea = document.createElement('textarea');
textarea.className = 'anomalous-inspector-textarea';
textarea.spellcheck = false;
syncDraftSynthesizedText(draft);
textarea.value = draft?.plan?.[currentRole] || '';
const updateStats = () => {
const val = textarea.value;
const words = val.split(/[,,\s\n]+/).filter(Boolean).length;
statsEl.textContent = `${val.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
};
const updateTabStyles = () => {
posTab.classList.toggle('is-active', currentRole === 'positive');
negTab.classList.toggle('is-active', currentRole === 'negative');
textarea.value = draft?.plan?.[currentRole] || '';
updateStats();
};
posTab.onclick = () => { currentRole = 'positive'; updateTabStyles(); };
negTab.onclick = () => { currentRole = 'negative'; updateTabStyles(); };
body.appendChild(textarea);
// Footer
const footer = document.createElement('footer');
footer.className = 'anomalous-inspector-footer';
const statsEl = document.createElement('div');
statsEl.style.fontSize = '0.75rem';
statsEl.style.color = '#94a3b8';
statsEl.style.fontFamily = 'monospace';
const saveEdits = () => {
replaceDraftRoleText(draft, currentRole, textarea.value);
onChange();
updateStats();
};
textarea.oninput = saveEdits;
updateStats();
footer.appendChild(statsEl);
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.gap = '6px';
// Normalize formatting
const normBtn = document.createElement('button');
normBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
normBtn.textContent = `🧹 ${window.anomalous_browser_lang === 'zh' ? '规范化' : 'Normalize'}`;
normBtn.title = window.anomalous_browser_lang === 'zh' ? '清理多余顿号、分号与重复逗号' : 'Clean formatting and punctuation';
normBtn.onclick = () => {
const cleaned = textarea.value
.replace(/[,,、;;||\n\r]+/g, ', ')
.replace(/\s+,/g, ',')
.replace(/,\s*,/g, ', ')
.replace(/^\s*,\s*|\s*,\s*$/g, '')
.trim();
textarea.value = cleaned;
saveEdits();
};
actions.appendChild(normBtn);
// Translate
const transBtn = document.createElement('button');
transBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
transBtn.textContent = `🌐 ${window.anomalous_browser_lang === 'zh' ? '翻译' : 'Translate'}`;
transBtn.onclick = async () => {
const raw = textarea.value.trim();
const role = currentRole;
if (!raw) return;
const orig = transBtn.textContent;
transBtn.textContent = '⏳';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (!scope.signal.aborted && currentRole === role && textarea.value.trim() === raw && res.ok && res.translated) {
textarea.value = res.translated;
saveEdits();
}
} finally {
transBtn.textContent = orig;
}
};
actions.appendChild(transBtn);
// Copy
const copyBtn = document.createElement('button');
copyBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`;
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(textarea.value);
copyBtn.textContent = '✓';
setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; }, 1200);
};
actions.appendChild(copyBtn);
// Apply to node & close
if (typeof onApplyToNode === 'function') {
const applyBtn = document.createElement('button');
applyBtn.className = 'anomalous-btn-primary anomalous-btn-sm';
applyBtn.textContent = `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点并关闭' : 'Write Node & Close'}`;
applyBtn.onclick = async () => {
if (await onApplyToNode(currentRole, textarea.value)) scope.dispose();
};
actions.appendChild(applyBtn);
}
footer.appendChild(actions);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.onclick = (e) => {
if (e.target === overlay) scope.dispose();
};
const onKey = (e) => {
if (e.key === 'Escape') {
scope.dispose();
}
};
scope.listen(window, 'keydown', onKey);
document.body.appendChild(overlay);
return () => scope.dispose();
}
+516
View File
@@ -0,0 +1,516 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js';
import { translatePromptText } from './translation_service.js';
export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCardToMixer) {
const sourceCards = [...STARTER_SOURCE_PROMPTS];
let sourceFilterCategory = 'all';
let sourceFilterKeyword = '';
let isCreatingNewCard = false;
let activeCardPreviewPopover = null;
function hideCardPreviewPopover() {
activeCardPreviewPopover?.remove();
activeCardPreviewPopover = null;
}
scope.onDispose(hideCardPreviewPopover);
const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel');
const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header');
const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title');
text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library');
const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter');
// Action button group in leftHeader
const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions');
// Button 1: One-click Sync / Import from Material Library
const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
importMaterialsBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library';
// Button 2: Create New Custom Card
const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
newCardTriggerBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card';
// Search and category filters bar
const leftFilterBar = text(leftPanel, 'div', '', 'anomalous-workbench-filter-bar');
const leftSearch = text(leftFilterBar, 'input', '', 'anomalous-workbench-search-input');
leftSearch.placeholder = window.anomalous_browser_lang === 'zh' ? '搜索词卡...' : 'Search cards...';
leftSearch.oninput = () => {
sourceFilterKeyword = leftSearch.value.trim().toLowerCase();
renderSourceCardsList();
};
const leftCategoryPills = text(leftPanel, 'div', '', 'anomalous-workbench-category-pills');
const filterCats = [
{ id: 'all', label: window.anomalous_browser_lang === 'zh' ? '全部' : 'All' },
{ id: 'base', label: window.anomalous_browser_lang === 'zh' ? '底模' : 'Base' },
{ id: 'style', label: window.anomalous_browser_lang === 'zh' ? '风格' : 'Style' },
{ id: 'subject', label: window.anomalous_browser_lang === 'zh' ? '主体' : 'Subject' },
{ id: 'trigger', label: window.anomalous_browser_lang === 'zh' ? '触发' : 'Trigger' },
];
filterCats.forEach(cat => {
const pill = text(leftCategoryPills, 'button', cat.label, `anomalous-workbench-pill${sourceFilterCategory === cat.id ? ' is-active' : ''}`);
pill.onclick = () => {
sourceFilterCategory = cat.id;
leftCategoryPills.querySelectorAll('.anomalous-workbench-pill').forEach(el => el.classList.remove('is-active'));
pill.classList.add('is-active');
renderSourceCardsList();
};
});
// Inline New Card Form (Hidden by default, shown on demand)
const newCardForm = text(leftPanel, 'div', '', 'anomalous-workbench-new-card-form');
newCardForm.style.display = 'none';
// Source Cards List
const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list');
function detectNodePromptRole(node, widgetName = '') {
if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative';
if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive';
if (app?.graph && Array.isArray(node?.outputs)) {
for (const output of node.outputs) {
if (!Array.isArray(output.links)) continue;
for (const linkId of output.links) {
const link = app.graph.links?.[linkId];
if (!link) continue;
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode?.inputs && targetNode.inputs[link.target_slot]) {
const targetInput = targetNode.inputs[link.target_slot];
const inputName = String(targetInput.name || '').toLowerCase();
if (inputName.includes('neg') || inputName.includes('负')) return 'negative';
if (inputName.includes('pos') || inputName.includes('正')) return 'positive';
}
}
}
}
const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase();
if (/neg|negative|反向|负向/.test(nodeText)) return 'negative';
if (/pos|positive|正面|正向/.test(nodeText)) return 'positive';
return 'positive';
}
// Extract prompts from selected canvas node
function extractPromptsFromSelectedNode(intoRightMixer = false) {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`;
let targets = promptWidgetTargets(node);
// Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths
if (!targets.length && Array.isArray(node.widgets)) {
targets = node.widgets.flatMap((w, idx) => {
const name = String(w.name || '').toLowerCase();
if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) {
return [];
}
if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) {
if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') {
return [{ index: idx, name: w.name || 'text' }];
}
}
return [];
});
}
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!`
: `⚠️ No valid prompt text found in selected node [${heading}]!`);
return;
}
let extractedCount = 0;
targets.forEach(t => {
const rawVal = String(node.widgets[t.index]?.value || '').trim();
if (!rawVal) return;
const role = detectNodePromptRole(node, t.name);
const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal);
const cardTitle = `${heading} · ${t.name}`;
const newCard = {
id: `node_${node.id}_${t.index}_${Date.now()}`,
title: cardTitle,
content: rawVal,
role,
category: cat,
persisted: false,
};
// Add into left source deck
sourceCards.unshift(newCard);
extractedCount++;
// If user clicked right panel, also insert into mixer track directly
if (intoRightMixer) {
addSourceCardToMixer(newCard);
}
});
if (extractedCount > 0) {
renderSourceCardsList();
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!`
: `Successfully extracted ${extractedCount} prompts from [${heading}]!`);
} else {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? `选中的节点【${heading}】文本内容为空!`
: `The text fields in node [${heading}] are empty!`);
}
}
let materialSyncController = null;
scope.onDispose(() => materialSyncController?.abort());
async function syncMaterialsIntoSourceDeck(notify = true) {
materialSyncController?.abort();
const controller = new AbortController();
materialSyncController = controller;
const icon = importMaterialsBtn.innerHTML;
importMaterialsBtn.disabled = true;
try {
const cards = await loadPromptSourceCards(controller.signal);
if (scope.signal.aborted || controller.signal.aborted) return;
const addedCount = mergePromptSourceCards(sourceCards, cards);
renderSourceCardsList();
if (notify) showWorkbenchToast(t('promptSourceSynced', { count: addedCount }));
} catch (error) {
if (!scope.signal.aborted && !controller.signal.aborted && notify) await anomalousAlert(t('materialLoadError'));
} finally {
if (materialSyncController === controller) {
materialSyncController = null;
importMaterialsBtn.disabled = false;
importMaterialsBtn.innerHTML = icon;
}
}
}
importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck();
// -------------------------------------------------------------------------
// RENDER: Left Panel Cards & Form
// -------------------------------------------------------------------------
function renderNewCardFormUI() {
if (!isCreatingNewCard) {
newCardForm.style.display = 'none';
newCardForm.replaceChildren();
return;
}
newCardForm.style.display = 'flex';
newCardForm.replaceChildren();
text(newCardForm, 'div', window.anomalous_browser_lang === 'zh' ? '✨ 新建提示词卡片' : '✨ New Prompt Card', 'anomalous-form-title');
const titleInput = text(newCardForm, 'input', '', 'anomalous-form-input');
titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '卡片名称(如:赛博光影、角色面部)...' : 'Card name...';
// Role & Category selector row
const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row');
// Role radio group
const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group');
let selectedRole = activeTab || 'positive';
const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const posRadio = text(posLabel, 'input', '');
posRadio.type = 'radio';
posRadio.name = 'new_card_role';
posRadio.value = 'positive';
posRadio.checked = selectedRole === 'positive';
text(posLabel, 'span', t('promptRolePositive'));
const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
const negRadio = text(negLabel, 'input', '');
negRadio.type = 'radio';
negRadio.name = 'new_card_role';
negRadio.value = 'negative';
negRadio.checked = selectedRole === 'negative';
text(negLabel, 'span', t('promptRoleNegative'));
posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; };
negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; };
// Category dropdown
const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap');
text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `);
let selectedCat = 'style';
const catSelect = text(catWrap, 'select', '', 'anomalous-form-select');
['base', 'style', 'subject', 'trigger'].forEach(catKey => {
const meta = CATEGORY_META[catKey];
const opt = text(catSelect, 'option', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en);
opt.value = catKey;
if (catKey === selectedCat) opt.selected = true;
});
catSelect.onchange = () => { selectedCat = catSelect.value; };
const contentInput = text(newCardForm, 'textarea', '', 'anomalous-form-textarea');
contentInput.placeholder = window.anomalous_browser_lang === 'zh' ? '输入提示词内容,多个短语用逗号隔开...' : 'Enter prompt text...';
contentInput.rows = 3;
const formBtnRow = text(newCardForm, 'div', '', 'anomalous-form-btn-row');
const submitBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '✓ 保存并加入库' : '✓ Save to Library', 'anomalous-btn-primary anomalous-btn-sm');
const translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate');
translateBtn.type = 'button';
translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation';
translateBtn.onclick = async () => {
const raw = contentInput.value.trim();
if (!raw) return;
const originalText = translateBtn.textContent;
translateBtn.disabled = true;
translateBtn.textContent = '⏳ ...';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (scope.signal.aborted || !contentInput.isConnected || contentInput.value.trim() !== raw) return;
if (res.ok && res.translated) {
contentInput.value = res.translated;
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
}
} finally {
translateBtn.disabled = false;
translateBtn.textContent = originalText;
}
};
const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm');
submitBtn.onclick = async () => {
const rawContent = contentInput.value.trim();
if (!rawContent) {
contentInput.focus();
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '⏳ ...';
const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en);
const newCard = {
id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
title: cardTitle,
content: rawContent,
role: selectedRole,
category: selectedCat,
persisted: false,
};
try {
const body = {
name: cardTitle,
tags: ['prompt_card', selectedCat, selectedRole],
allow_duplicate: true,
plan: {
version: 2,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
parts: [{
id: newCard.id,
name: cardTitle,
category: selectedCat,
role: selectedRole,
positive: selectedRole === 'positive' ? rawContent : '',
negative: selectedRole === 'negative' ? rawContent : '',
enabled: true,
}],
},
};
const res = await fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await jsonResponse(res, 'save prompt card');
if (scope.signal.aborted) return;
if (payload.status === 'success') {
newCard.persisted = true;
newCard.filename = payload.filename;
showWorkbenchToast(t('promptSaveCardSuccess'));
}
} catch (err) {
if (scope.signal.aborted) return;
showWorkbenchToast(t('promptSaveCardFailed'));
}
sourceCards.unshift(newCard);
isCreatingNewCard = false;
renderNewCardFormUI();
renderSourceCardsList();
};
cancelBtn.onclick = () => {
isCreatingNewCard = false;
renderNewCardFormUI();
};
titleInput.focus();
}
newCardTriggerBtn.onclick = () => {
isCreatingNewCard = !isCreatingNewCard;
renderNewCardFormUI();
};
function showCardPreviewPopover(card, anchorEl) {
hideCardPreviewPopover();
if (!anchorEl?.isConnected) return;
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const popover = document.createElement('div');
popover.className = 'anomalous-card-preview-popover';
const header = document.createElement('div');
header.className = 'anomalous-popover-header';
const tags = document.createElement('div');
tags.className = 'anomalous-popover-tags';
const catBadge = document.createElement('span');
catBadge.className = 'anomalous-popover-cat';
catBadge.style.color = catMeta.color;
catBadge.style.background = catMeta.bg;
catBadge.style.borderColor = catMeta.border;
catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en;
tags.appendChild(catBadge);
const roleBadge = document.createElement('span');
roleBadge.className = `anomalous-popover-role is-${card.role}`;
roleBadge.textContent = card.role === 'negative'
? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative')
: (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive');
tags.appendChild(roleBadge);
header.appendChild(tags);
const titleEl = document.createElement('div');
titleEl.className = 'anomalous-popover-title';
titleEl.textContent = card.title;
header.appendChild(titleEl);
popover.appendChild(header);
const body = document.createElement('div');
body.className = 'anomalous-popover-body';
const snippet = document.createElement('pre');
snippet.className = 'anomalous-popover-snippet';
snippet.textContent = card.content;
body.appendChild(snippet);
popover.appendChild(body);
const footer = document.createElement('div');
footer.className = 'anomalous-popover-footer';
footer.textContent = window.anomalous_browser_lang === 'zh'
? '💡 点击直接添加 · 拖拽自由调序'
: '💡 Click to add · Drag to assemble';
popover.appendChild(footer);
document.body.appendChild(popover);
activeCardPreviewPopover = popover;
// Smart Positioning
const rect = anchorEl.getBoundingClientRect();
const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true;
const popoverHeight = popover.offsetHeight || 160;
const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6));
popover.style.top = `${top}px`;
if (isDockLeft) {
popover.style.left = `${rect.right + 10}px`;
} else {
popover.style.left = `${Math.max(12, rect.left - 320)}px`;
}
}
function renderSourceCardsList() {
hideCardPreviewPopover();
sourceCardsList.replaceChildren();
sourceCardsList.onscroll = () => hideCardPreviewPopover();
const filtered = sourceCards.filter(card => {
if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false;
if (sourceFilterKeyword) {
const matchTitle = card.title.toLowerCase().includes(sourceFilterKeyword);
const matchContent = card.content.toLowerCase().includes(sourceFilterKeyword);
if (!matchTitle && !matchContent) return false;
}
return true;
});
leftCounter.textContent = `(${filtered.length})`;
if (!filtered.length) {
const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty');
empty.innerHTML = `
<div style="font-size: 24px; margin-bottom: 6px;">🔍</div>
<div>${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}</div>
`;
return;
}
filtered.forEach(card => {
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category}`);
cardEl.setAttribute('draggable', 'true');
// Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip)
cardEl.onmouseenter = () => {
showCardPreviewPopover(card, cardEl);
};
cardEl.onmouseleave = () => {
hideCardPreviewPopover();
};
// Drag Start
cardEl.ondragstart = (e) => {
hideCardPreviewPopover();
const payload = {
title: card.title,
content: card.content,
role: card.role,
category: card.category,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', card.content);
e.dataTransfer.effectAllowed = 'copyMove';
cardEl.classList.add('is-dragging-source');
workbenchGrid.classList.add('is-source-dragging');
};
cardEl.ondragend = () => {
cardEl.classList.remove('is-dragging-source');
workbenchGrid.classList.remove('is-source-dragging');
};
const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot');
dot.style.backgroundColor = catMeta.color;
const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name');
const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon');
cardEl.onclick = () => {
hideCardPreviewPopover();
addSourceCardToMixer(card);
};
});
}
renderNewCardFormUI();
renderSourceCardsList();
void syncMaterialsIntoSourceDeck(false);
return { extractSelected: extractPromptsFromSelectedNode, refresh: renderSourceCardsList, sync: syncMaterialsIntoSourceDeck };
}
+11
View File
@@ -0,0 +1,11 @@
export function showWorkbenchToast(message) {
const toast = document.createElement('div');
toast.className = 'anomalous-mixer-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('is-show'), 10);
setTimeout(() => {
toast.classList.remove('is-show');
setTimeout(() => toast.remove(), 300);
}, 2400);
}
+47 -71
View File
@@ -8,9 +8,10 @@
import { app } from '../../../scripts/app.js';
import { translatePromptText, splitPromptTags, normalizePromptFormatting, hasChinese } from './translation_service.js';
import { selectedMaterialNode, promptWidgetTargets, applyNodeMaterialValues } from './node_material_actions.js';
import { createViewScope, bindDrawerResize } from './ui_lifecycle.js';
import { appendPromptToStudio } from './ui_prompt_composer.js';
let activeTranslatorModal = null;
let activeTranslator = null;
function t(zh, en) {
return window.anomalous_browser_lang === 'zh' ? zh : en;
@@ -96,15 +97,17 @@ function writeToSelectedNode(text) {
* @param {Object} owner - AnomalousBrowser instance
*/
export function openPromptTranslator(owner) {
if (activeTranslatorModal) {
activeTranslatorModal.remove();
activeTranslatorModal = null;
}
activeTranslator?.dispose();
const scope = createViewScope();
activeTranslator = scope;
// Modal Overlay
const overlay = document.createElement('div');
overlay.className = 'anomalous-translator-overlay';
activeTranslatorModal = overlay;
scope.onDispose(() => {
overlay.remove();
if (activeTranslator === scope) activeTranslator = null;
});
// Modal Window
const modal = document.createElement('div');
@@ -125,48 +128,14 @@ export function openPromptTranslator(owner) {
resizeHandle.className = 'anomalous-translator-resize-handle';
resizeHandle.title = t('拖动调整侧边栏宽度,双击恢复默认', 'Drag to resize sidebar, double-click to reset');
let isResizing = false;
let startX = 0;
let startWidth = 380;
let currentSide = localStorage.getItem('anomalous_translator_dock_side') || 'right';
resizeHandle.onmousedown = (e) => {
if (!modal.classList.contains('is-sidebar')) return;
isResizing = true;
startX = e.clientX;
startWidth = modal.getBoundingClientRect().width;
resizeHandle.classList.add('is-resizing');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
const onMouseMove = (moveEvt) => {
if (!isResizing) return;
let newWidth;
if (currentSide === 'left') {
const deltaX = moveEvt.clientX - startX;
newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX));
} else {
const deltaX = startX - moveEvt.clientX;
newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX));
}
modal.style.setProperty('--amb-translator-width', `${newWidth}px`);
};
const onMouseUp = () => {
if (!isResizing) return;
isResizing = false;
resizeHandle.classList.remove('is-resizing');
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const finalWidth = modal.getBoundingClientRect().width;
localStorage.setItem('anomalous_translator_width', Math.round(finalWidth));
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
bindDrawerResize(resizeHandle, modal, scope, {
side: () => currentSide,
enabled: () => modal.classList.contains('is-sidebar'),
minWidth: 320,
setWidth: width => modal.style.setProperty('--amb-translator-width', `${width}px`),
saveWidth: width => localStorage.setItem('anomalous_translator_width', String(Math.round(width))),
});
resizeHandle.ondblclick = () => {
if (!modal.classList.contains('is-sidebar')) return;
@@ -280,8 +249,7 @@ export function openPromptTranslator(owner) {
closeBtn.innerHTML = '&times;';
closeBtn.title = t('关闭 (Esc)', 'Close (Esc)');
closeBtn.onclick = () => {
overlay.remove();
activeTranslatorModal = null;
scope.dispose();
};
headerRight.appendChild(closeBtn);
header.appendChild(headerRight);
@@ -494,8 +462,7 @@ export function openPromptTranslator(owner) {
if (/[,、;;||]/.test(out) && !hasChinese(out)) {
out = normalizePromptFormatting(out);
}
overlay.remove();
activeTranslatorModal = null;
scope.dispose();
if (typeof owner?.openPromptStudio === 'function') {
owner.openPromptStudio();
}
@@ -527,33 +494,40 @@ export function openPromptTranslator(owner) {
return;
}
if (!hasChinese(out)) {
out = normalizePromptFormatting(out);
targetTextarea.value = out;
updateTagChips(out);
const res = writeToSelectedNode(out);
showTranslatorToast(modal, res.message, !res.success);
if (translateAndWriteBtn.disabled || scope.signal.aborted) return;
// Capture the exact destination before awaiting a translation.
const graph = app.graph;
const node = selectedMaterialNode(app);
const target = node && promptWidgetTargets(node)[0];
if (!target) {
showTranslatorToast(modal, t('请先选中可写入的提示词节点', 'Select a writable prompt node first'), true);
return;
}
const widget = node.widgets[target.index];
const previousValue = widget.value;
translateAndWriteBtn.disabled = true;
translateAndWriteBtn.innerHTML = `⏳ ${t('反译写入中...', 'Translating & Writing...')}`;
try {
const res = await translatePromptText(out, { targetLang: 'en' });
const res = await translatePromptText(out, { targetLang: 'en', signal: scope.signal });
if (scope.signal.aborted) return;
if (res.ok && res.translated) {
const enTags = normalizePromptFormatting(res.translated);
const writeRes = writeToSelectedNode(enTags);
if (writeRes.success) {
showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!'));
sourceTextarea.value = enTags;
} else {
showTranslatorToast(modal, writeRes.message, true);
if (app.graph !== graph || selectedMaterialNode(app) !== node ||
graph.getNodeById(node.id) !== node || node.widgets[target.index] !== widget ||
widget.value !== previousValue || targetTextarea.value.trim() !== out) {
showTranslatorToast(modal, t('目标或文本已改变,请重新点击翻译写入', 'Target or text changed. Translate and write again.'), true);
return;
}
applyNodeMaterialValues(app, node, [{ index: target.index, value: enTags }]);
showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!'));
sourceTextarea.value = enTags;
} else {
showTranslatorToast(modal, t(`反译失败: ${res.error || '网络错误'}`, `Translation failed`), true);
}
} catch (err) {
if (scope.signal.aborted) return;
showTranslatorToast(modal, t(`反译异常: ${err.message}`, `Error: ${err.message}`), true);
} finally {
translateAndWriteBtn.disabled = false;
@@ -597,6 +571,7 @@ export function openPromptTranslator(owner) {
// Translation Handler
async function doTranslate() {
if (translateBtn.disabled || scope.signal.aborted) return;
const raw = sourceTextarea.value.trim();
if (!raw) {
sourceTextarea.focus();
@@ -604,7 +579,8 @@ export function openPromptTranslator(owner) {
}
const selectedLang = langSelect.value;
const options = {};
const previousOutput = targetTextarea.value;
const options = { signal: scope.signal };
if (selectedLang !== 'auto') {
options.targetLang = selectedLang;
}
@@ -614,6 +590,8 @@ export function openPromptTranslator(owner) {
try {
const res = await translatePromptText(raw, options);
if (scope.signal.aborted || sourceTextarea.value.trim() !== raw ||
langSelect.value !== selectedLang || targetTextarea.value !== previousOutput) return;
if (res.ok && res.translated) {
targetTextarea.value = res.translated;
updateTagChips(res.translated);
@@ -622,6 +600,7 @@ export function openPromptTranslator(owner) {
showTranslatorToast(modal, t(`翻译失败: ${res.error || '网络错误'}`, `Failed: ${res.error || 'Network error'}`), true);
}
} catch (err) {
if (scope.signal.aborted) return;
showTranslatorToast(modal, t(`翻译异常: ${err.message}`, `Error: ${err.message}`), true);
} finally {
translateBtn.disabled = false;
@@ -649,20 +628,17 @@ export function openPromptTranslator(owner) {
// Close on clicking backdrop (only active in centered modal mode)
overlay.onclick = (e) => {
if (e.target === overlay && !overlay.classList.contains('is-sidebar')) {
overlay.remove();
activeTranslatorModal = null;
scope.dispose();
}
};
// Close on Escape key
const onKeyDown = (e) => {
if (e.key === 'Escape') {
overlay.remove();
activeTranslatorModal = null;
document.removeEventListener('keydown', onKeyDown);
scope.dispose();
}
};
document.addEventListener('keydown', onKeyDown);
scope.listen(document, 'keydown', onKeyDown);
document.body.appendChild(overlay);
sourceTextarea.focus();
+895
View File
@@ -0,0 +1,895 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text, jsonResponse } from './ui_dom.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, workbenchDraftToSavedPlan } from './prompt_composition.js';
import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import { showMaterialApplication } from './ui_material_application.js';
import { showMaterialSaved } from './material_feedback.js';
import { translatePromptText } from './translation_service.js';
import { CATEGORY_META, newDraft, normalizeBlock, syncDraftSynthesizedText } from './prompt_studio_data.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { openPromptInspectorModal } from './ui_prompt_inspector.js';
import { createPromptSourceDeck } from './ui_prompt_source_deck.js';
export function createPromptWorkbench(owner, container, scope, options) {
const view = text(container, 'section', '', 'anomalous-prompt-composer anomalous-prompt-workbench is-side-studio');
let draft = owner.promptPlanDraft ||= newDraft();
draft.plan ||= { version: 2, parts: [], positive: '', negative: '' };
draft.plan.parts ||= [];
// Migrate existing text if parts are empty
if (!draft.plan.parts.length) {
if (draft.plan.positive?.trim()) {
draft.plan.parts.push(normalizeBlock({
title: window.anomalous_browser_lang === 'zh' ? '基础正向词' : 'Positive Base',
content: draft.plan.positive,
role: 'positive',
category: categorizePromptSnippet(draft.plan.positive),
}, 0));
}
if (draft.plan.negative?.trim()) {
draft.plan.parts.push(normalizeBlock({
title: window.anomalous_browser_lang === 'zh' ? '通用负向过滤' : 'Negative Filter',
content: draft.plan.negative,
role: 'negative',
category: 'base',
}, 1));
}
}
syncDraftSynthesizedText(draft);
let activeTab = 'positive'; // 'positive' | 'negative'
let draggedBlockId = null;
// Local in-memory source prompt cards (merged starters + materials + user custom)
// 1. Studio Topbar (Streamlined with inline preset name input)
const topbar = text(view, 'header', '', 'anomalous-prompt-topbar');
const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left');
text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词工坊' : '🎛️ Prompt Studio');
// Inline Preset Name Input
const nameInput = text(topLeft, 'input', '', 'anomalous-prompt-name-input');
nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称...' : 'Preset name...';
nameInput.title = window.anomalous_browser_lang === 'zh' ? '输入组合方案名称' : 'Enter preset name';
nameInput.maxLength = 120;
nameInput.value = draft.name || '';
nameInput.oninput = () => { draft.name = nameInput.value; };
const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions');
// Quick Extract from Selected Canvas Node
const extractQuickBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
extractQuickBtn.innerHTML = '🎯';
extractQuickBtn.title = window.anomalous_browser_lang === 'zh'
? '从画布选中节点一键吸取提示词'
: 'Extract prompt from selected node';
extractQuickBtn.onclick = () => sourceDeck.extractSelected(true);
// Dock Side Switch (Left / Right)
if (typeof options.onToggleDockSide === 'function') {
const dockSideBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
const isLeft = () => container?.classList.contains('is-dock-left');
const updateDockBtn = () => {
dockSideBtn.innerHTML = isLeft()
? `<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>`
: `<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>`;
dockSideBtn.title = isLeft()
? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right')
: (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left');
};
updateDockBtn();
dockSideBtn.onclick = () => {
options.onToggleDockSide();
updateDockBtn();
};
}
// "More" Dropdown Menu (holds Save, New Draft, Tags, Export, Material Sync)
const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap');
moreWrap.style.position = 'relative';
moreWrap.style.display = 'inline-flex';
const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm');
const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu');
moreMenu.style.display = 'none';
moreMenu.style.position = 'absolute';
moreMenu.style.top = 'calc(100% + 4px)';
moreMenu.style.right = '0';
moreMenu.style.zIndex = '1000';
moreMenu.style.minWidth = '150px';
moreMenu.style.background = 'var(--amb-bg-panel, #1C1E24)';
moreMenu.style.border = '1px solid var(--amb-border, rgba(255, 255, 255, 0.14))';
moreMenu.style.borderRadius = 'var(--amb-radius-control, 6px)';
moreMenu.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.6)';
moreMenu.style.padding = '4px';
const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-ghost anomalous-btn-sm');
saveBtn.style.width = '100%';
saveBtn.style.justifyContent = 'flex-start';
saveBtn.style.border = 'none';
saveBtn.title = t('promptSavePlan') || '保存方案至素材库';
const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost anomalous-btn-sm');
newBtn.style.width = '100%';
newBtn.style.justifyContent = 'flex-start';
newBtn.style.border = 'none';
const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-btn-ghost anomalous-btn-sm');
tagsBtn.style.width = '100%';
tagsBtn.style.justifyContent = 'flex-start';
tagsBtn.style.border = 'none';
tagsBtn.onclick = () => {
closeMoreMenu();
const current = (draft.tags || []).join(', ');
const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current);
if (val !== null) {
draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean);
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`);
}
};
const exportBtn = text(moreMenu, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost anomalous-btn-sm');
exportBtn.style.width = '100%';
exportBtn.style.justifyContent = 'flex-start';
exportBtn.style.border = 'none';
const syncLibBtn = text(moreMenu, 'button', `📥 ${window.anomalous_browser_lang === 'zh' ? '同步素材库' : 'Sync Library'}`, 'anomalous-btn-ghost anomalous-btn-sm');
syncLibBtn.style.width = '100%';
syncLibBtn.style.justifyContent = 'flex-start';
syncLibBtn.style.border = 'none';
syncLibBtn.onclick = () => {
closeMoreMenu();
return sourceDeck.sync();
};
moreBtn.onclick = (e) => {
e.stopPropagation();
const isHidden = moreMenu.style.display === 'none';
moreMenu.style.display = isHidden ? 'block' : 'none';
};
const closeMoreMenu = () => { moreMenu.style.display = 'none'; };
scope.listen(document, 'click', closeMoreMenu);
const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn');
closeBtn.title = t('close') || '关闭';
closeBtn.onclick = () => {
options.onClose();
};
// Insertion preference lives with the other secondary actions.
const posWrap = document.createElement('label');
posWrap.className = 'anomalous-prompt-insert-pos';
const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select');
for (const value of ['after', 'before']) {
text(posSelect, 'option', t(`promptInsert_${value}`)).value = value;
}
posSelect.value = owner.promptInsertPosition || 'after';
posSelect.setAttribute('aria-label', t('promptInsertPosition'));
moreMenu.appendChild(posWrap);
posSelect.onchange = () => {
owner.promptInsertPosition = posSelect.value;
};
// Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode)
const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid');
// =========================================================================
// LEFT COLUMN: Ready-to-use Prompt Cards (词卡库)
// =========================================================================
const sourceDeck = createPromptSourceDeck(workbenchGrid, container, scope, addSourceCardToMixer);
// =========================================================================
// RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台)
// =========================================================================
const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel');
const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header');
// Role switch tabs (Positive / Negative)
const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs');
const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions');
// Right quick extract: suck into right mixer directly
const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
rightSuckNodeBtn.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>${t('promptReadSelectedNode')}`;
rightSuckNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '直接将画布选中节点的提示词作为积木吸入当前拼装台' : 'Extract node prompt directly into current mixer track';
const smartSortBtn = text(rightActions, 'button', '', 'anomalous-mixer-smart-sort-btn');
smartSortBtn.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>${t('promptSortByCategory')}`;
smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]';
const clearRightBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost');
clearRightBtn.innerHTML = '<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track';
// Blocks Container & Dropzone
const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track');
blocksContainer.ondragover = (e) => {
e.preventDefault();
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) {
snapDock.classList.add('is-drag-over');
blocksContainer.classList.add('is-drag-over-end');
}
};
blocksContainer.ondragleave = (e) => {
if (!blocksContainer.contains(e.relatedTarget)) {
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
snapDock?.classList.remove('is-drag-over');
blocksContainer.classList.remove('is-drag-over-end');
}
};
blocksContainer.ondrop = (e) => {
if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) {
e.preventDefault();
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
snapDock?.classList.remove('is-drag-over');
blocksContainer.classList.remove('is-drag-over-end');
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
addSourceCardToMixer(JSON.parse(jsonStr));
return;
} catch (err) {}
}
if (draggedBlockId) {
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
if (fromIndex >= 0) {
const [moved] = draft.plan.parts.splice(fromIndex, 1);
draft.plan.parts.push(moved);
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
}
};
// -------------------------------------------------------------------------
// Node Direct Write Handler
// -------------------------------------------------------------------------
const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => {
const node = selectedMaterialNode(app);
if (!node) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!'
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
return;
}
const targets = promptWidgetTargets(node);
if (!targets.length) {
anomalousAlert(window.anomalous_browser_lang === 'zh'
? '⚠️ 选中的节点中未找到可写入的提示词文本输入!'
: '⚠️ No writable prompt text widget found in selected node!');
return;
}
const widgetIndex = targets[0].index;
try {
const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value);
showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node);
if (triggerBtn) {
const orig = triggerBtn.textContent;
triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written');
setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!');
}
return true;
} catch (error) {
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
}
};
// -------------------------------------------------------------------------
// Floating Action Dock (Minimalist & Slides Up Only When Content Exists)
// -------------------------------------------------------------------------
const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock');
floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added
const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left');
const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text');
const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right');
// 1. Write Node Button
const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm');
writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node';
writeNodeBtn.onclick = async () => {
syncDraftSynthesizedText(draft);
const compiledText = (draft.plan[activeTab] || '').trim();
if (!compiledText) return;
await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn);
};
// 2. Quick Copy Button
const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm');
copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text';
// 3. Popout Full Inspector Modal Button
let closeInspector = null;
scope.onDispose(() => closeInspector?.());
const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm');
popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal';
popoutBtn.onclick = () => {
closeInspector?.();
closeInspector = openPromptInspectorModal(draft, activeTab, async (role, textVal) => {
return applyPromptToCurrentNode(role, textVal);
}, () => {
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
});
};
// -------------------------------------------------------------------------
// FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync
// -------------------------------------------------------------------------
rightSuckNodeBtn.onclick = () => sourceDeck.extractSelected(true);
function updateRightTabsUI() {
rightTabs.replaceChildren();
const posCount = draft.plan.parts.filter(p => p.role === 'positive').length;
const negCount = draft.plan.parts.filter(p => p.role === 'negative').length;
const tabs = [
{ key: 'positive', label: `${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})` },
{ key: 'negative', label: `${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})` },
];
for (const tab of tabs) {
const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`);
btn.onclick = () => {
activeTab = tab.key;
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
};
}
}
function addSourceCardToMixer(cardData, targetIndex = null) {
const cardRole = cardData.role || 'positive';
if (activeTab !== cardRole) {
activeTab = cardRole;
updateRightTabsUI();
}
const newBlock = normalizeBlock({
title: cardData.title,
content: cardData.content,
role: cardRole,
category: cardData.category,
enabled: true,
}, draft.plan.parts.length);
newBlock._justAdded = true;
if (targetIndex !== null && targetIndex >= 0) {
draft.plan.parts.splice(targetIndex, 0, newBlock);
} else {
draft.plan.parts.push(newBlock);
}
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
const count = draft.plan.parts.filter(p => p.role === activeTab).length;
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台(共 ${count} 块)`
: `Added to ${activeTab} track (${count} blocks)`);
}
function renderBlocksList() {
blocksContainer.replaceChildren();
const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab);
if (!currentRoleParts.length) {
const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone');
dropzoneNotice.innerHTML = `
<div style="margin-bottom: 12px; opacity: 0.65;">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>
</svg>
</div>
<div style="font-size: 0.88rem; font-weight: 600; color: #f1f5f9;">${window.anomalous_browser_lang === 'zh' ? '点击词卡或拖拽到这里拼装' : 'Click cards or drag here to assemble'}</div>
<div style="font-size: 0.74rem; color: #64748b; margin-top: 5px;">${window.anomalous_browser_lang === 'zh' ? '支持自由拖拽调换次序,点击【智能排序】一键理顺' : 'Reorder freely anytime, or click Smart Sort.'}</div>
`;
// Accept drops on empty dropzone
setupDropzoneListeners(dropzoneNotice);
return;
}
currentRoleParts.forEach((block, index) => {
const isJustAdded = !!block._justAdded;
if (isJustAdded) delete block._justAdded;
const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.role}${isJustAdded ? ' is-just-added' : ''}`);
blockEl.setAttribute('draggable', 'true');
blockEl.dataset ||= {};
blockEl.dataset.blockId = block.id;
if (isJustAdded) {
requestAnimationFrame(() => {
blockEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
// Reorder Drag Listeners
blockEl.ondragstart = (e) => {
draggedBlockId = block.id;
blockEl.classList.add('is-dragging');
e.dataTransfer.setData('text/plain', block.content);
e.dataTransfer.effectAllowed = 'move';
};
blockEl.ondragend = () => {
draggedBlockId = null;
blockEl.classList.remove('is-dragging');
blocksContainer.querySelectorAll('.anomalous-mixer-block').forEach(el => {
el.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
});
};
blockEl.ondragover = (e) => {
e.preventDefault();
const rect = blockEl.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
blockEl.classList.add('is-drag-over-top');
blockEl.classList.remove('is-drag-over-bottom');
} else {
blockEl.classList.add('is-drag-over-bottom');
blockEl.classList.remove('is-drag-over-top');
}
};
blockEl.ondragleave = () => {
blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
};
blockEl.ondrop = (e) => {
e.preventDefault();
blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
// Case 1: Drop from Left Source Card
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
const parsed = JSON.parse(jsonStr);
const toIndex = draft.plan.parts.findIndex(p => p.id === block.id);
const insertAfter = e.clientY >= (blockEl.getBoundingClientRect().top + blockEl.offsetHeight / 2);
addSourceCardToMixer(parsed, insertAfter ? toIndex + 1 : toIndex);
return;
} catch (err) {}
}
// Case 2: Reorder inside Right Track
if (!draggedBlockId || draggedBlockId === block.id) return;
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
if (fromIndex < 0) return;
const [moved] = draft.plan.parts.splice(fromIndex, 1);
const currentTargetIndex = draft.plan.parts.findIndex(p => p.id === block.id);
if (currentTargetIndex < 0) {
draft.plan.parts.push(moved);
} else {
const rect = blockEl.getBoundingClientRect();
const insertAfter = e.clientY >= (rect.top + rect.height / 2);
const finalIndex = insertAfter ? currentTargetIndex + 1 : currentTargetIndex;
draft.plan.parts.splice(finalIndex, 0, moved);
}
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
};
// Header
const blockHeader = text(blockEl, 'div', '', 'anomalous-mixer-block-header');
const headerLeft = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-left');
const dragHandle = text(headerLeft, 'span', '⠿', 'anomalous-mixer-drag-handle');
dragHandle.title = window.anomalous_browser_lang === 'zh' ? '抓取按住上下拖拽排序' : 'Drag to reorder';
// A/B Bypass Checkbox
const toggleWrap = text(headerLeft, 'label', '', 'anomalous-mixer-block-toggle');
const checkbox = text(toggleWrap, 'input', '');
checkbox.type = 'checkbox';
checkbox.checked = !!block.enabled;
checkbox.title = window.anomalous_browser_lang === 'zh' ? '勾选参与拼装,取消勾选即旁路跳过(做A/B对比)' : 'Include or bypass in assembly';
checkbox.onchange = () => {
block.enabled = checkbox.checked;
blockEl.classList.toggle('is-bypassed', !block.enabled);
syncDraftSynthesizedText(draft);
updateOutputPreview();
};
// Category Badge
const catMeta = CATEGORY_META[block.category] || CATEGORY_META.subject;
const catBadge = text(headerLeft, 'span', window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en, 'anomalous-mixer-cat-badge');
catBadge.style.color = catMeta.color;
catBadge.style.backgroundColor = catMeta.bg;
catBadge.style.borderColor = catMeta.border;
catBadge.title = window.anomalous_browser_lang === 'zh' ? '点击切换词性分类' : 'Cycle category';
catBadge.onclick = () => {
const cats = ['base', 'style', 'subject', 'trigger'];
const nextIdx = (cats.indexOf(block.category) + 1) % cats.length;
block.category = cats[nextIdx];
renderBlocksList();
};
// Editable title
const titleInput = text(headerLeft, 'input', '', 'anomalous-mixer-block-title-input');
titleInput.value = block.title || '';
titleInput.oninput = () => { block.title = titleInput.value; };
// Weight Adjustment Pill with Mouse Wheel & Click
let currentWeight = block.weight !== undefined ? block.weight : 1.0;
const weightPill = text(headerLeft, 'span', '', 'anomalous-block-weight-pill');
weightPill.title = window.anomalous_browser_lang === 'zh'
? '滚轮上下滑动调节权重,或点击加减'
: 'Scroll wheel or click +/- to adjust weight';
const decBtn = text(weightPill, 'button', '-', 'anomalous-block-weight-btn');
const weightVal = text(weightPill, 'span', currentWeight.toFixed(2));
const incBtn = text(weightPill, 'button', '+', 'anomalous-block-weight-btn');
const applyBlockWeight = (newW) => {
newW = Math.round(Math.max(0.1, Math.min(3.0, newW)) * 100) / 100;
currentWeight = newW;
block.weight = newW;
weightVal.textContent = newW.toFixed(2);
let clean = block.content.replace(/^\((.+):[0-9.]+\)$/, '$1').replace(/^\((.+)\)$/, '$1').trim();
if (newW !== 1.0) {
block.content = `(${clean}:${newW.toFixed(2)})`;
} else {
block.content = clean;
}
const txtArea = blockEl.querySelector('.anomalous-mixer-block-textarea');
if (txtArea) txtArea.value = block.content;
syncDraftSynthesizedText(draft);
updateOutputPreview();
};
decBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight - 0.05); };
incBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight + 0.05); };
weightPill.onwheel = (e) => {
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY < 0 ? 0.05 : -0.05;
applyBlockWeight(currentWeight + delta);
};
// Right Action micro buttons
const headerRight = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-right');
const transBtn = text(headerRight, 'button', '🌐', 'anomalous-mixer-block-btn is-translate');
transBtn.title = window.anomalous_browser_lang === 'zh' ? '一键翻译此块提示词' : 'Translate this block';
transBtn.onclick = async () => {
const raw = block.content || '';
if (!raw.trim()) return;
const orig = transBtn.textContent;
transBtn.textContent = '⏳';
try {
const res = await translatePromptText(raw, { signal: scope.signal });
if (scope.signal.aborted || !draft.plan.parts.includes(block) || block.content !== raw) return;
if (res.ok && res.translated) {
block.content = res.translated;
textarea.value = res.translated;
syncDraftSynthesizedText(draft);
updateOutputPreview();
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 词块已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Block translated to ${res.targetLang}`);
} else {
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
}
} finally {
transBtn.textContent = orig;
}
};
const upBtn = text(headerRight, 'button', '▲', 'anomalous-mixer-block-btn');
upBtn.title = window.anomalous_browser_lang === 'zh' ? '上移' : 'Move up';
upBtn.disabled = index === 0;
upBtn.onclick = () => {
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
if (realIdx < 0) return;
const myRole = block.role || activeTab;
let prevIdx = -1;
for (let i = realIdx - 1; i >= 0; i--) {
if ((draft.plan.parts[i].role || 'positive') === myRole) {
prevIdx = i;
break;
}
}
if (prevIdx >= 0) {
const temp = draft.plan.parts[realIdx];
draft.plan.parts[realIdx] = draft.plan.parts[prevIdx];
draft.plan.parts[prevIdx] = temp;
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
};
const downBtn = text(headerRight, 'button', '▼', 'anomalous-mixer-block-btn');
downBtn.title = window.anomalous_browser_lang === 'zh' ? '下移' : 'Move down';
downBtn.disabled = index === currentRoleParts.length - 1;
downBtn.onclick = () => {
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
if (realIdx < 0) return;
const myRole = block.role || activeTab;
let nextIdx = -1;
for (let i = realIdx + 1; i < draft.plan.parts.length; i++) {
if ((draft.plan.parts[i].role || 'positive') === myRole) {
nextIdx = i;
break;
}
}
if (nextIdx >= 0) {
const temp = draft.plan.parts[realIdx];
draft.plan.parts[realIdx] = draft.plan.parts[nextIdx];
draft.plan.parts[nextIdx] = temp;
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
};
const delBtn = text(headerRight, 'button', '✕', 'anomalous-mixer-block-btn is-delete');
delBtn.title = window.anomalous_browser_lang === 'zh' ? '移除此块' : 'Remove block';
delBtn.onclick = () => {
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
if (realIdx >= 0) {
draft.plan.parts.splice(realIdx, 1);
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
}
};
// Body
const blockBody = text(blockEl, 'div', '', 'anomalous-mixer-block-body');
const textarea = text(blockBody, 'textarea', '', 'anomalous-mixer-block-textarea');
textarea.value = block.content || '';
textarea.rows = 1;
textarea.oninput = () => {
block.content = textarea.value;
syncDraftSynthesizedText(draft);
updateOutputPreview();
};
});
// Interactive Auto-snap Dock at end of track
const snapDock = text(blocksContainer, 'div', '', 'anomalous-assembly-snap-dock');
snapDock.innerHTML = `
<div class="anomalous-snap-dock-content">
<svg class="anomalous-snap-icon" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span class="anomalous-snap-dock-text">${window.anomalous_browser_lang === 'zh' ? '拖拽卡片至此自动贴合至末尾' : 'Drop card here to snap to bottom'}</span>
</div>
`;
setupDropzoneListeners(snapDock);
snapDock.onclick = () => {
leftSearch?.focus?.();
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '点击左侧词卡即可直接加入此处' : 'Click any card on left to add here');
};
}
function setupDropzoneListeners(el) {
el.ondragover = (e) => {
e.preventDefault();
el.classList.add('is-drag-over');
};
el.ondragleave = () => {
el.classList.remove('is-drag-over');
};
el.ondrop = (e) => {
e.preventDefault();
el.classList.remove('is-drag-over');
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
addSourceCardToMixer(JSON.parse(jsonStr));
return;
} catch (err) {}
}
if (draggedBlockId) {
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
if (fromIndex >= 0) {
const [moved] = draft.plan.parts.splice(fromIndex, 1);
draft.plan.parts.push(moved);
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
}
}
};
}
function updateOutputPreview() {
syncDraftSynthesizedText(draft);
const compiledText = (draft.plan[activeTab] || '').trim();
const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab);
if (!currentRoleParts.length && !compiledText) {
floatingDock.style.display = 'none';
} else {
floatingDock.style.display = 'flex';
const words = compiledText.split(/[,,\s\n]+/).filter(Boolean).length;
dockStats.innerHTML = `${activeTab === 'negative' ? '🌙' : '🔆'} <strong>${currentRoleParts.length}</strong> ${window.anomalous_browser_lang === 'zh' ? '块' : 'blocks'} · ~<strong>${words}</strong> ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
dockStats.title = activeTab === 'negative'
? `${window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`
: `${window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`;
}
}
// Smart Sort
smartSortBtn.onclick = () => {
if (!draft.plan.parts.length) return;
const roleParts = draft.plan.parts.filter(p => p.role === activeTab);
const otherParts = draft.plan.parts.filter(p => p.role !== activeTab);
const sorted = smartSortPromptBlocks(roleParts, activeTab);
draft.plan.parts = [...sorted, ...otherParts];
syncDraftSynthesizedText(draft);
renderBlocksList();
updateOutputPreview();
smartSortBtn.classList.add('is-animating');
setTimeout(() => smartSortBtn.classList.remove('is-animating'), 500);
};
// Clear track
clearRightBtn.onclick = async () => {
const msg = window.anomalous_browser_lang === 'zh'
? `确定清空当前【${activeTab === 'positive' ? '正向' : '负向'}】拼装池吗?`
: `Clear ${activeTab} mixer track?`;
if (await anomalousConfirm(msg) && !scope.signal.aborted) {
draft.plan.parts = draft.plan.parts.filter(p => p.role !== activeTab);
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
}
};
// Copy preview text
copyOutputBtn.onclick = async (e) => {
e?.stopPropagation?.();
const textToCopy = (draft.plan[activeTab] || '').trim();
if (!textToCopy) return;
try {
await navigator.clipboard.writeText(textToCopy);
copyOutputBtn.textContent = '✅';
setTimeout(() => { if (copyOutputBtn.isConnected) copyOutputBtn.textContent = `📋`; }, 1500);
} catch (err) {
await anomalousAlert(t('materialCopyError'));
}
};
// -------------------------------------------------------------------------
// Target Node Direct Injection Toolbar (Compact sticky bar integrated into Action Dock)
// -------------------------------------------------------------------------
// Save Plan to Material Library
saveBtn.onclick = async () => {
if (!draft.name?.trim()) {
nameInput.focus();
nameInput.classList.add('is-invalid');
setTimeout(() => nameInput.classList.remove('is-invalid'), 1200);
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!');
return;
}
saveBtn.disabled = true;
syncDraftSynthesizedText(draft);
const saved = workbenchDraftToSavedPlan(draft);
const body = {
name: draft.name.trim(),
tags: draft.tags || [],
plan: saved.plan,
};
const send = () => fetch('/anomalous/save_prompt_plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
try {
let response = await send();
if (response.status === 409) {
const duplicate = await response.json();
if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name })) || scope.signal.aborted) return;
body.allow_duplicate = true;
response = await send();
}
const payload = await jsonResponse(response, 'prompt save failed');
if (payload.status !== 'success') throw new Error('prompt save failed');
if (!scope.signal.aborted) showMaterialSaved(owner, payload.material);
} catch (error) {
if (!scope.signal.aborted) await anomalousAlert(t('materialSaveError'));
} finally {
saveBtn.disabled = false;
}
};
// Export Plan JSON
exportBtn.onclick = () => {
if (!draft.name?.trim()) {
nameInput.focus();
nameInput.classList.add('is-invalid');
setTimeout(() => nameInput.classList.remove('is-invalid'), 1200);
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!');
return;
}
syncDraftSynthesizedText(draft);
const saved = workbenchDraftToSavedPlan(draft);
const exportData = {
format: 'anomalous-prompt-plan-v1',
version: 2,
name: draft.name,
tags: draft.tags,
plan: saved.plan,
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
if (blob.size > 2 * 1024 * 1024 || draft.tags.length > 20 || draft.tags.some(tag => tag.length > 60)) {
return anomalousAlert(t('promptExportError'));
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${draft.name || 'prompt-mixer-preset'}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
// New Draft
newBtn.onclick = async () => {
if (await anomalousConfirm(t('promptReplaceDraft')) && !scope.signal.aborted) {
owner.promptPlanDraft = draft = newDraft();
draft.plan.parts.push(normalizeBlock({
title: window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt',
content: '',
role: 'positive',
category: 'base',
}, 0));
syncDraftSynthesizedText(draft);
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
nameInput.value = '';
draft.tags = [];
}
};
// Initial render execution
sourceDeck.refresh();
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
const control = {
updateAll: () => {
updateRightTabsUI();
renderBlocksList();
updateOutputPreview();
sourceDeck.refresh();
},
addBlock: (blockData) => {
addSourceCardToMixer(blockData);
},
};
return control;
}
-2
View File
@@ -887,9 +887,7 @@ function buildRecipeStudioTopbar(owner) {
}
export async function showRecipes() {
this.closePromptImportDrawer?.();
if (this.materialContainer) this.materialContainer.style.display = 'none';
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
if (this.notebookContainer) this.notebookContainer.style.display = 'none';
if (this.nbPanel) this.nbPanel.style.display = 'flex';
+35 -213
View File
@@ -12305,172 +12305,6 @@ button:hover .anomalous-btn-icon {
z-index: 0 !important;
}
/* Sidebar / Docked Mode Dedicated Adapters for Prompt Studio */
#anomalous-container.anomalous-docked .anomalous-prompt-workbench {
padding: 6px 8px 8px;
gap: 6px;
}
#anomalous-container.anomalous-docked .anomalous-prompt-topbar {
padding: 4px 6px 6px;
gap: 6px;
flex-wrap: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
#anomalous-container.anomalous-docked .anomalous-prompt-topbar-left {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
#anomalous-container.anomalous-docked .anomalous-prompt-topbar-left h3 {
font-size: 0.9rem;
margin: 0;
white-space: nowrap;
}
#anomalous-container.anomalous-docked .anomalous-prompt-name-input {
flex: 1 1 100px;
min-width: 60px;
max-width: 200px;
height: 26px;
padding: 2px 6px;
font-size: 0.76rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #f1f5f9;
}
#anomalous-container.anomalous-docked .anomalous-prompt-name-input:focus {
border-color: #38bdf8;
background: rgba(0, 0, 0, 0.5);
}
#anomalous-container.anomalous-docked .anomalous-prompt-name-input.is-invalid {
border-color: #ef4444;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
#anomalous-container.anomalous-docked .anomalous-prompt-topbar-actions {
gap: 4px;
flex-shrink: 0;
}
#anomalous-container.anomalous-docked .anomalous-prompt-workbench-grid {
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
overflow: hidden !important;
gap: 6px !important;
padding: 4px 6px 6px !important;
}
#anomalous-container.anomalous-docked .anomalous-workbench-right-panel {
order: 1 !important;
flex: 1 1 50% !important;
min-height: 150px !important;
max-height: 54% !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
padding: 8px 8px 4px !important;
gap: 6px !important;
}
#anomalous-container.anomalous-docked .anomalous-workbench-left-panel {
order: 2 !important;
flex: 1 1 50% !important;
min-height: 150px !important;
max-height: 50% !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
padding: 8px 8px 6px !important;
gap: 6px !important;
}
#anomalous-container.anomalous-docked .anomalous-source-cards-list {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)) !important;
gap: 6px !important;
overflow-y: auto !important;
align-content: start !important;
padding-right: 2px !important;
}
#anomalous-container.anomalous-docked .anomalous-source-prompt-card {
padding: 5px 7px !important;
gap: 3px !important;
border-radius: 5px !important;
cursor: pointer !important;
}
#anomalous-container.anomalous-docked .anomalous-source-card-snippet {
-webkit-line-clamp: 1 !important;
font-size: 0.68rem !important;
padding: 2px 4px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-blocks-container {
gap: 5px !important;
padding: 2px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-block {
border-radius: 5px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-block-header {
padding: 3px 6px !important;
gap: 4px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-block-body {
padding: 2px 6px 3px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-block-textarea {
min-height: 24px !important;
height: 24px !important;
font-size: 0.72rem !important;
padding: 2px 4px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-deck-output.anomalous-prompt-action-dock {
padding: 3px 6px !important;
gap: 3px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-output-summary {
min-height: 26px !important;
gap: 4px !important;
}
#anomalous-container.anomalous-docked .anomalous-mixer-output-stats {
font-size: 0.68rem !important;
}
#anomalous-container.anomalous-docked .anomalous-prompt-target-actions {
gap: 4px !important;
}
#anomalous-container.anomalous-docked .anomalous-prompt-target-actions select {
height: 22px !important;
padding: 1px 4px !important;
font-size: 0.68rem !important;
}
#anomalous-container.anomalous-docked .anomalous-assembly-snap-dock {
min-height: 32px !important;
padding: 4px 8px !important;
font-size: 0.68rem !important;
margin-top: 3px !important;
}
/* ==========================================================================
Recipe Studio Dedicated Container & Topbar Close Button
========================================================================== */
@@ -12969,10 +12803,10 @@ button:hover .anomalous-btn-icon {
flex-direction: column;
}
/* Left Dock Mode (Default): Left Column = Cards (190px), Right Column = Assembly Track (faces canvas on right) */
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-prompt-workbench-grid {
/* Shared drawer geometry. Dock direction only changes column order and separators. */
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-prompt-workbench-grid,
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-prompt-workbench-grid {
display: grid !important;
grid-template-columns: 190px minmax(0, 1fr) !important;
flex-direction: row !important;
gap: 0 !important;
padding: 0 !important;
@@ -12980,13 +12814,20 @@ button:hover .anomalous-btn-icon {
min-height: 0;
}
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-left-panel {
order: 1 !important;
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-prompt-workbench-grid {
grid-template-columns: 190px minmax(0, 1fr) !important;
}
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-prompt-workbench-grid {
grid-template-columns: minmax(0, 1fr) 190px !important;
}
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-left-panel,
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-left-panel {
flex: none !important;
width: 190px !important;
max-height: 100% !important;
padding: 8px 10px !important;
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
border-left: none !important;
background: rgba(18, 20, 24, 0.7) !important;
box-sizing: border-box;
@@ -12994,55 +12835,36 @@ button:hover .anomalous-btn-icon {
flex-direction: column;
}
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-left-panel {
order: 1 !important;
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
}
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-left-panel {
order: 2 !important;
border-right: none !important;
}
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-right-panel,
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-right-panel {
flex: 1 !important;
max-height: 100% !important;
padding: 8px 12px 52px !important;
position: relative;
box-sizing: border-box;
background: rgba(14, 15, 18, 0.5) !important;
display: flex;
flex-direction: column;
}
.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-right-panel {
order: 2 !important;
flex: 1 !important;
max-height: 100% !important;
padding: 8px 12px 52px !important; /* reserve bottom space for floating dock */
position: relative;
box-sizing: border-box;
background: rgba(14, 15, 18, 0.5) !important;
display: flex;
flex-direction: column;
}
/* Right Dock Mode: Right Column = Cards (190px), Left Column = Assembly Track (faces canvas on left) */
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-prompt-workbench-grid {
display: grid !important;
grid-template-columns: minmax(0, 1fr) 190px !important;
flex-direction: row !important;
gap: 0 !important;
padding: 0 !important;
flex: 1;
min-height: 0;
}
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-right-panel {
order: 1 !important;
flex: 1 !important;
max-height: 100% !important;
padding: 8px 12px 52px !important;
position: relative;
box-sizing: border-box;
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
border-left: none !important;
background: rgba(14, 15, 18, 0.5) !important;
display: flex;
flex-direction: column;
}
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-left-panel {
order: 2 !important;
flex: none !important;
width: 190px !important;
max-height: 100% !important;
padding: 8px 10px !important;
border-left: none !important;
border-right: none !important;
background: rgba(18, 20, 24, 0.7) !important;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
/* Column Headers & Action Buttons */