@@ -86,41 +84,21 @@ const styleVariables = computed(() => ({
opacity: 0.8;
}
-.moving-border__aura,
-.moving-border__beam,
-.moving-border__spark {
+.moving-border__beam {
stroke-dashoffset: 0;
animation: moving-border-travel var(--moving-border-duration) linear infinite;
- will-change: stroke-dashoffset, opacity;
+ will-change: stroke-dashoffset;
transition: opacity 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
-.moving-border__aura {
- stroke: var(--primary);
- stroke-width: 8;
- stroke-dasharray: 20 80;
- filter: blur(5px);
- opacity: 0.35;
-}
-
.moving-border__beam {
stroke: var(--primary);
- stroke-width: 3.5;
- stroke-dasharray: 20 80;
- opacity: 0.95;
-}
-
-.moving-border__spark {
- stroke: var(--primary-text);
- stroke-width: 2.8;
- stroke-dasharray: 10 90;
- animation-delay: calc(var(--moving-border-duration) * -0.02);
- opacity: 0.95;
+ stroke-width: 2.5;
+ stroke-dasharray: 16 84;
+ opacity: 0.82;
}
-.moving-border:not(.moving-border--active) .moving-border__aura,
-.moving-border:not(.moving-border--active) .moving-border__beam,
-.moving-border:not(.moving-border--active) .moving-border__spark {
+.moving-border:not(.moving-border--active) .moving-border__beam {
opacity: 0;
pointer-events: none;
}
@@ -130,9 +108,7 @@ const styleVariables = computed(() => ({
}
@media (prefers-reduced-motion: reduce) {
- .moving-border__aura,
- .moving-border__beam,
- .moving-border__spark {
+ .moving-border__beam {
animation: none;
}
}
diff --git a/src/config/spotlight-search.ts b/src/config/spotlight-search.ts
new file mode 100644
index 00000000..7c186c46
--- /dev/null
+++ b/src/config/spotlight-search.ts
@@ -0,0 +1,27 @@
+const DEFAULT_LEXICAL_DEBOUNCE_MS = 25
+const DEFAULT_SEMANTIC_DEBOUNCE_MS = 80
+const MAX_CONFIGURED_DEBOUNCE_MS = 1_000
+
+function configuredDelay(rawValue: unknown, fallbackMs: number): number {
+ if (typeof rawValue !== 'string' || rawValue.trim() === '') return fallbackMs
+ const parsed = Number(rawValue)
+ if (!Number.isFinite(parsed)) return fallbackMs
+ return Math.min(MAX_CONFIGURED_DEBOUNCE_MS, Math.max(0, Math.round(parsed)))
+}
+
+const lexicalDebounceMs = configuredDelay(
+ import.meta.env.VITE_SPOTLIGHT_LEXICAL_DEBOUNCE_MS,
+ DEFAULT_LEXICAL_DEBOUNCE_MS,
+)
+const semanticDebounceMs = Math.max(
+ lexicalDebounceMs,
+ configuredDelay(
+ import.meta.env.VITE_SPOTLIGHT_SEMANTIC_DEBOUNCE_MS,
+ DEFAULT_SEMANTIC_DEBOUNCE_MS,
+ ),
+)
+
+export const spotlightSearchTiming = Object.freeze({
+ lexicalDebounceMs,
+ semanticDebounceMs,
+})
diff --git a/src/i18n/ar/search.ts b/src/i18n/ar/search.ts
index 28bca538..37598601 100644
--- a/src/i18n/ar/search.ts
+++ b/src/i18n/ar/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'سيقوم Imagyx بفهرسته محليًا وتحضير البحث الدلالي تلقائيًا.',
'search.loading': 'جارٍ التحميل',
'search.analyzing_tags': 'تحليل الوسوم…',
+ 'search.visual.action': 'البحث باستخدام صورة',
+ 'search.visual.choose_file': 'اختر صورة مرجعية',
+ 'search.visual.image_files': 'الصور',
+ 'search.visual.drop_paste_choose': 'أسقط صورة أو الصقها أو اخترها',
+ 'search.visual.drop_here': 'أسقط الصورة لبدء البحث',
+ 'search.visual.preparing': 'جارٍ تجهيز البحث البصري…',
+ 'search.visual.mobileclip': 'يحلل MobileCLIP الصورة محليًا',
+ 'search.visual.error': 'تعذر استخدام هذه الصورة',
+ 'search.visual.error_desc': 'جرّب صورة مرجعية أخرى.',
+ 'search.visual.similar_to': 'صور مشابهة لـ {name}',
+ 'search.visual.pure_similarity': 'مرتبة حسب التشابه البصري فقط',
+ 'search.visual.back': 'العودة إلى البحث السابق',
+ 'search.visual.find_similar': 'العثور على صور متشابهة بصريًا',
+ 'search.visual.find_similar_desc': 'استخدام هذه الصورة كمرجع بصري',
} as const
diff --git a/src/i18n/ar/spotlight.ts b/src/i18n/ar/spotlight.ts
index 8513f3ca..0aeacc51 100644
--- a/src/i18n/ar/spotlight.ts
+++ b/src/i18n/ar/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'بحث سريع',
'spotlight.clear_search': 'مسح البحث',
'spotlight.open_settings': 'فتح الإعدادات',
+ 'spotlight.actions.toolbar': 'إجراءات الصورة',
+ 'spotlight.actions.more': 'مزيد من الإجراءات',
+
+ 'spotlight.convert.action': 'تحويل الصورة',
+ 'spotlight.convert.title': 'تحويل',
+ 'spotlight.convert.description': 'أنشئ نسخة جاهزة للاستخدام دون مغادرة Spotlight.',
+ 'spotlight.convert.back': 'العودة إلى النتائج',
+ 'spotlight.convert.source': 'المصدر',
+ 'spotlight.convert.target': 'النسخة المحوّلة',
+ 'spotlight.convert.magic': 'نقرة واحدة مع أولوية الجودة',
+ 'spotlight.convert.choose_format': 'اختر تنسيق الوجهة',
+ 'spotlight.convert.format_hint': 'لا إعدادات معقدة — يختار Imagyx قيماً مناسبة تلقائياً.',
+ 'spotlight.convert.format.avif': 'حديث ومضغوط',
+ 'spotlight.convert.format.webp': 'سريع للويب',
+ 'spotlight.convert.format.png': 'بلا فقدان + شفافية',
+ 'spotlight.convert.format.jpg': 'تنسيق صور عالمي',
+ 'spotlight.convert.format.ico': 'أيقونة سطح المكتب',
+ 'spotlight.convert.progress.decoding': 'فك ترميز الصورة المصدر',
+ 'spotlight.convert.progress.encoding': 'ترميز التنسيق الجديد',
+ 'spotlight.convert.progress.indexing': 'ربطها بمكتبتك',
+ 'spotlight.convert.progress.complete': 'اكتمل التحويل',
+ 'spotlight.convert.progress_note': 'تُنفذ المعالجة الثقيلة محلياً باستخدام Rust.',
+ 'spotlight.convert.complete': 'التحويل جاهز',
+ 'spotlight.convert.complete_desc': 'تمت فهرسة الصورة الجديدة بجانب مصدرها.',
+ 'spotlight.convert.inherited_ai': 'تم توريث فهرس الذكاء الاصطناعي من المصدر.',
+ 'spotlight.convert.no_ai': 'لم يكن لدى المصدر فهرس ذكاء اصطناعي لتوريثه.',
+ 'spotlight.convert.again': 'التحويل إلى تنسيق آخر',
+ 'spotlight.convert.error': 'فشل التحويل',
+ 'spotlight.convert.retry': 'إعادة المحاولة',
'spotlight.loading_library': 'جارٍ تحميل المكتبة…',
'spotlight.setup_required': 'الإعداد مطلوب',
diff --git a/src/i18n/de/search.ts b/src/i18n/de/search.ts
index a667fcb4..ba02fa62 100644
--- a/src/i18n/de/search.ts
+++ b/src/i18n/de/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx wird ihn lokal indizieren und die semantische Suche automatisch vorbereiten.',
'search.loading': 'Laden',
'search.analyzing_tags': 'Analysiere Tags…',
+ 'search.visual.action': 'Mit einem Bild suchen',
+ 'search.visual.choose_file': 'Referenzbild auswählen',
+ 'search.visual.image_files': 'Bilder',
+ 'search.visual.drop_paste_choose': 'Bild ablegen, einfügen oder auswählen',
+ 'search.visual.drop_here': 'Bild ablegen, um die Suche zu starten',
+ 'search.visual.preparing': 'Visuelle Suche wird vorbereitet…',
+ 'search.visual.mobileclip': 'MobileCLIP analysiert das Bild lokal',
+ 'search.visual.error': 'Dieses Bild konnte nicht verwendet werden',
+ 'search.visual.error_desc': 'Versuche ein anderes Referenzbild.',
+ 'search.visual.similar_to': 'Ähnliche Bilder wie {name}',
+ 'search.visual.pure_similarity': 'Nur nach visueller Ähnlichkeit sortiert',
+ 'search.visual.back': 'Zur vorherigen Suche zurück',
+ 'search.visual.find_similar': 'Visuell ähnliche Bilder finden',
+ 'search.visual.find_similar_desc': 'Dieses Bild als visuelle Referenz verwenden',
} as const
diff --git a/src/i18n/de/spotlight.ts b/src/i18n/de/spotlight.ts
index a14d310a..47f37e41 100644
--- a/src/i18n/de/spotlight.ts
+++ b/src/i18n/de/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Schnellsuche',
'spotlight.clear_search': 'Suche löschen',
'spotlight.open_settings': 'Einstellungen öffnen',
+ 'spotlight.actions.toolbar': 'Bildaktionen',
+ 'spotlight.actions.more': 'Weitere Aktionen',
+
+ 'spotlight.convert.action': 'Bild konvertieren',
+ 'spotlight.convert.title': 'Konvertieren',
+ 'spotlight.convert.description': 'Erstelle eine sofort nutzbare Kopie, ohne Spotlight zu verlassen.',
+ 'spotlight.convert.back': 'Zurück zu den Ergebnissen',
+ 'spotlight.convert.source': 'Quelle',
+ 'spotlight.convert.target': 'Konvertierte Kopie',
+ 'spotlight.convert.magic': 'Ein Klick, Qualität zuerst',
+ 'spotlight.convert.choose_format': 'Zielformat wählen',
+ 'spotlight.convert.format_hint': 'Keine Einstellungen nötig – Imagyx wählt sinnvolle Standardwerte.',
+ 'spotlight.convert.format.avif': 'Modern und kompakt',
+ 'spotlight.convert.format.webp': 'Schnell fürs Web',
+ 'spotlight.convert.format.png': 'Verlustfrei + Transparenz',
+ 'spotlight.convert.format.jpg': 'Universelles Fotoformat',
+ 'spotlight.convert.format.ico': 'Desktop-Symbol',
+ 'spotlight.convert.progress.decoding': 'Quelle wird dekodiert',
+ 'spotlight.convert.progress.encoding': 'Neues Format wird kodiert',
+ 'spotlight.convert.progress.indexing': 'Mit deiner Bibliothek verknüpfen',
+ 'spotlight.convert.progress.complete': 'Konvertierung abgeschlossen',
+ 'spotlight.convert.progress_note': 'Die rechenintensive Arbeit läuft lokal in Rust.',
+ 'spotlight.convert.complete': 'Konvertierung bereit',
+ 'spotlight.convert.complete_desc': 'Das neue Bild wird neben seiner Quelle indiziert.',
+ 'spotlight.convert.inherited_ai': 'KI-Index von der Quelle übernommen.',
+ 'spotlight.convert.no_ai': 'Die Quelle hatte keinen KI-Index zum Übernehmen.',
+ 'spotlight.convert.again': 'In ein anderes Format konvertieren',
+ 'spotlight.convert.error': 'Konvertierung fehlgeschlagen',
+ 'spotlight.convert.retry': 'Erneut versuchen',
'spotlight.loading_library': 'Bibliothek wird geladen…',
'spotlight.setup_required': 'Einrichtung erforderlich',
diff --git a/src/i18n/en/common.ts b/src/i18n/en/common.ts
index eb65840d..07569c29 100644
--- a/src/i18n/en/common.ts
+++ b/src/i18n/en/common.ts
@@ -1,57 +1,56 @@
export default {
- 'app.name': 'Imagyx',
- 'app.tagline': 'Local Intelligence',
-
- 'loading': 'Loading',
- 'settings': 'Settings',
-
- 'all_images': 'All Images',
- 'all_images_prefix': 'All images: ',
- 'library': 'Library',
- 'watched_folders': 'Watched Folders',
- 'no_watched_folders': 'No watched folders',
-
- 'add_folder': 'Add Folder',
- 'add_folder_title': 'Choose a folder to index',
- 'unfollow': 'Unfollow',
- 'reindex': 'Reindex',
- 'reindexing': 'Reindexing…',
- 'hide_error': 'Hide error',
- 'folder_actions': 'Folder Actions',
-
- 'copy': 'Copy',
- 'copied': 'Copied',
- 'copy_image': 'Copy image',
- 'copy_debug': 'Copy debug information',
- 'copy_additional_info': 'Copy additional information',
-
- 'open_file': 'Open file',
- 'open_in_imagyx': 'Open in Imagyx',
- 'titlebar.minimize': 'Minimize',
- 'titlebar.maximize': 'Maximize',
- 'titlebar.close': 'Close',
-
- 'shortcut.press_combo': 'Press the new combination',
- 'shortcut.hold_modifier': 'Hold a modifier key then press a key',
- 'shortcut.add_modifier': 'Add Ctrl, Alt, Shift or Command',
- 'shortcut.applying': 'Applying shortcut…',
-
- 'preview.information': 'Information',
- 'preview.resolution': 'Resolution',
- 'preview.size': 'Size',
- 'preview.location': 'Location',
- 'preview.detected_concepts': 'Detected Concepts',
- 'preview.local_ai': 'Local AI',
- 'preview.details_ai_tags': 'Details & AI Tags',
- 'preview.open_file_title': 'Open file',
- 'preview.open_file_title': 'Open file',
- 'open_in_file_manager': 'Open in {name}',
- 'open_in_file_manager_short': '{name}',
-
- 'toast.dismiss': 'Dismiss notification',
-
- 'select.placeholder': 'Select',
- 'select.aria_label': 'Select an option',
-
- 'progress.aria_label': 'Progress',
-} as const
+ "app.name": "Imagyx",
+ "app.tagline": "Local Intelligence",
+
+ loading: "Loading",
+ settings: "Settings",
+
+ all_images: "All Images",
+ all_images_prefix: "All images: ",
+ library: "Library",
+ watched_folders: "Watched Folders",
+ no_watched_folders: "No watched folders",
+
+ add_folder: "Add Folder",
+ add_folder_title: "Choose a folder to index",
+ unfollow: "Unfollow",
+ reindex: "Reindex",
+ reindexing: "Reindexing…",
+ hide_error: "Hide error",
+ folder_actions: "Folder Actions",
+
+ copy: "Copy",
+ copied: "Copied",
+ copy_image: "Copy image",
+ copy_debug: "Copy debug information",
+ copy_additional_info: "Copy additional information",
+
+ open_file: "Open file",
+ open_in_imagyx: "Open in Imagyx",
+ "titlebar.minimize": "Minimize",
+ "titlebar.maximize": "Maximize",
+ "titlebar.close": "Close",
+
+ "shortcut.press_combo": "Press the new combination",
+ "shortcut.hold_modifier": "Hold a modifier key then press a key",
+ "shortcut.add_modifier": "Add Ctrl, Alt, Shift or Command",
+ "shortcut.applying": "Applying shortcut…",
+
+ "preview.information": "Information",
+ "preview.resolution": "Resolution",
+ "preview.size": "Size",
+ "preview.location": "Location",
+ "preview.detected_concepts": "Detected Concepts",
+ "preview.local_ai": "Local AI",
+ "preview.details_ai_tags": "Details & AI Tags",
+ "preview.open_file_title": "Open file",
+ open_in_file_manager: "Open in {name}",
+ open_in_file_manager_short: "{name}",
+
+ "toast.dismiss": "Dismiss notification",
+
+ "select.placeholder": "Select",
+ "select.aria_label": "Select an option",
+
+ "progress.aria_label": "Progress",
+} as const;
diff --git a/src/i18n/en/search.ts b/src/i18n/en/search.ts
index b849c0f3..c20eb6bd 100644
--- a/src/i18n/en/search.ts
+++ b/src/i18n/en/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx will index it locally and prepare semantic search automatically.',
'search.loading': 'Loading',
'search.analyzing_tags': 'Analyzing tags…',
+ 'search.visual.action': 'Search with an image',
+ 'search.visual.choose_file': 'Choose a reference image',
+ 'search.visual.image_files': 'Images',
+ 'search.visual.drop_paste_choose': 'Drop, paste, or choose an image',
+ 'search.visual.drop_here': 'Drop the image to start searching',
+ 'search.visual.preparing': 'Preparing visual search…',
+ 'search.visual.mobileclip': 'MobileCLIP is analyzing the image locally',
+ 'search.visual.error': 'This image could not be used',
+ 'search.visual.error_desc': 'Try another reference image.',
+ 'search.visual.similar_to': 'Images similar to {name}',
+ 'search.visual.pure_similarity': 'Ranked only by visual similarity',
+ 'search.visual.back': 'Back to the previous search',
+ 'search.visual.find_similar': 'Find visually similar images',
+ 'search.visual.find_similar_desc': 'Use this image as the visual reference',
} as const
diff --git a/src/i18n/en/spotlight.ts b/src/i18n/en/spotlight.ts
index 38f02cbe..e9e453bd 100644
--- a/src/i18n/en/spotlight.ts
+++ b/src/i18n/en/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Quick search',
'spotlight.clear_search': 'Clear search',
'spotlight.open_settings': 'Open settings',
+ 'spotlight.actions.toolbar': 'Image actions',
+ 'spotlight.actions.more': 'More actions',
+
+ 'spotlight.convert.action': 'Convert image',
+ 'spotlight.convert.title': 'Convert',
+ 'spotlight.convert.description': 'Create a ready-to-use copy without leaving Spotlight.',
+ 'spotlight.convert.back': 'Back to results',
+ 'spotlight.convert.source': 'Source',
+ 'spotlight.convert.target': 'Converted copy',
+ 'spotlight.convert.magic': 'One click, quality-first',
+ 'spotlight.convert.choose_format': 'Choose a target format',
+ 'spotlight.convert.format_hint': 'No settings to tune — Imagyx picks sensible defaults.',
+ 'spotlight.convert.format.avif': 'Modern and compact',
+ 'spotlight.convert.format.webp': 'Fast for the web',
+ 'spotlight.convert.format.png': 'Lossless + transparency',
+ 'spotlight.convert.format.jpg': 'Universal photo format',
+ 'spotlight.convert.format.ico': 'Desktop icon',
+ 'spotlight.convert.progress.decoding': 'Decoding the source',
+ 'spotlight.convert.progress.encoding': 'Encoding the new format',
+ 'spotlight.convert.progress.indexing': 'Linking it to your library',
+ 'spotlight.convert.progress.complete': 'Conversion complete',
+ 'spotlight.convert.progress_note': 'The heavy lifting runs locally in Rust.',
+ 'spotlight.convert.complete': 'Conversion ready',
+ 'spotlight.convert.complete_desc': 'The new image is indexed beside its source.',
+ 'spotlight.convert.inherited_ai': 'AI index inherited from the source.',
+ 'spotlight.convert.no_ai': 'The source had no AI index to inherit.',
+ 'spotlight.convert.again': 'Convert another format',
+ 'spotlight.convert.error': 'Conversion failed',
+ 'spotlight.convert.retry': 'Retry',
'spotlight.loading_library': 'Loading library…',
'spotlight.setup_required': 'Setup required',
diff --git a/src/i18n/es/search.ts b/src/i18n/es/search.ts
index b0f62dd2..8eecf476 100644
--- a/src/i18n/es/search.ts
+++ b/src/i18n/es/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx la indexará localmente y preparará la búsqueda semántica automáticamente.',
'search.loading': 'Cargando',
'search.analyzing_tags': 'Analizando etiquetas…',
+ 'search.visual.action': 'Buscar con una imagen',
+ 'search.visual.choose_file': 'Elegir una imagen de referencia',
+ 'search.visual.image_files': 'Imágenes',
+ 'search.visual.drop_paste_choose': 'Arrastra, pega o elige una imagen',
+ 'search.visual.drop_here': 'Suelta la imagen para iniciar la búsqueda',
+ 'search.visual.preparing': 'Preparando la búsqueda visual…',
+ 'search.visual.mobileclip': 'MobileCLIP analiza la imagen localmente',
+ 'search.visual.error': 'No se pudo usar esta imagen',
+ 'search.visual.error_desc': 'Prueba con otra imagen de referencia.',
+ 'search.visual.similar_to': 'Imágenes similares a {name}',
+ 'search.visual.pure_similarity': 'Ordenadas solo por similitud visual',
+ 'search.visual.back': 'Volver a la búsqueda anterior',
+ 'search.visual.find_similar': 'Buscar imágenes visualmente similares',
+ 'search.visual.find_similar_desc': 'Usar esta imagen como referencia visual',
} as const
diff --git a/src/i18n/es/spotlight.ts b/src/i18n/es/spotlight.ts
index f0610e0b..5a90d52c 100644
--- a/src/i18n/es/spotlight.ts
+++ b/src/i18n/es/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Búsqueda rápida',
'spotlight.clear_search': 'Limpiar búsqueda',
'spotlight.open_settings': 'Abrir ajustes',
+ 'spotlight.actions.toolbar': 'Acciones de imagen',
+ 'spotlight.actions.more': 'Más acciones',
+
+ 'spotlight.convert.action': 'Convertir imagen',
+ 'spotlight.convert.title': 'Convertir',
+ 'spotlight.convert.description': 'Crea una copia lista para usar sin salir de Spotlight.',
+ 'spotlight.convert.back': 'Volver a los resultados',
+ 'spotlight.convert.source': 'Origen',
+ 'spotlight.convert.target': 'Copia convertida',
+ 'spotlight.convert.magic': 'Un clic, calidad primero',
+ 'spotlight.convert.choose_format': 'Elige un formato de destino',
+ 'spotlight.convert.format_hint': 'Sin ajustes que configurar: Imagyx elige valores sensatos.',
+ 'spotlight.convert.format.avif': 'Moderno y compacto',
+ 'spotlight.convert.format.webp': 'Rápido para la web',
+ 'spotlight.convert.format.png': 'Sin pérdida + transparencia',
+ 'spotlight.convert.format.jpg': 'Formato fotográfico universal',
+ 'spotlight.convert.format.ico': 'Icono de escritorio',
+ 'spotlight.convert.progress.decoding': 'Decodificando el original',
+ 'spotlight.convert.progress.encoding': 'Codificando el nuevo formato',
+ 'spotlight.convert.progress.indexing': 'Vinculándolo a tu biblioteca',
+ 'spotlight.convert.progress.complete': 'Conversión completa',
+ 'spotlight.convert.progress_note': 'El trabajo pesado se ejecuta localmente en Rust.',
+ 'spotlight.convert.complete': 'Conversión lista',
+ 'spotlight.convert.complete_desc': 'La nueva imagen se indexa junto a su origen.',
+ 'spotlight.convert.inherited_ai': 'Índice de IA heredado del original.',
+ 'spotlight.convert.no_ai': 'El original no tenía un índice de IA para heredar.',
+ 'spotlight.convert.again': 'Convertir a otro formato',
+ 'spotlight.convert.error': 'La conversión falló',
+ 'spotlight.convert.retry': 'Reintentar',
'spotlight.loading_library': 'Cargando biblioteca…',
'spotlight.setup_required': 'Configuración requerida',
diff --git a/src/i18n/fr/search.ts b/src/i18n/fr/search.ts
index d3cb91b1..b339d36c 100644
--- a/src/i18n/fr/search.ts
+++ b/src/i18n/fr/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx l’indexera localement et préparera la recherche sémantique automatiquement.',
'search.loading': 'Chargement',
'search.analyzing_tags': 'Analyse des tags…',
+ 'search.visual.action': 'Rechercher avec une image',
+ 'search.visual.choose_file': 'Choisir une image de référence',
+ 'search.visual.image_files': 'Images',
+ 'search.visual.drop_paste_choose': 'Dépose, colle ou choisis une image',
+ 'search.visual.drop_here': 'Dépose l’image pour lancer la recherche',
+ 'search.visual.preparing': 'Préparation de la recherche visuelle…',
+ 'search.visual.mobileclip': 'MobileCLIP analyse l’image localement',
+ 'search.visual.error': 'Impossible d’utiliser cette image',
+ 'search.visual.error_desc': 'Essaie une autre image de référence.',
+ 'search.visual.similar_to': 'Images similaires à {name}',
+ 'search.visual.pure_similarity': 'Classées uniquement par similarité visuelle',
+ 'search.visual.back': 'Retour à la recherche précédente',
+ 'search.visual.find_similar': 'Trouver des images similaires',
+ 'search.visual.find_similar_desc': 'Utiliser cette image comme référence visuelle',
} as const
diff --git a/src/i18n/fr/spotlight.ts b/src/i18n/fr/spotlight.ts
index 92cf180d..d383ba7d 100644
--- a/src/i18n/fr/spotlight.ts
+++ b/src/i18n/fr/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Recherche rapide',
'spotlight.clear_search': 'Effacer la recherche',
'spotlight.open_settings': 'Ouvrir les réglages',
+ 'spotlight.actions.toolbar': 'Actions de l’image',
+ 'spotlight.actions.more': 'Plus d’actions',
+
+ 'spotlight.convert.action': 'Convertir l’image',
+ 'spotlight.convert.title': 'Convertir',
+ 'spotlight.convert.description': 'Crée une copie prête à utiliser sans quitter Spotlight.',
+ 'spotlight.convert.back': 'Retour aux résultats',
+ 'spotlight.convert.source': 'Source',
+ 'spotlight.convert.target': 'Copie convertie',
+ 'spotlight.convert.magic': 'Un clic, qualité prioritaire',
+ 'spotlight.convert.choose_format': 'Choisis un format de destination',
+ 'spotlight.convert.format_hint': 'Aucun réglage à gérer — Imagyx choisit les bons paramètres.',
+ 'spotlight.convert.format.avif': 'Moderne et compact',
+ 'spotlight.convert.format.webp': 'Rapide pour le Web',
+ 'spotlight.convert.format.png': 'Sans perte + transparence',
+ 'spotlight.convert.format.jpg': 'Format photo universel',
+ 'spotlight.convert.format.ico': 'Icône de bureau',
+ 'spotlight.convert.progress.decoding': 'Décodage de la source',
+ 'spotlight.convert.progress.encoding': 'Encodage du nouveau format',
+ 'spotlight.convert.progress.indexing': 'Ajout à ta bibliothèque',
+ 'spotlight.convert.progress.complete': 'Conversion terminée',
+ 'spotlight.convert.progress_note': 'Le traitement lourd s’exécute localement en Rust.',
+ 'spotlight.convert.complete': 'Conversion prête',
+ 'spotlight.convert.complete_desc': 'La nouvelle image est indexée à côté de sa source.',
+ 'spotlight.convert.inherited_ai': 'Index IA hérité de l’image source.',
+ 'spotlight.convert.no_ai': 'L’image source n’avait pas d’index IA à hériter.',
+ 'spotlight.convert.again': 'Convertir vers un autre format',
+ 'spotlight.convert.error': 'Échec de la conversion',
+ 'spotlight.convert.retry': 'Réessayer',
'spotlight.loading_library': 'Chargement de la bibliothèque…',
'spotlight.setup_required': 'Configuration requise',
diff --git a/src/i18n/it/search.ts b/src/i18n/it/search.ts
index e7221f1c..1130718a 100644
--- a/src/i18n/it/search.ts
+++ b/src/i18n/it/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx la indicizzerà localmente e preparerà la ricerca semantica automaticamente.',
'search.loading': 'Caricamento',
'search.analyzing_tags': 'Analisi tag…',
+ 'search.visual.action': 'Cerca con un’immagine',
+ 'search.visual.choose_file': 'Scegli un’immagine di riferimento',
+ 'search.visual.image_files': 'Immagini',
+ 'search.visual.drop_paste_choose': 'Trascina, incolla o scegli un’immagine',
+ 'search.visual.drop_here': 'Rilascia l’immagine per avviare la ricerca',
+ 'search.visual.preparing': 'Preparazione della ricerca visiva…',
+ 'search.visual.mobileclip': 'MobileCLIP analizza l’immagine localmente',
+ 'search.visual.error': 'Impossibile usare questa immagine',
+ 'search.visual.error_desc': 'Prova un’altra immagine di riferimento.',
+ 'search.visual.similar_to': 'Immagini simili a {name}',
+ 'search.visual.pure_similarity': 'Ordinate solo per somiglianza visiva',
+ 'search.visual.back': 'Torna alla ricerca precedente',
+ 'search.visual.find_similar': 'Trova immagini visivamente simili',
+ 'search.visual.find_similar_desc': 'Usa questa immagine come riferimento visivo',
} as const
diff --git a/src/i18n/it/spotlight.ts b/src/i18n/it/spotlight.ts
index 5246125e..70c1cd20 100644
--- a/src/i18n/it/spotlight.ts
+++ b/src/i18n/it/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Ricerca rapida',
'spotlight.clear_search': 'Cancella ricerca',
'spotlight.open_settings': 'Apri impostazioni',
+ 'spotlight.actions.toolbar': 'Azioni immagine',
+ 'spotlight.actions.more': 'Altre azioni',
+
+ 'spotlight.convert.action': 'Converti immagine',
+ 'spotlight.convert.title': 'Converti',
+ 'spotlight.convert.description': 'Crea una copia pronta all’uso senza uscire da Spotlight.',
+ 'spotlight.convert.back': 'Torna ai risultati',
+ 'spotlight.convert.source': 'Origine',
+ 'spotlight.convert.target': 'Copia convertita',
+ 'spotlight.convert.magic': 'Un clic, qualità prima di tutto',
+ 'spotlight.convert.choose_format': 'Scegli un formato di destinazione',
+ 'spotlight.convert.format_hint': 'Nessuna impostazione da regolare: Imagyx sceglie valori sensati.',
+ 'spotlight.convert.format.avif': 'Moderno e compatto',
+ 'spotlight.convert.format.webp': 'Veloce per il web',
+ 'spotlight.convert.format.png': 'Senza perdita + trasparenza',
+ 'spotlight.convert.format.jpg': 'Formato foto universale',
+ 'spotlight.convert.format.ico': 'Icona desktop',
+ 'spotlight.convert.progress.decoding': 'Decodifica dell’origine',
+ 'spotlight.convert.progress.encoding': 'Codifica del nuovo formato',
+ 'spotlight.convert.progress.indexing': 'Collegamento alla libreria',
+ 'spotlight.convert.progress.complete': 'Conversione completata',
+ 'spotlight.convert.progress_note': 'Il lavoro pesante viene eseguito localmente in Rust.',
+ 'spotlight.convert.complete': 'Conversione pronta',
+ 'spotlight.convert.complete_desc': 'La nuova immagine è indicizzata accanto all’origine.',
+ 'spotlight.convert.inherited_ai': 'Indice IA ereditato dall’origine.',
+ 'spotlight.convert.no_ai': 'L’origine non aveva un indice IA da ereditare.',
+ 'spotlight.convert.again': 'Converti in un altro formato',
+ 'spotlight.convert.error': 'Conversione non riuscita',
+ 'spotlight.convert.retry': 'Riprova',
'spotlight.loading_library': 'Caricamento libreria…',
'spotlight.setup_required': 'Configurazione richiesta',
diff --git a/src/i18n/ja/search.ts b/src/i18n/ja/search.ts
index daf2fc13..b0454d82 100644
--- a/src/i18n/ja/search.ts
+++ b/src/i18n/ja/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyxがローカルにインデックスし、セマンティック検索を自動的に準備します。',
'search.loading': '読み込み中',
'search.analyzing_tags': 'タグを分析中…',
+ 'search.visual.action': '画像で検索',
+ 'search.visual.choose_file': '参照画像を選択',
+ 'search.visual.image_files': '画像',
+ 'search.visual.drop_paste_choose': '画像をドロップ、貼り付け、または選択',
+ 'search.visual.drop_here': '画像をドロップして検索を開始',
+ 'search.visual.preparing': 'ビジュアル検索を準備中…',
+ 'search.visual.mobileclip': 'MobileCLIPが画像をローカルで解析しています',
+ 'search.visual.error': 'この画像を使用できませんでした',
+ 'search.visual.error_desc': '別の参照画像をお試しください。',
+ 'search.visual.similar_to': '{name} に似た画像',
+ 'search.visual.pure_similarity': '視覚的な類似度のみで並べ替え',
+ 'search.visual.back': '前の検索に戻る',
+ 'search.visual.find_similar': '視覚的に似た画像を探す',
+ 'search.visual.find_similar_desc': 'この画像を視覚的な参照として使用',
} as const
diff --git a/src/i18n/ja/spotlight.ts b/src/i18n/ja/spotlight.ts
index 90ec2023..f2304067 100644
--- a/src/i18n/ja/spotlight.ts
+++ b/src/i18n/ja/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'クイック検索',
'spotlight.clear_search': '検索をクリア',
'spotlight.open_settings': '設定を開く',
+ 'spotlight.actions.toolbar': '画像の操作',
+ 'spotlight.actions.more': 'その他の操作',
+
+ 'spotlight.convert.action': '画像を変換',
+ 'spotlight.convert.title': '変換',
+ 'spotlight.convert.description': 'Spotlightを離れずに、すぐ使えるコピーを作成します。',
+ 'spotlight.convert.back': '結果に戻る',
+ 'spotlight.convert.source': '元画像',
+ 'spotlight.convert.target': '変換後のコピー',
+ 'spotlight.convert.magic': 'ワンクリックで高品質',
+ 'spotlight.convert.choose_format': '変換先の形式を選択',
+ 'spotlight.convert.format_hint': '細かな設定は不要です。Imagyxが適切な値を選びます。',
+ 'spotlight.convert.format.avif': '新しくコンパクト',
+ 'spotlight.convert.format.webp': 'Web向けで高速',
+ 'spotlight.convert.format.png': '可逆圧縮 + 透過',
+ 'spotlight.convert.format.jpg': '汎用的な写真形式',
+ 'spotlight.convert.format.ico': 'デスクトップアイコン',
+ 'spotlight.convert.progress.decoding': '元画像をデコード中',
+ 'spotlight.convert.progress.encoding': '新しい形式にエンコード中',
+ 'spotlight.convert.progress.indexing': 'ライブラリに関連付け中',
+ 'spotlight.convert.progress.complete': '変換完了',
+ 'spotlight.convert.progress_note': '重い処理はRustでローカル実行されます。',
+ 'spotlight.convert.complete': '変換の準備ができました',
+ 'spotlight.convert.complete_desc': '新しい画像は元画像の隣にインデックスされます。',
+ 'spotlight.convert.inherited_ai': '元画像のAIインデックスを引き継ぎました。',
+ 'spotlight.convert.no_ai': '元画像には引き継げるAIインデックスがありませんでした。',
+ 'spotlight.convert.again': '別の形式に変換',
+ 'spotlight.convert.error': '変換に失敗しました',
+ 'spotlight.convert.retry': '再試行',
'spotlight.loading_library': 'ライブラリを読み込み中…',
'spotlight.setup_required': 'セットアップが必要です',
diff --git a/src/i18n/pt/search.ts b/src/i18n/pt/search.ts
index a5670f28..6f9e3f53 100644
--- a/src/i18n/pt/search.ts
+++ b/src/i18n/pt/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'O Imagyx irá indexá-la localmente e preparar a pesquisa semântica automaticamente.',
'search.loading': 'Carregando',
'search.analyzing_tags': 'Analisando tags…',
+ 'search.visual.action': 'Pesquisar com uma imagem',
+ 'search.visual.choose_file': 'Escolher uma imagem de referência',
+ 'search.visual.image_files': 'Imagens',
+ 'search.visual.drop_paste_choose': 'Arraste, cole ou escolha uma imagem',
+ 'search.visual.drop_here': 'Solte a imagem para iniciar a pesquisa',
+ 'search.visual.preparing': 'Preparando a pesquisa visual…',
+ 'search.visual.mobileclip': 'O MobileCLIP analisa a imagem localmente',
+ 'search.visual.error': 'Não foi possível usar esta imagem',
+ 'search.visual.error_desc': 'Tente outra imagem de referência.',
+ 'search.visual.similar_to': 'Imagens semelhantes a {name}',
+ 'search.visual.pure_similarity': 'Ordenadas apenas por semelhança visual',
+ 'search.visual.back': 'Voltar à pesquisa anterior',
+ 'search.visual.find_similar': 'Encontrar imagens visualmente semelhantes',
+ 'search.visual.find_similar_desc': 'Usar esta imagem como referência visual',
} as const
diff --git a/src/i18n/pt/spotlight.ts b/src/i18n/pt/spotlight.ts
index a37d66ba..96c8e9f9 100644
--- a/src/i18n/pt/spotlight.ts
+++ b/src/i18n/pt/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Pesquisa rápida',
'spotlight.clear_search': 'Limpar pesquisa',
'spotlight.open_settings': 'Abrir configurações',
+ 'spotlight.actions.toolbar': 'Ações da imagem',
+ 'spotlight.actions.more': 'Mais ações',
+
+ 'spotlight.convert.action': 'Converter imagem',
+ 'spotlight.convert.title': 'Converter',
+ 'spotlight.convert.description': 'Crie uma cópia pronta para usar sem sair do Spotlight.',
+ 'spotlight.convert.back': 'Voltar aos resultados',
+ 'spotlight.convert.source': 'Origem',
+ 'spotlight.convert.target': 'Cópia convertida',
+ 'spotlight.convert.magic': 'Um clique, qualidade em primeiro lugar',
+ 'spotlight.convert.choose_format': 'Escolha um formato de destino',
+ 'spotlight.convert.format_hint': 'Sem ajustes para configurar — o Imagyx escolhe padrões adequados.',
+ 'spotlight.convert.format.avif': 'Moderno e compacto',
+ 'spotlight.convert.format.webp': 'Rápido para a web',
+ 'spotlight.convert.format.png': 'Sem perdas + transparência',
+ 'spotlight.convert.format.jpg': 'Formato de foto universal',
+ 'spotlight.convert.format.ico': 'Ícone da área de trabalho',
+ 'spotlight.convert.progress.decoding': 'Decodificando a origem',
+ 'spotlight.convert.progress.encoding': 'Codificando o novo formato',
+ 'spotlight.convert.progress.indexing': 'Vinculando à sua biblioteca',
+ 'spotlight.convert.progress.complete': 'Conversão concluída',
+ 'spotlight.convert.progress_note': 'O processamento pesado é executado localmente em Rust.',
+ 'spotlight.convert.complete': 'Conversão pronta',
+ 'spotlight.convert.complete_desc': 'A nova imagem é indexada ao lado da origem.',
+ 'spotlight.convert.inherited_ai': 'Índice de IA herdado da origem.',
+ 'spotlight.convert.no_ai': 'A origem não tinha índice de IA para herdar.',
+ 'spotlight.convert.again': 'Converter para outro formato',
+ 'spotlight.convert.error': 'Falha na conversão',
+ 'spotlight.convert.retry': 'Tentar novamente',
'spotlight.loading_library': 'Carregando biblioteca…',
'spotlight.setup_required': 'Configuração necessária',
diff --git a/src/i18n/ru/search.ts b/src/i18n/ru/search.ts
index 0f8dcae1..6561f0c7 100644
--- a/src/i18n/ru/search.ts
+++ b/src/i18n/ru/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx проиндексирует её локально и автоматически подготовит семантический поиск.',
'search.loading': 'Загрузка',
'search.analyzing_tags': 'Анализ тегов…',
+ 'search.visual.action': 'Искать по изображению',
+ 'search.visual.choose_file': 'Выбрать эталонное изображение',
+ 'search.visual.image_files': 'Изображения',
+ 'search.visual.drop_paste_choose': 'Перетащите, вставьте или выберите изображение',
+ 'search.visual.drop_here': 'Перетащите изображение, чтобы начать поиск',
+ 'search.visual.preparing': 'Подготовка визуального поиска…',
+ 'search.visual.mobileclip': 'MobileCLIP локально анализирует изображение',
+ 'search.visual.error': 'Не удалось использовать это изображение',
+ 'search.visual.error_desc': 'Попробуйте другое эталонное изображение.',
+ 'search.visual.similar_to': 'Изображения, похожие на {name}',
+ 'search.visual.pure_similarity': 'Сортировка только по визуальному сходству',
+ 'search.visual.back': 'Вернуться к предыдущему поиску',
+ 'search.visual.find_similar': 'Найти визуально похожие изображения',
+ 'search.visual.find_similar_desc': 'Использовать это изображение как визуальный эталон',
} as const
diff --git a/src/i18n/ru/spotlight.ts b/src/i18n/ru/spotlight.ts
index c805e001..aa764e77 100644
--- a/src/i18n/ru/spotlight.ts
+++ b/src/i18n/ru/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': 'Быстрый поиск',
'spotlight.clear_search': 'Очистить поиск',
'spotlight.open_settings': 'Открыть настройки',
+ 'spotlight.actions.toolbar': 'Действия с изображением',
+ 'spotlight.actions.more': 'Другие действия',
+
+ 'spotlight.convert.action': 'Конвертировать изображение',
+ 'spotlight.convert.title': 'Конвертация',
+ 'spotlight.convert.description': 'Создайте готовую копию, не выходя из Spotlight.',
+ 'spotlight.convert.back': 'Назад к результатам',
+ 'spotlight.convert.source': 'Источник',
+ 'spotlight.convert.target': 'Конвертированная копия',
+ 'spotlight.convert.magic': 'Один клик, качество прежде всего',
+ 'spotlight.convert.choose_format': 'Выберите целевой формат',
+ 'spotlight.convert.format_hint': 'Никаких настроек — Imagyx выберет разумные параметры.',
+ 'spotlight.convert.format.avif': 'Современный и компактный',
+ 'spotlight.convert.format.webp': 'Быстрый для веба',
+ 'spotlight.convert.format.png': 'Без потерь + прозрачность',
+ 'spotlight.convert.format.jpg': 'Универсальный формат фото',
+ 'spotlight.convert.format.ico': 'Значок рабочего стола',
+ 'spotlight.convert.progress.decoding': 'Декодирование источника',
+ 'spotlight.convert.progress.encoding': 'Кодирование нового формата',
+ 'spotlight.convert.progress.indexing': 'Связывание с библиотекой',
+ 'spotlight.convert.progress.complete': 'Конвертация завершена',
+ 'spotlight.convert.progress_note': 'Тяжёлая обработка выполняется локально на Rust.',
+ 'spotlight.convert.complete': 'Конвертация готова',
+ 'spotlight.convert.complete_desc': 'Новое изображение проиндексировано рядом с источником.',
+ 'spotlight.convert.inherited_ai': 'ИИ-индекс унаследован от источника.',
+ 'spotlight.convert.no_ai': 'У источника не было ИИ-индекса для наследования.',
+ 'spotlight.convert.again': 'Конвертировать в другой формат',
+ 'spotlight.convert.error': 'Не удалось конвертировать',
+ 'spotlight.convert.retry': 'Повторить',
'spotlight.loading_library': 'Загрузка библиотеки…',
'spotlight.setup_required': 'Требуется настройка',
diff --git a/src/i18n/zh/search.ts b/src/i18n/zh/search.ts
index c1de34d1..f1b549f4 100644
--- a/src/i18n/zh/search.ts
+++ b/src/i18n/zh/search.ts
@@ -10,4 +10,18 @@ export default {
'search.no_folder_desc': 'Imagyx 会在本地索引并自动准备语义搜索。',
'search.loading': '加载中',
'search.analyzing_tags': '正在分析标签…',
+ 'search.visual.action': '以图搜图',
+ 'search.visual.choose_file': '选择参考图片',
+ 'search.visual.image_files': '图片',
+ 'search.visual.drop_paste_choose': '拖放、粘贴或选择一张图片',
+ 'search.visual.drop_here': '放下图片以开始搜索',
+ 'search.visual.preparing': '正在准备视觉搜索…',
+ 'search.visual.mobileclip': 'MobileCLIP 正在本地分析图片',
+ 'search.visual.error': '无法使用此图片',
+ 'search.visual.error_desc': '请尝试另一张参考图片。',
+ 'search.visual.similar_to': '与 {name} 相似的图片',
+ 'search.visual.pure_similarity': '仅按视觉相似度排序',
+ 'search.visual.back': '返回上一次搜索',
+ 'search.visual.find_similar': '查找视觉相似图片',
+ 'search.visual.find_similar_desc': '使用此图片作为视觉参考',
} as const
diff --git a/src/i18n/zh/spotlight.ts b/src/i18n/zh/spotlight.ts
index 9e5746b0..1eec3304 100644
--- a/src/i18n/zh/spotlight.ts
+++ b/src/i18n/zh/spotlight.ts
@@ -10,6 +10,35 @@ export default {
'spotlight.quick_search': '快速搜索',
'spotlight.clear_search': '清除搜索',
'spotlight.open_settings': '打开设置',
+ 'spotlight.actions.toolbar': '图片操作',
+ 'spotlight.actions.more': '更多操作',
+
+ 'spotlight.convert.action': '转换图片',
+ 'spotlight.convert.title': '转换',
+ 'spotlight.convert.description': '无需离开 Spotlight,即可创建可直接使用的副本。',
+ 'spotlight.convert.back': '返回结果',
+ 'spotlight.convert.source': '源图片',
+ 'spotlight.convert.target': '转换后的副本',
+ 'spotlight.convert.magic': '一键转换,质量优先',
+ 'spotlight.convert.choose_format': '选择目标格式',
+ 'spotlight.convert.format_hint': '无需调整参数——Imagyx 会选择合理的默认值。',
+ 'spotlight.convert.format.avif': '现代且紧凑',
+ 'spotlight.convert.format.webp': '适合网页,速度快',
+ 'spotlight.convert.format.png': '无损 + 透明',
+ 'spotlight.convert.format.jpg': '通用照片格式',
+ 'spotlight.convert.format.ico': '桌面图标',
+ 'spotlight.convert.progress.decoding': '正在解码源图片',
+ 'spotlight.convert.progress.encoding': '正在编码新格式',
+ 'spotlight.convert.progress.indexing': '正在关联到图库',
+ 'spotlight.convert.progress.complete': '转换完成',
+ 'spotlight.convert.progress_note': '繁重处理由 Rust 在本地完成。',
+ 'spotlight.convert.complete': '转换已就绪',
+ 'spotlight.convert.complete_desc': '新图片已在源图片旁建立索引。',
+ 'spotlight.convert.inherited_ai': '已继承源图片的 AI 索引。',
+ 'spotlight.convert.no_ai': '源图片没有可继承的 AI 索引。',
+ 'spotlight.convert.again': '转换为其他格式',
+ 'spotlight.convert.error': '转换失败',
+ 'spotlight.convert.retry': '重试',
'spotlight.loading_library': '正在加载图库…',
'spotlight.setup_required': '需要设置',
diff --git a/src/main.ts b/src/main.ts
index 75f28af2..8b688f60 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,8 +1,12 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
+import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import App from "./App.vue";
import SpotlightSearch from "./components/Spotlight/SpotlightSearch.vue";
+import { installSpotlightConverterKeyboardGuard } from "./services/spotlight-converter-keyboard";
+import { installUnifiedSearchEngine } from "./services/unified-search-engine";
+import { visualSearchSession } from "./services/visual-search-session";
import { installPerformanceDiagnostics } from "./utils";
import { initI18n } from "./i18n";
import "./style.css";
@@ -40,8 +44,24 @@ document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.window = currentWindowLabel;
document.documentElement.style.colorScheme = resolvedTheme;
+installUnifiedSearchEngine();
+if (currentWindowLabel === "spotlight") {
+ installSpotlightConverterKeyboardGuard();
+ void listen("spotlight-opened", () => {
+ // Spotlight intentionally resets its local input while opening. Re-emit the
+ // retained visual session after that reset so the query and its results are
+ // restored together instead of showing an orphaned reference thumbnail.
+ window.setTimeout(() => {
+ const state = visualSearchSession.state;
+ if (state.status !== "ready" || !state.token) return;
+ window.dispatchEvent(new CustomEvent(visualSearchSession.eventName, {
+ detail: { status: state.status, token: state.token },
+ }));
+ }, 0);
+ });
+}
+
const i18n = initI18n();
const RootComponent =
currentWindowLabel === "spotlight" ? SpotlightSearch : App;
createApp(RootComponent).use(createPinia()).use(i18n).mount("#app");
-
diff --git a/src/services/native-dialog-state.ts b/src/services/native-dialog-state.ts
new file mode 100644
index 00000000..35bbd13c
--- /dev/null
+++ b/src/services/native-dialog-state.ts
@@ -0,0 +1,44 @@
+let activeDialogs = 0
+let installed = false
+let releaseUploadDialog: (() => void) | null = null
+let releaseFallback: number | undefined
+
+export function beginNativeDialog(): () => void {
+ activeDialogs += 1
+ let closed = false
+ return () => {
+ if (closed) return
+ closed = true
+ activeDialogs = Math.max(0, activeDialogs - 1)
+ }
+}
+
+export function nativeDialogIsOpen(): boolean {
+ return activeDialogs > 0
+}
+
+function releaseActiveUploadDialog(): void {
+ if (releaseFallback) window.clearTimeout(releaseFallback)
+ releaseFallback = undefined
+ releaseUploadDialog?.()
+ releaseUploadDialog = null
+}
+
+export function installNativeDialogFocusGuard(): void {
+ if (installed || typeof document === 'undefined') return
+ installed = true
+
+ document.addEventListener('click', (event) => {
+ const target = event.target
+ if (!(target instanceof Element) || !target.closest('.unified-search-input__upload')) return
+
+ releaseActiveUploadDialog()
+ releaseUploadDialog = beginNativeDialog()
+ // Focus normally returns when the system picker closes. The fallback only
+ // prevents a failed native dialog from keeping Spotlight pinned forever.
+ releaseFallback = window.setTimeout(releaseActiveUploadDialog, 120_000)
+ window.addEventListener('focus', () => {
+ window.setTimeout(releaseActiveUploadDialog, 0)
+ }, { once: true })
+ }, { capture: true })
+}
diff --git a/src/services/semantic-channel.ts b/src/services/semantic-channel.ts
index 1e2b0142..84279379 100644
--- a/src/services/semantic-channel.ts
+++ b/src/services/semantic-channel.ts
@@ -1,4 +1,5 @@
import { emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event'
+import { perfLog } from '../utils'
import type { EmbeddedQuery } from './semantic'
export const SEMANTIC_QUERY_REQUEST_EVENT = 'semantic-query-requested'
@@ -11,6 +12,7 @@ export interface SemanticQueryRequest {
}
interface SemanticQueryResponse {
+ requestId: string
result?: EmbeddedQuery
error?: string
}
@@ -21,6 +23,7 @@ export async function requestSemanticEmbedding(
): Promise
{
const requestId = crypto.randomUUID()
const replyEvent = `semantic-query-response:${requestId}`
+ const startedAt = import.meta.env.DEV ? performance.now() : 0
let unlisten: UnlistenFn | null = null
let timeout: number | undefined
let resolveResponse: (value: EmbeddedQuery | undefined) => void = () => undefined
@@ -30,22 +33,77 @@ export async function requestSemanticEmbedding(
rejectResponse = reject
})
+ if (import.meta.env.DEV) {
+ console.info(`[Imagyx][SemanticQuery][${requestId}] start`, { query, timeoutMs })
+ }
+
try {
+ const listenerStartedAt = import.meta.env.DEV ? performance.now() : 0
unlisten = await listen(replyEvent, (event) => {
+ if (import.meta.env.DEV) {
+ const responseMs = performance.now() - startedAt
+ perfLog('SemanticQuery', 'Spotlight response event', responseMs, {
+ requestId,
+ query,
+ responseRequestId: event.payload.requestId,
+ vectorDimensions: event.payload.result?.queryVector.length ?? 0,
+ concepts: event.payload.result?.concepts.length ?? 0,
+ error: event.payload.error,
+ })
+ }
if (event.payload.error) rejectResponse(new Error(event.payload.error))
else resolveResponse(event.payload.result)
})
+ if (import.meta.env.DEV) {
+ perfLog('SemanticQuery', 'reply listener setup', performance.now() - listenerStartedAt, {
+ requestId,
+ query,
+ })
+ }
+
timeout = window.setTimeout(
() => rejectResponse(new Error('Le moteur de recherche sémantique ne répond pas')),
timeoutMs,
)
+
+ const dispatchStartedAt = import.meta.env.DEV ? performance.now() : 0
await emitTo('main', SEMANTIC_QUERY_REQUEST_EVENT, {
requestId,
query,
replyTo: 'spotlight',
replyEvent,
} satisfies SemanticQueryRequest)
- return await response
+ if (import.meta.env.DEV) {
+ perfLog('SemanticQuery', 'Spotlight to main dispatch', performance.now() - dispatchStartedAt, {
+ requestId,
+ query,
+ })
+ }
+
+ const result = await response
+ if (import.meta.env.DEV) {
+ const totalMs = performance.now() - startedAt
+ perfLog('SemanticQuery', 'Spotlight embedding round trip', totalMs, {
+ requestId,
+ query,
+ vectorDimensions: result?.queryVector.length ?? 0,
+ concepts: result?.concepts.length ?? 0,
+ })
+ console.info(`[Imagyx][SemanticQuery][${requestId}] complete in ${totalMs.toFixed(1)} ms`, {
+ query,
+ vectorDimensions: result?.queryVector.length ?? 0,
+ concepts: result?.concepts.length ?? 0,
+ })
+ }
+ return result
+ } catch (error) {
+ if (import.meta.env.DEV) {
+ console.error(
+ `[Imagyx][SemanticQuery][${requestId}] failed after ${(performance.now() - startedAt).toFixed(1)} ms`,
+ { query, error },
+ )
+ }
+ throw error
} finally {
if (timeout) window.clearTimeout(timeout)
unlisten?.()
diff --git a/src/services/semantic-provider.ts b/src/services/semantic-provider.ts
index fe3153f5..8318d596 100644
--- a/src/services/semantic-provider.ts
+++ b/src/services/semantic-provider.ts
@@ -1,27 +1,77 @@
import { emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event'
+import { perfLog } from '../utils'
import { semanticRuntime } from './semantic'
import {
SEMANTIC_QUERY_REQUEST_EVENT,
type SemanticQueryRequest,
} from './semantic-channel'
-export function registerSemanticQueryProvider(): Promise {
- return listen(SEMANTIC_QUERY_REQUEST_EVENT, (event) => {
- const request = event.payload
- const target = request.replyTo === 'spotlight' ? 'spotlight' : 'main'
- void semanticRuntime
- .embedQuery(request.query)
- .then((result) =>
- emitTo(target, request.replyEvent, {
- requestId: request.requestId,
- result,
- }),
+async function handleSemanticQueryRequest(request: SemanticQueryRequest): Promise {
+ const startedAt = import.meta.env.DEV ? performance.now() : 0
+ const target = request.replyTo === 'spotlight' ? 'spotlight' : 'main'
+
+ if (import.meta.env.DEV) {
+ console.info(`[Imagyx][SemanticProvider][${request.requestId}] received`, {
+ query: request.query,
+ target,
+ })
+ }
+
+ try {
+ const embeddingStartedAt = import.meta.env.DEV ? performance.now() : 0
+ const result = await semanticRuntime.embedQuery(request.query)
+ if (import.meta.env.DEV) {
+ const embeddingMs = performance.now() - embeddingStartedAt
+ perfLog('SemanticProvider', 'text embedding', embeddingMs, {
+ requestId: request.requestId,
+ query: request.query,
+ vectorDimensions: result?.queryVector.length ?? 0,
+ concepts: result?.concepts.length ?? 0,
+ })
+
+ const replyStartedAt = performance.now()
+ await emitTo(target, request.replyEvent, {
+ requestId: request.requestId,
+ result,
+ })
+ const replyMs = performance.now() - replyStartedAt
+ perfLog('SemanticProvider', 'main to Spotlight reply', replyMs, {
+ requestId: request.requestId,
+ query: request.query,
+ target,
+ })
+ console.info(
+ `[Imagyx][SemanticProvider][${request.requestId}] complete in ${(performance.now() - startedAt).toFixed(1)} ms`,
+ {
+ query: request.query,
+ embeddingMs: Number(embeddingMs.toFixed(2)),
+ replyMs: Number(replyMs.toFixed(2)),
+ vectorDimensions: result?.queryVector.length ?? 0,
+ },
)
- .catch((error) =>
- emitTo(target, request.replyEvent, {
- requestId: request.requestId,
- error: String(error),
- }),
+ } else {
+ await emitTo(target, request.replyEvent, {
+ requestId: request.requestId,
+ result,
+ })
+ }
+ } catch (error) {
+ const failedAt = import.meta.env.DEV ? performance.now() : 0
+ await emitTo(target, request.replyEvent, {
+ requestId: request.requestId,
+ error: String(error),
+ })
+ if (import.meta.env.DEV) {
+ console.error(
+ `[Imagyx][SemanticProvider][${request.requestId}] failed after ${(failedAt - startedAt).toFixed(1)} ms`,
+ { query: request.query, error },
)
+ }
+ }
+}
+
+export function registerSemanticQueryProvider(): Promise {
+ return listen(SEMANTIC_QUERY_REQUEST_EVENT, (event) => {
+ void handleSemanticQueryRequest(event.payload)
})
}
diff --git a/src/services/spotlight-converter-keyboard.ts b/src/services/spotlight-converter-keyboard.ts
new file mode 100644
index 00000000..0505e97c
--- /dev/null
+++ b/src/services/spotlight-converter-keyboard.ts
@@ -0,0 +1,116 @@
+const CONVERTER_SELECTOR = ".spotlight-converter";
+const BACK_BUTTON_SELECTOR = ".spotlight-conversion-input__back";
+const CONVERT_BUTTON_SELECTOR = ".spotlight-converter__command";
+const VISUAL_SEARCH_SELECTOR = ".unified-search-input--visual";
+const VISUAL_BACK_BUTTON_SELECTOR = ".unified-search-input--visual .unified-search-input__nav";
+const ACTION_POPOVER_SELECTOR = ".spotlight-action-popover";
+const MORE_ACTIONS_BUTTON_SELECTOR = ".spotlight-dock-button--more";
+
+let installed = false;
+
+function converterIsOpen(): boolean {
+ return document.querySelector(CONVERTER_SELECTOR) !== null;
+}
+
+function visualSearchIsOpen(): boolean {
+ return document.querySelector(VISUAL_SEARCH_SELECTOR) !== null;
+}
+
+function actionPopoverIsOpen(): boolean {
+ return document.querySelector(ACTION_POPOVER_SELECTOR) !== null;
+}
+
+function enabledButton(selector: string): HTMLButtonElement | null {
+ const button = document.querySelector(selector);
+ return button && !button.disabled ? button : null;
+}
+
+function clickEnabledButton(selector: string): boolean {
+ const button = enabledButton(selector);
+ if (!button) return false;
+ button.click();
+ return true;
+}
+
+function consume(event: KeyboardEvent): void {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+}
+
+function handleConverterKeydown(event: KeyboardEvent): void {
+ if (event.isComposing) return;
+
+ if (converterIsOpen()) {
+ if (event.key !== "Escape" && event.key !== "Enter") return;
+
+ // Installed before Vue mounts so converter keys never reach Spotlight's
+ // global Escape/Enter handlers and accidentally hide/open Imagyx.
+ consume(event);
+
+ if (event.key === "Escape") {
+ const wentBack = clickEnabledButton(BACK_BUTTON_SELECTOR);
+ if (import.meta.env.DEV) {
+ console.debug("[Imagyx][SpotlightConverterKeyboard] Escape", {
+ action: wentBack ? "back-to-search" : "ignored-while-converting",
+ });
+ }
+ return;
+ }
+
+ const hasModifier = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
+ const started = !event.repeat && !hasModifier
+ ? clickEnabledButton(CONVERT_BUTTON_SELECTOR)
+ : false;
+
+ if (import.meta.env.DEV) {
+ console.debug("[Imagyx][SpotlightConverterKeyboard] Enter", {
+ action: started ? "convert" : "consumed",
+ repeat: event.repeat,
+ hasModifier,
+ });
+ }
+ return;
+ }
+
+ if (event.key === "Escape" && visualSearchIsOpen()) {
+ consume(event);
+ const wentBack = clickEnabledButton(VISUAL_BACK_BUTTON_SELECTOR);
+ if (import.meta.env.DEV) {
+ console.debug("[Imagyx][SpotlightVisualSearchKeyboard] Escape", {
+ action: wentBack ? "back-to-previous-search" : "consumed",
+ });
+ }
+ return;
+ }
+
+ const key = event.key.toLocaleLowerCase();
+ const primaryModifier = event.ctrlKey || event.metaKey;
+ if (primaryModifier && !event.shiftKey && !event.altKey && key === "k") {
+ if (!enabledButton(MORE_ACTIONS_BUTTON_SELECTOR)) return;
+ consume(event);
+ const toggled = !event.repeat && clickEnabledButton(MORE_ACTIONS_BUTTON_SELECTOR);
+ if (import.meta.env.DEV) {
+ console.debug("[Imagyx][SpotlightActionDockKeyboard] Ctrl+K", {
+ action: toggled ? "toggle-actions" : "consumed-repeat",
+ repeat: event.repeat,
+ });
+ }
+ return;
+ }
+
+ if (event.key === "Escape" && actionPopoverIsOpen()) {
+ consume(event);
+ const closed = clickEnabledButton(MORE_ACTIONS_BUTTON_SELECTOR);
+ if (import.meta.env.DEV) {
+ console.debug("[Imagyx][SpotlightActionDockKeyboard] Escape", {
+ action: closed ? "close-actions" : "consumed",
+ });
+ }
+ }
+}
+
+export function installSpotlightConverterKeyboardGuard(): void {
+ if (installed || typeof window === "undefined") return;
+ installed = true;
+ window.addEventListener("keydown", handleConverterKeydown, { capture: true });
+}
diff --git a/src/services/spotlight-search-pagination.ts b/src/services/spotlight-search-pagination.ts
new file mode 100644
index 00000000..ac32d3a4
--- /dev/null
+++ b/src/services/spotlight-search-pagination.ts
@@ -0,0 +1,216 @@
+import { readonly, ref, type Ref } from 'vue'
+import type { ImageAsset, SearchPage, SearchRequest } from '../types'
+
+const PAGE_CACHE_TTL_MS = 2_000
+const DEFAULT_PAGE_SIZE = 60
+
+type InitialSearchContext = {
+ token: number
+ rawQuery: string
+ requestKey: string
+}
+
+type CachedPage = {
+ request: SearchRequest
+ items: ImageAsset[]
+ total: number
+ storedAt: number
+}
+
+type SearchPageLoader = (request: SearchRequest) => Promise
+
+const totalResults = ref(0)
+const loadingMore = ref(false)
+const hasKnownTotal = ref(false)
+const cachedPages = new Map()
+
+let currentRawQuery = ''
+let requestSequence = 0
+let activeToken = 0
+let activeRequestKey = ''
+let activeRequest: SearchRequest | null = null
+let activeItems: ImageAsset[] | null = null
+
+function isSpotlightSurface(): boolean {
+ return typeof document !== 'undefined'
+ && document.documentElement.dataset.window === 'spotlight'
+}
+
+function requestKey(request: SearchRequest): string {
+ return [
+ request.mode ?? 'text',
+ request.folderId ?? 'all',
+ request.excludeImageId ?? '-',
+ request.query.trim().toLocaleLowerCase('en'),
+ ].join(':')
+}
+
+function trimCache(now = performance.now()): void {
+ for (const [key, page] of cachedPages) {
+ if (now - page.storedAt > PAGE_CACHE_TTL_MS) cachedPages.delete(key)
+ }
+}
+
+function activate(page: CachedPage, token = activeToken): void {
+ activeToken = token
+ activeRequestKey = requestKey(page.request)
+ activeRequest = page.request
+ activeItems = page.items
+ totalResults.value = page.total
+ hasKnownTotal.value = true
+}
+
+function setRawQuery(rawQuery: string): void {
+ if (!isSpotlightSurface()) return
+ currentRawQuery = rawQuery
+ trimCache()
+ const cached = cachedPages.get(rawQuery)
+ if (cached) {
+ activate(cached)
+ return
+ }
+ activeRequestKey = ''
+ activeRequest = null
+ activeItems = null
+ totalResults.value = 0
+ hasKnownTotal.value = false
+}
+
+function beginInitialSearch(request: SearchRequest): InitialSearchContext | null {
+ if (!isSpotlightSurface()) return null
+ requestSequence += 1
+ const context = {
+ token: requestSequence,
+ rawQuery: currentRawQuery,
+ requestKey: requestKey(request),
+ }
+ if (activeRequestKey && activeRequestKey !== context.requestKey) {
+ totalResults.value = 0
+ hasKnownTotal.value = false
+ }
+ return context
+}
+
+function completeInitialSearch(
+ context: InitialSearchContext | null,
+ request: SearchRequest,
+ page: SearchPage,
+ reactiveItems: ImageAsset[],
+): void {
+ if (!context) return
+ const cached = {
+ request: { ...request, offset: 0 },
+ items: reactiveItems,
+ total: page.total,
+ storedAt: performance.now(),
+ }
+ cachedPages.set(context.rawQuery, cached)
+ trimCache(cached.storedAt)
+
+ if (context.rawQuery !== currentRawQuery || context.token < requestSequence) return
+ activate(cached, context.token)
+
+ if (import.meta.env.DEV) {
+ console.info('[Imagyx][SpotlightPagination] initial page ready', {
+ rawQuery: context.rawQuery,
+ mode: request.mode ?? 'text',
+ query: request.query,
+ folderId: request.folderId ?? null,
+ loaded: reactiveItems.length,
+ total: page.total,
+ hasMore: reactiveItems.length < page.total,
+ })
+ }
+}
+
+async function loadMore(loader: SearchPageLoader): Promise {
+ const request = activeRequest
+ const items = activeItems
+ if (!request || !items || loadingMore.value || items.length >= totalResults.value) return
+
+ const token = activeToken
+ const key = activeRequestKey
+ const rawQuery = currentRawQuery
+ const offset = items.length
+ const limit = Math.max(1, request.limit ?? DEFAULT_PAGE_SIZE)
+ const diagnosticId = import.meta.env.DEV
+ ? `${request.diagnosticId ?? 'spotlight-page'}-offset-${offset}`
+ : undefined
+
+ loadingMore.value = true
+ const startedAt = import.meta.env.DEV ? performance.now() : 0
+ try {
+ const page = await loader({
+ ...request,
+ limit,
+ offset,
+ diagnosticId,
+ })
+ if (
+ token !== activeToken
+ || key !== activeRequestKey
+ || rawQuery !== currentRawQuery
+ || items !== activeItems
+ ) return
+
+ const knownIds = new Set(items.map((image) => image.id))
+ const appended = page.items.filter((image) => !knownIds.has(image.id))
+ items.push(...appended)
+ totalResults.value = page.total
+ hasKnownTotal.value = true
+
+ const cached = cachedPages.get(rawQuery)
+ if (cached && cached.items === items) {
+ cached.total = page.total
+ cached.storedAt = performance.now()
+ }
+
+ if (import.meta.env.DEV) {
+ console.info('[Imagyx][SpotlightPagination] page appended', {
+ rawQuery,
+ mode: request.mode ?? 'text',
+ query: request.query,
+ folderId: request.folderId ?? null,
+ offset,
+ requested: limit,
+ received: page.items.length,
+ appended: appended.length,
+ loaded: items.length,
+ total: page.total,
+ hasMore: items.length < page.total,
+ durationMs: Number((performance.now() - startedAt).toFixed(2)),
+ })
+ }
+ } catch (reason) {
+ if (import.meta.env.DEV) {
+ console.error('[Imagyx][SpotlightPagination] page failed', {
+ rawQuery,
+ mode: request.mode ?? 'text',
+ query: request.query,
+ folderId: request.folderId ?? null,
+ offset,
+ reason,
+ })
+ }
+ } finally {
+ loadingMore.value = false
+ }
+}
+
+export const spotlightSearchPagination: {
+ totalResults: Readonly[>
+ loadingMore: Readonly][>
+ hasKnownTotal: Readonly][>
+ setRawQuery: typeof setRawQuery
+ beginInitialSearch: typeof beginInitialSearch
+ completeInitialSearch: typeof completeInitialSearch
+ loadMore: typeof loadMore
+} = {
+ totalResults: readonly(totalResults),
+ loadingMore: readonly(loadingMore),
+ hasKnownTotal: readonly(hasKnownTotal),
+ setRawQuery,
+ beginInitialSearch,
+ completeInitialSearch,
+ loadMore,
+}
diff --git a/src/services/thumbnails.ts b/src/services/thumbnails.ts
index dd203bda..e3724bfd 100644
--- a/src/services/thumbnails.ts
+++ b/src/services/thumbnails.ts
@@ -5,7 +5,9 @@ import { perfSample } from '../utils'
const HARDWARE_CONCURRENCY = typeof navigator === 'undefined'
? 4
: navigator.hardwareConcurrency || 4
-const MAX_CONCURRENT_REQUESTS = Math.max(2, HARDWARE_CONCURRENCY)
+// Generating thumbnails competes with the webview's paint thread. More than
+// four simultaneous native jobs makes fast virtual scrolling visibly stutter.
+const MAX_CONCURRENT_REQUESTS = Math.min(4, Math.max(2, HARDWARE_CONCURRENCY))
const MAX_QUEUED_REQUESTS = MAX_CONCURRENT_REQUESTS * 8
const MEMORY_CACHE_MAX_ITEMS = 64
diff --git a/src/services/unified-search-engine.ts b/src/services/unified-search-engine.ts
new file mode 100644
index 00000000..a07241ae
--- /dev/null
+++ b/src/services/unified-search-engine.ts
@@ -0,0 +1,58 @@
+import { spotlightSearchTiming } from '../config/spotlight-search'
+import { installNativeDialogFocusGuard } from './native-dialog-state'
+import { semanticRuntime } from './semantic'
+import { visualSearchSession } from './visual-search-session'
+
+let installed = false
+let mainEmbeddingSequence = 0
+
+function waitForDelay(delayMs: number): Promise {
+ if (delayMs <= 0) return Promise.resolve()
+ return new Promise((resolve) => window.setTimeout(resolve, delayMs))
+}
+
+function installMainVisualEscape() {
+ if ((document.documentElement.dataset.window ?? 'main') === 'spotlight') return
+ window.addEventListener('keydown', (event) => {
+ if (event.isComposing || event.key !== 'Escape' || visualSearchSession.state.status === 'idle') return
+ const backButton = document.querySelector(
+ '.unified-search-input--visual .unified-search-input__nav',
+ )
+ if (!backButton || backButton.disabled) return
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ backButton.click()
+ }, { capture: true })
+}
+
+export function installUnifiedSearchEngine(): void {
+ if (installed || typeof window === 'undefined') return
+ installed = true
+ installNativeDialogFocusGuard()
+ installMainVisualEscape()
+
+ const embedTextQuery = semanticRuntime.embedQuery.bind(semanticRuntime)
+ semanticRuntime.embedQuery = async (query: string) => {
+ const trimmed = query.trim()
+ // imagyxApi already turns this token into the pure visual request. The
+ // semantic stage must stop here so no text model or duplicate search runs.
+ if (visualSearchSession.vectorForQuery(trimmed)) {
+ mainEmbeddingSequence += 1
+ return undefined
+ }
+
+ const surface = document.documentElement.dataset.window ?? 'main'
+ if (surface !== 'spotlight') {
+ mainEmbeddingSequence += 1
+ const sequence = mainEmbeddingSequence
+ const remainingDelay = Math.max(
+ 0,
+ spotlightSearchTiming.semanticDebounceMs - spotlightSearchTiming.lexicalDebounceMs,
+ )
+ await waitForDelay(remainingDelay)
+ if (sequence !== mainEmbeddingSequence) return undefined
+ }
+
+ return embedTextQuery(trimmed)
+ }
+}
diff --git a/src/services/vision-worker-client.ts b/src/services/vision-worker-client.ts
index 624b0708..e469a45b 100644
--- a/src/services/vision-worker-client.ts
+++ b/src/services/vision-worker-client.ts
@@ -99,7 +99,6 @@ type ErrorResponse = {
}
type WorkerResponse = ReadyResponse | ResultResponse | ErrorResponse
-
type SuccessfulResponse = Exclude
type TimedResponse = {
@@ -114,15 +113,17 @@ type PendingRequest = {
reject: (error: Error) => void
}
-export class VisionWorkerClient {
- private worker: Worker | null = null
- private initialization: Promise | null = null
- private readonly pending = new Map()
- private nextRequestId = 1
+// Every surface and feature uses this shared module-level worker. Creating a
+// client is cheap and never loads a second MobileCLIP model into memory.
+let sharedWorker: Worker | null = null
+let sharedInitialization: Promise | null = null
+const sharedPending = new Map()
+let sharedNextRequestId = 1
+export class VisionWorkerClient {
initialize(modelId: string, localModelPath: string): Promise {
- if (!this.initialization) {
- this.initialization = this.request((requestId) => ({
+ if (!sharedInitialization) {
+ sharedInitialization = this.request((requestId) => ({
type: 'init',
requestId,
modelId,
@@ -133,7 +134,7 @@ export class VisionWorkerClient {
return ready
})
}
- return this.initialization
+ return sharedInitialization
}
async infer(pixels: ArrayBuffer, count: number, batchId: string): Promise {
@@ -189,11 +190,11 @@ export class VisionWorkerClient {
transfer: Transferable[] = [],
): Promise> {
const worker = this.ensureWorker()
- const requestId = this.nextRequestId
- this.nextRequestId += 1
+ const requestId = sharedNextRequestId
+ sharedNextRequestId += 1
return new Promise>((resolve, reject) => {
- this.pending.set(requestId, {
+ sharedPending.set(requestId, {
sentAtPerfMs: performance.now(),
resolve: (response, roundTripMs, receivedAtEpochMs) => resolve({
response: response as T,
@@ -207,7 +208,7 @@ export class VisionWorkerClient {
}
private ensureWorker(): Worker {
- if (this.worker) return this.worker
+ if (sharedWorker) return sharedWorker
const worker = new Worker(new URL('../workers/vision-worker.ts', import.meta.url), {
type: 'module',
@@ -216,9 +217,9 @@ export class VisionWorkerClient {
worker.onmessage = (event: MessageEvent) => {
const receivedAtEpochMs = Date.now()
const response = event.data
- const pending = this.pending.get(response.requestId)
+ const pending = sharedPending.get(response.requestId)
if (!pending) return
- this.pending.delete(response.requestId)
+ sharedPending.delete(response.requestId)
const roundTripMs = performance.now() - pending.sentAtPerfMs
if (response.type === 'error') {
pending.reject(new Error(response.message))
@@ -232,15 +233,15 @@ export class VisionWorkerClient {
worker.onmessageerror = () => {
this.failWorker(new Error('Message invalide reçu du worker vision'))
}
- this.worker = worker
+ sharedWorker = worker
return worker
}
private failWorker(error: Error) {
- for (const pending of this.pending.values()) pending.reject(error)
- this.pending.clear()
- this.worker?.terminate()
- this.worker = null
- this.initialization = null
+ for (const pending of sharedPending.values()) pending.reject(error)
+ sharedPending.clear()
+ sharedWorker?.terminate()
+ sharedWorker = null
+ sharedInitialization = null
}
}
diff --git a/src/services/visual-search-session.ts b/src/services/visual-search-session.ts
new file mode 100644
index 00000000..7cf36946
--- /dev/null
+++ b/src/services/visual-search-session.ts
@@ -0,0 +1,159 @@
+import { readonly, shallowReactive } from 'vue'
+import type { ImageAsset, SearchRequest } from '../types'
+
+const VISUAL_QUERY_PREFIX = '\u2063imagyx-visual:'
+const SESSION_EVENT = 'imagyx-visual-search-session'
+
+export type VisualSearchSourceKind = 'indexed' | 'file' | 'clipboard' | 'drop'
+export type VisualSearchStatus = 'idle' | 'loading' | 'ready' | 'error'
+
+interface VisualSearchState {
+ status: VisualSearchStatus
+ token: string
+ sourceKind: VisualSearchSourceKind | null
+ label: string
+ previewUrl: string | null
+ sourceImage: ImageAsset | null
+ vector: number[] | null
+ excludeImageId: string | null
+ error: string | null
+}
+
+const state = shallowReactive({
+ status: 'idle',
+ token: '',
+ sourceKind: null,
+ label: '',
+ previewUrl: null,
+ sourceImage: null,
+ vector: null,
+ excludeImageId: null,
+ error: null,
+})
+
+let sessionSequence = 0
+let operationSequence = 0
+let ownedPreviewUrl: string | null = null
+
+function dispatchSessionEvent() {
+ if (typeof window === 'undefined') return
+ window.dispatchEvent(new CustomEvent(SESSION_EVENT, {
+ detail: {
+ status: state.status,
+ token: state.token,
+ },
+ }))
+}
+
+function releaseOwnedPreview() {
+ if (!ownedPreviewUrl) return
+ URL.revokeObjectURL(ownedPreviewUrl)
+ ownedPreviewUrl = null
+}
+
+function begin(
+ sourceKind: VisualSearchSourceKind,
+ label: string,
+ options: { previewUrl?: string | null; sourceImage?: ImageAsset | null } = {},
+): number {
+ operationSequence += 1
+ releaseOwnedPreview()
+ state.status = 'loading'
+ state.token = ''
+ state.sourceKind = sourceKind
+ state.label = label
+ state.previewUrl = options.previewUrl ?? null
+ state.sourceImage = options.sourceImage ?? null
+ state.vector = null
+ state.excludeImageId = null
+ state.error = null
+ dispatchSessionEvent()
+ return operationSequence
+}
+
+function activate(
+ vector: number[],
+ options: {
+ sourceKind: VisualSearchSourceKind
+ label: string
+ previewUrl?: string | null
+ sourceImage?: ImageAsset | null
+ excludeImageId?: string | null
+ ownsPreviewUrl?: boolean
+ },
+ operationId = operationSequence,
+): string {
+ if (operationId !== operationSequence) return ''
+ if (!vector.length) throw new Error('Visual search embedding is empty')
+ if (options.ownsPreviewUrl && options.previewUrl) ownedPreviewUrl = options.previewUrl
+ sessionSequence += 1
+ state.status = 'ready'
+ state.token = `${VISUAL_QUERY_PREFIX}${Date.now().toString(36)}-${sessionSequence}`
+ state.sourceKind = options.sourceKind
+ state.label = options.label
+ state.previewUrl = options.previewUrl ?? null
+ state.sourceImage = options.sourceImage ?? null
+ state.vector = vector
+ state.excludeImageId = options.excludeImageId ?? null
+ state.error = null
+ dispatchSessionEvent()
+ return state.token
+}
+
+function fail(reason: unknown, operationId = operationSequence) {
+ if (operationId !== operationSequence) return
+ if (import.meta.env.DEV) console.error('[Imagyx][VisualSearch] query failed', reason)
+ state.status = 'error'
+ state.vector = null
+ state.token = ''
+ // The shared input renders the localized error description. Keep technical
+ // backend details in development logs instead of leaking English strings.
+ state.error = null
+ dispatchSessionEvent()
+}
+
+function clear() {
+ operationSequence += 1
+ releaseOwnedPreview()
+ state.status = 'idle'
+ state.token = ''
+ state.sourceKind = null
+ state.label = ''
+ state.previewUrl = null
+ state.sourceImage = null
+ state.vector = null
+ state.excludeImageId = null
+ state.error = null
+ dispatchSessionEvent()
+}
+
+function isVisualQuery(query: string): boolean {
+ return Boolean(state.token) && query === state.token
+}
+
+function requestForActiveSession(request: SearchRequest): SearchRequest {
+ if (!isVisualQuery(request.query) || state.status !== 'ready' || !state.vector) return request
+ return {
+ ...request,
+ query: '',
+ queryVector: state.vector,
+ mode: 'visual',
+ excludeImageId: state.excludeImageId ?? undefined,
+ }
+}
+
+function vectorForQuery(query: string): number[] | null {
+ return isVisualQuery(query) && state.status === 'ready' ? state.vector : null
+}
+
+export const visualSearchSession = {
+ state: readonly(state),
+ eventName: SESSION_EVENT,
+ begin,
+ activate,
+ fail,
+ clear,
+ isVisualQuery,
+ requestForActiveSession,
+ vectorForQuery,
+}
diff --git a/src/services/visual-search.ts b/src/services/visual-search.ts
new file mode 100644
index 00000000..b60b82a0
--- /dev/null
+++ b/src/services/visual-search.ts
@@ -0,0 +1,180 @@
+import { convertFileSrc, invoke } from '@tauri-apps/api/core'
+import type { ImageAsset } from '../types'
+import { VisionWorkerClient } from './vision-worker-client'
+import { visualSearchSession, type VisualSearchSourceKind } from './visual-search-session'
+
+const MODEL_ID = 'Xenova/mobileclip_s0'
+const IMAGE_EDGE = 224
+const IMAGE_CHANNELS = 3
+const IMAGE_BYTES = IMAGE_EDGE * IMAGE_EDGE * IMAGE_CHANNELS
+const visionWorker = new VisionWorkerClient()
+let visionReady: Promise | null = null
+let querySequence = 0
+
+function filename(path: string): string {
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
+}
+
+async function ensureVisionReady() {
+ if (!visionReady) {
+ visionReady = (async () => {
+ const modelRoot = await invoke('prepare_local_model', { modelKey: 'mobileclip-s0' })
+ const modelsDir = modelRoot.replace(/[\\/]+Xenova[\\/]mobileclip_s0$/, '')
+ const localModelPath = `${convertFileSrc(modelsDir).replace(/\/$/, '')}/`
+ await visionWorker.initialize(MODEL_ID, localModelPath)
+ })().catch((error) => {
+ visionReady = null
+ throw error
+ })
+ }
+ await visionReady
+}
+
+async function inferPixels(pixels: ArrayBuffer): Promise {
+ if (pixels.byteLength !== IMAGE_BYTES) {
+ throw new Error(`Invalid visual query pixels: ${pixels.byteLength} bytes`)
+ }
+ await ensureVisionReady()
+ querySequence += 1
+ const result = await visionWorker.infer(pixels, 1, `visual-query-${Date.now().toString(36)}-${querySequence}`)
+ const vector = result.vectors[0]
+ if (!vector?.length) throw new Error('MobileCLIP returned an empty visual query')
+ return vector
+}
+
+function rgbPreviewUrl(pixels: ArrayBuffer): string {
+ if (pixels.byteLength !== IMAGE_BYTES) return ''
+ const rgb = new Uint8Array(pixels)
+ const rgba = new Uint8ClampedArray(IMAGE_EDGE * IMAGE_EDGE * 4)
+ for (let source = 0, target = 0; source < rgb.length; source += 3) {
+ rgba[target] = rgb[source] ?? 0
+ rgba[target + 1] = rgb[source + 1] ?? 0
+ rgba[target + 2] = rgb[source + 2] ?? 0
+ rgba[target + 3] = 255
+ target += 4
+ }
+ const canvas = document.createElement('canvas')
+ canvas.width = IMAGE_EDGE
+ canvas.height = IMAGE_EDGE
+ const context = canvas.getContext('2d')
+ if (!context) return ''
+ context.putImageData(new ImageData(rgba, IMAGE_EDGE, IMAGE_EDGE), 0, 0)
+ return canvas.toDataURL('image/jpeg', 0.88)
+}
+
+async function blobPixels(blob: Blob): Promise {
+ const bitmap = await createImageBitmap(blob)
+ try {
+ const canvas = document.createElement('canvas')
+ canvas.width = IMAGE_EDGE
+ canvas.height = IMAGE_EDGE
+ const context = canvas.getContext('2d', { alpha: false, willReadFrequently: true })
+ if (!context) throw new Error('Canvas 2D is unavailable')
+
+ const sourceRatio = bitmap.width / Math.max(1, bitmap.height)
+ const targetRatio = 1
+ let sourceX = 0
+ let sourceY = 0
+ let sourceWidth = bitmap.width
+ let sourceHeight = bitmap.height
+ if (sourceRatio > targetRatio) {
+ sourceWidth = bitmap.height
+ sourceX = (bitmap.width - sourceWidth) / 2
+ } else if (sourceRatio < targetRatio) {
+ sourceHeight = bitmap.width
+ sourceY = (bitmap.height - sourceHeight) / 2
+ }
+ context.drawImage(
+ bitmap,
+ sourceX,
+ sourceY,
+ sourceWidth,
+ sourceHeight,
+ 0,
+ 0,
+ IMAGE_EDGE,
+ IMAGE_EDGE,
+ )
+ const rgba = context.getImageData(0, 0, IMAGE_EDGE, IMAGE_EDGE).data
+ const rgb = new Uint8Array(IMAGE_BYTES)
+ for (let source = 0, target = 0; source < rgba.length; source += 4) {
+ rgb[target] = rgba[source] ?? 0
+ rgb[target + 1] = rgba[source + 1] ?? 0
+ rgb[target + 2] = rgba[source + 2] ?? 0
+ target += 3
+ }
+ return rgb.buffer
+ } finally {
+ bitmap.close()
+ }
+}
+
+async function findSimilar(image: ImageAsset): Promise {
+ const operationId = visualSearchSession.begin('indexed', image.name, { sourceImage: image })
+ try {
+ const vector = await invoke('get_image_embedding', { imageId: image.id })
+ return visualSearchSession.activate(vector, {
+ sourceKind: 'indexed',
+ label: image.name,
+ sourceImage: image,
+ excludeImageId: image.id,
+ }, operationId)
+ } catch (error) {
+ visualSearchSession.fail(error, operationId)
+ return ''
+ }
+}
+
+async function searchPath(path: string, sourceKind: VisualSearchSourceKind = 'file'): Promise {
+ const label = filename(path)
+ const operationId = visualSearchSession.begin(sourceKind, label)
+ try {
+ const pixels = await invoke('prepare_visual_query_image', { path })
+ const previewUrl = rgbPreviewUrl(pixels)
+ const vector = await inferPixels(pixels)
+ return visualSearchSession.activate(vector, {
+ sourceKind,
+ label,
+ previewUrl,
+ }, operationId)
+ } catch (error) {
+ visualSearchSession.fail(error, operationId)
+ return ''
+ }
+}
+
+async function searchBlob(
+ blob: Blob,
+ label: string,
+ sourceKind: Extract,
+): Promise {
+ const operationId = visualSearchSession.begin(sourceKind, label)
+ if (blob.type && !blob.type.startsWith('image/')) {
+ visualSearchSession.fail('The dropped or pasted file is not an image', operationId)
+ return ''
+ }
+ const previewUrl = URL.createObjectURL(blob)
+ try {
+ const vector = await inferPixels(await blobPixels(blob))
+ const token = visualSearchSession.activate(vector, {
+ sourceKind,
+ label,
+ previewUrl,
+ ownsPreviewUrl: true,
+ }, operationId)
+ if (!token) URL.revokeObjectURL(previewUrl)
+ return token
+ } catch (error) {
+ URL.revokeObjectURL(previewUrl)
+ visualSearchSession.fail(error, operationId)
+ return ''
+ }
+}
+
+export const visualSearch = {
+ findSimilar,
+ searchPath,
+ searchFile: (file: File, sourceKind: 'clipboard' | 'drop' = 'drop') =>
+ searchBlob(file, file.name || 'image', sourceKind),
+ searchBlob,
+}
diff --git a/src/spotlight-window.css b/src/spotlight-window.css
index 92a1a1b7..8b94a037 100644
--- a/src/spotlight-window.css
+++ b/src/spotlight-window.css
@@ -23,6 +23,21 @@
animation: spotlight-loading-reveal 0s linear 300ms forwards !important;
}
+:root[data-window='spotlight'] .spotlight-results--with-actions {
+ padding-bottom: 58px !important;
+}
+
+:root[data-window='spotlight'] .spotlight-action-dock {
+ padding: 7px 10px 8px !important;
+ border-top-color: color-mix(in srgb, var(--border-strong) 34%, transparent) !important;
+ background: color-mix(in srgb, var(--surface-elevated) 82%, transparent) !important;
+ box-shadow:
+ 0 -8px 22px -17px rgb(2 6 23 / 0.42),
+ inset 0 1px rgb(255 255 255 / 0.055) !important;
+ backdrop-filter: blur(18px) saturate(1.1) !important;
+ -webkit-backdrop-filter: blur(18px) saturate(1.1) !important;
+}
+
@keyframes spotlight-loading-reveal {
to {
opacity: 1;
diff --git a/src/stores/library.ts b/src/stores/library.ts
index 1ade591e..3060fac7 100644
--- a/src/stores/library.ts
+++ b/src/stores/library.ts
@@ -56,8 +56,10 @@ interface LibraryState {
folders: FollowedFolder[];
indexCoverage: FolderIndexCoverage[];
images: ImageAsset[];
+ totalResults: number;
selectedFolderId: string | null;
query: string;
+ activeQueryVector: number[] | null;
activeConcepts: QueryConcept[];
loading: boolean;
loadingMore: boolean;
@@ -80,8 +82,10 @@ export const useLibraryStore = defineStore("library", {
folders: [],
indexCoverage: [],
images: [],
+ totalResults: 0,
selectedFolderId: null,
query: "",
+ activeQueryVector: null,
activeConcepts: [],
loading: false,
loadingMore: false,
@@ -403,6 +407,7 @@ export const useLibraryStore = defineStore("library", {
const query = this.query.trim();
const folderId = this.selectedFolderId ?? undefined;
this.error = null;
+ this.activeQueryVector = null;
this.activeConcepts = [];
this.semanticSearching = Boolean(query);
this.loadingMore = false;
@@ -410,42 +415,45 @@ export const useLibraryStore = defineStore("library", {
if (!this.images.length) this.loading = true;
try {
if (!query) {
- const results = await imagyxApi.search({
+ const page = await imagyxApi.searchPage({
query,
folderId,
limit: BROWSE_PAGE_SIZE,
offset: 0,
});
if (sequence !== this.searchSequence) return;
- this.images = imageList(results);
- this.hasMoreImages = results.length === BROWSE_PAGE_SIZE;
+ this.images = imageList(page.items);
+ this.totalResults = page.total;
+ this.hasMoreImages = this.images.length < page.total;
perfLog(
"LibraryStore",
"refreshImages first browse page",
performance.now() - start,
- { received: results.length, hasMore: this.hasMoreImages },
+ { received: page.items.length, total: page.total, hasMore: this.hasMoreImages },
);
return;
}
const lexicalStarted = performance.now();
const lexicalPromise = imagyxApi
- .search({ query, folderId, limit: SEARCH_RESULT_LIMIT })
- .then((results) => {
+ .searchPage({ query, folderId, limit: SEARCH_RESULT_LIMIT, offset: 0 })
+ .then((page) => {
if (
sequence === this.searchSequence &&
- (results.length > 0 || this.images.length === 0)
+ (page.items.length > 0 || this.images.length === 0)
) {
- this.images = imageList(results);
+ this.images = imageList(page.items);
+ this.totalResults = page.total;
+ this.hasMoreImages = this.images.length < page.total;
this.loading = false;
perfLog(
"LibraryStore",
- "refreshImages fast lexical",
+ "refreshImages fast first page",
performance.now() - lexicalStarted,
- { results: results.length },
+ { results: page.items.length, total: page.total },
);
}
- return results;
+ return page;
});
const embeddingStarted = performance.now();
const embeddingPromise = semanticRuntime.embedQuery(query).then((result) => {
@@ -466,28 +474,32 @@ export const useLibraryStore = defineStore("library", {
if (embeddingResult.status === "rejected") throw embeddingResult.reason;
const embedded = embeddingResult.value;
+ this.activeQueryVector = embedded?.queryVector ?? null;
this.activeConcepts = embedded?.concepts ?? [];
if (!embedded?.queryVector) return;
const hybridStarted = performance.now();
- const hybrid = await imagyxApi.search({
+ const hybrid = await imagyxApi.searchPage({
query,
queryVector: embedded.queryVector,
folderId,
limit: SEARCH_RESULT_LIMIT,
+ offset: 0,
});
if (sequence !== this.searchSequence) return;
- this.images = imageList(hybrid);
+ this.images = imageList(hybrid.items);
+ this.totalResults = hybrid.total;
+ this.hasMoreImages = this.images.length < hybrid.total;
perfLog(
"LibraryStore",
"refreshImages hybrid IPC",
performance.now() - hybridStarted,
- { results: hybrid.length },
+ { results: hybrid.items.length, total: hybrid.total },
);
perfLog(
"LibraryStore",
"refreshImages semantic total",
performance.now() - start,
- { results: hybrid.length },
+ { results: hybrid.items.length, total: hybrid.total },
);
} catch (error) {
if (sequence === this.searchSequence) this.reportError(error);
@@ -500,39 +512,39 @@ export const useLibraryStore = defineStore("library", {
},
async loadMoreImages() {
- if (
- this.query.trim() ||
- !this.hasMoreImages ||
- this.loadingMore
- ) {
- return;
- }
+ if (!this.hasMoreImages || this.loadingMore) return;
const sequence = this.searchSequence;
+ const query = this.query.trim();
const offset = this.images.length;
const folderId = this.selectedFolderId ?? undefined;
+ const limit = query ? SEARCH_RESULT_LIMIT : BROWSE_PAGE_SIZE;
const started = performance.now();
this.loadingMore = true;
try {
- const page = await imagyxApi.search({
- query: "",
+ const page = await imagyxApi.searchPage({
+ query,
+ queryVector: this.activeQueryVector ?? undefined,
folderId,
- limit: BROWSE_PAGE_SIZE,
+ limit,
offset,
});
if (sequence !== this.searchSequence) return;
const existing = new Set(this.images.map((image) => image.id));
- const appended = page.filter((image) => !existing.has(image.id));
+ const appended = page.items.filter((image) => !existing.has(image.id));
this.images = imageList([...this.images, ...appended]);
- this.hasMoreImages = page.length === BROWSE_PAGE_SIZE;
+ this.totalResults = page.total;
+ this.hasMoreImages = this.images.length < page.total;
perfLog(
"LibraryStore",
- "loadMoreImages backend page",
+ "loadMoreImages shared search page",
performance.now() - started,
{
+ queryMode: query ? (this.activeQueryVector ? "hybrid" : "lexical-or-visual") : "browse",
offset,
- received: page.length,
+ received: page.items.length,
appended: appended.length,
totalLoaded: this.images.length,
+ total: page.total,
},
);
} catch (error) {
@@ -615,7 +627,7 @@ export const useLibraryStore = defineStore("library", {
current: 0,
total: folder.imageCount,
stage: "queued",
- message: storeT('spotlight.index_message.queued', { total: folder.imageCount }),
+ message: storeT('spotlight.index_message.queued', undefined, { total: folder.imageCount }),
};
try {
await semanticRuntime.resumeIndexing(folderId);
diff --git a/src/types.ts b/src/types.ts
index 0c394842..7f227017 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -38,6 +38,22 @@ export interface ImageAsset {
semanticMatches?: SemanticMatch[]
}
+export type ImageConversionFormat = 'avif' | 'webp' | 'png' | 'jpg' | 'ico'
+
+export type ImageConversionStage = 'decoding' | 'encoding' | 'indexing' | 'complete'
+
+export interface ImageConversionProgress {
+ sourceImageId: string
+ targetFormat: ImageConversionFormat
+ progress: number
+ stage: ImageConversionStage
+}
+
+export interface ImageConversionResult {
+ image: ImageAsset
+ inheritedSemanticIndex: boolean
+}
+
export interface ImageEmbedding {
imageId: string
vector: number[]
@@ -119,10 +135,20 @@ export interface IndexProgress {
message: string
}
+export type SearchMode = 'text' | 'visual'
+
export interface SearchRequest {
query: string
folderId?: string
limit?: number
offset?: number
queryVector?: number[]
+ diagnosticId?: string
+ mode?: SearchMode
+ excludeImageId?: string
+}
+
+export interface SearchPage {
+ items: ImageAsset[]
+ total: number
}
diff --git a/tsconfig.json b/tsconfig.json
index 85b0c2b5..1586f5f5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,7 +21,8 @@
],
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "skipLibCheck": true
},
"include": [
"src/**/*.ts",
]