diff --git a/README.md b/README.md
index c52c00d1..2a4f78d8 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Useful links:
- ๐ Changelog: [`CHANGELOG.md`](CHANGELOG.md)
- ๐ Documentation index: [`docs/DOCUMENTATION_INDEX.md`](docs/DOCUMENTATION_INDEX.md)
- ๐ง API reference: [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md)
+- ๐ค **AI Features Guide**: [`docs/AI_FEATURES.md`](docs/AI_FEATURES.md) โ NEW!
---
@@ -45,6 +46,9 @@ Useful links:
- [Majoor Floating Viewer (MFV)](#majoor-floating-viewer-mfv)
- [Hotkeys & Shortcuts](#hotkeys--shortcuts)
- [Advanced Features](#advanced-features)
+- [AI Features (How to Use)](#ai-features-how-to-use)
+- [Debug: Reset Index / Delete DB](#debug-reset-index--delete-db)
+- [Backfill Warning (Important)](#backfill-warning-important)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Development & Testing](#development--testing)
@@ -83,6 +87,17 @@ Useful links:
## What's New in v2.4.0
+### ๐ค AI Features โ Comprehensive Guide โ NEW!
+Complete AI-powered asset discovery and organization:
+- **Semantic Search**: Natural language queries ("sunset over mountains")
+- **Find Similar**: Visual similarity matching
+- **AI Auto-Tags**: Automatic tagging (portrait, landscape, cyberpunk, etc.)
+- **Enhanced Captions**: Florence-2 generated descriptions
+- **Prompt Alignment**: Score image-prompt matching quality
+- **Smart Collections**: Auto-group by visual themes
+- **Models**: SigLIP2 (image/text), X-CLIP (video), Florence-2 (caption)
+- See full guide: [`docs/AI_FEATURES.md`](docs/AI_FEATURES.md)
+
### ๐ Majoor Floating Viewer (MFV) โ NEW! ๐ฏ
A lightweight floating viewer panel for real-time generation comparison:
- **Live Stream Mode**: Automatically follows new generations from Save/Load nodes
@@ -322,6 +337,139 @@ See [`docs/DB_MAINTENANCE.md`](docs/DB_MAINTENANCE.md) for database recovery pro
---
+## AI Features (How to Use)
+
+๐ **Complete Guide**: See [`docs/AI_FEATURES.md`](docs/AI_FEATURES.md) for comprehensive documentation.
+
+### What AI features are available
+
+- **๐ฎ AI Semantic Search**: Natural-language search across your library (e.g., "sunset over mountains")
+- **๐ Find Similar**: Pick one asset and retrieve visually similar assets
+- **๐ท๏ธ AI Auto-Tags**: Automatic tag suggestions (portrait, landscape, cyberpunk, anime, etc.)
+- **๐ Enhanced Captions**: Florence-2 generated detailed image descriptions
+- **๐ Prompt Alignment Score**: Measure how well an image matches its generation prompt
+- **๐ Smart Collections**: Auto-create themed collections from AI suggestions
+- **๐ฌ Discover Groups**: Cluster your library by visual similarity
+
+### How to use AI features
+
+1. **Enable AI**: Open **Settings** โ Keep **Enable AI semantic search** enabled (default: ON)
+2. **Build Vectors**: After indexing, click **Backfill vectors** in **Index Status**
+3. **Use Features**:
+ - **Search**: Click sparkles icon (๐ฎ) โ Type natural language queries
+ - **Find Similar**: Select asset โ Right-click โ Find Similar
+ - **Collections**: Open Collections โ Smart Suggestions / Discover Groups
+ - **Caption**: Open asset โ Sidebar โ Image Description โ Generate
+ - **Alignment**: Open asset โ Sidebar โ Prompt Alignment score
+
+### Per-Asset Quick Actions
+
+You can trigger scan and backfill vectors for **individual assets** directly from the grid:
+
+| Action | How To |
+|--------|--------|
+| **Index Asset** | Click status dot on card โ "Index Asset" |
+| **Generate Vector** | Click status dot โ "Generate Vector" |
+| **Generate Caption** | Hover card โ Click sparkles (๐ฎ) โ Generate Caption |
+| **Compute Alignment** | Hover card โ Click sparkles โ Compute Alignment |
+| **Suggest Tags** | Hover card โ Click sparkles โ Suggest Tags |
+| **Find Similar** | Hover card โ Click sparkles โ Find Similar |
+
+#### Visual Status Indicators
+
+| Indicator | Meaning |
+|-----------|---------|
+| ๐ข Green dot | Asset fully indexed with vectors |
+| ๐ก Yellow dot | Asset indexed, vectors pending |
+| ๐ด Red dot | Indexing failed or vectors unavailable |
+| ๐ฎ Sparkles visible | AI features available |
+| โณ Spinning icon | Processing in progress |
+
+### AI Models
+
+| Model | Purpose | Size |
+|-------|---------|------|
+| **SigLIP2 SO400M** | Image & text embeddings | ~1.2 GB |
+| **X-CLIP Base** | Video embeddings | ~600 MB |
+| **Florence-2 Base** | Image captioning | ~800 MB |
+
+Models are downloaded automatically on first use and cached locally.
+
+### Quick Tips
+
+- โ Non-AI features work independently if AI is disabled
+- โ AI quality depends on metadata quality and vector coverage
+- โ First backfill can take time (5-30 min for 1000 assets)
+- โ GPU acceleration recommended for large libraries
+
+### Configuration
+
+```bash
+# Enable/disable AI features (default: ON)
+export MJR_AM_ENABLE_VECTOR_SEARCH=1
+
+# Adjust auto-tag sensitivity (0.0-1.0, default: 0.06)
+export MJR_AM_VECTOR_AUTOTAG_THRESHOLD=0.06
+
+# Verbose AI logging for debugging
+export MJR_AM_AI_VERBOSE_LOGS=1
+```
+
+### Performance
+
+| Library Size | Backfill Time (CPU) | Backfill Time (GPU) |
+|--------------|---------------------|---------------------|
+| 100 assets | 2-5 min | 30-60 sec |
+| 500 assets | 10-25 min | 2-5 min |
+| 1000 assets | 20-50 min | 5-10 min |
+| 5000 assets | 2-4 hours | 25-50 min |
+
+*GPU: NVIDIA RTX 3060 or equivalent*
+
+---
+
+## Debug: Reset Index / Delete DB
+
+Use this when indexing behavior is inconsistent, after major updates, or when the DB is corrupted.
+
+### Reset Index
+- Rebuilds index data and rescans files.
+- Best first step when DB is still readable.
+
+### Delete DB
+- Emergency recovery mode: force-delete DB files and rebuild from scratch.
+- Use this when DB is corrupted or reset fails.
+
+### Dialog behavior
+When you click **Reset Index** or **Delete DB**, the UI first asks whether existing AI vectors should be kept, then shows a simple confirmation dialog.
+- If you keep vectors, the reset/rebuild preserves the existing AI vector data.
+- If you do not keep vectors, the reset is full and vectors will be recalculated later if needed.
+
+---
+
+## Backfill Warning (Important)
+
+Backfilling vectors can take a long time on large libraries, especially over 1000 assets.
+
+### How backfill works
+- It scans indexed assets and computes missing AI embeddings (vectors).
+- It runs as a background process, in batches.
+- You can continue using **Assets Manager** and **ComfyUI** while it runs.
+
+### Why it can be slow
+- Number of assets and media type mix (images/videos)
+- CPU/GPU availability and model load time
+- Disk speed and overall system load
+- Available system RAM (more RAM helps keep processing smoother)
+
+### What you get after backfill
+- Better semantic search coverage
+- Better **Find Similar** results
+- Better clustering/smart collection quality
+- Better prompt-alignment and AI-assisted metadata workflows
+
+---
+
## Configuration
### Browser Settings (localStorage)
diff --git a/docs/AI_FEATURES.md b/docs/AI_FEATURES.md
new file mode 100644
index 00000000..06df7a80
--- /dev/null
+++ b/docs/AI_FEATURES.md
@@ -0,0 +1,1081 @@
+# Majoor Assets Manager โ AI Features Guide
+
+**Comprehensive guide to AI-powered features in Majoor Assets Manager**
+
+*Version: 2.4.0*
+*Last Updated: March 5, 2026*
+
+---
+
+## Table of Contents
+
+- [Overview](#overview)
+- [AI Models & Technologies](#ai-models--technologies)
+- [Enabling AI Features](#enabling-ai-features)
+- [Core AI Features](#core-ai-features)
+ - [Semantic Search](#1-semantic-search)
+ - [Find Similar](#2-find-similar)
+ - [AI Auto-Tags](#3-ai-auto-tags)
+ - [Enhanced Captions (Florence-2)](#4-enhanced-captions-florence-2)
+ - [Prompt Alignment Score](#5-prompt-alignment-score)
+ - [Smart Collections](#6-smart-collections)
+ - [Discover Groups](#7-discover-groups)
+- [How It Works](#how-it-works)
+- [Configuration & Tuning](#configuration--tuning)
+- [Performance Considerations](#performance-considerations)
+- [Troubleshooting](#troubleshooting)
+- [API Reference](#api-reference)
+- [Privacy & Security](#privacy--security)
+
+---
+
+## Overview
+
+Majoor Assets Manager includes a suite of **AI-powered features** that leverage multimodal embedding models to provide semantic search, visual similarity matching, and automated metadata generation. These features help you discover and organize your generated assets more intelligently.
+
+### What AI Features Provide
+
+| Feature | Description | Use Case |
+|---------|-------------|----------|
+| **Semantic Search** | Search using natural language instead of keywords | "sunset over mountains" finds matching images |
+| **Find Similar** | Discover visually similar assets | Find variations of a successful generation |
+| **AI Auto-Tags** | Automatic tag suggestions based on image content | Auto-tag "portrait", "landscape", "cyberpunk" |
+| **Enhanced Captions** | AI-generated detailed image descriptions | Generate searchable captions for images |
+| **Prompt Alignment** | Score how well image matches its prompt | Verify generation quality |
+| **Smart Collections** | Auto-group assets by visual similarity | Create themed collections automatically |
+| **Discover Groups** | Cluster library by visual themes | Explore your library's content patterns |
+
+### Key Benefits
+
+- **Natural Language Search**: No need to remember exact filenames or tags
+- **Visual Discovery**: Find assets by appearance, not just metadata
+- **Automated Organization**: Reduce manual tagging workload
+- **Quality Insights**: Understand prompt-image alignment
+- **Themed Grouping**: Automatically discover patterns in your library
+
+---
+
+## AI Models & Technologies
+
+### Primary Models
+
+| Model | Purpose | Dimensions | Source |
+|-------|---------|------------|--------|
+| **SigLIP2 SO400M** | Image & text embeddings | 1152 | Google |
+| **X-CLIP Base** | Video embeddings | 768 | Microsoft |
+| **Florence-2 Base** | Image captioning | N/A | Microsoft |
+
+### Technology Stack
+
+- **Sentence Transformers**: Model loading and inference
+- **Faiss**: Vector similarity search (Facebook AI Similarity Search)
+- **Transformers**: HuggingFace transformers for model inference
+- **SQLite with vector extension**: Embedding storage
+
+### Model Download & Caching
+
+Models are downloaded automatically on first use and cached locally:
+
+```
+Cache Location: ~/.cache/huggingface/hub/
+Models:
+ - google/siglip-so400m-patch14-384 (SigLIP2)
+ - microsoft/xclip-base-patch32 (X-CLIP)
+ - microsoft/Florence-2-base (Florence-2)
+```
+
+**Initial Download Sizes:**
+- SigLIP2: ~1.2 GB
+- X-CLIP: ~600 MB
+- Florence-2: ~800 MB
+
+---
+
+## Enabling AI Features
+
+### Method 1: Via Settings UI (Recommended)
+
+1. Open **Assets Manager** panel in ComfyUI
+2. Click **Settings** (gear icon)
+3. Find **AI Features** section
+4. Toggle **Enable AI semantic search** to ON
+5. Wait for initial model download (progress shown in console)
+6. Click **Save Settings**
+
+### Method 2: Via Environment Variable
+
+Add to your environment before starting ComfyUI:
+
+```bash
+# Windows (PowerShell)
+$env:MJR_AM_ENABLE_VECTOR_SEARCH="1"
+
+# Windows (Command Prompt)
+set MJR_AM_ENABLE_VECTOR_SEARCH=1
+
+# Linux/macOS
+export MJR_AM_ENABLE_VECTOR_SEARCH=1
+```
+
+### Verification
+
+After enabling:
+
+1. Check console for model loading messages:
+ ```
+ INFO: Loading multimodal embedding model 'google/siglip-so400m-patch14-384' โฆ
+ INFO: SigLIP2 model loaded and ready: 'google/siglip-so400m-patch14-384' (dim=1152)
+ ```
+
+2. In Assets Manager, look for:
+ - Sparkles icon (๐ฎ) in search bar for semantic search toggle
+ - "Find Similar" button in asset context menu
+ - AI-related options in Collections panel
+
+### Initial Setup Workflow
+
+```
+1. Enable AI features in Settings
+ โ
+2. Trigger initial index scan (Ctrl+S)
+ โ
+3. Wait for metadata extraction to complete
+ โ
+4. Click "Backfill vectors" in Index Status
+ โ
+5. Wait for vector embeddings to be computed
+ โ
+6. AI features are now fully functional
+```
+
+### Per-Asset Vector Operations
+
+You can trigger scan and backfill vectors for **individual assets** directly from the grid view:
+
+#### Via Card Status Dot
+
+1. **Locate the status indicator** on an asset card (small dot in corner)
+2. **Click the status dot** to open the asset actions menu
+3. Select one of:
+ - **"Index Asset"** โ Trigger metadata scan for this asset only
+ - **"Generate Vector"** โ Compute AI embedding for this asset
+ - **"Re-index"** โ Force re-scan and re-compute vectors
+
+#### Via Sparkles Icon (Prime Icon)
+
+1. **Hover over an asset card** to reveal action buttons
+2. **Click the sparkles icon** (๐ฎ) if visible on the card
+3. Choose from AI-specific actions:
+ - **Generate Caption** โ Run Florence-2 captioning
+ - **Compute Alignment** โ Calculate prompt alignment score
+ - **Suggest Tags** โ Generate AI auto-tags
+ - **Find Similar** โ Show visually similar assets
+
+#### When to Use Per-Asset Operations
+
+| Scenario | Recommended Action |
+|----------|-------------------|
+| New asset added after backfill | Click status dot โ Generate Vector |
+| Caption missing/outdated | Click sparkles โ Generate Caption |
+| Poor alignment score | Click sparkles โ Re-compute Alignment |
+| Asset not appearing in semantic search | Status dot โ Index Asset, then Generate Vector |
+| Testing AI features on single image | Use sparkles icon actions |
+
+#### Visual Indicators
+
+| Indicator | Meaning |
+|-----------|---------|
+| ๐ข Green dot | Asset fully indexed with vectors |
+| ๐ก Yellow dot | Asset indexed, vectors pending |
+| ๐ด Red dot | Indexing failed or vectors unavailable |
+| ๐ฎ Sparkles visible | AI features available for this asset |
+| โณ Spinning icon | Processing in progress |
+
+
+๐ก Tip: For best results, run a full backfill first, then use per-asset operations to update individual items as needed.
+
+
+---
+
+## Core AI Features
+
+### 1. Semantic Search
+
+Search your asset library using natural language queries instead of keywords.
+
+#### How to Use
+
+1. Open **Assets Manager** panel
+2. Click the **sparkles icon** (๐ฎ) in the search bar to enable semantic mode
+3. Type a natural language query:
+ - "sunset over mountains with orange sky"
+ - "cyberpunk city at night"
+ - "portrait of a woman with blue hair"
+4. Press Enter to search
+
+#### Query Examples
+
+| Query Type | Example |
+|------------|---------|
+| **Visual description** | "red sports car on mountain road" |
+| **Style/Mood** | "dark gothic castle in mist" |
+| **Color-based** | "images with dominant blue tones" |
+| **Subject-based** | "anime character with long white hair" |
+| **French queries** | "chien" โ finds dog images (auto-translated) |
+
+#### How It Works
+
+1. Your query is converted to a text embedding using SigLIP2
+2. The embedding is compared against all indexed image embeddings
+3. Results are ranked by cosine similarity (closest matches first)
+4. Results are hydrated with full asset metadata for display
+
+#### Tips
+
+- Be descriptive: "sunset beach with palm trees" works better than "beach"
+- Use color words: "green forest", "blue ocean", "red sunset"
+- Include style keywords: "anime", "photorealistic", "cyberpunk"
+- Semantic search works across languages (FR โ EN auto-translation)
+
+---
+
+### 2. Find Similar
+
+Discover assets visually similar to a selected asset.
+
+#### How to Use
+
+1. **Select an asset** in the grid view (click once)
+2. **Right-click** โ **Find Similar** (or click the Clone icon)
+3. View visually similar assets ranked by similarity score
+4. Adjust `top_k` parameter to show more/fewer results (default: 20)
+
+#### Use Cases
+
+- **Variation Discovery**: Find successful variations of a generation
+- **Style Exploration**: Discover assets with similar aesthetic
+- **Quality Comparison**: Compare different attempts at same prompt
+- **Theme Grouping**: Find all assets matching a visual theme
+
+#### Similarity Score
+
+Results include a similarity score (0.0 to 1.0):
+- **0.85+**: Very similar (near-duplicates or variations)
+- **0.70-0.85**: Highly similar (same theme/style)
+- **0.55-0.70**: Moderately similar (related concepts)
+- **<0.55**: Loosely related (shared elements only)
+
+#### Technical Details
+
+- Reference asset is excluded from results
+- Uses cosine similarity in embedding space
+- Searches across all scopes (Outputs, Inputs, Custom)
+- Respects current scope filter if applied
+
+---
+
+### 3. AI Auto-Tags
+
+Automatically suggested tags based on image content analysis.
+
+#### How It Works
+
+1. Image embedding is compared against predefined tag vocabulary
+2. Tags with similarity above threshold are suggested
+3. Auto-tags are stored separately from user tags
+4. Tags can be viewed and applied to assets
+
+#### Available Auto-Tags
+
+The system includes 20+ canonical tags:
+
+| Category | Tags |
+|----------|------|
+| **Subject** | portrait, landscape, character, food, vehicle, nature, architecture |
+| **Style** | cyberpunk, anime, photorealistic, abstract, fantasy, sci-fi, horror |
+| **Medium** | watercolor, pixel-art, 3d-render, sketch, black-and-white |
+| **Content** | nsfw (adult content detection) |
+
+#### How to View Auto-Tags
+
+1. Open an asset in the **Viewer**
+2. Look at the **Sidebar** โ **AI Tags** section
+3. See suggested tags with confidence scores
+
+#### How to Apply Auto-Tags
+
+1. In Viewer sidebar, click **Apply All** to add all AI tags
+2. Or click individual tags to add selectively
+3. Tags are merged with your existing tags
+
+#### Configuration
+
+Adjust sensitivity via environment variable:
+
+```bash
+# Lower threshold = more tags (less precise)
+# Higher threshold = fewer tags (more precise)
+MJR_AM_VECTOR_AUTOTAG_THRESHOLD=0.06 # Default
+```
+
+---
+
+### 4. Enhanced Captions (Florence-2)
+
+Generate detailed AI-powered image descriptions.
+
+#### How to Generate
+
+1. Open an asset in the **Viewer**
+2. In the sidebar, find **Image Description** section
+3. Click **Generate** button
+4. Wait for caption generation (5-30 seconds)
+5. Generated caption appears in sidebar
+
+#### Caption Quality
+
+Florence-2 generates detailed, descriptive captions:
+
+**Example Input**: Anime-style image of a girl with blue hair
+
+**Generated Caption**:
+> "An anime-style illustration of a young woman with long flowing blue hair and large expressive eyes. She is wearing a school uniform with a white shirt and blue skirt. The background shows a soft gradient of pink and purple, suggesting a sunset or dawn setting. The art style is typical of modern anime with clean lines and vibrant colors."
+
+#### Use Cases
+
+- **Accessibility**: Describe images for visually impaired users
+- **Search Enhancement**: Captions are indexed for semantic search
+- **Documentation**: Auto-generate descriptions for asset catalogs
+- **Prompt Analysis**: Compare generated caption with original prompt
+
+#### Batch Generation
+
+For multiple assets, use the API:
+
+```bash
+POST /mjr/am/vector/caption/{asset_id}
+```
+
+Or use the "Backfill vectors" feature which generates captions for all images.
+
+#### Storage
+
+- Captions stored in `asset_metadata.enhanced_caption` field
+- Persisted in SQLite database
+- Survives index resets (stored separately from vectors)
+
+---
+
+### 5. Prompt Alignment Score
+
+Measure how well an image matches its generation prompt.
+
+#### What It Measures
+
+The alignment score quantifies the semantic similarity between:
+- The original generation prompt (from metadata)
+- The visual content of the image (from embedding)
+
+#### Score Interpretation
+
+| Score Range | Interpretation |
+|-------------|----------------|
+| **0.80-1.00** | Excellent alignment (image matches prompt very well) |
+| **0.65-0.80** | Good alignment (minor deviations) |
+| **0.50-0.65** | Moderate alignment (noticeable differences) |
+| **0.30-0.50** | Poor alignment (significant prompt drift) |
+| **<0.30** | Very poor alignment (image doesn't match prompt) |
+
+#### How It's Calculated
+
+The score uses **multi-signal fusion**:
+
+1. **Multi-segment imageโtext score** (60% weight)
+ - Prompt split into segments
+ - Each segment scored against image
+ - Length-weighted average with best-segment bonus
+
+2. **Captionโprompt text similarity** (20% weight)
+ - Florence-2 caption compared to prompt
+ - Text-to-text coherence measure
+
+3. **Semantic dimension score** (20% weight)
+ - Subject, style, medium, mood, color dimensions
+ - Each dimension scored separately
+
+**Negative Prompt Penalty**: If negative prompt exists, its similarity to image reduces the score (penalizes unwanted content leakage).
+
+#### How to View
+
+1. Open asset in **Viewer**
+2. Look for **Prompt Alignment** section in sidebar
+3. Score displayed as percentage (0-100%)
+
+#### Use Cases
+
+- **Quality Control**: Identify generations that didn't match intent
+- **Prompt Engineering**: Refine prompts based on alignment feedback
+- **Curation**: Filter library by alignment quality
+- **Model Comparison**: Compare different models' prompt adherence
+
+---
+
+### 6. Smart Collections
+
+Automatically create collections based on AI suggestions.
+
+#### How to Use
+
+1. Open **Collections** panel
+2. Click **Smart Suggestions** button
+3. Review AI-suggested collections
+4. Click **Create** to accept a suggestion
+
+#### Suggestion Types
+
+The AI analyzes your library and suggests:
+
+- **Theme-based**: "Cyberpunk Collection", "Nature Scenes"
+- **Style-based**: "Anime Collection", "Photorealistic"
+- **Color-based**: "Blue Tones", "Warm Colors"
+- **Subject-based**: "Portraits", "Landscapes", "Vehicles"
+
+#### How Suggestions Work
+
+1. Asset embeddings are clustered using k-means
+2. Each cluster is analyzed for common themes
+3. Representative tags and descriptions generated
+4. Suggestions presented for approval
+
+#### Configuration
+
+Adjust number of suggestions:
+
+```json
+{
+ "k": 8 // Number of clusters (2-20)
+}
+```
+
+More clusters = more specific collections
+Fewer clusters = broader collections
+
+---
+
+### 7. Discover Groups
+
+Cluster your entire library by visual similarity.
+
+#### How to Use
+
+1. Open **Collections** panel
+2. Click **Discover Groups** button
+3. Wait for clustering to complete
+4. Browse discovered groups
+5. Convert groups to collections as desired
+
+#### Group Characteristics
+
+Each group includes:
+- **Preview**: Sample images from the group
+- **Size**: Number of assets in group
+- **Tags**: Common AI-suggested tags
+- **Representative Query**: Natural language description
+
+#### Use Cases
+
+- **Library Exploration**: Discover patterns in your assets
+- **Cleanup**: Identify duplicate or near-duplicate generations
+- **Curation**: Find themed subsets for projects
+- **Analysis**: Understand your generation habits
+
+#### Performance
+
+Clustering time depends on library size:
+
+| Assets | Time (approx.) |
+|--------|----------------|
+| 100-500 | 5-15 seconds |
+| 500-2000 | 15-60 seconds |
+| 2000-10000 | 1-5 minutes |
+| 10000+ | 5-15 minutes |
+
+---
+
+## How It Works
+
+### Architecture Overview
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ Assets Manager UI โ
+โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
+โ โ Semantic โ โ Find โ โ Auto โ โ Smart โ โ
+โ โ Search โ โ Similar โ โ Tags โ โCollectionsโ โ
+โ โโโโโโฌโโโโโโ โโโโโโฌโโโโโโ โโโโโโฌโโโโโโ โโโโโโฌโโโโโโ โ
+โโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโ
+ โ โ โ โ
+ โโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโ
+ โ
+ HTTP API
+ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ Majoor Backend (Python) โ
+โ โโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ Vector Service Layer โ โ
+โ โ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโ โ โ
+โ โ โ SigLIP2 โ โ X-CLIP โ โFlorence-2โ โ โ
+โ โ โ (Image) โ โ (Video) โ โ (Caption)โ โ โ
+โ โ โโโโโโโฌโโโโโโโ โโโโโโโฌโโโโโโโ โโโโโโฌโโโโโโ โ โ
+โ โโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโ โ
+โ โ โ โ โ
+โ โโโโโโโโโโดโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโโโ โ
+โ โ Faiss Index (In-Memory) โ โ
+โ โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ โ
+โ โโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ SQLite (asset_embeddings table) โ โ
+โ โ - asset_id โ โ
+โ โ - vector (BLOB) โ โ
+โ โ - aesthetic_score โ โ
+โ โ - auto_tags (JSON) โ โ
+โ โ - enhanced_caption โ โ
+โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+```
+
+### Embedding Pipeline
+
+```
+1. Asset Discovery
+ โ
+2. Metadata Extraction (ExifTool, FFprobe)
+ โ
+3. Vector Embedding Computation
+ โโ Image โ SigLIP2 image encoder
+ โโ Video โ X-CLIP video encoder (keyframes)
+ โโ Text โ SigLIP2 text encoder
+ โ
+4. Embedding Storage
+ โโ SQLite: asset_embeddings table
+ โ
+5. Index Building
+ โโ Faiss IndexFlatIP (cosine similarity)
+ โ
+6. Query Processing
+ โโ Text query โ text embedding
+ โโ Image query โ image embedding
+ โโ Similarity search โ ranked results
+```
+
+### Vector Storage Schema
+
+```sql
+CREATE TABLE asset_embeddings (
+ asset_id INTEGER PRIMARY KEY,
+ vector BLOB NOT NULL, -- Packed float32 array
+ model_name TEXT NOT NULL, -- e.g., "google/siglip-so400m-patch14-384"
+ aesthetic_score REAL, -- Prompt alignment score (0-1)
+ auto_tags TEXT, -- JSON array of tag strings
+ enhanced_caption TEXT, -- Florence-2 generated caption
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+---
+
+## Configuration & Tuning
+
+### Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `MJR_AM_ENABLE_VECTOR_SEARCH` | `1` | Enable/disable AI features |
+| `MJR_AM_VECTOR_MODEL` | `google/siglip-so400m-patch14-384` | Image/text model |
+| `MJR_AM_VECTOR_VIDEO_MODEL` | `microsoft/xclip-base-patch32` | Video model |
+| `MJR_AM_PROMPT_MODEL` | `microsoft/Florence-2-base` | Caption model |
+| `MJR_AM_VECTOR_DIM` | `1152` | Embedding dimension |
+| `MJR_AM_VECTOR_AUTOTAG_THRESHOLD` | `0.06` | Auto-tag sensitivity |
+| `MJR_AM_VECTOR_SIMILAR_TOPK` | `20` | Default similar results |
+| `MJR_AM_VECTOR_KEYFRAME_INTERVAL` | `5.0` | Video keyframe interval (sec) |
+| `MJR_AM_VECTOR_BATCH_SIZE` | `32` | Embedding batch size |
+| `MJR_AM_AI_VERBOSE_LOGS` | `0` | Verbose AI logging |
+
+### Model Selection
+
+#### Image/Text Model Options
+
+| Model | Dimensions | Speed | Quality | Use Case |
+|-------|------------|-------|---------|----------|
+| `google/siglip-so400m-patch14-384` | 1152 | Medium | High | Default, balanced |
+| `google/siglip-base-patch16-224` | 768 | Fast | Medium | Lower RAM systems |
+| `google/siglip-large-patch16-384` | 1024 | Slow | Very High | Quality-focused |
+
+#### Video Model Options
+
+| Model | Dimensions | Speed | Quality |
+|-------|------------|-------|---------|
+| `microsoft/xclip-base-patch32` | 768 | Medium | Good |
+| `microsoft/xclip-large-patch14` | 1024 | Slow | Better |
+
+#### Caption Model Options
+
+| Model | Size | Speed | Quality |
+|-------|------|-------|---------|
+| `microsoft/Florence-2-base` | ~230M | Fast | Good |
+| `microsoft/Florence-2-large` | ~580M | Medium | Better |
+
+### Tuning Examples
+
+#### Faster Embedding (Lower Quality)
+
+```bash
+export MJR_AM_VECTOR_MODEL="google/siglip-base-patch16-224"
+export MJR_AM_VECTOR_BATCH_SIZE=64
+export MJR_AM_VECTOR_DIM=768
+```
+
+#### Higher Quality (Slower)
+
+```bash
+export MJR_AM_VECTOR_MODEL="google/siglip-large-patch16-384"
+export MJR_AM_PROMPT_MODEL="microsoft/Florence-2-large"
+export MJR_AM_VECTOR_AUTOTAG_THRESHOLD=0.30
+```
+
+#### Low-RAM System
+
+```bash
+export MJR_AM_VECTOR_BATCH_SIZE=8
+export MJR_AM_VECTOR_MODEL="google/siglip-base-patch16-224"
+```
+
+---
+
+## Performance Considerations
+
+### Resource Requirements
+
+| Component | Minimum | Recommended | Optimal |
+|-----------|---------|-------------|---------|
+| **RAM** | 8 GB | 16 GB | 32 GB |
+| **VRAM** | 0 GB (CPU) | 4 GB | 8+ GB |
+| **Storage** | 5 GB free | 10 GB free | 20+ GB free |
+| **CPU** | 4 cores | 8 cores | 12+ cores |
+
+### Model Loading Times
+
+| Model | Cold Load | Cached |
+|-------|-----------|--------|
+| SigLIP2 SO400M | 30-60s | 5-10s |
+| X-CLIP Base | 20-40s | 3-8s |
+| Florence-2 Base | 15-30s | 2-5s |
+
+### Embedding Speed
+
+| Asset Type | CPU Only | GPU (RTX 3060) |
+|------------|----------|----------------|
+| Image (1080p) | 2-5 sec | 0.5-1 sec |
+| Video (1 min) | 10-30 sec | 3-10 sec |
+| Text caption | 0.5-2 sec | 0.1-0.5 sec |
+
+### Backfill Performance
+
+**Backfill vectors** computes embeddings for all assets without vectors:
+
+| Library Size | Time (CPU) | Time (GPU) |
+|--------------|------------|------------|
+| 100 assets | 2-5 min | 30-60 sec |
+| 500 assets | 10-25 min | 2-5 min |
+| 1000 assets | 20-50 min | 5-10 min |
+| 5000 assets | 2-4 hours | 25-50 min |
+
+### Optimization Tips
+
+1. **Enable GPU acceleration** (CUDA):
+ ```bash
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
+ ```
+
+2. **Adjust batch size** for your hardware:
+ - Low RAM: `MJR_AM_VECTOR_BATCH_SIZE=8`
+ - High RAM: `MJR_AM_VECTOR_BATCH_SIZE=64`
+
+3. **Use CPU-only mode** if GPU memory is limited:
+ ```bash
+ export CUDA_VISIBLE_DEVICES=""
+ ```
+
+4. **Limit index size** for large libraries:
+ - Vector searcher caps at 100,000 assets
+ - Most recent assets prioritized
+
+5. **Schedule backfill** during off-hours:
+ - Run overnight for large libraries
+ - System remains usable during backfill
+
+---
+
+## Troubleshooting
+
+### Common Issues
+
+#### 1. AI Features Not Appearing
+
+**Symptoms**: No sparkles icon, no "Find Similar" button
+
+**Solutions**:
+- Check if AI features are enabled in Settings
+- Verify `MJR_AM_ENABLE_VECTOR_SEARCH=1` in environment
+- Restart ComfyUI after enabling
+- Check console for model loading errors
+
+#### 2. Model Download Fails
+
+**Symptoms**: Error downloading models, timeout
+
+**Solutions**:
+```bash
+# Check internet connectivity
+ping huggingface.co
+
+# Manual model download
+huggingface-cli download google/siglip-so400m-patch14-384
+
+# Set HF mirror (for China/regions with restrictions)
+export HF_ENDPOINT=https://hf-mirror.com
+```
+
+#### 3. Out of Memory (OOM)
+
+**Symptoms**: System freezes, crashes during embedding
+
+**Solutions**:
+- Reduce batch size: `MJR_AM_VECTOR_BATCH_SIZE=8`
+- Close other applications
+- Use CPU-only mode to free GPU RAM
+- Restart ComfyUI to clear memory
+
+#### 4. Slow Performance
+
+**Symptoms**: Searches take >5 seconds, UI lag
+
+**Solutions**:
+- Reduce library size (split into multiple scopes)
+- Use GPU acceleration if available
+- Increase system RAM
+- Disable verbose logging: `MJR_AM_AI_VERBOSE_LOGS=0`
+
+#### 5. Poor Search Results
+
+**Symptoms**: Irrelevant results for queries
+
+**Solutions**:
+- Ensure vectors are backfilled: Index Status โ Backfill vectors
+- Use more descriptive queries
+- Check model loaded correctly in console
+- Verify embedding dimension matches model
+
+#### 6. Caption Generation Fails
+
+**Symptoms**: "Generate" button shows error, no caption
+
+**Solutions**:
+- Check Florence-2 model loaded (console logs)
+- Verify asset is an image (not video)
+- Increase timeout: `MJR_AM_DB_TIMEOUT=120`
+- Check disk space for model cache
+
+### Diagnostic Commands
+
+```bash
+# Check model cache
+ls -la ~/.cache/huggingface/hub/
+
+# Verify Python packages
+pip list | grep -E "sentence-transformers|transformers|faiss"
+
+# Test model loading
+python -c "from sentence_transformers import SentenceTransformer; m = SentenceTransformer('google/siglip-so400m-patch14-384'); print('OK')"
+
+# Check vector index status
+curl http://localhost:8188/mjr/am/vector/stats
+```
+
+### Log Analysis
+
+Enable verbose logging for debugging:
+
+```bash
+export MJR_AM_AI_VERBOSE_LOGS=1
+```
+
+Look for these log patterns:
+
+```
+# Successful model load
+INFO: Loading multimodal embedding model 'google/siglip-so400m-patch14-384' โฆ
+INFO: SigLIP2 model loaded and ready: 'google/siglip-so400m-patch14-384' (dim=1152)
+
+# Successful embedding
+DEBUG: Vector embedding computed for asset 12345
+
+# Search query
+INFO: Semantic search: 'sunset mountains' โ 20 results
+```
+
+---
+
+## API Reference
+
+### Semantic Search
+
+```http
+GET /mjr/am/vector/search?q={query}&top_k={count}&scope={scope}
+```
+
+**Parameters**:
+- `q` (required): Natural language query
+- `top_k` (optional): Max results (default: 20, max: 200)
+- `scope` (optional): `output`, `input`, `custom`, `all`
+
+**Example**:
+```bash
+curl "http://localhost:8188/mjr/am/vector/search?q=sunset%20beach&top_k=10"
+```
+
+**Response**:
+```json
+{
+ "ok": true,
+ "data": [
+ {
+ "id": 12345,
+ "asset_id": 12345,
+ "filepath": "/path/to/image.png",
+ "filename": "image.png",
+ "kind": "image",
+ "_vectorScore": 0.8923
+ }
+ ]
+}
+```
+
+### Find Similar
+
+```http
+GET /mjr/am/vector/similar/{asset_id}?top_k={count}
+```
+
+**Parameters**:
+- `asset_id` (required): Reference asset ID
+- `top_k` (optional): Max results (default: 20)
+
+**Example**:
+```bash
+curl "http://localhost:8188/mjr/am/vector/similar/12345?top_k=10"
+```
+
+### Prompt Alignment
+
+```http
+GET /mjr/am/vector/alignment/{asset_id}
+```
+
+**Response**:
+```json
+{
+ "ok": true,
+ "data": 0.7523
+}
+```
+
+### Generate Caption
+
+```http
+POST /mjr/am/vector/caption/{asset_id}
+```
+
+**Response**:
+```json
+{
+ "ok": true,
+ "data": "An anime-style illustration of..."
+}
+```
+
+### Auto-Tags
+
+```http
+GET /mjr/am/vector/auto-tags/{asset_id}
+```
+
+**Response**:
+```json
+{
+ "ok": true,
+ "data": ["anime", "portrait", "fantasy"]
+}
+```
+
+### Suggest Collections
+
+```http
+POST /mjr/am/vector/suggest-collections
+Content-Type: application/json
+
+{"k": 8}
+```
+
+**Response**:
+```json
+{
+ "ok": true,
+ "data": {
+ "suggestions": [
+ {
+ "name": "Cyberpunk Collection",
+ "query": "cyberpunk neon city night",
+ "estimated_size": 45,
+ "tags": ["cyberpunk", "sci-fi", "night"]
+ }
+ ]
+ }
+}
+```
+
+### Vector Stats
+
+```http
+GET /mjr/am/vector/stats
+```
+
+**Response**:
+```json
+{
+ "ok": true,
+ "data": {
+ "total": 1234,
+ "avg_score": 0.6523,
+ "dim": 1152,
+ "enabled": true
+ }
+}
+```
+
+### Re-index Asset
+
+```http
+POST /mjr/am/vector/index/{asset_id}
+```
+
+Force re-computation of embedding for a single asset.
+
+---
+
+## Privacy & Security
+
+### Data Privacy
+
+- **Local Processing**: All AI inference runs locally on your machine
+- **No Cloud Upload**: Images and prompts never leave your computer
+- **Local Cache**: Models cached in `~/.cache/huggingface/hub/`
+- **No Telemetry**: No usage data sent to developers
+
+### Network Access
+
+Initial model download requires internet access:
+
+- Downloads from HuggingFace CDN
+- One-time download per model
+- Subsequent uses work offline
+
+### Security Considerations
+
+1. **Model Integrity**: Models downloaded from official HuggingFace repos
+2. **Code Execution**: No arbitrary code execution from models
+3. **Sandboxing**: Models run in same process as ComfyUI
+4. **Resource Limits**: Built-in rate limiting prevents abuse
+
+### Environment Isolation
+
+For enhanced security, run in isolated environment:
+
+```bash
+# Create virtual environment
+python -m venv venv
+source venv/bin/activate # Linux/macOS
+venv\Scripts\activate # Windows
+
+# Install dependencies
+pip install -r requirements.txt
+```
+
+---
+
+## Appendix: Model Cards
+
+### SigLIP2 SO400M
+
+- **Publisher**: Google
+- **Architecture**: Vision-Language Transformer
+- **Training**: Contrastive image-text pairs
+- **License**: Apache 2.0
+- **HuggingFace**: https://huggingface.co/google/siglip-so400m-patch14-384
+
+### X-CLIP Base
+
+- **Publisher**: Microsoft
+- **Architecture**: Video-Language Transformer
+- **Training**: Video-text pairs
+- **License**: MIT
+- **HuggingFace**: https://huggingface.co/microsoft/xclip-base-patch32
+
+### Florence-2 Base
+
+- **Publisher**: Microsoft
+- **Architecture**: Vision-Language Model
+- **Training**: Image-caption pairs
+- **License**: MIT
+- **HuggingFace**: https://huggingface.co/microsoft/Florence-2-base
+
+---
+
+## Changelog
+
+### v2.4.0 (March 2026)
+- Added Florence-2 caption generation
+- Improved prompt alignment scoring with multi-signal fusion
+- Added French-to-English query translation
+- Enhanced auto-tag vocabulary (20+ tags)
+- Smart Collections with k-means clustering
+
+### v2.3.0 (February 2026)
+- Initial AI features release
+- Semantic search with SigLIP2
+- Find Similar functionality
+- Basic auto-tagging
+
+---
+
+## Support & Resources
+
+### Documentation
+- [Main Documentation Index](DOCUMENTATION_INDEX.md)
+- [API Reference](API_REFERENCE.md)
+- [Configuration Guide](SETTINGS_CONFIGURATION.md)
+
+### Community
+- [GitHub Issues](https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/issues)
+- [GitHub Discussions](https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/discussions)
+
+### Model Documentation
+- [Sentence Transformers Docs](https://sbert.net/)
+- [HuggingFace Transformers](https://huggingface.co/docs/transformers)
+- [Faiss Documentation](https://faiss.ai/)
+
+---
+
+*This documentation is part of the Majoor Assets Manager project.*
+*Copyright ยฉ 2026 Ewald ALOEBOETOE (MajoorWaldi)*
diff --git a/docs/DB_MAINTENANCE.md b/docs/DB_MAINTENANCE.md
index ae29d8e6..9fd70056 100644
--- a/docs/DB_MAINTENANCE.md
+++ b/docs/DB_MAINTENANCE.md
@@ -42,9 +42,7 @@ The **Delete DB** button calls `POST /mjr/am/db/force-delete`. It is designed to
### Confirmation dialog
-Before proceeding, a native browser `confirm()` dialog warns:
-
-> This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.
+Before proceeding, the UI first asks whether existing AI vectors should be kept, then shows a simple confirmation dialog in the ComfyUI Manager style for the selected action.
### What is lost
diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md
index 78bb2e4b..070d2675 100644
--- a/docs/DOCUMENTATION_INDEX.md
+++ b/docs/DOCUMENTATION_INDEX.md
@@ -2,8 +2,8 @@
Welcome to the comprehensive documentation for the **Majoor Assets Manager** for ComfyUI. This collection of guides covers all features, from basic usage to advanced configuration and development.
-**Current Version**: 2.3.3
-**Last Updated**: February 28, 2026
+**Current Version**: 2.4.0
+**Last Updated**: March 5, 2026
---
@@ -15,7 +15,8 @@ Welcome to the comprehensive documentation for the **Majoor Assets Manager** for
3. **[Hotkeys & Shortcuts](HOTKEYS_SHORTCUTS.md)** โ Essential keyboard shortcuts
4. **[Basic Search](SEARCH_FILTERING.md)** โ Find your assets quickly
-### Returning Users (v2.3.3)
+### Returning Users (v2.4.0)
+- **[AI Features Guide](AI_FEATURES.md)** โ NEW comprehensive AI capabilities
- **[Majoor Floating Viewer](VIEWER_FEATURE_TUTORIAL.md#majoor-floating-viewer-mfv)** โ NEW real-time preview panel
- **[Changelog](../CHANGELOG.md)** โ What's new in this version
- **[Linux Support](INSTALLATION.md#linux-installation)** โ Now fully supported on Linux
@@ -39,6 +40,7 @@ Welcome to the comprehensive documentation for the **Majoor Assets Manager** for
| **[VIEWER_FEATURE_TUTORIAL.md](VIEWER_FEATURE_TUTORIAL.md)** | Advanced viewer, MFV, and analysis tools |
| **[RATINGS_TAGS_COLLECTIONS.md](RATINGS_TAGS_COLLECTIONS.md)** | Organization with ratings, tags, and collections |
| **[DRAG_DROP.md](DRAG_DROP.md)** | Drag & drop to canvas and OS |
+| **[AI_FEATURES.md](AI_FEATURES.md)** | ๐ AI-powered semantic search, auto-tags, and more |
### Configuration & Settings
| Document | Description |
@@ -103,6 +105,18 @@ Welcome to the comprehensive documentation for the **Majoor Assets Manager** for
- Node compatibility and staging
- File transfer mechanisms
+### AI Features โ NEW! ๐
+**[AI_FEATURES.md](AI_FEATURES.md)** โ Complete guide to AI-powered capabilities:
+- **Semantic Search**: Natural language search across your library
+- **Find Similar**: Discover visually similar assets
+- **AI Auto-Tags**: Automatic tag suggestions (portrait, landscape, cyberpunk, etc.)
+- **Enhanced Captions**: Florence-2 generated image descriptions
+- **Prompt Alignment Score**: Measure image-prompt matching quality
+- **Smart Collections**: Auto-group assets by visual similarity
+- **Discover Groups**: Cluster library by visual themes
+- Models: SigLIP2 (image/text), X-CLIP (video), Florence-2 (caption)
+- Configuration, tuning, and troubleshooting
+
---
## ๐ง Configuration Guides
diff --git a/js/api/client.js b/js/api/client.js
index 3e53de1a..2bbce067 100644
--- a/js/api/client.js
+++ b/js/api/client.js
@@ -882,6 +882,13 @@ export async function resetIndex(options = {}) {
clear_metadata_cache: _bool(options.clearMetadataCache ?? options.clear_metadata_cache, true),
clear_asset_metadata: _bool(options.clearAssetMetadata ?? options.clear_asset_metadata, true),
clear_assets: _bool(options.clearAssets ?? options.clear_assets, true),
+ preserve_vectors: _bool(
+ options.preserveVectors ??
+ options.preserve_vectors ??
+ options.keepVectors ??
+ options.keep_vectors,
+ false
+ ),
rebuild_fts: _bool(options.rebuildFts ?? options.rebuild_fts, true),
incremental: _bool(options.incremental, false),
fast: _bool(options.fast, true),
@@ -1099,7 +1106,8 @@ export async function vectorSearch(query, topKOrOptions = 20) {
let url = `${ENDPOINTS.VECTOR_SEARCH}?q=${encodeURIComponent(q)}&top_k=${topK}`;
if (scope) url += `&scope=${encodeURIComponent(scope)}`;
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
- return get(url);
+ // Model cold-start (SigLIP download/load) can take 60-120s on first use
+ return get(url, { timeoutMs: 120_000 });
}
/**
@@ -1310,7 +1318,8 @@ export async function hybridSearch(query, { topK = 50, scope = "output", customR
if (!q) return { ok: false, error: "Empty query" };
let url = `${ENDPOINTS.HYBRID_SEARCH}?q=${encodeURIComponent(q)}&top_k=${Math.max(1, Math.min(200, topK))}&scope=${encodeURIComponent(scope)}`;
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
- return get(url);
+ // Model cold-start (SigLIP download/load) can take 60-120s on first use
+ return get(url, { timeoutMs: 120_000 });
}
/**
diff --git a/js/app/dialogs.js b/js/app/dialogs.js
index 60a29a23..e898fd0e 100644
--- a/js/app/dialogs.js
+++ b/js/app/dialogs.js
@@ -5,6 +5,9 @@
* alert/confirm/prompt popups. Falls back to window.* when unavailable.
*/
+import { getComfyApp } from "./comfyApiBridge.js";
+import { t } from "./i18n.js";
+
const getComfyUi = () => {
try {
const app = getComfyApp();
@@ -232,7 +235,7 @@ export const comfyAlert = async (message, title = "Majoor", options = {}) => {
style: { display: "flex", justifyContent: "flex-end", gap: "10px" },
}, [
$el("button", {
- textContent: "Confirm",
+ textContent: t("dialog.confirm", "Confirm"),
onclick: close,
style: {
padding: "10px 16px",
@@ -264,7 +267,7 @@ export const comfyConfirm = async (message, title = "Majoor") => {
if (extensionDialog) {
try {
const result = await extensionDialog.confirm({
- title: String(title || "Confirm"),
+ title: String(title || t("dialog.confirm", "Confirm")),
message: String(message || ""),
});
return !!result;
@@ -329,7 +332,7 @@ export const comfyConfirm = async (message, title = "Majoor") => {
style: { display: "flex", justifyContent: "flex-end", gap: "10px" },
}, [
$el("button", {
- textContent: "Cancel",
+ textContent: t("dialog.cancel", "Cancel"),
onclick: () => finish(false),
style: {
padding: "10px 16px",
@@ -342,7 +345,7 @@ export const comfyConfirm = async (message, title = "Majoor") => {
},
}),
$el("button", {
- textContent: "Confirm",
+ textContent: t("dialog.confirm", "Confirm"),
onclick: () => finish(true),
style: {
padding: "10px 16px",
@@ -370,12 +373,273 @@ export const comfyConfirm = async (message, title = "Majoor") => {
});
};
+export const comfyYesNoCancel = async (message, title = "Majoor", labels = {}) => {
+ const yesLabel = String(labels?.yes || t("dialog.yes", "Yes"));
+ const noLabel = String(labels?.no || t("dialog.no", "No"));
+ const cancelLabel = String(labels?.cancel || t("dialog.cancel", "Cancel"));
+
+ const Dialog = getComfyDialogCtor();
+ if (!Dialog) {
+ try {
+ const answer = window.prompt(
+ `${String(title || "Majoor")}\n\n${String(message || "")}\n\n${yesLabel}=y, ${noLabel}=n, ${cancelLabel}=c`,
+ "c"
+ );
+ const normalized = String(answer || "").trim().toLowerCase();
+ if (!normalized) return "cancel";
+ if (["y", "yes", "1"].includes(normalized)) return "yes";
+ if (["n", "no", "2"].includes(normalized)) return "no";
+ return "cancel";
+ } catch {
+ return "cancel";
+ }
+ }
+
+ return new Promise((resolve) => {
+ const dialog = new Dialog();
+ styleDialog(dialog);
+
+ const finish = (value) => {
+ try {
+ dialog.close();
+ } catch (e) { console.debug?.(e); }
+ resolve(value || "cancel");
+ };
+
+ const content = $el("div", {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "18px",
+ padding: "18px 20px 18px 20px",
+ },
+ onkeydown: (e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ finish("cancel");
+ }
+ },
+ }, [
+ $el("div", {
+ style: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "flex-start",
+ },
+ }, [
+ $el("div", {
+ textContent: title,
+ style: {
+ fontWeight: "700",
+ fontSize: "30px",
+ color: "rgba(255,255,255,0.96)",
+ lineHeight: "1.2",
+ },
+ }),
+ ]),
+ $el("div", {
+ textContent: String(message || ""),
+ style: {
+ fontSize: "22px",
+ color: "rgba(255,255,255,0.86)",
+ whiteSpace: "pre-wrap",
+ lineHeight: "1.45",
+ },
+ }),
+ $el("div", {
+ style: { display: "flex", justifyContent: "flex-end", gap: "10px", flexWrap: "wrap" },
+ }, [
+ $el("button", {
+ textContent: yesLabel,
+ onclick: () => finish("yes"),
+ style: {
+ padding: "10px 16px",
+ borderRadius: "10px",
+ border: "1px solid rgba(17,132,255,0.75)",
+ background: "#1184ff",
+ color: "rgba(255,255,255,0.98)",
+ fontWeight: "600",
+ cursor: "pointer",
+ },
+ }),
+ $el("button", {
+ textContent: noLabel,
+ onclick: () => finish("no"),
+ style: {
+ padding: "10px 16px",
+ borderRadius: "10px",
+ border: "1px solid rgba(244,67,54,0.70)",
+ background: "rgba(244,67,54,0.18)",
+ color: "rgba(255,220,220,0.98)",
+ fontWeight: "600",
+ cursor: "pointer",
+ },
+ }),
+ $el("button", {
+ textContent: cancelLabel,
+ onclick: () => finish("cancel"),
+ style: {
+ padding: "10px 16px",
+ borderRadius: "10px",
+ border: "1px solid rgba(255,255,255,0.18)",
+ background: "rgba(255,255,255,0.06)",
+ color: "rgba(255,255,255,0.85)",
+ fontWeight: "600",
+ cursor: "pointer",
+ },
+ }),
+ ]),
+ ]);
+
+ try {
+ dialog.show(content);
+ setTimeout(() => hideDialogNativeClose(dialog), 0);
+ } catch {
+ resolve("cancel");
+ }
+ });
+};
+
+export const comfyChoice = async (message, title = "Majoor", choices = []) => {
+ const safeChoices = Array.isArray(choices)
+ ? choices
+ .filter((c) => c && typeof c === "object")
+ .map((c, idx) => ({
+ id: String(c.id || `choice_${idx}`),
+ label: String(c.label || c.id || `Choice ${idx + 1}`),
+ variant: String(c.variant || "").toLowerCase(),
+ }))
+ : [];
+
+ if (!safeChoices.length) return null;
+
+ const Dialog = getComfyDialogCtor();
+ if (!Dialog) {
+ try {
+ const numbered = safeChoices
+ .map((c, idx) => `${idx + 1}. ${c.label}`)
+ .join("\n");
+ const answer = window.prompt(
+ `${String(title || "Majoor")}\n\n${String(message || "")}\n\n${numbered}\n\n${t("dialog.choiceTypeNumber", "Type a number:")}`,
+ String(safeChoices.length)
+ );
+ if (answer == null) return null;
+ const index = Number.parseInt(String(answer).trim(), 10) - 1;
+ if (Number.isInteger(index) && index >= 0 && index < safeChoices.length) {
+ return safeChoices[index].id;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+ }
+
+ return new Promise((resolve) => {
+ const dialog = new Dialog();
+ styleDialog(dialog);
+
+ const finish = (value) => {
+ try {
+ dialog.close();
+ } catch (e) { console.debug?.(e); }
+ resolve(value ?? null);
+ };
+
+ const buttonForChoice = (choice) => {
+ const isDanger = choice.variant === "danger";
+ const isPrimary = choice.variant === "primary";
+ return $el("button", {
+ textContent: choice.label,
+ onclick: () => finish(choice.id),
+ style: {
+ padding: "10px 14px",
+ borderRadius: "10px",
+ border: isDanger
+ ? "1px solid rgba(244,67,54,0.70)"
+ : isPrimary
+ ? "1px solid rgba(17,132,255,0.75)"
+ : "1px solid rgba(255,255,255,0.18)",
+ background: isDanger
+ ? "rgba(244,67,54,0.18)"
+ : isPrimary
+ ? "#1184ff"
+ : "rgba(255,255,255,0.06)",
+ color: isDanger
+ ? "rgba(255,220,220,0.98)"
+ : isPrimary
+ ? "rgba(255,255,255,0.98)"
+ : "rgba(255,255,255,0.85)",
+ fontWeight: "600",
+ cursor: "pointer",
+ },
+ });
+ };
+
+ const content = $el("div", {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "18px",
+ padding: "18px 20px 18px 20px",
+ },
+ onkeydown: (e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ finish(null);
+ }
+ },
+ }, [
+ $el("div", {
+ style: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "flex-start",
+ },
+ }, [
+ $el("div", {
+ textContent: title,
+ style: {
+ fontWeight: "700",
+ fontSize: "30px",
+ color: "rgba(255,255,255,0.96)",
+ lineHeight: "1.2",
+ },
+ }),
+ ]),
+ $el("div", {
+ textContent: String(message || ""),
+ style: {
+ fontSize: "22px",
+ color: "rgba(255,255,255,0.86)",
+ whiteSpace: "pre-wrap",
+ lineHeight: "1.45",
+ },
+ }),
+ $el("div", {
+ style: {
+ display: "flex",
+ justifyContent: "flex-end",
+ gap: "10px",
+ flexWrap: "wrap",
+ },
+ }, safeChoices.map(buttonForChoice)),
+ ]);
+
+ try {
+ dialog.show(content);
+ setTimeout(() => hideDialogNativeClose(dialog), 0);
+ } catch {
+ resolve(null);
+ }
+ });
+};
+
export const comfyPrompt = async (message, defaultValue = "", title = "Majoor") => {
const extensionDialog = getExtensionManagerDialog();
if (extensionDialog) {
try {
const result = await extensionDialog.prompt({
- title: String(title || "Prompt"),
+ title: String(title || t("dialog.prompt", "Prompt")),
message: String(message || ""),
defaultValue: String(defaultValue ?? ""),
});
@@ -454,7 +718,7 @@ export const comfyPrompt = async (message, defaultValue = "", title = "Majoor")
style: { display: "flex", justifyContent: "flex-end", gap: "10px" },
}, [
$el("button", {
- textContent: "Cancel",
+ textContent: t("dialog.cancel", "Cancel"),
onclick: () => finish(null),
style: {
padding: "8px 12px",
@@ -466,7 +730,7 @@ export const comfyPrompt = async (message, defaultValue = "", title = "Majoor")
},
}),
$el("button", {
- textContent: "OK",
+ textContent: t("dialog.ok", "OK"),
onclick: () => finish(input.value),
style: {
padding: "8px 12px",
@@ -498,4 +762,3 @@ export const comfyPrompt = async (message, defaultValue = "", title = "Majoor")
}
});
};
-import { getComfyApp } from "./comfyApiBridge.js";
diff --git a/js/app/i18n.js b/js/app/i18n.js
index 440b949f..28147e32 100644
--- a/js/app/i18n.js
+++ b/js/app/i18n.js
@@ -198,6 +198,7 @@ const DICTIONARY = {
"tab.input": "Input",
"tab.all": "All",
"tab.custom": "Custom",
+ "tab.similar": "Similar",
"manager.title": "Assets Manager",
// --- Panel: Buttons ---
@@ -226,6 +227,7 @@ const DICTIONARY = {
"btn.up": "Up",
"btn.saving": "Saving...",
"btn.restoring": "Restoring...",
+ "btn.markAllRead": "Mark all read",
// --- Panel: Labels ---
"label.folder": "Folder",
@@ -252,14 +254,27 @@ const DICTIONARY = {
"label.heightPx": "Height (px)",
"label.day": "Day",
"label.collections": "Collections",
+ "label.collection": "collection",
+ "label.messages": "Messages",
+ "label.readMe": "Read Me",
+ "label.userGuide": "User Guide",
+ "label.info": "Info",
+ "btn.giveStar": "Give a star",
"label.filters": "Filters",
"label.selectFolder": "Select folder?",
"label.thisFolder": "this folder",
+ "label.thisFile": "this file",
"label.computer": "Computer",
"search.placeholder": "Search assets...",
"search.title": "Search by filename, tags, or attributes (e.g. rating:5, ext:png)",
"search.semanticToggle": "Toggle AI semantic search (CLIP-based)",
"search.aiSearch": "AI Search",
+ "search.findSimilar": "Find Similar",
+ "search.findingSimilar": "Finding similar assets...",
+ "search.selectAssetForSimilar": "Select an asset first to find similar images/videos.",
+ "search.findSimilarFailed": "Failed to find similar assets",
+ "search.similarResults": "Similar to asset #{id} ({n} results)",
+ "search.similarReference": "Reference #{id}",
"action.copyToClipboard": "Copy to clipboard",
"action.clickToCopy": "Click to copy",
"tooltip.copyFieldValue": "Copy value",
@@ -274,6 +289,7 @@ const DICTIONARY = {
"tooltip.tab.input": "Browse input folder assets",
"tooltip.tab.output": "Browse generated outputs",
"tooltip.tab.custom": "Browse browser folders",
+ "tooltip.tab.similar": "Browse current similar findings",
"tooltip.browserFolders": "Browser folders",
"tooltip.pinnedFolders": "Pinned folders",
"tooltip.clearFilter": "Clear {label}",
@@ -281,9 +297,12 @@ const DICTIONARY = {
"tooltip.closeSidebar": "Close sidebar",
"tooltip.closeSidebarEsc": "Close sidebar (Esc)",
"tooltip.supportKofi": "Buy Me a White Monster Drink",
+ "tooltip.starGithub": "Open GitHub and give a star",
"tooltip.sidebarTab": "Assets Manager - Browse and search your outputs",
"tooltip.openMFV": "Open Floating Viewer",
"tooltip.closeMFV": "Close Floating Viewer",
+ "tooltip.openMessages": "Messages and updates",
+ "tooltip.openMessagesUnread": "Messages ({count} unread)",
"tooltip.deleteDb": "Force-delete database and rebuild from scratch",
"tooltip.workflowMultiOutput": "Multiple outputs with different prompts",
"tooltip.generationInputs": "Input files used in generation",
@@ -424,6 +443,7 @@ const DICTIONARY = {
"scope.output": "Outputs",
"scope.custom": "Custom",
"scope.customBrowser": "Browser",
+ "scope.similar": "Similar",
// --- Tools ---
"tool.exiftool": "ExifTool metadata",
@@ -440,8 +460,24 @@ const DICTIONARY = {
"msg.errorLoadingFolders": "Error loading folders",
"msg.noGenerationData": "No generation data found for this file.",
"msg.rawMetadata": "Raw metadata",
+ "msg.noMessages": "No messages for now.",
"msg.noPinnedFolders": "No pinned folders",
"msg.noTagsYet": "No tags yet...",
+ "msg.category.information": "Information",
+ "msg.whatsNew.title.pinReference": "What's New",
+ "msg.whatsNew.body.pinReference": "Floating Viewer: new Pin Reference feature. You can now pin A or B, then compare quickly with selected assets in the grid while keeping the fixed reference.",
+ "msg.whatsNew.title.vectorResetKeepVectors": "Important",
+ "msg.whatsNew.body.vectorResetKeepVectors": "Reset index and Delete DB now first ask whether to keep AI vectors. If you already have older indexed assets, keeping the vectors is recommended: a full reset without them can trigger a long Vector Backfill for old assets and temporarily increase RAM usage.",
+ "msg.whatsNew.title.localUserGuide": "Need help?",
+ "msg.whatsNew.body.localUserGuide": "Open the local User Guide directly from your Assets Manager custom_nodes folder.",
+ "msg.collectionAdd.added": "Added {added} item(s) to \"{name}\".",
+ "msg.collectionAdd.skippedExisting": "Skipped {count} item(s): already present in the collection.",
+ "msg.collectionAdd.skippedDuplicate": "Ignored {count} duplicate(s) in selection.",
+ "msg.collectionAdd.noneAddedExisting": "No new items added to \"{name}\" (all exist).",
+ "msg.aiFeaturesNotice": "Warning:\n\nAssets Manager now includes AI features like auto captions, AI search, smart collections, etc.\nThese features are still WIP.\n\nIf you already had Assets Manager installed, delete the DB first.\nWhen enrich is finished, run Vector Backfill for your existing assets.\nAfter clicking Backfill, you can go sleep: it may take a very, very long time if you have many assets.",
+ "msg.dbResetNoticeDetail": "Majoor Update Notice:\n\nTo avoid database errors with this new version, please delete your existing index. Click the 'Delete DB' button in the Index Status panel to reset it.",
+ "msg.nightlyUpdateTitle": "Majoor Assets Manager",
+ "msg.nightlyUpdateDetail": "A newer nightly build is available: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/releases/tag/nightly",
"msg.newVersionTitle": "Majoor Assets Manager",
"msg.newVersionDetail": "A new version is available: {latest} (Current: {current})",
"msg.dbResetNotice": "Database reset required",
@@ -530,11 +566,17 @@ const DICTIONARY = {
"ctx.resetRating": "Reset rating",
"ctx.showMetadataPanel": "Show metadata panel",
"ctx.unpinFolder": "Unpin folder",
+ "ctx.openFolder": "Open folder",
"ctx.pinAsBrowserRoot": "Pin as Browser Root",
// --- Dialogs ---
"dialog.confirm": "Confirm",
"dialog.cancel": "Cancel",
+ "dialog.yes": "Yes",
+ "dialog.no": "No",
+ "dialog.ok": "OK",
+ "dialog.prompt": "Prompt",
+ "dialog.choiceTypeNumber": "Type a number:",
"dialog.delete.title": "Delete file?",
"dialog.delete.msg": "Are you sure you want to delete this file? This cannot be undone.",
"dialog.rename.title": "Rename file",
@@ -563,6 +605,20 @@ const DICTIONARY = {
"dialog.deleteExactDuplicates": "Delete exact duplicates?",
"dialog.startDuplicateAnalysis": "Start duplicate analysis?",
"dialog.dbDelete.confirm": "This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.\n\nContinue?",
+ "dialog.settingsSaveFailed": "Majoor: Failed to save settings (browser storage full or blocked).",
+ "dialog.confirmDeleteTitle": "Majoor: Confirm delete",
+ "dialog.deleteSelectedFiles": "Delete {count} selected files?",
+ "dialog.deleteSingleFile": "Delete \"{label}\"?",
+ "dialog.vectorsReset.title": "AI vectors",
+ "dialog.vectorsReset.choice": "Also reset AI vectors?\n\nConfirm = yes, reset everything (vectors will be recalculated)\nCancel = no, keep existing vectors",
+ "dialog.vectorsReset.keepQuestion": "Keep existing AI vectors?\n\nConfirm = keep vectors\nCancel = continue without vectors",
+ "dialog.vectorsReset.wipeConfirm": "Reset AI vectors too?\n\nConfirm = yes, reset everything\nCancel = abort",
+ "dialog.vectorsReset.singleQuestion": "Choose reset mode for {action}:\n\nYes = keep existing AI vectors\nNo = full reset (vectors will be recalculated)\nCancel = abort",
+ "dialog.vectorsReset.optionKeep": "Yes - keep vectors",
+ "dialog.vectorsReset.optionFull": "No - full reset",
+ "dialog.vectorsReset.optionCancel": "Cancel",
+ "dialog.resetIndex.confirmKeepVectors": "This will reset index data and rescan files while keeping existing AI vectors.\n\nContinue?",
+ "dialog.dbDelete.keepVectorsConfirm": "This will reset index data and keep existing AI vectors. Database files will not be force-deleted.\n\nContinue?",
// --- Toasts ---
"toast.scanStarted": "Scan started",
@@ -600,6 +656,13 @@ const DICTIONARY = {
"toast.noValidAssetsSelected": "No valid assets selected.",
"toast.failedCreateCollectionDot": "Failed to create collection.",
"toast.failedAddAssetsToCollection": "Failed to add assets to collection.",
+ "toast.failedCreateSmartCollection": "Failed to create smart collection",
+ "toast.failedAddAssetsToSmartCollection": "Failed to add assets to smart collection",
+ "toast.noGroupsFoundIndexFirst": "No groups found. Index more assets first.",
+ "toast.failedLoadClusterAssets": "Failed to load cluster assets",
+ "toast.collectionCreatedWithAssets": "Collection \"{name}\" created with {count} assets!",
+ "toast.collectionCreatedNamed": "Collection \"{name}\" created.",
+ "toast.clusterAnalysisFailed": "Cluster analysis failed",
"toast.removeFromCollectionFailed": "Failed to remove from collection.",
"toast.removeFromCollectionError": "Error removing from collection: {error}",
"toast.copyClipboardFailed": "Failed to copy to clipboard",
@@ -644,6 +707,14 @@ const DICTIONARY = {
"toast.unableResolveFolderPath": "Unable to resolve folder path",
"toast.pathCopyFailed": "Failed to copy path",
"toast.noFilePath": "No file path available for this asset.",
+ "toast.vectorBackfillStarting": "Starting vector backfill... This may take a while.",
+ "toast.vectorBackfillRunning": "Vector backfill still running in background{job}.",
+ "toast.vectorBackfillComplete": "Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
+ "toast.vectorBackfillFailedGeneric": "Backfill failed",
+ "toast.vectorBackfillFailedDetail": "Vector backfill failed: {error}",
+ "toast.rescanUpdatingAiIndex": "Rescanning file + updating AI index...",
+ "toast.metadataVectorUpdated": "Metadata + AI vector index updated for this asset.",
+ "toast.metadataUpdatedVectorFailed": "Metadata updated. AI vector index could not be updated.",
"toast.downloadingFile": "Downloading {filename}...",
"toast.playbackRate": "Playback {rate}x",
"toast.metadataRefreshed": "Metadata refreshed{suffix}",
@@ -691,6 +762,7 @@ const DICTIONARY = {
"tab.input": "Entree",
"tab.all": "Tout",
"tab.custom": "Navigateur",
+ "tab.similar": "Similaire",
"manager.title": "Gestionnaire d'assets",
"scope.all": "Entrees + Sorties",
@@ -699,11 +771,21 @@ const DICTIONARY = {
"scope.output": "Sorties",
"scope.custom": "Navigateur",
"scope.customBrowser": "Navigateur",
+ "scope.similar": "Similaire",
"search.placeholder": "Rechercher des assets...",
"search.title": "Rechercher par nom de fichier, tags ou attributs (ex. rating:5, ext:png)",
"search.semanticToggle": "Activer/desactiver la recherche semantique IA (CLIP)",
"search.aiSearch": "Recherche IA",
+ "search.findSimilar": "Trouver similaires",
+ "search.findingSimilar": "Recherche d'assets similaires...",
+ "search.selectAssetForSimilar": "Selectionnez d'abord un asset pour trouver des images/videos similaires.",
+ "search.findSimilarFailed": "Echec de la recherche similaire",
+ "search.similarResults": "Similaires a l'asset #{id} ({n} resultats)",
+ "search.similarReference": "Reference #{id}",
+ "tooltip.openMessages": "Messages et nouveautes",
+ "tooltip.openMessagesUnread": "Messages ({count} non lus)",
+ "tooltip.tab.similar": "Parcourir les trouvailles similaires courantes",
"setting.ai.vector.enabled.name": "Activer la recherche semantique IA",
"setting.ai.vector.enabled.desc": "Active la recherche semantique basee sur CLIP. Desactivez pour une recherche par mots-cles uniquement.",
@@ -718,6 +800,7 @@ const DICTIONARY = {
"btn.up": "Monter",
"btn.saving": "Sauvegarde...",
"btn.restoring": "Restauration...",
+ "btn.markAllRead": "Tout marquer lu",
"ctx.pinAsBrowserRoot": "Epingler comme racine Browser",
"ctx.createFolderHere": "Creer dossier ici...",
@@ -725,12 +808,32 @@ const DICTIONARY = {
"ctx.moveFolder": "Deplacer dossier...",
"ctx.deleteFolder": "Supprimer dossier...",
"ctx.refreshMetadata": "Rafraichir metadonnees",
+ "ctx.openFolder": "Ouvrir dossier",
"dialog.browserRootLabelOptional": "Label pour nouvelle racine browser (optionnel)",
"dialog.newFolderName": "Nom du nouveau dossier",
"dialog.renameFolder": "Renommer dossier",
"dialog.destinationDirectoryPath": "Chemin dossier destination",
"dialog.deleteFolderRecursive": "Supprimer le dossier \"{name}\" et tout son contenu ?",
+ "dialog.settingsSaveFailed": "Majoor : echec sauvegarde des parametres (stockage navigateur plein ou bloque).",
+ "dialog.yes": "Oui",
+ "dialog.no": "Non",
+ "dialog.ok": "OK",
+ "dialog.prompt": "Saisie",
+ "dialog.choiceTypeNumber": "Entrez un numero :",
+ "dialog.confirmDeleteTitle": "Majoor : confirmer suppression",
+ "dialog.deleteSelectedFiles": "Supprimer {count} fichiers selectionnes ?",
+ "dialog.deleteSingleFile": "Supprimer \"{label}\" ?",
+ "dialog.vectorsReset.title": "Vecteurs IA",
+ "dialog.vectorsReset.choice": "Reinitialiser aussi les vecteurs IA ?\n\nConfirmer = oui, tout reinitialiser (les vecteurs seront recalcules)\nAnnuler = non, conserver les vecteurs existants",
+ "dialog.vectorsReset.keepQuestion": "Conserver les vecteurs IA existants ?\n\nConfirmer = conserver les vecteurs\nAnnuler = continuer sans vecteurs",
+ "dialog.vectorsReset.wipeConfirm": "Reinitialiser aussi les vecteurs IA ?\n\nConfirmer = oui, tout reinitialiser\nAnnuler = abandonner",
+ "dialog.vectorsReset.singleQuestion": "Choisissez le mode de reinitialisation pour {action} :\n\nOui = conserver les vecteurs IA existants\nNon = reinitialisation complete (les vecteurs seront recalcules)\nAnnuler = abandonner",
+ "dialog.vectorsReset.optionKeep": "Oui - conserver vecteurs",
+ "dialog.vectorsReset.optionFull": "Non - reinit complete",
+ "dialog.vectorsReset.optionCancel": "Annuler",
+ "dialog.resetIndex.confirmKeepVectors": "Cette action reinitialise l'index et relance le scan en conservant les vecteurs IA existants.\n\nContinuer ?",
+ "dialog.dbDelete.keepVectorsConfirm": "Cette action reinitialise l'index et conserve les vecteurs IA existants. Les fichiers DB ne seront pas supprimes de force.\n\nContinuer ?",
"toast.createFolderFailed": "Echec creation dossier",
"toast.renameFolderFailed": "Echec renommage dossier",
@@ -742,13 +845,52 @@ const DICTIONARY = {
"toast.folderDeleted": "Dossier supprime",
"toast.pinFolderFailed": "Echec epinglage dossier",
"toast.folderPinnedAsBrowserRoot": "Dossier epingle comme racine browser",
+ "toast.failedCreateSmartCollection": "Echec creation collection intelligente",
+ "toast.failedAddAssetsToSmartCollection": "Echec ajout des assets a la collection intelligente",
+ "toast.noGroupsFoundIndexFirst": "Aucun groupe trouve. Indexez plus d'assets d'abord.",
+ "toast.failedLoadClusterAssets": "Echec chargement assets du cluster",
+ "toast.collectionCreatedWithAssets": "Collection \"{name}\" creee avec {count} assets !",
+ "toast.collectionCreatedNamed": "Collection \"{name}\" creee.",
+ "toast.clusterAnalysisFailed": "Echec analyse des clusters",
+ "toast.vectorBackfillStarting": "Demarrage du vector backfill... Cela peut prendre du temps.",
+ "toast.vectorBackfillRunning": "Le vector backfill continue en arriere-plan{job}.",
+ "toast.vectorBackfillComplete": "Vector backfill termine ! Traites : {processed}, Indexes : {indexed}, Ignores : {skipped}",
+ "toast.vectorBackfillFailedGeneric": "Echec du backfill",
+ "toast.vectorBackfillFailedDetail": "Echec du vector backfill : {error}",
+ "toast.rescanUpdatingAiIndex": "Rescan du fichier + mise a jour index IA...",
+ "toast.metadataVectorUpdated": "Metadonnees + index vectoriel IA mis a jour pour cet asset.",
+ "toast.metadataUpdatedVectorFailed": "Metadonnees mises a jour. L'index vectoriel IA n'a pas pu etre mis a jour.",
"label.computer": "Ordinateur",
+ "label.collection": "collection",
+ "label.thisFile": "ce fichier",
+ "label.messages": "Messages",
+ "label.readMe": "Read Me",
+ "label.userGuide": "Guide utilisateur",
+ "label.info": "Info",
+ "btn.giveStar": "Mettre une etoile",
"label.resolutionMinWxH": "Min LxH (px)",
"label.resolutionMaxWxH": "Max LxH (px)",
+ "msg.noMessages": "Aucun message pour le moment.",
"msg.noPinnedFolders": "Aucun dossier epingle",
"sidebar.placeholderSelectAsset": "Selectionnez un asset pour voir les details",
- "msg.noTagsYet": "Aucun tag pour le moment..."
+ "msg.noTagsYet": "Aucun tag pour le moment...",
+ "msg.category.information": "Information",
+ "msg.whatsNew.title.pinReference": "Quoi de neuf",
+ "msg.whatsNew.body.pinReference": "Floating Viewer : nouvelle fonction Pin Reference. Vous pouvez maintenant epingler A ou B, puis comparer rapidement avec les assets selectionnes dans la grille tout en gardant la reference fixe.",
+ "msg.whatsNew.title.vectorResetKeepVectors": "Quoi de neuf",
+ "msg.whatsNew.body.vectorResetKeepVectors": "Reset index et Delete DB demandent d'abord s'il faut conserver les vecteurs IA. Si vous avez deja des anciens assets indexes, garder les vecteurs est recommande : un reset complet sans eux peut declencher un long Vector Backfill sur les anciens assets et augmenter temporairement la consommation RAM.",
+ "msg.whatsNew.title.localUserGuide": "Quoi de neuf",
+ "msg.whatsNew.body.localUserGuide": "Ouvrez le Guide utilisateur local directement depuis le dossier custom_nodes d'Assets Manager.",
+ "msg.collectionAdd.added": "{added} element(s) ajoute(s) a \"{name}\".",
+ "msg.collectionAdd.skippedExisting": "{count} element(s) ignores : deja presents dans la collection.",
+ "msg.collectionAdd.skippedDuplicate": "{count} doublon(s) ignores dans la selection.",
+ "msg.collectionAdd.noneAddedExisting": "Aucun nouvel element ajoute a \"{name}\" (tous deja presents).",
+ "msg.aiFeaturesNotice": "Avertissement :\n\nAssets Manager inclut maintenant des fonctions IA (auto captions, recherche IA, smart collections, etc.).\nCes fonctions sont encore en cours de developpement.\n\nSi Assets Manager etait deja installe, supprimez d'abord la DB.\nQuand l'enrichissement est termine, lancez Vector Backfill pour vos assets existants.\nApres avoir clique sur Backfill, cela peut prendre tres longtemps si vous avez beaucoup d'assets.",
+ "msg.dbResetNoticeDetail": "Note de mise a jour Majoor :\n\nPour eviter les erreurs de base de donnees avec cette version, supprimez votre index existant. Cliquez sur le bouton \"Delete DB\" dans le panneau Index Status pour le reinitialiser.",
+ "msg.nightlyUpdateTitle": "Majoor Assets Manager",
+ "msg.nightlyUpdateDetail": "Une build nightly plus recente est disponible : https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/releases/tag/nightly",
+ "tooltip.starGithub": "Ouvrir GitHub et mettre une etoile",
},
};
diff --git a/js/app/settings/settingsAdvanced.js b/js/app/settings/settingsAdvanced.js
index f5f00c74..9ddcf460 100644
--- a/js/app/settings/settingsAdvanced.js
+++ b/js/app/settings/settingsAdvanced.js
@@ -543,7 +543,7 @@ export function registerAdvancedSettings(safeAddSetting, settings, notifyApplied
if (String(value || "") !== "Run backfill now") return;
try {
- comfyToast("Starting vector backfill... This may take a while.", "info");
+ comfyToast(t("toast.vectorBackfillStarting", "Starting vector backfill... This may take a while."), "info");
const result = await vectorBackfill(64);
if (result?.ok) {
@@ -557,11 +557,19 @@ export function registerAdvancedSettings(safeAddSetting, settings, notifyApplied
if (pending) {
const jobId = String(data?.job_id || "").trim();
comfyToast(
- `Vector backfill still running in background${jobId ? ` (job ${jobId.slice(0, 8)})` : ""}.`,
+ t(
+ "toast.vectorBackfillRunning",
+ "Vector backfill still running in background{job}.",
+ { job: jobId ? ` (job ${jobId.slice(0, 8)})` : "" }
+ ),
"info",
);
} else {
- const msg = `Vector backfill complete! Processed: ${processed}, Indexed: ${indexed}, Skipped: ${skipped}`;
+ const msg = t(
+ "toast.vectorBackfillComplete",
+ "Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
+ { processed, indexed, skipped }
+ );
comfyToast(msg, "success");
}
try {
@@ -573,11 +581,11 @@ export function registerAdvancedSettings(safeAddSetting, settings, notifyApplied
console.warn("[Majoor] Failed to refresh vector stats:", statsErr);
}
} else {
- throw new Error(result?.error || "Backfill failed");
+ throw new Error(result?.error || t("toast.vectorBackfillFailedGeneric", "Backfill failed"));
}
} catch (error) {
- const errMsg = error?.message || String(error || "Unknown error");
- comfyToast(`Vector backfill failed: ${errMsg}`, "error");
+ const errMsg = error?.message || String(error || t("status.unknown", "unknown"));
+ comfyToast(t("toast.vectorBackfillFailedDetail", "Vector backfill failed: {error}", { error: errMsg }), "error");
console.error("[Majoor] Vector backfill error:", error);
}
},
diff --git a/js/app/settings/settingsCore.js b/js/app/settings/settingsCore.js
index 65670264..21e0dbb5 100644
--- a/js/app/settings/settingsCore.js
+++ b/js/app/settings/settingsCore.js
@@ -9,6 +9,7 @@ import { safeDispatchCustomEvent } from "../../utils/events.js";
import { SettingsStore } from "./SettingsStore.js";
import { SETTINGS_KEY, SETTINGS_SCHEMA_VERSION } from "../settingsStore.js";
import { _safeBool, _safeNum, deepMerge, resolveGridMinSize } from "./settingsUtils.js";
+import { t } from "../i18n.js";
// โโโ Default settings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -236,7 +237,9 @@ export const saveMajoorSettings = (settings) => {
if (now - last > 30_000) {
window._mjrSettingsSaveFailAt = now;
import("./dialogs.js")
- .then(({ comfyAlert }) => comfyAlert("Majoor: Failed to save settings (browser storage full or blocked)."))
+ .then(({ comfyAlert }) => comfyAlert(
+ t("dialog.settingsSaveFailed", "Majoor: Failed to save settings (browser storage full or blocked).")
+ ))
.catch(() => {});
}
} catch (e) { console.debug?.(e); }
diff --git a/js/app/versionCheck.js b/js/app/versionCheck.js
index ecb08cc2..336417be 100644
--- a/js/app/versionCheck.js
+++ b/js/app/versionCheck.js
@@ -160,7 +160,7 @@ function showAiFeaturesNoticeOnce(localVersion, branch) {
comfyAlert(
t(
"msg.aiFeaturesNotice",
- "Avertissement / Warning:\n\nAssets Manager now includes AI features like auto captions, AI search, smart collections, etc.\nThese features are still WIP.\n\nIf you already had Assets Manager installed, delete the DB first.\nWhen enrich is finished, run Vector Backfill for your existing assets.\nAfter clicking Backfill, you can go sleep: it may take a very, very long time if you have many assets."
+ "Warning:\n\nAssets Manager now includes AI features like auto captions, AI search, smart collections, etc.\nThese features are still WIP.\n\nIf you already had Assets Manager installed, delete the DB first.\nWhen enrich is finished, run Vector Backfill for your existing assets.\nAfter clicking Backfill, you can go sleep: it may take a very, very long time if you have many assets."
),
"Majoor",
{ native: true }
@@ -313,7 +313,7 @@ export async function checkMajoorVersion({ force = false } = {}) {
setTimeout(() => {
comfyAlert(
t(
- "msg.dbResetNotice",
+ "msg.dbResetNoticeDetail",
"Majoor Update Notice:\n\nTo avoid database errors with this new version, please delete your existing index. Click the 'Delete DB' button in the Index Status panel to reset it."
),
"Majoor",
diff --git a/js/features/collections/contextmenu/addToCollectionMenu.js b/js/features/collections/contextmenu/addToCollectionMenu.js
index 8c7bf0fd..7a828cda 100644
--- a/js/features/collections/contextmenu/addToCollectionMenu.js
+++ b/js/features/collections/contextmenu/addToCollectionMenu.js
@@ -1,6 +1,7 @@
import { comfyPrompt } from "../../../app/dialogs.js";
import { comfyToast } from "../../../app/toast.js";
import { listCollections, createCollection, addAssetsToCollection } from "../../../api/client.js";
+import { t } from "../../../app/i18n.js";
import {
getOrCreateMenu as getOrCreateMenuCore,
createMenuItem,
@@ -51,17 +52,17 @@ function formatAddResultMessage({ collectionName, selectedCount, addRes }) {
const skippedExisting = Number(addRes?.data?.skipped_existing ?? 0) || 0;
const skippedDuplicate = Number(addRes?.data?.skipped_duplicate ?? 0) || 0;
- const name = String(collectionName || "").trim() || "collection";
- let msg = `Added ${added} item(s) to "${name}".`;
+ const name = String(collectionName || "").trim() || t("label.collection", "collection");
+ let msg = t("msg.collectionAdd.added", "Added {added} item(s) to \"{name}\".", { added, name });
if (skippedExisting > 0) {
- msg += `\n\nSkipped ${skippedExisting} item(s): already present in the collection.`;
+ msg += `\n\n${t("msg.collectionAdd.skippedExisting", "Skipped {count} item(s): already present in the collection.", { count: skippedExisting })}`;
}
if (skippedDuplicate > 0) {
- msg += `\n\nIgnored ${skippedDuplicate} duplicate(s) in selection.`;
+ msg += `\n\n${t("msg.collectionAdd.skippedDuplicate", "Ignored {count} duplicate(s) in selection.", { count: skippedDuplicate })}`;
}
if (added === 0 && skippedExisting > 0 && selectedCount > 0) {
- msg = `No new items added to "${name}" (all exist).`;
+ msg = t("msg.collectionAdd.noneAddedExisting", "No new items added to \"{name}\" (all exist).", { name });
}
return msg;
}
@@ -74,7 +75,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
const selected = Array.isArray(assets) ? assets.map(simplifyAsset).filter(Boolean) : [];
if (!selected.length) {
- comfyToast("No valid assets selected.", "warning");
+ comfyToast(t("toast.noValidAssetsSelected", "No valid assets selected."), "warning");
return;
}
@@ -83,17 +84,20 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
// If none exist, directly prompt creation.
if (!collections.length) {
- const name = await comfyPrompt("Create collection", "My collection");
+ const name = await comfyPrompt(
+ t("dialog.createCollection", "Create collection"),
+ t("dialog.collectionPlaceholder", "My collection")
+ );
if (!name) return;
const created = await createCollection(name);
if (!created?.ok) {
- comfyToast(created?.error || "Failed to create collection.", "error");
+ comfyToast(created?.error || t("toast.failedCreateCollectionDot", "Failed to create collection."), "error");
return;
}
const cid = created.data?.id;
const addRes = await addAssetsToCollection(cid, selected);
if (!addRes?.ok) {
- comfyToast(addRes?.error || "Failed to add assets to collection.", "error");
+ comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection."), "error");
return;
}
comfyToast(formatAddResultMessage({ collectionName: created.data?.name || name, selectedCount: selected.length, addRes }), "success", 4000);
@@ -103,19 +107,22 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
const menu = getOrCreateCollectionsMenu();
clearMenu(menu);
- const createIt = createItem("Create collection...", "pi pi-plus", async () => {
+ const createIt = createItem(`${t("dialog.createCollection", "Create collection")}...`, "pi pi-plus", async () => {
hideMenu(menu);
- const name = await comfyPrompt("Create collection", "My collection");
+ const name = await comfyPrompt(
+ t("dialog.createCollection", "Create collection"),
+ t("dialog.collectionPlaceholder", "My collection")
+ );
if (!name) return;
const created = await createCollection(name);
if (!created?.ok) {
- comfyToast(created?.error || "Failed to create collection.", "error");
+ comfyToast(created?.error || t("toast.failedCreateCollectionDot", "Failed to create collection."), "error");
return;
}
const cid = created.data?.id;
const addRes = await addAssetsToCollection(cid, selected);
if (!addRes?.ok) {
- comfyToast(addRes?.error || "Failed to add assets to collection.", "error");
+ comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection."), "error");
return;
}
comfyToast(formatAddResultMessage({ collectionName: created.data?.name || name, selectedCount: selected.length, addRes }), "success", 4000);
@@ -132,7 +139,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
hideMenu(menu);
const addRes = await addAssetsToCollection(cid, selected);
if (!addRes?.ok) {
- comfyToast(addRes?.error || "Failed to add assets to collection.", "error");
+ comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection."), "error");
return;
}
comfyToast(formatAddResultMessage({ collectionName: name, selectedCount: selected.length, addRes }), "success", 4000);
diff --git a/js/features/collections/controllers/collectionsController.js b/js/features/collections/controllers/collectionsController.js
index 3ff8b9c7..8a828667 100644
--- a/js/features/collections/controllers/collectionsController.js
+++ b/js/features/collections/controllers/collectionsController.js
@@ -306,7 +306,7 @@ async function _appendSmartSuggestions(menu, state, popovers, collectionsPopover
// 1. Create the collection
const res = await createCollection(idea.label);
if (!res?.ok) {
- comfyToast(res?.error || "Failed to create smart collection", "error");
+ comfyToast(res?.error || t("toast.failedCreateSmartCollection", "Failed to create smart collection"), "error");
return;
}
const colId = String(res.data?.id || "");
@@ -334,7 +334,7 @@ async function _appendSmartSuggestions(menu, state, popovers, collectionsPopover
}
const addRes = await addAssetsToCollection(colId, assets);
if (!addRes?.ok) {
- comfyToast(addRes?.error || "Failed to add assets to smart collection", "error");
+ comfyToast(addRes?.error || t("toast.failedAddAssetsToSmartCollection", "Failed to add assets to smart collection"), "error");
return;
}
const addedCount = Number(addRes?.data?.added || assets.length || 0);
@@ -359,7 +359,7 @@ async function _appendSmartSuggestions(menu, state, popovers, collectionsPopover
await reloadGrid();
} catch (err) {
console.error("[Majoor] Smart collection creation failed:", err);
- comfyToast("Failed to create smart collection", "error");
+ comfyToast(t("toast.failedCreateSmartCollection", "Failed to create smart collection"), "error");
}
});
@@ -444,7 +444,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
try {
const res = await vectorSuggestCollections(8);
if (!res?.ok || !Array.isArray(res.data) || !res.data.length) {
- comfyToast("No groups found. Index more assets first.", "info", 3000);
+ comfyToast(t("toast.noGroupsFoundIndexFirst", "No groups found. Index more assets first."), "info", 3000);
discoverBtn.textContent = "๐ Analyze Library";
discoverBtn.disabled = false;
return;
@@ -522,7 +522,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
createBtn.textContent = "โฆ";
const colRes = await createCollection(label);
if (!colRes?.ok) {
- comfyToast(colRes?.error || "Failed to create collection", "error");
+ comfyToast(colRes?.error || t("toast.failedCreateCollection", "Failed to create collection"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
return;
@@ -531,22 +531,29 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
if (colId && allIds.length) {
const hydrated = await _resolveCollectionAssetsByIds(allIds);
if (!hydrated.ok) {
- comfyToast(hydrated.error || "Failed to load cluster assets", "error");
+ comfyToast(hydrated.error || t("toast.failedLoadClusterAssets", "Failed to load cluster assets"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
return;
}
const addRes = await addAssetsToCollection(colId, hydrated.data || []);
if (!addRes?.ok) {
- comfyToast(addRes?.error || "Failed to add assets to collection", "error");
+ comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
return;
}
const addedCount = Number(addRes?.data?.added || (hydrated.data || []).length || 0);
- comfyToast(`Collection "${label}" created with ${addedCount} assets!`, "success", 3000);
+ comfyToast(
+ t("toast.collectionCreatedWithAssets", "Collection \"{name}\" created with {count} assets!", {
+ name: label,
+ count: addedCount,
+ }),
+ "success",
+ 3000
+ );
} else {
- comfyToast(`Collection "${label}" created.`, "success", 2200);
+ comfyToast(t("toast.collectionCreatedNamed", "Collection \"{name}\" created.", { name: label }), "success", 2200);
}
state.collectionId = colId;
state.collectionName = label;
@@ -555,7 +562,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
await reloadGrid();
} catch (err) {
console.error("[Majoor] Cluster collection creation failed:", err);
- comfyToast("Failed to create collection", "error");
+ comfyToast(t("toast.failedCreateCollection", "Failed to create collection"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
}
@@ -568,7 +575,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
}
} catch (err) {
console.error("[Majoor] Discover groups failed:", err);
- comfyToast("Cluster analysis failed", "error");
+ comfyToast(t("toast.clusterAnalysisFailed", "Cluster analysis failed"), "error");
discoverBtn.textContent = "๐ Analyze Library";
discoverBtn.disabled = false;
}
diff --git a/js/features/contextmenu/GridContextMenu.js b/js/features/contextmenu/GridContextMenu.js
index e67349ad..c6e16b94 100644
--- a/js/features/contextmenu/GridContextMenu.js
+++ b/js/features/contextmenu/GridContextMenu.js
@@ -403,9 +403,9 @@ export function bindGridContextMenu({
const currentPath = String(gridContainer?.dataset?.mjrSubfolder || "").trim();
if (!currentPath) return;
menu.appendChild(
- createItem("Create folder here...", "pi pi-plus", null, async () => {
+ createItem(t("ctx.createFolderHere", "Create folder here..."), "pi pi-plus", null, async () => {
try {
- const name = await comfyPrompt("New folder name", "");
+ const name = await comfyPrompt(t("dialog.newFolderName", "New folder name"), "");
const next = String(name || "").trim();
if (!next) return;
const res = await browserFolderOp({ op: "create", path: currentPath, name: next });
@@ -480,7 +480,7 @@ export function bindGridContextMenu({
const isBrowserScope = String(panelState?.scope || "").toLowerCase() === "custom";
const folderPath = String(asset?.filepath || "").trim();
menu.appendChild(
- createItem("Open folder", "pi pi-folder-open", null, () => {
+ createItem(t("ctx.openFolder", "Open folder"), "pi pi-folder-open", null, () => {
try {
gridContainer.dispatchEvent(
new CustomEvent("mjr:open-folder-asset", {
@@ -581,7 +581,7 @@ export function bindGridContextMenu({
menu.appendChild(
createItem(t("ctx.deleteFolder"), "pi pi-trash", null, async () => {
try {
- const folderName = String(asset?.filename || "this folder");
+ const folderName = String(asset?.filename || t("label.thisFolder", "this folder"));
const ok = await comfyConfirm(
t("dialog.deleteFolderRecursive", { name: folderName })
);
diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js
index 4f5377f9..ec8533d6 100644
--- a/js/features/panel/AssetsManagerPanel.js
+++ b/js/features/panel/AssetsManagerPanel.js
@@ -52,8 +52,10 @@ import { createFilterPopoverView } from "./views/filterPopoverView.js";
import { createSortPopoverView } from "./views/sortPopoverView.js";
import { createSearchView } from "./views/searchView.js";
import { createPinnedFoldersPopoverView } from "./views/pinnedFoldersPopoverView.js";
+import { createMessagePopoverView } from "./views/messagePopoverView.js";
import { createSummaryBarView } from "./views/summaryBarView.js";
import { createAgendaCalendar } from "../filters/calendar/AgendaCalendar.js";
+import { bindMessagePopoverController } from "./messages/messagePopoverController.js";
import { normalizeQuery } from "./controllers/query.js";
import { createGridController } from "./controllers/gridController.js";
@@ -133,9 +135,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
try {
container._mjrPanelState = state;
} catch (e) { console.debug?.(e); }
+ state.viewScope = String(state.viewScope || "").trim().toLowerCase() === "similar" ? "similar" : "";
+ state.similarResults = Array.isArray(state.similarResults) ? state.similarResults : [];
+ state.similarTitle = String(state.similarTitle || "");
+ state.similarSourceAssetId = String(state.similarSourceAssetId || "");
const headerView = createHeaderView();
- const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn } = headerView;
+ const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn, messageBtn } = headerView;
const { customPopover, customSelect, customAddBtn, customRemoveBtn } = createCustomPopoverView();
const {
@@ -158,8 +164,10 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
const { sortPopover, sortMenu } = createSortPopoverView();
const { collectionsPopover, collectionsMenu } = createCollectionsPopoverView();
const { pinnedFoldersPopover, pinnedFoldersMenu } = createPinnedFoldersPopoverView();
+ const { messagePopover, messageList, markReadBtn } = createMessagePopoverView();
headerActions.appendChild(customPopover);
+ headerActions.appendChild(messagePopover);
const { searchSection, searchInputEl, similarBtn, setSemanticEnabled } = createSearchView({
filterBtn,
@@ -213,6 +221,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
`;
const gridWrapper = document.createElement("div");
+ gridWrapper.classList.add("mjr-am-grid-scroll");
gridWrapper.style.cssText = `
flex: 1;
overflow: auto;
@@ -479,6 +488,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
getCollectionAssets,
disposeGrid,
getQuery,
+ searchInputEl,
state
});
@@ -683,6 +693,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
}
});
+ const exitSimilarViewIfActive = async ({ reload = false } = {}) => {
+ try {
+ if (String(state.viewScope || "").trim().toLowerCase() !== "similar") return;
+ await scopeController?.clearSimilarScope?.({ reload });
+ } catch (e) { console.debug?.(e); }
+ };
+
if (!header._mjrTabListenersBound) {
Object.values(tabButtons).forEach((btn) => {
btn.addEventListener("click", () => scopeController.setScope(btn.dataset.scope), { signal: panelLifecycleAC?.signal });
@@ -690,17 +707,35 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
header._mjrTabListenersBound = true;
}
+ bindMessagePopoverController({
+ messageBtn,
+ messagePopover,
+ messageList,
+ markReadBtn,
+ popovers,
+ signal: panelLifecycleAC?.signal,
+ onBeforeToggle: () => {
+ popovers.close(customPopover);
+ popovers.close(filterPopover);
+ popovers.close(sortPopover);
+ popovers.close(collectionsPopover);
+ popovers.close(pinnedFoldersPopover);
+ },
+ });
+
popovers.setDismissWhitelist([
customPopover,
filterPopover,
sortPopover,
collectionsPopover,
pinnedFoldersPopover,
+ messagePopover,
customMenuBtn,
filterBtn,
sortBtn,
collectionsBtn,
- pinnedFoldersBtn
+ pinnedFoldersBtn,
+ messageBtn
]);
customMenuBtn.addEventListener("click", (e) => {
@@ -708,6 +743,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
+ popovers.close(messagePopover);
popovers.toggle(customPopover, customMenuBtn);
}, { signal: panelLifecycleAC?.signal });
@@ -716,6 +752,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(customPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
+ popovers.close(messagePopover);
popovers.toggle(filterPopover, filterBtn);
}, { signal: panelLifecycleAC?.signal });
@@ -730,6 +767,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
+ popovers.close(messagePopover);
} catch (err) { console.debug?.(err); }
const selectedIdRaw = String(
@@ -757,13 +795,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
return;
}
const list = Array.isArray(res?.data) ? res.data : [];
- await loadAssetsFromList(gridContainer, list, {
- title: t("search.similarResults", "Similar to asset #{id} ({n} results)", {
- id: selectedId,
- n: list.length,
- }),
- reset: true,
+ state.similarResults = list;
+ state.similarSourceAssetId = String(selectedId);
+ state.similarTitle = t("search.similarResults", "Similar to asset #{id} ({n} results)", {
+ id: selectedId,
+ n: list.length,
});
+ await scopeController?.setScope?.("similar");
try {
state.lastGridCount = Number(list.length || 0) || 0;
state.lastGridTotal = Number(list.length || 0) || 0;
@@ -794,6 +832,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(collectionsPopover);
+ popovers.close(messagePopover);
}
});
@@ -814,6 +853,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
+ popovers.close(messagePopover);
}
});
@@ -961,6 +1001,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
+ popovers.close(messagePopover);
popovers.toggle(pinnedFoldersPopover, pinnedFoldersBtn);
}, { signal: panelLifecycleAC?.signal });
@@ -1013,6 +1054,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
reloadGrid: gridController.reloadGrid,
lifecycleSignal: panelLifecycleAC?.signal || null,
onFiltersChanged: () => {
+ void exitSimilarViewIfActive({ reload: false });
try {
agendaCalendar?.refresh?.();
} catch (e) { console.debug?.(e); }
@@ -1048,6 +1090,19 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
reloadGrid: () => gridController.reloadGrid(),
getExtraContext: () => ({ duplicatesAlert: _duplicatesAlert }),
extraActions: {
+ clearSimilarScope: async () => {
+ try {
+ await scopeController?.clearSimilarScope?.();
+ } catch (e) { console.debug?.(e); }
+ },
+ clearTransientContext: async () => {
+ try {
+ state.viewScope = "";
+ state.similarResults = [];
+ state.similarTitle = "";
+ state.similarSourceAssetId = "";
+ } catch (e) { console.debug?.(e); }
+ },
resetBrowserHistory: () => {
try {
browserNav?.resetHistory?.();
@@ -1423,6 +1478,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
const value = e?.target?.value ?? "";
if (value === lastSearchValue) return;
lastSearchValue = value;
+ void exitSimilarViewIfActive({ reload: false });
notifyContextChanged();
if (searchTimeout) clearTimeout(searchTimeout);
@@ -1443,6 +1499,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
}, { signal: panelLifecycleAC?.signal });
searchInputEl.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
+ void exitSimilarViewIfActive({ reload: false });
startTimer('searchQuery');
gridController.reloadGrid();
notifyContextChanged();
diff --git a/js/features/panel/controllers/contextController.js b/js/features/panel/controllers/contextController.js
index ac57a381..91b6f1bb 100644
--- a/js/features/panel/controllers/contextController.js
+++ b/js/features/panel/controllers/contextController.js
@@ -84,6 +84,12 @@ export function createContextController({
} catch (e) { console.debug?.(e); }
},
clearScope: async () => {
+ try {
+ if (String(state?.viewScope || "").trim().toLowerCase() === "similar" && typeof extraActions?.clearSimilarScope === "function") {
+ await extraActions.clearSimilarScope();
+ return;
+ }
+ } catch (e) { console.debug?.(e); }
let didSetScope = false;
try {
state.customRootId = "";
@@ -244,6 +250,9 @@ export function createContextController({
state.currentFolderRelativePath = "";
delete gridContainer?.dataset?.mjrSubfolder;
resetBrowserHistory();
+ try {
+ await extraActions?.clearTransientContext?.();
+ } catch (e) { console.debug?.(e); }
if (typeof scopeController?.setScope === "function") {
await scopeController.setScope("output");
didSetScope = true;
diff --git a/js/features/panel/controllers/gridController.js b/js/features/panel/controllers/gridController.js
index 87e19ccc..1fd4ae8e 100644
--- a/js/features/panel/controllers/gridController.js
+++ b/js/features/panel/controllers/gridController.js
@@ -3,12 +3,22 @@ import { comfyToast } from "../../../app/toast.js";
import { t } from "../../../app/i18n.js";
import { loadMajoorSettings } from "../../../app/settings.js";
-export function createGridController({ gridContainer, loadAssets, loadAssetsFromList, getCollectionAssets, disposeGrid, getQuery, state }) {
+export function createGridController({
+ gridContainer,
+ loadAssets,
+ loadAssetsFromList,
+ getCollectionAssets,
+ disposeGrid,
+ getQuery,
+ searchInputEl,
+ state,
+}) {
let _isReloading = false;
let _pendingReload = false;
let _lastReloadErrorAt = 0;
let _lastAiHintAt = 0;
const RELOAD_WATCHDOG_MS = 30000;
+ const AI_RELOAD_WATCHDOG_MS = 150000;
const AI_HINT_COOLDOWN_MS = 15_000;
const runWithWatchdog = async (promiseFactory, timeoutMs = RELOAD_WATCHDOG_MS) => {
@@ -32,8 +42,7 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
*/
const _isSemanticMode = () => {
try {
- const input = gridContainer?.closest?.(".mjr-am-panel")?.querySelector?.("#mjr-search-input");
- return input?.dataset?.mjrSemanticMode === "1";
+ return searchInputEl?.dataset?.mjrSemanticMode === "1";
} catch { return false; }
};
@@ -305,6 +314,25 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
state.collectionName = "";
}
+ if (String(state.viewScope || "").toLowerCase() === "similar") {
+ const list = Array.isArray(state.similarResults) ? state.similarResults : [];
+ const sourceId = String(state.similarSourceAssetId || "").trim();
+ const title = String(
+ state.similarTitle
+ || t("search.similarResults", "Similar to asset #{id} ({n} results)", {
+ id: sourceId || "?",
+ n: list.length,
+ })
+ ).trim();
+ const result = await loadAssetsFromList(gridContainer, list, { title: title || "Similar", reset: true });
+ try {
+ state.lastGridCount = Number(result?.count || 0) || 0;
+ state.lastGridTotal = Number(result?.total || 0) || 0;
+ gridContainer.dispatchEvent?.(new CustomEvent("mjr:grid-stats", { detail: result || {} }));
+ } catch (e) { console.debug?.(e); }
+ return;
+ }
+
const result = await _loadWithSemanticFallback(getQuery());
// Track search query timing if timer was started
@@ -335,7 +363,8 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
while (_pendingReload) {
_pendingReload = false;
try {
- await runWithWatchdog(() => runReloadOnce());
+ const useAiTimeout = _isAiEnabled() && (_isSemanticMode() || String(getQuery?.() || "").length >= 12);
+ await runWithWatchdog(() => runReloadOnce(), useAiTimeout ? AI_RELOAD_WATCHDOG_MS : RELOAD_WATCHDOG_MS);
} catch (err) {
// Keep UI responsive even if one reload got stuck, but don't fail silently.
try {
@@ -367,4 +396,3 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
return { reloadGrid };
}
-
diff --git a/js/features/panel/controllers/scopeController.js b/js/features/panel/controllers/scopeController.js
index 0d49c208..8e270a70 100644
--- a/js/features/panel/controllers/scopeController.js
+++ b/js/features/panel/controllers/scopeController.js
@@ -10,29 +10,79 @@ export function createScopeController({
onBeforeReload = null
}) {
let scopeSwitchSeq = 0;
+ const hasSimilarResults = () => {
+ try {
+ return !!(String(state?.viewScope || "").trim().toLowerCase() === "similar"
+ || (Array.isArray(state?.similarResults) && state.similarResults.length > 0)
+ || String(state?.similarTitle || "").trim());
+ } catch {
+ return false;
+ }
+ };
+
+ const resetSimilarScope = () => {
+ state.viewScope = "";
+ state.similarResults = [];
+ state.similarTitle = "";
+ state.similarSourceAssetId = "";
+ };
const setActiveTabStyles = () => {
const buttons = Object.values(tabButtons || {});
+ const activeScope = String(state?.viewScope || state?.scope || "output").toLowerCase();
buttons.forEach((b) => {
const scope = b?.dataset?.scope;
- const active = scope === state.scope;
+ const active = scope === activeScope;
b?.classList?.toggle?.("is-active", active);
});
+ try {
+ const similarBtn = tabButtons?.tabSimilar;
+ if (similarBtn) {
+ similarBtn.style.display = hasSimilarResults() ? "" : "none";
+ }
+ } catch (e) { console.debug?.(e); }
customMenuBtn.style.display = "none";
customPopover.style.display = "none";
popovers.close(customPopover);
};
+ const clearSimilarScope = async ({ reload = true } = {}) => {
+ const hadSimilar = hasSimilarResults();
+ resetSimilarScope();
+ setActiveTabStyles();
+ try {
+ onChanged?.();
+ } catch (e) { console.debug?.(e); }
+ if (!hadSimilar || !reload) return;
+ await reloadGrid();
+ };
+
const setScope = async (scope) => {
const requestedSeq = ++scopeSwitchSeq;
- const allowed = new Set(["output", "outputs", "input", "inputs", "all", "custom"]);
+ const allowed = new Set(["output", "outputs", "input", "inputs", "all", "custom", "similar"]);
const normalized = String(scope || "").toLowerCase();
if (!allowed.has(normalized)) return;
+ if (normalized === "similar") {
+ if (!hasSimilarResults()) return;
+ state.viewScope = "similar";
+ setActiveTabStyles();
+ try {
+ onChanged?.();
+ } catch (e) { console.debug?.(e); }
+ try {
+ await onBeforeReload?.(state);
+ } catch (e) { console.debug?.(e); }
+ if (requestedSeq !== scopeSwitchSeq) return;
+ await reloadGrid();
+ return;
+ }
+
// Switching scope exits any active collection view.
state.collectionId = "";
state.collectionName = "";
+ resetSimilarScope();
state.scope = normalized === "outputs" ? "output" : normalized === "inputs" ? "input" : normalized;
// Scope switch should reset folder context to avoid stale filtering behavior.
@@ -56,6 +106,6 @@ export function createScopeController({
await reloadGrid();
};
- return { setScope, setActiveTabStyles };
+ return { setScope, setActiveTabStyles, clearSimilarScope };
}
diff --git a/js/features/panel/controllers/sidebarController.js b/js/features/panel/controllers/sidebarController.js
index 583dad43..e715e2be 100644
--- a/js/features/panel/controllers/sidebarController.js
+++ b/js/features/panel/controllers/sidebarController.js
@@ -31,7 +31,7 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
}
} catch (e) { console.debug?.(e); }
- try { comfyToast("Rescanning file + updating AI index...", "info", 2200); } catch (e) { console.debug?.(e); }
+ try { comfyToast(t("toast.rescanUpdatingAiIndex", "Rescanning file + updating AI index..."), "info", 2200); } catch (e) { console.debug?.(e); }
const fileEntry = {
filename: asset.filename,
@@ -106,9 +106,9 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
try {
if (indexOk && vectorIndexOk) {
- comfyToast("Metadata + AI vector index updated for this asset.", "success", 2200);
+ comfyToast(t("toast.metadataVectorUpdated", "Metadata + AI vector index updated for this asset."), "success", 2200);
} else if (indexOk && vectorAttempted && !vectorIndexOk) {
- comfyToast("Metadata updated. AI vector index could not be updated.", "warning", 2600);
+ comfyToast(t("toast.metadataUpdatedVectorFailed", "Metadata updated. AI vector index could not be updated."), "warning", 2600);
}
} catch (e) { console.debug?.(e); }
diff --git a/js/features/panel/messages/messageCenter.js b/js/features/panel/messages/messageCenter.js
new file mode 100644
index 00000000..9915ff2c
--- /dev/null
+++ b/js/features/panel/messages/messageCenter.js
@@ -0,0 +1,324 @@
+const STORAGE_MESSAGES_KEY = "mjr_panel_messages_v1";
+const STORAGE_LAST_READ_KEY = "mjr_panel_messages_last_read_v1";
+const STORAGE_DISMISSED_BUILTIN_IDS_KEY = "mjr_panel_messages_dismissed_builtin_ids_v1";
+const MAX_STORED_MESSAGES = 120;
+const LOCAL_USER_GUIDE_URL = "/mjr/am/user-guide";
+const ALLOWED_MESSAGE_LEVELS = new Set(["info", "success", "warning", "error"]);
+
+export const PANEL_MESSAGES_EVENT = "mjr:panel-messages-changed";
+
+let _loaded = false;
+let _messages = [];
+const BUILTIN_PANEL_MESSAGES = Object.freeze([
+ {
+ id: "whats-new-2026-03-05-pin-reference",
+ title: "What's New",
+ titleKey: "msg.whatsNew.title.pinReference",
+ category: "Information",
+ categoryKey: "msg.category.information",
+ level: "info",
+ createdAt: Date.parse("2026-03-05T10:00:00Z"),
+ body: "Floating Viewer: new Pin Reference feature. You can now pin A or B, then compare quickly with selected assets in the grid while keeping the fixed reference.",
+ bodyKey: "msg.whatsNew.body.pinReference",
+ },
+ {
+ id: "whats-new-2026-03-05-vector-reset-keep-vectors",
+ title: "What's New",
+ titleKey: "msg.whatsNew.title.vectorResetKeepVectors",
+ category: "Information",
+ categoryKey: "msg.category.information",
+ level: "info",
+ createdAt: Date.parse("2026-03-05T12:00:00Z"),
+ body: "Reset index and Delete DB now first ask whether to keep AI vectors. If you already have older indexed assets, keeping the vectors is recommended: a full reset without them can trigger a long Vector Backfill for old assets and temporarily increase RAM usage.",
+ bodyKey: "msg.whatsNew.body.vectorResetKeepVectors",
+ },
+ {
+ id: "whats-new-2026-03-05-local-user-guide",
+ title: "What's New",
+ titleKey: "msg.whatsNew.title.localUserGuide",
+ category: "Information",
+ categoryKey: "msg.category.information",
+ level: "info",
+ createdAt: Date.parse("2026-03-05T13:00:00Z"),
+ body: "Open the local User Guide directly from your Assets Manager custom_nodes folder.",
+ bodyKey: "msg.whatsNew.body.localUserGuide",
+ actionLabel: "User Guide",
+ actionLabelKey: "label.userGuide",
+ actionUrl: LOCAL_USER_GUIDE_URL,
+ },
+]);
+const BUILTIN_MESSAGE_IDS = new Set(BUILTIN_PANEL_MESSAGES
+ .map((msg) => String(msg?.id || "").trim())
+ .filter(Boolean));
+
+function _toNumber(value, fallback = 0) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function _safeNow() {
+ return Date.now();
+}
+
+function _makeId() {
+ return `msg-${_safeNow()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function _normalizeMessageLevel(value) {
+ const raw = String(value || "").trim().toLowerCase();
+ if (raw === "warn") return "warning";
+ if (raw === "danger") return "error";
+ return ALLOWED_MESSAGE_LEVELS.has(raw) ? raw : "info";
+}
+
+function _isBuiltinMessageId(id) {
+ return BUILTIN_MESSAGE_IDS.has(String(id || "").trim());
+}
+
+function _normalizeMessage(input = {}) {
+ const id = String(input?.id || "").trim() || _makeId();
+ const title = String(input?.title || "").trim() || "Info";
+ const titleKey = String(input?.titleKey || "").trim();
+ const emoji = String(input?.emoji || "").trim();
+ const body = String(input?.body || "").trim();
+ const bodyKey = String(input?.bodyKey || "").trim();
+ const category = String(input?.category || "").trim() || "Info";
+ const categoryKey = String(input?.categoryKey || "").trim();
+ const createdAt = _toNumber(input?.createdAt, _safeNow());
+ const level = _normalizeMessageLevel(input?.level);
+ const actionLabel = String(input?.actionLabel || "").trim();
+ const actionLabelKey = String(input?.actionLabelKey || "").trim();
+ const actionUrl = String(input?.actionUrl || "").trim();
+ return {
+ id,
+ title,
+ titleKey,
+ emoji,
+ body,
+ bodyKey,
+ category,
+ categoryKey,
+ createdAt,
+ level,
+ actionLabel,
+ actionLabelKey,
+ actionUrl,
+ };
+}
+
+function _readStorageJson(key, fallback) {
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return fallback;
+ return JSON.parse(raw);
+ } catch {
+ return fallback;
+ }
+}
+
+function _writeStorageJson(key, value) {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ // ignore storage quota/privacy errors
+ }
+}
+
+function _readLastReadAt() {
+ try {
+ return _toNumber(localStorage.getItem(STORAGE_LAST_READ_KEY), 0);
+ } catch {
+ return 0;
+ }
+}
+
+function _writeLastReadAt(ts) {
+ try {
+ localStorage.setItem(STORAGE_LAST_READ_KEY, String(_toNumber(ts, _safeNow())));
+ } catch {
+ // ignore storage quota/privacy errors
+ }
+}
+
+function _readDismissedBuiltinIds() {
+ const raw = _readStorageJson(STORAGE_DISMISSED_BUILTIN_IDS_KEY, []);
+ const ids = Array.isArray(raw) ? raw : [];
+ return new Set(
+ ids
+ .map((id) => String(id || "").trim())
+ .filter((id) => _isBuiltinMessageId(id))
+ );
+}
+
+function _writeDismissedBuiltinIds(ids) {
+ const safeIds = Array.from(ids || [])
+ .map((id) => String(id || "").trim())
+ .filter((id) => _isBuiltinMessageId(id));
+ _writeStorageJson(STORAGE_DISMISSED_BUILTIN_IDS_KEY, safeIds);
+}
+
+function _dismissBuiltinMessage(id) {
+ const key = String(id || "").trim();
+ if (!_isBuiltinMessageId(key)) return false;
+ const dismissedIds = _readDismissedBuiltinIds();
+ if (dismissedIds.has(key)) return false;
+ dismissedIds.add(key);
+ _writeDismissedBuiltinIds(dismissedIds);
+ return true;
+}
+
+function _restoreBuiltinMessage(id) {
+ const key = String(id || "").trim();
+ if (!_isBuiltinMessageId(key)) return false;
+ const dismissedIds = _readDismissedBuiltinIds();
+ if (!dismissedIds.delete(key)) return false;
+ _writeDismissedBuiltinIds(dismissedIds);
+ return true;
+}
+
+function _sortMessages() {
+ _messages.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
+ if (_messages.length > MAX_STORED_MESSAGES) {
+ _messages = _messages.slice(0, MAX_STORED_MESSAGES);
+ }
+}
+
+function _persistMessages() {
+ _writeStorageJson(STORAGE_MESSAGES_KEY, _messages);
+}
+
+function _seedBuiltinMessages() {
+ const dismissedBuiltinIds = _readDismissedBuiltinIds();
+ let changed = false;
+ for (const raw of BUILTIN_PANEL_MESSAGES) {
+ const normalized = _normalizeMessage(raw);
+ if (dismissedBuiltinIds.has(normalized.id)) {
+ continue;
+ }
+ const idx = _messages.findIndex((m) => m.id === normalized.id);
+ if (idx < 0) {
+ _messages.push(normalized);
+ changed = true;
+ continue;
+ }
+ const prev = _messages[idx];
+ const merged = {
+ ...prev,
+ ...normalized,
+ // Keep the original publish timestamp if already present.
+ createdAt: _toNumber(prev?.createdAt, normalized.createdAt),
+ };
+ if (JSON.stringify(prev) !== JSON.stringify(merged)) {
+ _messages[idx] = merged;
+ changed = true;
+ }
+ }
+ if (!changed) return false;
+ _sortMessages();
+ _persistMessages();
+ return true;
+}
+
+function _emitChanged() {
+ try {
+ window.dispatchEvent(new CustomEvent(PANEL_MESSAGES_EVENT, {
+ detail: {
+ total: _messages.length,
+ unread: getPanelUnreadCount(),
+ },
+ }));
+ } catch (e) { console.debug?.(e); }
+}
+
+function _ensureLoaded() {
+ if (_loaded) return;
+ _loaded = true;
+ const parsed = _readStorageJson(STORAGE_MESSAGES_KEY, []);
+ const safeArray = Array.isArray(parsed) ? parsed : [];
+ _messages = safeArray.map(_normalizeMessage);
+ _sortMessages();
+}
+
+export function ensurePanelMessagesReady() {
+ _ensureLoaded();
+ if (_seedBuiltinMessages()) _emitChanged();
+}
+
+export function listPanelMessages() {
+ _ensureLoaded();
+ return _messages.map((message) => ({ ...message }));
+}
+
+export function getPanelUnreadCount() {
+ _ensureLoaded();
+ const lastReadAt = _readLastReadAt();
+ return _messages.reduce((count, msg) => {
+ return count + (Number(msg.createdAt || 0) > lastReadAt ? 1 : 0);
+ }, 0);
+}
+
+export function getPanelLastReadAt() {
+ _ensureLoaded();
+ return _readLastReadAt();
+}
+
+export function markPanelMessagesRead({ upTo = _safeNow() } = {}) {
+ _ensureLoaded();
+ _writeLastReadAt(_toNumber(upTo, _safeNow()));
+ _emitChanged();
+}
+
+export function upsertPanelMessage(message) {
+ _ensureLoaded();
+ const normalized = _normalizeMessage(message);
+ _restoreBuiltinMessage(normalized.id);
+ const idx = _messages.findIndex((m) => m.id === normalized.id);
+ let stored = null;
+ if (idx >= 0) {
+ const prev = _messages[idx];
+ stored = {
+ ...prev,
+ ...normalized,
+ // Keep original publish timestamp unless explicitly provided.
+ createdAt: _toNumber(message?.createdAt, _toNumber(prev?.createdAt, normalized.createdAt)),
+ };
+ _messages[idx] = stored;
+ } else {
+ stored = { ...normalized };
+ _messages.push(stored);
+ }
+ _sortMessages();
+ _persistMessages();
+ _emitChanged();
+ return { ...stored };
+}
+
+export function addPanelMessage(message) {
+ return upsertPanelMessage(message);
+}
+
+export function removePanelMessage(id) {
+ _ensureLoaded();
+ const key = String(id || "").trim();
+ if (!key) return false;
+ const next = _messages.filter((m) => m.id !== key);
+ if (next.length === _messages.length) return false;
+ _messages = next;
+ _dismissBuiltinMessage(key);
+ _persistMessages();
+ _emitChanged();
+ return true;
+}
+
+export function clearPanelMessages() {
+ _ensureLoaded();
+ const dismissedBuiltinIds = _readDismissedBuiltinIds();
+ for (const builtinId of BUILTIN_MESSAGE_IDS) {
+ dismissedBuiltinIds.add(builtinId);
+ }
+ _writeDismissedBuiltinIds(dismissedBuiltinIds);
+ _messages = [];
+ _persistMessages();
+ _writeLastReadAt(_safeNow());
+ _emitChanged();
+}
diff --git a/js/features/panel/messages/messagePopoverController.js b/js/features/panel/messages/messagePopoverController.js
new file mode 100644
index 00000000..41f494e7
--- /dev/null
+++ b/js/features/panel/messages/messagePopoverController.js
@@ -0,0 +1,250 @@
+import { t } from "../../../app/i18n.js";
+import {
+ PANEL_MESSAGES_EVENT,
+ ensurePanelMessagesReady,
+ getPanelLastReadAt,
+ getPanelUnreadCount,
+ listPanelMessages,
+ markPanelMessagesRead,
+} from "./messageCenter.js";
+
+const ALLOWED_MESSAGE_LEVELS = new Set(["info", "success", "warning", "error"]);
+
+function formatMessageDate(ts) {
+ const stamp = Number(ts);
+ if (!Number.isFinite(stamp) || stamp <= 0) return "";
+ try {
+ return new Date(stamp).toLocaleString([], {
+ year: "numeric",
+ month: "short",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return "";
+ }
+}
+
+function resolveMessageText(entry, keyField, fallbackField, fallbackText) {
+ const key = String(entry?.[keyField] || "").trim();
+ const fallback = String(entry?.[fallbackField] || fallbackText || "").trim() || String(fallbackText || "");
+ return key ? t(key, fallback) : fallback;
+}
+
+function normalizeMessageLevel(value) {
+ const raw = String(value || "").trim().toLowerCase();
+ if (raw === "warn") return "warning";
+ if (raw === "danger") return "error";
+ return ALLOWED_MESSAGE_LEVELS.has(raw) ? raw : "info";
+}
+
+function resolveSafeActionUrl(value) {
+ const raw = String(value || "").trim();
+ if (!raw) return "";
+ try {
+ const baseHref = String(globalThis?.window?.location?.href || "http://127.0.0.1/");
+ const url = new URL(raw, baseHref);
+ const protocol = String(url.protocol || "").toLowerCase();
+ return protocol === "http:" || protocol === "https:" ? url.href : "";
+ } catch {
+ return "";
+ }
+}
+
+function resolveMessageEmoji(entry) {
+ const explicit = String(entry?.emoji || "").trim();
+ if (explicit) return explicit;
+
+ const level = normalizeMessageLevel(entry?.level);
+ if (level === "success") return "\u2705";
+ if (level === "warning") return "\u26A0\uFE0F";
+ if (level === "error") return "\u274C";
+ return "\uD83D\uDCA1";
+}
+
+export function bindMessagePopoverController({
+ messageBtn = null,
+ messagePopover = null,
+ messageList = null,
+ markReadBtn = null,
+ popovers = null,
+ onBeforeToggle = null,
+ signal = null,
+} = {}) {
+ ensurePanelMessagesReady();
+
+ const syncExpandedState = () => {
+ const isOpen = messagePopover?.style?.display === "block";
+ try {
+ messageBtn?.setAttribute("aria-expanded", isOpen ? "true" : "false");
+ } catch (e) { console.debug?.(e); }
+ try {
+ messagePopover?.setAttribute("aria-hidden", isOpen ? "false" : "true");
+ } catch (e) { console.debug?.(e); }
+ };
+
+ const renderMessagesPopover = () => {
+ if (!messageList) return;
+ const entries = listPanelMessages();
+ const lastReadAt = getPanelLastReadAt();
+ messageList.replaceChildren();
+ if (!entries.length) {
+ const empty = document.createElement("div");
+ empty.className = "mjr-messages-empty";
+ empty.textContent = t("msg.noMessages", "No messages for now.");
+ messageList.appendChild(empty);
+ return;
+ }
+
+ for (const entry of entries) {
+ const item = document.createElement("article");
+ item.className = "mjr-message-item";
+ const level = normalizeMessageLevel(entry?.level);
+ item.classList.add(`mjr-message-item--${level}`);
+ const createdAt = Number(entry?.createdAt || 0);
+ const unread = createdAt > Number(lastReadAt || 0);
+ if (unread) item.classList.add("mjr-message-item--unread");
+
+ const head = document.createElement("div");
+ head.className = "mjr-message-item-head";
+
+ const titleWrap = document.createElement("div");
+ titleWrap.className = "mjr-message-item-title-wrap";
+
+ const emoji = document.createElement("span");
+ emoji.className = "mjr-message-item-emoji";
+ emoji.textContent = resolveMessageEmoji(entry);
+ emoji.setAttribute("aria-hidden", "true");
+
+ const title = document.createElement("div");
+ title.className = "mjr-message-item-title";
+ title.textContent = resolveMessageText(entry, "titleKey", "title", t("label.info", "Info"));
+
+ const category = document.createElement("span");
+ category.className = "mjr-message-item-category";
+ category.textContent = resolveMessageText(entry, "categoryKey", "category", t("label.info", "Info"));
+
+ titleWrap.appendChild(emoji);
+ titleWrap.appendChild(title);
+ head.appendChild(titleWrap);
+ head.appendChild(category);
+
+ const meta = document.createElement("div");
+ meta.className = "mjr-message-item-meta";
+ meta.textContent = formatMessageDate(createdAt);
+
+ const body = document.createElement("div");
+ body.className = "mjr-message-item-body";
+ body.textContent = resolveMessageText(entry, "bodyKey", "body", "");
+
+ item.appendChild(head);
+ if (meta.textContent) item.appendChild(meta);
+ if (body.textContent) item.appendChild(body);
+
+ const actionUrl = resolveSafeActionUrl(entry?.actionUrl);
+ const actionLabel = resolveMessageText(entry, "actionLabelKey", "actionLabel", t("label.readMe", "Read Me"));
+ if (actionUrl && actionLabel) {
+ const action = document.createElement("a");
+ action.className = "mjr-message-item-action";
+ action.href = actionUrl;
+ action.target = "_blank";
+ action.rel = "noopener noreferrer";
+ action.textContent = actionLabel;
+ item.appendChild(action);
+ }
+ messageList.appendChild(item);
+ }
+ };
+
+ const updateMessageButtonState = () => {
+ if (!messageBtn) return;
+ const unreadCount = Number(getPanelUnreadCount() || 0);
+ const badge = messageBtn.querySelector(".mjr-message-badge");
+ const title = unreadCount > 0
+ ? t("tooltip.openMessagesUnread", "Messages ({count} unread)", { count: unreadCount })
+ : t("tooltip.openMessages", "Messages and updates");
+ messageBtn.classList.toggle("mjr-message-has-unread", unreadCount > 0);
+ messageBtn.title = title;
+ messageBtn.setAttribute("aria-label", title);
+ if (!badge) return;
+ if (unreadCount <= 0) {
+ badge.style.display = "none";
+ badge.textContent = "";
+ return;
+ }
+ badge.style.display = "inline-flex";
+ badge.textContent = unreadCount > 9 ? "9+" : String(unreadCount);
+ };
+
+ const refreshMessagesUI = () => {
+ updateMessageButtonState();
+ if (messagePopover?.style?.display === "block") {
+ renderMessagesPopover();
+ }
+ };
+
+ const listenerOptions = signal ? { signal } : undefined;
+
+ try {
+ window.addEventListener(PANEL_MESSAGES_EVENT, refreshMessagesUI, listenerOptions);
+ } catch (e) { console.debug?.(e); }
+ try {
+ if (messageBtn && messagePopover) {
+ if (!messagePopover.id) messagePopover.id = "mjr-messages-popover";
+ messageBtn.setAttribute("aria-haspopup", "dialog");
+ messageBtn.setAttribute("aria-controls", messagePopover.id);
+ }
+ } catch (e) { console.debug?.(e); }
+
+ let expandedObserver = null;
+ try {
+ if (typeof MutationObserver !== "undefined" && messagePopover) {
+ expandedObserver = new MutationObserver(syncExpandedState);
+ expandedObserver.observe(messagePopover, {
+ attributes: true,
+ attributeFilter: ["style"],
+ });
+ }
+ } catch (e) { console.debug?.(e); }
+ try {
+ signal?.addEventListener?.("abort", () => expandedObserver?.disconnect?.(), { once: true });
+ } catch (e) { console.debug?.(e); }
+
+ updateMessageButtonState();
+ syncExpandedState();
+
+ messageBtn?.addEventListener("click", (e) => {
+ if (!messagePopover || !popovers || !messageBtn) return;
+ e.stopPropagation();
+ renderMessagesPopover();
+ try {
+ if (typeof onBeforeToggle === "function") onBeforeToggle();
+ } catch (err) { console.debug?.(err); }
+ popovers.toggle(messagePopover, messageBtn);
+ if (messagePopover?.style?.display === "block") {
+ markPanelMessagesRead();
+ }
+ syncExpandedState();
+ updateMessageButtonState();
+ }, listenerOptions);
+
+ markReadBtn?.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ markPanelMessagesRead();
+ renderMessagesPopover();
+ updateMessageButtonState();
+ }, listenerOptions);
+
+ return {
+ refresh: refreshMessagesUI,
+ render: renderMessagesPopover,
+ updateButtonState: updateMessageButtonState,
+ close: () => {
+ if (!messagePopover || !popovers) return;
+ popovers.close(messagePopover);
+ syncExpandedState();
+ },
+ };
+}
diff --git a/js/features/panel/state/panelState.js b/js/features/panel/state/panelState.js
index 3a2dea02..7470d386 100644
--- a/js/features/panel/state/panelState.js
+++ b/js/features/panel/state/panelState.js
@@ -72,6 +72,10 @@ function savePanelState(state) {
// Don't persist transient stats
delete toSave.lastGridCount;
delete toSave.lastGridTotal;
+ delete toSave.viewScope;
+ delete toSave.similarResults;
+ delete toSave.similarTitle;
+ delete toSave.similarSourceAssetId;
const serialized = JSON.stringify(toSave);
if (serialized === _lastSavedSerialized) return;
_lastSavedSerialized = serialized;
@@ -115,7 +119,13 @@ export function createPanelState() {
selectedAssetIds: saved.selectedAssetIds || [],
// Sidebar open state for restoring across panel close/reopen.
- sidebarOpen: saved.sidebarOpen || false
+ sidebarOpen: saved.sidebarOpen || false,
+
+ // Transient virtual scopes.
+ viewScope: "",
+ similarResults: [],
+ similarTitle: "",
+ similarSourceAssetId: "",
};
let proxy = null;
diff --git a/js/features/panel/views/contextPillsView.js b/js/features/panel/views/contextPillsView.js
index c03e08e3..ca3d12eb 100644
--- a/js/features/panel/views/contextPillsView.js
+++ b/js/features/panel/views/contextPillsView.js
@@ -18,6 +18,7 @@ const _labelSort = (key) => {
const _labelScope = (scope) => {
const s = String(scope || "").toLowerCase();
+ if (s === "similar") return t("scope.similar", "Similar");
if (s === "custom") return t("scope.customBrowser");
if (s === "all") return t("tab.all");
if (s === "input" || s === "inputs") return t("scope.input");
@@ -81,12 +82,14 @@ export function createContextPillsView() {
);
const isSortActive = _safeText(state?.sort || "mtime_desc").trim() !== "mtime_desc";
const isCollectionActive = _safeText(state?.collectionId || "").trim().length > 0;
- const scopeValue = String(state?.scope || "output").toLowerCase();
+ const scopeValue = String(state?.viewScope || state?.scope || "output").toLowerCase();
+ const isSimilarScope = scopeValue === "similar";
const isBrowserScope = scopeValue === "custom";
const isScopeActive = scopeValue !== "output";
+ const similarSourceId = _safeText(state?.similarSourceAssetId || "").trim();
const anyContext =
- isQueryActive || isFilterActive || isSortActive || isCollectionActive || isScopeActive;
+ isQueryActive || isFilterActive || isSortActive || isCollectionActive || isScopeActive || isSimilarScope;
if (!anyContext && !root.childElementCount) {
return;
}
@@ -104,12 +107,23 @@ export function createContextPillsView() {
})
);
}
+ if (isSimilarScope) {
+ root.appendChild(
+ _createPill({
+ label: t("search.findSimilar", "Find Similar"),
+ value: similarSourceId
+ ? t("search.similarReference", "Reference #{id}", { id: similarSourceId })
+ : t("scope.similar", "Similar"),
+ onClear: () => safeActions?.clearSimilarScope?.()
+ })
+ );
+ }
if (isScopeActive) {
root.appendChild(
_createPill({
label: t("label.scope"),
- value: _labelScope(state?.scope || "output"),
- onClear: () => safeActions?.clearScope?.()
+ value: _labelScope(scopeValue || "output"),
+ onClear: () => (isSimilarScope ? safeActions?.clearSimilarScope?.() : safeActions?.clearScope?.())
})
);
}
diff --git a/js/features/panel/views/headerView.js b/js/features/panel/views/headerView.js
index 03b474d7..af647e1f 100644
--- a/js/features/panel/views/headerView.js
+++ b/js/features/panel/views/headerView.js
@@ -236,6 +236,15 @@ export function createHeaderView() {
window.addEventListener(EVENTS.MFV_VISIBILITY_CHANGED, _mfvVisibilityHandler);
headerTools.appendChild(mfvBtn);
+ const messageBtn = createIconButton("pi-info-circle", t("tooltip.openMessages", "Messages and updates"));
+ messageBtn.classList.add("mjr-message-btn");
+ const messageBadge = document.createElement("span");
+ messageBadge.className = "mjr-message-badge";
+ messageBadge.style.display = "none";
+ messageBadge.setAttribute("aria-hidden", "true");
+ messageBtn.appendChild(messageBadge);
+ headerTools.appendChild(messageBtn);
+
headerRow.appendChild(headerLeft);
headerRow.appendChild(headerActions);
header.appendChild(headerRow);
@@ -287,6 +296,7 @@ export function createHeaderView() {
collectionsBtn,
pinnedFoldersBtn,
mfvBtn,
+ messageBtn,
dispose,
};
}
diff --git a/js/features/panel/views/iconButton.js b/js/features/panel/views/iconButton.js
index 41e462d1..16da8e5a 100644
--- a/js/features/panel/views/iconButton.js
+++ b/js/features/panel/views/iconButton.js
@@ -3,8 +3,10 @@ export function createIconButton(iconClass, title) {
btn.type = "button";
btn.className = "mjr-icon-btn";
btn.title = title;
+ if (title) btn.setAttribute("aria-label", title);
const i = document.createElement("i");
i.className = `pi ${iconClass}`;
+ i.setAttribute("aria-hidden", "true");
btn.appendChild(i);
return btn;
}
diff --git a/js/features/panel/views/messagePopoverView.js b/js/features/panel/views/messagePopoverView.js
new file mode 100644
index 00000000..8dcb3e0c
--- /dev/null
+++ b/js/features/panel/views/messagePopoverView.js
@@ -0,0 +1,73 @@
+import { t } from "../../../app/i18n.js";
+
+const GITHUB_REPO_URL = "https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager";
+
+export function createMessagePopoverView() {
+ const messagePopover = document.createElement("div");
+ messagePopover.className = "mjr-popover mjr-messages-popover";
+ messagePopover.id = "mjr-messages-popover";
+ messagePopover.setAttribute("role", "dialog");
+ messagePopover.setAttribute("aria-label", t("label.messages", "Messages"));
+ messagePopover.setAttribute("aria-hidden", "true");
+ messagePopover.tabIndex = -1;
+ messagePopover.style.display = "none";
+ messagePopover.style.cssText = "display:none;";
+
+ const head = document.createElement("div");
+ head.className = "mjr-messages-head";
+
+ const title = document.createElement("div");
+ title.className = "mjr-messages-title";
+ title.textContent = t("label.messages", "Messages");
+
+ const actions = document.createElement("div");
+ actions.className = "mjr-messages-actions";
+
+ const starLink = document.createElement("a");
+ starLink.className = "mjr-btn mjr-messages-star-link";
+ starLink.href = GITHUB_REPO_URL;
+ starLink.target = "_blank";
+ starLink.rel = "noopener noreferrer";
+ starLink.title = t("tooltip.starGithub", "Open GitHub and give a star");
+
+ const starIcon = document.createElement("span");
+ starIcon.className = "mjr-messages-star-icon";
+ starIcon.textContent = "\u2605";
+ starIcon.setAttribute("aria-hidden", "true");
+
+ const starLabel = document.createElement("span");
+ starLabel.className = "mjr-messages-star-label";
+ starLabel.textContent = t("btn.giveStar", "Give a star");
+
+ starLink.appendChild(starIcon);
+ starLink.appendChild(starLabel);
+
+ const markReadBtn = document.createElement("button");
+ markReadBtn.type = "button";
+ markReadBtn.className = "mjr-btn mjr-messages-mark-read-btn";
+ markReadBtn.textContent = t("btn.markAllRead", "Mark all read");
+ markReadBtn.title = t("tooltip.markMessagesRead", "Mark all messages as read");
+
+ head.appendChild(title);
+ actions.appendChild(starLink);
+ actions.appendChild(markReadBtn);
+ head.appendChild(actions);
+
+ const messageList = document.createElement("div");
+ messageList.className = "mjr-messages-list";
+
+ const emptyState = document.createElement("div");
+ emptyState.className = "mjr-messages-empty";
+ emptyState.textContent = t("msg.noMessages", "No messages for now.");
+
+ messageList.appendChild(emptyState);
+ messagePopover.appendChild(head);
+ messagePopover.appendChild(messageList);
+
+ return {
+ messagePopover,
+ messageList,
+ emptyState,
+ markReadBtn,
+ };
+}
diff --git a/js/features/panel/views/popoverManager.js b/js/features/panel/views/popoverManager.js
index b77808bd..6f375731 100644
--- a/js/features/panel/views/popoverManager.js
+++ b/js/features/panel/views/popoverManager.js
@@ -17,8 +17,24 @@ export function createPopoverManager(container) {
const POPOVER_BOTTOM_SAFETY_PX = 80;
const POPOVER_MIN_HEIGHT_PX = 140;
+ const bindPopoverInteractionGuards = (popover) => {
+ if (!popover || popover._mjrInteractionGuardsBound) return;
+ const stopPropagation = (event) => {
+ try {
+ event.stopPropagation();
+ } catch (e) { console.debug?.(e); }
+ };
+ try {
+ popover.addEventListener("wheel", stopPropagation, { passive: true });
+ popover.addEventListener("mousedown", stopPropagation);
+ popover.addEventListener("touchmove", stopPropagation, { passive: true });
+ popover._mjrInteractionGuardsBound = true;
+ } catch (e) { console.debug?.(e); }
+ };
+
const attachPopoverToPortal = (popover) => {
if (!popover || !(popover instanceof HTMLElement)) return;
+ bindPopoverInteractionGuards(popover);
if (!popover._mjrPortal) {
popover._mjrPortal = {
parent: popover.parentNode,
@@ -90,6 +106,7 @@ export function createPopoverManager(container) {
const availableBelow = bottomLimit - top;
popover.style.maxHeight = `${Math.max(POPOVER_MIN_HEIGHT_PX, availableBelow)}px`;
popover.style.overflow = "auto";
+ popover.style.overscrollBehavior = "contain";
popover.style.left = `${Math.round(left)}px`;
popover.style.top = `${Math.round(top)}px`;
diff --git a/js/features/panel/views/summaryBarView.js b/js/features/panel/views/summaryBarView.js
index 721cd54b..08222723 100644
--- a/js/features/panel/views/summaryBarView.js
+++ b/js/features/panel/views/summaryBarView.js
@@ -11,6 +11,7 @@ const _safeText = (v) => {
const _titleScope = (scope) => {
const s = String(scope || "").toLowerCase();
+ if (s === "similar") return t("scope.similar", "Similar");
if (s === "input" || s === "inputs") return t("scope.input");
if (s === "custom") return t("scope.customBrowser");
if (s === "all") return t("tab.all");
@@ -108,7 +109,8 @@ export function createSummaryBarView() {
Math.max(0, Number(state?.lastGridCount ?? 0) || 0) ||
cardsCount;
- const scope = _titleScope(state?.scope || gridContainer?.dataset?.mjrScope || "output");
+ const activeScope = String(state?.viewScope || state?.scope || gridContainer?.dataset?.mjrScope || "output").toLowerCase();
+ const scope = _titleScope(activeScope);
const countPart = total && total >= shown ? `${shown}/${total}` : `${shown}`;
const primaryLabel =
@@ -127,21 +129,26 @@ export function createSummaryBarView() {
} catch (e) { console.debug?.(e); }
try {
- const alert = context?.duplicatesAlert || null;
- const exact = Number(alert?.exactCount || 0) || 0;
- const similar = Number(alert?.similarCount || 0) || 0;
- const hasAlert = exact > 0 || similar > 0;
- if (!hasAlert) {
+ if (activeScope === "similar") {
dupAlert.style.display = "none";
dupAlert.onclick = null;
} else {
- dupAlert.style.display = "";
- dupAlert.textContent = `${t("summary.duplicates")}: ${exact} | ${t("summary.similar")}: ${similar}`;
- dupAlert.onclick = () => {
- try {
- actions?.onDuplicateAlertClick?.(alert);
- } catch (e) { console.debug?.(e); }
- };
+ const alert = context?.duplicatesAlert || null;
+ const exact = Number(alert?.exactCount || 0) || 0;
+ const similar = Number(alert?.similarCount || 0) || 0;
+ const hasAlert = exact > 0 || similar > 0;
+ if (!hasAlert) {
+ dupAlert.style.display = "none";
+ dupAlert.onclick = null;
+ } else {
+ dupAlert.style.display = "";
+ dupAlert.textContent = `${t("summary.duplicates")}: ${exact} | ${t("summary.similar")}: ${similar}`;
+ dupAlert.onclick = () => {
+ try {
+ actions?.onDuplicateAlertClick?.(alert);
+ } catch (e) { console.debug?.(e); }
+ };
+ }
}
} catch (e) { console.debug?.(e); }
diff --git a/js/features/panel/views/tabsView.js b/js/features/panel/views/tabsView.js
index 2003f390..f9e99179 100644
--- a/js/features/panel/views/tabsView.js
+++ b/js/features/panel/views/tabsView.js
@@ -18,14 +18,17 @@ export function createTabsView() {
const tabInputs = makeTab(t("tab.input"), "input", t("tooltip.tab.input"));
const tabOutputs = makeTab(t("tab.output"), "output", t("tooltip.tab.output"));
const tabCustom = makeTab(t("tab.custom"), "custom", t("tooltip.tab.custom"));
+ const tabSimilar = makeTab(t("tab.similar", "Similar"), "similar", t("tooltip.tab.similar", "Browse current similar findings"));
+ tabSimilar.style.display = "none";
tabs.appendChild(tabAll);
tabs.appendChild(tabInputs);
tabs.appendChild(tabOutputs);
tabs.appendChild(tabCustom);
+ tabs.appendChild(tabSimilar);
return {
tabs,
- tabButtons: { tabAll, tabInputs, tabOutputs, tabCustom }
+ tabButtons: { tabAll, tabInputs, tabOutputs, tabCustom, tabSimilar }
};
}
diff --git a/js/features/status/StatusDot.js b/js/features/status/StatusDot.js
index 00f753bf..d1dcb7cc 100644
--- a/js/features/status/StatusDot.js
+++ b/js/features/status/StatusDot.js
@@ -5,6 +5,7 @@
import { get, getToolsStatus, post, resetIndex, getWatcherStatus, forceDeleteDb, listDbBackups, saveDbBackup, restoreDbBackup, vectorBackfill } from "../../api/client.js";
import { ENDPOINTS } from "../../api/endpoints.js";
import { APP_CONFIG } from "../../app/config.js";
+import { comfyConfirm } from "../../app/dialogs.js";
import { comfyToast } from "../../app/toast.js";
import { t } from "../../app/i18n.js";
import { getEnrichmentState, setEnrichmentState } from "../../app/runtimeState.js";
@@ -180,6 +181,16 @@ function emitGlobalGridReload(reason = "status-action") {
} catch (e) { console.debug?.(e); }
}
+async function askKeepVectors(actionLabel = "") {
+ return await comfyConfirm(
+ t(
+ "dialog.vectorsReset.keepQuestion",
+ "Keep existing AI vectors?\n\nConfirm = keep vectors\nCancel = continue without vectors"
+ ),
+ actionLabel || t("dialog.vectorsReset.title", "AI vectors")
+ );
+}
+
/**
* Create status indicator section
*/
@@ -362,7 +373,10 @@ export function createStatusIndicator(options = {}) {
comfyToast(t("toast.dbRestoreSelect", "Select a DB backup first"), "warning", 2000);
return;
}
- const confirmed = confirm(t("dialog.dbRestore.confirm", "Restore selected DB backup? Current DB will be replaced."));
+ const confirmed = await comfyConfirm(
+ t("dialog.dbRestore.confirm", "Restore selected DB backup? Current DB will be replaced."),
+ t("btn.dbRestore", "Restore DB")
+ );
if (!confirmed) return;
comfyToast(t("toast.dbRestoreStarted", "DB restore started"), "info", 1800);
setMaintenanceActive(true);
@@ -453,6 +467,17 @@ export function createStatusIndicator(options = {}) {
};
resetBtn.onclick = async (event) => {
event.stopPropagation();
+ const preserveVectors = await askKeepVectors(t("btn.resetIndex", "Reset index"));
+ const confirmed = await comfyConfirm(
+ preserveVectors
+ ? t(
+ "dialog.resetIndex.confirmKeepVectors",
+ "This will reset index data and rescan files while keeping existing AI vectors.\n\nContinue?"
+ )
+ : t("dialog.resetIndex.msg", "This will delete the database and rescan all files. Continue?"),
+ t("dialog.resetIndex.title", "Reset index?")
+ );
+ if (!confirmed) return;
setMaintenanceActive(true);
const originalText = resetBtn.textContent;
@@ -476,11 +501,12 @@ export function createStatusIndicator(options = {}) {
customRootId: isCustomBrowserMode ? null : customRootId,
reindex: true,
maintenance_force: true,
- hard_reset_db: isAll,
+ hard_reset_db: isAll && !preserveVectors,
clear_scan_journal: true,
clear_metadata_cache: true,
clear_asset_metadata: true,
- clear_assets: true,
+ clear_assets: !preserveVectors,
+ preserve_vectors: preserveVectors,
rebuild_fts: true,
});
if (res?.ok) {
@@ -593,7 +619,11 @@ export function createStatusIndicator(options = {}) {
const errors = Number(res?.data?.errors ?? progress?.errors ?? 0);
if (pending) {
const jobId = String(res?.data?.job_id || "").trim();
- const msg = `Vector backfill is still running in background${jobId ? ` (job ${jobId.slice(0, 8)})` : ""}.`;
+ const msg = t(
+ "toast.vectorBackfillRunning",
+ "Vector backfill still running in background{job}.",
+ { job: jobId ? ` (job ${jobId.slice(0, 8)})` : "" }
+ );
comfyToast(msg, "info", 4200);
setActionLog(
`Backfill running in background โ candidates ${processed}, indexed ${indexed}, skipped ${skipped}, errors ${errors}`,
@@ -602,13 +632,21 @@ export function createStatusIndicator(options = {}) {
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
applyStatusHighlight(section, "info");
} else {
- comfyToast(`Vector backfill done โ processed ${processed}, indexed ${indexed}, skipped ${skipped}`, "success", 3000);
+ comfyToast(
+ t(
+ "toast.vectorBackfillComplete",
+ "Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
+ { processed, indexed, skipped }
+ ),
+ "success",
+ 3000
+ );
setActionLog(`Backfill OK โ processed ${processed}, indexed ${indexed}, skipped ${skipped}`, "success");
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
applyStatusHighlight(section, "success");
}
} else {
- const err = String(res?.error || "Backfill failed");
+ const err = String(res?.error || t("toast.vectorBackfillFailedGeneric", "Backfill failed"));
const code = String(res?.code || "").trim();
const status = Number(res?.status || 0) || 0;
const detail = [
@@ -671,21 +709,45 @@ export function createStatusIndicator(options = {}) {
};
deleteDbBtn.onclick = async (event) => {
event.stopPropagation();
-
- const confirmed = confirm(t("dialog.dbDelete.confirm"));
+ const preserveVectors = await askKeepVectors(t("btn.deleteDb", "Delete DB"));
+ const confirmed = await comfyConfirm(
+ preserveVectors
+ ? t(
+ "dialog.dbDelete.keepVectorsConfirm",
+ "This will reset index data and keep existing AI vectors. Database files will not be force-deleted.\n\nContinue?"
+ )
+ : t(
+ "dialog.dbDelete.confirm",
+ "This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.\n\nContinue?"
+ ),
+ t("btn.deleteDb", "Delete DB")
+ );
if (!confirmed) return;
setMaintenanceActive(true);
const originalText = deleteDbBtn.textContent;
deleteDbBtn.disabled = true;
- deleteDbBtn.textContent = t("btn.deletingDb");
+ deleteDbBtn.textContent = preserveVectors ? t("btn.resetting", "Resetting...") : t("btn.deletingDb");
resetBtn.disabled = true;
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
applyStatusHighlight(section, "info");
try {
- const res = await forceDeleteDb();
+ const res = preserveVectors
+ ? await resetIndex({
+ scope: "all",
+ reindex: true,
+ maintenance_force: true,
+ hard_reset_db: false,
+ clear_scan_journal: true,
+ clear_metadata_cache: true,
+ clear_asset_metadata: true,
+ clear_assets: false,
+ preserve_vectors: true,
+ rebuild_fts: true,
+ })
+ : await forceDeleteDb();
if (res?.ok) {
globalThis._mjrCorruptToastShown = false;
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
@@ -702,7 +764,7 @@ export function createStatusIndicator(options = {}) {
deleteDbBtn.disabled = false;
deleteDbBtn.textContent = originalText;
resetBtn.disabled = false;
- emitGlobalGridReload("db-force-delete");
+ emitGlobalGridReload(preserveVectors ? "index-reset-preserve-vectors" : "db-force-delete");
try {
const target = getScanContext ? getScanContext() : null;
await updateStatus(statusDot, statusText, capabilities, target, null, { force: true });
diff --git a/js/features/viewer/FloatingViewer.js b/js/features/viewer/FloatingViewer.js
index 443d7274..f5737750 100644
--- a/js/features/viewer/FloatingViewer.js
+++ b/js/features/viewer/FloatingViewer.js
@@ -24,6 +24,7 @@ const MFV_ZOOM_MIN = 0.25;
const MFV_ZOOM_MAX = 8;
const MFV_ZOOM_FACTOR = 0.0008; // multiplied by deltaY per wheel tick
const MFV_RESIZE_EDGE_HIT_PX = 8;
+let _mfvInstanceSeq = 0;
// Media extensions for explicit kind detection.
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
@@ -184,10 +185,13 @@ function _canvasLabel(ctx, text, x, y) {
export class FloatingViewer {
constructor() {
+ this._instanceId = ++_mfvInstanceSeq;
this.element = null;
this.isVisible = false;
this._contentEl = null;
+ this._closeBtn = null;
this._modeBtn = null;
+ this._pinSelect = null;
this._liveBtn = null;
this._genBtn = null;
this._genDropdown = null;
@@ -196,6 +200,7 @@ export class FloatingViewer {
this._mode = MFV_MODES.SIMPLE;
this._mediaA = null;
this._mediaB = null;
+ this._pinnedSlot = null;
this._abDividerX = 0.5; // 0..1
// Pan/zoom state
@@ -207,7 +212,7 @@ export class FloatingViewer {
// AbortController for toolbar/header button click listeners (NM-1).
// Aborted in dispose() so listeners are cleaned up without needing named references.
- this._btnAC = new AbortController();
+ this._btnAC = null;
// Generation counter: incremented on every loadMediaA/loadMediaPair call so
// stale async metadata enrichment results can be discarded (NM-2).
this._refreshGen = 0;
@@ -216,6 +221,9 @@ export class FloatingViewer {
this._popoutWindow = null;
this._popoutBtn = null;
this._isPopped = false;
+ this._popoutCloseHandler = null;
+ this._popoutCloseTimer = null;
+ this._popoutRestoreGuard = false;
// Preview stream state: button ref + last blob URL for cleanup.
this._previewBtn = null;
@@ -225,6 +233,10 @@ export class FloatingViewer {
// Panel-level listeners and edge-resize state.
this._panelAC = new AbortController();
this._resizeState = null;
+ this._titleId = `mjr-mfv-title-${this._instanceId}`;
+ this._genDropdownId = `mjr-mfv-gen-dropdown-${this._instanceId}`;
+ this._docClickHost = null;
+ this._handleDocClick = null;
}
// โโ Build DOM โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -232,17 +244,22 @@ export class FloatingViewer {
render() {
const el = document.createElement("div");
el.className = "mjr-mfv";
+ el.setAttribute("role", "dialog");
+ el.setAttribute("aria-modal", "false");
+ el.setAttribute("aria-hidden", "true");
this.element = el;
el.appendChild(this._buildHeader());
+ el.setAttribute("aria-labelledby", this._titleId);
el.appendChild(this._buildToolbar());
this._contentEl = document.createElement("div");
this._contentEl.className = "mjr-mfv-content";
el.appendChild(this._contentEl);
- this._initEdgeResize(el);
- this._initDrag(el.querySelector(".mjr-mfv-header"));
+ this._rebindControlHandlers();
+ this._bindPanelInteractions();
+ this._bindDocumentUiHandlers();
this._refresh();
return el;
}
@@ -253,19 +270,20 @@ export class FloatingViewer {
const title = document.createElement("span");
title.className = "mjr-mfv-header-title";
+ title.id = this._titleId;
title.textContent = "Majoor Viewer Lite";
const closeBtn = document.createElement("button");
+ this._closeBtn = closeBtn;
closeBtn.type = "button";
closeBtn.className = "mjr-icon-btn";
- closeBtn.title = t("tooltip.closeViewer", "Close viewer");
+ const closeLabel = t("tooltip.closeViewer", "Close viewer");
+ closeBtn.title = closeLabel;
+ closeBtn.setAttribute("aria-label", closeLabel);
const _closeBtnIcon = document.createElement("i");
_closeBtnIcon.className = "pi pi-times";
_closeBtnIcon.setAttribute("aria-hidden", "true");
closeBtn.appendChild(_closeBtnIcon);
- closeBtn.addEventListener("click", () => {
- window.dispatchEvent(new CustomEvent(EVENTS.MFV_CLOSE));
- }, { signal: this._btnAC.signal });
header.appendChild(title);
header.appendChild(closeBtn);
@@ -280,10 +298,26 @@ export class FloatingViewer {
this._modeBtn = document.createElement("button");
this._modeBtn.type = "button";
this._modeBtn.className = "mjr-icon-btn";
- this._modeBtn.addEventListener("click", () => this._cycleMode(), { signal: this._btnAC.signal });
this._updateModeBtnUI();
bar.appendChild(this._modeBtn);
+ this._pinSelect = document.createElement("select");
+ this._pinSelect.className = "mjr-mfv-pin-select";
+ this._pinSelect.setAttribute("aria-label", "Pin Reference");
+ this._pinSelect.value = this._pinnedSlot || "";
+ for (const { value, label } of [
+ { value: "", label: "No Pin" },
+ { value: "A", label: "Pin A" },
+ { value: "B", label: "Pin B" },
+ ]) {
+ const option = document.createElement("option");
+ option.value = value;
+ option.textContent = label;
+ this._pinSelect.appendChild(option);
+ }
+ this._updatePinSelectUI();
+ bar.appendChild(this._pinSelect);
+
// Separator
const sep = document.createElement("div");
sep.className = "mjr-mfv-toolbar-sep";
@@ -294,40 +328,32 @@ export class FloatingViewer {
this._liveBtn = document.createElement("button");
this._liveBtn.type = "button";
this._liveBtn.className = "mjr-icon-btn";
- this._liveBtn.title = t("tooltip.liveStreamOff", "Live Stream: OFF โ click to follow");
this._liveBtn.innerHTML = '';
- this._liveBtn.addEventListener("click", () => {
- window.dispatchEvent(new CustomEvent(EVENTS.MFV_LIVE_TOGGLE));
- }, { signal: this._btnAC.signal });
+ this._liveBtn.setAttribute("aria-pressed", "false");
+ this._liveBtn.setAttribute("aria-label", t("tooltip.liveStreamOff", "Live Stream: OFF โ click to follow"));
+ this._liveBtn.title = t("tooltip.liveStreamOff", "Live Stream: OFF โ click to follow");
bar.appendChild(this._liveBtn);
// KSampler Preview Stream toggle
this._previewBtn = document.createElement("button");
this._previewBtn.type = "button";
this._previewBtn.className = "mjr-icon-btn";
- this._previewBtn.title = t("tooltip.previewStreamOff", "KSampler Preview: OFF โ click to stream denoising steps");
this._previewBtn.innerHTML = '';
- this._previewBtn.addEventListener("click", () => {
- window.dispatchEvent(new CustomEvent(EVENTS.MFV_PREVIEW_TOGGLE));
- }, { signal: this._btnAC.signal });
+ this._previewBtn.setAttribute("aria-pressed", "false");
+ this._previewBtn.setAttribute("aria-label", t("tooltip.previewStreamOff", "KSampler Preview: OFF โ click to stream denoising steps"));
+ this._previewBtn.title = t("tooltip.previewStreamOff", "KSampler Preview: OFF โ click to stream denoising steps");
bar.appendChild(this._previewBtn);
// Gen Info button (shows dropdown with checkboxes)
this._genBtn = document.createElement("button");
this._genBtn.type = "button";
this._genBtn.className = "mjr-icon-btn";
+ this._genBtn.setAttribute("aria-haspopup", "dialog");
+ this._genBtn.setAttribute("aria-expanded", "false");
const _genBtnIcon = document.createElement("i");
_genBtnIcon.className = "pi pi-info-circle";
_genBtnIcon.setAttribute("aria-hidden", "true");
this._genBtn.appendChild(_genBtnIcon);
- this._genBtn.addEventListener("click", (e) => {
- e.stopPropagation();
- if (this._genDropdown?.classList?.contains("is-visible")) {
- this._closeGenDropdown();
- } else {
- this._openGenDropdown();
- }
- }, { signal: this._btnAC.signal });
bar.appendChild(this._genBtn);
this._updateGenBtnUI();
@@ -335,46 +361,117 @@ export class FloatingViewer {
this._popoutBtn = document.createElement("button");
this._popoutBtn.type = "button";
this._popoutBtn.className = "mjr-icon-btn";
- this._popoutBtn.title = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
+ const popoutLabel = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
+ this._popoutBtn.title = popoutLabel;
+ this._popoutBtn.setAttribute("aria-label", popoutLabel);
+ this._popoutBtn.setAttribute("aria-pressed", "false");
const _popoutIcon = document.createElement("i");
_popoutIcon.className = "pi pi-external-link";
_popoutIcon.setAttribute("aria-hidden", "true");
this._popoutBtn.appendChild(_popoutIcon);
- this._popoutBtn.addEventListener("click", () => {
- window.dispatchEvent(new CustomEvent(EVENTS.MFV_POPOUT));
- }, { signal: this._btnAC.signal });
bar.appendChild(this._popoutBtn);
// Download / capture button
this._captureBtn = document.createElement("button");
this._captureBtn.type = "button";
this._captureBtn.className = "mjr-icon-btn";
- this._captureBtn.title = t("tooltip.captureView", "Save view as image");
+ const captureLabel = t("tooltip.captureView", "Save view as image");
+ this._captureBtn.title = captureLabel;
+ this._captureBtn.setAttribute("aria-label", captureLabel);
const _captureBtnIcon = document.createElement("i");
_captureBtnIcon.className = "pi pi-download";
_captureBtnIcon.setAttribute("aria-hidden", "true");
this._captureBtn.appendChild(_captureBtnIcon);
- this._captureBtn.addEventListener("click", () => this._captureView(), { signal: this._btnAC.signal });
bar.appendChild(this._captureBtn);
- // Close dropdown when clicking outside
this._handleDocClick = (ev) => {
if (!this._genDropdown) return;
- if (ev.target === this._genBtn) return;
- if (this._genDropdown.contains(ev.target)) return;
+ const target = ev?.target;
+ if (this._genBtn?.contains?.(target)) return;
+ if (this._genDropdown.contains(target)) return;
this._closeGenDropdown();
};
- document.addEventListener("click", this._handleDocClick);
+ this._bindDocumentUiHandlers();
return bar;
}
+ _rebindControlHandlers() {
+ try { this._btnAC?.abort(); } catch (e) { console.debug?.(e); }
+ this._btnAC = new AbortController();
+ const signal = this._btnAC.signal;
+
+ this._closeBtn?.addEventListener("click", () => {
+ window.dispatchEvent(new CustomEvent(EVENTS.MFV_CLOSE));
+ }, { signal });
+
+ this._modeBtn?.addEventListener("click", () => this._cycleMode(), { signal });
+
+ this._pinSelect?.addEventListener("change", (e) => {
+ this._pinnedSlot = e?.target?.value || null;
+ if (this._pinnedSlot && this._mode === MFV_MODES.SIMPLE) {
+ this.setMode(MFV_MODES.AB);
+ }
+ this._updatePinSelectUI();
+ }, { signal });
+
+ this._liveBtn?.addEventListener("click", () => {
+ window.dispatchEvent(new CustomEvent(EVENTS.MFV_LIVE_TOGGLE));
+ }, { signal });
+
+ this._previewBtn?.addEventListener("click", () => {
+ window.dispatchEvent(new CustomEvent(EVENTS.MFV_PREVIEW_TOGGLE));
+ }, { signal });
+
+ this._genBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (this._genDropdown?.classList?.contains("is-visible")) {
+ this._closeGenDropdown();
+ } else {
+ this._openGenDropdown();
+ }
+ }, { signal });
+
+ this._popoutBtn?.addEventListener("click", () => {
+ window.dispatchEvent(new CustomEvent(EVENTS.MFV_POPOUT));
+ }, { signal });
+
+ this._captureBtn?.addEventListener("click", () => this._captureView(), { signal });
+ }
+
+ _resetGenDropdownForCurrentDocument() {
+ this._closeGenDropdown();
+ try { this._genDropdown?.remove?.(); } catch (e) { console.debug?.(e); }
+ this._genDropdown = null;
+ this._updateGenBtnUI();
+ }
+
+ _bindDocumentUiHandlers() {
+ if (!this._handleDocClick) return;
+ const doc = this.element?.ownerDocument || document;
+ if (this._docClickHost === doc) return;
+ this._unbindDocumentUiHandlers();
+ doc.addEventListener("click", this._handleDocClick);
+ this._docClickHost = doc;
+ }
+
+ _unbindDocumentUiHandlers() {
+ if (!this._docClickHost || !this._handleDocClick) return;
+ try { this._docClickHost.removeEventListener("click", this._handleDocClick); } catch (e) { console.debug?.(e); }
+ this._docClickHost = null;
+ }
+
+ _isGenDropdownOpen() {
+ return !!this._genDropdown?.classList?.contains("is-visible");
+ }
+
_openGenDropdown() {
if (!this.element) return;
if (!this._genDropdown) {
this._genDropdown = this._buildGenDropdown();
this.element.appendChild(this._genDropdown);
}
+ this._bindDocumentUiHandlers();
const rect = this._genBtn.getBoundingClientRect();
const parentRect = this.element.getBoundingClientRect();
const left = rect.left - parentRect.left;
@@ -382,11 +479,13 @@ export class FloatingViewer {
this._genDropdown.style.left = `${left}px`;
this._genDropdown.style.top = `${top}px`;
this._genDropdown.classList.add("is-visible");
+ this._updateGenBtnUI();
}
_closeGenDropdown() {
if (!this._genDropdown) return;
this._genDropdown.classList.remove("is-visible");
+ this._updateGenBtnUI();
}
/** Reflect how many fields are enabled on the gen info button. */
@@ -394,15 +493,28 @@ export class FloatingViewer {
if (!this._genBtn) return;
const count = this._genInfoSelections.size;
const isOn = count > 0;
+ const isOpen = this._isGenDropdownOpen();
this._genBtn.classList.toggle("is-on", isOn);
- this._genBtn.title = isOn
- ? `Gen Info (${count} field${count > 1 ? "s" : ""} shown) โ click to configure`
- : "Gen Info โ click to show overlay";
+ this._genBtn.classList.toggle("is-open", isOpen);
+ const label = isOn
+ ? `Gen Info (${count} field${count > 1 ? "s" : ""} shown)${isOpen ? " โ open" : " โ click to configure"}`
+ : `Gen Info${isOpen ? " โ open" : " โ click to show overlay"}`;
+ this._genBtn.title = label;
+ this._genBtn.setAttribute("aria-label", label);
+ this._genBtn.setAttribute("aria-expanded", String(isOpen));
+ if (this._genDropdown) {
+ this._genBtn.setAttribute("aria-controls", this._genDropdownId);
+ } else {
+ this._genBtn.removeAttribute("aria-controls");
+ }
}
_buildGenDropdown() {
const d = document.createElement("div");
d.className = "mjr-mfv-gen-dropdown";
+ d.id = this._genDropdownId;
+ d.setAttribute("role", "group");
+ d.setAttribute("aria-label", "Generation info fields");
const opts = [
["prompt", "Prompt"],
["seed", "Seed"],
@@ -552,6 +664,22 @@ export class FloatingViewer {
this._refresh();
}
+ getPinnedSlot() {
+ return this._pinnedSlot;
+ }
+
+ _updatePinSelectUI() {
+ if (!this._pinSelect) return;
+ const pinned = this._pinnedSlot === "A" || this._pinnedSlot === "B";
+ this._pinSelect.value = this._pinnedSlot || "";
+ this._pinSelect.classList.toggle("is-pinned", pinned);
+ const label = pinned
+ ? `Pin Reference: ${this._pinnedSlot}`
+ : "Pin Reference: Off";
+ this._pinSelect.title = label;
+ this._pinSelect.setAttribute("aria-label", label);
+ }
+
_updateModeBtnUI() {
if (!this._modeBtn) return;
const cfg = {
@@ -565,25 +693,33 @@ export class FloatingViewer {
_modeBtnIcon.setAttribute("aria-hidden", "true");
this._modeBtn.replaceChildren(_modeBtnIcon);
this._modeBtn.title = label;
+ this._modeBtn.setAttribute("aria-label", label);
+ this._modeBtn.removeAttribute("aria-pressed");
}
// โโ Live Stream UI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
setLiveActive(active) {
if (!this._liveBtn) return;
- this._liveBtn.classList.toggle("mjr-live-active", Boolean(active));
- if (active) {
+ const isActive = Boolean(active);
+ this._liveBtn.classList.toggle("mjr-live-active", isActive);
+ const label = isActive
+ ? t("tooltip.liveStreamOn", "Live Stream: ON โ click to disable")
+ : t("tooltip.liveStreamOff", "Live Stream: OFF โ click to follow");
+ this._liveBtn.setAttribute("aria-pressed", String(isActive));
+ this._liveBtn.setAttribute("aria-label", label);
+ if (isActive) {
const _liveIconActive = document.createElement("i");
_liveIconActive.className = "pi pi-circle-fill";
_liveIconActive.setAttribute("aria-hidden", "true");
this._liveBtn.replaceChildren(_liveIconActive);
- this._liveBtn.title = t("tooltip.liveStreamOn", "Live Stream: ON โ click to disable");
+ this._liveBtn.title = label;
} else {
const _liveIconInactive = document.createElement("i");
_liveIconInactive.className = "pi pi-circle";
_liveIconInactive.setAttribute("aria-hidden", "true");
this._liveBtn.replaceChildren(_liveIconInactive);
- this._liveBtn.title = t("tooltip.liveStreamOff", "Live Stream: OFF โ click to follow");
+ this._liveBtn.title = label;
}
}
@@ -592,19 +728,24 @@ export class FloatingViewer {
setPreviewActive(active) {
this._previewActive = Boolean(active);
if (!this._previewBtn) return;
- this._previewBtn.classList.toggle("mjr-live-active", this._previewActive);
+ this._previewBtn.classList.toggle("mjr-preview-active", this._previewActive);
+ const label = this._previewActive
+ ? t("tooltip.previewStreamOn", "KSampler Preview: ON โ streaming denoising steps")
+ : t("tooltip.previewStreamOff", "KSampler Preview: OFF โ click to stream denoising steps");
+ this._previewBtn.setAttribute("aria-pressed", String(this._previewActive));
+ this._previewBtn.setAttribute("aria-label", label);
if (this._previewActive) {
const icon = document.createElement("i");
icon.className = "pi pi-eye";
icon.setAttribute("aria-hidden", "true");
this._previewBtn.replaceChildren(icon);
- this._previewBtn.title = t("tooltip.previewStreamOn", "KSampler Preview: ON โ streaming denoising steps");
+ this._previewBtn.title = label;
} else {
const icon = document.createElement("i");
icon.className = "pi pi-eye-slash";
icon.setAttribute("aria-hidden", "true");
this._previewBtn.replaceChildren(icon);
- this._previewBtn.title = t("tooltip.previewStreamOff", "KSampler Preview: OFF โ click to stream denoising steps");
+ this._previewBtn.title = label;
// Revoke last blob URL when turning off
this._revokePreviewBlob();
}
@@ -1024,7 +1165,9 @@ export class FloatingViewer {
show() {
if (!this.element) return;
+ this._bindDocumentUiHandlers();
this.element.classList.add("is-visible");
+ this.element.setAttribute("aria-hidden", "false");
this.isVisible = true;
}
@@ -1034,7 +1177,9 @@ export class FloatingViewer {
// even if hide() is called mid-drag (NM-5).
this._destroyPanZoom();
this._stopEdgeResize();
+ this._closeGenDropdown();
this.element.classList.remove("is-visible");
+ this.element.setAttribute("aria-hidden", "true");
this.isVisible = false;
}
@@ -1042,8 +1187,8 @@ export class FloatingViewer {
/**
* Move the viewer into a separate browser window so it can be
- * dragged onto a second monitor. Uses adoptNode() to transfer
- * the live DOM tree โ all JS references and listeners stay valid.
+ * dragged onto a second monitor. Opens a dedicated same-origin page,
+ * then adopts the live DOM tree into that page so state/listeners persist.
*/
popOut() {
if (this._isPopped || !this.element) return;
@@ -1058,34 +1203,201 @@ export class FloatingViewer {
const left = (window.screenX || window.screenLeft) + Math.round((window.outerWidth - w) / 2);
const top = (window.screenY || window.screenTop) + Math.round((window.outerHeight - h) / 2);
const features = `width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no`;
- const popup = window.open("", "_mjr_viewer", features);
+ const popup = window.open(this._getPopoutUrl(), "_mjr_viewer", features);
if (!popup) {
console.warn("[MFV] Pop-out blocked โ allow popups for this site.");
return;
}
this._popoutWindow = popup;
this._isPopped = true;
+ this._popoutRestoreGuard = false;
+ let mounted = false;
+ const handlePopupClosing = () => this._schedulePopInFromPopupClose();
+ this._popoutCloseHandler = handlePopupClosing;
- // ----- Build the popup document -----
- const doc = popup.document;
- doc.title = "Majoor Viewer";
+ const mountViewer = () => {
+ if (mounted) return;
+ const doc = popup.document;
+ if (!doc) return;
+ if (!this._ensurePopoutShell(doc)) return;
+ doc.title = "Majoor Viewer";
+ this._installPopoutStyles(doc);
+ const root = doc.getElementById("mjr-mfv-popout-root") || doc.body;
+ if (!root) return;
+ try { root.replaceChildren(); } catch (e) { console.debug?.(e); }
+ root.appendChild(doc.adoptNode(el));
+ el.classList.add("is-visible");
+ this.isVisible = true;
+ mounted = true;
+ this._resetGenDropdownForCurrentDocument();
+ this._rebindControlHandlers();
+ this._bindDocumentUiHandlers();
+ this._updatePopoutBtnUI();
+ };
- // Copy ALL stylesheets from the parent page so themes, PrimeIcons,
- // and MFV-specific CSS are available in the popup.
- for (const ss of document.querySelectorAll('link[rel="stylesheet"], style')) {
- doc.head.appendChild(doc.importNode(ss, true));
+ try {
+ popup.addEventListener("load", mountViewer, { once: true });
+ } catch (e) {
+ console.debug?.("[MFV] pop-out page mount failed", e);
+ }
+ if (popup.document?.readyState === "interactive" || popup.document?.readyState === "complete") {
+ setTimeout(mountViewer, 0);
}
- // Override styles for the popped-out viewer (fill window, no fixed position)
- const overrideStyle = doc.createElement("style");
- overrideStyle.textContent = `
- html, body {
- margin: 0; padding: 0;
- width: 100%; height: 100%;
- overflow: hidden;
- background: #1e1e1e;
- color: #ddd;
+ // When the user closes the popup window, automatically pop the viewer back in.
+ popup.addEventListener("beforeunload", handlePopupClosing);
+ popup.addEventListener("pagehide", handlePopupClosing);
+ popup.addEventListener("unload", handlePopupClosing);
+ this._startPopoutCloseWatch();
+
+ // Relay keyboard shortcuts from popup back to main window
+ popup.addEventListener("keydown", (e) => {
+ const tag = String(e?.target?.tagName || "").toLowerCase();
+ if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") {
+ return;
}
+ window.dispatchEvent(new KeyboardEvent("keydown", {
+ key: e.key, code: e.code, keyCode: e.keyCode,
+ ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
+ }));
+ });
+ }
+
+ _clearPopoutCloseWatch() {
+ if (this._popoutCloseTimer == null) return;
+ try { window.clearInterval(this._popoutCloseTimer); } catch (e) { console.debug?.(e); }
+ this._popoutCloseTimer = null;
+ }
+
+ _startPopoutCloseWatch() {
+ this._clearPopoutCloseWatch();
+ this._popoutCloseTimer = window.setInterval(() => {
+ if (!this._isPopped) {
+ this._clearPopoutCloseWatch();
+ return;
+ }
+ const popup = this._popoutWindow;
+ if (!popup || popup.closed) {
+ this._clearPopoutCloseWatch();
+ this._schedulePopInFromPopupClose();
+ }
+ }, 250);
+ }
+
+ _schedulePopInFromPopupClose() {
+ if (!this._isPopped || this._popoutRestoreGuard) return;
+ this._popoutRestoreGuard = true;
+ window.setTimeout(() => {
+ try {
+ this.popIn({ closePopupWindow: false });
+ } finally {
+ this._popoutRestoreGuard = false;
+ }
+ }, 0);
+ }
+
+ _getPopoutUrl() {
+ try {
+ return new URL("/mjr/viewer/popout", window.location.href).toString();
+ } catch {
+ return "/mjr/viewer/popout";
+ }
+ }
+
+ _buildPopoutShellHtml() {
+ return `
+
+
+
+
+
+ Majoor Viewer
+
+
+
+
+
Preparing viewer...
+
+
+`;
+ }
+
+ _ensurePopoutShell(doc) {
+ if (!doc) return false;
+ try {
+ if (doc.getElementById("mjr-mfv-popout-root")) return true;
+ doc.open();
+ doc.write(this._buildPopoutShellHtml());
+ doc.close();
+ return !!doc.getElementById("mjr-mfv-popout-root");
+ } catch (e) {
+ console.debug?.("[MFV] pop-out shell init failed", e);
+ return false;
+ }
+ }
+
+ _installPopoutStyles(doc) {
+ if (!doc?.head) return;
+ try {
+ for (const existing of doc.head.querySelectorAll("[data-mjr-popout-cloned-style='1']")) {
+ existing.remove();
+ }
+ } catch (e) { console.debug?.(e); }
+ for (const ss of document.querySelectorAll('link[rel="stylesheet"], style')) {
+ try {
+ let clone = null;
+ if (ss.tagName === "LINK") {
+ clone = doc.createElement("link");
+ for (const attr of Array.from(ss.attributes || [])) {
+ if (attr?.name === "href") continue;
+ clone.setAttribute(attr.name, attr.value);
+ }
+ clone.setAttribute("href", ss.href || ss.getAttribute("href") || "");
+ } else {
+ clone = doc.importNode(ss, true);
+ }
+ clone.setAttribute("data-mjr-popout-cloned-style", "1");
+ doc.head.appendChild(clone);
+ } catch (e) { console.debug?.(e); }
+ }
+ const overrideStyle = doc.createElement("style");
+ overrideStyle.setAttribute("data-mjr-popout-cloned-style", "1");
+ overrideStyle.textContent = `
.mjr-mfv {
position: static !important;
width: 100% !important;
@@ -1100,46 +1412,48 @@ export class FloatingViewer {
}
`;
doc.head.appendChild(overrideStyle);
-
- // Adopt the existing DOM node into the popup document
- doc.body.appendChild(doc.adoptNode(el));
-
- // Force visible (it may have been hidden if toggled before pop-out)
- el.classList.add("is-visible");
- this.isVisible = true;
-
- // Update pop-out button to show "pop-in" icon
- this._updatePopoutBtnUI();
-
- // When the user closes the popup window, automatically pop the viewer back in.
- popup.addEventListener("beforeunload", () => this.popIn());
-
- // Relay keyboard shortcuts from popup back to main window
- popup.addEventListener("keydown", (e) => {
- window.dispatchEvent(new KeyboardEvent("keydown", {
- key: e.key, code: e.code, keyCode: e.keyCode,
- ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
- }));
- });
}
/**
* Move the viewer back into the main ComfyUI page from the popup window.
*/
- popIn() {
+ popIn({ closePopupWindow = true } = {}) {
if (!this._isPopped || !this.element) return;
+ const popup = this._popoutWindow;
+ const closeHandler = this._popoutCloseHandler;
+ this._clearPopoutCloseWatch();
+ if (popup && closeHandler) {
+ try { popup.removeEventListener("beforeunload", closeHandler); } catch (e) { console.debug?.(e); }
+ try { popup.removeEventListener("pagehide", closeHandler); } catch (e) { console.debug?.(e); }
+ try { popup.removeEventListener("unload", closeHandler); } catch (e) { console.debug?.(e); }
+ }
+ this._popoutCloseHandler = null;
this._isPopped = false;
// Re-adopt the element into the main document and append to body
- const adopted = document.adoptNode(this.element);
+ let adopted = this.element;
+ try {
+ adopted = adopted?.ownerDocument === document ? adopted : document.adoptNode(adopted);
+ } catch (e) {
+ console.debug?.("[MFV] pop-in adopt failed", e);
+ }
document.body.appendChild(adopted);
+ this._resetGenDropdownForCurrentDocument();
+ this._rebindControlHandlers();
+ this._bindPanelInteractions();
+ this._bindDocumentUiHandlers();
+ adopted.classList.add("is-visible");
+ adopted.setAttribute("aria-hidden", "false");
+ this.isVisible = true;
// Restore inline position styles (they were cleared by the popup override)
// The viewer will revert to its normal fixed-position CSS.
this._updatePopoutBtnUI();
// Close the popup window if it's still open
- try { this._popoutWindow?.close(); } catch (e) { console.debug?.(e); }
+ if (closePopupWindow) {
+ try { popup?.close(); } catch (e) { console.debug?.(e); }
+ }
this._popoutWindow = null;
}
@@ -1151,13 +1465,17 @@ export class FloatingViewer {
}
this._popoutBtn.classList.toggle("mjr-popin-active", this._isPopped);
const icon = this._popoutBtn.querySelector("i") || document.createElement("i");
+ const label = this._isPopped
+ ? t("tooltip.popInViewer", "Return viewer to ComfyUI window")
+ : t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
if (this._isPopped) {
icon.className = "pi pi-sign-in";
- this._popoutBtn.title = t("tooltip.popInViewer", "Return viewer to ComfyUI window");
} else {
icon.className = "pi pi-external-link";
- this._popoutBtn.title = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
}
+ this._popoutBtn.title = label;
+ this._popoutBtn.setAttribute("aria-label", label);
+ this._popoutBtn.setAttribute("aria-pressed", String(this._isPopped));
if (!this._popoutBtn.contains(icon)) {
this._popoutBtn.replaceChildren(icon);
}
@@ -1207,6 +1525,15 @@ export class FloatingViewer {
this.element.style.cursor = "";
}
+ _bindPanelInteractions() {
+ if (!this.element) return;
+ this._stopEdgeResize();
+ try { this._panelAC?.abort(); } catch (e) { console.debug?.(e); }
+ this._panelAC = new AbortController();
+ this._initEdgeResize(this.element);
+ this._initDrag(this.element.querySelector(".mjr-mfv-header"));
+ }
+
_initEdgeResize(el) {
if (!el) return;
const resolveDir = (e) => {
@@ -1319,9 +1646,11 @@ export class FloatingViewer {
_initDrag(handle) {
if (!handle) return;
+ const signal = this._panelAC?.signal;
handle.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (e.target.closest("button")) return; // Don't drag when clicking buttons
+ if (e.target.closest("select")) return;
if (this._isPopped || !this.element) return;
// Let edge-resize take precedence when pointer is near panel borders.
const edgeDir = this._getResizeDirectionFromPoint(
@@ -1352,7 +1681,7 @@ export class FloatingViewer {
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
- });
+ }, signal ? { signal } : undefined);
}
// โโ Canvas capture โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -1621,6 +1950,7 @@ export class FloatingViewer {
dispose() {
this._destroyPanZoom();
this._stopEdgeResize();
+ this._clearPopoutCloseWatch();
try { this._panelAC?.abort(); this._panelAC = null; } catch (e) { console.debug?.(e); }
// Abort all button click listeners in one call (NM-1).
try { this._btnAC?.abort(); this._btnAC = null; } catch (e) { console.debug?.(e); }
@@ -1630,11 +1960,13 @@ export class FloatingViewer {
try { this.element?.remove(); } catch (e) { console.debug?.(e); }
this.element = null;
this._contentEl = null;
+ this._closeBtn = null;
this._modeBtn = null;
+ this._pinSelect = null;
this._liveBtn = null;
this._popoutBtn = null;
this._captureBtn = null;
- try { document.removeEventListener("click", this._handleDocClick); } catch (e) { console.debug?.(e); }
+ this._unbindDocumentUiHandlers();
try { this._genDropdown?.remove(); } catch (e) { console.debug?.(e); }
this._mediaA = null;
this._mediaB = null;
diff --git a/js/features/viewer/floatingViewerManager.js b/js/features/viewer/floatingViewerManager.js
index 21b030ca..952d681b 100644
--- a/js/features/viewer/floatingViewerManager.js
+++ b/js/features/viewer/floatingViewerManager.js
@@ -40,6 +40,18 @@ function _cancelFetch() {
_fetchAC = null;
}
+function _emitVisibilityChanged(visible) {
+ window.dispatchEvent(new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, {
+ detail: { visible: Boolean(visible) },
+ }));
+}
+
+function _syncViewerControls(inst) {
+ if (!inst) return;
+ inst.setLiveActive(_liveActive);
+ inst.setPreviewActive(_previewActive);
+}
+
/**
* When only 1 asset is selected and the MFV is in a compare mode,
* look up the adjacent card in the rendered grid DOM to use as slot B.
@@ -78,22 +90,30 @@ async function _loadFromIds(selectedIds) {
_fetchAC = ac;
try {
+ const pinnedSlot = _instance.getPinnedSlot();
let ids = selectedIds.slice(0, 2);
- // Compare-mode fallback: if only 1 asset, auto-pick the adjacent grid item for slot B.
const mode = _instance._mode;
- if (ids.length === 1 && (mode === MFV_MODES.AB || mode === MFV_MODES.SIDE)) {
+ if (pinnedSlot && (mode === MFV_MODES.AB || mode === MFV_MODES.SIDE)) {
+ // With a pinned side, only one new asset is needed.
+ ids = [ids[0]];
+ } else if (ids.length === 1 && (mode === MFV_MODES.AB || mode === MFV_MODES.SIDE)) {
+ // Compare-mode fallback: if only 1 asset, auto-pick the adjacent grid item for slot B.
const adjId = _findAdjacentGridId(ids[0]);
if (adjId) ids = [ids[0], adjId];
}
- const result = await getAssetsBatch(ids);
+ const result = await getAssetsBatch(ids, ac ? { signal: ac.signal } : {});
if (ac?.signal.aborted) return;
if (!result?.ok || !Array.isArray(result.data) || !result.data.length) return;
if (!_instance) return; // disposed while fetching
const assets = result.data;
- if (ids.length >= 2 && assets.length >= 2) {
+ if (pinnedSlot === "A" && _instance._mediaA) {
+ _instance.loadMediaPair(_instance._mediaA, assets[0]);
+ } else if (pinnedSlot === "B" && _instance._mediaB) {
+ _instance.loadMediaPair(assets[0], _instance._mediaB);
+ } else if (ids.length >= 2 && assets.length >= 2) {
_instance.loadMediaPair(assets[0], assets[1]);
} else {
_instance.loadMediaA(assets[0], { autoMode: true });
@@ -164,17 +184,17 @@ export const floatingViewerManager = {
open() {
const inst = _getInstance();
inst.show();
- inst.setLiveActive(_liveActive); // Sync button visual state
+ _syncViewerControls(inst);
_bindSelectionListener();
// KEY FIX: immediately show whatever is selected in the grid.
_syncCurrentGridSelection();
- window.dispatchEvent(new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, { detail: { visible: true } }));
+ _emitVisibilityChanged(true);
},
close() {
if (_instance) _instance.hide();
_unbindSelectionListener();
- window.dispatchEvent(new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, { detail: { visible: false } }));
+ _emitVisibilityChanged(false);
},
toggle() {
@@ -192,9 +212,12 @@ export const floatingViewerManager = {
*/
upsertWithContent(fileData) {
const inst = _getInstance();
+ const wasVisible = Boolean(inst.isVisible);
inst.show();
+ _syncViewerControls(inst);
_bindSelectionListener();
inst.loadMediaA(fileData, { autoMode: true });
+ if (!wasVisible) _emitVisibilityChanged(true);
},
setLiveActive(active) {
@@ -243,10 +266,13 @@ export const floatingViewerManager = {
feedPreviewBlob(blob) {
if (!_previewActive) return;
const inst = _getInstance();
+ const wasVisible = Boolean(inst.isVisible);
if (!inst.isVisible) {
inst.show();
}
+ _syncViewerControls(inst);
inst.loadPreviewBlob(blob);
+ if (!wasVisible) _emitVisibilityChanged(true);
},
};
diff --git a/js/tests/floating_viewer.vitest.mjs b/js/tests/floating_viewer.vitest.mjs
new file mode 100644
index 00000000..dda10bae
--- /dev/null
+++ b/js/tests/floating_viewer.vitest.mjs
@@ -0,0 +1,169 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("../app/i18n.js", () => ({
+ t: (_key, fallback) => fallback,
+}));
+
+vi.mock("../app/events.js", () => ({
+ EVENTS: {},
+}));
+
+vi.mock("../api/endpoints.js", () => ({
+ buildViewURL: vi.fn(() => ""),
+ buildAssetViewURL: vi.fn(() => ""),
+}));
+
+vi.mock("../features/viewer/genInfo.js", () => ({
+ ensureViewerMetadataAsset: vi.fn(async (asset) => asset),
+}));
+
+vi.mock("../api/client.js", () => ({
+ getAssetMetadata: vi.fn(async () => ({})),
+ getFileMetadataScoped: vi.fn(async () => ({})),
+}));
+
+vi.mock("../components/sidebar/parsers/geninfoParser.js", () => ({
+ normalizeGenerationMetadata: vi.fn((meta) => meta),
+}));
+
+describe("FloatingViewer", () => {
+ beforeEach(() => {
+ globalThis.document = {
+ adoptNode: vi.fn((node) => node),
+ body: {
+ appendChild: vi.fn(),
+ },
+ createElement: vi.fn(() => ({
+ className: "",
+ setAttribute: vi.fn(),
+ appendChild: vi.fn(),
+ replaceChildren: vi.fn(),
+ })),
+ };
+ });
+
+ it("rebinds panel interactions when popping back into the main document", async () => {
+ const { FloatingViewer } = await import("../features/viewer/FloatingViewer.js");
+
+ const popup = {
+ removeEventListener: vi.fn(),
+ close: vi.fn(),
+ };
+ const element = {
+ ownerDocument: { name: "popup-doc" },
+ classList: {
+ add: vi.fn(),
+ },
+ setAttribute: vi.fn(),
+ };
+ const viewer = {
+ _isPopped: true,
+ element,
+ _popoutWindow: popup,
+ _popoutCloseHandler: vi.fn(),
+ _clearPopoutCloseWatch: vi.fn(),
+ _resetGenDropdownForCurrentDocument: vi.fn(),
+ _rebindControlHandlers: vi.fn(),
+ _bindPanelInteractions: vi.fn(),
+ _bindDocumentUiHandlers: vi.fn(),
+ _updatePopoutBtnUI: vi.fn(),
+ isVisible: false,
+ };
+
+ FloatingViewer.prototype.popIn.call(viewer, { closePopupWindow: false });
+
+ expect(document.adoptNode).toHaveBeenCalledWith(element);
+ expect(document.body.appendChild).toHaveBeenCalledWith(element);
+ expect(viewer._clearPopoutCloseWatch).toHaveBeenCalledTimes(1);
+ expect(popup.removeEventListener).toHaveBeenCalledTimes(3);
+ expect(viewer._resetGenDropdownForCurrentDocument).toHaveBeenCalledTimes(1);
+ expect(viewer._rebindControlHandlers).toHaveBeenCalledTimes(1);
+ expect(viewer._bindPanelInteractions).toHaveBeenCalledTimes(1);
+ expect(viewer._bindDocumentUiHandlers).toHaveBeenCalledTimes(1);
+ expect(viewer._updatePopoutBtnUI).toHaveBeenCalledTimes(1);
+ expect(element.classList.add).toHaveBeenCalledWith("is-visible");
+ expect(element.setAttribute).toHaveBeenCalledWith("aria-hidden", "false");
+ expect(viewer.isVisible).toBe(true);
+ expect(viewer._isPopped).toBe(false);
+ expect(viewer._popoutWindow).toBeNull();
+ expect(popup.close).not.toHaveBeenCalled();
+ });
+
+ it("refreshes drag and resize bindings when interactions are rebound", async () => {
+ const { FloatingViewer } = await import("../features/viewer/FloatingViewer.js");
+
+ const oldController = {
+ abort: vi.fn(),
+ };
+ const header = { nodeName: "HEADER" };
+ const element = {
+ querySelector: vi.fn(() => header),
+ };
+ const viewer = {
+ element,
+ _panelAC: oldController,
+ _stopEdgeResize: vi.fn(),
+ _initEdgeResize: vi.fn(),
+ _initDrag: vi.fn(),
+ };
+
+ FloatingViewer.prototype._bindPanelInteractions.call(viewer);
+
+ expect(viewer._stopEdgeResize).toHaveBeenCalledTimes(1);
+ expect(oldController.abort).toHaveBeenCalledTimes(1);
+ expect(viewer._panelAC).toBeInstanceOf(AbortController);
+ expect(viewer._panelAC).not.toBe(oldController);
+ expect(viewer._initEdgeResize).toHaveBeenCalledWith(element);
+ expect(element.querySelector).toHaveBeenCalledWith(".mjr-mfv-header");
+ expect(viewer._initDrag).toHaveBeenCalledWith(header);
+ });
+
+ it("rebinds toolbar and header controls with a fresh abort controller", async () => {
+ const { FloatingViewer } = await import("../features/viewer/FloatingViewer.js");
+
+ const oldController = {
+ abort: vi.fn(),
+ };
+ const closeBtn = { addEventListener: vi.fn() };
+ const modeBtn = { addEventListener: vi.fn() };
+ const pinSelect = { addEventListener: vi.fn() };
+ const liveBtn = { addEventListener: vi.fn() };
+ const previewBtn = { addEventListener: vi.fn() };
+ const genBtn = { addEventListener: vi.fn() };
+ const popoutBtn = { addEventListener: vi.fn() };
+ const captureBtn = { addEventListener: vi.fn() };
+ const viewer = {
+ _btnAC: oldController,
+ _closeBtn: closeBtn,
+ _modeBtn: modeBtn,
+ _pinSelect: pinSelect,
+ _liveBtn: liveBtn,
+ _previewBtn: previewBtn,
+ _genBtn: genBtn,
+ _popoutBtn: popoutBtn,
+ _captureBtn: captureBtn,
+ _genDropdown: null,
+ _pinnedSlot: null,
+ _mode: "simple",
+ _cycleMode: vi.fn(),
+ _updatePinSelectUI: vi.fn(),
+ _closeGenDropdown: vi.fn(),
+ _openGenDropdown: vi.fn(),
+ _captureView: vi.fn(),
+ setMode: vi.fn(),
+ };
+
+ FloatingViewer.prototype._rebindControlHandlers.call(viewer);
+
+ expect(oldController.abort).toHaveBeenCalledTimes(1);
+ expect(viewer._btnAC).toBeInstanceOf(AbortController);
+ expect(closeBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(modeBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(pinSelect.addEventListener).toHaveBeenCalledWith("change", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(liveBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(previewBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(genBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(popoutBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ expect(captureBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
+ });
+});
diff --git a/js/tests/floating_viewer_manager.vitest.mjs b/js/tests/floating_viewer_manager.vitest.mjs
new file mode 100644
index 00000000..663c8383
--- /dev/null
+++ b/js/tests/floating_viewer_manager.vitest.mjs
@@ -0,0 +1,324 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => {
+ const MFV_MODES = {
+ SIMPLE: "simple",
+ AB: "ab",
+ SIDE: "side",
+ };
+
+ let viewerMode = MFV_MODES.SIMPLE;
+ let lastViewer = null;
+ let activeGrid = null;
+ let pinnedSlot = null;
+
+ const getAssetsBatchMock = vi.fn();
+ const getSelectedIdSetMock = vi.fn();
+ const reportErrorMock = vi.fn();
+
+ class FloatingViewerMock {
+ constructor() {
+ this._mode = viewerMode;
+ this._mediaA = null;
+ this._mediaB = null;
+ this._pinnedSlot = pinnedSlot;
+ this.isVisible = false;
+ this.loadMediaA = vi.fn((asset, opts) => {
+ this._mediaA = asset;
+ this._lastLoadMediaA = [asset, opts];
+ });
+ this.loadMediaPair = vi.fn((assetA, assetB) => {
+ this._mediaA = assetA;
+ this._mediaB = assetB;
+ this._lastLoadMediaPair = [assetA, assetB];
+ });
+ this.setLiveActive = vi.fn();
+ this.setPreviewActive = vi.fn();
+ this.loadPreviewBlob = vi.fn();
+ lastViewer = this;
+ }
+
+ render() {
+ return { nodeName: "DIV" };
+ }
+
+ show() {
+ this.isVisible = true;
+ }
+
+ hide() {
+ this.isVisible = false;
+ }
+
+ get isPopped() {
+ return false;
+ }
+
+ getPinnedSlot() {
+ return this._pinnedSlot;
+ }
+
+ popOut() {}
+
+ popIn() {}
+
+ }
+
+ return {
+ MFV_MODES,
+ FloatingViewerMock,
+ getAssetsBatchMock,
+ getSelectedIdSetMock,
+ reportErrorMock,
+ getActiveGridContainer() {
+ return activeGrid;
+ },
+ reset() {
+ viewerMode = MFV_MODES.SIMPLE;
+ lastViewer = null;
+ activeGrid = null;
+ pinnedSlot = null;
+ getAssetsBatchMock.mockReset();
+ getSelectedIdSetMock.mockReset();
+ reportErrorMock.mockReset();
+ },
+ setActiveGrid(grid) {
+ activeGrid = grid;
+ },
+ setViewerMode(mode) {
+ viewerMode = mode;
+ },
+ setPinnedSlot(slot) {
+ pinnedSlot = slot || null;
+ },
+ getLastViewer() {
+ return lastViewer;
+ },
+ };
+});
+
+vi.mock("../features/viewer/FloatingViewer.js", () => ({
+ FloatingViewer: state.FloatingViewerMock,
+ MFV_MODES: state.MFV_MODES,
+}));
+
+vi.mock("../api/client.js", () => ({
+ getAssetsBatch: state.getAssetsBatchMock,
+}));
+
+vi.mock("../features/panel/AssetsManagerPanel.js", () => ({
+ getActiveGridContainer: () => state.getActiveGridContainer(),
+}));
+
+vi.mock("../features/grid/GridSelectionManager.js", () => ({
+ getSelectedIdSet: state.getSelectedIdSetMock,
+}));
+
+vi.mock("../utils/logging.js", () => ({
+ reportError: state.reportErrorMock,
+}));
+
+function createWindowStub() {
+ const listeners = new Map();
+ return {
+ addEventListener(type, handler) {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type).add(handler);
+ },
+ removeEventListener(type, handler) {
+ listeners.get(type)?.delete(handler);
+ },
+ dispatchEvent(event) {
+ const handlers = Array.from(listeners.get(event?.type) || []);
+ for (const handler of handlers) {
+ handler(event);
+ }
+ return true;
+ },
+ };
+}
+
+function createGrid(ids = []) {
+ return {
+ querySelectorAll() {
+ return ids.map((id) => ({ dataset: { mjrAssetId: String(id) } }));
+ },
+ };
+}
+
+async function flushAsyncWork() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+beforeEach(() => {
+ vi.resetModules();
+ state.reset();
+ globalThis.window = createWindowStub();
+ globalThis.document = {
+ body: {
+ appendChild: vi.fn(),
+ },
+ };
+ globalThis.CustomEvent = class {
+ constructor(type, init = {}) {
+ this.type = type;
+ this.detail = init.detail;
+ }
+ };
+});
+
+describe("floatingViewerManager", () => {
+ it("loads the current selection when no pin is active", async () => {
+ state.setActiveGrid(createGrid(["101"]));
+ state.getSelectedIdSetMock.mockReturnValue(new Set(["101"]));
+ state.getAssetsBatchMock.mockResolvedValue({
+ ok: true,
+ data: [{ id: 101, filename: "one.png" }],
+ });
+
+ const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
+ floatingViewerManager.open();
+ await flushAsyncWork();
+
+ const viewer = state.getLastViewer();
+ expect(viewer).toBeTruthy();
+ expect(viewer.loadMediaA).toHaveBeenCalledWith(
+ { id: 101, filename: "one.png" },
+ { autoMode: true },
+ );
+ expect(state.reportErrorMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps compare-mode fallback working when no pin is active", async () => {
+ state.setViewerMode(state.MFV_MODES.AB);
+ state.setActiveGrid(createGrid(["10", "11", "12"]));
+ state.getSelectedIdSetMock.mockReturnValue(new Set(["11"]));
+ state.getAssetsBatchMock.mockResolvedValue({
+ ok: true,
+ data: [
+ { id: 11, filename: "left.png" },
+ { id: 12, filename: "right.png" },
+ ],
+ });
+
+ const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
+ floatingViewerManager.open();
+ await flushAsyncWork();
+
+ const viewer = state.getLastViewer();
+ expect(viewer.loadMediaPair).toHaveBeenCalledWith(
+ { id: 11, filename: "left.png" },
+ { id: 12, filename: "right.png" },
+ );
+ expect(state.getAssetsBatchMock).toHaveBeenCalledWith(
+ ["11", "12"],
+ expect.objectContaining({ signal: expect.any(Object) }),
+ );
+ expect(state.reportErrorMock).not.toHaveBeenCalled();
+ });
+
+ it("preserves pinned slot A and updates slot B from new selections", async () => {
+ state.setViewerMode(state.MFV_MODES.AB);
+ state.setPinnedSlot("A");
+ state.setActiveGrid(createGrid(["201", "202"]));
+ state.getSelectedIdSetMock.mockReturnValue(new Set());
+ state.getAssetsBatchMock.mockResolvedValue({
+ ok: true,
+ data: [{ id: 202, filename: "candidate.png" }],
+ });
+
+ const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
+ floatingViewerManager.open();
+
+ const viewer = state.getLastViewer();
+ viewer._mediaA = { id: 9001, filename: "reference-a.png" };
+
+ window.dispatchEvent(new CustomEvent("mjr:selection-changed", {
+ detail: { selectedIds: ["202"] },
+ }));
+ await flushAsyncWork();
+
+ expect(viewer.loadMediaPair).toHaveBeenCalledWith(
+ { id: 9001, filename: "reference-a.png" },
+ { id: 202, filename: "candidate.png" },
+ );
+ expect(state.getAssetsBatchMock).toHaveBeenCalledWith(
+ ["202"],
+ expect.objectContaining({ signal: expect.any(Object) }),
+ );
+ });
+
+ it("preserves pinned slot B and updates slot A from new selections", async () => {
+ state.setViewerMode(state.MFV_MODES.AB);
+ state.setPinnedSlot("B");
+ state.setActiveGrid(createGrid(["301", "302"]));
+ state.getSelectedIdSetMock.mockReturnValue(new Set());
+ state.getAssetsBatchMock.mockResolvedValue({
+ ok: true,
+ data: [{ id: 301, filename: "candidate-a.png" }],
+ });
+
+ const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
+ floatingViewerManager.open();
+
+ const viewer = state.getLastViewer();
+ viewer._mediaB = { id: 9002, filename: "reference-b.png" };
+
+ window.dispatchEvent(new CustomEvent("mjr:selection-changed", {
+ detail: { selectedIds: ["301"] },
+ }));
+ await flushAsyncWork();
+
+ expect(viewer.loadMediaPair).toHaveBeenCalledWith(
+ { id: 301, filename: "candidate-a.png" },
+ { id: 9002, filename: "reference-b.png" },
+ );
+ expect(state.getAssetsBatchMock).toHaveBeenCalledWith(
+ ["301"],
+ expect.objectContaining({ signal: expect.any(Object) }),
+ );
+ });
+
+ it("emits visibility and syncs control states when live stream auto-opens the viewer", async () => {
+ const seen = [];
+ window.addEventListener("mjr:mfv-visibility-changed", (event) => {
+ seen.push(Boolean(event?.detail?.visible));
+ });
+
+ const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
+ floatingViewerManager.setLiveActive(true);
+ floatingViewerManager.upsertWithContent({ filename: "live-output.png", type: "output" });
+
+ const viewer = state.getLastViewer();
+ expect(viewer).toBeTruthy();
+ expect(viewer.isVisible).toBe(true);
+ expect(viewer.setLiveActive).toHaveBeenLastCalledWith(true);
+ expect(viewer.setPreviewActive).toHaveBeenLastCalledWith(false);
+ expect(viewer.loadMediaA).toHaveBeenCalledWith(
+ { filename: "live-output.png", type: "output" },
+ { autoMode: true },
+ );
+ expect(seen).toEqual([true]);
+ });
+
+ it("emits visibility and syncs preview state when preview stream auto-opens the viewer", async () => {
+ const seen = [];
+ const blob = { fake: "blob" };
+ window.addEventListener("mjr:mfv-visibility-changed", (event) => {
+ seen.push(Boolean(event?.detail?.visible));
+ });
+
+ const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
+ floatingViewerManager.setPreviewActive(true);
+ floatingViewerManager.feedPreviewBlob(blob);
+
+ const viewer = state.getLastViewer();
+ expect(viewer).toBeTruthy();
+ expect(viewer.isVisible).toBe(true);
+ expect(viewer.setLiveActive).toHaveBeenLastCalledWith(false);
+ expect(viewer.setPreviewActive).toHaveBeenLastCalledWith(true);
+ expect(viewer.loadPreviewBlob).toHaveBeenCalledWith(blob);
+ expect(seen).toEqual([true]);
+ });
+});
diff --git a/js/tests/grid_controller_semantic_mode.vitest.mjs b/js/tests/grid_controller_semantic_mode.vitest.mjs
new file mode 100644
index 00000000..5713cb65
--- /dev/null
+++ b/js/tests/grid_controller_semantic_mode.vitest.mjs
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const apiState = vi.hoisted(() => ({
+ hybridSearch: vi.fn(),
+ vectorSearch: vi.fn(),
+ vectorStats: vi.fn(),
+}));
+
+vi.mock("../api/client.js", () => ({
+ hybridSearch: apiState.hybridSearch,
+ vectorSearch: apiState.vectorSearch,
+ vectorStats: apiState.vectorStats,
+}));
+
+vi.mock("../app/toast.js", () => ({
+ comfyToast: vi.fn(),
+}));
+
+vi.mock("../app/i18n.js", () => ({
+ t: (_key, fallback, vars) => {
+ if (!vars) return fallback;
+ return String(fallback || "").replace(/\{(\w+)\}/g, (_m, key) => String(vars[key] ?? ""));
+ },
+}));
+
+vi.mock("../app/settings.js", () => ({
+ loadMajoorSettings: () => ({ ai: { vectorSearchEnabled: true } }),
+}));
+
+function createGridContainer() {
+ return {
+ dataset: {},
+ dispatchEvent: vi.fn(),
+ };
+}
+
+describe("gridController semantic mode", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ globalThis.window = {};
+ globalThis.CustomEvent = class {
+ constructor(type, init = {}) {
+ this.type = type;
+ this.detail = init.detail;
+ }
+ };
+ });
+
+ it("uses hybrid AI search when the semantic toggle is active", async () => {
+ const { createGridController } = await import("../features/panel/controllers/gridController.js");
+
+ apiState.hybridSearch.mockResolvedValue({
+ ok: true,
+ data: [{ id: 3, asset_id: 3, filename: "dino.png" }],
+ });
+ apiState.vectorSearch.mockResolvedValue({ ok: true, data: [] });
+ apiState.vectorStats.mockResolvedValue({ ok: true, data: { total: 33 } });
+
+ const loadAssets = vi.fn(async () => ({ ok: true, count: 0, total: 0 }));
+ const loadAssetsFromList = vi.fn(async (_grid, assets) => ({
+ ok: true,
+ count: assets.length,
+ total: assets.length,
+ }));
+
+ const controller = createGridController({
+ gridContainer: createGridContainer(),
+ loadAssets,
+ loadAssetsFromList,
+ getCollectionAssets: vi.fn(),
+ disposeGrid: vi.fn(),
+ getQuery: () => "dinosaure",
+ searchInputEl: { dataset: { mjrSemanticMode: "1" } },
+ state: {
+ scope: "output",
+ customRootId: "",
+ currentFolderRelativePath: "",
+ kindFilter: "",
+ workflowOnly: false,
+ minRating: 0,
+ minSizeMB: 0,
+ maxSizeMB: 0,
+ resolutionCompare: "gte",
+ minWidth: 0,
+ minHeight: 0,
+ maxWidth: 0,
+ maxHeight: 0,
+ workflowType: "",
+ dateRangeFilter: "",
+ dateExactFilter: "",
+ sort: "mtime_desc",
+ selectedAssetIds: [],
+ activeAssetId: "",
+ collectionId: "",
+ collectionName: "",
+ viewScope: "",
+ },
+ });
+
+ await controller.reloadGrid();
+
+ expect(apiState.hybridSearch).toHaveBeenCalledWith("dinosaure", expect.objectContaining({
+ topK: 100,
+ scope: "output",
+ }));
+ expect(loadAssetsFromList).toHaveBeenCalledWith(
+ expect.any(Object),
+ [{ id: 3, asset_id: 3, filename: "dino.png" }],
+ expect.objectContaining({ reset: true })
+ );
+ expect(loadAssets).not.toHaveBeenCalled();
+ });
+});
diff --git a/js/tests/message_center.vitest.mjs b/js/tests/message_center.vitest.mjs
new file mode 100644
index 00000000..c8aa56c8
--- /dev/null
+++ b/js/tests/message_center.vitest.mjs
@@ -0,0 +1,115 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+class MemoryStorage {
+ constructor() {
+ this.store = new Map();
+ }
+
+ clear() {
+ this.store.clear();
+ }
+
+ getItem(key) {
+ return this.store.has(key) ? this.store.get(key) : null;
+ }
+
+ removeItem(key) {
+ this.store.delete(key);
+ }
+
+ setItem(key, value) {
+ this.store.set(key, String(value));
+ }
+}
+
+function installRuntimeGlobals() {
+ const eventTarget = new EventTarget();
+ eventTarget.location = { href: "http://127.0.0.1:8188/" };
+ globalThis.window = eventTarget;
+ globalThis.localStorage = new MemoryStorage();
+ globalThis.CustomEvent = class CustomEvent extends Event {
+ constructor(type, init = {}) {
+ super(type);
+ this.detail = init.detail;
+ }
+ };
+}
+
+async function loadMessageCenter() {
+ vi.resetModules();
+ return import("../features/panel/messages/messageCenter.js");
+}
+
+describe("messageCenter", () => {
+ beforeEach(() => {
+ installRuntimeGlobals();
+ });
+
+ it("sanitizes unsupported levels to safe values", async () => {
+ const { addPanelMessage, listPanelMessages } = await loadMessageCenter();
+
+ addPanelMessage({ id: "bad-level", title: "Bad", level: "error urgent" });
+ addPanelMessage({ id: "warn-level", title: "Warn", level: "warn" });
+
+ const bad = listPanelMessages().find((message) => message.id === "bad-level");
+ const warn = listPanelMessages().find((message) => message.id === "warn-level");
+
+ expect(bad?.level).toBe("info");
+ expect(warn?.level).toBe("warning");
+ });
+
+ it("keeps builtin removals dismissed across reloads", async () => {
+ const builtinId = "whats-new-2026-03-05-pin-reference";
+ const firstLoad = await loadMessageCenter();
+
+ firstLoad.ensurePanelMessagesReady();
+ expect(firstLoad.listPanelMessages().map((message) => message.id)).toContain(builtinId);
+ expect(firstLoad.removePanelMessage(builtinId)).toBe(true);
+ expect(firstLoad.listPanelMessages().map((message) => message.id)).not.toContain(builtinId);
+
+ const secondLoad = await loadMessageCenter();
+ secondLoad.ensurePanelMessagesReady();
+ expect(secondLoad.listPanelMessages().map((message) => message.id)).not.toContain(builtinId);
+ });
+
+ it("keeps builtin announcements cleared after clear all", async () => {
+ const firstLoad = await loadMessageCenter();
+
+ firstLoad.ensurePanelMessagesReady();
+ expect(firstLoad.listPanelMessages().length).toBeGreaterThan(0);
+ firstLoad.clearPanelMessages();
+ expect(firstLoad.listPanelMessages()).toEqual([]);
+
+ const secondLoad = await loadMessageCenter();
+ secondLoad.ensurePanelMessagesReady();
+ expect(secondLoad.listPanelMessages()).toEqual([]);
+ });
+
+ it("returns the stored merged message when updating an existing entry", async () => {
+ const { addPanelMessage, listPanelMessages, upsertPanelMessage } = await loadMessageCenter();
+
+ addPanelMessage({
+ id: "existing-message",
+ title: "Initial title",
+ createdAt: 123456,
+ level: "info",
+ });
+
+ const updated = upsertPanelMessage({
+ id: "existing-message",
+ title: "Updated title",
+ level: "success",
+ });
+ const stored = listPanelMessages().find((message) => message.id === "existing-message");
+
+ expect(updated.createdAt).toBe(123456);
+ expect(updated.title).toBe("Updated title");
+ expect(updated.level).toBe("success");
+ expect(stored).toMatchObject({
+ id: "existing-message",
+ title: "Updated title",
+ createdAt: 123456,
+ level: "success",
+ });
+ });
+});
diff --git a/js/tests/scope_controller.vitest.mjs b/js/tests/scope_controller.vitest.mjs
new file mode 100644
index 00000000..af307173
--- /dev/null
+++ b/js/tests/scope_controller.vitest.mjs
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createScopeController } from "../features/panel/controllers/scopeController.js";
+
+function makeButton(scope) {
+ const classes = new Set();
+ return {
+ dataset: { scope },
+ style: { display: "" },
+ classList: {
+ toggle(name, active) {
+ if (active) classes.add(name);
+ else classes.delete(name);
+ },
+ has(name) {
+ return classes.has(name);
+ },
+ },
+ };
+}
+
+describe("scopeController similar scope", () => {
+ it("activates a temporary similar scope and clears it cleanly", async () => {
+ const tabButtons = {
+ tabAll: makeButton("all"),
+ tabInputs: makeButton("input"),
+ tabOutputs: makeButton("output"),
+ tabCustom: makeButton("custom"),
+ tabSimilar: makeButton("similar"),
+ };
+ const state = {
+ scope: "output",
+ viewScope: "",
+ similarResults: [{ asset_id: 7 }],
+ similarTitle: "Similar to asset #42 (1 results)",
+ similarSourceAssetId: "42",
+ collectionId: "",
+ collectionName: "",
+ currentFolderRelativePath: "",
+ customRootId: "",
+ };
+ const reloadGrid = vi.fn(async () => {});
+ const onChanged = vi.fn();
+ const onScopeChanged = vi.fn();
+ const onBeforeReload = vi.fn();
+ const popovers = { close: vi.fn() };
+ const customMenuBtn = { style: { display: "" } };
+ const customPopover = { style: { display: "" } };
+
+ const controller = createScopeController({
+ state,
+ tabButtons,
+ customMenuBtn,
+ customPopover,
+ popovers,
+ reloadGrid,
+ onChanged,
+ onScopeChanged,
+ onBeforeReload,
+ });
+
+ controller.setActiveTabStyles();
+ expect(tabButtons.tabSimilar.style.display).toBe("");
+ expect(tabButtons.tabOutputs.classList.has("is-active")).toBe(true);
+
+ await controller.setScope("similar");
+ expect(state.viewScope).toBe("similar");
+ expect(tabButtons.tabSimilar.classList.has("is-active")).toBe(true);
+ expect(onScopeChanged).not.toHaveBeenCalled();
+ expect(onBeforeReload).toHaveBeenCalledTimes(1);
+ expect(reloadGrid).toHaveBeenCalledTimes(1);
+
+ await controller.clearSimilarScope({ reload: false });
+ expect(state.viewScope).toBe("");
+ expect(state.similarResults).toEqual([]);
+ expect(state.similarTitle).toBe("");
+ expect(state.similarSourceAssetId).toBe("");
+ expect(tabButtons.tabSimilar.style.display).toBe("none");
+ });
+});
diff --git a/js/theme/theme-comfy.css b/js/theme/theme-comfy.css
index b22a555e..d8d24488 100644
--- a/js/theme/theme-comfy.css
+++ b/js/theme/theme-comfy.css
@@ -179,6 +179,37 @@
opacity: 1;
}
+.mjr-assets-manager .mjr-icon-btn.mjr-message-btn {
+ position: relative;
+ overflow: visible;
+}
+
+.mjr-assets-manager .mjr-icon-btn.mjr-message-has-unread {
+ border-color: color-mix(in srgb, var(--mjr-accent) 75%, var(--mjr-border));
+ background: color-mix(in srgb, var(--mjr-surface-2) 72%, var(--mjr-accent) 11%);
+}
+
+.mjr-assets-manager .mjr-message-badge {
+ position: absolute;
+ top: -5px;
+ right: -6px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--mjr-surface-1) 55%, rgba(255,255,255,0.3));
+ background: #ef5350;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+}
+
.mjr-assets-manager .mjr-icon-btn .pi {
font-size: 13px;
line-height: 1;
@@ -237,6 +268,9 @@
border: 1px solid var(--mjr-border, var(--border-color, rgba(255, 255, 255, 0.12)));
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
z-index: var(--mjr-popover-z-index);
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ touch-action: pan-y;
}
.mjr-filter-popover {
@@ -257,6 +291,175 @@
padding: 10px;
}
+.mjr-messages-popover {
+ min-width: 320px;
+ width: min(380px, calc(100vw - 24px));
+ padding: 10px;
+}
+
+.mjr-messages-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+}
+
+.mjr-messages-title {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: color-mix(in srgb, var(--content-fg, #ddd) 82%, var(--mjr-muted));
+}
+
+.mjr-messages-actions {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.mjr-messages-mark-read-btn {
+ padding: 4px 8px;
+ font-size: 11px;
+ line-height: 1.2;
+}
+
+.mjr-messages-star-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ font-size: 11px;
+ line-height: 1.2;
+ text-decoration: none;
+ color: var(--content-fg, #f1f1f1);
+ border-color: color-mix(in srgb, #f3c84b 42%, var(--mjr-border));
+ background: color-mix(in srgb, #f3c84b 12%, var(--mjr-surface-2));
+}
+
+.mjr-messages-star-link:hover,
+.mjr-messages-star-link:focus-visible {
+ text-decoration: none;
+}
+
+.mjr-messages-star-icon {
+ color: #f3c84b;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.mjr-messages-star-label {
+ white-space: nowrap;
+}
+
+.mjr-messages-list {
+ display: grid;
+ gap: 8px;
+}
+
+.mjr-messages-empty {
+ padding: 8px 6px;
+ font-size: 12px;
+ opacity: 0.72;
+}
+
+.mjr-message-item {
+ display: grid;
+ gap: 5px;
+ border-radius: 10px;
+ border: 1px solid color-mix(in srgb, var(--mjr-border) 86%, transparent);
+ background: color-mix(in srgb, var(--mjr-surface-2) 58%, transparent);
+ padding: 8px;
+}
+
+.mjr-message-item--unread {
+ border-color: color-mix(in srgb, var(--mjr-accent) 58%, var(--mjr-border));
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 26%, transparent);
+}
+
+.mjr-message-item--warning {
+ border-color: color-mix(in srgb, #f0b85a 62%, var(--mjr-border));
+}
+
+.mjr-message-item--error {
+ border-color: color-mix(in srgb, #f36b6b 62%, var(--mjr-border));
+}
+
+.mjr-message-item--success {
+ border-color: color-mix(in srgb, #58c07d 62%, var(--mjr-border));
+}
+
+.mjr-message-item-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.mjr-message-item-title-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.mjr-message-item-emoji {
+ flex: 0 0 auto;
+ font-size: 16px;
+ line-height: 1;
+}
+
+.mjr-message-item-title {
+ min-width: 0;
+ font-weight: 600;
+ color: var(--content-fg, #f1f1f1);
+}
+
+.mjr-message-item-category {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 6px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ background: color-mix(in srgb, var(--mjr-accent) 20%, var(--mjr-surface-2));
+ color: color-mix(in srgb, var(--content-fg, #fff) 86%, transparent);
+ white-space: nowrap;
+}
+
+.mjr-message-item-meta {
+ font-size: 11px;
+ opacity: 0.72;
+}
+
+.mjr-message-item-body {
+ font-size: 12px;
+ opacity: 0.92;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.mjr-message-item-action {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--mjr-accent, var(--comfy-accent, #5fb3ff));
+ text-decoration: none;
+ width: fit-content;
+}
+
+.mjr-message-item-action:hover {
+ text-decoration: underline;
+}
+
.mjr-filter-group {
border: 1px solid color-mix(in srgb, var(--mjr-border) 85%, transparent);
background: color-mix(in srgb, var(--mjr-surface-2) 70%, transparent);
@@ -340,32 +543,32 @@
}
/* Scrollbars (match ComfyUI feel) */
-.mjr-assets-manager .mjr-am-browse {
+.mjr-assets-manager .mjr-am-grid-scroll {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--mjr-border) 85%, var(--content-fg, #ddd)) transparent;
}
-.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar {
+.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
-.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-track {
+.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar-track {
background: transparent;
}
-.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-thumb {
+.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--mjr-surface-2) 55%, var(--content-fg, #ddd));
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 999px;
}
-.mjr-assets-manager .mjr-am-browse:hover::-webkit-scrollbar-thumb {
+.mjr-assets-manager .mjr-am-grid-scroll:hover::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--mjr-surface-2) 70%, var(--content-fg, #ddd));
}
-.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-corner {
+.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar-corner {
background: transparent;
}
@@ -815,8 +1018,10 @@
.mjr-assets-manager .mjr-am-browse {
margin-top: 0;
- /* Keep cards away from the vertical scrollbar */
- padding-right: 10px;
+}
+
+.mjr-assets-manager .mjr-am-grid-scroll {
+ padding-right: 4px;
scrollbar-gutter: stable;
}
@@ -1336,7 +1541,7 @@
/* Space between sidebar and grid */
.mjr-assets-manager .mjr-am-browse {
- gap: 10px;
+ gap: 0;
}
/* Border based on position */
@@ -2347,62 +2552,119 @@
display: flex;
align-items: center;
justify-content: space-between;
- padding: 4px 8px;
+ padding: 6px 10px;
background: var(--mjr-surface-1);
border-bottom: 1px solid var(--mjr-border);
cursor: move;
user-select: none;
flex-shrink: 0;
gap: 6px;
- min-height: 30px;
+ min-height: 34px;
}
.mjr-mfv-header-title {
- font-size: 11px;
- opacity: 0.6;
+ font-size: 12px;
+ font-weight: 600;
+ opacity: 0.78;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
- letter-spacing: 0.2px;
+ letter-spacing: 0.28px;
}
/* โโ Toolbar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.mjr-mfv-toolbar {
display: flex;
align-items: center;
- gap: 3px;
- padding: 3px 7px;
+ gap: 4px;
+ padding: 4px 8px;
background: color-mix(in srgb, var(--mjr-surface-0) 50%, var(--mjr-surface-1) 50%);
border-bottom: 1px solid var(--mjr-border);
flex-shrink: 0;
- min-height: 28px;
+ min-height: 36px;
}
.mjr-mfv-toolbar-sep {
width: 1px;
- height: 14px;
+ height: 18px;
background: var(--mjr-border);
- margin: 0 2px;
+ margin: 0 3px;
flex-shrink: 0;
}
+.mjr-mfv-pin-select {
+ height: 30px;
+ min-width: 88px;
+ border-radius: 7px;
+ border: 1px solid var(--mjr-border);
+ background: var(--mjr-surface-2);
+ color: var(--content-fg, #ddd);
+ outline: none;
+ padding: 0 28px 0 10px;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 28px;
+ flex-shrink: 0;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ color-scheme: dark;
+ background-image:
+ linear-gradient(45deg, transparent 50%, currentColor 50%),
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
+ background-position:
+ calc(100% - 14px) calc(50% - 1px),
+ calc(100% - 9px) calc(50% - 1px);
+ background-size: 5px 5px, 5px 5px;
+ background-repeat: no-repeat;
+ transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
+}
+
+.mjr-mfv-pin-select:hover {
+ border-color: color-mix(in srgb, var(--mjr-accent) 45%, var(--mjr-border));
+ background-color: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-accent) 8%);
+}
+
+.mjr-mfv-pin-select:focus {
+ border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 28%, transparent);
+}
+
+.mjr-mfv-pin-select:focus-visible {
+ border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border));
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 28%, transparent);
+}
+
+.mjr-mfv-pin-select.is-pinned {
+ border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 12%);
+ background-color: color-mix(in srgb, var(--mjr-surface-2) 42%, var(--mjr-accent) 58%);
+ color: #eef7ff;
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 24%, transparent);
+}
+
+.mjr-mfv-pin-select option {
+ background: var(--mjr-surface-1, #1c1c1c);
+ color: var(--content-fg, #ddd);
+}
+
/* โโ Icon buttons inside MFV (outside .mjr-assets-manager scope) โโโโโโโ */
.mjr-mfv .mjr-icon-btn {
- width: 26px;
- height: 26px;
+ width: 30px;
+ height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
- border-radius: 6px;
+ border-radius: 7px;
border: 1px solid var(--mjr-border);
background: var(--mjr-surface-2);
color: var(--content-fg, #ddd);
cursor: pointer;
padding: 0;
flex-shrink: 0;
- transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease;
+ transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.mjr-mfv .mjr-icon-btn:hover {
@@ -2411,8 +2673,14 @@
transform: translateY(-1px);
}
+.mjr-mfv .mjr-icon-btn:focus-visible {
+ outline: none;
+ border-color: color-mix(in srgb, var(--mjr-accent) 58%, var(--mjr-border));
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 24%, transparent);
+}
+
.mjr-mfv .mjr-icon-btn .pi {
- font-size: 12px;
+ font-size: 13px;
line-height: 1;
opacity: 0.9;
}
@@ -2421,18 +2689,122 @@
border-color: color-mix(in srgb, var(--mjr-status-success) 60%, var(--mjr-border));
color: var(--mjr-status-success);
background: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-status-success) 12%);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-status-success) 25%, transparent);
+}
+
+.mjr-mfv .mjr-icon-btn.mjr-live-active .pi {
+ opacity: 1;
+}
+
+.mjr-mfv .mjr-icon-btn.mjr-preview-active {
+ border-color: color-mix(in srgb, var(--mjr-accent) 60%, var(--mjr-border));
+ color: var(--mjr-accent);
+ background: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-accent) 12%);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 25%, transparent);
+}
+
+.mjr-mfv .mjr-icon-btn.mjr-preview-active .pi {
+ opacity: 1;
}
/* Pop-in mode (viewer popped out): raise button contrast/brightness */
+.mjr-mfv.mjr-mfv--popped {
+ background: #262626;
+ border-color: color-mix(in srgb, var(--mjr-border) 86%, rgba(255,255,255,0.14));
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.52);
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-header {
+ background: linear-gradient(
+ 180deg,
+ rgba(74, 74, 74, 0.98),
+ rgba(52, 52, 52, 0.98)
+ );
+ border-bottom-color: color-mix(in srgb, var(--mjr-border) 88%, rgba(255,255,255,0.12));
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-header-title {
+ opacity: 0.96;
+ color: #eef4ff;
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-toolbar {
+ background: linear-gradient(
+ 180deg,
+ rgba(48, 48, 48, 0.98),
+ rgba(28, 28, 28, 0.98)
+ );
+ border-bottom-color: color-mix(in srgb, var(--mjr-border) 88%, rgba(255,255,255,0.12));
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-toolbar-sep {
+ background: color-mix(in srgb, var(--mjr-border) 84%, rgba(255,255,255,0.16));
+}
+
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn {
- border-color: color-mix(in srgb, var(--mjr-border) 40%, #ffffff 60%);
- background: color-mix(in srgb, var(--mjr-surface-2) 65%, #ffffff 18%);
- color: color-mix(in srgb, var(--content-fg, #ddd) 75%, #ffffff 25%);
+ border-color: color-mix(in srgb, var(--mjr-border) 84%, rgba(255,255,255,0.16));
+ background: linear-gradient(
+ 180deg,
+ rgba(88, 88, 88, 0.94),
+ rgba(66, 66, 66, 0.98)
+ );
+ color: var(--content-fg, #ddd) !important;
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn:hover {
- border-color: color-mix(in srgb, var(--mjr-accent) 50%, #ffffff 50%);
- background: color-mix(in srgb, var(--mjr-surface-2) 55%, #ffffff 30%);
+ border-color: color-mix(in srgb, var(--mjr-accent) 52%, var(--mjr-border));
+ background: linear-gradient(
+ 180deg,
+ rgba(91, 120, 148, 0.96),
+ rgba(56, 82, 106, 0.98)
+ );
+ color: #f4f8ff !important;
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-icon-btn .pi {
+ opacity: 1;
+ color: inherit !important;
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-icon-btn.mjr-live-active {
+ border-color: color-mix(in srgb, var(--mjr-status-success) 60%, #ffffff 40%);
+ background: linear-gradient(180deg, rgba(76, 175, 80, 0.62), rgba(76, 175, 80, 0.34));
+ color: #ffffff !important;
+ box-shadow:
+ inset 0 0 0 1px rgba(255,255,255,0.12),
+ 0 0 0 1px color-mix(in srgb, var(--mjr-status-success) 24%, transparent),
+ 0 0 18px rgba(76, 175, 80, 0.18);
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-icon-btn.mjr-preview-active {
+ border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 42%);
+ background: linear-gradient(180deg, rgba(95, 179, 255, 0.62), rgba(95, 179, 255, 0.34));
+ color: #ffffff !important;
+ box-shadow:
+ inset 0 0 0 1px rgba(255,255,255,0.12),
+ 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 24%, transparent),
+ 0 0 18px rgba(95, 179, 255, 0.18);
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-pin-select,
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select {
+ border-color: color-mix(in srgb, var(--mjr-border) 84%, rgba(255,255,255,0.16));
+ background-color: rgba(74, 74, 74, 0.96);
+ color: var(--content-fg, #ddd);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select:hover,
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select:focus {
+ border-color: color-mix(in srgb, var(--mjr-accent) 52%, var(--mjr-border));
+ background-color: rgba(66, 96, 124, 0.98);
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select.is-pinned {
+ border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 42%);
+ background: linear-gradient(180deg, rgba(95,179,255,0.60), rgba(95,179,255,0.34));
+ color: #ffffff;
}
.mjr-mfv .mjr-icon-btn.mjr-popin-active {
@@ -2487,15 +2859,25 @@
position: absolute;
top: 0;
bottom: 0;
- width: 3px;
+ width: 0.5px;
cursor: col-resize;
z-index: 10;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.88);
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.4);
+ box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.4);
transition: background 100ms ease;
}
+.mjr-mfv-ab-divider::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 12px;
+ transform: translateX(-50%);
+}
+
.mjr-mfv-ab-divider:hover,
.mjr-mfv-ab-divider:active {
background: var(--mjr-accent, #5fb3ff);
@@ -2754,6 +3136,19 @@
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent, #5fb3ff) 25%, transparent);
}
+.mjr-mfv .mjr-icon-btn.is-open {
+ border-color: color-mix(in srgb, var(--mjr-accent, #5fb3ff) 62%, #ffffff 12%);
+ color: #eef7ff;
+ background: linear-gradient(180deg, rgba(95, 179, 255, 0.28), rgba(95, 179, 255, 0.14));
+ box-shadow:
+ inset 0 0 0 1px rgba(255,255,255,0.08),
+ 0 0 0 1px color-mix(in srgb, var(--mjr-accent, #5fb3ff) 26%, transparent);
+}
+
+.mjr-mfv.mjr-mfv--popped .mjr-icon-btn.is-open {
+ color: #ffffff !important;
+}
+
/* Gen info dropdown panel */
.mjr-mfv-gen-dropdown {
position: absolute;
diff --git a/js/utils/deleteGuard.js b/js/utils/deleteGuard.js
index 20fe4b43..0cce3eec 100644
--- a/js/utils/deleteGuard.js
+++ b/js/utils/deleteGuard.js
@@ -1,11 +1,13 @@
import { APP_CONFIG } from "../app/config.js";
import { comfyConfirm } from "../app/dialogs.js";
+import { t } from "../app/i18n.js";
export async function confirmDeletion(count, label) {
if (!APP_CONFIG.DELETE_CONFIRMATION) return true;
- const target = count > 1 ? `${count} files` : `"${label || "this file"}"`;
const message = count > 1
- ? `Delete ${count} selected files?`
- : `Delete ${target}?`;
- return comfyConfirm(message, "Majoor: Confirm delete");
+ ? t("dialog.deleteSelectedFiles", "Delete {count} selected files?", { count })
+ : t("dialog.deleteSingleFile", "Delete \"{label}\"?", {
+ label: String(label || t("label.thisFile", "this file")),
+ });
+ return comfyConfirm(message, t("dialog.confirmDeleteTitle", "Majoor: Confirm delete"));
}
diff --git a/list.json b/list.json
new file mode 100644
index 00000000..92d2980e
--- /dev/null
+++ b/list.json
@@ -0,0 +1 @@
+{"ok": true, "data": {"assets": [{"id": 6, "filename": "ComfyU_00003_.png", "subfolder": "", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\comfyu_00003_.png", "kind": "image", "width": 1280, "height": 720, "duration": null, "size": 930777, "mtime": 1772672877, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "The image shows a silhouette of a dinosaur wearing headphones. The dinosaur is standing on its hind legs and appears to be listening to music. The background is a beautiful orange and pink sunset, with the sun setting in the distance. The overall mood of the image is peaceful and serene.", "auto_tags": ["3d-render"], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 5, "filename": "ComfyU_00002_.png", "subfolder": "", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\comfyu_00002_.png", "kind": "image", "width": 832, "height": 1216, "duration": null, "size": 1419652, "mtime": 1772668041, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "The image is a digital illustration of a piece of cheese floating in the sky. The cheese is yellow in color and has a rectangular shape with rounded edges. It appears to be made of cheese and has small holes on its surface. The background is a deep purple color with small white dots scattered throughout. In the center of the image, there is a pink nebula, which is visible in the bottom right corner. The nebula is a bright pink color and is slightly blurred, giving the image a dreamy, ethereal quality.", "auto_tags": [], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 0, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 4, "filename": "ComfyU_00001_.png", "subfolder": "", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\comfyu_00001_.png", "kind": "image", "width": 1280, "height": 720, "duration": null, "size": 930777, "mtime": 1772662110, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "The image shows a silhouette of a dinosaur wearing headphones. The dinosaur is standing on its hind legs and appears to be listening to music. The background is a beautiful orange and pink sunset, with the sun setting in the distance. The overall mood of the image is peaceful and serene.", "auto_tags": ["3d-render"], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 3, "filename": "ComfyUI_00003_.png", "subfolder": "", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\comfyui_00003_.png", "kind": "image", "width": 1280, "height": 720, "duration": null, "size": 930780, "mtime": 1772661621, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "The image shows a silhouette of a dinosaur wearing headphones. The dinosaur is standing on its hind legs and appears to be listening to music. The background is a beautiful orange and pink sunset, with the sun setting in the distance. The overall mood of the image is peaceful and serene.", "auto_tags": ["3d-render"], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 2, "filename": "ComfyUI_00002_.png", "subfolder": "", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\comfyui_00002_.png", "kind": "image", "width": 512, "height": 512, "duration": null, "size": 319318, "mtime": 1772661222, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "The image is an underwater photograph of a soccer ball floating in the ocean. The ball is white with black stripes and has small holes in the center. It is surrounded by a group of small fish, including orange and blue fish, swimming around it. The background is dark blue, and there are rocks and coral visible in the bottom right corner of the image. The water is clear and the sun is shining brightly, creating a beautiful reflection on the surface of the water.", "auto_tags": ["nature", "3d-render"], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 1, "filename": "ComfyUI_00001_.png", "subfolder": "", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\comfyui_00001_.png", "kind": "image", "width": 864, "height": 1536, "duration": null, "size": 1299001, "mtime": 1772660636, "rating": 0, "tags": [], "tags_text": "image_2d101a55jpg_553e93b706.jpg", "enhanced_caption": "The image shows a man sitting at a table in a restaurant. He is wearing a dark suit and a brown fedora. He has a serious expression on his face and is looking off to the side. The table is covered with a white tablecloth and there is a wine glass on the table. In the background, there are buildings and lights that appear to be lit up. A hand is visible on the left side of the image, reaching out towards the man.", "auto_tags": ["portrait"], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 13, "filename": "ComfyUI_00002_test.png", "subfolder": "test", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\test\\comfyui_00002_test.png", "kind": "image", "width": 832, "height": 1216, "duration": null, "size": 1441882, "mtime": 1772386739, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "", "auto_tags": [], "has_ai_enhanced_caption": 0, "has_ai_auto_tags": 0, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 16, "filename": "Image_2d101a55jpg_eb711ec7a7.jpeg", "subfolder": "test", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\test\\image_2d101a55jpg_eb711ec7a7.jpeg", "kind": "image", "width": 864, "height": 1536, "duration": null, "size": 209749, "mtime": 1772132899, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "", "auto_tags": ["portrait", "food", "nsfw"], "has_ai_enhanced_caption": 0, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 0, "has_generation_data": 0, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 15, "filename": "Image_2d101a55jpg_553e93b706.jpeg", "subfolder": "test", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\test\\image_2d101a55jpg_553e93b706.jpeg", "kind": "image", "width": 864, "height": 1536, "duration": null, "size": 240103, "mtime": 1772132887, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "The image shows a man sitting at a table in a restaurant. He is wearing a dark suit and a brown fedora. He has a serious expression on his face and is looking off to the side. The table is covered with a white tablecloth and there is a wine glass on the table. In the background, there are buildings and lights that appear to be lit up. A hand is visible on the left side of the image, reaching out towards the man.", "auto_tags": ["portrait"], "has_ai_enhanced_caption": 1, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 0, "has_generation_data": 0, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}, {"id": 23, "filename": "260207_Test__00014_.png", "subfolder": "drive-download-20260208T181559Z-3-001", "filepath": "c:\\users\\ewald\\documents\\comfyui_vscode\\comfyui_windows_portable\\comfyui\\output\\drive-download-20260208t181559z-3-001\\260207_test__00014_.png", "kind": "image", "width": 1280, "height": 720, "duration": null, "size": 957948, "mtime": 1770574596, "rating": 0, "tags": [], "tags_text": "", "enhanced_caption": "", "auto_tags": ["3d-render"], "has_ai_enhanced_caption": 0, "has_ai_auto_tags": 1, "has_ai_vector": 1, "has_ai_info": 1, "has_workflow": 1, "has_generation_data": 1, "generation_time_ms": null, "file_creation_time": null, "file_birth_time": null, "type": "output"}], "limit": 10, "offset": 0, "query": "*", "total": 23, "sort": "mtime_desc", "scope": "output"}, "error": null, "code": "OK", "meta": {}}
\ No newline at end of file
diff --git a/mjr_am_backend/adapters/db/sqlite_facade.py b/mjr_am_backend/adapters/db/sqlite_facade.py
index 5405c5a2..d0d8402c 100644
--- a/mjr_am_backend/adapters/db/sqlite_facade.py
+++ b/mjr_am_backend/adapters/db/sqlite_facade.py
@@ -444,6 +444,16 @@ class Sqlite:
def __init__(self, db_path: str, max_connections: int | None = None, timeout: float = 30.0, *, attach: dict[str, str] | None = None):
self.db_path = Path(db_path)
self._attach_dbs: dict[str, str] = dict(attach) if attach else {}
+ if "vec" not in self._attach_dbs:
+ # Keep vector DB colocated with the primary DB by default so schema
+ # self-heal works even when callers don't pass attach={"vec": ...}.
+ default_vec = self.db_path.with_name("vectors.sqlite")
+ try:
+ if default_vec.resolve(strict=False) != self.db_path.resolve(strict=False):
+ self._attach_dbs["vec"] = str(default_vec)
+ except Exception:
+ if default_vec != self.db_path:
+ self._attach_dbs["vec"] = str(default_vec)
user_config = self._load_user_db_config()
max_conn = (
int(max_connections)
diff --git a/mjr_am_backend/config.py b/mjr_am_backend/config.py
index d0ebbba1..052887c6 100644
--- a/mjr_am_backend/config.py
+++ b/mjr_am_backend/config.py
@@ -479,16 +479,43 @@ VECTOR_INDEX_DIR = str(VECTOR_INDEX_DIR_PATH)
# Embedding dimensionality (SigLIP SO400M โ 1152, base โ 768).
VECTOR_EMBEDDING_DIM = _env_int(1152, "MJR_AM_VECTOR_DIM", min_value=64, max_value=4096)
+# Pre-warm the text-query model path and Faiss index during startup prewarm.
+VECTOR_PREWARM_ON_STARTUP = _env_bool(
+ True,
+ "MJR_AM_VECTOR_PREWARM_ON_STARTUP",
+ "MAJOOR_VECTOR_PREWARM_ON_STARTUP",
+)
+
# Auto-tagging: minimum cosine similarity to assign a tag.
VECTOR_AUTOTAG_THRESHOLD = _env_float(
- 0.25,
+ 0.06,
"MJR_AM_VECTOR_AUTOTAG_THRESHOLD",
min_value=0.0,
max_value=1.0,
)
+# Semantic text search: score floor and relative cutoff versus best hit.
+VECTOR_TEXT_SEARCH_MIN_SCORE = _env_float(
+ 0.02,
+ "MJR_AM_VECTOR_TEXT_SEARCH_MIN_SCORE",
+ min_value=0.0,
+ max_value=1.0,
+)
+VECTOR_TEXT_SEARCH_RELATIVE_RATIO = _env_float(
+ 0.70,
+ "MJR_AM_VECTOR_TEXT_SEARCH_RELATIVE_RATIO",
+ min_value=0.0,
+ max_value=1.0,
+)
+
# Maximum number of similar results returned by "Find Similar".
VECTOR_SIMILAR_TOPK = _env_int(20, "MJR_AM_VECTOR_SIMILAR_TOPK", min_value=1, max_value=200)
+VECTOR_SIMILAR_MIN_SCORE = _env_float(
+ 0.60,
+ "MJR_AM_VECTOR_SIMILAR_MIN_SCORE",
+ min_value=0.0,
+ max_value=1.0,
+)
# Number of threads used by Faiss for search (0 = automatic).
VECTOR_FAISS_NPROBE = _env_int(0, "MJR_AM_VECTOR_FAISS_NPROBE", min_value=0, max_value=128)
diff --git a/mjr_am_backend/features/index/index_prepare_ops.py b/mjr_am_backend/features/index/index_prepare_ops.py
index a15e743f..e3f14311 100644
--- a/mjr_am_backend/features/index/index_prepare_ops.py
+++ b/mjr_am_backend/features/index/index_prepare_ops.py
@@ -15,6 +15,18 @@ from .metadata_helpers import MetadataHelpers
from .scan_batch_utils import compute_state_hash, is_incremental_unchanged, normalize_filepath_str
+async def _asset_missing_vector(scanner: Any, *, asset_id: int) -> bool:
+ checker = getattr(scanner, "_asset_has_vector", None)
+ if not callable(checker):
+ return False
+ try:
+ return not bool(await checker(asset_id=asset_id))
+ except TypeError:
+ return not bool(await checker(asset_id))
+ except Exception:
+ return False
+
+
async def prepare_metadata_for_entry(
scanner: Any,
*,
@@ -142,6 +154,9 @@ async def maybe_skip_prepare_for_incremental(
cached_result = await refresh_entry_from_cached_metadata(scanner, prepare_ctx=prepare_ctx, file_path=file_path, fast=fast)
if cached_result is not None:
return cached_result
+ existing_id = int(prepare_ctx.get("existing_id") or 0)
+ if existing_id and await _asset_missing_vector(scanner, asset_id=existing_id):
+ return None
if await scanner._asset_has_rich_metadata(asset_id=prepare_ctx["existing_id"]):
return Result.Ok({"action": "skipped"})
return None
@@ -160,9 +175,13 @@ async def should_skip_by_journal_state(
return False
if str(prepare_ctx["journal_state_hash"]) != prepare_ctx["state_hash"]:
return False
- if fast:
- return True
existing_id = int(prepare_ctx.get("existing_id") or 0)
+ if fast:
+ if existing_id and await _asset_missing_vector(scanner, asset_id=existing_id):
+ return False
+ return True
+ if existing_id and await _asset_missing_vector(scanner, asset_id=existing_id):
+ return False
return bool(existing_id and await scanner._asset_has_rich_metadata(asset_id=existing_id))
diff --git a/mjr_am_backend/features/index/scanner.py b/mjr_am_backend/features/index/scanner.py
index d84681a1..b96494df 100644
--- a/mjr_am_backend/features/index/scanner.py
+++ b/mjr_am_backend/features/index/scanner.py
@@ -9,7 +9,7 @@ from typing import Any
from ...adapters.db.sqlite import Sqlite
from ...config import MAX_TO_ENRICH_ITEMS, is_vector_index_on_scan_enabled
-from ...shared import get_logger
+from ...shared import FileKind, get_logger
from ..metadata import MetadataService
from .fs_walker import SCAN_IOPS_LIMIT, FileSystemWalker
from .index_batching import append_batch_metadata_entries, existing_map_for_batch, journal_map_for_batch, prefetch_batch_cache_and_rich_meta, prefetch_metadata_cache_rows, prefetch_rich_metadata_rows, prepare_batch_entries, prepare_single_batch_entry, resolve_existing_state_for_batch
@@ -108,37 +108,93 @@ class IndexScanner:
added_ids=added_ids,
is_fatal_db_error=_is_fatal_db_error,
)
- self._schedule_added_image_vector_index(prev_added_count=prev_added_count, added_ids=added_ids)
+ self._schedule_prepared_vector_index(
+ prepared=prepared,
+ prev_added_count=prev_added_count,
+ added_ids=added_ids,
+ )
def set_vector_services(self, vector_service: Any, vector_searcher: Any | None = None) -> None:
self._vector_service = vector_service
self._vector_searcher = vector_searcher
+ async def _asset_has_vector(self, *, asset_id: int) -> bool:
+ try:
+ asset_id_int = int(asset_id)
+ except Exception:
+ return False
+ if asset_id_int <= 0:
+ return False
+ result = await self.db.aquery(
+ """
+ SELECT 1
+ FROM vec.asset_embeddings
+ WHERE asset_id = ?
+ AND vector IS NOT NULL
+ AND length(vector) > 0
+ LIMIT 1
+ """,
+ (asset_id_int,),
+ )
+ return bool(result.ok and result.data)
+
def _schedule_added_image_vector_index(self, *, prev_added_count: int, added_ids: list[int] | None) -> None:
+ self._schedule_prepared_vector_index(
+ prepared=None,
+ prev_added_count=prev_added_count,
+ added_ids=added_ids,
+ )
+
+ def _schedule_prepared_vector_index(
+ self,
+ *,
+ prepared: list[dict[str, Any]] | None,
+ prev_added_count: int,
+ added_ids: list[int] | None,
+ ) -> None:
if not is_vector_index_on_scan_enabled():
return
- if self._vector_service is None or not isinstance(added_ids, list) or len(added_ids) <= prev_added_count:
+ if self._vector_service is None:
return
- new_ids: list[int] = []
- for aid in added_ids[prev_added_count:]:
+ candidate_ids: list[int] = []
+ seen_ids: set[int] = set()
+
+ def _append_asset_id(raw_id: Any) -> None:
try:
- parsed_id = int(aid)
+ parsed_id = int(raw_id)
except Exception:
- continue
- if parsed_id > 0:
- new_ids.append(parsed_id)
- if not new_ids:
+ return
+ if parsed_id <= 0 or parsed_id in seen_ids:
+ return
+ seen_ids.add(parsed_id)
+ candidate_ids.append(parsed_id)
+
+ if isinstance(added_ids, list):
+ for aid in added_ids[prev_added_count:]:
+ _append_asset_id(aid)
+
+ if isinstance(prepared, list):
+ for entry in prepared:
+ action = str(entry.get("action") or "").strip().lower()
+ if action not in {"refresh", "updated"}:
+ continue
+ _append_asset_id(entry.get("asset_id"))
+
+ if not candidate_ids:
return
try:
- task = asyncio.create_task(self._index_added_image_vectors(asset_ids=new_ids))
+ task = asyncio.create_task(self._index_missing_asset_vectors(asset_ids=candidate_ids))
self._vector_index_tasks.add(task)
task.add_done_callback(self._vector_index_tasks.discard)
except Exception as exc:
logger.debug("Scanner vector indexing scheduling failed: %s", exc)
async def _index_added_image_vectors(self, *, asset_ids: list[int]) -> None:
+ await self._index_missing_asset_vectors(asset_ids=asset_ids)
+
+ async def _index_missing_asset_vectors(self, *, asset_ids: list[int]) -> None:
if self._vector_service is None or not isinstance(asset_ids, list) or not asset_ids:
return
@@ -148,8 +204,11 @@ class IndexScanner:
"""
SELECT a.id, a.filepath, a.kind, m.metadata_raw
FROM assets a
+ LEFT JOIN vec.asset_embeddings ae ON ae.asset_id = a.id
LEFT JOIN asset_metadata m ON a.id = m.asset_id
- WHERE a.kind IN ('image', 'video') AND {IN_CLAUSE}
+ WHERE a.kind IN ('image', 'video')
+ AND (ae.asset_id IS NULL OR ae.vector IS NULL OR length(ae.vector) = 0)
+ AND {IN_CLAUSE}
""",
"a.id",
asset_ids,
@@ -195,6 +254,8 @@ class IndexScanner:
aid = int(entry.get("asset_id") or 0)
try:
filepath = str(entry.get("filepath") or "")
+ entry_kind_raw = str(entry.get("kind") or "").strip().lower()
+ entry_kind: FileKind = "video" if entry_kind_raw == "video" else "image"
if not filepath:
skipped += 1
continue
@@ -204,7 +265,7 @@ class IndexScanner:
self._vector_service,
asset_id=aid,
filepath=filepath,
- kind=str(entry.get("kind") or "image"),
+ kind=entry_kind,
metadata_raw=entry.get("metadata_raw"),
),
timeout=_VECTOR_INDEX_PER_ASSET_TIMEOUT_S,
diff --git a/mjr_am_backend/features/index/vector_indexer.py b/mjr_am_backend/features/index/vector_indexer.py
index 924ddabe..66d8fef7 100644
--- a/mjr_am_backend/features/index/vector_indexer.py
+++ b/mjr_am_backend/features/index/vector_indexer.py
@@ -52,14 +52,35 @@ _A1111_PARAMS_RE = re.compile(
re.IGNORECASE,
)
+# Regex to split prompts into semantic segments (commas, periods, semicolons)
+_SEGMENT_SPLIT_RE = re.compile(r"[,;.]+")
+_MIN_SEGMENT_CHARS = 4 # ignore segments shorter than this
+
# โโ Score calibration constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-# Raw SigLIP2 cosine similarity for well-aligned image-text pairs typically
-# falls in the 0.15โ0.45 range. We remap this to a 0โ1 scale so that
-# the UI percentage is meaningful to users.
-_CALIB_LOW = 0.15 # raw score mapped to 0 (floor)
-_CALIB_HIGH = 0.45 # raw score mapped to 1 (ceiling)
+# SigLIP2 native features (get_image_features / get_text_features) produce
+# L2-normalised embeddings whose cosine similarity for well-aligned pairs
+# typically falls in the โ0.06 to +0.05 range (sigmoid loss โ lower raw
+# values than CLIP). We remap this band to a 0โ1 UI scale.
+_CALIB_LOW = 0.00 # raw score mapped to 0 (floor)
+_CALIB_HIGH = 0.06 # raw score mapped to 1 (ceiling)
_NEG_PENALTY_WEIGHT = 0.25 # how much negative-prompt match penalises the score
+# โโ Multi-signal fusion weights โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+_W_IMAGE_TEXT = 0.60 # weight for imageโtext multi-segment score
+_W_CAPTION_TEXT = 0.20 # weight for enhanced_captionโprompt text similarity
+_W_SEMANTIC = 0.20 # weight for semantic dimension decomposition
+_SEGMENT_MAX_BOOST = 0.10 # bonus factor for best-matching segment
+
+# โโ Semantic dimension categories โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Each dimension has a prefix used to construct a probing prompt.
+_SEMANTIC_DIMENSIONS: dict[str, str] = {
+ "subject": "a photo depicting",
+ "style": "an artwork in the style of",
+ "medium": "created using the medium of",
+ "mood": "an image with the mood of",
+ "color": "an image with the color palette of",
+}
+
# โโ Canonical auto-tag vocabulary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Each entry maps a human-readable tag to a text prompt.
# The prompt is intentionally verbose to improve multimodal classification accuracy.
@@ -165,20 +186,24 @@ async def index_asset_vector(
vector = emb_result.data
- # 2. Prompt-image alignment score (optional)
- aesthetic = await _compute_prompt_alignment(vs, vector, metadata_raw)
+ # 2. Enhanced caption (Florence-2) for image assets (best-effort).
+ # Generated BEFORE alignment so it can contribute to the score.
+ caption: str | None = None
+ if kind == "image":
+ caption = await _generate_and_store_caption(db, vs, asset_id, filepath)
- # 3. Persist embedding
+ # 3. Prompt-image alignment score (optional, uses caption if available)
+ aesthetic = await _compute_prompt_alignment(
+ vs, vector, metadata_raw, enhanced_caption=caption,
+ )
+
+ # 4. Persist embedding
blob = vector_to_blob(vector)
store_result = await _store_embedding(db, asset_id, blob, aesthetic, vs._model_name)
if not store_result.ok:
return store_result
- # 3b. Enhanced caption (Florence-2) for image assets (best-effort).
- if kind == "image":
- await _try_store_enhanced_caption(db, vs, asset_id, filepath)
-
- # 4. Auto-tagging
+ # 5. Auto-tagging
await _apply_autotags(db, vs, asset_id, vector)
return Result.Ok(True)
@@ -233,18 +258,37 @@ async def compute_prompt_alignment(
prompt: str,
*,
negative_prompt: str | None = None,
+ enhanced_caption: str | None = None,
) -> Result[float]:
"""Compute calibrated alignment between an image embedding and a prompt.
Returns a float in [0, 1]. Higher means the image closely matches the
- textual prompt. If *negative_prompt* is provided, its similarity to the
- image is subtracted (weighted) to penalise unwanted content leaking in.
- """
- text_result = await vs.get_text_embedding(prompt)
- if not text_result.ok or not text_result.data:
- return Result.Err("METADATA_FAILED", text_result.error or "Text embedding failed")
- raw_pos = VectorService.cosine_similarity(asset_embedding, text_result.data)
+ textual prompt.
+ Three signals are fused:
+ 1. **Multi-segment imageโtext** โ the prompt is split into segments;
+ each segment is scored against the image embedding, then combined
+ as a length-weighted average with a best-segment bonus.
+ 2. **Captionโprompt text** โ if an enhanced caption is available, its
+ text embedding is compared to the prompt embedding for a textโtext
+ coherence signal.
+ 3. **Semantic decomposition** โ key concepts (subject, style, medium,
+ mood, color) are extracted and each dimension is scored separately
+ against the image embedding.
+
+ If *negative_prompt* is provided, its similarity to the image is
+ subtracted (weighted) to penalise unwanted content leaking in.
+ """
+ # โโ 1. Multi-segment imageโtext score โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ raw_image_text = await _multi_segment_score(vs, asset_embedding, prompt)
+ if raw_image_text is None:
+ # Fallback: single-embedding score
+ text_result = await vs.get_text_embedding(prompt)
+ if not text_result.ok or not text_result.data:
+ return Result.Err("METADATA_FAILED", text_result.error or "Text embedding failed")
+ raw_image_text = VectorService.cosine_similarity(asset_embedding, text_result.data)
+
+ # โโ Negative prompt penalty โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
neg_penalty = 0.0
if negative_prompt:
neg_result = await vs.get_text_embedding(negative_prompt)
@@ -252,10 +296,181 @@ async def compute_prompt_alignment(
raw_neg = VectorService.cosine_similarity(asset_embedding, neg_result.data)
neg_penalty = max(0.0, raw_neg) * _NEG_PENALTY_WEIGHT
- calibrated = _calibrate_score(raw_pos - neg_penalty)
+ adjusted_image_text = raw_image_text - neg_penalty
+
+ # โโ 2. Captionโprompt text similarity โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ caption_score: float | None = None
+ if enhanced_caption and enhanced_caption.strip():
+ caption_score = await _caption_prompt_similarity(vs, enhanced_caption, prompt)
+
+ # โโ 3. Semantic decomposition โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ semantic_score = await _semantic_dimension_score(vs, asset_embedding, prompt)
+
+ # โโ Weighted fusion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ total_weight = _W_IMAGE_TEXT
+ weighted_sum = adjusted_image_text * _W_IMAGE_TEXT
+
+ if caption_score is not None:
+ weighted_sum += caption_score * _W_CAPTION_TEXT
+ total_weight += _W_CAPTION_TEXT
+
+ if semantic_score is not None:
+ weighted_sum += semantic_score * _W_SEMANTIC
+ total_weight += _W_SEMANTIC
+
+ fused_raw = weighted_sum / total_weight if total_weight > 0 else adjusted_image_text
+ calibrated = _calibrate_score(fused_raw)
return Result.Ok(round(calibrated, 4))
+async def _multi_segment_score(
+ vs: VectorService,
+ asset_embedding: list[float],
+ prompt: str,
+) -> float | None:
+ """Split prompt into segments and compute a weighted similarity score.
+
+ Each segment is embedded independently and scored against the image.
+ The final score is a length-weighted average with a bonus for the
+ best-matching segment, which captures the "main subject" better.
+ """
+ segments = [s.strip() for s in _SEGMENT_SPLIT_RE.split(prompt) if len(s.strip()) >= _MIN_SEGMENT_CHARS]
+ if len(segments) <= 1:
+ # Not worth splitting โ return None to use the full prompt
+ return None
+
+ scores: list[tuple[float, int]] = [] # (similarity, char_length)
+ for seg in segments:
+ emb = await vs.get_text_embedding(seg)
+ if emb.ok and emb.data:
+ sim = VectorService.cosine_similarity(asset_embedding, emb.data)
+ scores.append((sim, len(seg)))
+
+ if not scores:
+ return None
+
+ # Length-weighted average
+ total_len = sum(length for _, length in scores)
+ if total_len == 0:
+ return None
+ weighted_avg = sum(sim * length for sim, length in scores) / total_len
+
+ # Boost from best segment
+ best_sim = max(sim for sim, _ in scores)
+ return weighted_avg + (best_sim - weighted_avg) * _SEGMENT_MAX_BOOST
+
+
+async def _caption_prompt_similarity(
+ vs: VectorService,
+ caption: str,
+ prompt: str,
+) -> float | None:
+ """Textโtext cosine similarity between enhanced caption and prompt."""
+ cap_emb = await vs.get_text_embedding(caption)
+ if not cap_emb.ok or not cap_emb.data:
+ return None
+ prompt_emb = await vs.get_text_embedding(prompt)
+ if not prompt_emb.ok or not prompt_emb.data:
+ return None
+ return VectorService.cosine_similarity(cap_emb.data, prompt_emb.data)
+
+
+async def _semantic_dimension_score(
+ vs: VectorService,
+ asset_embedding: list[float],
+ prompt: str,
+) -> float | None:
+ """Score the image against semantic concept dimensions extracted from the prompt.
+
+ For each semantic dimension (subject, style, medium, mood, color),
+ we construct a probing prompt "" and
+ measure its similarity to the image embedding. The final score is
+ the average of the individual dimension scores (only counting
+ dimensions where a concept was actually extracted).
+ """
+ concepts = _extract_semantic_concepts(prompt)
+ if not concepts:
+ return None
+
+ dim_scores: list[float] = []
+ for dim, concept in concepts.items():
+ prefix = _SEMANTIC_DIMENSIONS.get(dim, "")
+ probe = f"{prefix} {concept}" if prefix else concept
+ emb = await vs.get_text_embedding(probe)
+ if emb.ok and emb.data:
+ sim = VectorService.cosine_similarity(asset_embedding, emb.data)
+ dim_scores.append(sim)
+
+ return sum(dim_scores) / len(dim_scores) if dim_scores else None
+
+
+def _extract_semantic_concepts(prompt: str) -> dict[str, str]:
+ """Heuristic extraction of semantic dimensions from a prompt.
+
+ Attempts to identify subject, style, medium, mood, and color palette
+ concepts by matching known keywords. Returns a dict of dimensionโconcept
+ for the dimensions that were detected.
+ """
+ lower = prompt.lower()
+ concepts: dict[str, str] = {}
+
+ # Subject: first segment (usually the main subject)
+ segments = [s.strip() for s in _SEGMENT_SPLIT_RE.split(prompt) if len(s.strip()) >= _MIN_SEGMENT_CHARS]
+ if segments:
+ concepts["subject"] = segments[0]
+
+ # Style keywords
+ style_kws = (
+ "anime", "manga", "photorealistic", "realistic", "cartoon", "oil painting",
+ "watercolor", "impressionist", "surrealist", "art nouveau", "art deco",
+ "pixel art", "comic", "3d render", "concept art", "digital art",
+ "cinematic", "studio ghibli", "ukiyo-e", "pop art", "minimalist",
+ "gothic", "baroque", "renaissance", "cubist", "futuristic",
+ )
+ for kw in style_kws:
+ if kw in lower:
+ concepts["style"] = kw
+ break
+
+ # Medium keywords (longer/compound keywords first to avoid substring
+ # matches like "painting" taking precedence over "watercolor painting").
+ medium_kws = (
+ "oil painting", "watercolor", "photograph", "photo", "illustration",
+ "painting", "drawing", "sketch", "render", "sculpture", "collage",
+ "engraving", "print", "pencil", "charcoal", "pastel", "acrylic",
+ "fresco", "mosaic", "tapestry",
+ )
+ for kw in medium_kws:
+ if kw in lower:
+ concepts["medium"] = kw
+ break
+
+ # Mood keywords
+ mood_kws = (
+ "dark", "bright", "moody", "cheerful", "mysterious", "serene",
+ "dramatic", "peaceful", "ethereal", "gloomy", "vibrant", "melancholic",
+ "nostalgic", "whimsical", "epic", "dreamy", "eerie", "cozy",
+ "romantic", "surreal", "ominous", "tranquil",
+ )
+ for kw in mood_kws:
+ if kw in lower:
+ concepts["mood"] = kw
+ break
+
+ # Color palette keywords
+ color_kws = (
+ "monochrome", "black and white", "pastel colors", "neon", "warm tones",
+ "cool tones", "golden hour", "blue hour", "sepia", "high contrast",
+ "muted colors", "saturated", "desaturated", "earth tones",
+ )
+ for kw in color_kws:
+ if kw in lower:
+ concepts["color"] = kw
+ break
+
+ return concepts
+
+
async def generate_enhanced_prompt(
db: Sqlite,
vs: VectorService,
@@ -314,6 +529,8 @@ async def _compute_prompt_alignment(
vs: VectorService,
vector: list[float],
metadata_raw: dict[str, Any] | None,
+ *,
+ enhanced_caption: str | None = None,
) -> float | None:
"""Extract the original prompt from metadata and compute alignment."""
if not metadata_raw:
@@ -324,7 +541,11 @@ async def _compute_prompt_alignment(
return None
negative = _extract_negative_prompt(metadata_raw)
- result = await compute_prompt_alignment(vs, vector, prompt, negative_prompt=negative)
+ result = await compute_prompt_alignment(
+ vs, vector, prompt,
+ negative_prompt=negative,
+ enhanced_caption=enhanced_caption,
+ )
return result.data if result.ok else None
@@ -522,6 +743,34 @@ async def _try_store_enhanced_caption(
logger.debug("Enhanced caption generation skipped for asset %d: %s", asset_id, exc)
+async def _generate_and_store_caption(
+ db: Sqlite,
+ vs: VectorService,
+ asset_id: int,
+ filepath: str,
+) -> str | None:
+ """Generate, store, and return the enhanced caption (or None on failure)."""
+ try:
+ generated = await vs.generate_enhanced_caption(filepath)
+ if not generated.ok or not generated.data:
+ return None
+ fallback_title = (
+ Path(filepath).stem.replace("_", " ").replace("-", " ").strip()
+ or "Untitled"
+ )
+ caption = _normalise_title_caption(
+ str(generated.data).strip(),
+ fallback_title=fallback_title,
+ )
+ if not caption:
+ return None
+ await _store_enhanced_caption(db, asset_id, caption)
+ return caption
+ except Exception as exc:
+ logger.debug("Enhanced caption generation skipped for asset %d: %s", asset_id, exc)
+ return None
+
+
async def _apply_autotags(
db: Sqlite,
vs: VectorService,
diff --git a/mjr_am_backend/features/index/vector_searcher.py b/mjr_am_backend/features/index/vector_searcher.py
index 638dd656..3199e0c2 100644
--- a/mjr_am_backend/features/index/vector_searcher.py
+++ b/mjr_am_backend/features/index/vector_searcher.py
@@ -53,6 +53,25 @@ class VectorSearcher:
"""Mark the in-memory index as stale (will be rebuilt on next query)."""
self._dirty = True
+ async def prewarm_index(self) -> Result[dict[str, Any]]:
+ """Build the in-memory Faiss index ahead of the first semantic query."""
+ try:
+ await self._ensure_index()
+ except Exception as exc:
+ return Result.Err("SERVICE_UNAVAILABLE", f"Vector index warmup failed: {exc}")
+
+ total = 0
+ try:
+ total = int(getattr(self._index, "ntotal", 0) or 0)
+ except Exception:
+ total = 0
+
+ return Result.Ok({
+ "loaded": bool(self._index is not None),
+ "total": total,
+ "dirty": bool(self._dirty),
+ })
+
async def _ensure_index(self) -> None:
"""Build (or rebuild) the Faiss index from the database."""
if not self._dirty and self._index is not None:
diff --git a/mjr_am_backend/features/index/vector_service.py b/mjr_am_backend/features/index/vector_service.py
index 27cfdf75..9412cd10 100644
--- a/mjr_am_backend/features/index/vector_service.py
+++ b/mjr_am_backend/features/index/vector_service.py
@@ -236,14 +236,69 @@ def _extract_frame_at(video_path: str, timestamp: float) -> PILImage.Image | Non
return None
+def _extract_keyframes_cv2(video_path: str, interval: float) -> list[PILImage.Image]:
+ """Fallback: extract key-frames using OpenCV when ffprobe/ffmpeg are unavailable."""
+ try:
+ import cv2
+ from PIL import Image as PILImage # noqa: F811
+ except ImportError:
+ return []
+
+ cap = cv2.VideoCapture(video_path)
+ if not cap.isOpened():
+ logger.debug("cv2.VideoCapture failed to open %s", video_path)
+ return []
+
+ try:
+ fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
+ duration = total_frames / fps if fps > 0 and total_frames > 0 else 0.0
+
+ if duration <= 0:
+ # Try a single frame
+ ret, frame_bgr = cap.read()
+ if ret and frame_bgr is not None:
+ frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
+ return [PILImage.fromarray(frame_rgb)]
+ return []
+
+ timestamps: list[float] = []
+ t = 0.0
+ while t < duration:
+ timestamps.append(t)
+ t += interval
+ if duration > interval and (duration / 2) not in timestamps:
+ timestamps.append(duration / 2)
+ timestamps.sort()
+
+ frames: list[PILImage.Image] = []
+ for ts in timestamps:
+ frame_no = int(ts * fps)
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
+ ret, frame_bgr = cap.read()
+ if ret and frame_bgr is not None:
+ frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
+ frames.append(PILImage.fromarray(frame_rgb))
+ return frames
+ finally:
+ cap.release()
+
+
def extract_keyframes(video_path: str, interval: float | None = None) -> list[PILImage.Image]:
- """Return a list of key-frame PIL Images sampled every *interval* seconds."""
+ """Return a list of key-frame PIL Images sampled every *interval* seconds.
+
+ Tries ffprobe/ffmpeg first, then falls back to OpenCV (cv2) if the
+ ffmpeg-based extraction yields no frames.
+ """
interval = interval or VECTOR_VIDEO_KEYFRAME_INTERVAL
duration = _extract_video_duration(video_path)
if duration is None or duration <= 0:
# Fallback: try a single frame at t=0
frame = _extract_frame_at(video_path, 0.0)
- return [frame] if frame else []
+ if frame:
+ return [frame]
+ # ffmpeg unavailable โ try OpenCV
+ return _extract_keyframes_cv2(video_path, interval)
timestamps = []
t = 0.0
@@ -260,6 +315,9 @@ def extract_keyframes(video_path: str, interval: float | None = None) -> list[PI
frame = _extract_frame_at(video_path, ts)
if frame is not None:
frames.append(frame)
+ # If ffmpeg extraction failed for every frame, fall back to OpenCV
+ if not frames:
+ frames = _extract_keyframes_cv2(video_path, interval)
return frames
@@ -299,6 +357,8 @@ class VectorService:
self._prompt_model: Any | None = None
self._prompt_processor: Any | None = None
self._prompt_lock = asyncio.Lock()
+ self._text_query_warmed = False
+ self._text_query_warm_lock = asyncio.Lock()
self._dim = VECTOR_EMBEDDING_DIM
self._last_error: str = ""
self._last_error_at: str | None = None
@@ -779,12 +839,40 @@ class VectorService:
self._last_error = ""
self._last_error_at = None
+ async def prewarm_text_queries(self, probe_text: str = "warmup") -> Result[dict[str, Any]]:
+ """Preload the text-query path so the first AI search is not cold."""
+ if self._text_query_warmed:
+ return Result.Ok(self.get_runtime_status())
+
+ async with self._text_query_warm_lock:
+ if self._text_query_warmed:
+ return Result.Ok(self.get_runtime_status())
+
+ result = await self.get_text_embedding(probe_text)
+ if not result.ok or not result.data:
+ return Result.Err(
+ result.code or "SERVICE_UNAVAILABLE",
+ result.error or "Vector text-query warmup failed",
+ )
+
+ self._text_query_warmed = True
+ return Result.Ok(self.get_runtime_status())
+
def get_runtime_status(self) -> dict[str, Any]:
+ siglip_loaded = bool(self._siglip_model is not None and self._siglip_processor is not None)
+ sentence_transformer_loaded = bool(self._model is not None)
+ prompt_model_loaded = bool(self._prompt_model is not None and self._prompt_processor is not None)
+ video_model_loaded = bool(self._video_model is not None and self._video_processor is not None)
return {
"model_name": self._model_name,
"video_model_name": self._video_model_name,
"prompt_model_name": self._prompt_model_name,
- "loaded": bool(self._model is not None),
+ "loaded": bool(sentence_transformer_loaded or siglip_loaded),
+ "sentence_transformer_loaded": sentence_transformer_loaded,
+ "siglip_loaded": siglip_loaded,
+ "video_model_loaded": video_model_loaded,
+ "prompt_model_loaded": prompt_model_loaded,
+ "text_query_warmed": bool(self._text_query_warmed),
"dim": int(self._dim),
"last_error": self._last_error or None,
"last_error_at": self._last_error_at,
@@ -1167,6 +1255,36 @@ class VectorService:
if not frames:
return Result.Err("UNSUPPORTED", "No frames could be extracted from video")
+ # Use the same native SigLIP2 path that works for image embeddings
+ # to encode each keyframe, then average.
+ if self._use_native_siglip():
+ try:
+ import numpy as np
+ import torch
+
+ processor, native_model = await self._ensure_siglip_components()
+
+ def _embed_frames_native() -> list[float]:
+ vecs = []
+ with torch.inference_mode():
+ for frame in frames:
+ inputs = processor(images=[frame], return_tensors="pt")
+ feats = native_model.get_image_features(**inputs)
+ arr = feats.detach().cpu().numpy()[0]
+ vecs.append(_normalise_vector(arr))
+ mean_vec = np.mean(vecs, axis=0)
+ vec = _normalise_vector(mean_vec)
+ return _coerce_vector_dim(vec, self._dim)
+
+ vec = await asyncio.to_thread(_embed_frames_native)
+ self._clear_error()
+ return Result.Ok(vec)
+ except Exception as exc:
+ self._record_error(f"Video embedding failed for {path.name}: {exc}")
+ logger.debug("Native SigLIP video embedding failed for %s: %s", path.name, exc)
+ return Result.Err("METADATA_FAILED", f"Video embedding failed: {exc}")
+
+ # Legacy SentenceTransformer fallback
try:
import numpy as np # noqa: F811
@@ -1201,17 +1319,19 @@ class VectorService:
path.name,
batch_exc,
)
+
+ def _encode_single_frame(frame_obj: Any) -> Any:
+ return _encode_quiet(
+ model,
+ cast(Any, [frame_obj]),
+ convert_to_numpy=True,
+ batch_size=1,
+ show_progress_bar=False,
+ )
+
vecs = []
for frame in frames:
- encoded = await asyncio.to_thread(
- lambda fr=frame: _encode_quiet(
- model,
- cast(Any, [fr]),
- convert_to_numpy=True,
- batch_size=1,
- show_progress_bar=False,
- )
- )
+ encoded = await asyncio.to_thread(_encode_single_frame, frame)
vecs.append(_coerce_first_vector(encoded))
mean_vec = np.mean(vecs, axis=0)
@@ -1538,73 +1658,38 @@ class VectorService:
if not rgb_frames:
raise RuntimeError("No RGB frames available for X-CLIP embedding")
- def _get_field(container: Any, field: str) -> Any:
- try:
- getter = getattr(container, "get", None)
- if callable(getter):
- return getter(field)
- except Exception:
- pass
- try:
- return container[field]
- except Exception:
- pass
- try:
- return getattr(container, field)
- except Exception:
- return None
-
- def _try_processor(**kwargs: Any) -> Any:
- try:
- return processor(**kwargs)
- except Exception:
- return None
+ # X-CLIP expects exactly 8 frames; pad by repeating if fewer.
+ target_nframes = 8
+ while len(rgb_frames) < target_nframes:
+ rgb_frames.append(rgb_frames[len(rgb_frames) % len(rgb_frames)])
with torch.inference_mode():
- inputs: Any | None = None
- pixel_values: Any | None = None
- # Some processor/model combos return non-mapping values for
- # `images=[...]` (seen in the field). Probe fallback signatures
- # and extract `pixel_values` defensively.
- processor_calls: list[dict[str, Any]] = [
- {"images": rgb_frames, "return_tensors": "pt", "padding": True},
- {"videos": [rgb_frames], "return_tensors": "pt", "padding": True},
- {"videos": rgb_frames, "return_tensors": "pt", "padding": True},
- {"images": rgb_frames[0], "return_tensors": "pt"},
- ]
- for kwargs in processor_calls:
- out = _try_processor(**kwargs)
- if out is None:
- continue
- inputs = out
- pixel_values = _get_field(out, "pixel_values")
- if pixel_values is None and hasattr(out, "shape") and hasattr(out, "dtype"):
- pixel_values = out
- if pixel_values is not None:
- break
+ # XCLIPProcessor uses images= (not videos=) to build
+ # pixel_values with shape [batch, num_frames, C, H, W].
+ inputs = processor(images=rgb_frames, return_tensors="pt")
+ pixel_values = inputs.get("pixel_values")
+ if pixel_values is None:
+ raise RuntimeError("X-CLIP processor returned no pixel_values")
- if hasattr(model, "get_image_features"):
- if pixel_values is None:
- raise RuntimeError("X-CLIP processor returned no pixel_values")
- feats = model.get_image_features(pixel_values=pixel_values)
+ if hasattr(model, "get_video_features"):
+ # get_video_features may return a tuple in some
+ # transformers versions; handle both cases.
+ out = model.get_video_features(pixel_values=pixel_values)
+ if isinstance(out, tuple):
+ feats = out[0]
+ else:
+ feats = out
else:
- model_inputs: dict[str, Any] = {}
- if isinstance(inputs, Mapping):
- try:
- model_inputs = dict(inputs)
- except Exception:
- model_inputs = {}
- if not model_inputs and pixel_values is not None:
- model_inputs = {"pixel_values": pixel_values}
- if not model_inputs:
- raise RuntimeError("X-CLIP processor returned no usable model inputs")
- out = model(**model_inputs)
- feats = getattr(out, "pooler_output", None)
+ out = model(pixel_values=pixel_values)
+ feats = getattr(out, "video_embeds", None)
+ if feats is None:
+ feats = getattr(out, "pooler_output", None)
if feats is None:
feats = getattr(out, "last_hidden_state", None)
if feats is None:
raise RuntimeError("X-CLIP output does not expose usable features")
feats = feats.mean(dim=1)
+
arr = feats.detach().cpu().numpy()
mean_vec = np.mean(arr, axis=0)
vec = _normalise_vector(mean_vec)
diff --git a/mjr_am_backend/routes/core/services.py b/mjr_am_backend/routes/core/services.py
index 62f2602d..f1c68b90 100644
--- a/mjr_am_backend/routes/core/services.py
+++ b/mjr_am_backend/routes/core/services.py
@@ -5,6 +5,7 @@ import asyncio
import threading
from typing import Any
+from mjr_am_backend.config import VECTOR_PREWARM_ON_STARTUP
from mjr_am_backend.deps import build_services
from mjr_am_backend.shared import Result, get_logger
@@ -100,6 +101,41 @@ def get_services_error():
return _services_error
+async def _run_vector_prewarm_step(label: str, runner: Any) -> None:
+ try:
+ result = await runner()
+ except Exception as exc:
+ logger.warning("%s failed during startup prewarm: %s", label, exc)
+ return
+
+ if not getattr(result, "ok", False):
+ logger.warning("%s failed during startup prewarm: %s", label, getattr(result, "error", None) or "unknown error")
+ return
+
+ logger.info("%s prewarmed", label)
+
+
+async def _prewarm_vector_components(services: dict[str, Any] | None) -> None:
+ if not VECTOR_PREWARM_ON_STARTUP or not isinstance(services, dict):
+ return
+
+ vector_service = services.get("vector_service")
+ vector_searcher = services.get("vector_searcher")
+ steps: list[asyncio.Future[Any] | asyncio.Task[Any] | Any] = []
+
+ prewarm_text = getattr(vector_service, "prewarm_text_queries", None)
+ if callable(prewarm_text):
+ steps.append(_run_vector_prewarm_step("Vector text-query model", prewarm_text))
+
+ prewarm_index = getattr(vector_searcher, "prewarm_index", None)
+ if callable(prewarm_index):
+ steps.append(_run_vector_prewarm_step("Vector search index", prewarm_index))
+
+ if steps:
+ await asyncio.gather(*steps)
+
+
async def prewarm_services() -> None:
"""Pre-warm services in the background so the first user request is fast."""
- await _build_services()
+ services = await _build_services()
+ await _prewarm_vector_components(services)
diff --git a/mjr_am_backend/routes/handlers/assets_impl.py b/mjr_am_backend/routes/handlers/assets_impl.py
index 26b01baf..c5b821b1 100644
--- a/mjr_am_backend/routes/handlers/assets_impl.py
+++ b/mjr_am_backend/routes/handlers/assets_impl.py
@@ -3,6 +3,7 @@ Asset management endpoints: ratings, tags, service retry.
"""
import asyncio
import errno
+import html as _html_mod
import json
import os
import re
@@ -58,6 +59,7 @@ logger = get_logger(__name__)
MAX_TAGS_PER_ASSET = 50
MAX_TAG_LENGTH = 100
MAX_RENAME_LENGTH = 255
+USER_GUIDE_FILE_NAME = "user_guide.html"
# P2-E-05/06 delegation to extracted modules.
from ..assets import filename_validator as _fv
@@ -76,6 +78,63 @@ _pg_build_download_response = _pg.build_download_response
_pg_safe_download_filename = _pg.safe_download_filename
+def _resolve_local_user_guide_path() -> Path:
+ try:
+ return (Path(__file__).resolve().parents[3] / USER_GUIDE_FILE_NAME).resolve(strict=False)
+ except Exception:
+ return Path(USER_GUIDE_FILE_NAME)
+
+
+def _resolve_docs_dir() -> Path:
+ """Return the absolute path to the docs/ folder in the project root."""
+ try:
+ return (Path(__file__).resolve().parents[3] / "docs").resolve(strict=False)
+ except Exception:
+ return Path("docs")
+
+
+# Allowed filename pattern for doc files (letters, digits, hyphens, underscores, dots).
+_DOC_FILENAME_RE = re.compile(r"^[A-Za-z0-9_\-]+\.md$")
+
+_DOCS_HTML_TEMPLATE = """\
+
+
+
+
+
+{title} - Majoor Assets Manager Docs
+
+
+
+