diff --git a/README.md b/README.md index 57bd02e..83afb8f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ In the rapidly evolving landscape of AI art tools, legacy plugins have become bl **Anomalous Model Browser** was born out of an extreme pursuit of minimalism and high performance: - **True Zero Dependencies**: We completely abandoned Node.js, Webpack, Vue/React/Svelte, and other heavy frontend tools. The entire extension is written in pure **Native Python + Vanilla JS**! -- **Unmatched Performance**: With a total codebase of under 100KB, it delivers a much smoother experience. No background polling loops, no memory leaks—just plug and play with millisecond response times. +- **Unmatched Performance**: With a total codebase of under 200KB, it delivers a much smoother experience. No background polling loops, no memory leaks—just plug and play with millisecond response times. - **Smart Association & Synchronization**: Say goodbye to complex database building. It smartly and seamlessly associates your `.safetensors` files with their corresponding `.info` descriptions and `.png`/`.mp4` previews purely through file naming. ### ✨ Core Features @@ -56,6 +56,11 @@ In the rapidly evolving landscape of AI art tools, legacy plugins have become bl - **Seamless i18n**: Switch between beautifully formatted pure English or pure Chinese UI with a single click, instantly transforming all buttons, dialogs, and dynamic text. - **Interactive Help Modal**: Never feel lost with the built-in `❓ Help` manual explaining every feature natively within the plugin. The sandwich layout ensures the close button is never pushed off-screen regardless of how long the text is. +7. **📑 Advanced Notebook System** + - **Bilingual Prompt Editor**: A dedicated drafting workspace for your prompts featuring a dual-pane side-by-side translator. It auto-splits your prompts by commas into visual interactive tags with 1-click copying! + - **Smart Architecture Filtering**: Select a Base Model from your local models, and the Notebook automatically curates a pristine, cross-folder gallery of exactly compatible Main Models and Loras. + - **One-Click Deployment**: Compile your entire notebook (Checkpoints, multiple Loras, Prompts) and click 'Send to Canvas' to instantly deploy a fully connected workflow to your ComfyUI workspace. + ### 📥 Installation Say goodbye to `pip install` and `npm install`! @@ -80,7 +85,7 @@ Say goodbye to `pip install` and `npm install`! **Anomalous Model Browser** 诞生于对“极简主义”和“高性能”的极致追求: - **真正的零依赖**:我们彻底抛弃了 Node.js、Webpack、Vue/React 等重型武器。全端采用最纯粹的 **原生 Python + Vanilla JS** 编写! -- **降维打击的性能**:不到 100KB 的总代码量,实现了极度丝滑的体验。没有内存泄漏,即插即用,毫秒级响应。 +- **降维打击的性能**:不到 200KB 的总代码量,实现了极度丝滑的体验。没有内存泄漏,即插即用,毫秒级响应。 - **智能级联关联机制**:不再需要复杂的数据库,直接通过底层文件名映射,无缝关联 `.safetensors` 与其对应的 `.info` 描述及动态预览媒体。 ### ✨ 核心特性 @@ -119,6 +124,11 @@ Say goodbye to `pip install` and `npm install`! - **无缝 i18n 切换**:一键在纯中文与纯英文界面间自由切换,告别拥挤的双语混排,所有弹窗与提示会瞬间自适应目标语言。并针对英文排版做了动态文字缩放,确保界面永不换行崩塌。 - **交互式帮助面板**:内置随叫随到的 `❓ 帮助` 面板,提供中英文纯血的详细说明书,再也不用切回 GitHub 看使用文档了!三明治结构设计保证关闭按钮永远固定在视野内,不会因为文字过多而点不到。 +7. **📑 极客级笔记本 (Notebook) 系统** + - **双语交互式提示词工作台**:内置了极具高级感的双向对照编辑器,利用深色模式和磨砂质感,自动将你的中英文提示词按逗号拆分成一个个悬停交互的高级标签快,指哪亮哪,并支持一键精准复制单个单词! + - **纯净架构雷达筛选**:当你新建一本笔记本并选定底模架构(纯净抓取你本地拥有的架构,绝不硬编码凑数),下方的图库会自动为你过滤出所有适配的主模型与 Lora,告别漫无目的的翻找。 + - **一键爆破式部署**:草稿打完后,点击“发送到画布”,所有的 Checkpoint、Lora 节点甚至连线逻辑都会瞬间部署至你的 ComfyUI 画布,真正的一气呵成! + ### 📥 极简安装说明 告别 `pip install` 和 `npm install`! diff --git a/__init__.py b/__init__.py index adfcf58..7c3ee42 100644 --- a/__init__.py +++ b/__init__.py @@ -1,12 +1,12 @@ -import os -from .api import setup_routes -from server import PromptServer - -WEB_DIRECTORY = "./web" - -# 注册 API 路由 -setup_routes(PromptServer.instance.app) - -NODE_CLASS_MAPPINGS = {} -NODE_DISPLAY_NAME_MAPPINGS = {} -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] +import os +from .api import setup_routes +from server import PromptServer + +WEB_DIRECTORY = "./web" + +# 注册 API 路由 +setup_routes(PromptServer.instance.app) + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] diff --git a/api.py b/api.py index 1373360..ebc186e 100644 --- a/api.py +++ b/api.py @@ -496,10 +496,10 @@ async def api_compatible_models(request): seen_files.add(real_path) meta = get_metadata(file_path) + m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "") + req_bm = str(base_model).strip().lower().replace(" ", "") - m_bm = str(meta.get("baseModel", "")).strip().lower() - req_bm = str(base_model).strip().lower() - if m_bm and m_bm == req_bm: + if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm): rel_subfolder = os.path.relpath(root, base_dir) if rel_subfolder == '.': rel_subfolder = '/' @@ -539,6 +539,145 @@ async def api_compatible_models(request): return web.json_response({"models": compatible_models}) +async def api_get_notebooks(request): + script_dir = os.path.dirname(os.path.abspath(__file__)) + nb_dir = os.path.join(script_dir, "notebooks") + if not os.path.exists(nb_dir): + os.makedirs(nb_dir) + + notebooks = [] + for f in os.listdir(nb_dir): + if f.endswith('.json'): + try: + with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file: + data = json.load(file) + notebooks.append({ + "filename": f, + "name": data.get("name", f.replace('.json', '')), + "data": data + }) + except Exception: + pass + return web.json_response({"notebooks": notebooks}) + +async def api_save_notebook(request): + try: + data = await request.json() + filename = data.get("filename", "") + if not filename or '..' in filename: + return web.json_response({"status": "error", "message": "Invalid filename"}) + if not filename.endswith('.json'): + filename += '.json' + + script_dir = os.path.dirname(os.path.abspath(__file__)) + nb_dir = os.path.join(script_dir, "notebooks") + if not os.path.exists(nb_dir): + os.makedirs(nb_dir) + + file_path = os.path.join(nb_dir, filename) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False) + + return web.json_response({"status": "success"}) + except Exception as e: + return web.json_response({"status": "error", "message": str(e)}) + +async def api_delete_notebook(request): + try: + data = await request.json() + filename = data.get("filename", "") + if not filename or '..' in filename: + return web.json_response({"status": "error", "message": "Invalid filename"}) + + script_dir = os.path.dirname(os.path.abspath(__file__)) + nb_dir = os.path.join(script_dir, "notebooks") + file_path = os.path.join(nb_dir, filename) + + if os.path.exists(file_path): + os.remove(file_path) + return web.json_response({"status": "success"}) + return web.json_response({"status": "error", "message": "File not found"}) + except Exception as e: + return web.json_response({"status": "error", "message": str(e)}) + +async def api_translate(request): + try: + data = await request.json() + text = data.get("text", "") + tl = data.get("target_lang", "zh-CN") + if not text: + return web.json_response({"translated": ""}) + + # Try DeepL if API key exists + script_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(script_dir, "config.json") + deepl_key = "" + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + cfg = json.load(f) + deepl_key = cfg.get("DEEPL_API_KEY", "").strip() + except: + pass + + if deepl_key: + deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" } + d_tl = deepl_map.get(tl, "EN") + url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate" + payload = urllib.parse.urlencode({ + "auth_key": deepl_key, + "text": text, + "target_lang": d_tl + }).encode('utf-8') + req = urllib.request.Request(url, data=payload) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read().decode('utf-8')) + return web.json_response({"translated": result["translations"][0]["text"]}) + else: + # Fallback to Google Translate (free, no key) + url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}" + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read().decode('utf-8')) + # Google Translate returns a list of fragments + translated_text = "".join([part[0] for part in result[0]]) + return web.json_response({"translated": translated_text}) + + except Exception as e: + print(f"Translate Error: {e}") + return web.json_response({"translated": text, "error": str(e)}) + + +async def api_base_models(request): + target_types = ['checkpoints', 'unet', 'diffusion_models', 'loras'] + base_models = set() + seen_files = set() + + for t in target_types: + try: + paths = folder_paths.get_folder_paths(t) + except Exception: + continue + if not paths: continue + + for base_dir in paths: + if not os.path.exists(base_dir): continue + for root, _, files in os.walk(base_dir): + for f in files: + if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'): + file_path = os.path.join(root, f) + real_path = os.path.realpath(file_path) + if real_path in seen_files: continue + seen_files.add(real_path) + + meta = get_metadata(file_path) + m_bm = meta.get("baseModel", "") + if m_bm and str(m_bm).strip(): + # Remove typical generic strings that might pollute + clean_bm = str(m_bm).strip() + base_models.add(clean_bm) + + return web.json_response({"base_models": sorted(list(base_models))}) def setup_routes(app): app.router.add_get('/anomalous/folders', api_get_folders) @@ -551,4 +690,8 @@ def setup_routes(app): app.router.add_post('/anomalous/delete_model', api_delete_model) app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info) app.router.add_get('/anomalous/compatible_models', api_compatible_models) - + app.router.add_get('/anomalous/notebooks', api_get_notebooks) + app.router.add_post('/anomalous/save_notebook', api_save_notebook) + app.router.add_post('/anomalous/delete_notebook', api_delete_notebook) + app.router.add_post('/anomalous/translate', api_translate) + app.router.add_get('/anomalous/base_models', api_base_models) diff --git a/changelog_v1.0.1.md b/changelog_v1.0.1.md new file mode 100644 index 0000000..66d6733 --- /dev/null +++ b/changelog_v1.0.1.md @@ -0,0 +1,22 @@ +# 📈 Changelog v1.0.1 + +## 🌟 New Features +- **Notebook System (`📑 Notebooks`)**: A powerful new drafting space designed for workflow preparation. + - **Bilingual Prompt Editor**: A dual-pane translation workspace (English/Chinese) that supports Google and DeepL translation via local Python backend (caching in `translations.json`). + - **Interactive Tag Engine**: Automatically splits prompts by commas into styled tags. Includes hover-sync between English and Chinese tags, individual tag editing, and 1-click clipboard copying. + - **Dynamic Architecture Filtering**: The Notebook allows selecting a Base Model directly parsed from your local library (e.g., `SDXL`, `SD 1.5`, `Pony`), strictly omitting unowned default models. Selecting an architecture perfectly filters the Main Model and Lora galleries below it. + - **One-Click Deployment**: The **"🚀 Send to Canvas"** button seamlessly injects your configured Checkpoint loader, Lora loaders, and CLIP Text Encode nodes directly onto the ComfyUI canvas, fully wired and ready to go. + +## 🛠️ Enhancements & UX Tweaks +- **Zero-Wait Delete Actions**: The "Delete Notebook" interaction has been overhauled. Instead of a mandatory 3-second wait, it now transforms into an explicit `[⚠️ Sure?]` and `[✕]` cancellation button combo, dramatically improving the user experience for accidental clicks. +- **Buttery Smooth Hover Dynamics**: Replaced harsh instant hovers on model cards with a `cubic-bezier(0.2, 0.8, 0.2, 1)` transition and a micro `0.02s` delay. This eliminates strobe-flashing when the mouse sweeps quickly across galleries, providing an extremely premium damping feel. +- **Smart Startup Navigation**: The plugin no longer opens to an empty root directory by default. It now intelligently scans all loaded trees on startup and auto-focuses the first valid folder containing models, ensuring you dive straight into your library. +- **Deep i18n Integration**: Total internationalization coverage extended to all deep-layer UI elements, including Notebook buttons, translation triggers, and dynamic dialogs. Language toggling triggers an instant virtual DOM re-render without page refreshes. + +## 🐛 Bug Fixes +- **Base Model Metadata Pollution**: Fixed an issue where the `baseModel` dropdown forcefully injected unowned models (like `HunyuanVideo` or `OmniGen`) into the UI. The filter now strictly traverses actual local `.safetensors` headers and `.info` configurations to provide a 100% accurate reflection of your physical library. +- **Nested Scrolling Traps**: Eliminated a severe UX "scroll trap" within the Notebook dual-pane editor by removing inner `max-height` constraints, allowing natural flex expansion utilizing the modal's primary scrollbar. +- **Model Selection Jumping**: Fixed layout shifting caused by variable-length text tags by implementing a rigid CSS grid/flex architecture (`.anomalous-nb-tag-row`). + +--- +*Anomalous Model Browser - Continuing the pursuit of absolute minimalism and uncompromised performance.* diff --git a/changelog_v1.0.2.md b/changelog_v1.0.2.md new file mode 100644 index 0000000..d429266 --- /dev/null +++ b/changelog_v1.0.2.md @@ -0,0 +1,13 @@ +# Changelog v1.0.2 (UX & Polish Hotfix) + +## 🚀 Enhancements +- **Magnetic Matrix Deployment**: When sending a Notebook to the canvas, the entire architecture (Checkpoint + Loras + CLIP Encoders) is now mathematically arranged in a clean, linear assembly line instead of clumping together. The entire node group magnetically sticks to your cursor until you click the canvas to drop it. +- **Smart Session Memory**: Re-opening the Notebook panel now instantly resumes your exact previous editing session rather than resetting to a blank state. +- **Auto-Focus First Notebook**: If no session is active, opening the Notebook modal will automatically open your first existing notebook to prevent "empty screen" fatigue. +- **Instant Save Feedback**: Added a satisfying 1.5-second green `✅` transient animation to the notebook save button for psychological assurance. +- **Clearer Documentation**: Updated README codebase size estimation to reflect reality (~150KB - 200KB) due to the massive features added, while still maintaining pure Vanilla JS zero-dependency dominance. + +## 🐛 Bug Fixes +- **Double Icon Glitch**: Removed a hardcoded `➕` emoji on the "Apply to Canvas" button that duplicated the icon injected by the translation engine. +- **Localization Override**: Fixed a critical bug where scan success dialogs were defaulting to English despite the UI being set to Chinese. This was caused by the plugin improperly reading the host ComfyUI root DOM `lang` attribute instead of the plugin's internal state. +- **Safer Reboot Advice**: Replaced misleading mentions of "Refresh ComfyUI" with strict advice to "Restart ComfyUI backend" after model scans to prevent deep path caching crashes. diff --git a/config.example.json b/config.example.json index aab05c0..a473033 100644 --- a/config.example.json +++ b/config.example.json @@ -1,3 +1,3 @@ -{ - "CIVITAI_API_KEY": "" -} +{ + "CIVITAI_API_KEY": "" +} diff --git a/error_and_experience_summary.md b/error_and_experience_summary.md index 21c56f7..4bbd136 100644 --- a/error_and_experience_summary.md +++ b/error_and_experience_summary.md @@ -1,30 +1,63 @@ -# 插件开发纠错与经验沉淀 (Error & Experience Summary) +# 📖 Error & Experience Summary -在开发与完善 `Anomalous_Model_Browser` 插件的过程中,我们遇到了几个典型的技术陷阱与用户体验问题。为了便于后续维护和其他开发者参考,特在此归纳沉淀: +This document serves as an architectural retrospective and UX diagnostic log for the development of the Anomalous Model Browser. -## 1. 后台 API 数据传递与作用域异常 (UnboundLocalError) -**问题描述**:在重构 `api.py` 中的 `api_compatible_models`(跨文件夹兼容模型扫描)逻辑时,遇到了前端抛出 500 错误(`Failed to load`)。 -**根源分析**:在 Python 中,如果一个变量(如 `rel_subfolder`)在 `if` 语句块内部被赋值,但在执行时该 `if` 条件未满足,后续代码又尝试读取该变量,就会引发 `UnboundLocalError`。在我们早期的代码缩进修复中,因为缩进层级错乱,导致赋值语句和读取语句的逻辑层级脱节。 -**解决方案**:严格对齐缩进,并确保所有分支(尤其是 `preview_url` 等变量)在被使用前都有安全的默认回退值(Fallback initialization, 如 `preview_url = ""`)。 +## 1. UX Scroll Traps (Nested Scrollbars) +**The Problem**: +In the Notebook modal, the translation dual-pane editor was initially assigned a `max-height: 400px` and `overflow-y: auto`. Since the parent container (the entire notebook editor body) was also scrollable, this created a severe "Scroll Trap". When users scrolled down the translation tags, hitting the bottom would unpredictably transfer the scroll event to the outer body, making navigation feel disjointed and frustrating. +**The Solution**: +Removed the `max-height` and internal scrollbar from the translation pane entirely. Letting the translation editor expand naturally utilizing the primary window scrollbar guarantees a continuous, smooth scrolling experience. -## 2. 文件夹别名导致的重复加载问题 (Duplicate Models) -**问题描述**:前端模型兼容列表出现了两个完全一模一样的 Flux 模型。 -**根源分析**:前端触发搜索时,同时传入了 `checkpoints,unet,diffusion_models` 三个目录作为扫描目标。而在 ComfyUI 的环境配置中,`unet` 和 `diffusion_models` 经常被映射到硬盘上的**同一个物理文件夹**。后台在遍历时,对同一个文件夹扫描了两次,导致相同文件被重复添加。 -**解决方案**:使用 `seen_files = set()` 记录已处理文件的**绝对物理路径**(`os.path.realpath(file_path)`),即使软链接或不同挂载点指向同一文件,也能被精准去重拦截。 +## 2. Artificial Metadata Pollution +**The Problem**: +When building the "Base Model" dropdown filter, the backend `api.py` attempted to aggregate local `baseModel` strings but fell back to aggressively injecting a hardcoded list of modern architectures (`SDXL`, `HunyuanVideo`, `OmniGen`) to ensure the dropdown looked "complete." This completely backfired, confusing the user who saw architectures they didn't even own installed on their drive. +**The Solution**: +Data truth must strictly reflect the local disk state. The backend was rewritten to strictly parse the `.safetensors`/`.civitai.info` headers without any artificial list padding. The UI dropdown now acts as an accurate mirror of the physical file ecosystem. -## 3. 增量扫描逻辑的盲区 (Scanner Skip Logic) -**问题描述**:用户手动删除了某个模型的 `.info` 文件或预览图,点击后台扫描引擎却没有补齐,依然直接跳过。 -**根源分析**:早期的增量逻辑比较“偷懒”,只要发现同名的 `.info` 文件存在,就直接 `continue` 跳过,忽略了对预览图的二次检查。 -**解决方案**:重写扫描引擎的短路逻辑。采用“三证合一”严苛检查:只有当 `.info` 文件存在,**且**同名的 `[.png, .jpg, .mp4, .webm]` 预览媒体中至少有一项存在时,才允许跳过;否则强制触发 C 站接口进行重扫补齐。 +## 3. UI Strobe Effects on Grid Hovers +**The Problem**: +Setting standard `transition: all 0.2s` for the hover states on model cards meant that quickly sweeping the mouse across the grid caused all cards to rapidly light up and drop down like a strobe light. It felt cheap and unresponsive. +**The Solution**: +UI damping is critical for premium feel. We introduced a customized `cubic-bezier(0.2, 0.8, 0.2, 1)` transition with a microscopically tiny `transition-delay: 0.02s` on the hover pseudo-class. This 20ms delay is imperceptible for intentional interactions but acts as a low-pass filter, completely eliminating strobe flashes from fast, accidental mouse sweeps. -## 4. UI/UX 自适应排版 (Responsive UI) -**问题描述**:模型名称过长时,顶部固定栏被强行撑高并发生换行;说明面板太大导致底部关闭按钮超出屏幕外。 -**根源分析**:未对弹性盒子(Flexbox)进行文本溢出控制,且模态框缺乏滚动边界限制。 -**解决方案**: -- **单行溢出省略**:对可能超长的标题应用 `white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1;`。 -- **三明治模态布局**:对弹出面板应用 `max-height: 90vh`,并对其内部的文本内容层(Body)单独应用 `overflow-y: auto`。这样无论内容多长,只会在中间区域滚动,头部的“X”和底部的“关闭”按钮始终固定可见。 +## 4. "Hostage" Inline Confirmations +**The Problem**: +To avoid browser `alert()` dialogs during deletion, an inline confirmation was built: clicking "Delete" changed the button to "Are you sure?" with a 3-second `setTimeout`. However, there was no explicit "Cancel" button. If the user misclicked, they were held "hostage" for 3 seconds waiting for the button to revert. +**The Solution**: +Never lock a user out without an exit hatch. The delete button logic was wrapped in an inline flex container that instantly reveals a secondary `[✕]` cancel button upon the first click. Clicking `[✕]` instantly clears the timeout and resets the state, giving control back to the user immediately. -## 5. 安全性审计 (Security Audit) -对后端的关键路由进行了路径穿越(Path Traversal)核查: -- `api_delete_model`, `api_serve_image`, `api_get_models` 均内置了 `if '..' in subfolder` 和 `if '..' in filename` 的强拦截逻辑,确保用户无法通过构造相对路径去跨级删除或访问插件/工作流之外的核心系统文件。 -- `api_serve_image` 增加了严格的文件拓展名(extension)白名单字典,强制转换 `Content-Type`,杜绝了把可执行脚本伪装成预览图返回的风险。 +## 5. First-load "Dead Interface" Avoidance +**The Problem**: +The plugin initialized its sidebar to the root directory `/`. If a user had deeply nested categories but no models in the root, opening the plugin presented a blank screen, creating the illusion that it had failed to load. +**The Solution**: +Implemented an intelligent `firstLoadDone` routine. During the initial folder fetch, the JS client parses the dictionary of directories, scanning the `model_count` parameters. It automatically forces the UI `currentSubfolder` to the very first directory that possesses `model_count > 0`. The user is instantly greeted with their content upon opening. + +## 6. Structural Layout Breakages (Flex vs Block) +**The Problem**: +When rendering translation tags side-by-side, placing varying lengths of text blocks sequentially caused extreme layout jitter. Hovering over a long English tag to highlight the Chinese counterpart caused the UI to "jump" if the lines didn't align perfectly. +**The Solution**: +Enforced a strict "Row-based" flex architecture (`.anomalous-nb-tag-row`). By slicing both the Source and Target arrays simultaneously and wrapping them in an `align-items: stretch` flex container, each tag pair is locked into a row, guaranteeing perfect geometric stability regardless of string length. + +## 7. Modal State Persistence vs DOM Lifecycle +**The Problem**: +When users closed the Notebook modal and reopened it, the right-side editor was blank. The JS application retained `this.currentNotebook` in memory from the previous session, skipping the "auto-open first notebook" logic, but because the DOM modal was completely destroyed and recreated via `.innerHTML`, the editor function `renderNotebookEditor()` was never invoked, leaving a desynced blank UI. +**The Solution**: +Explicitly check the cached state during modal initialization. If `this.currentNotebook` exists upon modal creation, force a call to `this.renderNotebookEditor()` instead of assuming it's already rendered. This achieves perfect "session memory" for the user. + +## 8. Missing Visual Feedback for Background Operations +**The Problem**: +The Notebook "Save" button executed a background `fetch` request perfectly, but provided absolutely no visual feedback. Users complained the button was "unresponsive" because nothing flashed or changed on screen, causing anxiety about data loss. +**The Solution**: +Transient UI states are mandatory for background tasks. We added a temporary state swap: clicking Save instantly changes the button to a green `✅` state for 1.5 seconds before reverting, providing the crucial psychological closure needed. + +## 9. Spatial Geometry for Multi-Node Spawning +**The Problem**: +When deploying an entire Notebook (Main Model + N Loras + 2 CLIP Prompts) to the ComfyUI canvas, spawning them all at `app.canvas.graph_mouse` or `(0,0)` would cause all nodes to perfectly overlap into an indistinguishable clump, forcing the user to manually detangle them. +**The Solution**: +Implemented a "Relative Offset Matrix Layout". The code iteratively calculates spatial offsets (`X + 350*N`, `Y + 250`) to construct a perfect assembly line of nodes. Combined with binding these coordinates to a `mousemove` event (Magnetic Sticking), the user can visually preview the massive node chain dragging with their cursor before clicking to drop it onto the canvas, preserving workflow layout sanity. + +## 10. Localization Host-Environment Constraints +**The Problem**: +Our localization engine was reading `document.documentElement.lang` to decide whether to show English or Chinese error popups. However, because our plugin runs inside ComfyUI, ComfyUI controls the root HTML element and keeps it permanently set to `en-US`. Consequently, Chinese users always received English system popups regardless of the plugin's internal language toggle. +**The Solution**: +Never trust the host document properties when building an embedded plugin. Localization logic was strictly re-routed to rely on our internal `currentLang` variable state, ensuring correct i18n rendering. diff --git a/web/main.js b/web/main.js index 11c3037..cb58cb5 100644 --- a/web/main.js +++ b/web/main.js @@ -32,7 +32,7 @@ const i18n = { delConfirm: '确定彻底删除', delConfirm2: '及其所有关联配置、预览图和缓存文件吗?此操作不可逆!', delSuccess: '✅ 删除成功!\n', - delNote: '\n\n⚠️ 提示:请务必【重启 ComfyUI 服务端】。如果不重启,继续使用可能会报错!', + delNote: '\n\n⚠️ 提示:请重启 ComfyUI,否则由于缓存继续使用会报错', delFail: '❌ 删除失败: ', copyAll: '📋 复制全部', copied: '✅ 已复制!', @@ -54,9 +54,22 @@ const i18n = {

6. 📂 目录折叠: 点击左侧边栏的“收起/展开全部”快速管理网格视图。

7. 🔗 兼容模型匹配: 点开大模型或 Lora 详细页,系统将自动基于 Base Model 架构,双向匹配并展示关联模型。

8. ➕ 一键投放节点: 在网格卡片右上角或详细页顶部,点击【➕】按钮,可将当前模型节点直接贴在鼠标上,并无缝插入工作流画布!

+

9. 📑 笔记本 (Notebook): 提供强大的工作流草稿本功能。你可以创建不同的笔记本,选择基础架构 (Base Model),系统会自动为你筛选出对应的纯净主模型和 Lora 画廊供你点选。同时内置了极具高级感的双语提示词对照编辑器,支持标签化悬停高亮与快捷复制,并且支持将整个配置一键发送到画布!

`, + notebooks: '📑 笔记本', + notebookTitle: '笔记本管理', + createNotebook: '➕ 新建', + saveNotebook: '💾 保存', + deleteNotebook: '🗑️ 删除', + renameNotebook: '✏️ 重命名', + baseModel: 'Base Model (基础模型)', + mainModel: 'Main Model (主模型)', + sendToCanvas: '🚀 发送到画布', + translate: '翻译', + newNotebookName: '新笔记本名称', + noBaseModel: '请先选择 Base Model', closeHelp: '关闭说明', - + delSure: '⚠️ 确定吗?', }, en: { title: '📦 Anomalous Model Browser', @@ -89,7 +102,7 @@ const i18n = { delConfirm: 'Are you sure you want to permanently delete', delConfirm2: ' and all its associated configs, previews, and cache files? This action is irreversible!', delSuccess: '✅ Delete Complete!\n', - delNote: '\n\n⚠️ Note: Please restart the ComfyUI backend server, otherwise errors may occur!', + delNote: '\n\n⚠️ Note: Please restart ComfyUI, otherwise continuing to use it due to cache will cause errors.', delFail: '❌ Delete Failed: ', copyAll: '📋 Copy All', copied: '✅ Copied!', @@ -109,9 +122,22 @@ const i18n = {

4. 🔋 Eco / 🎬 AutoPlay: Toggle video playback modes. Eco mode plays videos only when hovering over a model card.

5. 🗂️ Details & Delete: Click a model card to view details and 1-click copy trained words. You can also permanently delete the model here (requires ComfyUI restart).

6. 📂 Folder Toggle: Use the Collapse/Expand All button in the sidebar to manage your view.

+

7. 📑 Notebook System: A powerful drafting workspace. Create notebooks, select a Base Model, and precisely match compatible Main Models and Loras. Includes a premium dual-pane bilingual prompt editor with hover-sync, tag splitting, and 1-click deployment to canvas.

`, + notebooks: '📑 Notebooks', + notebookTitle: 'Notebook Manager', + createNotebook: '➕ New', + saveNotebook: '💾 Save', + deleteNotebook: '🗑️ Delete', + renameNotebook: '✏️ Rename', + baseModel: 'Base Model', + mainModel: 'Main Model', + sendToCanvas: '🚀 Send to Canvas', + translate: 'Translate', + newNotebookName: 'New Notebook Name', + noBaseModel: 'Select a Base Model first', closeHelp: 'Close Manual', - + delSure: '⚠️ Sure?', } }; @@ -178,15 +204,21 @@ class AnomalousBrowser { scanBtn.title = t('scanTitle'); scanBtn.innerHTML = `🔄 ${t('scan')}`; cleanBtn.title = t('cleanTitle'); - cleanBtn.innerHTML = `🧹 ${t('clean')}`; + cleanBtn.innerHTML = `🧹 ${t('clean')}`; helpBtn.title = t('helpTitle'); helpBtn.innerHTML = `❓ ${t('help')}`; + nbBtn.title = t('notebookTitle'); + nbBtn.innerHTML = `${t('notebooks')}`; renderEnergyBtn(); this.renderSidebar(); this.loadModels(); if (this.detailPanel.style.display !== 'none' && this.currentDetailModel) { this.showDetail(this.currentDetailModel); } + if (this.nbEditor && this.nbEditor.innerHTML !== '') { + this.renderNotebookEditor(); + this.refreshNotebooks(); + } }; this.settingsArea.appendChild(langBtn); this.sidebarWrapper.appendChild(this.sidebar); @@ -210,6 +242,12 @@ class AnomalousBrowser { helpBtn.innerHTML = `❓ ${t('help')}`; helpBtn.onclick = () => this.showHelp(); + const nbBtn = document.createElement('button'); + nbBtn.id = 'anomalous-notebook-btn'; + nbBtn.title = t('notebookTitle'); + nbBtn.innerHTML = `${t('notebooks')}`; + nbBtn.onclick = () => this.showNotebooks(); + const apiKeyInput = document.createElement('input'); apiKeyInput.id = 'anomalous-api-key'; apiKeyInput.placeholder = '🔑 API Key'; @@ -252,14 +290,19 @@ class AnomalousBrowser { clearInterval(poll); scanBtn.innerHTML = `✅ ${t('scanDone')}`; - let msg = t('scanCompleteMsg'); + let msg = ''; if (sd.result) { - msg = `✅ 扫描结束!\n成功处理:${sd.result.success} 个\n处理失败:${sd.result.fail} 个\n\n⚠️ 提示:请点击 ComfyUI 的 [Refresh] 按钮以同步,若有模型未生效,请【重启 ComfyUI 服务端】。`; - if (document.documentElement.lang !== 'zh') { - msg = `✅ Scan Complete!\nSuccess: ${sd.result.success}\nFailed: ${sd.result.fail}\n\n⚠️ Tip: Click [Refresh] in ComfyUI, or RESTART the ComfyUI backend if models don't appear.`; + if (currentLang === 'zh') { + msg = `✅ 扫描结束!\n成功处理:${sd.result.success} 个\n处理失败:${sd.result.fail} 个\n\n⚠️ 重要提示:为了让重命名后的模型生效,请务必【重启 ComfyUI 服务端后台】。`; + } else { + msg = `✅ Scan Complete!\nSuccess: ${sd.result.success}\nFailed: ${sd.result.fail}\n\n⚠️ Important: Please fully RESTART the ComfyUI backend to load the renamed models.`; } } else { - msg = msg.replace('[Refresh] 按钮以同步最新的模型列表。', '[Refresh] 按钮以同步,若有模型未生效,请【重启 ComfyUI 服务端】。'); + if (currentLang === 'zh') { + msg = `✅ 扫描完成!\n\n⚠️ 重要提示:为了让重命名后的模型生效,请务必【重启 ComfyUI 服务端后台】。`; + } else { + msg = `✅ Scan Complete!\n\n⚠️ Important: Please fully RESTART the ComfyUI backend to load the renamed models.`; + } } alert(msg); @@ -320,7 +363,7 @@ class AnomalousBrowser { cleanBtn.disabled = false; }; - header.appendChild(title); + header.appendChild(nbBtn); header.appendChild(helpBtn); header.appendChild(apiKeyInput); header.appendChild(cleanBtn); @@ -390,6 +433,9 @@ class AnomalousBrowser { const closeX = document.createElement('div'); closeX.innerHTML = '×'; + closeX.style.position = 'absolute'; + closeX.style.top = '10px'; + closeX.style.right = '15px'; closeX.style.fontSize = '1.8em'; closeX.style.cursor = 'pointer'; closeX.style.color = '#ff4444'; @@ -434,7 +480,25 @@ class AnomalousBrowser { try { const res = await fetch('/anomalous/folders'); const data = await res.json(); - this.foldersData = data.folders || {}; + this.foldersData = data.folders || []; + + if (!this.firstLoadDone && this.foldersData.length > 0) { + this.firstLoadDone = true; + let found = false; + for (const typeGroup of this.foldersData) { + const sortedPaths = Object.keys(typeGroup.folders).sort(); + for (const path of sortedPaths) { + if (typeGroup.folders[path].model_count > 0) { + this.currentType = typeGroup.type; + this.currentPathIdx = typeGroup.path_idx; + this.currentSubfolder = path; + found = true; + break; + } + } + if (found) break; + } + } // Auto expand all (this.foldersData || []).forEach(typeGroup => { @@ -452,11 +516,31 @@ class AnomalousBrowser { renderSidebar() { this.sidebar.innerHTML = ''; + const brandBar = document.createElement('div'); + brandBar.style.padding = '12px 15px 0 15px'; + brandBar.style.display = 'flex'; + brandBar.style.alignItems = 'center'; + + const badge = document.createElement('div'); + badge.style.background = 'linear-gradient(135deg, #444, #222)'; + badge.style.color = '#ccc'; + badge.style.fontSize = '0.7em'; + badge.style.padding = '4px 8px'; + badge.style.borderRadius = '6px'; + badge.style.letterSpacing = '1px'; + badge.style.border = '1px solid #555'; + badge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)'; + badge.style.textTransform = 'uppercase'; + badge.style.fontWeight = 'bold'; + badge.innerHTML = 'Anomalous Browser'; + + brandBar.appendChild(badge); + const topBar = document.createElement('div'); topBar.style.display = 'flex'; topBar.style.justifyContent = 'space-between'; topBar.style.alignItems = 'center'; - topBar.style.padding = '15px'; + topBar.style.padding = '10px 15px 15px 15px'; const title = document.createElement('h3'); title.innerHTML = t('folders'); @@ -488,6 +572,7 @@ class AnomalousBrowser { topBar.appendChild(title); topBar.appendChild(collapseAllBtn); + this.sidebar.appendChild(brandBar); this.sidebar.appendChild(topBar); (this.foldersData || []).forEach(typeGroup => { @@ -856,9 +941,9 @@ const backBtn = document.createElement('button'); header.appendChild(jumpBtn); const applyDetailBtn = document.createElement('button'); - applyDetailBtn.innerHTML = t('applyToCanvas'); + applyDetailBtn.innerHTML = t('applyToCanvas') || 'Apply to Canvas'; applyDetailBtn.style.padding = '6px 12px'; - applyDetailBtn.style.background = '#28a745'; + applyDetailBtn.style.background = '#007bff'; applyDetailBtn.style.color = '#fff'; applyDetailBtn.style.border = 'none'; applyDetailBtn.style.borderRadius = '4px'; @@ -1130,7 +1215,7 @@ const backBtn = document.createElement('button'); let thumb = ''; if (m_comp.preview_url) { const isVid = m_comp.preview_url.toLowerCase().endsWith('.mp4') || m_comp.preview_url.toLowerCase().endsWith('.webm'); - if (isVid) thumb = ``; + if (isVid) thumb = ``; else thumb = ``; } else { thumb = `
?
`; @@ -1138,6 +1223,11 @@ const backBtn = document.createElement('button'); mItem.innerHTML = `${thumb}
${m_comp.filename}
`; + if (m_comp.preview_url && (m_comp.preview_url.toLowerCase().endsWith('.mp4') || m_comp.preview_url.toLowerCase().endsWith('.webm'))) { + mItem.onmouseenter = () => { const v = mItem.querySelector('video'); if (v) v.play().catch(e=>{}); }; + mItem.onmouseleave = () => { const v = mItem.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } }; + } + mItem.onclick = () => { this.historyStack.push({ type: this.currentType, @@ -1181,6 +1271,745 @@ const backBtn = document.createElement('button'); } } close() { this.modal.classList.remove('visible'); } + + async showNotebooks() { + if (this.nbModal) { + this.nbModal.remove(); + } + + this.nbModal = document.createElement('div'); + this.nbModal.className = 'anomalous-nb-modal'; + + const container = document.createElement('div'); + container.className = 'anomalous-nb-container'; + + const header = document.createElement('div'); + header.className = 'anomalous-nb-header'; + + const title = document.createElement('h2'); + title.innerHTML = t('notebookTitle'); + title.style.margin = '0'; + + const closeX = document.createElement('div'); + closeX.innerHTML = '×'; + closeX.className = 'anomalous-nb-close'; + closeX.onclick = () => this.nbModal.remove(); + + header.appendChild(title); + header.appendChild(closeX); + + const body = document.createElement('div'); + body.className = 'anomalous-nb-body'; + + // Sidebar for notebooks list + const sidebar = document.createElement('div'); + sidebar.className = 'anomalous-nb-sidebar'; + + const nbList = document.createElement('div'); + nbList.className = 'anomalous-nb-list'; + + const btnRow = document.createElement('div'); + btnRow.style.padding = '10px'; + btnRow.style.display = 'flex'; + btnRow.style.gap = '5px'; + + const createBtn = document.createElement('button'); + createBtn.innerHTML = t('createNotebook'); + createBtn.className = 'anomalous-btn-primary'; + + const createInput = document.createElement('input'); + createInput.type = 'text'; + createInput.placeholder = t('newNotebookName'); + createInput.style.display = 'none'; + createInput.style.flex = '1'; + createInput.style.padding = '4px'; + createInput.style.background = '#222'; + createInput.style.color = '#fff'; + createInput.style.border = '1px solid #555'; + createInput.style.borderRadius = '4px'; + + createBtn.onclick = () => { + if (createInput.style.display === 'none') { + createInput.style.display = 'block'; + createBtn.innerHTML = '✓'; + createInput.focus(); + } else { + const name = createInput.value.trim(); + if (name) { + this.currentNotebook = { filename: name + '.json', name: name, data: { baseModel: '', mainModel: null, loras: [], promptEn: '', promptZh: '' } }; + this.saveCurrentNotebook(); + this.renderNotebookEditor(); + createInput.value = ''; + } + createInput.style.display = 'none'; + createBtn.innerHTML = t('createNotebook'); + } + }; + + btnRow.appendChild(createInput); + btnRow.appendChild(createBtn); + sidebar.appendChild(btnRow); + sidebar.appendChild(nbList); + + // Editor area + this.nbEditor = document.createElement('div'); + this.nbEditor.className = 'anomalous-nb-editor'; + + body.appendChild(sidebar); + body.appendChild(this.nbEditor); + + container.appendChild(header); + container.appendChild(body); + this.nbModal.appendChild(container); + + document.getElementById('anomalous-container').appendChild(this.nbModal); + + this.nbListEl = nbList; + this.refreshNotebooks(true); + } + + async refreshNotebooks(autoOpenFirst = false) { + try { + const res = await fetch('/anomalous/notebooks'); + const data = await res.json(); + this.nbListEl.innerHTML = ''; + + if (data.notebooks && data.notebooks.length > 0) { + if (autoOpenFirst) { + if (!this.currentNotebook) { + this.currentNotebook = data.notebooks[0]; + } + if (this.currentNotebook) { + this.renderNotebookEditor(); + } + } + + data.notebooks.forEach(nb => { + const item = document.createElement('div'); + item.className = 'anomalous-nb-item'; + if (this.currentNotebook && this.currentNotebook.filename === nb.filename) { + item.classList.add('active'); + } + item.innerHTML = '📄 ' + nb.name; + item.onclick = () => { + this.currentNotebook = nb; + this.renderNotebookEditor(); + this.refreshNotebooks(); + }; + this.nbListEl.appendChild(item); + }); + } + } catch(e) {} + } + + async saveCurrentNotebook() { + if (!this.currentNotebook) return; + try { + await fetch('/anomalous/save_notebook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.currentNotebook) + }); + this.refreshNotebooks(); + } catch(e) {} + } + + async deleteCurrentNotebook(skipConfirm = false) { + if (!this.currentNotebook) return; + if (!skipConfirm && !confirm(t('deleteNotebook') + ' ?')) return; + try { + await fetch('/anomalous/delete_notebook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: this.currentNotebook.filename }) + }); + this.currentNotebook = null; + this.nbEditor.innerHTML = ''; + this.refreshNotebooks(); + } catch(e) {} + } + + renderNotebookEditor() { + this.nbEditor.innerHTML = ''; + if (!this.currentNotebook) return; + + const data = this.currentNotebook.data || {}; + if (!data.loras) data.loras = []; + + // Toolbar + const tb = document.createElement('div'); + tb.className = 'anomalous-nb-toolbar'; + + const titleArea = document.createElement('h3'); + titleArea.innerHTML = this.currentNotebook.name; + titleArea.style.margin = '0'; + + const rightBtns = document.createElement('div'); + + const saveBtn = document.createElement('button'); + saveBtn.innerHTML = t('saveNotebook'); + saveBtn.className = 'anomalous-btn-primary'; + saveBtn.onclick = async () => { + const orig = saveBtn.innerHTML; + saveBtn.innerHTML = '⏳...'; + await this.saveCurrentNotebook(); + saveBtn.innerHTML = '✅'; + saveBtn.style.background = '#2e8b57'; + setTimeout(() => { + saveBtn.innerHTML = orig; + saveBtn.style.background = ''; + }, 1500); + }; + + let delTimer = null; + const delContainer = document.createElement('span'); + delContainer.style.display = 'inline-flex'; + delContainer.style.alignItems = 'center'; + + const delBtn = document.createElement('button'); + delBtn.innerHTML = t('deleteNotebook'); + delBtn.className = 'anomalous-btn-danger'; + + const cancelDelBtn = document.createElement('button'); + cancelDelBtn.innerHTML = '✕'; + cancelDelBtn.className = 'anomalous-btn-danger'; + cancelDelBtn.style.display = 'none'; + cancelDelBtn.style.background = '#555'; + cancelDelBtn.style.marginLeft = '2px'; + cancelDelBtn.style.padding = '6px 8px'; + + delContainer.appendChild(delBtn); + delContainer.appendChild(cancelDelBtn); + + const resetDel = () => { + clearTimeout(delTimer); + delBtn.innerHTML = t('deleteNotebook'); + delBtn.style.background = ''; + cancelDelBtn.style.display = 'none'; + }; + + delBtn.onclick = () => { + if (delBtn.innerHTML === t('deleteNotebook')) { + delBtn.innerHTML = t('delSure'); + delBtn.style.background = '#800'; + cancelDelBtn.style.display = 'block'; + delTimer = setTimeout(resetDel, 4000); + } else { + resetDel(); + this.deleteCurrentNotebook(true); + } + }; + + cancelDelBtn.onclick = resetDel; + + const sendBtn = document.createElement('button'); + sendBtn.innerHTML = t('sendToCanvas'); + sendBtn.className = 'anomalous-btn-success'; + sendBtn.onclick = () => this.sendNotebookToCanvas(); + + rightBtns.appendChild(saveBtn); + rightBtns.appendChild(sendBtn); + rightBtns.appendChild(delContainer); + + tb.appendChild(titleArea); + tb.appendChild(rightBtns); + + // Settings / Models + const modelSection = document.createElement('div'); + modelSection.className = 'anomalous-nb-section'; + + // Base Model + const baseRow = document.createElement('div'); + baseRow.className = 'anomalous-nb-row'; + baseRow.innerHTML = `${t('baseModel')}`; + const baseSelect = document.createElement('select'); + baseSelect.className = 'anomalous-nb-select'; + const buildSelect = (bases) => { + baseSelect.innerHTML = ''; + bases.forEach(b => { + const opt = document.createElement('option'); + opt.value = b; opt.text = b; + if (data.baseModel === b) opt.selected = true; + baseSelect.appendChild(opt); + }); + if (!data.baseModel && bases.length > 0) data.baseModel = bases[0]; + }; + + if (this.baseModelsCache) { + buildSelect(this.baseModelsCache); + } else { + const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen']; + buildSelect(tempBases); + fetch('/anomalous/base_models').then(r=>r.json()).then(d => { + if (d.base_models && d.base_models.length > 0) { + this.baseModelsCache = d.base_models; + buildSelect(this.baseModelsCache); + } + }).catch(e => {}); + } + if (!data.baseModel) data.baseModel = 'SDXL'; + baseSelect.onchange = () => { + data.baseModel = baseSelect.value; + data.mainModel = null; + data.loras = []; + this.saveCurrentNotebook(); + this.renderNotebookEditor(); + }; + baseRow.appendChild(baseSelect); + + // Main Model (Card Selection) + const mainBox = document.createElement('div'); + mainBox.className = 'anomalous-nb-gallery-box'; + const mainRow = document.createElement('div'); + mainRow.className = 'anomalous-nb-row'; + mainRow.innerHTML = `${t('mainModel')}`; + + const mainGallery = document.createElement('div'); + mainGallery.className = 'anomalous-nb-gallery-wrap'; + + mainBox.appendChild(mainRow); + mainBox.appendChild(mainGallery); + + // Loras (Card Selection) + const loraBox = document.createElement('div'); + loraBox.className = 'anomalous-nb-gallery-box'; + const loraRow = document.createElement('div'); + loraRow.className = 'anomalous-nb-row'; + loraRow.innerHTML = `Loras`; + + const loraGallery = document.createElement('div'); + loraGallery.className = 'anomalous-nb-gallery-wrap'; + + loraBox.appendChild(loraRow); + loraBox.appendChild(loraGallery); + + modelSection.appendChild(baseRow); + modelSection.appendChild(mainBox); + modelSection.appendChild(loraBox); + + // Prompt Section + const promptSec = document.createElement('div'); + promptSec.className = 'anomalous-nb-section'; + + // Toolbar + const pToolbar = document.createElement('div'); + pToolbar.style.display = 'flex'; + pToolbar.style.gap = '10px'; + pToolbar.style.marginBottom = '10px'; + pToolbar.style.alignItems = 'center'; + + const langSelect = document.createElement('select'); + langSelect.className = 'anomalous-nb-select'; + const langs = [ + {v: 'zh-CN', l: '🇨🇳 中文 (zh-CN)'}, {v: 'en', l: '🇬🇧 English (en)'}, + {v: 'ja', l: '🇯🇵 日本语 (ja)'}, {v: 'ko', l: '🇰🇷 한국어 (ko)'}, + {v: 'fr', l: '🇫🇷 Français (fr)'}, {v: 'de', l: '🇩🇪 Deutsch (de)'}, + {v: 'es', l: '🇪🇸 Español (es)'}, {v: 'ru', l: '🇷🇺 Русский (ru)'} + ]; + langs.forEach(lg => { + const opt = document.createElement('option'); + opt.value = lg.v; opt.text = lg.l; + if ((data.targetLang || 'zh-CN') === lg.v) opt.selected = true; + langSelect.appendChild(opt); + }); + langSelect.onchange = () => { + data.targetLang = langSelect.value; + data.translations = {}; // Clear translation cache on lang change + this.saveCurrentNotebook(); + updateVisualTags(); + }; + + const findInput = document.createElement('input'); + findInput.className = 'anomalous-nb-select'; + findInput.placeholder = 'Find...'; + findInput.style.flex = '1'; + + const replaceInput = document.createElement('input'); + replaceInput.className = 'anomalous-nb-select'; + replaceInput.placeholder = 'Replace with...'; + replaceInput.style.flex = '1'; + + const replaceBtn = document.createElement('button'); + replaceBtn.className = 'anomalous-btn-primary'; + replaceBtn.innerHTML = 'Replace All'; + + pToolbar.appendChild(langSelect); + pToolbar.appendChild(findInput); + pToolbar.appendChild(replaceInput); + pToolbar.appendChild(replaceBtn); + + // Raw Input Toggle + const toggleRow = document.createElement('div'); + toggleRow.style.display = 'flex'; + toggleRow.style.justifyContent = 'space-between'; + toggleRow.style.marginBottom = '5px'; + toggleRow.innerHTML = `Prompt Editor`; + const rawBtn = document.createElement('button'); + rawBtn.className = 'anomalous-btn-primary'; + rawBtn.innerHTML = '📝 Edit Raw / Paste'; + toggleRow.appendChild(rawBtn); + + // Raw Textarea + const rawArea = document.createElement('textarea'); + rawArea.className = 'anomalous-nb-textarea'; + rawArea.value = data.promptEn || ''; + rawArea.style.display = 'none'; + rawArea.style.height = '150px'; + + // Visual Dual Pane + const dualPane = document.createElement('div'); + dualPane.className = 'anomalous-nb-dual-pane'; + + if (!data.translations) data.translations = {}; + + const updateVisualTags = () => { + dualPane.innerHTML = ''; + const txt = rawArea.value; + data.promptEn = txt; + this.saveCurrentNotebook(); + if (!txt.trim()) return; + + const tags = txt.split(',').map(s => s.trim()).filter(s => s); + tags.forEach((tag, idx) => { + const tagRow = document.createElement('div'); + tagRow.className = 'anomalous-nb-tag-row'; + + const tagL = document.createElement('div'); + tagL.className = 'anomalous-nb-visual-tag'; + tagL.style.flex = '1'; + tagL.style.justifyContent = 'space-between'; + const txtL = document.createElement('span'); + txtL.innerText = tag; + const copyL = document.createElement('span'); + copyL.className = 'anomalous-nb-copy-btn'; + copyL.innerHTML = '📋'; + copyL.onclick = (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(tag).then(() => { copyL.innerHTML = '✅'; setTimeout(()=>copyL.innerHTML='📋', 1000); }); + }; + tagL.appendChild(txtL); + tagL.appendChild(copyL); + + const tagR = document.createElement('div'); + tagR.className = 'anomalous-nb-visual-tag'; + tagR.style.flex = '1'; + tagR.style.justifyContent = 'space-between'; + const transTxt = data.translations[tag] ? data.translations[tag] : '...'; + const txtR = document.createElement('span'); + txtR.innerText = transTxt; + const copyR = document.createElement('span'); + copyR.className = 'anomalous-nb-copy-btn'; + copyR.innerHTML = '📋'; + copyR.onclick = (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(txtR.innerText).then(() => { copyR.innerHTML = '✅'; setTimeout(()=>copyR.innerHTML='📋', 1000); }); + }; + tagR.appendChild(txtR); + tagR.appendChild(copyR); + + tagL.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); }; + tagL.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); }; + tagR.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); }; + tagR.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); }; + + tagL.onclick = () => { + const inp = document.createElement('input'); + inp.value = tag; inp.className = 'anomalous-nb-tag-edit'; + tagL.innerHTML = ''; tagL.appendChild(inp); inp.focus(); + const finish = () => { + tags[idx] = inp.value.trim(); + rawArea.value = tags.join(', '); + updateVisualTags(); + }; + inp.onblur = finish; + inp.onkeydown = (e) => { if(e.key==='Enter') inp.blur(); }; + }; + + tagR.onclick = () => { + const inp = document.createElement('input'); + inp.value = data.translations[tag] || ''; inp.className = 'anomalous-nb-tag-edit'; + tagR.innerHTML = ''; tagR.appendChild(inp); inp.focus(); + const finish = () => { + data.translations[tag] = inp.value.trim(); + this.saveCurrentNotebook(); + updateVisualTags(); + }; + inp.onblur = finish; + inp.onkeydown = (e) => { if(e.key==='Enter') inp.blur(); }; + }; + + tagRow.appendChild(tagL); + tagRow.appendChild(tagR); + dualPane.appendChild(tagRow); + + if (!data.translations[tag]) { + fetch('/anomalous/translate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: tag, target_lang: data.targetLang || 'zh-CN' }) + }).then(r=>r.json()).then(d => { + if (d.translated) { + data.translations[tag] = d.translated; + txtR.innerText = d.translated; + this.saveCurrentNotebook(); + } + }).catch(()=>{}); + } + }); + }; + + rawBtn.onclick = () => { + if (rawArea.style.display === 'none') { + rawArea.style.display = 'block'; + dualPane.style.display = 'none'; + rawBtn.innerHTML = '👁️ Done Editing'; + pToolbar.style.display = 'none'; + } else { + rawArea.style.display = 'none'; + dualPane.style.display = 'flex'; + rawBtn.innerHTML = '📝 Edit Raw / Paste'; + pToolbar.style.display = 'flex'; + updateVisualTags(); + } + }; + + replaceBtn.onclick = () => { + const findStr = findInput.value; + const repStr = replaceInput.value; + if (!findStr) return; + const newTxt = rawArea.value.split(findStr).join(repStr); + rawArea.value = newTxt; + updateVisualTags(); + }; + + rawArea.oninput = () => { + clearTimeout(this.pTimeout); + this.pTimeout = setTimeout(() => { data.promptEn = rawArea.value; this.saveCurrentNotebook(); }, 500); + }; + + updateVisualTags(); + + promptSec.appendChild(pToolbar); + promptSec.appendChild(toggleRow); + promptSec.appendChild(rawArea); + promptSec.appendChild(dualPane); + + this.nbEditor.appendChild(tb); + this.nbEditor.appendChild(modelSection); + this.nbEditor.appendChild(promptSec); + + // Fetch compatible models and fill galleries + this.fillNotebookGalleries(data.baseModel, mainGallery, loraGallery, data); + } + + fillNotebookGalleries(baseModel, mainGallery, loraGallery, data) { + if (!baseModel) return; + + const buildThumbHtml = (m) => { + let thumb = ''; + if (m.preview_url) { + const isVid = m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm'); + if (isVid) thumb = ``; + else thumb = ``; + } else { + thumb = `
?
`; + } + return thumb; + }; + + fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=checkpoints,unet,diffusion_models`) + .then(r=>r.json()).then(d => { + const buildMainDOM = (models) => { + mainGallery.innerHTML = ''; + if (!models || !models.length) { + mainGallery.innerHTML = 'No compatible main models found.'; + } else { + models.forEach(m => { + const isSelected = (data.mainModel && data.mainModel.filename === m.filename); + const card = document.createElement('div'); + card.className = 'anomalous-nb-minicheck ' + (isSelected ? 'selected' : ''); + card.innerHTML = `${buildThumbHtml(m)}
${m.filename}
`; + + if (m.preview_url && (m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm'))) { + card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e=>{}); }; + card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } }; + } + + card.onclick = () => { + data.mainModel = m; + this.saveCurrentNotebook(); + buildMainDOM(models); // re-render just the main gallery + }; + mainGallery.appendChild(card); + }); + } + }; + buildMainDOM(d.models || []); + }); + + fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=loras`) + .then(r=>r.json()).then(d => { + const buildLoraDOM = (models) => { + loraGallery.innerHTML = ''; + if (!models || !models.length) { + loraGallery.innerHTML = 'No compatible Loras found.'; + } else { + models.forEach(m => { + const loraIndex = data.loras.findIndex(l => l.filename === m.filename); + const isSelected = loraIndex !== -1; + const card = document.createElement('div'); + card.className = 'anomalous-nb-minilora ' + (isSelected ? 'selected' : ''); + card.style.position = 'relative'; // for badge positioning + + let badgeHtml = ''; + if (isSelected) { + badgeHtml = `
${loraIndex + 1}
`; + } + + card.innerHTML = `${badgeHtml}${buildThumbHtml(m)}
${m.filename}
`; + + if (m.preview_url && (m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm'))) { + card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e=>{}); }; + card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } }; + } + + card.onclick = () => { + if (isSelected) { + data.loras = data.loras.filter(l => l.filename !== m.filename); + } else { + data.loras.push(m); + } + this.saveCurrentNotebook(); + buildLoraDOM(models); // re-render just the lora gallery + }; + loraGallery.appendChild(card); + }); + } + }; + buildLoraDOM(d.models || []); + }); + } + + sendNotebookToCanvas() { + if (!this.currentNotebook) return; + const data = this.currentNotebook.data || {}; + if (!data.mainModel) { + alert("Please select a Main Model first."); + return; + } + + const groupNodes = []; + const isUnet = data.mainModel.type === 'unet' || data.mainModel.type === 'diffusion_models'; + + const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple"); + app.graph.add(ckptNode); + groupNodes.push({ node: ckptNode, relX: 0, relY: 0 }); + + const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, ''); + const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename; + if (ckptNode.widgets && ckptNode.widgets.length > 0) { + const w = ckptNode.widgets.find(w => w.type === 'combo'); + if (w) w.value = relPath; + else ckptNode.widgets[0].value = relPath; + } + + let lastNode = ckptNode; + let lastModelSlot = isUnet ? 0 : 0; + let lastClipSlot = isUnet ? null : 1; + + let relX = 350; + let relY = 0; + + data.loras.forEach((lora, idx) => { + const loraNode = LiteGraph.createNode("LoraLoader"); + app.graph.add(loraNode); + groupNodes.push({ node: loraNode, relX: relX, relY: relY }); + + const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, ''); + const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename; + if (loraNode.widgets && loraNode.widgets.length > 0) { + const w = loraNode.widgets.find(w => w.type === 'combo'); + if (w) w.value = lrelPath; + else loraNode.widgets[0].value = lrelPath; + } + + lastNode.connect(lastModelSlot, loraNode, 0); + if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1); + + lastNode = loraNode; + lastModelSlot = 0; + lastClipSlot = 1; + relX += 350; + }); + + if (data.promptEn) { + const posNode = LiteGraph.createNode("CLIPTextEncode"); + posNode.title = "CLIP Text Encode (Positive)"; + app.graph.add(posNode); + groupNodes.push({ node: posNode, relX: relX, relY: 0 }); + + if (posNode.widgets && posNode.widgets.length > 0) { + const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext'); + if (tw) tw.value = data.promptEn; + } + if (lastClipSlot !== null) { + lastNode.connect(lastClipSlot, posNode, 0); + } + + const negNode = LiteGraph.createNode("CLIPTextEncode"); + negNode.title = "CLIP Text Encode (Negative)"; + app.graph.add(negNode); + groupNodes.push({ node: negNode, relX: relX, relY: 250 }); + + if (negNode.widgets && negNode.widgets.length > 0) { + const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext'); + if (tw) tw.value = "text, watermark, ugly, bad anatomy"; + } + if (lastClipSlot !== null) { + lastNode.connect(lastClipSlot, negNode, 0); + } + } + + this.nbModal.remove(); + this.close(); + + // Magnetic Sticking Logic + let isSticking = true; + const stickHandler = (e) => { + if (!isSticking || !app.canvas) return; + const canvas = app.canvas; + + let canvasX, canvasY; + if (canvas.convertEventToCanvasOffset) { + const pos = canvas.convertEventToCanvasOffset(e); + canvasX = pos[0]; + canvasY = pos[1]; + } else { + const rect = canvas.canvas.getBoundingClientRect(); + canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale; + canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale; + } + + groupNodes.forEach(item => { + item.node.pos = [canvasX - (item.node.size[0] / 2) + item.relX, canvasY - 20 + item.relY]; + }); + canvas.setDirty(true, true); + }; + + const dropHandler = (e) => { + isSticking = false; + window.removeEventListener('mousemove', stickHandler, true); + window.removeEventListener('pointerdown', dropHandler, true); + window.removeEventListener('mousedown', dropHandler, true); + window.removeEventListener('click', dropHandler, true); + }; + + window.addEventListener('mousemove', stickHandler, true); + setTimeout(() => { + window.addEventListener('pointerdown', dropHandler, true); + window.addEventListener('mousedown', dropHandler, true); + window.addEventListener('click', dropHandler, true); + }, 100); + } } app.registerExtension({ diff --git a/web/main.js.backup b/web/main.js.backup new file mode 100644 index 0000000..86fc9c1 --- /dev/null +++ b/web/main.js.backup @@ -0,0 +1,1968 @@ +import { app } from "../../scripts/app.js"; + +const i18n = { + zh: { + title: '📦 Anomalous 模型浏览器', + scan: '扫描', + scanTitle: '扫描目录', + scanConfirm: '注意:\n扫描过程将会自动比对 Civitai 并重命名本地模型,同时清理损坏文件。\n确定要开始扫描吗?', + scanning: '扫描中...', + scanBg: '🚀 扫描后台启动!', + scanDone: '完成', + scanCompleteMsg: '✅ 扫描完成!\n\n⚠️ 提示:由于部分模型已被重命名或去重,请点击 ComfyUI 的 [Refresh] 按钮以同步最新的模型列表。', + eco: '节能', + autoPlay: '自动播放', + togglePlayTitle: '切换播放模式', + clean: '清理', + cleanTitle: '清理重复 Info', + cleanConfirm: '将扫描并删除所有无用的 .civitai.info 残留文件。是否继续?', + cleaning: '清理中', + cleanDone: '✅ 清理完毕!删除了', + files: '个文件。', + cleanFail: '❌ 清理失败: ', + cleanErr: '❌ 清理出错: ', + folders: '📂 文件夹', + collapseAll: '➖ 收起全部', + expandAll: '➕ 展开全部', + noModels: '此文件夹中没有找到模型。', + noPreview: '暂无预览图', + clickScan: '点击扫描', + back: '⬅️ 返回网格', + delModel: '🗑️ 删除', + delConfirm: '确定彻底删除', + delConfirm2: '及其所有关联配置、预览图和缓存文件吗?此操作不可逆!', + delSuccess: '✅ 删除成功!\n', + delNote: '\n\n⚠️ 提示:请务必【重启 ComfyUI 服务端】。如果不重启,继续使用可能会报错!', + delFail: '❌ 删除失败: ', + copyAll: '📋 复制全部', + copied: '✅ 已复制!', + clickToCopy: '点击复制: ', + compatibleModels: '🔗 兼容模型', + loadingCompatible: '加载中...', + backToPrev: '🔙 返回上一层', + applyToCanvas: '➕ 插入节点', + applySuccess: '✅ 已添加至工作流', + help: '帮助', + helpTitle: '📖 使用说明', + helpContent: ` +
+

1. 🔑 API Key (Civitai): 在顶部输入你的 API Key,可用于扫描时获取限制级 (NSFW) 模型的封面和信息。

+

2. 🔄 扫描 (Scan): 自动扫描当前选中的文件夹,从 C 站匹配并下载封面图,同时规范化重命名你的本地模型文件。

+

3. 🧹 清理 (Clean Info): 全局深度清理:自动扫描并删除所有因为模型被删而遗留的无效 .civitai.info 文件。

+

4. 🔋 节能 / 🎬 自动播放: 切换视频封面的播放行为。“节能”模式下,鼠标悬浮在卡片上时才会播放预览视频。

+

5. 🗂️ 详情与删除: 点击任何模型卡片,可查看介绍并一键复制触发词。也可以在此处彻底删除模型及关联配置(删除后须重启 ComfyUI 引擎生效)。

+

6. 📂 目录折叠: 点击左侧边栏的“收起/展开全部”快速管理网格视图。

+

7. 🔗 兼容模型匹配: 点开大模型或 Lora 详细页,系统将自动基于 Base Model 架构,双向匹配并展示关联模型。

+

8. ➕ 一键投放节点: 在网格卡片右上角或详细页顶部,点击【➕】按钮,可将当前模型节点直接贴在鼠标上,并无缝插入工作流画布!

+
`, + closeHelp: '关闭说明', + + }, + en: { + title: '📦 Anomalous Model Browser', + scan: 'Scan', + scanTitle: 'Scan Folder', + scanConfirm: 'Notice:\nThe scan process will automatically compare with Civitai and rename your local files.\nStart scan?', + scanning: 'Scanning...', + scanBg: '🚀 Scan started in background!', + scanDone: 'Done', + scanCompleteMsg: '✅ Scan Complete!\n\n⚠️ Note: Please click [Refresh] to sync the model list since files were renamed.', + eco: 'Eco', + autoPlay: 'AutoPlay', + togglePlayTitle: 'Toggle Play Mode', + clean: 'Clean Info', + cleanTitle: 'Clean Duplicate Info', + cleanConfirm: 'This will globally scan and delete all redundant .civitai.info files. Continue?', + cleaning: 'Cleaning', + cleanDone: '✅ Clean Complete! Deleted', + files: 'files.', + cleanFail: '❌ Failed: ', + cleanErr: '❌ Error: ', + folders: '📂 Folders', + collapseAll: '➖ Collapse All', + expandAll: '➕ Expand All', + noModels: 'No models found in this folder.', + noPreview: 'No preview available', + clickScan: 'Click Scan', + back: '⬅️ Back to grid', + delModel: '🗑️ Delete', + delConfirm: 'Are you sure you want to permanently delete', + delConfirm2: ' and all its associated configs, previews, and cache files? This action is irreversible!', + delSuccess: '✅ Delete Complete!\n', + delNote: '\n\n⚠️ Note: Please restart the ComfyUI backend server, otherwise errors may occur!', + delFail: '❌ Delete Failed: ', + copyAll: '📋 Copy All', + copied: '✅ Copied!', + clickToCopy: 'Click to copy: ', + compatibleModels: '🔗 Compatible Models', + loadingCompatible: 'Loading...', + backToPrev: '🔙 Back to prev', + applyToCanvas: '➕ Add Node', + applySuccess: '✅ Added to workflow', + help: 'Help', + helpTitle: '📖 User Manual', + helpContent: ` +
+

1. 🔑 API Key (Civitai): Enter your key at the top to fetch metadata and previews for NSFW models during scanning.

+

2. 🔄 Scan: Automatically scans the current folder, fetches Civitai metadata, downloads previews, and standardizes model filenames.

+

3. 🧹 Clean Info: Globally scans and deletes orphaned .civitai.info files to free up disk space.

+

4. 🔋 Eco / 🎬 AutoPlay: Toggle video playback modes. Eco mode plays videos only when hovering over a model card.

+

5. 🗂️ Details & Delete: Click a model card to view details and 1-click copy trained words. You can also permanently delete the model here (requires ComfyUI restart).

+

6. 📂 Folder Toggle: Use the Collapse/Expand All button in the sidebar to manage your view.

+
`, + closeHelp: 'Close Manual', + + } +}; + +let currentLang = localStorage.getItem('anomalous_lang') || 'zh'; +const t = (key) => i18n[currentLang][key] || key; + +class AnomalousBrowser { + constructor() { + this.modal = null; + this.sidebar = null; + this.grid = null; + this.detailPanel = null; + this.currentType = 'loras'; + this.currentPathIdx = 0; + this.currentSubfolder = '/'; + this.foldersData = null; + this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']); + this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true'; + this.createDOM(); + } + + createDOM() { + this.modal = document.createElement('div'); + this.modal.id = 'anomalous-modal'; + this.modal.onclick = (e) => { if (e.target === this.modal) this.close(); }; + + const container = document.createElement('div'); + container.id = 'anomalous-container'; + + const updateLangClass = () => { + if (currentLang === 'en') container.classList.add('anomalous-lang-en'); + else container.classList.remove('anomalous-lang-en'); + }; + updateLangClass(); + + // Sidebar + this.sidebarWrapper = document.createElement('div'); + this.sidebarWrapper.id = 'anomalous-sidebar-wrapper'; + + this.sidebar = document.createElement('div'); + this.sidebar.id = 'anomalous-sidebar'; + + this.settingsArea = document.createElement('div'); + this.settingsArea.id = 'anomalous-settings'; + + // API key moved to header + + const langBtn = document.createElement('button'); + langBtn.style.padding = '6px'; + langBtn.style.width = '100%'; + langBtn.style.background = '#444'; + langBtn.style.color = '#fff'; + langBtn.style.border = '1px solid #555'; + langBtn.style.borderRadius = '4px'; + langBtn.style.cursor = 'pointer'; + langBtn.innerHTML = currentLang === 'zh' ? '🌐 Language: EN' : '🌐 语言: 中文'; + langBtn.onclick = () => { + currentLang = currentLang === 'zh' ? 'en' : 'zh'; + localStorage.setItem('anomalous_lang', currentLang); + langBtn.innerHTML = currentLang === 'zh' ? '🌐 Language: EN' : '🌐 语言: 中文'; + // re-render UI + updateLangClass(); + title.innerHTML = t('title'); + scanBtn.title = t('scanTitle'); + scanBtn.innerHTML = `🔄 ${t('scan')}`; + cleanBtn.title = t('cleanTitle'); + cleanBtn.innerHTML = `🧹 ${t('clean')}`; + helpBtn.title = t('helpTitle'); + helpBtn.innerHTML = `❓ ${t('help')}`; + renderEnergyBtn(); + this.renderSidebar(); + this.loadModels(); + if (this.detailPanel.style.display !== 'none' && this.currentDetailModel) { + this.showDetail(this.currentDetailModel); + } + }; + this.settingsArea.appendChild(langBtn); + + const transSettingsTitle = document.createElement('div'); + transSettingsTitle.innerHTML = '🌐 Translation API'; + transSettingsTitle.style.marginTop = '15px'; + transSettingsTitle.style.marginBottom = '5px'; + this.settingsArea.appendChild(transSettingsTitle); + + const engineSel = document.createElement('select'); + engineSel.innerHTML = ''; + engineSel.style.width = '100%'; + engineSel.style.padding = '4px'; + engineSel.style.marginBottom = '5px'; + engineSel.style.background = '#333'; + engineSel.style.color = '#fff'; + this.settingsArea.appendChild(engineSel); + + const deeplKeyInput = document.createElement('input'); + deeplKeyInput.type = 'password'; + deeplKeyInput.placeholder = 'DeepL API Key (if DeepL selected)'; + deeplKeyInput.style.width = '100%'; + deeplKeyInput.style.padding = '4px'; + deeplKeyInput.style.marginBottom = '5px'; + deeplKeyInput.style.background = '#333'; + deeplKeyInput.style.color = '#fff'; + this.settingsArea.appendChild(deeplKeyInput); + + const targetSel = document.createElement('select'); + targetSel.innerHTML = ''; + targetSel.style.width = '100%'; + targetSel.style.padding = '4px'; + targetSel.style.marginBottom = '10px'; + targetSel.style.background = '#333'; + targetSel.style.color = '#fff'; + this.settingsArea.appendChild(targetSel); + + const saveConfigBtn = document.createElement('button'); + saveConfigBtn.innerHTML = '💾 Save Settings'; + saveConfigBtn.style.width = '100%'; + saveConfigBtn.style.padding = '6px'; + saveConfigBtn.style.background = '#28a745'; + saveConfigBtn.style.color = '#fff'; + saveConfigBtn.style.border = 'none'; + saveConfigBtn.style.borderRadius = '4px'; + saveConfigBtn.style.cursor = 'pointer'; + saveConfigBtn.onclick = async () => { + const cfg = { + trans_engine: engineSel.value, + deepl_key: deeplKeyInput.value, + trans_target: targetSel.value + }; + await fetch('/anomalous/save_config', { method:'POST', body: JSON.stringify(cfg) }); + alert('Settings saved!'); + }; + this.settingsArea.appendChild(saveConfigBtn); + + fetch('/anomalous/config').then(r => r.json()).then(cfg => { + if(cfg.trans_engine) engineSel.value = cfg.trans_engine; + if(cfg.deepl_key) deeplKeyInput.value = cfg.deepl_key; + if(cfg.trans_target) targetSel.value = cfg.trans_target; + }).catch(e=>{}); + + this.sidebarWrapper.appendChild(this.sidebar); + this.sidebarWrapper.appendChild(this.settingsArea); + + // Content Area + const content = document.createElement('div'); + content.id = 'anomalous-content'; + + const header = document.createElement('div'); + header.id = 'anomalous-header'; + + const title = document.createElement('div'); + title.id = 'anomalous-title'; + title.innerHTML = t('title'); + + + const helpBtn = document.createElement('button'); + helpBtn.id = 'anomalous-help-btn'; + helpBtn.title = t('helpTitle'); + helpBtn.innerHTML = `❓ ${t('help')}`; + helpBtn.onclick = () => this.showHelp(); + + const apiKeyInput = document.createElement('input'); + apiKeyInput.id = 'anomalous-api-key'; + apiKeyInput.placeholder = '🔑 API Key'; + apiKeyInput.title = '输入 Civitai API Key / Enter Civitai API Key'; + apiKeyInput.type = 'password'; + apiKeyInput.value = localStorage.getItem('anomalous_api_key') || ''; + apiKeyInput.onchange = async () => { + localStorage.setItem('anomalous_api_key', apiKeyInput.value); + try { + await fetch('/anomalous/save_config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ api_key: apiKeyInput.value }) + }); + } catch(e) {} + }; + fetch('/anomalous/config').then(r=>r.json()).then(d=>{ + if (d.has_api_key) apiKeyInput.placeholder = 'API Key Saved'; + }).catch(()=>{}); + + const scanBtn = document.createElement('button'); + scanBtn.id = 'anomalous-scan-btn'; + scanBtn.title = t('scanTitle'); + scanBtn.innerHTML = `🔄 ${t('scan')}`; + scanBtn.onclick = async () => { + if (!confirm(t('scanConfirm'))) return; + scanBtn.innerHTML = `⏳ ${t('scanning')}`; + scanBtn.disabled = true; + try { + const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder }); + const res = await fetch('/anomalous/scan?' + params.toString(), {method: 'POST'}); + const data = await res.json(); + if (data.status === 'ok') { + alert(t('scanBg')); + const poll = setInterval(async () => { + try { + const sr = await fetch('/anomalous/scan_status?' + params.toString()); + const sd = await sr.json(); + if (!sd.scanning) { + clearInterval(poll); + scanBtn.innerHTML = `✅ ${t('scanDone')}`; + + let msg = t('scanCompleteMsg'); + if (sd.result) { + msg = `✅ 扫描结束!\n成功处理:${sd.result.success} 个\n处理失败:${sd.result.fail} 个\n\n⚠️ 提示:请点击 ComfyUI 的 [Refresh] 按钮以同步,若有模型未生效,请【重启 ComfyUI 服务端】。`; + if (document.documentElement.lang !== 'zh') { + msg = `✅ Scan Complete!\nSuccess: ${sd.result.success}\nFailed: ${sd.result.fail}\n\n⚠️ Tip: Click [Refresh] in ComfyUI, or RESTART the ComfyUI backend if models don't appear.`; + } + } else { + msg = msg.replace('[Refresh] 按钮以同步最新的模型列表。', '[Refresh] 按钮以同步,若有模型未生效,请【重启 ComfyUI 服务端】。'); + } + alert(msg); + + this.loadModels(); + setTimeout(() => { scanBtn.innerHTML = `🔄 ${t('scan')}`; scanBtn.disabled = false; }, 2000); + } + } catch(e) { clearInterval(poll); scanBtn.disabled = false; } + }, 3000); + } else { + scanBtn.disabled = false; + } + } catch (e) { scanBtn.disabled = false; } + }; + + const energyBtn = document.createElement('button'); + energyBtn.id = 'anomalous-energy-btn'; + energyBtn.title = t('togglePlayTitle'); + const renderEnergyBtn = () => { + energyBtn.innerHTML = this.energySaving + ? `🔋 ${t('eco')}` + : `🎬 ${t('autoPlay')}`; + }; + renderEnergyBtn(); + energyBtn.onclick = () => { + this.energySaving = !this.energySaving; + localStorage.setItem('anomalous_energy_saving', this.energySaving); + renderEnergyBtn(); + this.loadModels(); + }; + + const closeBtn = document.createElement('div'); + closeBtn.id = 'anomalous-close'; + closeBtn.innerHTML = '×'; + closeBtn.onclick = () => this.close(); + + const cleanBtn = document.createElement('button'); + cleanBtn.id = 'anomalous-clean-btn'; + cleanBtn.title = t('cleanTitle'); + cleanBtn.innerHTML = `🧹 ${t('clean')}`; + cleanBtn.onclick = async () => { + if (!confirm(t('cleanConfirm'))) return; + cleanBtn.innerHTML = `⏳ ${t('cleaning')}`; + cleanBtn.disabled = true; + try { + const res = await fetch('/anomalous/clean_civitai_info', {method: 'POST'}); + const data = await res.json(); + if (data.status === 'success') { + alert(`${t('cleanDone')} ${data.count} ${t('files')}`); + } else { + alert(t('cleanFail') + data.message); + } + } catch (e) { alert(t('cleanErr') + e.message); } + cleanBtn.innerHTML = `🧹 ${t('clean')}`; + cleanBtn.disabled = false; + }; + header.appendChild(helpBtn); + + const nbBtn = document.createElement('button'); + nbBtn.title = t('notebooksTitle') || 'Notebooks'; + nbBtn.innerHTML = `📓 ${t('notebooks') || 'Notebooks'}`; + nbBtn.onclick = () => { + if (!this.notebookManager) this.notebookManager = new NotebookManager(this); + this.notebookManager.show(); + }; + nbBtn.style.padding = '8px 15px'; + nbBtn.style.background = '#444'; + nbBtn.style.color = '#fff'; + nbBtn.style.border = 'none'; + nbBtn.style.borderRadius = '4px'; + nbBtn.style.cursor = 'pointer'; + nbBtn.style.display = 'flex'; + nbBtn.style.alignItems = 'center'; + nbBtn.style.gap = '8px'; + header.appendChild(nbBtn); + header.appendChild(apiKeyInput); + header.appendChild(cleanBtn); + header.appendChild(scanBtn); + header.appendChild(energyBtn); + header.appendChild(closeBtn); + + this.grid = document.createElement('div'); + this.grid.id = 'anomalous-grid'; + + this.detailPanel = document.createElement('div'); + this.detailPanel.id = 'anomalous-detail'; + this.detailPanel.style.display = 'none'; + + content.appendChild(header); + content.appendChild(this.grid); + content.appendChild(this.detailPanel); + + container.appendChild(this.sidebarWrapper); + container.appendChild(content); + + this.modal.appendChild(container); + document.body.appendChild(this.modal); + } + + + showHelp() { + if (this.helpModal) { + this.helpModal.remove(); + } + this.helpModal = document.createElement('div'); + this.helpModal.style.position = 'absolute'; + this.helpModal.style.top = '0'; + this.helpModal.style.left = '0'; + this.helpModal.style.width = '100%'; + this.helpModal.style.height = '100%'; + this.helpModal.style.background = 'rgba(0,0,0,0.85)'; + this.helpModal.style.zIndex = '9999'; + this.helpModal.style.display = 'flex'; + this.helpModal.style.alignItems = 'center'; + this.helpModal.style.justifyContent = 'center'; + + const box = document.createElement('div'); + box.style.background = 'var(--bg-color, #222)'; + box.style.border = '1px solid var(--border-color, #444)'; + box.style.borderRadius = '8px'; + box.style.width = '550px'; + box.style.maxWidth = '90%'; + box.style.boxShadow = '0 10px 40px rgba(0,0,0,0.8)'; + box.style.display = 'flex'; + box.style.flexDirection = 'column'; + box.style.maxHeight = '90vh'; + + const header = document.createElement('div'); + header.style.padding = '15px 20px'; + header.style.borderBottom = '1px solid #444'; + header.style.background = '#333'; + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.justifyContent = 'space-between'; + header.style.flexWrap = 'wrap'; + header.style.gap = '10px'; + + const title = document.createElement('h2'); + title.innerHTML = t('helpTitle'); + title.style.margin = '0'; + title.style.color = '#fff'; + title.style.fontSize = '1.2em'; + + const closeX = document.createElement('div'); + closeX.innerHTML = '×'; + closeX.style.fontSize = '1.8em'; + closeX.style.cursor = 'pointer'; + closeX.style.color = '#ff4444'; + closeX.onclick = () => this.helpModal.remove(); + + header.appendChild(title); + header.appendChild(closeX); + + const body = document.createElement('div'); + body.style.padding = '20px'; + body.innerHTML = t('helpContent'); + body.style.overflowY = 'auto'; + body.style.flex = '1'; + + const footer = document.createElement('div'); + footer.style.padding = '15px'; + footer.style.borderTop = '1px solid #444'; + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + + const closeBtn = document.createElement('button'); + closeBtn.innerHTML = t('closeHelp'); + closeBtn.style.padding = '8px 16px'; + closeBtn.style.background = '#444'; + closeBtn.style.color = '#fff'; + closeBtn.style.border = 'none'; + closeBtn.style.borderRadius = '4px'; + closeBtn.style.cursor = 'pointer'; + closeBtn.onclick = () => this.helpModal.remove(); + + footer.appendChild(closeBtn); + + box.appendChild(header); + box.appendChild(body); + box.appendChild(footer); + this.helpModal.appendChild(box); + + document.getElementById('anomalous-container').appendChild(this.helpModal); + } + + async loadFolders() { + try { + const res = await fetch('/anomalous/folders'); + const data = await res.json(); + this.foldersData = data.folders || {}; + + // Auto expand all + (this.foldersData || []).forEach(typeGroup => { + this.expandedFolders.add(typeGroup.type); + Object.keys(typeGroup.folders).forEach(path => { + this.expandedFolders.add(typeGroup.type + path); + }); + }); + + this.renderSidebar(); + this.loadModels(); + } catch(e) {} + } + + renderSidebar() { + this.sidebar.innerHTML = ''; + + const topBar = document.createElement('div'); + topBar.style.display = 'flex'; + topBar.style.flexDirection = 'column'; + topBar.style.gap = '10px'; + topBar.style.padding = '15px'; + + const logoRow = document.createElement('div'); + logoRow.innerHTML = '📦 ANOMALOUS BROWSER'; + + const folderRow = document.createElement('div'); + folderRow.style.display = 'flex'; + folderRow.style.justifyContent = 'space-between'; + folderRow.style.alignItems = 'center'; + + const title = document.createElement('h3'); + title.innerHTML = t('folders'); + title.style.color = '#fff'; + title.style.margin = '0'; + + const isAllCollapsed = this.expandedFolders.size === 0; + const collapseAllBtn = document.createElement('button'); + collapseAllBtn.innerHTML = isAllCollapsed ? t('expandAll') : t('collapseAll'); + collapseAllBtn.style.padding = '4px 8px'; + collapseAllBtn.style.background = '#444'; + collapseAllBtn.style.color = '#fff'; + collapseAllBtn.style.border = 'none'; + collapseAllBtn.style.borderRadius = '4px'; + collapseAllBtn.style.cursor = 'pointer'; + collapseAllBtn.style.whiteSpace = 'nowrap'; + collapseAllBtn.onclick = () => { + if (isAllCollapsed) { + (this.foldersData || []).forEach(typeGroup => { + this.expandedFolders.add(typeGroup.type); + Object.keys(typeGroup.folders).forEach(path => { + this.expandedFolders.add(typeGroup.type + path); + }); + }); + } else { + this.expandedFolders.clear(); + } + this.renderSidebar(); + }; + + folderRow.appendChild(title); + folderRow.appendChild(collapseAllBtn); + + topBar.appendChild(logoRow); + topBar.appendChild(folderRow); + + this.sidebar.appendChild(topBar); + + (this.foldersData || []).forEach(typeGroup => { + const header = document.createElement('div'); + header.className = 'anomalous-type-header'; + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.flexWrap = 'wrap'; + header.style.gap = '10px'; + header.style.cursor = 'pointer'; + + const isTypeExpanded = this.expandedFolders.has(typeGroup.type); + header.innerHTML = `${typeGroup.label} ${isTypeExpanded ? '▼' : '▶'}`; + + header.onclick = () => { + if (isTypeExpanded) this.expandedFolders.delete(typeGroup.type); + else this.expandedFolders.add(typeGroup.type); + this.renderSidebar(); + }; + this.sidebar.appendChild(header); + + if (!isTypeExpanded) return; + + const sortedPaths = Object.keys(typeGroup.folders).sort(); + + sortedPaths.forEach(path => { + const info = typeGroup.folders[path]; + const parts = path.split('/').filter(p => p); + const parentPath = parts.length > 1 ? '/' + parts.slice(0, -1).join('/') : '/'; + + if (path !== '/' && parentPath !== '/') { + let parentId = typeGroup.type + parentPath; + if (!this.expandedFolders.has(parentId)) return; + } + + const hasChildren = sortedPaths.some(p => p !== path && p.startsWith(path === '/' ? '/' : path + '/')); + + const item = document.createElement('div'); + item.className = 'anomalous-folder-item'; + + const depth = path === '/' ? 0 : parts.length; + item.style.paddingLeft = (15 + depth * 15) + 'px'; + + const myId = typeGroup.type + path; + const isExpanded = this.expandedFolders.has(myId); + + let toggleIcon = ''; + if (hasChildren) { + toggleIcon = `${isExpanded ? '▼' : '▶'}`; + } else { + toggleIcon = ``; + } + + item.innerHTML = `${toggleIcon}📁 ${info.name} (${info.model_count})`; + + if (this.currentType === typeGroup.type && this.currentPathIdx === typeGroup.path_idx && this.currentSubfolder === path) { + item.classList.add('active'); + } + + item.onclick = (e) => { + if (e.target.classList.contains('anomalous-folder-toggle')) { + if (isExpanded) this.expandedFolders.delete(myId); + else this.expandedFolders.add(myId); + this.renderSidebar(); + return; + } + this.currentType = typeGroup.type; + this.currentPathIdx = typeGroup.path_idx; + this.currentSubfolder = path; + + this.detailPanel.style.display = 'none'; + this.grid.style.display = 'grid'; + + this.renderSidebar(); + this.loadModels(); + }; + + this.sidebar.appendChild(item); + }); + }); + } + + async loadModels() { + try { + const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder }); + const res = await fetch('/anomalous/models?' + params.toString()); + const data = await res.json(); + this.grid.innerHTML = ''; + + if (!data.models || data.models.length === 0) { + this.grid.innerHTML = `
${t('noModels')}
`; + return; + } + + data.models.forEach(model => { + const card = document.createElement('div'); + card.className = 'anomalous-card'; + if (model.preview_url) { + const isVideo = model.preview_url.toLowerCase().endsWith('.mp4') || model.preview_url.toLowerCase().endsWith('.webm'); + if (isVideo) { + const video = document.createElement('video'); + video.src = model.preview_url; + video.muted = true; video.loop = true; video.playsInline = true; + if (this.energySaving) { + video.pause(); + card.addEventListener('mouseenter', () => video.play().catch(e=>{})); + card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; }); + } else { + video.autoplay = true; + } + card.appendChild(video); + } else { + const img = document.createElement('img'); + img.src = model.preview_url; + card.appendChild(img); + } + } else { + const ph = document.createElement('div'); + ph.className = 'anomalous-card-placeholder'; + ph.innerHTML = `
${t('noPreview')}
${t('clickScan')}
`; + card.appendChild(ph); + } + const title = document.createElement('div'); + if (model.metadata && model.metadata.baseModel) { + const badge = document.createElement('div'); + badge.innerText = model.metadata.baseModel; + badge.style.position = 'absolute'; + badge.style.top = '6px'; + badge.style.left = '6px'; + badge.style.background = 'rgba(0,0,0,0.85)'; + badge.style.color = '#00ffcc'; + badge.style.padding = '3px 6px'; + badge.style.borderRadius = '4px'; + badge.style.fontSize = '0.75em'; + badge.style.fontWeight = 'bold'; + badge.style.border = '1px solid rgba(0,255,204,0.3)'; + badge.style.pointerEvents = 'none'; + badge.style.zIndex = '10'; + card.appendChild(badge); + } + title.className = 'anomalous-card-title'; + title.innerText = model.filename; + card.appendChild(title); + + card.onclick = () => { this.historyStack = []; this.currentDetailModel = model; this.showDetail(model); }; + + const applyBtn = document.createElement('button'); + applyBtn.innerHTML = '➕'; + applyBtn.title = t('applyToCanvas'); + applyBtn.style.position = 'absolute'; + applyBtn.style.top = '6px'; + applyBtn.style.right = '6px'; + applyBtn.style.background = 'rgba(0,0,0,0.7)'; + applyBtn.style.color = '#fff'; + applyBtn.style.border = '1px solid rgba(255,255,255,0.2)'; + applyBtn.style.borderRadius = '4px'; + applyBtn.style.cursor = 'pointer'; + applyBtn.style.padding = '4px 6px'; + applyBtn.style.zIndex = '20'; + applyBtn.style.fontSize = '1em'; + applyBtn.style.display = 'none'; + + card.addEventListener('mouseenter', () => applyBtn.style.display = 'block'); + card.addEventListener('mouseleave', () => applyBtn.style.display = 'none'); + + applyBtn.onclick = (e) => { + e.stopPropagation(); + this.applyModelToCanvas(this.currentType, this.currentSubfolder, model); + }; + card.appendChild(applyBtn); + + + this.grid.appendChild(card); + }); + } catch(e) {} + } + + + applyModelToCanvas(type, subfolder, model) { + const nodeTypeMap = { + 'checkpoints': 'CheckpointLoaderSimple', + 'loras': 'LoraLoader', + 'unet': 'UNETLoader', + 'diffusion_models': 'UNETLoader' + }; + const nodeType = nodeTypeMap[type]; + if (!nodeType) { + alert('Unsupported model type for auto-apply.'); + return; + } + + const node = LiteGraph.createNode(nodeType); + if (!node) { + alert('Failed to create node: ' + nodeType); + return; + } + + if (app.canvas && app.canvas.graph_mouse) { + node.pos = [ + app.canvas.graph_mouse[0] || (window.innerWidth / 2), + app.canvas.graph_mouse[1] || (window.innerHeight / 2) + ]; + } else { + node.pos = [ window.innerWidth / 2, window.innerHeight / 2 ]; + } + + app.graph.add(node); + + const sub = subfolder.replace(/^\/+/, '').replace(/\/+$/, ''); + const relPath = sub ? `${sub}/${model.filename}` : model.filename; + + if (node.widgets) { + // First try to find a combo widget (dropdown) + const combo = node.widgets.find(w => w.type === 'combo'); + if (combo) { + combo.value = relPath; + } else if (node.widgets.length > 0) { + // fallback to first widget + node.widgets[0].value = relPath; + } + } + +this.close(); + + // 粘到鼠标上的逻辑 + let isSticking = true; + const stickHandler = (e) => { + if (!isSticking || !app.canvas) return; + const canvas = app.canvas; + + // LiteGraph内置了坐标转换,它会完美处理缩放和偏移带来的坐标偏移问题 + let canvasX, canvasY; + if (canvas.convertEventToCanvasOffset) { + const pos = canvas.convertEventToCanvasOffset(e); + canvasX = pos[0]; + canvasY = pos[1]; + } else { + // 如果API不可用,使用标准降级计算 + const rect = canvas.canvas.getBoundingClientRect(); + canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale; + canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale; + } + + node.pos = [canvasX - node.size[0] / 2, canvasY - 20]; + canvas.setDirty(true, true); + }; + const dropHandler = (e) => { + isSticking = false; + window.removeEventListener('mousemove', stickHandler, true); + window.removeEventListener('pointerdown', dropHandler, true); + window.removeEventListener('mousedown', dropHandler, true); + window.removeEventListener('click', dropHandler, true); + }; + window.addEventListener('mousemove', stickHandler, true); + setTimeout(() => { + window.addEventListener('pointerdown', dropHandler, true); + window.addEventListener('mousedown', dropHandler, true); + window.addEventListener('click', dropHandler, true); + }, 100); + } + + showDetail(model) { + this.grid.style.display = 'none'; + this.detailPanel.style.display = 'flex'; + this.detailPanel.innerHTML = ''; + + const header = document.createElement('div'); + header.style.width = '100%'; + header.style.padding = '8px 15px'; + header.style.background = 'var(--comfy-menu-bg, #333)'; + header.style.borderBottom = '1px solid var(--border-color, #444)'; + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.boxSizing = 'border-box'; + +const backBtn = document.createElement('button'); + backBtn.innerHTML = this.historyStack.length > 0 ? t('backToPrev') : t('back'); + backBtn.style.padding = '6px 12px'; + backBtn.style.background = '#444'; + backBtn.style.color = '#fff'; + backBtn.style.border = 'none'; + backBtn.style.borderRadius = '4px'; + backBtn.style.cursor = 'pointer'; + backBtn.onclick = () => { + if (this.historyStack.length > 0) { + const prev = this.historyStack.pop(); + this.currentType = prev.type; + this.currentPathIdx = prev.pathIdx; + this.currentSubfolder = prev.subfolder; + this.currentDetailModel = prev.model; + this.renderSidebar(); + this.showDetail(prev.model); + } else { + this.detailPanel.style.display = 'none'; + this.detailPanel.innerHTML = ''; + this.grid.style.display = 'grid'; + } + }; + + const title = document.createElement('h2'); + title.innerHTML = model.filename; + title.style.margin = '0 20px 0 20px'; + title.style.color = '#fff'; + title.style.fontSize = '1.2em'; + // 强制单行并溢出显示省略号 + title.style.whiteSpace = 'nowrap'; + title.style.overflow = 'hidden'; + title.style.textOverflow = 'ellipsis'; + title.style.flex = '1'; // 撑开剩余空间,把右侧按钮挤到最右边 + + const delBtn = document.createElement('button'); + delBtn.innerHTML = t('delModel'); + delBtn.style.padding = '6px 12px'; + delBtn.style.background = '#ff4444'; + delBtn.style.color = '#fff'; + delBtn.style.border = 'none'; + delBtn.style.borderRadius = '4px'; + delBtn.style.cursor = 'pointer'; + delBtn.style.marginLeft = 'auto'; // push to the right + delBtn.style.whiteSpace = 'nowrap'; + delBtn.onclick = async () => { + if (!confirm(`${t('delConfirm')} ${model.filename} ${t('delConfirm2')}`)) return; + try { + const res = await fetch('/anomalous/delete_model', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: this.currentType, + path_idx: this.currentPathIdx, + subfolder: this.currentSubfolder, + filename: model.filename + }) + }); + const data = await res.json(); + if (data.status === 'success') { + alert(t('delSuccess') + data.deleted.join('\n') + t('delNote')); + this.detailPanel.style.display = 'none'; + this.detailPanel.innerHTML = ''; + this.grid.style.display = 'grid'; + this.loadModels(); // refresh grid + } else { + alert(t('delFail') + data.message); + } + } catch (e) { + alert(t('delFail') + e.message); + } + }; + + const jumpBtn = document.createElement('button'); + jumpBtn.innerHTML = '⬇️'; + jumpBtn.title = t('jumpToBottom') || 'Jump to bottom'; + jumpBtn.style.padding = '6px 12px'; + jumpBtn.style.background = '#444'; + jumpBtn.style.color = '#fff'; + jumpBtn.style.border = 'none'; + jumpBtn.style.borderRadius = '4px'; + jumpBtn.style.cursor = 'pointer'; + jumpBtn.style.marginLeft = '10px'; + jumpBtn.onclick = () => { + // Find rightPanel which is created later, so we bind it dynamically + const rp = this.detailPanel.querySelector('.anomalous-split-right'); + if (rp) rp.scrollTo({ top: rp.scrollHeight, behavior: 'smooth' }); + }; + + header.appendChild(backBtn); + header.appendChild(title); + header.appendChild(delBtn); + header.appendChild(jumpBtn); + + const applyDetailBtn = document.createElement('button'); + applyDetailBtn.innerHTML = t('applyToCanvas'); + applyDetailBtn.style.padding = '6px 12px'; + applyDetailBtn.style.background = '#28a745'; + applyDetailBtn.style.color = '#fff'; + applyDetailBtn.style.border = 'none'; + applyDetailBtn.style.borderRadius = '4px'; + applyDetailBtn.style.cursor = 'pointer'; + applyDetailBtn.style.marginLeft = '10px'; + applyDetailBtn.style.fontWeight = 'bold'; + applyDetailBtn.onclick = () => { + this.applyModelToCanvas(this.currentType, this.currentSubfolder, model); + }; + header.appendChild(applyDetailBtn); + + + const splitContainer = document.createElement('div'); + splitContainer.className = 'anomalous-split-container'; + + const leftPanel = document.createElement('div'); + leftPanel.className = 'anomalous-split-left'; + + if (model.preview_url) { + const isVideo = model.preview_url.toLowerCase().endsWith('.mp4') || model.preview_url.toLowerCase().endsWith('.webm'); + if (isVideo) { + const video = document.createElement('video'); + video.src = model.preview_url; + video.controls = true; + video.autoplay = true; + video.loop = true; + video.style.width = '100%'; + video.style.height = '100%'; + video.style.objectFit = 'contain'; + leftPanel.appendChild(video); + } else { + const img = document.createElement('img'); + img.src = model.preview_url; + img.style.width = '100%'; + img.style.height = '100%'; + img.style.objectFit = 'contain'; + leftPanel.appendChild(img); + } + } else { + leftPanel.innerHTML = `
${t('noPreview')}
`; + } + + const resizer = document.createElement('div'); + resizer.className = 'anomalous-resizer'; + + let isResizing = false; + resizer.addEventListener('mousedown', (e) => { + isResizing = true; + document.body.style.cursor = 'col-resize'; + e.preventDefault(); + }); + + window.addEventListener('mousemove', (e) => { + if (!isResizing) return; + const containerRect = splitContainer.getBoundingClientRect(); + let newWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; + if (newWidth < 20) newWidth = 20; + if (newWidth > 80) newWidth = 80; + leftPanel.style.width = newWidth + '%'; + rightPanel.style.width = (100 - newWidth) + '%'; + }); + + window.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + document.body.style.cursor = ''; + } + }); + + const rightPanel = document.createElement('div'); + rightPanel.className = 'anomalous-split-right'; + rightPanel.style.display = 'flex'; + rightPanel.style.flexDirection = 'column'; + rightPanel.style.height = '100%'; + rightPanel.style.boxSizing = 'border-box'; + rightPanel.style.overflow = 'auto'; + rightPanel.style.padding = '15px'; + rightPanel.style.color = '#eee'; + + const m = model.metadata || {}; + + // 1. Top Bar (Title + Size + Model + Button) in a single compact row if possible + const topRow = document.createElement('div'); + topRow.style.flexShrink = '0'; + topRow.style.display = 'flex'; + topRow.style.flexWrap = 'wrap'; + topRow.style.alignItems = 'center'; + topRow.style.gap = '10px'; + topRow.style.paddingBottom = '10px'; + topRow.style.marginBottom = '10px'; + topRow.style.borderBottom = '1px solid #444'; + + const titleEl = document.createElement('h3'); + titleEl.style.margin = '0'; + titleEl.style.fontSize = '1.3em'; + titleEl.style.marginRight = '10px'; + titleEl.innerText = m.name || model.filename; + topRow.appendChild(titleEl); + + const metaSpan = document.createElement('span'); + metaSpan.style.fontSize = '0.9em'; + metaSpan.style.color = '#aaa'; + metaSpan.innerHTML = `Size: ${model.size_mb} MB` + (m.baseModel ? ` Base: ${m.baseModel}` : ''); + topRow.appendChild(metaSpan); + + if (m.civitai_url) { + const cBtn = document.createElement('a'); + cBtn.href = m.civitai_url; + cBtn.target = '_blank'; + cBtn.innerHTML = '🌐 Civitai'; + cBtn.style.marginLeft = 'auto'; + cBtn.style.padding = '4px 8px'; + cBtn.style.background = '#1a73e8'; + cBtn.style.color = '#fff'; + cBtn.style.textDecoration = 'none'; + cBtn.style.borderRadius = '4px'; + cBtn.style.fontSize = '0.85em'; + cBtn.style.fontWeight = 'bold'; + topRow.appendChild(cBtn); + } + + rightPanel.appendChild(topRow); + + // 2. Trained Words Section + if (m.trainedWords && m.trainedWords.length > 0) { + const twCont = document.createElement('div'); + twCont.style.flexShrink = '0'; + twCont.style.marginBottom = '10px'; + + const twHeader = document.createElement('div'); + twHeader.style.display = 'flex'; + twHeader.style.alignItems = 'center'; + twHeader.style.marginBottom = '5px'; + + const twLabel = document.createElement('strong'); + twLabel.innerText = 'Trained Words:'; + twHeader.appendChild(twLabel); + + const copyAll = document.createElement('button'); + copyAll.innerText = t('copyAll'); + copyAll.style.marginLeft = '10px'; + copyAll.style.padding = '2px 6px'; + copyAll.style.background = '#444'; + copyAll.style.color = '#fff'; + copyAll.style.border = 'none'; + copyAll.style.borderRadius = '3px'; + copyAll.style.cursor = 'pointer'; + copyAll.style.fontSize = '0.8em'; + copyAll.onclick = () => { + const allWords = m.trainedWords.join(', '); + navigator.clipboard.writeText(allWords).then(() => { + const old = copyAll.innerText; + copyAll.innerText = t('copied'); + setTimeout(() => { copyAll.innerText = old; }, 1500); + }); + }; + twHeader.appendChild(copyAll); + twCont.appendChild(twHeader); + + const tagsCont = document.createElement('div'); + tagsCont.style.display = 'flex'; + tagsCont.style.flexWrap = 'wrap'; + tagsCont.style.gap = '4px'; + + m.trainedWords.forEach(w => { + const tag = document.createElement('span'); + tag.innerText = w; + tag.style.background = '#333'; + tag.style.padding = '2px 6px'; + tag.style.borderRadius = '4px'; + tag.style.fontSize = '0.85em'; + tag.style.cursor = 'pointer'; + tag.style.border = '1px solid #555'; + tag.title = t('clickToCopy') + w; + tag.onclick = () => { + navigator.clipboard.writeText(w).then(() => { + tag.style.background = '#28a745'; + setTimeout(() => { tag.style.background = '#333'; }, 500); + }); + }; + tagsCont.appendChild(tag); + }); + twCont.appendChild(tagsCont); + rightPanel.appendChild(twCont); + } + + // 3. Description Section (Expands to fill remaining height) + if (m.description) { + const descCont = document.createElement('div'); + descCont.style.flex = 'none'; + descCont.style.display = 'flex'; + descCont.style.flexDirection = 'column'; + // important for flex scroll + + const descLabel = document.createElement('strong'); + descLabel.innerText = 'Description:'; + descLabel.style.marginBottom = '5px'; + descCont.appendChild(descLabel); + + const descText = document.createElement('div'); + descText.style.flex = 'none'; + + descText.style.background = '#222'; + descText.style.padding = '10px'; + descText.style.borderRadius = '6px'; + descText.style.border = '1px solid #333'; + descText.style.fontSize = '0.95em'; + descText.style.lineHeight = '1.4'; + descText.innerHTML = m.description; + descCont.appendChild(descText); + + rightPanel.appendChild(descCont); + } + + // 4. Notes Section + if (m.notes) { + const notesCont = document.createElement('div'); + notesCont.style.flexShrink = '0'; + notesCont.style.marginTop = '10px'; + + const notesLabel = document.createElement('strong'); + notesLabel.innerText = 'Notes:'; + notesCont.appendChild(notesLabel); + + const notesText = document.createElement('div'); + notesText.style.background = '#332b00'; + notesText.style.padding = '8px'; + notesText.style.borderRadius = '6px'; + notesText.style.marginTop = '5px'; + notesText.style.border = '1px solid #554400'; + notesText.style.fontSize = '0.9em'; + notesText.innerHTML = m.notes; + notesCont.appendChild(notesText); + + rightPanel.appendChild(notesCont); + } + + // --- Compatible Models Section --- + if (m.baseModel) { + const compSec = document.createElement('div'); + compSec.className = 'anomalous-compatible-section'; + + const compTitle = document.createElement('div'); + compTitle.className = 'anomalous-compatible-title'; + compTitle.innerHTML = `${t('compatibleModels') || '🔗 Compatible'} (${m.baseModel})`; + + const compList = document.createElement('div'); + compList.className = 'anomalous-compatible-list'; + compList.innerHTML = `${t('loadingCompatible') || 'Loading...'}`; + + compSec.appendChild(compTitle); + compSec.appendChild(compList); + rightPanel.appendChild(compSec); + + const targetType = this.currentType === 'loras' ? 'checkpoints,unet,diffusion_models' : 'loras'; + fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(m.baseModel)}&target_type=${encodeURIComponent(targetType)}`) + .then(r => r.json()) + .then(d => { + compList.innerHTML = ''; + if (!d.models || d.models.length === 0) { + compList.innerHTML = `No compatible models found.`; + return; + } + d.models.forEach(m_comp => { + const mItem = document.createElement('div'); + mItem.className = 'anomalous-compatible-item'; + mItem.title = m_comp.filename; + + let thumb = ''; + let isVid = false; + if (m_comp.preview_url) { + isVid = m_comp.preview_url.toLowerCase().endsWith('.mp4') || m_comp.preview_url.toLowerCase().endsWith('.webm'); + if (isVid) thumb = ``; + else thumb = ``; + } else { + thumb = `
?
`; + } + + mItem.innerHTML = `${thumb}
${m_comp.filename}
`; + if (isVid) { + mItem.onmouseenter = () => { const v = mItem.querySelector('video'); if(v) v.play(); }; + mItem.onmouseleave = () => { const v = mItem.querySelector('video'); if(v) { v.pause(); v.currentTime = 0; } }; + } + + mItem.onclick = () => { + this.historyStack.push({ + type: this.currentType, + pathIdx: this.currentPathIdx, + subfolder: this.currentSubfolder, + model: this.currentDetailModel + }); + + this.currentType = m_comp.type; + this.currentPathIdx = m_comp.path_idx; + this.currentSubfolder = m_comp.subfolder; + this.currentDetailModel = m_comp; + + this.renderSidebar(); + this.showDetail(m_comp); + }; + + compList.appendChild(mItem); + }); + }) + .catch(e => { + compList.innerHTML = `Failed to load.`; + }); + } + // --------------------------------- + + splitContainer.appendChild(leftPanel); + splitContainer.appendChild(resizer); + splitContainer.appendChild(rightPanel); + + this.detailPanel.appendChild(header); + this.detailPanel.appendChild(splitContainer); + } + + show() { + this.modal.classList.add('visible'); + if (!this.foldersData) { + this.loadFolders(); + } else { + this.loadModels(); + } + } + close() { this.modal.classList.remove('visible'); } +} + +app.registerExtension({ + name: 'Anomalous.ModelBrowser', + async setup() { + try { + const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now(); + if (!document.querySelector(`link[href^="/extensions/Anomalous_Model_Browser/styles.css"]`)) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = cssUrl; + document.head.appendChild(link); + } + + const browser = new AnomalousBrowser(); + const btn = document.createElement('button'); + btn.id = 'anomalous-trigger-btn'; + btn.innerHTML = '📦'; + btn.title = 'Open Anomalous Model Browser'; + btn.style.position = 'fixed'; + btn.style.bottom = '20px'; + btn.style.right = '20px'; + btn.style.zIndex = '99999'; + btn.style.width = '48px'; + btn.style.height = '48px'; + btn.style.borderRadius = '50%'; + btn.style.border = '2px solid #555'; + btn.style.background = 'linear-gradient(135deg, #1a1a2e, #16213e)'; + btn.style.color = '#fff'; + btn.style.fontSize = '22px'; + btn.style.cursor = 'pointer'; + btn.style.boxShadow = '0 4px 15px rgba(0,0,0,0.5)'; + btn.style.transition = 'transform 0.2s, box-shadow 0.2s'; + btn.onmouseenter = () => { btn.style.transform = 'scale(1.15)'; btn.style.boxShadow = '0 6px 20px rgba(100,100,255,0.4)'; }; + btn.onmouseleave = () => { btn.style.transform = 'scale(1)'; btn.style.boxShadow = '0 4px 15px rgba(0,0,0,0.5)'; }; + btn.onclick = () => browser.show(); + document.body.appendChild(btn); + } catch(e) { + console.error('[Anomalous Model Browser] Setup error:', e); + } + } +}); + + +class NotebookManager { + constructor(browser) { + this.browser = browser; + this.modal = null; + this.currentNotebook = null; + this.checkpoints = []; + this.loras = []; + this.init(); + } + + async init() { + try { + const res = await fetch('/anomalous/all_models'); + const data = await res.json(); + this.checkpoints = data.checkpoints || []; + this.loras = data.loras || []; + + const bases = new Set(); + [...this.checkpoints, ...this.loras].forEach(m => { + if (m.baseModel && m.baseModel.trim() !== '') { + bases.add(m.baseModel.trim()); + } + }); + this.dynamicBases = Array.from(bases).sort(); + } catch(e) {} + } + + show() { + if (this.modal) this.modal.remove(); + + this.modal = document.createElement('div'); + this.modal.style.position = 'absolute'; + this.modal.style.top = '0'; + this.modal.style.left = '0'; + this.modal.style.width = '100%'; + this.modal.style.height = '100%'; + this.modal.style.background = 'rgba(0,0,0,0.85)'; + this.modal.style.zIndex = '99999'; + this.modal.style.display = 'flex'; + this.modal.style.alignItems = 'center'; + this.modal.style.justifyContent = 'center'; + + const box = document.createElement('div'); + box.style.background = '#222'; + box.style.border = '1px solid #444'; + box.style.borderRadius = '8px'; + box.style.width = '1000px'; + box.style.maxWidth = '95%'; + box.style.height = '80vh'; + box.style.display = 'flex'; + box.style.flexDirection = 'column'; + + const header = document.createElement('div'); + header.style.padding = '15px 20px'; + header.style.borderBottom = '1px solid #444'; + header.style.background = '#333'; + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.justifyContent = 'space-between'; + header.style.flexWrap = 'wrap'; + header.style.gap = '10px'; + + const title = document.createElement('h2'); + title.innerHTML = '📓 Prompt Notebooks'; + title.style.margin = '0'; + title.style.color = '#fff'; + + const closeX = document.createElement('div'); + closeX.innerHTML = '×'; + closeX.style.fontSize = '1.8em'; + closeX.style.cursor = 'pointer'; + closeX.style.color = '#ff4444'; + closeX.onclick = () => this.modal.remove(); + + header.appendChild(title); + header.appendChild(closeX); + + this.body = document.createElement('div'); + this.body.style.flex = '1'; + this.body.style.display = 'flex'; + this.body.style.overflow = 'hidden'; + + box.appendChild(header); + box.appendChild(this.body); + this.modal.appendChild(box); + document.body.appendChild(this.modal); + + this.renderList(); + } + + async renderList() { + this.body.innerHTML = ''; + + const sidebar = document.createElement('div'); + sidebar.style.width = '250px'; + sidebar.style.borderRight = '1px solid #444'; + sidebar.style.display = 'flex'; + sidebar.style.flexDirection = 'column'; + sidebar.style.background = '#1a1a1a'; + + const topBar = document.createElement('div'); + topBar.style.padding = '10px'; + topBar.style.borderBottom = '1px solid #333'; + const newBtn = document.createElement('button'); + newBtn.innerHTML = '➕ New Notebook'; + newBtn.style.width = '100%'; + newBtn.style.padding = '8px'; + newBtn.style.background = '#4CAF50'; + newBtn.style.color = '#fff'; + newBtn.style.border = 'none'; + newBtn.style.borderRadius = '4px'; + newBtn.style.cursor = 'pointer'; + newBtn.onclick = () => this.openEditor({ name: 'New_Notebook', baseModel: '', checkpoint: '', loras: [], prompt: '' }, true); + topBar.appendChild(newBtn); + sidebar.appendChild(topBar); + + const listCont = document.createElement('div'); + listCont.style.flex = '1'; + listCont.style.overflowY = 'auto'; + + try { + const res = await fetch('/anomalous/notebooks'); + const data = await res.json(); + if (data.notebooks) { + data.notebooks.forEach(nb => { + const item = document.createElement('div'); + item.style.padding = '12px 15px'; + item.style.borderBottom = '1px solid #333'; + item.style.cursor = 'pointer'; + item.style.color = '#ccc'; + item.innerHTML = `${nb.name}
${nb.baseModel || 'No Base'}`; + item.onmouseover = () => item.style.background = '#333'; + item.onmouseout = () => item.style.background = 'transparent'; + item.onclick = () => this.openEditor(nb, false); + listCont.appendChild(item); + }); + } + } catch(e) {} + + sidebar.appendChild(listCont); + this.body.appendChild(sidebar); + + this.editorArea = document.createElement('div'); + this.editorArea.style.flex = '1'; + this.editorArea.style.display = 'flex'; + this.editorArea.style.flexDirection = 'column'; + this.editorArea.style.background = '#222'; + this.editorArea.style.padding = '20px'; + this.editorArea.style.overflowY = 'auto'; + this.editorArea.innerHTML = '
Select or create a notebook to start
'; + this.body.appendChild(this.editorArea); + } + + openEditor(nb, isNew) { + this.currentNotebook = JSON.parse(JSON.stringify(nb)); + this.editorArea.innerHTML = ''; + + // Toolbar + const tb = document.createElement('div'); + tb.style.display = 'flex'; + tb.style.gap = '10px'; + tb.style.marginBottom = '15px'; + + const nameInput = document.createElement('input'); + nameInput.value = nb.name; + nameInput.style.flex = '1'; + nameInput.style.padding = '8px'; + nameInput.style.background = '#111'; + nameInput.style.color = '#fff'; + nameInput.style.border = '1px solid #444'; + nameInput.style.borderRadius = '4px'; + + const saveBtn = document.createElement('button'); + saveBtn.innerHTML = '💾 Save'; + saveBtn.style.padding = '8px 15px'; + saveBtn.style.background = '#007bff'; + saveBtn.style.color = '#fff'; + saveBtn.style.border = 'none'; + saveBtn.style.borderRadius = '4px'; + saveBtn.style.cursor = 'pointer'; + saveBtn.onclick = async () => { + this.currentNotebook.name = nameInput.value; + this.currentNotebook.prompt = this.promptArea.value; + await fetch('/anomalous/notebooks/save', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(this.currentNotebook) + }); + this.renderList(); + alert('Saved successfully!'); + }; + + const delBtn = document.createElement('button'); + delBtn.innerHTML = '🗑️ Delete'; + delBtn.style.padding = '8px 15px'; + delBtn.style.background = '#ff4444'; + delBtn.style.color = '#fff'; + delBtn.style.border = 'none'; + delBtn.style.borderRadius = '4px'; + delBtn.style.cursor = 'pointer'; + delBtn.onclick = async () => { + if(!confirm('Delete this notebook?')) return; + await fetch('/anomalous/notebooks/delete', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({name: nb.name}) + }); + this.renderList(); + }; + + const applyBtn = document.createElement('button'); + applyBtn.innerHTML = '🚀 Send to Canvas'; + applyBtn.style.padding = '8px 15px'; + applyBtn.style.background = '#28a745'; + applyBtn.style.color = '#fff'; + applyBtn.style.border = 'none'; + applyBtn.style.borderRadius = '4px'; + applyBtn.style.cursor = 'pointer'; + applyBtn.onclick = () => this.sendToCanvas(); + + tb.appendChild(nameInput); + tb.appendChild(saveBtn); + if (!isNew) tb.appendChild(delBtn); + tb.appendChild(applyBtn); + this.editorArea.appendChild(tb); + + // Model Selection Gallery + const modelsContainer = document.createElement('div'); + modelsContainer.style.display = 'flex'; + modelsContainer.style.flexDirection = 'column'; + modelsContainer.style.gap = '15px'; + modelsContainer.style.marginBottom = '15px'; + + const baseRow = document.createElement('div'); + const baseLabel = document.createElement('span'); + baseLabel.innerText = 'Base Model: '; + baseLabel.style.color = '#ccc'; + + const baseSel = document.createElement('select'); + const defaultBases = ['', 'SD 1.5', 'SDXL 1.0', 'Pony', 'Flux.1 Schnell', 'Flux.1 D', 'Illustrious']; + const allBases = Array.from(new Set([...defaultBases, ...(this.dynamicBases || [])])).filter(b => b !== 'Flux.1 S'); // Replaced Flux.1 S + + allBases.forEach(b => { + const opt = document.createElement('option'); + opt.value = b; opt.innerText = b || 'All Models (No Filter)'; + if (b === nb.baseModel) opt.selected = true; + baseSel.appendChild(opt); + }); + + baseRow.appendChild(baseLabel); + baseRow.appendChild(baseSel); + modelsContainer.appendChild(baseRow); + + const mainGallery = document.createElement('div'); + const loraGallery = document.createElement('div'); + modelsContainer.appendChild(mainGallery); + modelsContainer.appendChild(loraGallery); + + const createGallery = (titleText, container, models, isMulti, selectedState, onSelectionChange) => { + container.innerHTML = ` +
${titleText}
+ + `; + const grid = container.querySelector('.nb-gallery-grid'); + if (isMulti) { + grid.style.flexWrap = 'wrap'; + grid.style.overflowX = 'hidden'; + } + + // Webkit scrollbar styles via JS injection + grid.style.cssText += 'scrollbar-width: thin; scrollbar-color: #555 #222;'; + + if (models.length === 0) { + grid.innerHTML = 'No models found.'; + return; + } + + models.forEach(m => { + const card = document.createElement('div'); + card.style.minWidth = '100px'; + card.style.width = '100px'; + card.style.height = '130px'; + card.style.background = '#222'; + card.style.borderRadius = '6px'; + card.style.cursor = 'pointer'; + card.style.position = 'relative'; + card.style.overflow = 'hidden'; + card.style.border = '2px solid transparent'; + card.style.transition = 'border-color 0.2s'; + card.style.flexShrink = '0'; + card.title = m.filename; + + let thumbHTML = ''; + let isVid = false; + if (m.preview_url) { + isVid = m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm'); + if (isVid) thumbHTML = ``; + else thumbHTML = ``; + } else { + thumbHTML = `
?
`; + } + + const shortName = m.filename.split('/').pop().split('\\').pop(); + + card.innerHTML = ` + ${thumbHTML} +
${shortName}
+ + `; + + let isSelectedLocally = false; + const updateState = () => { + const badge = card.querySelector('.nb-badge'); + if (isMulti) { + const idx = selectedState.indexOf(m.filename); + if (idx !== -1) { + card.style.borderColor = '#6f42c1'; + badge.style.display = 'block'; + badge.innerText = (idx + 1).toString(); + isSelectedLocally = true; + } else { + card.style.borderColor = 'transparent'; + badge.style.display = 'none'; + isSelectedLocally = false; + } + } else { + if (selectedState === m.filename) { + card.style.borderColor = '#4CAF50'; + isSelectedLocally = true; + } else { + card.style.borderColor = 'transparent'; + isSelectedLocally = false; + } + } + }; + + updateState(); + + if (isVid) { + card.onmouseenter = () => { const v = card.querySelector('video'); if(v) v.play(); }; + card.onmouseleave = () => { const v = card.querySelector('video'); if(v) { v.pause(); v.currentTime=0; } }; + } + + card.onclick = () => { + if (isMulti) { + const idx = selectedState.indexOf(m.filename); + if (idx !== -1) { + selectedState.splice(idx, 1); + } else { + selectedState.push(m.filename); + } + onSelectionChange(selectedState); + } else { + // Single selection logic + selectedState = isSelectedLocally ? '' : m.filename; + onSelectionChange(selectedState); + } + }; + + grid.appendChild(card); + }); + }; + + const filterModels = () => { + const currentBase = baseSel.value; + this.currentNotebook.baseModel = currentBase; + + const filteredCkpts = this.checkpoints.filter(c => !currentBase || (c.baseModel === currentBase)); + const filteredLoras = this.loras.filter(l => !currentBase || (l.baseModel === currentBase)); + + if (!this.currentNotebook.loras) this.currentNotebook.loras = []; + + createGallery('Main Model (Select 1)', mainGallery, filteredCkpts, false, this.currentNotebook.checkpoint, (newVal) => { + this.currentNotebook.checkpoint = newVal; + filterModels(); + }); + + createGallery('Loras (Click multiple to chain in order)', loraGallery, filteredLoras, true, this.currentNotebook.loras, (newArr) => { + this.currentNotebook.loras = newArr; + filterModels(); + }); + }; + + baseSel.onchange = filterModels; + filterModels(); + + this.editorArea.appendChild(modelsContainer); + + // Find & Replace + const frDiv = document.createElement('div'); + frDiv.style.display = 'flex'; + frDiv.style.gap = '10px'; + frDiv.style.marginBottom = '10px'; + const fInput = document.createElement('input'); fInput.placeholder = 'Find...'; fInput.style.flex = '1'; fInput.style.padding = '6px'; + const rInput = document.createElement('input'); rInput.placeholder = 'Replace...'; rInput.style.flex = '1'; rInput.style.padding = '6px'; + const rBtn = document.createElement('button'); rBtn.innerText = 'Replace All'; rBtn.style.padding = '6px 12px'; + rBtn.onclick = () => { + if (!fInput.value) return; + const regex = new RegExp(fInput.value, 'gi'); + this.promptArea.value = this.promptArea.value.replace(regex, rInput.value); + this.currentNotebook.prompt = this.promptArea.value; + }; + frDiv.appendChild(fInput); frDiv.appendChild(rInput); frDiv.appendChild(rBtn); + this.editorArea.appendChild(frDiv); + + // Split view for translation + const splitView = document.createElement('div'); + splitView.style.flex = '1'; + splitView.style.display = 'flex'; + splitView.style.gap = '15px'; + splitView.style.overflow = 'hidden'; + splitView.style.minHeight = '400px'; + + const leftCol = document.createElement('div'); + leftCol.style.flex = '1'; + leftCol.style.display = 'flex'; + leftCol.style.flexDirection = 'column'; + leftCol.style.overflowY = 'auto'; + + this.promptArea = document.createElement('textarea'); + this.promptArea.value = nb.prompt || ''; + this.promptArea.style.flex = '1'; + this.promptArea.style.background = '#111'; + this.promptArea.style.color = '#fff'; + this.promptArea.style.border = '1px solid #444'; + this.promptArea.style.padding = '10px'; + this.promptArea.style.resize = 'none'; + this.promptArea.style.fontFamily = 'monospace'; + + const transBtn = document.createElement('button'); + transBtn.innerHTML = '🌐 Translate & Link'; + transBtn.style.padding = '8px'; + transBtn.style.marginTop = '10px'; + transBtn.style.background = '#6f42c1'; + transBtn.style.color = '#fff'; + transBtn.style.border = 'none'; + transBtn.style.borderRadius = '4px'; + transBtn.style.cursor = 'pointer'; + + leftCol.appendChild(this.promptArea); + leftCol.appendChild(transBtn); + + const rightCol = document.createElement('div'); + rightCol.style.flex = '1'; + rightCol.style.background = '#111'; + rightCol.style.border = '1px solid #444'; + rightCol.style.borderRadius = '4px'; + rightCol.style.padding = '10px'; + rightCol.style.overflowY = 'auto'; + + splitView.appendChild(leftCol); + splitView.appendChild(rightCol); + this.editorArea.appendChild(splitView); + + // Translate Logic + transBtn.onclick = async () => { + const text = this.promptArea.value.trim(); + if (!text) return; + + // Tag split mode + const tags = text.split(',').map(t => t.trim()); + const payload = tags.join('\n'); + + transBtn.innerHTML = '⏳ Translating...'; + try { + const res = await fetch('/anomalous/translate', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({text: payload, target: 'zh-CN'}) + }); + const data = await res.json(); + + const zhTags = data.translated.split('\n').map(t => t.trim()); + + // Render interactive viewer + leftCol.innerHTML = ''; + rightCol.innerHTML = ''; + + const backBtn = document.createElement('button'); + backBtn.innerHTML = '✏️ Edit Text'; + backBtn.style.padding = '8px'; + backBtn.style.marginBottom = '10px'; + backBtn.onclick = () => { + leftCol.innerHTML = ''; rightCol.innerHTML = ''; + leftCol.appendChild(this.promptArea); leftCol.appendChild(transBtn); + }; + leftCol.appendChild(backBtn); + + const leftTags = document.createElement('div'); + const rightTags = document.createElement('div'); + + tags.forEach((enTag, i) => { + if(!enTag) return; + const zhTag = zhTags[i] || ''; + + const el = document.createElement('span'); + el.innerText = enTag + ', '; + el.style.display = 'inline-block'; + el.style.padding = '2px'; + el.style.margin = '2px'; + el.style.borderRadius = '3px'; + el.style.cursor = 'pointer'; + + const zl = document.createElement('span'); + zl.innerText = zhTag + ','; + zl.style.display = 'inline-block'; + zl.style.padding = '2px'; + zl.style.margin = '2px'; + zl.style.borderRadius = '3px'; + + el.onmouseover = () => { el.style.background = '#4CAF50'; zl.style.background = '#4CAF50'; }; + el.onmouseout = () => { el.style.background = 'transparent'; zl.style.background = 'transparent'; }; + zl.onmouseover = el.onmouseover; + zl.onmouseout = el.onmouseout; + + leftTags.appendChild(el); + rightTags.appendChild(zl); + }); + + leftCol.appendChild(leftTags); + rightCol.appendChild(rightTags); + + } catch(e) { + alert('Translation failed.'); + } + transBtn.innerHTML = '🌐 Translate & Link'; + }; + } + + sendToCanvas() { + if (!window.LiteGraph || !app.graph) return; + + let x = window.innerWidth / 2; + let y = window.innerHeight / 2; + + if (app.canvas && app.canvas.graph_mouse) { + x = app.canvas.graph_mouse[0] || x; + y = app.canvas.graph_mouse[1] || y; + } + + let ckptNode = null; + let currentModelNode = null; + let currentModelSlot = 0; + let currentClipNode = null; + let currentClipSlot = 1; + + // 1. Create Checkpoint Node + if (this.currentNotebook.checkpoint) { + ckptNode = LiteGraph.createNode("CheckpointLoaderSimple"); + if (ckptNode) { + ckptNode.pos = [x, y]; + app.graph.add(ckptNode); + if (ckptNode.widgets && ckptNode.widgets.length > 0) { + ckptNode.widgets[0].value = this.currentNotebook.checkpoint; + } + currentModelNode = ckptNode; + currentModelSlot = 0; + currentClipNode = ckptNode; + currentClipSlot = 1; + } + } + + // 2. Add Loras + let loraX = x + 300; + if (this.currentNotebook.loras && this.currentNotebook.loras.length > 0) { + this.currentNotebook.loras.forEach(lora => { + if (!lora) return; + const loraNode = LiteGraph.createNode("LoraLoader"); + if (loraNode) { + loraNode.pos = [loraX, y]; + app.graph.add(loraNode); + if (loraNode.widgets && loraNode.widgets.length > 0) { + loraNode.widgets[0].value = lora; + } + + // Connect from previous + if (currentModelNode) { + currentModelNode.connect(currentModelSlot, loraNode, 0); // MODEL + } + if (currentClipNode) { + currentClipNode.connect(currentClipSlot, loraNode, 1); // CLIP + } + + // Update current pointers + currentModelNode = loraNode; + currentModelSlot = 0; + currentClipNode = loraNode; + currentClipSlot = 1; + + loraX += 300; + } + }); + } + + let dragNode = currentClipNode || ckptNode; + + // 3. Create Prompt Node + if (this.currentNotebook.prompt) { + const promptNode = LiteGraph.createNode("CLIPTextEncode"); + if (promptNode) { + promptNode.pos = [loraX, y + 200]; + app.graph.add(promptNode); + if (promptNode.widgets && promptNode.widgets.length > 0) { + promptNode.widgets[0].value = this.currentNotebook.prompt; + } + if (currentClipNode) { + currentClipNode.connect(currentClipSlot, promptNode, 0); // connect CLIP + } + dragNode = promptNode; + } + } + + this.modal.remove(); + + // Make it stick to mouse if app.canvas exists + if (lastNode && app.canvas) { + let isSticking = true; + const stickHandler = (e) => { + if (!isSticking || !app.canvas) return; + const canvas = app.canvas; + let canvasX, canvasY; + if (canvas.convertEventToCanvasOffset) { + const pos = canvas.convertEventToCanvasOffset(e); + canvasX = pos[0]; canvasY = pos[1]; + } else { + const rect = canvas.canvas.getBoundingClientRect(); + canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale; + canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale; + } + dragNode.pos = [canvasX - dragNode.size[0] / 2, canvasY - 20]; + canvas.setDirty(true, true); + }; + const dropHandler = (e) => { + isSticking = false; + window.removeEventListener('mousemove', stickHandler, true); + window.removeEventListener('pointerdown', dropHandler, true); + window.removeEventListener('mousedown', dropHandler, true); + window.removeEventListener('click', dropHandler, true); + }; + window.addEventListener('mousemove', stickHandler, true); + setTimeout(() => { + window.addEventListener('pointerdown', dropHandler, true); + window.addEventListener('mousedown', dropHandler, true); + window.addEventListener('click', dropHandler, true); + }, 100); + } + } +} diff --git a/web/styles.css b/web/styles.css index e58e384..f6191eb 100644 --- a/web/styles.css +++ b/web/styles.css @@ -296,3 +296,117 @@ text-overflow: ellipsis; flex: 1; } + + +/* Notebook UI */ +.anomalous-nb-modal { + position: absolute; + top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.85); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; +} +.anomalous-nb-container { + width: 85%; height: 85%; + background: var(--bg-color, #222); + border: 1px solid var(--border-color, #444); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 10px 40px rgba(0,0,0,0.8); +} +.anomalous-nb-header { + height: 50px; + background: var(--comfy-menu-bg, #333); + border-bottom: 1px solid #444; + display: flex; + align-items: center; + padding: 0 20px; + justify-content: space-between; + color: #fff; +} +.anomalous-nb-close { + font-size: 2em; cursor: pointer; color: #ff4444; +} +.anomalous-nb-body { + flex: 1; display: flex; overflow: hidden; +} +.anomalous-nb-sidebar { + width: 250px; background: #111; border-right: 1px solid #444; display: flex; flex-direction: column; +} +.anomalous-nb-list { + flex: 1; overflow-y: auto; padding: 10px; +} +.anomalous-nb-item { + padding: 10px; color: #ccc; cursor: pointer; border-radius: 4px; margin-bottom: 5px; background: #222; +} +.anomalous-nb-item:hover { background: #333; } +.anomalous-nb-item.active { background: #007bff; color: #fff; font-weight: bold; } +.anomalous-nb-editor { + flex: 1; background: #222; overflow-y: auto; padding: 20px; color: #eee; +} +.anomalous-nb-toolbar { + display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; +} +.anomalous-btn-primary { background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; margin-left: 5px; } +.anomalous-btn-danger { background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; margin-left: 5px; } +.anomalous-btn-success { background: #28a745; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; margin-left: 5px; } +.anomalous-nb-section { + background: #2a2a2a; border: 1px solid #444; border-radius: 8px; padding: 15px; margin-bottom: 20px; +} +.anomalous-nb-row { margin-bottom: 10px; } +.anomalous-nb-select { background: #111; color: #fff; padding: 6px; border: 1px solid #555; border-radius: 4px; } +.anomalous-nb-gallery { display: flex; gap: 10px; overflow-x: auto; padding: 10px 0; } +.anomalous-nb-gallery-wrap { display: flex; gap: 10px; flex-wrap: wrap; padding: 10px 0; } +.anomalous-nb-minicheck, .anomalous-nb-minilora { + display: flex; align-items: center; gap: 12px; padding: 8px 12px 8px 8px; + background: #2a2a2a; border: 1px solid #444; border-radius: 8px; cursor: pointer; + flex: 1 1 220px; max-width: 320px; transition: all 0.35s cubic-bezier(0.2, 0.8, 0.2, 1); +} +.anomalous-nb-minicheck:hover, .anomalous-nb-minilora:hover { + border-color: #888; background: #383838; transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); transition-delay: 0.02s; +} +.anomalous-nb-minicheck.selected, .anomalous-nb-minilora.selected { + border-color: #00ffcc; background: rgba(0, 255, 204, 0.15); box-shadow: 0 0 12px rgba(0,255,204,0.2); +} +.anomalous-nb-minicheck img, .anomalous-nb-minicheck video, .anomalous-nb-minilora img, .anomalous-nb-minilora video { + width: 52px; height: 52px; object-fit: cover; border-radius: 6px; flex-shrink: 0; +} +.anomalous-nb-minicheck-name, .anomalous-nb-minilora-name { + color: #eee; font-size: 0.95em; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; text-align: left; +} +.anomalous-nb-minicheck.selected .anomalous-nb-minicheck-name, .anomalous-nb-minilora.selected .anomalous-nb-minilora-name { + color: #00ffcc; font-weight: bold; +} +.anomalous-nb-textarea { + width: 100%; height: 60px; background: #111; color: #fff; border: 1px solid #555; border-radius: 4px; padding: 8px; margin-top: 10px; resize: vertical; box-sizing: border-box; +} +.anomalous-nb-dual-pane { + display: flex; flex-direction: column; width: 100%; background: #1a1a1a; padding: 10px; border-radius: 8px; border: 1px solid #444; box-sizing: border-box; +} +.anomalous-nb-tag-row { + display: flex; gap: 15px; width: 100%; align-items: stretch; margin-bottom: 6px; +} +.anomalous-nb-gallery-box { + background: #1e1e1e; border: 1px solid #444; border-radius: 8px; padding: 12px; margin-bottom: 15px; +} +.anomalous-nb-visual-tag { + background: #2a2a2a; color: #eee; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; cursor: pointer; transition: all 0.2s; min-height: 36px; display: flex; align-items: center; word-break: break-word; font-size: 0.95em; line-height: 1.4; +} +.anomalous-nb-visual-tag.hover { + background: #3a3a3a; border-color: #00ffcc; color: #fff; box-shadow: inset 0 0 5px rgba(0,255,204,0.2); +} +.anomalous-nb-copy-btn { + opacity: 0.3; transition: all 0.2s; padding-left: 10px; flex-shrink: 0; user-select: none; +} +.anomalous-nb-visual-tag:hover .anomalous-nb-copy-btn { + opacity: 1; +} +.anomalous-nb-copy-btn:hover { + transform: scale(1.3); +} +.anomalous-nb-tag-edit { background: #111; color: #fff; border: 1px solid #00ffcc; border-radius: 4px; padding: 6px; width: 100%; font-size: 1em; box-sizing: border-box; }