refactor: split prompt studio views and unify UI lifecycle
This commit is contained in:
+8
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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
@@ -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
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const i18n = {
|
||||
zh: {
|
||||
promptSourceSynced: '已同步 {count} 张新词卡',
|
||||
promptFullTextEditHint: '修改全文会将当前方向已启用的词块合并为一个词块。',
|
||||
materialNoPromptContent: '这份素材没有已明确分类的提示词,请先在详情中检查提示词角色。',
|
||||
promptDrawerReplace: '替换这组文字',
|
||||
promptDrawerAppend: '加入这组文字',
|
||||
@@ -1106,6 +1108,8 @@ export const i18n = {
|
||||
recipeCardSpecsResolution: '尺寸',
|
||||
},
|
||||
en: {
|
||||
promptSourceSynced: 'Synced {count} new prompt cards',
|
||||
promptFullTextEditHint: 'Editing the full text merges the enabled blocks in this direction into one block.',
|
||||
materialNoPromptContent: 'This material has no classified prompts. Check its prompt roles in details first.',
|
||||
promptDrawerReplace: 'Replace this text',
|
||||
promptDrawerAppend: 'Add this text',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { materialNodeHeading, text } from './material_inspector.js';
|
||||
import { text } from './ui_dom.js';
|
||||
import { materialNodeHeading } from './material_inspector.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
|
||||
let activeDrag = null;
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { text } from './ui_dom.js';
|
||||
/** Shared image metadata and node-parameter inspection for the library and workbench. */
|
||||
import { translate } from './locales.js';
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export function text(parent, tag, value, className = '') {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
element.textContent = value == null ? '' : String(value);
|
||||
parent.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function sectionLabel(parent, value) {
|
||||
return text(parent, 'div', value, 'anomalous-material-section-label');
|
||||
}
|
||||
@@ -75,13 +68,6 @@ export function lookupLocalModel(localModels, path) {
|
||||
return localModels[path] || localModels[fileBaseName(path)] || null;
|
||||
}
|
||||
|
||||
export async function jsonResponse(response, fallbackMessage) {
|
||||
let payload = {};
|
||||
try { payload = await response.json(); } catch (error) { /* non-JSON server failure */ }
|
||||
if (!response.ok) throw new Error(payload.message || fallbackMessage);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function parsePngMetadataFromUrl(url, options = {}) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { composePromptPlan } from './prompt_composition.js';
|
||||
import { jsonResponse } from './material_inspector.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
|
||||
|
||||
export function materialPromptText(payload) {
|
||||
const data = payload.data || {};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { loadMaterialPrompts } from './material_prompt_data.js';
|
||||
import { categorizePromptSnippet } from './prompt_composition.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
|
||||
// Load every page through the same path for initial and explicit synchronization.
|
||||
export async function loadPromptSourceCards(signal) {
|
||||
const cards = [];
|
||||
const filenames = new Set();
|
||||
let page = 1;
|
||||
let pages = 1;
|
||||
do {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal });
|
||||
const payload = await jsonResponse(response, 'materialLoadError');
|
||||
if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError');
|
||||
pages = Number(payload.pages) || 1;
|
||||
for (const item of payload.materials) {
|
||||
signal.throwIfAborted();
|
||||
if (filenames.has(item.filename)) continue;
|
||||
filenames.add(item.filename);
|
||||
const prompts = await loadMaterialPrompts(item.filename, signal);
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const content = prompts[role]?.trim();
|
||||
if (!content) continue;
|
||||
cards.push({
|
||||
id: `mat_${role}_${item.filename}`, filename: item.filename,
|
||||
title: item.name || item.filename, content, role,
|
||||
category: role === 'negative' ? 'base' : categorizePromptSnippet(content),
|
||||
persisted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
page++;
|
||||
} while (page <= pages);
|
||||
signal.throwIfAborted();
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function mergePromptSourceCards(existing, incoming) {
|
||||
const keys = new Set(existing.map(card => `${card.role}\0${card.content.trim()}`));
|
||||
let added = 0;
|
||||
for (const card of incoming) {
|
||||
const key = `${card.role}\0${card.content.trim()}`;
|
||||
if (keys.has(key)) continue;
|
||||
existing.push(card);
|
||||
keys.add(key);
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js';
|
||||
export const newDraft = () => ({
|
||||
name: '',
|
||||
tags: [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: [],
|
||||
positive: '',
|
||||
negative: '',
|
||||
},
|
||||
});
|
||||
|
||||
export const CATEGORY_META = {
|
||||
base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' },
|
||||
style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' },
|
||||
subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' },
|
||||
trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' },
|
||||
};
|
||||
|
||||
// Built-in starter prompt cards for left source deck
|
||||
export const STARTER_SOURCE_PROMPTS = [
|
||||
{
|
||||
id: 'preset_base_quality',
|
||||
title: '画质底模词 (Quality Base)',
|
||||
content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
},
|
||||
{
|
||||
id: 'preset_real_detail',
|
||||
title: '写实与材质增强 (Realistic Detail)',
|
||||
content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
},
|
||||
{
|
||||
id: 'preset_cinematic_light',
|
||||
title: '电影胶片光影 (Cinematic Lighting)',
|
||||
content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric',
|
||||
role: 'positive',
|
||||
category: 'style',
|
||||
},
|
||||
{
|
||||
id: 'preset_anime_cyber',
|
||||
title: '赛博霓虹风 (Cyberpunk Neon)',
|
||||
content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop',
|
||||
role: 'positive',
|
||||
category: 'style',
|
||||
},
|
||||
{
|
||||
id: 'preset_girl_face',
|
||||
title: '美少女面部特写 (Portrait 1girl)',
|
||||
content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair',
|
||||
role: 'positive',
|
||||
category: 'subject',
|
||||
},
|
||||
{
|
||||
id: 'preset_neg_filter',
|
||||
title: '通用负向过滤词 (Negative Base Filter)',
|
||||
content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark',
|
||||
role: 'negative',
|
||||
category: 'base',
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeBlock(part, index = 0) {
|
||||
const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive');
|
||||
const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim();
|
||||
let category = part.category;
|
||||
if (!CATEGORY_META[category]) {
|
||||
category = categorizePromptSnippet(content);
|
||||
}
|
||||
const meta = CATEGORY_META[category] || CATEGORY_META.subject;
|
||||
const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : '');
|
||||
return {
|
||||
id,
|
||||
title: part.name || part.title || defaultTitle,
|
||||
content,
|
||||
role,
|
||||
category,
|
||||
enabled: part.enabled !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncDraftSynthesizedText(draft) {
|
||||
if (!draft || !draft.plan) return;
|
||||
draft.plan.parts ||= [];
|
||||
const posParts = draft.plan.parts.filter(p => p.role === 'positive');
|
||||
const negParts = draft.plan.parts.filter(p => p.role === 'negative');
|
||||
draft.plan.positive = assemblePromptBlocks(posParts, 'positive');
|
||||
draft.plan.negative = assemblePromptBlocks(negParts, 'negative');
|
||||
}
|
||||
|
||||
// Full-text edits replace the enabled blocks of one role. Keep disabled blocks
|
||||
// and the other role intact; synthesized fields are always derived from parts.
|
||||
export function replaceDraftRoleText(draft, role, content) {
|
||||
const parts = draft.plan.parts || [];
|
||||
const first = parts.findIndex(part => part.role === role && part.enabled !== false);
|
||||
const replacement = content.trim() ? normalizeBlock({ content, role }) : null;
|
||||
const next = [];
|
||||
parts.forEach((part, index) => {
|
||||
if (index === first && replacement) next.push(replacement);
|
||||
if (part.role !== role || part.enabled === false) next.push(part);
|
||||
});
|
||||
if (first < 0 && replacement) next.push(replacement);
|
||||
draft.plan.parts = next;
|
||||
syncDraftSynthesizedText(draft);
|
||||
}
|
||||
@@ -52,10 +52,12 @@ export function normalizePromptFormatting(text, delimiter = ', ') {
|
||||
* @param {string} text - Raw prompt text to translate
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.targetLang] - Target language ('en' | 'zh-CN' | 'ja' | etc.)
|
||||
* @param {AbortSignal} [options.signal] - Cancels requests when their owning view closes
|
||||
* @param {boolean} [options.bypassCache=false] - If true, ignores in-memory cache
|
||||
* @returns {Promise<{ ok: boolean, translated: string, targetLang: string, error?: string }>}
|
||||
*/
|
||||
export async function translatePromptText(text, options = {}) {
|
||||
if (options.signal?.aborted) return { ok: false, cancelled: true, translated: '', targetLang: options.targetLang || 'en' };
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) {
|
||||
return { ok: true, translated: '', targetLang: options.targetLang || 'en' };
|
||||
@@ -71,6 +73,7 @@ export async function translatePromptText(text, options = {}) {
|
||||
try {
|
||||
const response = await fetch('/anomalous/translate', {
|
||||
method: 'POST',
|
||||
signal: options.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: raw,
|
||||
@@ -83,6 +86,7 @@ export async function translatePromptText(text, options = {}) {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
options.signal?.throwIfAborted();
|
||||
if (data.status === 'error' || data.error) {
|
||||
throw new Error(data.error || 'Translation service failed');
|
||||
}
|
||||
@@ -93,7 +97,7 @@ export async function translatePromptText(text, options = {}) {
|
||||
}
|
||||
|
||||
// Cache the successful result (limit cache to 500 items)
|
||||
if (translationCache.size > 500) {
|
||||
if (translationCache.size >= 500 && !translationCache.has(cacheKey)) {
|
||||
const oldestKey = translationCache.keys().next().value;
|
||||
translationCache.delete(oldestKey);
|
||||
}
|
||||
@@ -101,6 +105,7 @@ export async function translatePromptText(text, options = {}) {
|
||||
|
||||
return { ok: true, translated, targetLang, engine: data.engine };
|
||||
} catch (err) {
|
||||
if (options.signal?.aborted || err.name === 'AbortError') return { ok: false, cancelled: true, translated: raw, targetLang };
|
||||
console.warn('[Anomalous Translation] Translate failed:', err);
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function text(parent, tag, value, className = '') {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
element.textContent = value == null ? '' : String(value);
|
||||
parent.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function jsonResponse(response, fallbackMessage) {
|
||||
let payload = {};
|
||||
try { payload = await response.json(); } catch { /* A server error may not be JSON. */ }
|
||||
if (!response.ok) throw new Error(payload.message || fallbackMessage);
|
||||
return payload;
|
||||
}
|
||||
@@ -15,13 +15,12 @@ import { showMaterialSaved } from './material_feedback.js';
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate } from './locales.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
import {
|
||||
text,
|
||||
sectionLabel,
|
||||
fileBaseName,
|
||||
appendLocalPreview,
|
||||
modelCustomNotes,
|
||||
jsonResponse,
|
||||
parsePngMetadataFromUrl,
|
||||
extractWorkflowDetails,
|
||||
resolveLocalModels,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Resources belong to the view that creates them, including every close path.
|
||||
export function createViewScope() {
|
||||
const controller = new AbortController();
|
||||
const cleanups = new Set();
|
||||
const onDispose = cleanup => {
|
||||
if (controller.signal.aborted) cleanup();
|
||||
else cleanups.add(cleanup);
|
||||
return () => cleanups.delete(cleanup);
|
||||
};
|
||||
return {
|
||||
signal: controller.signal,
|
||||
onDispose,
|
||||
listen(target, event, handler, options) {
|
||||
target.addEventListener(event, handler, options);
|
||||
return onDispose(() => target.removeEventListener(event, handler, options));
|
||||
},
|
||||
dispose() {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort();
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
cleanups.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function bindDrawerResize(handle, panel, scope, { side, minWidth, setWidth, saveWidth, enabled = () => true }) {
|
||||
let finish = null;
|
||||
handle.onmousedown = event => {
|
||||
if (event.button !== 0 || scope.signal.aborted || !enabled()) return;
|
||||
finish?.();
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = panel.getBoundingClientRect().width;
|
||||
const previousSelect = document.body.style.userSelect;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
handle.classList.add('is-resizing');
|
||||
const move = moveEvent => {
|
||||
const delta = (moveEvent.clientX - startX) * (side() === 'left' ? 1 : -1);
|
||||
setWidth(Math.max(minWidth, Math.min(window.innerWidth * 0.85, startWidth + delta)));
|
||||
};
|
||||
const stop = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
document.body.style.userSelect = previousSelect;
|
||||
document.body.style.cursor = previousCursor;
|
||||
handle.classList.remove('is-resizing');
|
||||
finish = null;
|
||||
};
|
||||
const up = () => { stop(); saveWidth(panel.getBoundingClientRect().width); };
|
||||
finish = stop;
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
scope.onDispose(() => { finish?.(); handle.onmousedown = null; });
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { text, materialNodeHeading } from './material_inspector.js';
|
||||
import { text } from './ui_dom.js';
|
||||
import { materialNodeHeading } from './material_inspector.js';
|
||||
import { applyMaterialBlock, selectedMaterialNode } from './node_material_actions.js';
|
||||
|
||||
export function showMaterialApplication(parent, result, node) {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { app } from '../../../scripts/app.js';
|
||||
import { translate, resolveLocale } from './locales.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import { showImageWorkbench } from './ui_gallery_detail.js';
|
||||
import { text, sectionLabel, fileBaseName, jsonResponse, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
import { escapeHtml } from './safe_dom.js';
|
||||
import { sectionLabel, fileBaseName, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material.kind);
|
||||
@@ -32,7 +34,6 @@ function hideSiblingWorkspaceViews(owner) {
|
||||
if (owner.notebookContainer) owner.notebookContainer.style.display = 'none';
|
||||
if (owner.notebookBody) owner.notebookBody.style.display = 'none';
|
||||
if (owner.recipeView) owner.recipeView.style.display = 'none';
|
||||
if (owner.promptStudioContainer) owner.promptStudioContainer.style.display = 'none';
|
||||
if (owner.recipeContainer) owner.recipeContainer.style.display = 'none';
|
||||
owner.notebookNotesTab?.classList.remove('active');
|
||||
owner.notebookRecipesTab?.classList.remove('active');
|
||||
@@ -924,9 +925,8 @@ export async function refreshMaterials(page = this.materialPage || 1) {
|
||||
}
|
||||
|
||||
export async function showMaterials() {
|
||||
this.closePromptImportDrawer?.();
|
||||
|
||||
this.nbPanel.style.display = 'flex';
|
||||
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
|
||||
if (!this.materialContainer) {
|
||||
this.materialContainer = text(this.nbPanel, 'div', '', 'anomalous-nb-container anomalous-material-container');
|
||||
const header = text(this.materialContainer, 'div', '', 'anomalous-nb-header');
|
||||
|
||||
@@ -31,7 +31,6 @@ function restoreWorkspaceReturnPanel(owner) {
|
||||
}
|
||||
|
||||
export function closeWorkspace() {
|
||||
this.closePromptImportDrawer?.();
|
||||
clearTimeout(this.materialSearchTimer);
|
||||
this.materialListController?.abort();
|
||||
this.materialListController = null;
|
||||
@@ -58,7 +57,6 @@ export function closeWorkspace() {
|
||||
if (this.materialView) this.materialView.style.display = 'none';
|
||||
if (this.notebookBody) this.notebookBody.style.display = 'none';
|
||||
if (this.materialContainer) this.materialContainer.style.display = 'none';
|
||||
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
|
||||
if (this.recipeContainer) this.recipeContainer.style.display = 'none';
|
||||
if (this.nbPanel) this.nbPanel.style.display = 'none';
|
||||
restoreWorkspaceReturnPanel(this);
|
||||
@@ -67,9 +65,7 @@ export function closeWorkspace() {
|
||||
|
||||
|
||||
export async function showNotebooks() {
|
||||
this.closePromptImportDrawer?.();
|
||||
if (this.materialContainer) this.materialContainer.style.display = 'none';
|
||||
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
|
||||
if (this.recipeContainer) this.recipeContainer.style.display = 'none';
|
||||
if (this.notebookContainer) this.notebookContainer.style.display = 'flex';
|
||||
if (this.nbInitialized) {
|
||||
|
||||
+33
-2092
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
import { createViewScope } from './ui_lifecycle.js';
|
||||
import { syncDraftSynthesizedText, replaceDraftRoleText } from './prompt_studio_data.js';
|
||||
import { translatePromptText } from './translation_service.js';
|
||||
import { translate as t } from './locales.js';
|
||||
|
||||
let activeInspector = null;
|
||||
|
||||
export function openPromptInspectorModal(draft, defaultRole = 'positive', onApplyToNode = null, onChange = () => {}) {
|
||||
activeInspector?.dispose();
|
||||
const scope = createViewScope();
|
||||
activeInspector = scope;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'anomalous-prompt-inspector-overlay';
|
||||
scope.onDispose(() => { overlay.remove(); if (activeInspector === scope) activeInspector = null; });
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'anomalous-prompt-inspector-modal';
|
||||
overlay.appendChild(modal);
|
||||
|
||||
let currentRole = defaultRole || 'positive';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('header');
|
||||
header.className = 'anomalous-inspector-header';
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = window.anomalous_browser_lang === 'zh' ? '🔍 提示词深度全览与精修' : '🔍 Prompt Inspector & Editor';
|
||||
h3.style.margin = '0';
|
||||
h3.style.fontSize = '0.92rem';
|
||||
h3.style.color = '#e2e8f0';
|
||||
header.appendChild(h3);
|
||||
|
||||
// Role Tabs
|
||||
const tabsWrap = document.createElement('div');
|
||||
tabsWrap.style.display = 'flex';
|
||||
tabsWrap.style.gap = '6px';
|
||||
|
||||
const posTab = document.createElement('button');
|
||||
posTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'positive' ? ' is-active' : ''}`;
|
||||
posTab.textContent = `🔆 ${window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive'}`;
|
||||
|
||||
const negTab = document.createElement('button');
|
||||
negTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'negative' ? ' is-active' : ''}`;
|
||||
negTab.textContent = `🌙 ${window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative'}`;
|
||||
|
||||
tabsWrap.appendChild(posTab);
|
||||
tabsWrap.appendChild(negTab);
|
||||
header.appendChild(tabsWrap);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭 (Esc)' : 'Close (Esc)';
|
||||
closeBtn.onclick = () => scope.dispose();
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement('div');
|
||||
body.className = 'anomalous-inspector-body';
|
||||
|
||||
const editHint = document.createElement('small');
|
||||
editHint.textContent = t('promptFullTextEditHint');
|
||||
body.appendChild(editHint);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'anomalous-inspector-textarea';
|
||||
textarea.spellcheck = false;
|
||||
|
||||
syncDraftSynthesizedText(draft);
|
||||
textarea.value = draft?.plan?.[currentRole] || '';
|
||||
|
||||
const updateStats = () => {
|
||||
const val = textarea.value;
|
||||
const words = val.split(/[,,\s\n]+/).filter(Boolean).length;
|
||||
statsEl.textContent = `${val.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
|
||||
};
|
||||
|
||||
const updateTabStyles = () => {
|
||||
posTab.classList.toggle('is-active', currentRole === 'positive');
|
||||
negTab.classList.toggle('is-active', currentRole === 'negative');
|
||||
textarea.value = draft?.plan?.[currentRole] || '';
|
||||
updateStats();
|
||||
};
|
||||
|
||||
posTab.onclick = () => { currentRole = 'positive'; updateTabStyles(); };
|
||||
negTab.onclick = () => { currentRole = 'negative'; updateTabStyles(); };
|
||||
|
||||
body.appendChild(textarea);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement('footer');
|
||||
footer.className = 'anomalous-inspector-footer';
|
||||
|
||||
const statsEl = document.createElement('div');
|
||||
statsEl.style.fontSize = '0.75rem';
|
||||
statsEl.style.color = '#94a3b8';
|
||||
statsEl.style.fontFamily = 'monospace';
|
||||
|
||||
const saveEdits = () => {
|
||||
replaceDraftRoleText(draft, currentRole, textarea.value);
|
||||
onChange();
|
||||
updateStats();
|
||||
};
|
||||
textarea.oninput = saveEdits;
|
||||
updateStats();
|
||||
|
||||
footer.appendChild(statsEl);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.style.display = 'flex';
|
||||
actions.style.gap = '6px';
|
||||
|
||||
// Normalize formatting
|
||||
const normBtn = document.createElement('button');
|
||||
normBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
|
||||
normBtn.textContent = `🧹 ${window.anomalous_browser_lang === 'zh' ? '规范化' : 'Normalize'}`;
|
||||
normBtn.title = window.anomalous_browser_lang === 'zh' ? '清理多余顿号、分号与重复逗号' : 'Clean formatting and punctuation';
|
||||
normBtn.onclick = () => {
|
||||
const cleaned = textarea.value
|
||||
.replace(/[,,、;;||\n\r]+/g, ', ')
|
||||
.replace(/\s+,/g, ',')
|
||||
.replace(/,\s*,/g, ', ')
|
||||
.replace(/^\s*,\s*|\s*,\s*$/g, '')
|
||||
.trim();
|
||||
textarea.value = cleaned;
|
||||
saveEdits();
|
||||
};
|
||||
actions.appendChild(normBtn);
|
||||
|
||||
// Translate
|
||||
const transBtn = document.createElement('button');
|
||||
transBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
|
||||
transBtn.textContent = `🌐 ${window.anomalous_browser_lang === 'zh' ? '翻译' : 'Translate'}`;
|
||||
transBtn.onclick = async () => {
|
||||
const raw = textarea.value.trim();
|
||||
const role = currentRole;
|
||||
if (!raw) return;
|
||||
const orig = transBtn.textContent;
|
||||
transBtn.textContent = '⏳';
|
||||
try {
|
||||
const res = await translatePromptText(raw, { signal: scope.signal });
|
||||
if (!scope.signal.aborted && currentRole === role && textarea.value.trim() === raw && res.ok && res.translated) {
|
||||
textarea.value = res.translated;
|
||||
saveEdits();
|
||||
}
|
||||
} finally {
|
||||
transBtn.textContent = orig;
|
||||
}
|
||||
};
|
||||
actions.appendChild(transBtn);
|
||||
|
||||
// Copy
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'anomalous-btn-ghost anomalous-btn-sm';
|
||||
copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`;
|
||||
copyBtn.onclick = async () => {
|
||||
await navigator.clipboard.writeText(textarea.value);
|
||||
copyBtn.textContent = '✓';
|
||||
setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; }, 1200);
|
||||
};
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
// Apply to node & close
|
||||
if (typeof onApplyToNode === 'function') {
|
||||
const applyBtn = document.createElement('button');
|
||||
applyBtn.className = 'anomalous-btn-primary anomalous-btn-sm';
|
||||
applyBtn.textContent = `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点并关闭' : 'Write Node & Close'}`;
|
||||
applyBtn.onclick = async () => {
|
||||
if (await onApplyToNode(currentRole, textarea.value)) scope.dispose();
|
||||
};
|
||||
actions.appendChild(applyBtn);
|
||||
}
|
||||
|
||||
footer.appendChild(actions);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay) scope.dispose();
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
scope.dispose();
|
||||
}
|
||||
};
|
||||
scope.listen(window, 'keydown', onKey);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
return () => scope.dispose();
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
import { materialNodeHeading } from './material_inspector.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
|
||||
import { categorizePromptSnippet } from './prompt_composition.js';
|
||||
import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js';
|
||||
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
||||
import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js';
|
||||
import { translatePromptText } from './translation_service.js';
|
||||
|
||||
export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCardToMixer) {
|
||||
const sourceCards = [...STARTER_SOURCE_PROMPTS];
|
||||
let sourceFilterCategory = 'all';
|
||||
let sourceFilterKeyword = '';
|
||||
let isCreatingNewCard = false;
|
||||
let activeCardPreviewPopover = null;
|
||||
function hideCardPreviewPopover() {
|
||||
activeCardPreviewPopover?.remove();
|
||||
activeCardPreviewPopover = null;
|
||||
}
|
||||
scope.onDispose(hideCardPreviewPopover);
|
||||
const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel');
|
||||
const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header');
|
||||
const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title');
|
||||
text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library');
|
||||
const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter');
|
||||
|
||||
// Action button group in leftHeader
|
||||
const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions');
|
||||
|
||||
// Button 1: One-click Sync / Import from Material Library
|
||||
const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
|
||||
importMaterialsBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
||||
importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library';
|
||||
|
||||
// Button 2: Create New Custom Card
|
||||
const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn');
|
||||
newCardTriggerBtn.innerHTML = `<svg style="width:13px;height:13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
|
||||
newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card';
|
||||
|
||||
// Search and category filters bar
|
||||
const leftFilterBar = text(leftPanel, 'div', '', 'anomalous-workbench-filter-bar');
|
||||
const leftSearch = text(leftFilterBar, 'input', '', 'anomalous-workbench-search-input');
|
||||
leftSearch.placeholder = window.anomalous_browser_lang === 'zh' ? '搜索词卡...' : 'Search cards...';
|
||||
leftSearch.oninput = () => {
|
||||
sourceFilterKeyword = leftSearch.value.trim().toLowerCase();
|
||||
renderSourceCardsList();
|
||||
};
|
||||
|
||||
const leftCategoryPills = text(leftPanel, 'div', '', 'anomalous-workbench-category-pills');
|
||||
const filterCats = [
|
||||
{ id: 'all', label: window.anomalous_browser_lang === 'zh' ? '全部' : 'All' },
|
||||
{ id: 'base', label: window.anomalous_browser_lang === 'zh' ? '底模' : 'Base' },
|
||||
{ id: 'style', label: window.anomalous_browser_lang === 'zh' ? '风格' : 'Style' },
|
||||
{ id: 'subject', label: window.anomalous_browser_lang === 'zh' ? '主体' : 'Subject' },
|
||||
{ id: 'trigger', label: window.anomalous_browser_lang === 'zh' ? '触发' : 'Trigger' },
|
||||
];
|
||||
filterCats.forEach(cat => {
|
||||
const pill = text(leftCategoryPills, 'button', cat.label, `anomalous-workbench-pill${sourceFilterCategory === cat.id ? ' is-active' : ''}`);
|
||||
pill.onclick = () => {
|
||||
sourceFilterCategory = cat.id;
|
||||
leftCategoryPills.querySelectorAll('.anomalous-workbench-pill').forEach(el => el.classList.remove('is-active'));
|
||||
pill.classList.add('is-active');
|
||||
renderSourceCardsList();
|
||||
};
|
||||
});
|
||||
|
||||
// Inline New Card Form (Hidden by default, shown on demand)
|
||||
const newCardForm = text(leftPanel, 'div', '', 'anomalous-workbench-new-card-form');
|
||||
newCardForm.style.display = 'none';
|
||||
|
||||
// Source Cards List
|
||||
const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list');
|
||||
|
||||
function detectNodePromptRole(node, widgetName = '') {
|
||||
if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative';
|
||||
if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive';
|
||||
|
||||
if (app?.graph && Array.isArray(node?.outputs)) {
|
||||
for (const output of node.outputs) {
|
||||
if (!Array.isArray(output.links)) continue;
|
||||
for (const linkId of output.links) {
|
||||
const link = app.graph.links?.[linkId];
|
||||
if (!link) continue;
|
||||
const targetNode = app.graph.getNodeById(link.target_id);
|
||||
if (targetNode?.inputs && targetNode.inputs[link.target_slot]) {
|
||||
const targetInput = targetNode.inputs[link.target_slot];
|
||||
const inputName = String(targetInput.name || '').toLowerCase();
|
||||
if (inputName.includes('neg') || inputName.includes('负')) return 'negative';
|
||||
if (inputName.includes('pos') || inputName.includes('正')) return 'positive';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase();
|
||||
if (/neg|negative|反向|负向/.test(nodeText)) return 'negative';
|
||||
if (/pos|positive|正面|正向/.test(nodeText)) return 'positive';
|
||||
|
||||
return 'positive';
|
||||
}
|
||||
|
||||
// Extract prompts from selected canvas node
|
||||
function extractPromptsFromSelectedNode(intoRightMixer = false) {
|
||||
const node = selectedMaterialNode(app);
|
||||
if (!node) {
|
||||
anomalousAlert(window.anomalous_browser_lang === 'zh'
|
||||
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!'
|
||||
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`;
|
||||
let targets = promptWidgetTargets(node);
|
||||
|
||||
// Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths
|
||||
if (!targets.length && Array.isArray(node.widgets)) {
|
||||
targets = node.widgets.flatMap((w, idx) => {
|
||||
const name = String(w.name || '').toLowerCase();
|
||||
if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) {
|
||||
return [];
|
||||
}
|
||||
if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) {
|
||||
if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') {
|
||||
return [{ index: idx, name: w.name || 'text' }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
if (!targets.length) {
|
||||
anomalousAlert(window.anomalous_browser_lang === 'zh'
|
||||
? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!`
|
||||
: `⚠️ No valid prompt text found in selected node [${heading}]!`);
|
||||
return;
|
||||
}
|
||||
|
||||
let extractedCount = 0;
|
||||
targets.forEach(t => {
|
||||
const rawVal = String(node.widgets[t.index]?.value || '').trim();
|
||||
if (!rawVal) return;
|
||||
const role = detectNodePromptRole(node, t.name);
|
||||
const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal);
|
||||
const cardTitle = `${heading} · ${t.name}`;
|
||||
|
||||
const newCard = {
|
||||
id: `node_${node.id}_${t.index}_${Date.now()}`,
|
||||
title: cardTitle,
|
||||
content: rawVal,
|
||||
role,
|
||||
category: cat,
|
||||
persisted: false,
|
||||
};
|
||||
|
||||
// Add into left source deck
|
||||
sourceCards.unshift(newCard);
|
||||
extractedCount++;
|
||||
|
||||
// If user clicked right panel, also insert into mixer track directly
|
||||
if (intoRightMixer) {
|
||||
addSourceCardToMixer(newCard);
|
||||
}
|
||||
});
|
||||
|
||||
if (extractedCount > 0) {
|
||||
renderSourceCardsList();
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
|
||||
? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!`
|
||||
: `Successfully extracted ${extractedCount} prompts from [${heading}]!`);
|
||||
} else {
|
||||
anomalousAlert(window.anomalous_browser_lang === 'zh'
|
||||
? `选中的节点【${heading}】文本内容为空!`
|
||||
: `The text fields in node [${heading}] are empty!`);
|
||||
}
|
||||
}
|
||||
|
||||
let materialSyncController = null;
|
||||
scope.onDispose(() => materialSyncController?.abort());
|
||||
async function syncMaterialsIntoSourceDeck(notify = true) {
|
||||
materialSyncController?.abort();
|
||||
const controller = new AbortController();
|
||||
materialSyncController = controller;
|
||||
const icon = importMaterialsBtn.innerHTML;
|
||||
importMaterialsBtn.disabled = true;
|
||||
try {
|
||||
const cards = await loadPromptSourceCards(controller.signal);
|
||||
if (scope.signal.aborted || controller.signal.aborted) return;
|
||||
const addedCount = mergePromptSourceCards(sourceCards, cards);
|
||||
renderSourceCardsList();
|
||||
if (notify) showWorkbenchToast(t('promptSourceSynced', { count: addedCount }));
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted && !controller.signal.aborted && notify) await anomalousAlert(t('materialLoadError'));
|
||||
} finally {
|
||||
if (materialSyncController === controller) {
|
||||
materialSyncController = null;
|
||||
importMaterialsBtn.disabled = false;
|
||||
importMaterialsBtn.innerHTML = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck();
|
||||
// -------------------------------------------------------------------------
|
||||
// RENDER: Left Panel Cards & Form
|
||||
// -------------------------------------------------------------------------
|
||||
function renderNewCardFormUI() {
|
||||
if (!isCreatingNewCard) {
|
||||
newCardForm.style.display = 'none';
|
||||
newCardForm.replaceChildren();
|
||||
return;
|
||||
}
|
||||
newCardForm.style.display = 'flex';
|
||||
newCardForm.replaceChildren();
|
||||
|
||||
text(newCardForm, 'div', window.anomalous_browser_lang === 'zh' ? '✨ 新建提示词卡片' : '✨ New Prompt Card', 'anomalous-form-title');
|
||||
|
||||
const titleInput = text(newCardForm, 'input', '', 'anomalous-form-input');
|
||||
titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '卡片名称(如:赛博光影、角色面部)...' : 'Card name...';
|
||||
|
||||
// Role & Category selector row
|
||||
const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row');
|
||||
|
||||
// Role radio group
|
||||
const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group');
|
||||
let selectedRole = activeTab || 'positive';
|
||||
|
||||
const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
|
||||
const posRadio = text(posLabel, 'input', '');
|
||||
posRadio.type = 'radio';
|
||||
posRadio.name = 'new_card_role';
|
||||
posRadio.value = 'positive';
|
||||
posRadio.checked = selectedRole === 'positive';
|
||||
text(posLabel, 'span', t('promptRolePositive'));
|
||||
|
||||
const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label');
|
||||
const negRadio = text(negLabel, 'input', '');
|
||||
negRadio.type = 'radio';
|
||||
negRadio.name = 'new_card_role';
|
||||
negRadio.value = 'negative';
|
||||
negRadio.checked = selectedRole === 'negative';
|
||||
text(negLabel, 'span', t('promptRoleNegative'));
|
||||
|
||||
posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; };
|
||||
negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; };
|
||||
|
||||
// Category dropdown
|
||||
const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap');
|
||||
text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `);
|
||||
let selectedCat = 'style';
|
||||
const catSelect = text(catWrap, 'select', '', 'anomalous-form-select');
|
||||
['base', 'style', 'subject', 'trigger'].forEach(catKey => {
|
||||
const meta = CATEGORY_META[catKey];
|
||||
const opt = text(catSelect, 'option', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en);
|
||||
opt.value = catKey;
|
||||
if (catKey === selectedCat) opt.selected = true;
|
||||
});
|
||||
catSelect.onchange = () => { selectedCat = catSelect.value; };
|
||||
|
||||
const contentInput = text(newCardForm, 'textarea', '', 'anomalous-form-textarea');
|
||||
contentInput.placeholder = window.anomalous_browser_lang === 'zh' ? '输入提示词内容,多个短语用逗号隔开...' : 'Enter prompt text...';
|
||||
contentInput.rows = 3;
|
||||
|
||||
const formBtnRow = text(newCardForm, 'div', '', 'anomalous-form-btn-row');
|
||||
const submitBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '✓ 保存并加入库' : '✓ Save to Library', 'anomalous-btn-primary anomalous-btn-sm');
|
||||
|
||||
const translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate');
|
||||
translateBtn.type = 'button';
|
||||
translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation';
|
||||
translateBtn.onclick = async () => {
|
||||
const raw = contentInput.value.trim();
|
||||
if (!raw) return;
|
||||
const originalText = translateBtn.textContent;
|
||||
translateBtn.disabled = true;
|
||||
translateBtn.textContent = '⏳ ...';
|
||||
try {
|
||||
const res = await translatePromptText(raw, { signal: scope.signal });
|
||||
if (scope.signal.aborted || !contentInput.isConnected || contentInput.value.trim() !== raw) return;
|
||||
if (res.ok && res.translated) {
|
||||
contentInput.value = res.translated;
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`);
|
||||
} else {
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
|
||||
}
|
||||
} finally {
|
||||
translateBtn.disabled = false;
|
||||
translateBtn.textContent = originalText;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
|
||||
submitBtn.onclick = async () => {
|
||||
const rawContent = contentInput.value.trim();
|
||||
if (!rawContent) {
|
||||
contentInput.focus();
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '⏳ ...';
|
||||
|
||||
const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en);
|
||||
const newCard = {
|
||||
id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
title: cardTitle,
|
||||
content: rawContent,
|
||||
role: selectedRole,
|
||||
category: selectedCat,
|
||||
persisted: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name: cardTitle,
|
||||
tags: ['prompt_card', selectedCat, selectedRole],
|
||||
allow_duplicate: true,
|
||||
plan: {
|
||||
version: 2,
|
||||
positive: selectedRole === 'positive' ? rawContent : '',
|
||||
negative: selectedRole === 'negative' ? rawContent : '',
|
||||
parts: [{
|
||||
id: newCard.id,
|
||||
name: cardTitle,
|
||||
category: selectedCat,
|
||||
role: selectedRole,
|
||||
positive: selectedRole === 'positive' ? rawContent : '',
|
||||
negative: selectedRole === 'negative' ? rawContent : '',
|
||||
enabled: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const res = await fetch('/anomalous/save_prompt_plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await jsonResponse(res, 'save prompt card');
|
||||
if (scope.signal.aborted) return;
|
||||
if (payload.status === 'success') {
|
||||
newCard.persisted = true;
|
||||
newCard.filename = payload.filename;
|
||||
showWorkbenchToast(t('promptSaveCardSuccess'));
|
||||
}
|
||||
} catch (err) {
|
||||
if (scope.signal.aborted) return;
|
||||
showWorkbenchToast(t('promptSaveCardFailed'));
|
||||
}
|
||||
|
||||
sourceCards.unshift(newCard);
|
||||
isCreatingNewCard = false;
|
||||
renderNewCardFormUI();
|
||||
renderSourceCardsList();
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => {
|
||||
isCreatingNewCard = false;
|
||||
renderNewCardFormUI();
|
||||
};
|
||||
|
||||
titleInput.focus();
|
||||
}
|
||||
|
||||
newCardTriggerBtn.onclick = () => {
|
||||
isCreatingNewCard = !isCreatingNewCard;
|
||||
renderNewCardFormUI();
|
||||
};
|
||||
|
||||
function showCardPreviewPopover(card, anchorEl) {
|
||||
hideCardPreviewPopover();
|
||||
if (!anchorEl?.isConnected) return;
|
||||
|
||||
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'anomalous-card-preview-popover';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'anomalous-popover-header';
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'anomalous-popover-tags';
|
||||
|
||||
const catBadge = document.createElement('span');
|
||||
catBadge.className = 'anomalous-popover-cat';
|
||||
catBadge.style.color = catMeta.color;
|
||||
catBadge.style.background = catMeta.bg;
|
||||
catBadge.style.borderColor = catMeta.border;
|
||||
catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en;
|
||||
tags.appendChild(catBadge);
|
||||
|
||||
const roleBadge = document.createElement('span');
|
||||
roleBadge.className = `anomalous-popover-role is-${card.role}`;
|
||||
roleBadge.textContent = card.role === 'negative'
|
||||
? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative')
|
||||
: (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive');
|
||||
tags.appendChild(roleBadge);
|
||||
header.appendChild(tags);
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'anomalous-popover-title';
|
||||
titleEl.textContent = card.title;
|
||||
header.appendChild(titleEl);
|
||||
|
||||
popover.appendChild(header);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'anomalous-popover-body';
|
||||
const snippet = document.createElement('pre');
|
||||
snippet.className = 'anomalous-popover-snippet';
|
||||
snippet.textContent = card.content;
|
||||
body.appendChild(snippet);
|
||||
popover.appendChild(body);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'anomalous-popover-footer';
|
||||
footer.textContent = window.anomalous_browser_lang === 'zh'
|
||||
? '💡 点击直接添加 · 拖拽自由调序'
|
||||
: '💡 Click to add · Drag to assemble';
|
||||
popover.appendChild(footer);
|
||||
|
||||
document.body.appendChild(popover);
|
||||
activeCardPreviewPopover = popover;
|
||||
|
||||
// Smart Positioning
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true;
|
||||
const popoverHeight = popover.offsetHeight || 160;
|
||||
const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6));
|
||||
popover.style.top = `${top}px`;
|
||||
|
||||
if (isDockLeft) {
|
||||
popover.style.left = `${rect.right + 10}px`;
|
||||
} else {
|
||||
popover.style.left = `${Math.max(12, rect.left - 320)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSourceCardsList() {
|
||||
hideCardPreviewPopover();
|
||||
sourceCardsList.replaceChildren();
|
||||
sourceCardsList.onscroll = () => hideCardPreviewPopover();
|
||||
|
||||
const filtered = sourceCards.filter(card => {
|
||||
if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false;
|
||||
if (sourceFilterKeyword) {
|
||||
const matchTitle = card.title.toLowerCase().includes(sourceFilterKeyword);
|
||||
const matchContent = card.content.toLowerCase().includes(sourceFilterKeyword);
|
||||
if (!matchTitle && !matchContent) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
leftCounter.textContent = `(${filtered.length})`;
|
||||
|
||||
if (!filtered.length) {
|
||||
const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty');
|
||||
empty.innerHTML = `
|
||||
<div style="font-size: 24px; margin-bottom: 6px;">🔍</div>
|
||||
<div>${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(card => {
|
||||
const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject;
|
||||
const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category}`);
|
||||
cardEl.setAttribute('draggable', 'true');
|
||||
|
||||
// Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip)
|
||||
cardEl.onmouseenter = () => {
|
||||
showCardPreviewPopover(card, cardEl);
|
||||
};
|
||||
cardEl.onmouseleave = () => {
|
||||
hideCardPreviewPopover();
|
||||
};
|
||||
|
||||
// Drag Start
|
||||
cardEl.ondragstart = (e) => {
|
||||
hideCardPreviewPopover();
|
||||
const payload = {
|
||||
title: card.title,
|
||||
content: card.content,
|
||||
role: card.role,
|
||||
category: card.category,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.setData('text/plain', card.content);
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
cardEl.classList.add('is-dragging-source');
|
||||
workbenchGrid.classList.add('is-source-dragging');
|
||||
};
|
||||
|
||||
cardEl.ondragend = () => {
|
||||
cardEl.classList.remove('is-dragging-source');
|
||||
workbenchGrid.classList.remove('is-source-dragging');
|
||||
};
|
||||
|
||||
const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot');
|
||||
dot.style.backgroundColor = catMeta.color;
|
||||
|
||||
const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name');
|
||||
|
||||
const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon');
|
||||
|
||||
cardEl.onclick = () => {
|
||||
hideCardPreviewPopover();
|
||||
addSourceCardToMixer(card);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renderNewCardFormUI();
|
||||
renderSourceCardsList();
|
||||
void syncMaterialsIntoSourceDeck(false);
|
||||
return { extractSelected: extractPromptsFromSelectedNode, refresh: renderSourceCardsList, sync: syncMaterialsIntoSourceDeck };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function showWorkbenchToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'anomalous-mixer-toast';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.classList.add('is-show'), 10);
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('is-show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2400);
|
||||
}
|
||||
@@ -8,9 +8,10 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translatePromptText, splitPromptTags, normalizePromptFormatting, hasChinese } from './translation_service.js';
|
||||
import { selectedMaterialNode, promptWidgetTargets, applyNodeMaterialValues } from './node_material_actions.js';
|
||||
import { createViewScope, bindDrawerResize } from './ui_lifecycle.js';
|
||||
import { appendPromptToStudio } from './ui_prompt_composer.js';
|
||||
|
||||
let activeTranslatorModal = null;
|
||||
let activeTranslator = null;
|
||||
|
||||
function t(zh, en) {
|
||||
return window.anomalous_browser_lang === 'zh' ? zh : en;
|
||||
@@ -96,15 +97,17 @@ function writeToSelectedNode(text) {
|
||||
* @param {Object} owner - AnomalousBrowser instance
|
||||
*/
|
||||
export function openPromptTranslator(owner) {
|
||||
if (activeTranslatorModal) {
|
||||
activeTranslatorModal.remove();
|
||||
activeTranslatorModal = null;
|
||||
}
|
||||
activeTranslator?.dispose();
|
||||
const scope = createViewScope();
|
||||
activeTranslator = scope;
|
||||
|
||||
// Modal Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'anomalous-translator-overlay';
|
||||
activeTranslatorModal = overlay;
|
||||
scope.onDispose(() => {
|
||||
overlay.remove();
|
||||
if (activeTranslator === scope) activeTranslator = null;
|
||||
});
|
||||
|
||||
// Modal Window
|
||||
const modal = document.createElement('div');
|
||||
@@ -125,48 +128,14 @@ export function openPromptTranslator(owner) {
|
||||
resizeHandle.className = 'anomalous-translator-resize-handle';
|
||||
resizeHandle.title = t('拖动调整侧边栏宽度,双击恢复默认', 'Drag to resize sidebar, double-click to reset');
|
||||
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 380;
|
||||
let currentSide = localStorage.getItem('anomalous_translator_dock_side') || 'right';
|
||||
|
||||
resizeHandle.onmousedown = (e) => {
|
||||
if (!modal.classList.contains('is-sidebar')) return;
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = modal.getBoundingClientRect().width;
|
||||
resizeHandle.classList.add('is-resizing');
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
|
||||
const onMouseMove = (moveEvt) => {
|
||||
if (!isResizing) return;
|
||||
let newWidth;
|
||||
if (currentSide === 'left') {
|
||||
const deltaX = moveEvt.clientX - startX;
|
||||
newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX));
|
||||
} else {
|
||||
const deltaX = startX - moveEvt.clientX;
|
||||
newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX));
|
||||
}
|
||||
modal.style.setProperty('--amb-translator-width', `${newWidth}px`);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isResizing) return;
|
||||
isResizing = false;
|
||||
resizeHandle.classList.remove('is-resizing');
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
const finalWidth = modal.getBoundingClientRect().width;
|
||||
localStorage.setItem('anomalous_translator_width', Math.round(finalWidth));
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
bindDrawerResize(resizeHandle, modal, scope, {
|
||||
side: () => currentSide,
|
||||
enabled: () => modal.classList.contains('is-sidebar'),
|
||||
minWidth: 320,
|
||||
setWidth: width => modal.style.setProperty('--amb-translator-width', `${width}px`),
|
||||
saveWidth: width => localStorage.setItem('anomalous_translator_width', String(Math.round(width))),
|
||||
});
|
||||
|
||||
resizeHandle.ondblclick = () => {
|
||||
if (!modal.classList.contains('is-sidebar')) return;
|
||||
@@ -280,8 +249,7 @@ export function openPromptTranslator(owner) {
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.title = t('关闭 (Esc)', 'Close (Esc)');
|
||||
closeBtn.onclick = () => {
|
||||
overlay.remove();
|
||||
activeTranslatorModal = null;
|
||||
scope.dispose();
|
||||
};
|
||||
headerRight.appendChild(closeBtn);
|
||||
header.appendChild(headerRight);
|
||||
@@ -494,8 +462,7 @@ export function openPromptTranslator(owner) {
|
||||
if (/[,、;;||]/.test(out) && !hasChinese(out)) {
|
||||
out = normalizePromptFormatting(out);
|
||||
}
|
||||
overlay.remove();
|
||||
activeTranslatorModal = null;
|
||||
scope.dispose();
|
||||
if (typeof owner?.openPromptStudio === 'function') {
|
||||
owner.openPromptStudio();
|
||||
}
|
||||
@@ -527,33 +494,40 @@ export function openPromptTranslator(owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasChinese(out)) {
|
||||
out = normalizePromptFormatting(out);
|
||||
targetTextarea.value = out;
|
||||
updateTagChips(out);
|
||||
const res = writeToSelectedNode(out);
|
||||
showTranslatorToast(modal, res.message, !res.success);
|
||||
if (translateAndWriteBtn.disabled || scope.signal.aborted) return;
|
||||
// Capture the exact destination before awaiting a translation.
|
||||
const graph = app.graph;
|
||||
const node = selectedMaterialNode(app);
|
||||
const target = node && promptWidgetTargets(node)[0];
|
||||
if (!target) {
|
||||
showTranslatorToast(modal, t('请先选中可写入的提示词节点', 'Select a writable prompt node first'), true);
|
||||
return;
|
||||
}
|
||||
const widget = node.widgets[target.index];
|
||||
const previousValue = widget.value;
|
||||
|
||||
translateAndWriteBtn.disabled = true;
|
||||
translateAndWriteBtn.innerHTML = `⏳ ${t('反译写入中...', 'Translating & Writing...')}`;
|
||||
|
||||
try {
|
||||
const res = await translatePromptText(out, { targetLang: 'en' });
|
||||
const res = await translatePromptText(out, { targetLang: 'en', signal: scope.signal });
|
||||
if (scope.signal.aborted) return;
|
||||
if (res.ok && res.translated) {
|
||||
const enTags = normalizePromptFormatting(res.translated);
|
||||
const writeRes = writeToSelectedNode(enTags);
|
||||
if (writeRes.success) {
|
||||
showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!'));
|
||||
sourceTextarea.value = enTags;
|
||||
} else {
|
||||
showTranslatorToast(modal, writeRes.message, true);
|
||||
if (app.graph !== graph || selectedMaterialNode(app) !== node ||
|
||||
graph.getNodeById(node.id) !== node || node.widgets[target.index] !== widget ||
|
||||
widget.value !== previousValue || targetTextarea.value.trim() !== out) {
|
||||
showTranslatorToast(modal, t('目标或文本已改变,请重新点击翻译写入', 'Target or text changed. Translate and write again.'), true);
|
||||
return;
|
||||
}
|
||||
applyNodeMaterialValues(app, node, [{ index: target.index, value: enTags }]);
|
||||
showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!'));
|
||||
sourceTextarea.value = enTags;
|
||||
} else {
|
||||
showTranslatorToast(modal, t(`反译失败: ${res.error || '网络错误'}`, `Translation failed`), true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (scope.signal.aborted) return;
|
||||
showTranslatorToast(modal, t(`反译异常: ${err.message}`, `Error: ${err.message}`), true);
|
||||
} finally {
|
||||
translateAndWriteBtn.disabled = false;
|
||||
@@ -597,6 +571,7 @@ export function openPromptTranslator(owner) {
|
||||
|
||||
// Translation Handler
|
||||
async function doTranslate() {
|
||||
if (translateBtn.disabled || scope.signal.aborted) return;
|
||||
const raw = sourceTextarea.value.trim();
|
||||
if (!raw) {
|
||||
sourceTextarea.focus();
|
||||
@@ -604,7 +579,8 @@ export function openPromptTranslator(owner) {
|
||||
}
|
||||
|
||||
const selectedLang = langSelect.value;
|
||||
const options = {};
|
||||
const previousOutput = targetTextarea.value;
|
||||
const options = { signal: scope.signal };
|
||||
if (selectedLang !== 'auto') {
|
||||
options.targetLang = selectedLang;
|
||||
}
|
||||
@@ -614,6 +590,8 @@ export function openPromptTranslator(owner) {
|
||||
|
||||
try {
|
||||
const res = await translatePromptText(raw, options);
|
||||
if (scope.signal.aborted || sourceTextarea.value.trim() !== raw ||
|
||||
langSelect.value !== selectedLang || targetTextarea.value !== previousOutput) return;
|
||||
if (res.ok && res.translated) {
|
||||
targetTextarea.value = res.translated;
|
||||
updateTagChips(res.translated);
|
||||
@@ -622,6 +600,7 @@ export function openPromptTranslator(owner) {
|
||||
showTranslatorToast(modal, t(`翻译失败: ${res.error || '网络错误'}`, `Failed: ${res.error || 'Network error'}`), true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (scope.signal.aborted) return;
|
||||
showTranslatorToast(modal, t(`翻译异常: ${err.message}`, `Error: ${err.message}`), true);
|
||||
} finally {
|
||||
translateBtn.disabled = false;
|
||||
@@ -649,20 +628,17 @@ export function openPromptTranslator(owner) {
|
||||
// Close on clicking backdrop (only active in centered modal mode)
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay && !overlay.classList.contains('is-sidebar')) {
|
||||
overlay.remove();
|
||||
activeTranslatorModal = null;
|
||||
scope.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
// Close on Escape key
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
activeTranslatorModal = null;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
scope.dispose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
scope.listen(document, 'keydown', onKeyDown);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
sourceTextarea.focus();
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import { joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, workbenchDraftToSavedPlan } from './prompt_composition.js';
|
||||
import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
|
||||
import { showMaterialApplication } from './ui_material_application.js';
|
||||
import { showMaterialSaved } from './material_feedback.js';
|
||||
import { translatePromptText } from './translation_service.js';
|
||||
import { CATEGORY_META, newDraft, normalizeBlock, syncDraftSynthesizedText } from './prompt_studio_data.js';
|
||||
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
||||
import { openPromptInspectorModal } from './ui_prompt_inspector.js';
|
||||
import { createPromptSourceDeck } from './ui_prompt_source_deck.js';
|
||||
|
||||
export function createPromptWorkbench(owner, container, scope, options) {
|
||||
const view = text(container, 'section', '', 'anomalous-prompt-composer anomalous-prompt-workbench is-side-studio');
|
||||
|
||||
let draft = owner.promptPlanDraft ||= newDraft();
|
||||
draft.plan ||= { version: 2, parts: [], positive: '', negative: '' };
|
||||
draft.plan.parts ||= [];
|
||||
|
||||
// Migrate existing text if parts are empty
|
||||
if (!draft.plan.parts.length) {
|
||||
if (draft.plan.positive?.trim()) {
|
||||
draft.plan.parts.push(normalizeBlock({
|
||||
title: window.anomalous_browser_lang === 'zh' ? '基础正向词' : 'Positive Base',
|
||||
content: draft.plan.positive,
|
||||
role: 'positive',
|
||||
category: categorizePromptSnippet(draft.plan.positive),
|
||||
}, 0));
|
||||
}
|
||||
if (draft.plan.negative?.trim()) {
|
||||
draft.plan.parts.push(normalizeBlock({
|
||||
title: window.anomalous_browser_lang === 'zh' ? '通用负向过滤' : 'Negative Filter',
|
||||
content: draft.plan.negative,
|
||||
role: 'negative',
|
||||
category: 'base',
|
||||
}, 1));
|
||||
}
|
||||
}
|
||||
syncDraftSynthesizedText(draft);
|
||||
|
||||
let activeTab = 'positive'; // 'positive' | 'negative'
|
||||
let draggedBlockId = null;
|
||||
|
||||
// Local in-memory source prompt cards (merged starters + materials + user custom)
|
||||
|
||||
// 1. Studio Topbar (Streamlined with inline preset name input)
|
||||
const topbar = text(view, 'header', '', 'anomalous-prompt-topbar');
|
||||
const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left');
|
||||
text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词工坊' : '🎛️ Prompt Studio');
|
||||
|
||||
// Inline Preset Name Input
|
||||
const nameInput = text(topLeft, 'input', '', 'anomalous-prompt-name-input');
|
||||
nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称...' : 'Preset name...';
|
||||
nameInput.title = window.anomalous_browser_lang === 'zh' ? '输入组合方案名称' : 'Enter preset name';
|
||||
nameInput.maxLength = 120;
|
||||
nameInput.value = draft.name || '';
|
||||
nameInput.oninput = () => { draft.name = nameInput.value; };
|
||||
|
||||
const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions');
|
||||
|
||||
// Quick Extract from Selected Canvas Node
|
||||
const extractQuickBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
extractQuickBtn.innerHTML = '🎯';
|
||||
extractQuickBtn.title = window.anomalous_browser_lang === 'zh'
|
||||
? '从画布选中节点一键吸取提示词'
|
||||
: 'Extract prompt from selected node';
|
||||
extractQuickBtn.onclick = () => sourceDeck.extractSelected(true);
|
||||
|
||||
// Dock Side Switch (Left / Right)
|
||||
if (typeof options.onToggleDockSide === 'function') {
|
||||
const dockSideBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
const isLeft = () => container?.classList.contains('is-dock-left');
|
||||
const updateDockBtn = () => {
|
||||
dockSideBtn.innerHTML = isLeft()
|
||||
? `<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>`
|
||||
: `<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>`;
|
||||
dockSideBtn.title = isLeft()
|
||||
? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right')
|
||||
: (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left');
|
||||
};
|
||||
updateDockBtn();
|
||||
dockSideBtn.onclick = () => {
|
||||
options.onToggleDockSide();
|
||||
updateDockBtn();
|
||||
};
|
||||
}
|
||||
|
||||
// "More" Dropdown Menu (holds Save, New Draft, Tags, Export, Material Sync)
|
||||
const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap');
|
||||
moreWrap.style.position = 'relative';
|
||||
moreWrap.style.display = 'inline-flex';
|
||||
const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu');
|
||||
moreMenu.style.display = 'none';
|
||||
moreMenu.style.position = 'absolute';
|
||||
moreMenu.style.top = 'calc(100% + 4px)';
|
||||
moreMenu.style.right = '0';
|
||||
moreMenu.style.zIndex = '1000';
|
||||
moreMenu.style.minWidth = '150px';
|
||||
moreMenu.style.background = 'var(--amb-bg-panel, #1C1E24)';
|
||||
moreMenu.style.border = '1px solid var(--amb-border, rgba(255, 255, 255, 0.14))';
|
||||
moreMenu.style.borderRadius = 'var(--amb-radius-control, 6px)';
|
||||
moreMenu.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.6)';
|
||||
moreMenu.style.padding = '4px';
|
||||
|
||||
const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
saveBtn.style.width = '100%';
|
||||
saveBtn.style.justifyContent = 'flex-start';
|
||||
saveBtn.style.border = 'none';
|
||||
saveBtn.title = t('promptSavePlan') || '保存方案至素材库';
|
||||
|
||||
const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
newBtn.style.width = '100%';
|
||||
newBtn.style.justifyContent = 'flex-start';
|
||||
newBtn.style.border = 'none';
|
||||
|
||||
const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
tagsBtn.style.width = '100%';
|
||||
tagsBtn.style.justifyContent = 'flex-start';
|
||||
tagsBtn.style.border = 'none';
|
||||
tagsBtn.onclick = () => {
|
||||
closeMoreMenu();
|
||||
const current = (draft.tags || []).join(', ');
|
||||
const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current);
|
||||
if (val !== null) {
|
||||
draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean);
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`);
|
||||
}
|
||||
};
|
||||
|
||||
const exportBtn = text(moreMenu, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
exportBtn.style.width = '100%';
|
||||
exportBtn.style.justifyContent = 'flex-start';
|
||||
exportBtn.style.border = 'none';
|
||||
|
||||
const syncLibBtn = text(moreMenu, 'button', `📥 ${window.anomalous_browser_lang === 'zh' ? '同步素材库' : 'Sync Library'}`, 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
syncLibBtn.style.width = '100%';
|
||||
syncLibBtn.style.justifyContent = 'flex-start';
|
||||
syncLibBtn.style.border = 'none';
|
||||
syncLibBtn.onclick = () => {
|
||||
closeMoreMenu();
|
||||
return sourceDeck.sync();
|
||||
};
|
||||
|
||||
moreBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const isHidden = moreMenu.style.display === 'none';
|
||||
moreMenu.style.display = isHidden ? 'block' : 'none';
|
||||
};
|
||||
const closeMoreMenu = () => { moreMenu.style.display = 'none'; };
|
||||
scope.listen(document, 'click', closeMoreMenu);
|
||||
|
||||
const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn');
|
||||
closeBtn.title = t('close') || '关闭';
|
||||
closeBtn.onclick = () => {
|
||||
options.onClose();
|
||||
};
|
||||
|
||||
// Insertion preference lives with the other secondary actions.
|
||||
const posWrap = document.createElement('label');
|
||||
posWrap.className = 'anomalous-prompt-insert-pos';
|
||||
const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select');
|
||||
for (const value of ['after', 'before']) {
|
||||
text(posSelect, 'option', t(`promptInsert_${value}`)).value = value;
|
||||
}
|
||||
posSelect.value = owner.promptInsertPosition || 'after';
|
||||
posSelect.setAttribute('aria-label', t('promptInsertPosition'));
|
||||
moreMenu.appendChild(posWrap);
|
||||
posSelect.onchange = () => {
|
||||
owner.promptInsertPosition = posSelect.value;
|
||||
|
||||
};
|
||||
|
||||
// Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode)
|
||||
const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid');
|
||||
|
||||
// =========================================================================
|
||||
// LEFT COLUMN: Ready-to-use Prompt Cards (词卡库)
|
||||
// =========================================================================
|
||||
const sourceDeck = createPromptSourceDeck(workbenchGrid, container, scope, addSourceCardToMixer);
|
||||
|
||||
// =========================================================================
|
||||
// RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台)
|
||||
// =========================================================================
|
||||
const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel');
|
||||
const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header');
|
||||
|
||||
// Role switch tabs (Positive / Negative)
|
||||
const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs');
|
||||
|
||||
const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions');
|
||||
|
||||
// Right quick extract: suck into right mixer directly
|
||||
const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
rightSuckNodeBtn.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>${t('promptReadSelectedNode')}`;
|
||||
rightSuckNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '直接将画布选中节点的提示词作为积木吸入当前拼装台' : 'Extract node prompt directly into current mixer track';
|
||||
|
||||
const smartSortBtn = text(rightActions, 'button', '', 'anomalous-mixer-smart-sort-btn');
|
||||
smartSortBtn.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>${t('promptSortByCategory')}`;
|
||||
smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]';
|
||||
|
||||
const clearRightBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost');
|
||||
clearRightBtn.innerHTML = '<svg style="width:13px;height:13px;vertical-align:middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track';
|
||||
|
||||
// Blocks Container & Dropzone
|
||||
const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track');
|
||||
|
||||
blocksContainer.ondragover = (e) => {
|
||||
e.preventDefault();
|
||||
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
|
||||
if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) {
|
||||
snapDock.classList.add('is-drag-over');
|
||||
blocksContainer.classList.add('is-drag-over-end');
|
||||
}
|
||||
};
|
||||
blocksContainer.ondragleave = (e) => {
|
||||
if (!blocksContainer.contains(e.relatedTarget)) {
|
||||
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
|
||||
snapDock?.classList.remove('is-drag-over');
|
||||
blocksContainer.classList.remove('is-drag-over-end');
|
||||
}
|
||||
};
|
||||
blocksContainer.ondrop = (e) => {
|
||||
if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) {
|
||||
e.preventDefault();
|
||||
const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock');
|
||||
snapDock?.classList.remove('is-drag-over');
|
||||
blocksContainer.classList.remove('is-drag-over-end');
|
||||
const jsonStr = e.dataTransfer.getData('application/json');
|
||||
if (jsonStr) {
|
||||
try {
|
||||
addSourceCardToMixer(JSON.parse(jsonStr));
|
||||
return;
|
||||
} catch (err) {}
|
||||
}
|
||||
if (draggedBlockId) {
|
||||
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
|
||||
if (fromIndex >= 0) {
|
||||
const [moved] = draft.plan.parts.splice(fromIndex, 1);
|
||||
draft.plan.parts.push(moved);
|
||||
syncDraftSynthesizedText(draft);
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Node Direct Write Handler
|
||||
// -------------------------------------------------------------------------
|
||||
const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => {
|
||||
const node = selectedMaterialNode(app);
|
||||
if (!node) {
|
||||
anomalousAlert(window.anomalous_browser_lang === 'zh'
|
||||
? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!'
|
||||
: '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!');
|
||||
return;
|
||||
}
|
||||
const targets = promptWidgetTargets(node);
|
||||
if (!targets.length) {
|
||||
anomalousAlert(window.anomalous_browser_lang === 'zh'
|
||||
? '⚠️ 选中的节点中未找到可写入的提示词文本输入!'
|
||||
: '⚠️ No writable prompt text widget found in selected node!');
|
||||
return;
|
||||
}
|
||||
const widgetIndex = targets[0].index;
|
||||
try {
|
||||
const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value);
|
||||
showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node);
|
||||
if (triggerBtn) {
|
||||
const orig = triggerBtn.textContent;
|
||||
triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written');
|
||||
setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200);
|
||||
} else {
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Floating Action Dock (Minimalist & Slides Up Only When Content Exists)
|
||||
// -------------------------------------------------------------------------
|
||||
const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock');
|
||||
floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added
|
||||
|
||||
const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left');
|
||||
const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text');
|
||||
|
||||
const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right');
|
||||
|
||||
// 1. Write Node Button
|
||||
const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm');
|
||||
writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node';
|
||||
writeNodeBtn.onclick = async () => {
|
||||
syncDraftSynthesizedText(draft);
|
||||
const compiledText = (draft.plan[activeTab] || '').trim();
|
||||
if (!compiledText) return;
|
||||
await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn);
|
||||
};
|
||||
|
||||
// 2. Quick Copy Button
|
||||
const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text';
|
||||
// 3. Popout Full Inspector Modal Button
|
||||
let closeInspector = null;
|
||||
scope.onDispose(() => closeInspector?.());
|
||||
const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm');
|
||||
popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal';
|
||||
popoutBtn.onclick = () => {
|
||||
closeInspector?.();
|
||||
closeInspector = openPromptInspectorModal(draft, activeTab, async (role, textVal) => {
|
||||
return applyPromptToCurrentNode(role, textVal);
|
||||
}, () => {
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
rightSuckNodeBtn.onclick = () => sourceDeck.extractSelected(true);
|
||||
|
||||
function updateRightTabsUI() {
|
||||
rightTabs.replaceChildren();
|
||||
const posCount = draft.plan.parts.filter(p => p.role === 'positive').length;
|
||||
const negCount = draft.plan.parts.filter(p => p.role === 'negative').length;
|
||||
|
||||
const tabs = [
|
||||
{ key: 'positive', label: `${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})` },
|
||||
{ key: 'negative', label: `${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})` },
|
||||
];
|
||||
|
||||
for (const tab of tabs) {
|
||||
const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`);
|
||||
btn.onclick = () => {
|
||||
activeTab = tab.key;
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function addSourceCardToMixer(cardData, targetIndex = null) {
|
||||
const cardRole = cardData.role || 'positive';
|
||||
if (activeTab !== cardRole) {
|
||||
activeTab = cardRole;
|
||||
updateRightTabsUI();
|
||||
}
|
||||
|
||||
const newBlock = normalizeBlock({
|
||||
title: cardData.title,
|
||||
content: cardData.content,
|
||||
role: cardRole,
|
||||
category: cardData.category,
|
||||
enabled: true,
|
||||
}, draft.plan.parts.length);
|
||||
newBlock._justAdded = true;
|
||||
|
||||
if (targetIndex !== null && targetIndex >= 0) {
|
||||
draft.plan.parts.splice(targetIndex, 0, newBlock);
|
||||
} else {
|
||||
draft.plan.parts.push(newBlock);
|
||||
}
|
||||
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
|
||||
|
||||
const count = draft.plan.parts.filter(p => p.role === activeTab).length;
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh'
|
||||
? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台(共 ${count} 块)`
|
||||
: `Added to ${activeTab} track (${count} blocks)`);
|
||||
}
|
||||
|
||||
function renderBlocksList() {
|
||||
blocksContainer.replaceChildren();
|
||||
const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab);
|
||||
|
||||
if (!currentRoleParts.length) {
|
||||
const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone');
|
||||
dropzoneNotice.innerHTML = `
|
||||
<div style="margin-bottom: 12px; opacity: 0.65;">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="font-size: 0.88rem; font-weight: 600; color: #f1f5f9;">${window.anomalous_browser_lang === 'zh' ? '点击词卡或拖拽到这里拼装' : 'Click cards or drag here to assemble'}</div>
|
||||
<div style="font-size: 0.74rem; color: #64748b; margin-top: 5px;">${window.anomalous_browser_lang === 'zh' ? '支持自由拖拽调换次序,点击【智能排序】一键理顺' : 'Reorder freely anytime, or click Smart Sort.'}</div>
|
||||
`;
|
||||
// Accept drops on empty dropzone
|
||||
setupDropzoneListeners(dropzoneNotice);
|
||||
return;
|
||||
}
|
||||
|
||||
currentRoleParts.forEach((block, index) => {
|
||||
const isJustAdded = !!block._justAdded;
|
||||
if (isJustAdded) delete block._justAdded;
|
||||
|
||||
const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.role}${isJustAdded ? ' is-just-added' : ''}`);
|
||||
blockEl.setAttribute('draggable', 'true');
|
||||
blockEl.dataset ||= {};
|
||||
blockEl.dataset.blockId = block.id;
|
||||
|
||||
if (isJustAdded) {
|
||||
requestAnimationFrame(() => {
|
||||
blockEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
// Reorder Drag Listeners
|
||||
blockEl.ondragstart = (e) => {
|
||||
draggedBlockId = block.id;
|
||||
blockEl.classList.add('is-dragging');
|
||||
e.dataTransfer.setData('text/plain', block.content);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
blockEl.ondragend = () => {
|
||||
draggedBlockId = null;
|
||||
blockEl.classList.remove('is-dragging');
|
||||
blocksContainer.querySelectorAll('.anomalous-mixer-block').forEach(el => {
|
||||
el.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
|
||||
});
|
||||
};
|
||||
|
||||
blockEl.ondragover = (e) => {
|
||||
e.preventDefault();
|
||||
const rect = blockEl.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (e.clientY < mid) {
|
||||
blockEl.classList.add('is-drag-over-top');
|
||||
blockEl.classList.remove('is-drag-over-bottom');
|
||||
} else {
|
||||
blockEl.classList.add('is-drag-over-bottom');
|
||||
blockEl.classList.remove('is-drag-over-top');
|
||||
}
|
||||
};
|
||||
|
||||
blockEl.ondragleave = () => {
|
||||
blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
|
||||
};
|
||||
|
||||
blockEl.ondrop = (e) => {
|
||||
e.preventDefault();
|
||||
blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom');
|
||||
|
||||
// Case 1: Drop from Left Source Card
|
||||
const jsonStr = e.dataTransfer.getData('application/json');
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const toIndex = draft.plan.parts.findIndex(p => p.id === block.id);
|
||||
const insertAfter = e.clientY >= (blockEl.getBoundingClientRect().top + blockEl.offsetHeight / 2);
|
||||
addSourceCardToMixer(parsed, insertAfter ? toIndex + 1 : toIndex);
|
||||
return;
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// Case 2: Reorder inside Right Track
|
||||
if (!draggedBlockId || draggedBlockId === block.id) return;
|
||||
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
|
||||
if (fromIndex < 0) return;
|
||||
|
||||
const [moved] = draft.plan.parts.splice(fromIndex, 1);
|
||||
const currentTargetIndex = draft.plan.parts.findIndex(p => p.id === block.id);
|
||||
if (currentTargetIndex < 0) {
|
||||
draft.plan.parts.push(moved);
|
||||
} else {
|
||||
const rect = blockEl.getBoundingClientRect();
|
||||
const insertAfter = e.clientY >= (rect.top + rect.height / 2);
|
||||
const finalIndex = insertAfter ? currentTargetIndex + 1 : currentTargetIndex;
|
||||
draft.plan.parts.splice(finalIndex, 0, moved);
|
||||
}
|
||||
|
||||
syncDraftSynthesizedText(draft);
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
};
|
||||
|
||||
// Header
|
||||
const blockHeader = text(blockEl, 'div', '', 'anomalous-mixer-block-header');
|
||||
const headerLeft = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-left');
|
||||
|
||||
const dragHandle = text(headerLeft, 'span', '⠿', 'anomalous-mixer-drag-handle');
|
||||
dragHandle.title = window.anomalous_browser_lang === 'zh' ? '抓取按住上下拖拽排序' : 'Drag to reorder';
|
||||
|
||||
// A/B Bypass Checkbox
|
||||
const toggleWrap = text(headerLeft, 'label', '', 'anomalous-mixer-block-toggle');
|
||||
const checkbox = text(toggleWrap, 'input', '');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = !!block.enabled;
|
||||
checkbox.title = window.anomalous_browser_lang === 'zh' ? '勾选参与拼装,取消勾选即旁路跳过(做A/B对比)' : 'Include or bypass in assembly';
|
||||
checkbox.onchange = () => {
|
||||
block.enabled = checkbox.checked;
|
||||
blockEl.classList.toggle('is-bypassed', !block.enabled);
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateOutputPreview();
|
||||
};
|
||||
|
||||
// Category Badge
|
||||
const catMeta = CATEGORY_META[block.category] || CATEGORY_META.subject;
|
||||
const catBadge = text(headerLeft, 'span', window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en, 'anomalous-mixer-cat-badge');
|
||||
catBadge.style.color = catMeta.color;
|
||||
catBadge.style.backgroundColor = catMeta.bg;
|
||||
catBadge.style.borderColor = catMeta.border;
|
||||
catBadge.title = window.anomalous_browser_lang === 'zh' ? '点击切换词性分类' : 'Cycle category';
|
||||
catBadge.onclick = () => {
|
||||
const cats = ['base', 'style', 'subject', 'trigger'];
|
||||
const nextIdx = (cats.indexOf(block.category) + 1) % cats.length;
|
||||
block.category = cats[nextIdx];
|
||||
renderBlocksList();
|
||||
};
|
||||
|
||||
// Editable title
|
||||
const titleInput = text(headerLeft, 'input', '', 'anomalous-mixer-block-title-input');
|
||||
titleInput.value = block.title || '';
|
||||
titleInput.oninput = () => { block.title = titleInput.value; };
|
||||
|
||||
// Weight Adjustment Pill with Mouse Wheel & Click
|
||||
let currentWeight = block.weight !== undefined ? block.weight : 1.0;
|
||||
const weightPill = text(headerLeft, 'span', '', 'anomalous-block-weight-pill');
|
||||
weightPill.title = window.anomalous_browser_lang === 'zh'
|
||||
? '滚轮上下滑动调节权重,或点击加减'
|
||||
: 'Scroll wheel or click +/- to adjust weight';
|
||||
|
||||
const decBtn = text(weightPill, 'button', '-', 'anomalous-block-weight-btn');
|
||||
const weightVal = text(weightPill, 'span', currentWeight.toFixed(2));
|
||||
const incBtn = text(weightPill, 'button', '+', 'anomalous-block-weight-btn');
|
||||
|
||||
const applyBlockWeight = (newW) => {
|
||||
newW = Math.round(Math.max(0.1, Math.min(3.0, newW)) * 100) / 100;
|
||||
currentWeight = newW;
|
||||
block.weight = newW;
|
||||
weightVal.textContent = newW.toFixed(2);
|
||||
let clean = block.content.replace(/^\((.+):[0-9.]+\)$/, '$1').replace(/^\((.+)\)$/, '$1').trim();
|
||||
if (newW !== 1.0) {
|
||||
block.content = `(${clean}:${newW.toFixed(2)})`;
|
||||
} else {
|
||||
block.content = clean;
|
||||
}
|
||||
const txtArea = blockEl.querySelector('.anomalous-mixer-block-textarea');
|
||||
if (txtArea) txtArea.value = block.content;
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateOutputPreview();
|
||||
};
|
||||
|
||||
decBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight - 0.05); };
|
||||
incBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight + 0.05); };
|
||||
|
||||
weightPill.onwheel = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const delta = e.deltaY < 0 ? 0.05 : -0.05;
|
||||
applyBlockWeight(currentWeight + delta);
|
||||
};
|
||||
|
||||
// Right Action micro buttons
|
||||
const headerRight = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-right');
|
||||
|
||||
const transBtn = text(headerRight, 'button', '🌐', 'anomalous-mixer-block-btn is-translate');
|
||||
transBtn.title = window.anomalous_browser_lang === 'zh' ? '一键翻译此块提示词' : 'Translate this block';
|
||||
transBtn.onclick = async () => {
|
||||
const raw = block.content || '';
|
||||
if (!raw.trim()) return;
|
||||
const orig = transBtn.textContent;
|
||||
transBtn.textContent = '⏳';
|
||||
try {
|
||||
const res = await translatePromptText(raw, { signal: scope.signal });
|
||||
if (scope.signal.aborted || !draft.plan.parts.includes(block) || block.content !== raw) return;
|
||||
if (res.ok && res.translated) {
|
||||
block.content = res.translated;
|
||||
textarea.value = res.translated;
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateOutputPreview();
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 词块已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Block translated to ${res.targetLang}`);
|
||||
} else {
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`);
|
||||
}
|
||||
} finally {
|
||||
transBtn.textContent = orig;
|
||||
}
|
||||
};
|
||||
|
||||
const upBtn = text(headerRight, 'button', '▲', 'anomalous-mixer-block-btn');
|
||||
upBtn.title = window.anomalous_browser_lang === 'zh' ? '上移' : 'Move up';
|
||||
upBtn.disabled = index === 0;
|
||||
upBtn.onclick = () => {
|
||||
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
|
||||
if (realIdx < 0) return;
|
||||
const myRole = block.role || activeTab;
|
||||
let prevIdx = -1;
|
||||
for (let i = realIdx - 1; i >= 0; i--) {
|
||||
if ((draft.plan.parts[i].role || 'positive') === myRole) {
|
||||
prevIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prevIdx >= 0) {
|
||||
const temp = draft.plan.parts[realIdx];
|
||||
draft.plan.parts[realIdx] = draft.plan.parts[prevIdx];
|
||||
draft.plan.parts[prevIdx] = temp;
|
||||
syncDraftSynthesizedText(draft);
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const downBtn = text(headerRight, 'button', '▼', 'anomalous-mixer-block-btn');
|
||||
downBtn.title = window.anomalous_browser_lang === 'zh' ? '下移' : 'Move down';
|
||||
downBtn.disabled = index === currentRoleParts.length - 1;
|
||||
downBtn.onclick = () => {
|
||||
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
|
||||
if (realIdx < 0) return;
|
||||
const myRole = block.role || activeTab;
|
||||
let nextIdx = -1;
|
||||
for (let i = realIdx + 1; i < draft.plan.parts.length; i++) {
|
||||
if ((draft.plan.parts[i].role || 'positive') === myRole) {
|
||||
nextIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextIdx >= 0) {
|
||||
const temp = draft.plan.parts[realIdx];
|
||||
draft.plan.parts[realIdx] = draft.plan.parts[nextIdx];
|
||||
draft.plan.parts[nextIdx] = temp;
|
||||
syncDraftSynthesizedText(draft);
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const delBtn = text(headerRight, 'button', '✕', 'anomalous-mixer-block-btn is-delete');
|
||||
delBtn.title = window.anomalous_browser_lang === 'zh' ? '移除此块' : 'Remove block';
|
||||
delBtn.onclick = () => {
|
||||
const realIdx = draft.plan.parts.findIndex(p => p.id === block.id);
|
||||
if (realIdx >= 0) {
|
||||
draft.plan.parts.splice(realIdx, 1);
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
}
|
||||
};
|
||||
|
||||
// Body
|
||||
const blockBody = text(blockEl, 'div', '', 'anomalous-mixer-block-body');
|
||||
const textarea = text(blockBody, 'textarea', '', 'anomalous-mixer-block-textarea');
|
||||
textarea.value = block.content || '';
|
||||
textarea.rows = 1;
|
||||
textarea.oninput = () => {
|
||||
block.content = textarea.value;
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateOutputPreview();
|
||||
};
|
||||
});
|
||||
|
||||
// Interactive Auto-snap Dock at end of track
|
||||
const snapDock = text(blocksContainer, 'div', '', 'anomalous-assembly-snap-dock');
|
||||
snapDock.innerHTML = `
|
||||
<div class="anomalous-snap-dock-content">
|
||||
<svg class="anomalous-snap-icon" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<span class="anomalous-snap-dock-text">${window.anomalous_browser_lang === 'zh' ? '拖拽卡片至此自动贴合至末尾' : 'Drop card here to snap to bottom'}</span>
|
||||
</div>
|
||||
`;
|
||||
setupDropzoneListeners(snapDock);
|
||||
snapDock.onclick = () => {
|
||||
leftSearch?.focus?.();
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '点击左侧词卡即可直接加入此处' : 'Click any card on left to add here');
|
||||
};
|
||||
}
|
||||
|
||||
function setupDropzoneListeners(el) {
|
||||
el.ondragover = (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.add('is-drag-over');
|
||||
};
|
||||
el.ondragleave = () => {
|
||||
el.classList.remove('is-drag-over');
|
||||
};
|
||||
el.ondrop = (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.remove('is-drag-over');
|
||||
const jsonStr = e.dataTransfer.getData('application/json');
|
||||
if (jsonStr) {
|
||||
try {
|
||||
addSourceCardToMixer(JSON.parse(jsonStr));
|
||||
return;
|
||||
} catch (err) {}
|
||||
}
|
||||
if (draggedBlockId) {
|
||||
const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId);
|
||||
if (fromIndex >= 0) {
|
||||
const [moved] = draft.plan.parts.splice(fromIndex, 1);
|
||||
draft.plan.parts.push(moved);
|
||||
syncDraftSynthesizedText(draft);
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateOutputPreview() {
|
||||
syncDraftSynthesizedText(draft);
|
||||
const compiledText = (draft.plan[activeTab] || '').trim();
|
||||
const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab);
|
||||
|
||||
if (!currentRoleParts.length && !compiledText) {
|
||||
floatingDock.style.display = 'none';
|
||||
} else {
|
||||
floatingDock.style.display = 'flex';
|
||||
const words = compiledText.split(/[,,\s\n]+/).filter(Boolean).length;
|
||||
dockStats.innerHTML = `${activeTab === 'negative' ? '🌙' : '🔆'} <strong>${currentRoleParts.length}</strong> ${window.anomalous_browser_lang === 'zh' ? '块' : 'blocks'} · ~<strong>${words}</strong> ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`;
|
||||
dockStats.title = activeTab === 'negative'
|
||||
? `${window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`
|
||||
: `${window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart Sort
|
||||
smartSortBtn.onclick = () => {
|
||||
if (!draft.plan.parts.length) return;
|
||||
const roleParts = draft.plan.parts.filter(p => p.role === activeTab);
|
||||
const otherParts = draft.plan.parts.filter(p => p.role !== activeTab);
|
||||
const sorted = smartSortPromptBlocks(roleParts, activeTab);
|
||||
draft.plan.parts = [...sorted, ...otherParts];
|
||||
|
||||
syncDraftSynthesizedText(draft);
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
|
||||
smartSortBtn.classList.add('is-animating');
|
||||
setTimeout(() => smartSortBtn.classList.remove('is-animating'), 500);
|
||||
};
|
||||
|
||||
// Clear track
|
||||
clearRightBtn.onclick = async () => {
|
||||
const msg = window.anomalous_browser_lang === 'zh'
|
||||
? `确定清空当前【${activeTab === 'positive' ? '正向' : '负向'}】拼装池吗?`
|
||||
: `Clear ${activeTab} mixer track?`;
|
||||
if (await anomalousConfirm(msg) && !scope.signal.aborted) {
|
||||
draft.plan.parts = draft.plan.parts.filter(p => p.role !== activeTab);
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
}
|
||||
};
|
||||
|
||||
// Copy preview text
|
||||
copyOutputBtn.onclick = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
const textToCopy = (draft.plan[activeTab] || '').trim();
|
||||
if (!textToCopy) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
copyOutputBtn.textContent = '✅';
|
||||
setTimeout(() => { if (copyOutputBtn.isConnected) copyOutputBtn.textContent = `📋`; }, 1500);
|
||||
} catch (err) {
|
||||
await anomalousAlert(t('materialCopyError'));
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Target Node Direct Injection Toolbar (Compact sticky bar integrated into Action Dock)
|
||||
// -------------------------------------------------------------------------
|
||||
// Save Plan to Material Library
|
||||
saveBtn.onclick = async () => {
|
||||
if (!draft.name?.trim()) {
|
||||
nameInput.focus();
|
||||
nameInput.classList.add('is-invalid');
|
||||
setTimeout(() => nameInput.classList.remove('is-invalid'), 1200);
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!');
|
||||
return;
|
||||
}
|
||||
saveBtn.disabled = true;
|
||||
syncDraftSynthesizedText(draft);
|
||||
const saved = workbenchDraftToSavedPlan(draft);
|
||||
const body = {
|
||||
name: draft.name.trim(),
|
||||
tags: draft.tags || [],
|
||||
plan: saved.plan,
|
||||
};
|
||||
|
||||
const send = () => fetch('/anomalous/save_prompt_plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
try {
|
||||
let response = await send();
|
||||
if (response.status === 409) {
|
||||
const duplicate = await response.json();
|
||||
if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name })) || scope.signal.aborted) return;
|
||||
body.allow_duplicate = true;
|
||||
response = await send();
|
||||
}
|
||||
const payload = await jsonResponse(response, 'prompt save failed');
|
||||
if (payload.status !== 'success') throw new Error('prompt save failed');
|
||||
if (!scope.signal.aborted) showMaterialSaved(owner, payload.material);
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) await anomalousAlert(t('materialSaveError'));
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Export Plan JSON
|
||||
exportBtn.onclick = () => {
|
||||
if (!draft.name?.trim()) {
|
||||
nameInput.focus();
|
||||
nameInput.classList.add('is-invalid');
|
||||
setTimeout(() => nameInput.classList.remove('is-invalid'), 1200);
|
||||
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!');
|
||||
return;
|
||||
}
|
||||
syncDraftSynthesizedText(draft);
|
||||
const saved = workbenchDraftToSavedPlan(draft);
|
||||
const exportData = {
|
||||
format: 'anomalous-prompt-plan-v1',
|
||||
version: 2,
|
||||
name: draft.name,
|
||||
tags: draft.tags,
|
||||
plan: saved.plan,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
if (blob.size > 2 * 1024 * 1024 || draft.tags.length > 20 || draft.tags.some(tag => tag.length > 60)) {
|
||||
return anomalousAlert(t('promptExportError'));
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${draft.name || 'prompt-mixer-preset'}.json`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
// New Draft
|
||||
newBtn.onclick = async () => {
|
||||
if (await anomalousConfirm(t('promptReplaceDraft')) && !scope.signal.aborted) {
|
||||
owner.promptPlanDraft = draft = newDraft();
|
||||
draft.plan.parts.push(normalizeBlock({
|
||||
title: window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt',
|
||||
content: '',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
}, 0));
|
||||
syncDraftSynthesizedText(draft);
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
nameInput.value = '';
|
||||
draft.tags = [];
|
||||
}
|
||||
};
|
||||
|
||||
// Initial render execution
|
||||
sourceDeck.refresh();
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
|
||||
|
||||
const control = {
|
||||
updateAll: () => {
|
||||
updateRightTabsUI();
|
||||
renderBlocksList();
|
||||
updateOutputPreview();
|
||||
sourceDeck.refresh();
|
||||
},
|
||||
addBlock: (blockData) => {
|
||||
addSourceCardToMixer(blockData);
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
}
|
||||
@@ -887,9 +887,7 @@ function buildRecipeStudioTopbar(owner) {
|
||||
}
|
||||
|
||||
export async function showRecipes() {
|
||||
this.closePromptImportDrawer?.();
|
||||
if (this.materialContainer) this.materialContainer.style.display = 'none';
|
||||
if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none';
|
||||
if (this.notebookContainer) this.notebookContainer.style.display = 'none';
|
||||
|
||||
if (this.nbPanel) this.nbPanel.style.display = 'flex';
|
||||
|
||||
+35
-213
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user