From 154f12465a973824913331b761a6b6dc12da3a70 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:47:49 -0500 Subject: [PATCH 1/2] Tooltip, smart folders, design system, lint: UX and consistency fixes - Tooltip: suppress native title (MutationObserver + proto patch), refine to fade-only animation, theme-consistent styling - Smart folders: create only Uncategorized on first launch; full defaults via Reset - Design system: tokenized shadow/radius scale, normalized utilities - ESLint: fix unused vars in OramaVectorService, ChatPanel, UnifiedSearchModal, useAnalysis --- src/main/core/customFolders.js | 117 ++- src/main/services/OramaVectorService.js | 269 ++++-- src/renderer/components/TooltipManager.jsx | 240 ++++-- .../components/search/BaseEdgeTooltip.jsx | 12 +- src/renderer/components/search/ChatPanel.jsx | 179 ++-- .../components/search/UnifiedSearchModal.jsx | 806 ++++++++++++++---- src/renderer/phases/discover/useAnalysis.js | 13 +- src/renderer/styles.css | 161 +++- test/TooltipManager.test.js | 19 + test/customFolders.test.js | 36 +- 10 files changed, 1317 insertions(+), 535 deletions(-) diff --git a/src/main/core/customFolders.js b/src/main/core/customFolders.js index db74f14d..61f18617 100644 --- a/src/main/core/customFolders.js +++ b/src/main/core/customFolders.js @@ -44,56 +44,39 @@ function normalizeFolderPaths(folders) { } } -async function ensureDefaultSmartFolders(folders) { +/** + * Ensure the essential Uncategorized fallback folder exists. + * We no longer auto-inject the full default set — users choose their own + * folder structure. The full defaults are only applied via "Reset to Defaults". + */ +async function ensureUncategorizedFolder(folders) { const safeFolders = Array.isArray(folders) ? folders : []; - const documentsDir = app.getPath('documents'); - const baseDir = path.join(documentsDir, 'StratoSort'); - const defaultFolders = getDefaultSmartFolders(baseDir); - const defaultNameSet = new Set(defaultFolders.map((folder) => normalizeName(folder.name))); - const existingNameSet = new Set(safeFolders.map((folder) => normalizeName(folder?.name))); - const isDefaultName = (nameKey) => - nameKey && nameKey !== 'uncategorized' && defaultNameSet.has(nameKey); - const hasDefaultFlag = safeFolders.some( - (folder) => folder?.isDefault && isDefaultName(normalizeName(folder?.name)) + const hasUncategorized = safeFolders.some( + (folder) => normalizeName(folder?.name) === 'uncategorized' ); - const hasDefaultName = safeFolders.some((folder) => isDefaultName(normalizeName(folder?.name))); - const hasCustom = safeFolders.some((folder) => { - const nameKey = normalizeName(folder?.name); - return nameKey && !defaultNameSet.has(nameKey); - }); - const shouldEnsureDefaults = - !hasCustom && (hasDefaultFlag || hasDefaultName || !safeFolders.length); - const missingDefaults = defaultFolders.filter((folder) => { - const nameKey = normalizeName(folder.name); - if (existingNameSet.has(nameKey)) return false; - if (!shouldEnsureDefaults && nameKey !== 'uncategorized') return false; - return true; - }); + if (hasUncategorized) return safeFolders; - if (missingDefaults.length === 0) return safeFolders; + const documentsDir = app.getPath('documents'); + const baseDir = path.join(documentsDir, 'StratoSort'); + const uncategorized = createUncategorizedFolder(baseDir); - logger.info('[STORAGE] Adding missing default smart folders:', { - count: missingDefaults.length, - names: missingDefaults.map((folder) => folder.name) - }); + logger.info('[STORAGE] Adding missing Uncategorized fallback folder'); - for (const folder of missingDefaults) { - try { - await fs.mkdir(folder.path, { recursive: true }); - logger.info(`[STORAGE] Created physical ${folder.name} directory at:`, folder.path); - } catch (error) { - logger.error(`[STORAGE] Failed to create ${folder.name} directory:`, error); - } + try { + await fs.mkdir(uncategorized.path, { recursive: true }); + logger.info('[STORAGE] Created Uncategorized directory at:', uncategorized.path); + } catch (error) { + logger.error('[STORAGE] Failed to create Uncategorized directory:', error); } - const updatedFolders = [...safeFolders, ...missingDefaults]; + const updatedFolders = [...safeFolders, uncategorized]; try { await saveCustomFolders(updatedFolders); - logger.info('[STORAGE] Persisted default smart folder updates to custom-folders.json'); + logger.info('[STORAGE] Persisted Uncategorized folder to custom-folders.json'); } catch (error) { - logger.error('[STORAGE] Failed to persist default folders:', error); + logger.error('[STORAGE] Failed to persist Uncategorized folder:', error); } return updatedFolders; @@ -178,8 +161,25 @@ async function recoverLegacyCustomFolders(currentFolders, defaultNameSet) { } /** - * Default smart folders that match the categorization system in fallbackUtils.js - * These provide a good starting point for file organization + * Create a single Uncategorized folder entry. + * Used for first-launch bootstrapping and the ensureUncategorizedFolder guard. + */ +function createUncategorizedFolder(baseDir) { + return { + id: `default-uncategorized-${crypto.randomUUID().slice(0, 8)}`, + name: 'Uncategorized', + path: path.join(baseDir, 'Uncategorized'), + description: "Default folder for files that don't match any smart folder", + keywords: [], + category: 'uncategorized', + isDefault: true, + createdAt: new Date().toISOString() + }; +} + +/** + * Full default smart folders that match the categorization system in fallbackUtils.js + * Only used when the user explicitly clicks "Reset to Defaults" */ function getDefaultSmartFolders(baseDir) { // FIX (M-8): Use crypto.randomUUID() instead of Date.now() offsets. @@ -286,8 +286,8 @@ async function loadCustomFolders() { const recovered = await recoverLegacyCustomFolders(normalized, defaultNameSet); - // Ensure missing defaults (including Uncategorized) are added as needed - return await ensureDefaultSmartFolders(recovered); + // Only ensure the essential Uncategorized fallback exists + return await ensureUncategorizedFolder(recovered); } catch { const documentsDir = app.getPath('documents'); const baseDir = path.join(documentsDir, 'StratoSort'); @@ -302,38 +302,35 @@ async function loadCustomFolders() { normalizedBackup, defaultNameSet ); - return await ensureDefaultSmartFolders(recoveredFromBackup); + return await ensureUncategorizedFolder(recoveredFromBackup); } const recoveredLegacy = await recoverLegacyCustomFolders([], defaultNameSet); if (recoveredLegacy.length > 0) { - return await ensureDefaultSmartFolders(recoveredLegacy); + return await ensureUncategorizedFolder(recoveredLegacy); } - logger.info('[STARTUP] No saved custom folders found, creating default smart folders'); + logger.info('[STARTUP] No saved custom folders found, creating Uncategorized fallback'); - // Create all default smart folders that match the categorization system - const defaultFolders = getDefaultSmartFolders(baseDir); + // First launch: only create the essential Uncategorized folder. + // Users build their own folder structure; full defaults are available via "Reset to Defaults". + const uncategorized = createUncategorizedFolder(baseDir); - // Create physical directories for each folder - for (const folder of defaultFolders) { - try { - await fs.mkdir(folder.path, { recursive: true }); - logger.info(`[STARTUP] Created directory: ${folder.name}`); - } catch (err) { - logger.error(`[STARTUP] Failed to create ${folder.name} directory:`, err); - } + try { + await fs.mkdir(uncategorized.path, { recursive: true }); + logger.info('[STARTUP] Created Uncategorized directory'); + } catch (err) { + logger.error('[STARTUP] Failed to create Uncategorized directory:', err); } - // Save the default folders try { - await saveCustomFolders(defaultFolders); - logger.info('[STARTUP] Saved default smart folders to disk'); + await saveCustomFolders([uncategorized]); + logger.info('[STARTUP] Saved Uncategorized folder to disk'); } catch (err) { - logger.error('[STARTUP] Failed to save default folders:', err); + logger.error('[STARTUP] Failed to save Uncategorized folder:', err); } - return defaultFolders; + return [uncategorized]; } } diff --git a/src/main/services/OramaVectorService.js b/src/main/services/OramaVectorService.js index 836c16ed..b2c8f6b7 100644 --- a/src/main/services/OramaVectorService.js +++ b/src/main/services/OramaVectorService.js @@ -15,8 +15,9 @@ const { create, insert, search, remove, update, count, getByID } = require('@ora const { persist, restore: _restore } = require('@orama/plugin-data-persistence'); const { createLogger } = require('../../shared/logger'); const { createSingletonHelpers } = require('../../shared/singletonFactory'); -const { AI_DEFAULTS } = require('../../shared/constants'); +const { AI_DEFAULTS, IPC_CHANNELS } = require('../../shared/constants'); const { ERROR_CODES } = require('../../shared/errorCodes'); +const { attachErrorCode } = require('../../shared/errorHandlingUtils'); const { replaceFileWithRetry } = require('../../shared/atomicFile'); const { compress, uncompress } = require('../../shared/lz4Codec'); const { resolveEmbeddingDimension } = require('../../shared/embeddingDimensions'); @@ -24,24 +25,13 @@ const { getEmbeddingModel, loadLlamaConfig, getLlamaService } = require('../llam const { writeEmbeddingIndexMetadata } = require('./vectorDb/embeddingIndexMetadata'); const { get: getConfig } = require('../../shared/config/index'); const { SEARCH } = require('../../shared/performanceConstants'); +const { LRUCache } = require('../../shared/LRUCache'); const logger = createLogger('OramaVectorService'); const PERSIST_COMPRESSION_ENABLED = String(process.env.STRATOSORT_ORAMA_COMPRESS || 'true').toLowerCase() !== 'false'; -const attachErrorCode = (error, code) => { - if (error && typeof error === 'object') { - if (!error.code) { - error.code = code; - } - return error; - } - const wrapped = new Error(String(error || 'Unknown error')); - wrapped.code = code; - return wrapped; -}; - const writeFileAtomic = async (filePath, data) => { const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`; try { @@ -180,10 +170,13 @@ class OramaVectorService extends EventEmitter { // Always online (in-process) this.isOnline = true; - // Query cache (simple LRU) - this._queryCache = new Map(); - this._queryCacheMaxSize = 200; - this._queryCacheTtlMs = 120000; + // Query cache (shared LRU implementation) + this._queryCache = new LRUCache({ + maxSize: 200, + ttlMs: 120000, + lruStrategy: 'access', + name: 'OramaQueryCache' + }); // Embedding sidecar store – Orama's restore() loses vector data (v3.1.x bug), // so we cache embeddings separately, keyed by collection name → Map. @@ -265,11 +258,47 @@ class OramaVectorService extends EventEmitter { } _setVectorHealth(update) { + const prevHealthy = this._vectorHealth.primaryHealthy; this._vectorHealth = { ...this._vectorHealth, ...update }; this.emit('vector-health', this._getVectorHealthSnapshot()); + + // Broadcast status to renderer on health transitions + if ('primaryHealthy' in update && update.primaryHealthy !== prevHealthy) { + if (update.primaryHealthy === true) { + this._broadcastStatus('online', 'healthy'); + } else if (update.primaryHealthy === false) { + this._broadcastStatus('error', 'degraded'); + } + } + } + + /** + * Broadcast vectordb:status-changed to all renderer windows. + * Called on key health transitions so the UI can update without polling. + * @param {'online'|'offline'|'error'|'recovering'} status + * @param {string} [health] - Optional richer health descriptor + * @private + */ + _broadcastStatus(status, health) { + try { + const { BrowserWindow } = require('electron'); + const channel = IPC_CHANNELS.VECTOR_DB.STATUS_CHANGED; + const { safeSend } = require('../ipc/ipcWrappers'); + const payload = { status, timestamp: Date.now() }; + if (health) payload.health = health; + + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (win && !win.isDestroyed() && win.webContents) { + safeSend(win.webContents, channel, payload); + } + } + } catch (error) { + logger.debug('[OramaVectorService] Failed to broadcast status:', error?.message); + } } async _getAllFileDocuments() { @@ -534,6 +563,7 @@ class OramaVectorService extends EventEmitter { this._initialized = true; this.isOnline = true; this.emit('online', { reason: 'initialized' }); + this._broadcastStatus('online', 'healthy'); // Get counts for logging const counts = {}; @@ -1364,8 +1394,8 @@ class OramaVectorService extends EventEmitter { embedding: file.vector, hasVector: !(file.vector[0] === 0 && file.vector.every((v) => v === 0)), filePath: file.meta?.path || file.meta?.filePath || '', - fileName: file.meta?.fileName || path.basename(file.meta?.path || ''), - fileType: file.meta?.fileType || file.meta?.mimeType || '', + fileName: file.meta?.fileName || file.meta?.name || path.basename(file.meta?.path || ''), + fileType: file.meta?.fileType || file.meta?.type || file.meta?.mimeType || '', analyzedAt: file.meta?.analyzedAt || new Date().toISOString(), suggestedName: file.meta?.suggestedName || '', keywords: file.meta?.keywords || [], @@ -1528,6 +1558,81 @@ class OramaVectorService extends EventEmitter { } } + /** + * Find files similar to a given file ID within a target directory. + * Useful for pre-move duplicate detection via embedding similarity. + * + * @param {string} fileId - The semantic file ID (e.g., "file:/path/to/file") + * @param {string} targetDirectory - Directory path to scope the search to + * @param {Object} [options] - Options + * @param {number} [options.threshold=0.9] - Minimum cosine similarity (0..1) + * @param {number} [options.topK=5] - Maximum results to return + * @returns {Promise>} + */ + async findSimilarInDirectory(fileId, targetDirectory, options = {}) { + const { threshold = 0.9, topK = 5 } = options; + await this.initialize(); + + if (!fileId || !targetDirectory) return []; + + // Get the source file's embedding + const sourceEmbedding = this._getEmbeddingForFile(fileId); + if (!sourceEmbedding || !Array.isArray(sourceEmbedding) || sourceEmbedding.length === 0) { + return []; + } + + try { + // Query the vector DB for similar files + const results = await this.querySimilarFiles(sourceEmbedding, topK * 3); + + // Normalize directory path for consistent comparison + const normalizedDir = targetDirectory.replace(/\\/g, '/').replace(/\/+$/, ''); + + // Filter to files within the target directory and above threshold + return results + .filter((result) => { + if (result.id === fileId) return false; // Exclude self + const filePath = (result.metadata?.filePath || result.metadata?.path || '').replace( + /\\/g, + '/' + ); + if (!filePath) return false; + // Check if file is within the target directory + return filePath.startsWith(normalizedDir + '/') && result.score >= threshold; + }) + .slice(0, topK) + .map((result) => ({ + id: result.id, + score: result.score, + metadata: result.metadata + })); + } catch (error) { + logger.warn('[OramaVectorService] findSimilarInDirectory failed:', { + fileId, + targetDirectory, + error: error.message + }); + return []; + } + } + + /** + * Get the stored embedding vector for a file ID. + * Checks sidecar store first (workaround for Orama vector loss), then falls back + * to the primary database. + * @private + * @param {string} fileId + * @returns {number[]|null} + */ + _getEmbeddingForFile(fileId) { + // Check sidecar store first (preferred, avoids Orama v3.1.x vector loss bug) + const sidecarEmb = this._embeddingStore?.files?.get(fileId); + if (Array.isArray(sidecarEmb) && sidecarEmb.length > 0) { + return sidecarEmb; + } + return null; + } + /** * Delete a file embedding */ @@ -1643,7 +1748,7 @@ class OramaVectorService extends EventEmitter { const { oldId, newId } = updateSpec || {}; // Backward-compatible meta handling: // Some callers pass { newPath, newName } (legacy), - // others pass { newMeta: { path, name } } (FilePathCoordinator). + // others pass { newMeta: { path, name, ...rest } } (FilePathCoordinator / embeddingSync). const newPath = updateSpec?.newPath || updateSpec?.newMeta?.path || updateSpec?.newMeta?.filePath || null; const newName = @@ -1655,11 +1760,18 @@ class OramaVectorService extends EventEmitter { const existing = await getByID(this._databases.files, oldId); if (!existing) continue; + // Build merged metadata: start with existing, apply all newMeta fields, + // then ensure filePath/fileName are correct. This propagates rich metadata + // (smartFolder, category, tags, summary, etc.) that callers like + // syncEmbeddingForMove provide, instead of silently dropping them. + const metaOverrides = this._buildMetaOverrides(updateSpec?.newMeta, newPath, newName); + // If ID changed, delete old and insert new if (newId && newId !== oldId) { await remove(this._databases.files, oldId); await insert(this._databases.files, { ...existing, + ...metaOverrides, id: newId, filePath: newPath || existing.filePath, fileName: newName || existing.fileName @@ -1674,9 +1786,10 @@ class OramaVectorService extends EventEmitter { embStore.delete(oldId); } } else { - // Just update the path + // Just update the path and metadata in-place await update(this._databases.files, oldId, { ...existing, + ...metaOverrides, filePath: newPath || existing.filePath, fileName: newName || existing.fileName }); @@ -1698,6 +1811,61 @@ class OramaVectorService extends EventEmitter { return updated; } + /** + * Build metadata overrides from newMeta, filtering out internal/id fields + * and only including non-null values that match the document schema. + * @private + * @param {Object|null} newMeta - Metadata from the caller + * @param {string|null} _newPath - Resolved new path (reserved for future use) + * @param {string|null} _newName - Resolved new name (reserved for future use) + * @returns {Object} Fields to merge into the document + */ + _buildMetaOverrides(newMeta, _newPath, _newName) { + if (!newMeta || typeof newMeta !== 'object') { + return {}; + } + + // Allowlisted schema fields that callers may update. + // Excludes 'id', 'embedding', 'hasVector' to prevent accidental overwrites. + const ALLOWED_META_FIELDS = new Set([ + 'fileType', + 'analyzedAt', + 'suggestedName', + 'keywords', + 'tags', + 'isOrphaned', + 'orphanedAt', + 'extractionMethod', + // Extended metadata from embeddingSync / analysis + 'category', + 'confidence', + 'type', + 'summary', + 'date', + 'entity', + 'project', + 'purpose', + 'reasoning', + 'documentType', + 'keyEntities', + 'extractedText', + 'smartFolder', + 'smartFolderPath', + 'content_type', + 'colors', + 'has_text' + ]); + + const overrides = {}; + for (const [key, value] of Object.entries(newMeta)) { + if (!ALLOWED_META_FIELDS.has(key)) continue; + if (value === undefined) continue; + overrides[key] = value; + } + + return overrides; + } + /** * Mark embeddings as orphaned */ @@ -2037,7 +2205,7 @@ class OramaVectorService extends EventEmitter { // Check cache const cacheKey = `folders:${fileId}:${topK}`; - const cached = this._getCachedQuery(cacheKey); + const cached = this._queryCache.get(cacheKey); if (cached) return cached; // Get file embedding — prefer sidecar (reliable) over getByID (may be zero-placeholder) @@ -2053,7 +2221,7 @@ class OramaVectorService extends EventEmitter { } const results = await this.queryFoldersByEmbedding(embedding, topK); - this._setCachedQuery(cacheKey, results); + this._queryCache.set(cacheKey, results); return results; } @@ -2721,7 +2889,7 @@ class OramaVectorService extends EventEmitter { vectorHealth: this._getVectorHealthSnapshot(), queryCache: { size: this._queryCache.size, - maxSize: this._queryCacheMaxSize + maxSize: this._queryCache.maxSize } }; } @@ -2797,48 +2965,22 @@ class OramaVectorService extends EventEmitter { // ==================== QUERY CACHE ==================== - _getCachedQuery(key) { - const entry = this._queryCache.get(key); - if (!entry) return null; - if (Date.now() - entry.timestamp > this._queryCacheTtlMs) { - this._queryCache.delete(key); - return null; - } - // True LRU: re-insert to move to end of Map iteration order - this._queryCache.delete(key); - this._queryCache.set(key, entry); - return entry.data; - } - - _setCachedQuery(key, data) { - // LRU eviction - if (this._queryCache.size >= this._queryCacheMaxSize) { - const firstKey = this._queryCache.keys().next().value; - this._queryCache.delete(firstKey); - } - this._queryCache.set(key, { data, timestamp: Date.now() }); - } - + /** + * Invalidate cache entries related to a specific file. + * Uses delimiter-aware matching to avoid substring collisions. + * Cache keys use the format "folders:{fileId}:{topK}". + */ _invalidateCacheForFile(fileId) { - // FIX: Use delimiter-aware matching to avoid substring collisions. - // Cache keys use the format "folders:{fileId}:{topK}" so checking for - // the fileId bounded by delimiters or at key boundaries prevents - // "abc" from matching "abc123". const delimited = `:${fileId}:`; const suffix = `:${fileId}`; - for (const key of this._queryCache.keys()) { - if (key.includes(delimited) || key.endsWith(suffix) || key === fileId) { - this._queryCache.delete(key); - } - } + this._queryCache.invalidateWhere( + (key) => key.includes(delimited) || key.endsWith(suffix) || key === fileId + ); } + /** Invalidate all folder-related cache entries. */ _invalidateCacheForFolder() { - for (const key of this._queryCache.keys()) { - if (key.startsWith('folders:')) { - this._queryCache.delete(key); - } - } + this._queryCache.invalidateWhere((key) => key.startsWith('folders:')); } _clearQueryCache() { @@ -2850,11 +2992,7 @@ class OramaVectorService extends EventEmitter { } getQueryCacheStats() { - return { - size: this._queryCache.size, - maxSize: this._queryCacheMaxSize, - ttlMs: this._queryCacheTtlMs - }; + return this._queryCache.getStats(); } // ==================== CLEANUP ==================== @@ -2899,6 +3037,7 @@ class OramaVectorService extends EventEmitter { this._isShuttingDown = true; this.isOnline = false; + this._broadcastStatus('offline'); // Clear state this._queryCache.clear(); diff --git a/src/renderer/components/TooltipManager.jsx b/src/renderer/components/TooltipManager.jsx index ab7a8259..ce6bba02 100644 --- a/src/renderer/components/TooltipManager.jsx +++ b/src/renderer/components/TooltipManager.jsx @@ -10,23 +10,128 @@ const TOOLTIP_CONFIG = { DEBOUNCE_DELAY: 300 // Delay before showing tooltip (ms) }; +/** + * Convert a single element's `title` → `data-tooltip`. + * This prevents the browser engine from ever rendering a native tooltip. + */ +function stripNativeTitle(el) { + if (!(el instanceof HTMLElement)) return; + const title = el.getAttribute('title'); + if (!title) return; + // Preserve existing data-tooltip (explicitly set by components) + if (!el.hasAttribute('data-tooltip')) { + el.setAttribute('data-tooltip', title); + } + el.removeAttribute('title'); +} + +/** + * Sweep all elements with a title attribute in a subtree and convert them. + */ +function sweepTitles(root) { + if (root instanceof HTMLElement && root.hasAttribute('title')) { + stripNativeTitle(root); + } + const els = (root.querySelectorAll ? root : document).querySelectorAll('[title]'); + for (let i = 0; i < els.length; i++) { + stripNativeTitle(els[i]); + } +} + /** * TooltipManager - * - Replaces native title tooltips with a unified, GPU-accelerated style - * - Uses event delegation for performance - * - No API change: developers can keep using the title attribute + * - Uses a MutationObserver to proactively strip native title attributes + * from the DOM, preventing the OS-native tooltip from ever appearing. + * - Replaces them with themed, GPU-accelerated custom tooltips. + * - Uses event delegation for performance. + * - No API change: developers keep using the title attribute; it gets + * converted to data-tooltip automatically. */ export default function TooltipManager() { const tooltipRef = useRef(null); const arrowRef = useRef(null); const currentTargetRef = useRef(null); - const titleCacheRef = useRef(new WeakMap()); const rafRef = useRef(0); - // Bug #37: Add debouncing for rapid mouseover events const debounceTimerRef = useRef(null); useEffect(() => { - // Create tooltip container once + // --- 0. Patch title writes to prevent native tooltip races --- + // MutationObserver is async (microtask). To eliminate any race where native + // tooltips might still appear, redirect title writes synchronously. + const originalSetAttribute = Element.prototype.setAttribute; + const originalRemoveAttribute = Element.prototype.removeAttribute; + const titleDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'title'); + + Element.prototype.setAttribute = function patchedSetAttribute(name, value) { + if (this instanceof HTMLElement && String(name).toLowerCase() === 'title') { + const text = value == null ? '' : String(value); + if (text) { + originalSetAttribute.call(this, 'data-tooltip', text); + } else { + originalRemoveAttribute.call(this, 'data-tooltip'); + } + originalRemoveAttribute.call(this, 'title'); + return; + } + return originalSetAttribute.call(this, name, value); + }; + + const canPatchTitleProperty = + Boolean(titleDescriptor?.configurable) && + typeof titleDescriptor?.get === 'function' && + typeof titleDescriptor?.set === 'function'; + + if (canPatchTitleProperty) { + Object.defineProperty(HTMLElement.prototype, 'title', { + configurable: true, + enumerable: titleDescriptor.enumerable, + get() { + return this.getAttribute('data-tooltip') || ''; + }, + set(value) { + const text = value == null ? '' : String(value); + if (text) { + originalSetAttribute.call(this, 'data-tooltip', text); + } else { + originalRemoveAttribute.call(this, 'data-tooltip'); + } + originalRemoveAttribute.call(this, 'title'); + } + }); + } + + // --- 1. Proactively strip all native title attributes --- + // Initial sweep for any titles already in the DOM + sweepTitles(document); + + // Watch for new elements or attribute changes that add title + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + sweepTitles(node); + } + } + } else if ( + mutation.type === 'attributes' && + mutation.attributeName === 'title' && + mutation.target instanceof HTMLElement && + mutation.target.hasAttribute('title') + ) { + stripNativeTitle(mutation.target); + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['title'] + }); + + // --- 2. Create custom tooltip element --- const tooltip = document.createElement('div'); tooltip.className = 'tooltip-enhanced'; tooltip.setAttribute('role', 'tooltip'); @@ -45,10 +150,9 @@ export default function TooltipManager() { tooltipRef.current = tooltip; arrowRef.current = arrow; - // Clean up on window visibility change to prevent dangling references + // Hide tooltip when window is hidden/minimized const handleVisibilityChange = () => { if (document.hidden && currentTargetRef.current) { - // Hide tooltip when window is hidden/minimized if (tooltipRef.current) { tooltipRef.current.classList.remove('show'); tooltipRef.current.style.opacity = '0'; @@ -59,35 +163,18 @@ export default function TooltipManager() { }; document.addEventListener('visibilitychange', handleVisibilityChange); - /** - * Schedule a callback using requestAnimationFrame for smooth updates - * @param {Function} cb - Callback function to execute - */ + // --- 3. Tooltip display helpers --- const schedule = (cb) => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(cb); }; - /** - * Show tooltip for the given target element - * @param {HTMLElement} target - Element to show tooltip for - */ const showTooltip = (target) => { - if (!tooltipRef.current || !titleCacheRef.current) return; - const title = target.getAttribute('data-tooltip') || target.getAttribute('title'); - if (!title) return; - - // Prevent native tooltip by clearing title temporarily - if (target.hasAttribute('title')) { - // FIX 82: Always update cache with current title to prevent stale restoration - if (titleCacheRef.current) { - titleCacheRef.current.set(target, title); - } - target.setAttribute('data-title', title); - target.removeAttribute('title'); - } + if (!tooltipRef.current) return; + const text = target.getAttribute('data-tooltip'); + if (!text) return; - tooltipRef.current.textContent = title; + tooltipRef.current.textContent = text; tooltipRef.current.appendChild(arrowRef.current); tooltipRef.current.classList.add('show'); tooltipRef.current.style.opacity = '1'; @@ -95,43 +182,26 @@ export default function TooltipManager() { positionTooltip(target); }; - /** - * Hide tooltip and restore original title attribute - * @param {HTMLElement} target - Element to hide tooltip for - */ - const hideTooltip = (target) => { + const hideTooltip = () => { if (!tooltipRef.current) return; tooltipRef.current.classList.remove('show'); tooltipRef.current.style.opacity = '0'; tooltipRef.current.style.transform = 'translate3d(-10000px, -10000px, 0)'; - - // Restore native title - add null check before calling .get() - if (titleCacheRef.current) { - const cached = titleCacheRef.current.get(target); - if (cached && !target.getAttribute('title')) { - target.setAttribute('title', cached); - } - } - target.removeAttribute('data-title'); }; - /** - * Calculate and apply optimal tooltip position relative to target - * @param {HTMLElement} target - Element to position tooltip relative to - */ const positionTooltip = (target) => { schedule(() => { if (!tooltipRef.current || !arrowRef.current) return; const rect = target.getBoundingClientRect(); - const tooltip = tooltipRef.current; - const arrow = arrowRef.current; + const tp = tooltipRef.current; + const ar = arrowRef.current; - // Measure tooltip size by placing it off-screen first - tooltip.style.top = '0px'; - tooltip.style.left = '0px'; - tooltip.style.transform = 'translate3d(-10000px, -10000px, 0)'; + // Measure tooltip size off-screen first + tp.style.top = '0px'; + tp.style.left = '0px'; + tp.style.transform = 'translate3d(-10000px, -10000px, 0)'; - const { width: tw, height: th } = tooltip.getBoundingClientRect(); + const { width: tw, height: th } = tp.getBoundingClientRect(); const margin = TOOLTIP_CONFIG.TARGET_MARGIN; let top = rect.top - th - margin; @@ -150,29 +220,24 @@ export default function TooltipManager() { if (left + tw > vw - TOOLTIP_CONFIG.VIEWPORT_PADDING) left = vw - TOOLTIP_CONFIG.VIEWPORT_PADDING - tw; - tooltip.style.left = `${Math.round(left)}px`; - tooltip.style.top = `${Math.round(top)}px`; - tooltip.style.transform = 'translate3d(0, 0, 0)'; + tp.style.left = `${Math.round(left)}px`; + tp.style.top = `${Math.round(top)}px`; + tp.style.transform = 'translate3d(0, 0, 0)'; // Arrow positioning const arrowSize = TOOLTIP_CONFIG.ARROW_SIZE; const arrowOffset = rect.left + rect.width / 2 - left - arrowSize / 2; - arrow.style.left = `${Math.max(arrowSize, Math.min(tw - arrowSize * 2, arrowOffset))}px`; - if (placement === 'top') { - arrow.style.top = `${th - arrowSize / 2}px`; - } else { - arrow.style.top = `-${arrowSize / 2}px`; - } + ar.style.left = `${Math.max(arrowSize, Math.min(tw - arrowSize * 2, arrowOffset))}px`; + ar.style.top = placement === 'top' ? `${th - arrowSize / 2}px` : `-${arrowSize / 2}px`; }); }; + // --- 4. Event delegation --- const delegatedMouseOver = (e) => { - // Check if refs are still valid before processing events - if (!tooltipRef.current || !titleCacheRef.current) return; - const target = e.target.closest('[title], [data-tooltip]'); + if (!tooltipRef.current) return; + const target = e.target.closest('[data-tooltip]'); if (!target || !(target instanceof HTMLElement)) return; - // Bug #37: Debounce rapid mouseover events (300ms delay) if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } @@ -184,10 +249,8 @@ export default function TooltipManager() { }; const delegatedMouseOut = (e) => { - // Check if refs are still valid before processing events - if (!tooltipRef.current || !titleCacheRef.current) return; + if (!tooltipRef.current) return; - // Bug #37: Clear debounce timer on mouseout if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; @@ -195,27 +258,24 @@ export default function TooltipManager() { const target = currentTargetRef.current; if (!target) return; - // Only hide when leaving the element completely if (!target.contains(e.relatedTarget)) { - hideTooltip(target); + hideTooltip(); currentTargetRef.current = null; } }; const delegatedFocus = (e) => { - // Check if refs are still valid before processing events - if (!tooltipRef.current || !titleCacheRef.current) return; - const target = e.target.closest('[title], [data-tooltip]'); + if (!tooltipRef.current) return; + const target = e.target.closest('[data-tooltip]'); if (!target || !(target instanceof HTMLElement)) return; currentTargetRef.current = target; showTooltip(target); }; const delegatedBlur = () => { - // Check if refs are still valid before processing events - if (!tooltipRef.current || !titleCacheRef.current) return; + if (!tooltipRef.current) return; if (currentTargetRef.current) { - hideTooltip(currentTargetRef.current); + hideTooltip(); currentTargetRef.current = null; } }; @@ -224,15 +284,22 @@ export default function TooltipManager() { document.addEventListener('mouseout', delegatedMouseOut, true); document.addEventListener('focusin', delegatedFocus); document.addEventListener('focusout', delegatedBlur); - // Keep tooltip anchored on scroll/resize + const handleViewportChange = () => { if (currentTargetRef.current) positionTooltip(currentTargetRef.current); }; window.addEventListener('scroll', handleViewportChange, true); window.addEventListener('resize', handleViewportChange); + // --- 5. Cleanup --- return () => { - // Remove event listeners first to prevent any new events during cleanup + observer.disconnect(); + + Element.prototype.setAttribute = originalSetAttribute; + if (canPatchTitleProperty && titleDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'title', titleDescriptor); + } + document.removeEventListener('mouseover', delegatedMouseOver, true); document.removeEventListener('mouseout', delegatedMouseOut, true); document.removeEventListener('focusin', delegatedFocus); @@ -241,33 +308,26 @@ export default function TooltipManager() { window.removeEventListener('scroll', handleViewportChange, true); window.removeEventListener('resize', handleViewportChange); - // Bug #37: Clear debounce timer on cleanup if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } - // Cancel any pending animations cancelAnimationFrame(rafRef.current); rafRef.current = 0; - // Hide tooltip if it's currently showing - if (currentTargetRef.current && titleCacheRef.current) { - hideTooltip(currentTargetRef.current); + if (currentTargetRef.current) { + hideTooltip(); } - // Clear refs in the correct order currentTargetRef.current = null; - // Remove DOM element before clearing refs that might be accessed if (tooltipRef.current && tooltipRef.current.parentNode) { tooltipRef.current.parentNode.removeChild(tooltipRef.current); } - // Clear remaining refs last tooltipRef.current = null; arrowRef.current = null; - titleCacheRef.current = null; }; }, []); diff --git a/src/renderer/components/search/BaseEdgeTooltip.jsx b/src/renderer/components/search/BaseEdgeTooltip.jsx index a541df36..fe3d69fc 100644 --- a/src/renderer/components/search/BaseEdgeTooltip.jsx +++ b/src/renderer/components/search/BaseEdgeTooltip.jsx @@ -50,7 +50,12 @@ const BaseEdgeTooltip = memo( className="absolute left-1/2 -translate-x-1/2 mt-2 z-50" style={{ minWidth: '200px', maxWidth: '280px' }} > -
+
{/* Header */}
+
)} diff --git a/src/renderer/components/search/ChatPanel.jsx b/src/renderer/components/search/ChatPanel.jsx index 5b5b79e6..19f4a68b 100644 --- a/src/renderer/components/search/ChatPanel.jsx +++ b/src/renderer/components/search/ChatPanel.jsx @@ -28,19 +28,6 @@ function normalizeImageSource(value) { return trimmed; } -function formatLocation(source = {}) { - const parts = []; - const page = source.page || source.pageNumber; - const line = source.line || source.lineNumber; - const offset = source.offset || source.charOffset; - const section = source.section || source.heading; - if (page) parts.push(`Page ${page}`); - if (section) parts.push(`Section ${section}`); - if (line) parts.push(`Line ${line}`); - if (offset) parts.push(`Offset ${offset}`); - return parts.join(' • '); -} - function buildAssistantText(message) { if (!message || message.role !== 'assistant') return ''; if (typeof message.text === 'string' && message.text.trim().length > 0) { @@ -117,7 +104,7 @@ function SourceList({ sources, onOpenSource }) { tone="neutral" size="sm" align="left" - title="No sources found for this response." + title="No matching documents found." className="mt-3 px-3 py-3 rounded-lg border border-system-gray-200 bg-white" /> ); @@ -134,44 +121,60 @@ function SourceList({ sources, onOpenSource }) { return []; }; + /** Build a concise one-liner describing what the source is about. */ + const buildContext = (source) => { + const parts = []; + if (source.category) parts.push(source.category); + if (source.documentType && source.documentType !== source.category) + parts.push(source.documentType); + if (source.project) parts.push(source.project); + if (source.entity) parts.push(source.entity); + if (source.documentDate) parts.push(source.documentDate); + return parts.join(' · '); + }; + return (
- - Sources -
{sources.map((source) => { - const tags = normalizeList(source.tags).slice(0, 3); - const entities = normalizeList(source.entities).slice(0, 3); - const dates = normalizeList(source.dates).slice(0, 2); - const matchSources = normalizeList(source.matchDetails?.sources).slice(0, 3); - const score = - typeof source.score === 'number' ? `${Math.round(source.score * 100)}%` : ''; - const location = formatLocation(source); + const tags = normalizeList(source.tags).slice(0, 5); + // Prefer semanticScore (raw cosine similarity) over the inflated + // fused score. semanticScore reflects actual embedding relevance. + const rawSemantic = + typeof source.semanticScore === 'number' ? source.semanticScore : source.score; + const scorePct = typeof rawSemantic === 'number' ? Math.round(rawSemantic * 100) : 0; const imageSrc = normalizeImageSource( source.previewImage || source.imagePath || source.thumbnail || source.image ); + const context = buildContext(source); return ( -
- - {source.id} - -
-
+
+ {/* Semantic relevance indicator (raw cosine similarity) */} +
+
= 70 + ? 'text-stratosort-success' + : scorePct >= 50 + ? 'text-stratosort-blue' + : 'text-system-gray-400' + }`} + > + {scorePct}% +
+
+
+
{source.name || source.fileId}
- {location ? ( - - {location} + {context ? ( + + {context} ) : null} {source.snippet ? ( - + {source.snippet} ) : null} @@ -186,26 +189,17 @@ function SourceList({ sources, onOpenSource }) {
) : null} {tags.length > 0 ? ( - - Tags: {tags.join(', ')} - - ) : null} - {entities.length > 0 ? ( - - Entities: {entities.join(', ')} - - ) : null} - {dates.length > 0 ? ( - - Dates: {dates.join(', ')} - +
+ {tags.map((tag) => ( + + {tag} + + ))} +
) : null} - {(matchSources.length > 0 || score) && ( - - Why: {matchSources.length > 0 ? matchSources.join(' + ') : 'matched'}{' '} - {score ? `(${score})` : ''} - - )}
{source.path ? ( @@ -408,8 +403,8 @@ export default function ChatPanel({
{messages.length === 0 ? ( - Ask about your documents. Responses will separate document evidence from model - knowledge. + Ask me anything about your documents — search by meaning, summarize, or explore + connections. ) : null} @@ -418,8 +413,6 @@ export default function ChatPanel({ const assistantText = buildAssistantText(message); const hasDocumentAnswer = Array.isArray(message.documentAnswer) && message.documentAnswer.length > 0; - const hasModelAnswer = - Array.isArray(message.modelAnswer) && message.modelAnswer.length > 0; const hasSources = Array.isArray(message.sources) && message.sources.length > 0; return ( @@ -443,40 +436,34 @@ export default function ChatPanel({ ? 'Thinking...' : 'I could not find an answer in the selected documents.')}
- {(hasDocumentAnswer || hasModelAnswer) && ( -
- {hasDocumentAnswer ? ( -
- - Evidence from documents ({message.documentAnswer.length}) - -
- -
-
- ) : null} - {hasModelAnswer ? ( -
- Model knowledge ({message.modelAnswer.length}) -
- -
-
- ) : null} -
- )} + {/* Only show the citations detail panel when document answers + actually have citation links — otherwise the collapsible sections + just duplicate the main text and add visual noise. */} + {hasDocumentAnswer && + message.documentAnswer.some( + (item) => item.citations && item.citations.length > 0 + ) && ( +
+ + Cited sources ( + { + new Set( + message.documentAnswer.flatMap((item) => item.citations || []) + ).size + } + ) + +
+ +
+
+ )} {Array.isArray(message.followUps) && message.followUps.length > 0 ? (
@@ -485,7 +472,7 @@ export default function ChatPanel({ variant="tiny" className="font-semibold text-system-gray-500 uppercase tracking-wide" > - Suggested follow-ups + Try next {idx === messages.length - 1 && (
); -} +}); ResultRow.propTypes = { result: PropTypes.object.isRequired, @@ -445,7 +512,146 @@ ResultRow.propTypes = { }; ResultRow.displayName = 'ResultRow'; -function StatsDisplay({ stats, isLoadingStats, onRefresh }) { +/** + * Virtualized search results list - prevents UI blocking with large result sets. + * Uses react-window to render only visible rows. + */ +const VirtualizedSearchResultRow = memo(function VirtualizedSearchResultRow({ + index, + style, + results = [], + selectedSearchId, + bulkSelectedIds = new Set(), + focusedResultIndex, + query = '', + onSelect, + onToggleBulk, + onOpen, + onReveal, + onCopyPath +}) { + const result = results?.[index]; + if (!result) return null; + return ( +
+ +
+ ); +}); + +VirtualizedSearchResultRow.propTypes = { + index: PropTypes.number.isRequired, + style: PropTypes.object.isRequired, + results: PropTypes.array.isRequired, + selectedSearchId: PropTypes.string, + bulkSelectedIds: PropTypes.instanceOf(Set), + focusedResultIndex: PropTypes.number.isRequired, + query: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, + onToggleBulk: PropTypes.func.isRequired, + onOpen: PropTypes.func.isRequired, + onReveal: PropTypes.func.isRequired, + onCopyPath: PropTypes.func.isRequired +}; + +function VirtualizedSearchResults({ + results, + selectedSearchId, + bulkSelectedIds, + focusedResultIndex, + query, + onSelect, + onToggleBulk, + onOpen, + onReveal, + onCopyPath +}) { + const containerRef = useRef(null); + const [height, setHeight] = useState(400); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const h = entry.contentRect.height; + setHeight(Math.max(200, h || 400)); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const rowProps = useMemo( + () => ({ + results, + selectedSearchId, + bulkSelectedIds, + focusedResultIndex, + query, + onSelect, + onToggleBulk, + onOpen, + onReveal, + onCopyPath + }), + [ + results, + selectedSearchId, + bulkSelectedIds, + focusedResultIndex, + query, + onSelect, + onToggleBulk, + onOpen, + onReveal, + onCopyPath + ] + ); + + return ( +
+ +
+ ); +} + +VirtualizedSearchResults.propTypes = { + results: PropTypes.array.isRequired, + selectedSearchId: PropTypes.string, + bulkSelectedIds: PropTypes.instanceOf(Set).isRequired, + focusedResultIndex: PropTypes.number.isRequired, + query: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, + onToggleBulk: PropTypes.func.isRequired, + onOpen: PropTypes.func.isRequired, + onReveal: PropTypes.func.isRequired, + onCopyPath: PropTypes.func.isRequired +}; + +function StatsDisplay({ stats, isLoadingStats, statsUnavailable, onRefresh }) { return (
{stats ? ( @@ -465,6 +671,11 @@ function StatsDisplay({ stats, isLoadingStats, onRefresh }) { Loading index... + ) : statsUnavailable ? ( + + + Initializing... + ) : ( No embeddings @@ -487,6 +698,7 @@ function StatsDisplay({ stats, isLoadingStats, onRefresh }) { StatsDisplay.propTypes = { stats: PropTypes.object, isLoadingStats: PropTypes.bool.isRequired, + statsUnavailable: PropTypes.bool, onRefresh: PropTypes.func.isRequired }; StatsDisplay.displayName = 'StatsDisplay'; @@ -647,6 +859,12 @@ export default function UnifiedSearchModal({ defaultTopK = 20, initialTab = 'search' }) { + // Memoize React Flow type maps to guarantee stable references per component + // instance. Module-level constants can break under HMR re-evaluation. + // See https://reactflow.dev/error#002 + const stableNodeTypes = useMemo(() => NODE_TYPES, []); + const stableEdgeTypes = useMemo(() => EDGE_TYPES, []); + const redactPaths = useAppSelector((state) => Boolean(state?.system?.redactPaths)); // Tab state // Graph is currently feature-flagged off. If callers pass initialTab="graph", @@ -684,6 +902,7 @@ export default function UnifiedSearchModal({ const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [stats, setStats] = useState(null); + const [statsUnavailable, setStatsUnavailable] = useState(false); const [isLoadingStats, setIsLoadingStats] = useState(false); const [hasLoadedStats, setHasLoadedStats] = useState(false); const [embeddingConfig, setEmbeddingConfig] = useState(null); @@ -925,7 +1144,6 @@ export default function UnifiedSearchModal({ zoomBucketRef.current = zoomLevel < ZOOM_LABEL_HIDE_THRESHOLD; }, [zoomLevel]); const [hoveredClusterId, setHoveredClusterId] = useState(null); - const [hasHoveredCluster, setHasHoveredCluster] = useState(false); // Focus mode state - for local graph view const [focusNodeId, setFocusNodeId] = useState(null); @@ -981,6 +1199,11 @@ export default function UnifiedSearchModal({ const guideIntentLoadTimeoutRef = useRef(null); const statsRequestCounterRef = useRef(0); const freshMetadataRequestCounterRef = useRef(0); + const recommendationRequestCounterRef = useRef(0); + const graphSearchRequestCounterRef = useRef(0); + const graphDropRequestCounterRef = useRef(0); + const chatRequestCounterRef = useRef(0); + const chatInFlightRef = useRef(false); const resultListRef = useRef(null); const graphContainerRef = useRef(null); const wasOpenRef = useRef(false); @@ -1047,10 +1270,13 @@ export default function UnifiedSearchModal({ }, []); const hydrateRecommendationMap = useCallback(async () => { + const requestId = nextRequestId(recommendationRequestCounterRef); const files = buildSuggestionFiles(searchResults); const getBatchSuggestions = window?.electronAPI?.suggestions?.getBatchSuggestions; if (!files.length || typeof getBatchSuggestions !== 'function') { - if (isMountedRef.current) setRecommendationMap({}); + if (isMountedRef.current && isCurrentRequest(recommendationRequestCounterRef, requestId)) { + setRecommendationMap({}); + } return; } @@ -1065,11 +1291,16 @@ export default function UnifiedSearchModal({ }); } - if (isMountedRef.current) setIsLoadingRecommendations(true); + if (isMountedRef.current && isCurrentRequest(recommendationRequestCounterRef, requestId)) { + setIsLoadingRecommendations(true); + } try { const response = await getBatchSuggestions(filesToProcess); + if (!isMountedRef.current || !isCurrentRequest(recommendationRequestCounterRef, requestId)) { + return; + } if (!response?.success || !Array.isArray(response.groups)) { - if (isMountedRef.current) setRecommendationMap({}); + setRecommendationMap({}); return; } @@ -1084,12 +1315,16 @@ export default function UnifiedSearchModal({ }); }); - if (isMountedRef.current) setRecommendationMap(nextMap); + setRecommendationMap(nextMap); } catch (recErr) { logger.debug('[Search] Recommendation lookup failed:', recErr?.message || recErr); - if (isMountedRef.current) setRecommendationMap({}); + if (isMountedRef.current && isCurrentRequest(recommendationRequestCounterRef, requestId)) { + setRecommendationMap({}); + } } finally { - if (isMountedRef.current) setIsLoadingRecommendations(false); + if (isMountedRef.current && isCurrentRequest(recommendationRequestCounterRef, requestId)) { + setIsLoadingRecommendations(false); + } } }, [buildSuggestionFiles, searchResults]); @@ -1109,6 +1344,13 @@ export default function UnifiedSearchModal({ async (text, overrideContextIds = null) => { const trimmed = typeof text === 'string' ? text.trim() : ''; if (!trimmed) return; + if (chatInFlightRef.current) { + setChatWarning('Please wait for the current response to finish.'); + return; + } + + const requestId = nextRequestId(chatRequestCounterRef); + chatInFlightRef.current = true; setChatError(''); setChatWarning(''); @@ -1155,6 +1397,7 @@ export default function UnifiedSearchModal({ contextFileIds, responseMode }); + if (!isCurrentRequest(chatRequestCounterRef, requestId)) return; if (!response || response.success !== true) { throw new Error(response?.error || 'Chat request failed'); @@ -1177,12 +1420,16 @@ export default function UnifiedSearchModal({ setChatMessages((prev) => [...prev, assistantMessage]); } catch (chatErr) { + if (!isCurrentRequest(chatRequestCounterRef, requestId)) return; logger.warn('[KnowledgeOS] Chat query failed', { error: chatErr?.message || chatErr }); const { message } = mapErrorToNotification({ error: chatErr?.message || chatErr }); setChatError(message); setChatWarning(''); } finally { - setIsChatting(false); + if (isCurrentRequest(chatRequestCounterRef, requestId)) { + setIsChatting(false); + chatInFlightRef.current = false; + } } }, [ @@ -1207,6 +1454,8 @@ export default function UnifiedSearchModal({ }, []); const handleChatReset = useCallback(async () => { + invalidateRequests(chatRequestCounterRef); + chatInFlightRef.current = false; setChatMessages([]); setChatError(''); setChatWarning(''); @@ -1369,6 +1618,7 @@ export default function UnifiedSearchModal({ setDebouncedQuery(''); setError(''); setStats(null); + setStatsUnavailable(false); setHasLoadedStats(false); setIsLoadingStats(false); setEmbeddingConfig(null); @@ -1406,7 +1656,6 @@ export default function UnifiedSearchModal({ setAutoLayout(true); setIsLayouting(false); setHoveredClusterId(null); - setHasHoveredCluster(false); setPerformanceNotice(''); hasAutoDisabledEdgeLabels.current = false; hasAutoDisabledTooltips.current = false; @@ -1972,13 +2221,22 @@ export default function UnifiedSearchModal({ folders: typeof res.folders === 'number' ? res.folders : 0, initialized: Boolean(res.initialized) }); + setStatsUnavailable(false); } else if (isMountedRef.current && statsRequestCounterRef.current === requestId) { setStats(null); + setStatsUnavailable( + Boolean( + res?.unavailable || + res?.code === 'VECTOR_DB_UNAVAILABLE' || + res?.code === 'VECTOR_DB_PENDING' + ) + ); } } catch (e) { logger.warn('Failed to load embedding stats', { error: e?.message }); if (isMountedRef.current && statsRequestCounterRef.current === requestId) { setStats(null); + setStatsUnavailable(false); setEmbeddingConfig(null); } } finally { @@ -2069,6 +2327,11 @@ export default function UnifiedSearchModal({ }); }, []); + const handleResultSelect = useCallback((result, index) => { + setSelectedSearchId(result?.id ?? null); + setFocusedResultIndex(index); + }, []); + const clearBulkSelection = useCallback(() => { setBulkSelectedIds(new Set()); }, []); @@ -2099,7 +2362,7 @@ export default function UnifiedSearchModal({ const fileName = safeBasename(sourcePath); try { await window.electronAPI?.files?.performOperation?.({ - operation: 'move', + type: 'move', source: sourcePath, destination: joinPath(destFolder, fileName) }); @@ -2214,7 +2477,7 @@ export default function UnifiedSearchModal({ const fileName = safeBasename(sourcePath); await window.electronAPI?.files?.performOperation?.({ - operation: 'move', + type: 'move', source: sourcePath, destination: joinPath(destFolder, fileName) }); @@ -2438,7 +2701,16 @@ export default function UnifiedSearchModal({ setGraphStatus('Focus cleared - showing all nodes'); }, []); - const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); + // Ref-based node lookup: always holds the latest nodes map but does NOT + // trigger downstream useMemo chains when only node positions change + // (layout adjustments). Consumers that only need label/kind read from the + // ref, keeping their dependency arrays small and stable. + const nodeByIdRef = useRef(new Map()); + useMemo(() => { + const map = new Map(nodes.map((n) => [n.id, n])); + nodeByIdRef.current = map; + return map; + }, [nodes]); const adjacencyByNode = useMemo(() => { const map = new Map(); edges.forEach((edge) => { @@ -2475,7 +2747,7 @@ export default function UnifiedSearchModal({ } // Also include cluster members if this is a cluster node - const node = nodeById.get(id); + const node = nodeByIdRef.current.get(id); if (node?.data?.memberIds) { node.data.memberIds.forEach((memberId) => { if (!visible.has(memberId)) { @@ -2490,7 +2762,7 @@ export default function UnifiedSearchModal({ return visible; }, - [adjacencyByNode, nodeById] + [adjacencyByNode] ); /** @@ -2975,6 +3247,8 @@ export default function UnifiedSearchModal({ setSearchResults(next); setSelectedSearchId(next[0]?.id || null); setBulkSelectedIds(new Set()); // Clear bulk selection on new results + // Add to recent searches when search completes (type-to-search flow) + if (q && q.trim().length >= 2) addToRecentSearches(q.trim()); // Store query processing metadata for "Did you mean?" feedback setQueryMeta(response.queryMeta || null); // Store search mode metadata (fallback detection) @@ -3216,6 +3490,9 @@ export default function UnifiedSearchModal({ setIsDragOver(false); if (!e.dataTransfer) return; + const requestId = nextRequestId(graphDropRequestCounterRef); + const isStaleRequest = () => + !isCurrentRequest(graphDropRequestCounterRef, requestId) || !isMountedRef.current; const { paths: droppedPaths } = extractDroppedFiles(e.dataTransfer); if (droppedPaths.length === 0) return; @@ -3225,10 +3502,12 @@ export default function UnifiedSearchModal({ ); try { + const currentNodes = nodesRef.current || []; const addedNodes = []; const addedEdges = []; for (const filePath of droppedPaths) { + if (isStaleRequest()) return; if (!filePath) continue; // Search for this file by name to find it in the vector DB @@ -3237,6 +3516,7 @@ export default function UnifiedSearchModal({ topK: 20, mode: 'hybrid' }); + if (isStaleRequest()) return; if (!searchResp?.success || !searchResp.results?.length) { logger.debug('[Graph] File not found in index:', fileName); @@ -3254,20 +3534,21 @@ export default function UnifiedSearchModal({ } // Calculate position for the new node - const existingNodeCount = nodes.length + addedNodes.length; + const existingNodeCount = currentNodes.length + addedNodes.length; const pos = defaultNodePosition(existingNodeCount); // Create node using upsertFileNode const node = upsertFileNode(matchingResult, pos); if (node) { // Check if node already exists - const existingNode = nodes.find((n) => n.id === node.id); + const existingNode = currentNodes.find((n) => n.id === node.id); if (!existingNode) { addedNodes.push(node); } } } + if (isStaleRequest()) return; if (addedNodes.length === 0) { setError('Dropped files not found in index. Try analyzing them first.'); setGraphStatus(''); @@ -3278,14 +3559,16 @@ export default function UnifiedSearchModal({ graphActions.setNodes((prev) => [...prev, ...addedNodes]); // If we have existing nodes, try to find similarity connections - if (nodes.length > 0 && addedNodes.length > 0) { + if (currentNodes.length > 0 && addedNodes.length > 0) { try { - const existingIds = nodes.map((n) => n.id); + const existingIds = currentNodes.map((n) => n.id); const newIds = addedNodes.map((n) => n.id); // Get similarity edges between new and existing nodes for (const newId of newIds) { + if (isStaleRequest()) return; const simResp = await window.electronAPI?.embeddings?.findSimilar?.(newId, 5); + if (isStaleRequest()) return; if (simResp?.success && simResp.results) { for (const sim of simResp.results) { if (existingIds.includes(sim.id) && sim.score > 0.5) { @@ -3322,8 +3605,9 @@ export default function UnifiedSearchModal({ // Apply layout if auto-layout is enabled if (autoLayout && addedNodes.length > 0) { - const allNodes = [...nodes, ...addedNodes]; - const allEdges = [...edges, ...addedEdges]; + const currentEdges = edgesRef.current || []; + const allNodes = [...currentNodes, ...addedNodes]; + const allEdges = [...currentEdges, ...addedEdges]; try { const { nodes: layoutedNodes, edges: layoutedEdges } = await debouncedElkLayout( allNodes, @@ -3334,6 +3618,7 @@ export default function UnifiedSearchModal({ layerSpacing: GRAPH_LAYER_SPACING } ); + if (isStaleRequest()) return; graphActions.setNodes(layoutedNodes); if (layoutedEdges && layoutedEdges.length > 0) { graphActions.setEdges(applyEdgeUiPrefs(layoutedEdges)); @@ -3347,12 +3632,13 @@ export default function UnifiedSearchModal({ `Added ${addedNodes.length} file${addedNodes.length > 1 ? 's' : ''} to graph` ); } catch (err) { + if (isStaleRequest()) return; logger.error('[Graph] File drop failed:', err); setError('Failed to add files to graph'); setGraphStatus(''); } }, - [nodes, edges, graphActions, upsertFileNode, autoLayout, applyEdgeUiPrefs] + [graphActions, upsertFileNode, autoLayout, applyEdgeUiPrefs] ); /** @@ -3656,14 +3942,14 @@ export default function UnifiedSearchModal({ } if (clustersResp.stale === true) { + // computeClusters now returns clusters + crossClusterEdges directly, + // with fast metadata-based labels. LLM refinement runs in the background. const computeResp = await window.electronAPI?.embeddings?.computeClusters?.('auto'); if (!computeResp || computeResp.success !== true) { throw new Error(computeResp?.error || 'Failed to compute clusters'); } - clustersResp = await window.electronAPI?.embeddings?.getClusters?.(); - if (!clustersResp || clustersResp.success !== true) { - throw new Error(clustersResp?.error || 'Failed to get clusters'); - } + // Use the response directly — no redundant second getClusters call needed. + clustersResp = computeResp; } const clusters = clustersResp.clusters || []; @@ -3681,6 +3967,29 @@ export default function UnifiedSearchModal({ lastCrossClusterEdgesRef.current = crossClusterEdges; renderClustersToGraph(clusters, crossClusterEdges); + + // Background: After a short delay, fetch refreshed labels (LLM may have + // finished in the background by now) and silently update the graph. + setTimeout(async () => { + try { + if (!isMountedRef.current) return; + const refreshed = await window.electronAPI?.embeddings?.getClusters?.(); + if ( + refreshed?.success && + Array.isArray(refreshed.clusters) && + refreshed.clusters.length > 0 + ) { + lastClustersRef.current = refreshed.clusters; + lastCrossClusterEdgesRef.current = refreshed.crossClusterEdges || crossClusterEdges; + renderClustersToGraph( + refreshed.clusters, + refreshed.crossClusterEdges || crossClusterEdges + ); + } + } catch { + // Label refresh is best-effort; failures are non-critical. + } + }, 6000); } catch (e) { setError(getErrorMessage(e, 'Cluster loading')); setGraphStatus(''); @@ -3908,6 +4217,16 @@ export default function UnifiedSearchModal({ return [...prev, ...newEdges, ...newOrganizeEdges]; }); + // Smooth viewport animation to show expanded members + setTimeout(() => { + const expandedNodeIds = [clusterId, ...memberIds]; + reactFlowInstance.current?.fitView({ + nodes: expandedNodeIds.map((id) => ({ id })), + padding: 0.25, + duration: 500 + }); + }, 100); + setGraphStatus( `${layoutedMemberNodes.length} related files. Right-click cluster to organize them.` ); @@ -3936,6 +4255,9 @@ export default function UnifiedSearchModal({ // Capture addMode at search start to prevent race condition if user toggles during async operation const shouldAddMode = addMode; + const requestId = nextRequestId(graphSearchRequestCounterRef); + const isStaleSearch = () => + !isCurrentRequest(graphSearchRequestCounterRef, requestId) || !isMountedRef.current; setError(''); setGraphStatus('Searching...'); @@ -3948,6 +4270,7 @@ export default function UnifiedSearchModal({ mode: 'hybrid', rerank: false }); + if (isStaleSearch()) return; if (!resp || resp.success !== true) { throw new Error(resp?.error || 'Search failed'); } @@ -4006,9 +4329,11 @@ export default function UnifiedSearchModal({ if (nextNodes.length > MAX_GRAPH_NODES) { finalNodes = nextNodes.slice(0, MAX_GRAPH_NODES); nodeLimitReached = true; - setError( - `Graph limit (${MAX_GRAPH_NODES} nodes) exceeded. Showing first ${MAX_GRAPH_NODES} results.` - ); + if (!isStaleSearch()) { + setError( + `Graph limit (${MAX_GRAPH_NODES} nodes) exceeded. Showing first ${MAX_GRAPH_NODES} results.` + ); + } } else { finalNodes = nextNodes; } @@ -4041,7 +4366,11 @@ export default function UnifiedSearchModal({ // Don't add more nodes if limit reached finalNodes = nodesWithoutClusters; nodeLimitReached = true; - setError(`Graph limit (${MAX_GRAPH_NODES} nodes) reached. Clear graph to start fresh.`); + if (!isStaleSearch()) { + setError( + `Graph limit (${MAX_GRAPH_NODES} nodes) reached. Clear graph to start fresh.` + ); + } } else { finalNodes = merged; } @@ -4059,6 +4388,7 @@ export default function UnifiedSearchModal({ (e) => finalNodeIds.has(e.source) && finalNodeIds.has(e.target) ); + if (isStaleSearch()) return; // Now update state with computed values (only if there are changes) graphActions.setNodes((prev) => { // Skip update if nothing changed (add mode and limit reached) @@ -4102,6 +4432,7 @@ export default function UnifiedSearchModal({ layerSpacing: GRAPH_LAYER_SPACING, progressive: true }); + if (isStaleSearch()) return; layoutedNodes = result.nodes; if (result.edges && result.edges.length > 0) { graphActions.setEdges(applyEdgeUiPrefs(result.edges)); @@ -4118,11 +4449,13 @@ export default function UnifiedSearchModal({ layerSpacing: GRAPH_LAYER_SPACING, debounceMs: 150 }); + if (isStaleSearch()) return; layoutedNodes = layoutResult.nodes; if (layoutResult.edges && layoutResult.edges.length > 0) { graphActions.setEdges(applyEdgeUiPrefs(layoutResult.edges)); } } + if (isStaleSearch()) return; graphActions.setNodes(layoutedNodes); if (finalNodes.length <= LARGE_GRAPH_THRESHOLD) { setGraphStatus( @@ -4154,6 +4487,7 @@ export default function UnifiedSearchModal({ expandedFileIds, { threshold: 0.75, maxEdgesPerNode: 1 } ); + if (isStaleSearch()) return; if (simEdgesResp?.success && Array.isArray(simEdgesResp.edges)) { const similarityEdges = simEdgesResp.edges @@ -4195,6 +4529,7 @@ export default function UnifiedSearchModal({ expandedFileIds, { minWeight: 2, maxEdges: 200 } ); + if (isStaleSearch()) return; if (relEdgesResp?.success && Array.isArray(relEdgesResp.edges)) { const relationshipEdges = relEdgesResp.edges.map((edge) => ({ @@ -4227,6 +4562,7 @@ export default function UnifiedSearchModal({ } } } catch (e) { + if (isStaleSearch()) return; setGraphStatus(''); setError(getErrorMessage(e, 'Graph search')); } @@ -4710,14 +5046,12 @@ export default function UnifiedSearchModal({ if (node.type !== 'clusterNode') return; setHoveredClusterId((prev) => (prev === node.id ? prev : node.id)); - setHasHoveredCluster(true); }, []); const onNodeMouseLeave = useCallback((_, node) => { if (node.type !== 'clusterNode') return; setHoveredClusterId((prev) => (prev === node.id ? null : prev)); - setHasHoveredCluster(false); }, []); const handleGraphMove = useCallback((_, viewport) => { @@ -4800,7 +5134,7 @@ export default function UnifiedSearchModal({ ...edge, style: { ...edge.style, - opacity: isBridge ? 0.9 : 0.05 + opacity: isBridge ? 0.9 : 0.15 }, data: { ...edge.data, @@ -4814,46 +5148,32 @@ export default function UnifiedSearchModal({ }, [edges, rfNodes, showClusters, bridgeOverlayEnabled]); const rfEdges = useMemo(() => { - if (!hoveredClusterId && !hasHoveredCluster) return baseEdges; + // FIX: Use hoveredClusterId as single source of truth instead of dual + // hoveredClusterId + hasHoveredCluster which could desync and permanently + // hide cross-cluster edges. + if (!hoveredClusterId) return baseEdges; if (!showClusters || bridgeOverlayEnabled) return baseEdges; - // Hovered cluster highlighting: only when overlay is off - if (hoveredClusterId) { - const hoveredId = hoveredClusterId; - return baseEdges.map((edge) => { - if (edge.data?.kind !== 'cross_cluster') return edge; - const isConnected = edge.source === hoveredId || edge.target === hoveredId; - const targetOpacity = isConnected ? 0.8 : 0; - const currentOpacity = edge.style?.opacity ?? 1; - if (currentOpacity === targetOpacity) return edge; - return { - ...edge, - style: { - ...edge.style, - opacity: targetOpacity - }, - className: isConnected - ? edge.className?.replace('opacity-0', 'opacity-100') || edge.className - : edge.className?.replace('opacity-100', 'opacity-0') || edge.className - }; - }); - } - - // hasHoveredCluster is true but hoveredClusterId is null (mouse left cluster) + // Hovered cluster highlighting: dim non-connected edges, highlight connected + const hoveredId = hoveredClusterId; return baseEdges.map((edge) => { if (edge.data?.kind !== 'cross_cluster') return edge; + const isConnected = edge.source === hoveredId || edge.target === hoveredId; + const targetOpacity = isConnected ? 0.8 : 0.1; const currentOpacity = edge.style?.opacity ?? 1; - if (currentOpacity === 0) return edge; + if (currentOpacity === targetOpacity) return edge; return { ...edge, style: { ...edge.style, - opacity: 0 + opacity: targetOpacity }, - className: edge.className?.replace('opacity-100', 'opacity-0') || edge.className + className: isConnected + ? edge.className?.replace('opacity-0', 'opacity-100') || edge.className + : edge.className?.replace('opacity-100', 'opacity-0') || edge.className }; }); - }, [baseEdges, hoveredClusterId, hasHoveredCluster, showClusters, bridgeOverlayEnabled]); + }, [baseEdges, hoveredClusterId, showClusters, bridgeOverlayEnabled]); const rfFitViewOptions = useMemo(() => ({ padding: 0.2 }), []); const rfDefaultViewport = useMemo(() => ({ x: 0, y: 0, zoom: 1 }), []); @@ -4913,6 +5233,61 @@ export default function UnifiedSearchModal({ const selectedPath = freshMetadata?.path || selectedNode?.data?.path || ''; const selectedLabel = freshMetadata?.name || selectedNode?.data?.label || selectedNode?.id || ''; const selectedKind = selectedNode?.data?.kind || ''; + const nodeLabelById = useMemo(() => { + const map = new Map(); + nodes.forEach((n) => map.set(n.id, n?.data?.label || n?.id || 'Unknown')); + return map; + }, [nodes]); + const selectedNodeEdges = useMemo(() => { + if (!selectedNode?.id) return []; + return edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id); + }, [edges, selectedNode?.id]); + const selectedNodeConnectionCount = selectedNodeEdges.length; + const selectedNodeNeighbors = useMemo(() => { + if (!selectedNode?.id || selectedNodeEdges.length === 0) return []; + // Use ref to avoid recomputing when only node positions change (layout). + // The ref is always kept in sync via the nodeById useMemo above. + const lookup = nodeByIdRef.current; + const neighborsMap = new Map(); + selectedNodeEdges.forEach((edge) => { + const otherId = edge.source === selectedNode.id ? edge.target : edge.source; + if (!otherId) return; + const similarity = edge.data?.similarity; + const existing = neighborsMap.get(otherId); + if (existing) { + if ( + typeof similarity === 'number' && + (typeof existing.similarity !== 'number' || similarity > existing.similarity) + ) { + neighborsMap.set(otherId, { ...existing, similarity }); + } + return; + } + const otherNode = lookup.get(otherId); + neighborsMap.set(otherId, { + id: otherId, + label: otherNode?.data?.label || otherNode?.id || 'Unknown', + kind: otherNode?.data?.kind || 'file', + similarity + }); + }); + return Array.from(neighborsMap.values()); + }, [selectedNode?.id, selectedNodeEdges]); + const selectedNodeWhyConnections = useMemo(() => { + if (!selectedNode?.id || selectedNodeEdges.length === 0) return []; + return selectedNodeEdges + .map((edge) => { + const targetId = edge.source === selectedNode.id ? edge.target : edge.source; + return { + edgeId: edge.id, + targetId, + targetLabel: nodeLabelById.get(targetId) || targetId || 'Unknown', + similarity: edge?.data?.similarity || edge?.data?.score || 0, + ...getConnectionReason(edge) + }; + }) + .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + }, [nodeLabelById, selectedNode?.id, selectedNodeEdges]); // Cluster-specific derived data const selectedClusterInfo = useMemo(() => { @@ -4998,6 +5373,7 @@ export default function UnifiedSearchModal({ refreshStats(true)} />
@@ -5197,26 +5573,38 @@ export default function UnifiedSearchModal({ /> ) : null} - {/* Flat list view */} + {/* Flat list view - virtualized when many results to avoid UI blocking */} {viewMode === 'all' && - searchResults.map((r, index) => ( - UI_VIRTUALIZATION.THRESHOLD ? ( + { - setSelectedSearchId(res.id); - setFocusedResultIndex(index); - }} + onSelect={handleResultSelect} onToggleBulk={toggleBulkSelection} onOpen={openFile} onReveal={revealFile} onCopyPath={copyPath} /> + ) : ( + searchResults.map((r, index) => ( + + )) ))} {/* Grouped by type view */} @@ -5251,10 +5639,7 @@ export default function UnifiedSearchModal({ isFocused={globalIndex === focusedResultIndex} query={debouncedQuery} index={globalIndex >= 0 ? globalIndex : 0} - onSelect={(res) => { - setSelectedSearchId(res.id); - setFocusedResultIndex(globalIndex); - }} + onSelect={handleResultSelect} onToggleBulk={toggleBulkSelection} onOpen={openFile} onReveal={revealFile} @@ -5416,7 +5801,7 @@ export default function UnifiedSearchModal({ {/* Chat Tab Content */} {activeTab === 'chat' && (
- {hasLoadedStats && (!stats || stats.files === 0) && ( + {hasLoadedStats && !statsUnavailable && (!stats || stats.files === 0) && (
Embeddings are not ready yet. Build your embeddings in Settings to enable document @@ -6622,8 +7007,8 @@ export default function UnifiedSearchModal({ {selectedNode ? ( <> +
+
+ + Selection overview + + + {selectedKind || 'node'} + +
+
+
+ + {selectedNodeConnectionCount} + + + Links + +
+
+ + {selectedNodeNeighbors.length} + + + Neighbors + +
+
+
+ {isLoadingMetadata ? ( /* Skeleton loading state */
@@ -6832,6 +7262,49 @@ export default function UnifiedSearchModal({
)} + {selectedNodeWhyConnections.length > 0 && ( +
+ + Why connected + +
+ {selectedNodeWhyConnections.slice(0, 6).map((reason) => ( +
+ + {reason.targetLabel} + + + {reason.label} + + + {reason.detail} + +
+ ))} +
+
+ )} + {selectedClusterInfo?.bridges?.length > 0 && (
@@ -7102,6 +7575,49 @@ export default function UnifiedSearchModal({
+ {selectedNodeWhyConnections.length > 0 && ( +
+ + Why connected + +
+ {selectedNodeWhyConnections.slice(0, 6).map((reason) => ( +
+ + {reason.targetLabel} + + + {reason.label} + + + {reason.detail} + +
+ ))} +
+
+ )} + {/* Analysis Section (Subject & Summary) */} {(() => { const subject = @@ -7147,6 +7663,7 @@ export default function UnifiedSearchModal({ const tags = selectedDocumentDetails?.analysis?.tags || selectedNode.data?.tags; if (!Array.isArray(tags) || tags.length === 0) return null; + const visibleTags = tags.slice(0, 16); return (
Tags -
- {tags.map((tag) => ( +
+ {visibleTags.map((tag) => ( {tag} ))} + {tags.length > visibleTags.length && ( + + +{tags.length - visibleTags.length} more + + )}
); @@ -7284,103 +7807,52 @@ export default function UnifiedSearchModal({ > Connections - { - edges.filter( - (e) => - e.source === selectedNode.id || e.target === selectedNode.id - ).length - }{' '} - links + {selectedNodeConnectionCount} links
- {(() => { - const neighborsMap = new Map(); - edges - .filter( - (e) => - e.source === selectedNode.id || e.target === selectedNode.id - ) - .forEach((e) => { - const otherId = - e.source === selectedNode.id ? e.target : e.source; - if (!otherId) return; - const similarity = e.data?.similarity; - const existing = neighborsMap.get(otherId); - if (existing) { - if ( - typeof similarity === 'number' && - (typeof existing.similarity !== 'number' || - similarity > existing.similarity) - ) { - neighborsMap.set(otherId, { - ...existing, - similarity - }); - } - return; - } - const otherNode = nodes.find((n) => n.id === otherId); - neighborsMap.set(otherId, { - id: otherId, - label: otherNode?.data?.label || otherNode?.id || 'Unknown', - kind: otherNode?.data?.kind || 'file', - similarity - }); - }); - const neighbors = Array.from(neighborsMap.values()); - - if (neighbors.length === 0) - return ( - - No direct connections - - ); - - return neighbors.slice(0, 10).map((n) => { + {selectedNodeNeighbors.length === 0 ? ( + + No direct connections + + ) : ( + selectedNodeNeighbors.slice(0, 10).map((neighbor) => { const icon = - n.kind === 'query' ? ( + neighbor.kind === 'query' ? ( - ) : n.kind === 'cluster' ? ( + ) : neighbor.kind === 'cluster' ? ( ) : ( ); return ( ); - }); - })()} - {edges.filter( - (e) => e.source === selectedNode.id || e.target === selectedNode.id - ).length > 10 && ( + }) + )} + {selectedNodeConnectionCount > 10 && (
- +{' '} - {edges.filter( - (e) => - e.source === selectedNode.id || e.target === selectedNode.id - ).length - 10}{' '} - more + +{selectedNodeConnectionCount - 10} more
)}
@@ -7556,5 +8028,5 @@ UnifiedSearchModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, defaultTopK: PropTypes.number, - initialTab: PropTypes.oneOf(['search', 'graph']) + initialTab: PropTypes.oneOf(['search', 'chat', 'graph']) }; diff --git a/src/renderer/phases/discover/useAnalysis.js b/src/renderer/phases/discover/useAnalysis.js index 88a01f7d..fbea9f63 100644 --- a/src/renderer/phases/discover/useAnalysis.js +++ b/src/renderer/phases/discover/useAnalysis.js @@ -303,10 +303,7 @@ export function useAnalysis(options = {}) { } = options; // Stabilize namingSettings reference: only produce a new object when contents change. - // This prevents downstream useCallback/useMemo deps from churning on every render - // when the parent passes a structurally-identical but referentially-new object. - // eslint-disable-next-line react-hooks/exhaustive-deps - const namingSettings = useMemo(() => rawNamingSettings, [JSON.stringify(rawNamingSettings)]); + const namingSettings = useMemo(() => rawNamingSettings, [rawNamingSettings]); const hasResumedRef = useRef(false); const analysisLockRef = useRef(false); @@ -583,12 +580,12 @@ export function useAnalysis(options = {}) { useEffect(() => { // FIX: Prevent render loop by checking if settings actually changed - // Compare by value (JSON stringify) to detect actual changes - const currentSettingsKey = JSON.stringify(namingSettings); - if (lastAppliedNamingRef.current === currentSettingsKey) { + // Compare by primitive values to detect actual changes (avoids JSON.stringify on every run) + const key = `${namingSettings?.convention ?? ''}|${namingSettings?.separator ?? ''}|${namingSettings?.dateFormat ?? ''}|${namingSettings?.caseConvention ?? ''}`; + if (lastAppliedNamingRef.current === key) { return; // Settings haven't changed, skip update } - lastAppliedNamingRef.current = currentSettingsKey; + lastAppliedNamingRef.current = key; setAnalysisResults((prev) => { if (!prev || prev.length === 0) return prev; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index e2821454..c4f4dcfa 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -78,9 +78,26 @@ --border-soft: #e2e8f0; --border-strong: #cbd5e1; --border-subtle: #e5e7eb; - --shadow-soft: 0 30px 45px rgba(15, 23, 42, 0.12); + /* Shared elevation scale: use these to keep depth consistent app-wide */ + --shadow-elev-1: 0 1px 2px color-mix(in srgb, var(--surface-contrast) 10%, transparent); + --shadow-elev-2: + 0 2px 4px color-mix(in srgb, var(--surface-contrast) 8%, transparent), + 0 1px 2px color-mix(in srgb, var(--surface-contrast) 6%, transparent); + --shadow-elev-3: + 0 8px 16px color-mix(in srgb, var(--surface-contrast) 10%, transparent), + 0 2px 4px color-mix(in srgb, var(--surface-contrast) 6%, transparent); + --shadow-elev-4: + 0 14px 28px color-mix(in srgb, var(--surface-contrast) 12%, transparent), + 0 4px 10px color-mix(in srgb, var(--surface-contrast) 7%, transparent); + --shadow-elev-5: + 0 22px 44px color-mix(in srgb, var(--surface-contrast) 14%, transparent), + 0 8px 18px color-mix(in srgb, var(--surface-contrast) 8%, transparent); + --shadow-elev-6: + 0 30px 60px color-mix(in srgb, var(--surface-contrast) 16%, transparent), + 0 12px 24px color-mix(in srgb, var(--surface-contrast) 9%, transparent); + --shadow-soft: var(--shadow-elev-4); --shadow-glow: 0 25px 55px rgba(37, 99, 235, 0.35); - --shadow-ambient: 0 18px 48px rgba(15, 23, 42, 0.14); + --shadow-ambient: var(--shadow-elev-5); --blur-strength: 18px; --radius-panel: 18px; --radius-card: 16px; @@ -97,6 +114,7 @@ --scrollbar-thumb: var(--color-system-gray-400); --scrollbar-thumb-hover: var(--color-system-gray-500); --scrollbar-track: color-mix(in srgb, var(--color-system-gray-100) 60%, transparent); + --scrollbar-radius: 4px; /* Layout guardrails */ --page-max-width: 1200px; @@ -334,16 +352,28 @@ background-color: var(--surface-primary) !important; } - /* Global: disable blur overlays to prevent native dropdown flicker on Windows/Electron */ + /* Global: disable blur overlays to prevent native dropdown flicker on Windows/Electron. + Also kill backdrop-saturate which is meaningless without blur. */ .backdrop-blur, .backdrop-blur-sm, .backdrop-blur-md, .backdrop-blur-lg, - .backdrop-blur-xl { + .backdrop-blur-xl, + .backdrop-saturate-100, + .backdrop-saturate-125, + .backdrop-saturate-150, + .backdrop-saturate-200 { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } + /* When backdrop-blur is disabled the semi-transparent header backgrounds + let the cool-gray page surface bleed through, producing a blue tint. + Force the header to solid white so the brand/badge area stays clean. */ + header.backdrop-blur-xl { + background-color: #ffffff !important; + } + /* Settings modal: disable blur/alpha surfaces to avoid native select flicker on Windows */ .settings-modal .surface-panel, .settings-modal .surface-card, @@ -384,11 +414,11 @@ } .glass-panel { - background: var(--glass-layer); + background: var(--surface-primary); border: 1px solid var(--glass-border); box-shadow: var(--shadow-soft); - backdrop-filter: blur(var(--blur-strength)); - -webkit-backdrop-filter: blur(var(--blur-strength)); + /* backdrop-filter disabled globally to prevent Windows/Electron flicker; + use solid surface-primary instead of translucent glass-layer */ border-radius: var(--radius-panel); } @@ -747,39 +777,34 @@ } .tooltip-enhanced { - @apply text-xs py-2 px-3.5 pointer-events-none opacity-0 transition-all text-system-gray-800 font-medium; + @apply text-xs py-2 px-3.5 pointer-events-none opacity-0 text-system-gray-800 font-medium; z-index: var(--z-toast); position: fixed; background: var(--surface-primary); border: 1px solid var(--border-soft); - border-radius: 12px; - transform: translate3d(0, -6px, 0); - transition-duration: var(--duration-fast); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - will-change: transform, opacity; - box-shadow: - 0 4px 6px -1px color-mix(in srgb, var(--surface-contrast) 8%, transparent), - 0 10px 15px -3px color-mix(in srgb, var(--surface-contrast) 8%, transparent), - 0 0 0 1px color-mix(in srgb, var(--surface-contrast) 4%, transparent); + border-radius: var(--radius-md); + transition: opacity 200ms cubic-bezier(0.25, 0.1, 0.25, 1); + box-shadow: var(--shadow-elev-4); max-width: 280px; line-height: 1.4; } .tooltip-enhanced.show { @apply opacity-100; - transform: translate3d(0, 0, 0); } .tooltip-arrow { position: absolute; width: 10px; height: 10px; background: var(--surface-primary); - box-shadow: - 1px 1px 3px color-mix(in srgb, var(--surface-contrast) 5%, transparent), - 0 0 0 1px color-mix(in srgb, var(--surface-contrast) 4%, transparent); + box-shadow: var(--shadow-elev-1); border-radius: 2px; transform: rotate(45deg); } + @media (prefers-reduced-motion: reduce) { + .tooltip-enhanced { + transition-duration: 1ms !important; + } + } .status-dot { @apply w-2 h-2 rounded-full; @@ -808,8 +833,8 @@ max-width: 400px; padding: 2rem; background: color-mix(in srgb, var(--surface-primary) 95%, transparent); - border-radius: 1rem; - box-shadow: 0 20px 50px color-mix(in srgb, var(--surface-contrast) 10%, transparent); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-elev-5); border: 1px solid color-mix(in srgb, var(--color-stratosort-danger) 20%, transparent); } @@ -852,7 +877,7 @@ padding: 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-soft); - border-radius: 0.5rem; + border-radius: var(--radius-sm); font-size: 0.75rem; color: var(--text-secondary); overflow-x: auto; @@ -945,7 +970,7 @@ } .panel-scroll::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: 4px; + border-radius: var(--scrollbar-radius); } .panel-scroll::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); @@ -1160,7 +1185,7 @@ } .flex-scroll-child::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: 4px; + border-radius: var(--scrollbar-radius); } .flex-scroll-child::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); @@ -1168,6 +1193,82 @@ } @layer utilities { + /* + * Radius normalization for common Tailwind rounded utilities. + * This keeps component curvature consistent while allowing + * situational exceptions (rounded-full, custom classes). + */ + .rounded-md { + border-radius: var(--radius-md); + } + + .rounded-lg { + border-radius: var(--radius-lg); + } + + .rounded-xl { + border-radius: var(--radius-panel); + } + + .rounded-2xl { + border-radius: calc(var(--radius-panel) + 6px); + } + + .border-border-soft { + border-color: var(--border-soft); + } + + .border-border-strong { + border-color: var(--border-strong); + } + + /* + * Normalize Tailwind shadow utilities to a shared elevation scale. + * Keep ring composition intact by preserving --tw-ring-* vars. + */ + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + + .shadow-xs { + --tw-shadow: var(--shadow-elev-1); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + + .shadow-sm { + --tw-shadow: var(--shadow-elev-2); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + + .shadow, + .shadow-md { + --tw-shadow: var(--shadow-elev-3); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + + .shadow-lg { + --tw-shadow: var(--shadow-elev-4); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + + .shadow-xl { + --tw-shadow: var(--shadow-elev-5); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + + .shadow-2xl { + --tw-shadow: var(--shadow-elev-6); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; + } + /* GPU acceleration utilities for smooth animations */ .backface-hidden { backface-visibility: hidden; @@ -1357,7 +1458,7 @@ } .modern-scrollbar::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: 4px; + border-radius: var(--scrollbar-radius); } .modern-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); @@ -1378,7 +1479,7 @@ } .custom-scrollbar::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: 4px; + border-radius: var(--scrollbar-radius); } .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); @@ -1648,7 +1749,7 @@ } .panel-responsive-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: 4px; + border-radius: var(--scrollbar-radius); } .panel-responsive-content::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); diff --git a/test/TooltipManager.test.js b/test/TooltipManager.test.js index 32ebdcd6..66af50ba 100644 --- a/test/TooltipManager.test.js +++ b/test/TooltipManager.test.js @@ -130,4 +130,23 @@ describe('TooltipManager', () => { value: false }); }); + + it('should convert native title attributes to data-tooltip', () => { + const { unmount } = render(); + + const testElement = document.createElement('button'); + testElement.setAttribute('title', 'Refresh models'); + document.body.appendChild(testElement); + + expect(testElement.getAttribute('title')).toBeNull(); + expect(testElement.getAttribute('data-tooltip')).toBe('Refresh models'); + + // Ensure property assignment is also redirected + testElement.title = 'Toggle model list'; + expect(testElement.getAttribute('title')).toBeNull(); + expect(testElement.getAttribute('data-tooltip')).toBe('Toggle model list'); + + unmount(); + document.body.removeChild(testElement); + }); }); diff --git a/test/customFolders.test.js b/test/customFolders.test.js index 96f83a7b..75194a04 100644 --- a/test/customFolders.test.js +++ b/test/customFolders.test.js @@ -69,7 +69,7 @@ describe('Custom Folders', () => { }); describe('loadCustomFolders', () => { - test('loads and parses saved folders', async () => { + test('loads and parses saved folders, only ensures Uncategorized', async () => { const savedFolders = [ { id: 'folder1', @@ -89,24 +89,25 @@ describe('Custom Folders', () => { const folders = await customFolders.loadCustomFolders(); expect(folders.some((f) => f.name === 'Documents')).toBe(true); - expect(folders.some((f) => f.name === 'Archives')).toBe(true); expect(folders.some((f) => f.name === 'Uncategorized')).toBe(true); + // Should NOT inject extra defaults the user didn't add + expect(folders.some((f) => f.name === 'Archives')).toBe(false); + expect(folders).toHaveLength(2); }); - test('creates default smart folders when file does not exist', async () => { + test('creates only Uncategorized when file does not exist (first launch)', async () => { mockFs.readFile.mockRejectedValueOnce(new Error('ENOENT')); const folders = await customFolders.loadCustomFolders(); - // Now creates 8 default folders: Documents, Images, Videos, Music, Spreadsheets, Presentations, Archives, Uncategorized - expect(folders).toHaveLength(8); - expect(folders.some((f) => f.name === 'Documents')).toBe(true); - expect(folders.some((f) => f.name === 'Uncategorized')).toBe(true); - expect(folders.every((f) => f.isDefault)).toBe(true); + // First launch: only Uncategorized is created; user builds their own structure + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('Uncategorized'); + expect(folders[0].isDefault).toBe(true); expect(mockFs.mkdir).toHaveBeenCalled(); }); - test('adds missing default folders if saved data is incomplete', async () => { + test('adds only Uncategorized if it is missing from saved data', async () => { const savedFolders = [ { id: 'folder1', @@ -119,10 +120,11 @@ describe('Custom Folders', () => { const folders = await customFolders.loadCustomFolders(); - // Should have full default set when defaults are partially present - expect(folders).toHaveLength(8); + // Should add Uncategorized but NOT other defaults + expect(folders).toHaveLength(2); + expect(folders.some((f) => f.name === 'Documents')).toBe(true); expect(folders.some((f) => f.name.toLowerCase() === 'uncategorized')).toBe(true); - expect(folders.some((f) => f.name === 'Archives')).toBe(true); + expect(folders.some((f) => f.name === 'Archives')).toBe(false); }); test('normalizes folder paths', async () => { @@ -218,9 +220,9 @@ describe('Custom Folders', () => { const folders = await customFolders.loadCustomFolders(); - // Should create all 8 default folders when JSON is invalid - expect(folders).toHaveLength(8); - expect(folders.some((f) => f.name === 'Uncategorized')).toBe(true); + // Should create only Uncategorized when JSON is invalid + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('Uncategorized'); }); test('handles non-array JSON payload gracefully', async () => { @@ -228,8 +230,8 @@ describe('Custom Folders', () => { const folders = await customFolders.loadCustomFolders(); - expect(folders).toHaveLength(8); - expect(folders.some((f) => f.name === 'Uncategorized')).toBe(true); + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('Uncategorized'); }); }); From ae3eb843df5daa22f3fd95350653ee0b054ba1e7 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:58:18 -0500 Subject: [PATCH 2/2] PerfAndDocs: performance, docs, services, and cleanup - Add AllDocs/ (architecture, guides, config, patterns) - Add docs/BETA_TESTER_GUIDE.md, docs/USER_GUIDE.md; update INSTALL_GUIDE - Remove SemanticRenameService and tests - Update main services (Chat, Clustering, Llama, Search, Settings, etc.) - Update IPC, error handling, store migrations - Add useEdgeInteraction, requestGuard utilities - Update tests and README --- AllDocs/ARCHITECTURE.md | 266 +++++++ AllDocs/BETA_TESTER_GUIDE.md | 174 +++++ AllDocs/CODE_QUALITY_STANDARDS.md | 688 ++++++++++++++++++ AllDocs/CONFIG.md | 271 +++++++ AllDocs/DEPENDENCY_BOUNDARIES.md | 21 + AllDocs/DI_PATTERNS.md | 152 ++++ AllDocs/ERROR_HANDLING_GUIDE.md | 674 +++++++++++++++++ AllDocs/FEATURES_GRAPH.md | 57 ++ AllDocs/GETTING_STARTED.md | 214 ++++++ AllDocs/INSTALL_GUIDE.md | 153 ++++ AllDocs/IPC_CONTRACTS.md | 43 ++ AllDocs/LEARNING_GUIDE.md | 557 ++++++++++++++ AllDocs/README.md | 91 +++ AllDocs/RELEASING.md | 97 +++ AllDocs/USER_GUIDE.md | 223 ++++++ AllDocs/migration-audit.md | 23 + README.md | 64 +- docs/BETA_TESTER_GUIDE.md | 191 +++++ docs/INSTALL_GUIDE.md | 266 +++++-- docs/USER_GUIDE.md | 223 ++++++ scripts/setup-models.js | 36 +- src/main/core/applicationMenu.js | 13 +- src/main/core/autoUpdater.js | 5 +- src/main/core/backgroundSetup.js | 9 +- src/main/core/jumpList.js | 3 +- src/main/core/systemTray.js | 3 +- src/main/core/userDataMigration.js | 4 + src/main/errors/ErrorHandler.js | 4 +- src/main/ipc/analysisHistory.js | 3 +- src/main/ipc/files/batchOrganizeHandler.js | 151 +++- src/main/ipc/files/batchProgressReporter.js | 5 +- src/main/ipc/files/embeddingSync.js | 93 ++- src/main/ipc/files/fileOperationHandlers.js | 67 +- src/main/ipc/llama.js | 72 +- src/main/ipc/semantic.js | 34 +- src/main/ipc/validationSchemas.js | 18 + src/main/ipc/vectordb.js | 6 +- src/main/services/ChatService.js | 204 +++++- src/main/services/ClusteringService.js | 16 +- src/main/services/DownloadWatcher.js | 4 +- src/main/services/LlamaService.js | 13 +- src/main/services/ModelManager.js | 6 +- src/main/services/NotificationService.js | 3 +- src/main/services/OrganizeResumeService.js | 9 +- src/main/services/SearchService.js | 31 +- src/main/services/SemanticRenameService.js | 89 --- src/main/services/SettingsService.js | 10 +- src/main/services/SmartFolderWatcher.js | 120 ++- src/main/simple-main.js | 4 +- src/main/utils/fileDedup.js | 73 +- .../components/AnalysisHistoryModal.jsx | 12 +- src/renderer/components/SettingsPanel.jsx | 70 +- .../components/search/ClusterNode.jsx | 8 +- .../components/search/KnowledgeEdge.jsx | 49 +- .../components/search/QueryMatchEdge.jsx | 32 +- .../components/search/SimilarityEdge.jsx | 61 +- .../components/search/SmartStepEdge.jsx | 22 +- .../components/search/nodes/FileNode.jsx | 8 +- .../components/search/useEdgeInteraction.js | 60 ++ .../settings/GraphRetrievalSection.jsx | 21 +- .../settings/LlamaConfigSection.jsx | 13 +- src/renderer/store/index.js | 25 +- .../store/middleware/persistenceMiddleware.js | 10 +- src/renderer/store/migrations.js | 3 + src/renderer/store/slices/analysisSlice.js | 7 +- src/renderer/utils/requestGuard.js | 19 + src/shared/constants.js | 25 + src/shared/errorHandlingUtils.js | 24 +- src/shared/ipcEventSchemas.js | 28 +- src/shared/performanceConstants.js | 5 + src/shared/securityConfig.js | 31 +- test/ChatService.extended.test.js | 6 +- test/ChatService.test.js | 3 +- test/ErrorHandler.extended.test.js | 14 + test/ErrorHandler.test.js | 14 + test/OramaVectorService.lruCache.test.js | 58 +- test/SearchService.test.js | 21 +- test/SemanticRenameService.test.js | 80 -- test/SettingsService.test.js | 7 +- test/analysisSlice.test.js | 23 +- test/fileSelectionHandlers.test.js | 17 + test/llamaIpc.handlers.test.js | 28 +- 82 files changed, 5728 insertions(+), 632 deletions(-) create mode 100644 AllDocs/ARCHITECTURE.md create mode 100644 AllDocs/BETA_TESTER_GUIDE.md create mode 100644 AllDocs/CODE_QUALITY_STANDARDS.md create mode 100644 AllDocs/CONFIG.md create mode 100644 AllDocs/DEPENDENCY_BOUNDARIES.md create mode 100644 AllDocs/DI_PATTERNS.md create mode 100644 AllDocs/ERROR_HANDLING_GUIDE.md create mode 100644 AllDocs/FEATURES_GRAPH.md create mode 100644 AllDocs/GETTING_STARTED.md create mode 100644 AllDocs/INSTALL_GUIDE.md create mode 100644 AllDocs/IPC_CONTRACTS.md create mode 100644 AllDocs/LEARNING_GUIDE.md create mode 100644 AllDocs/README.md create mode 100644 AllDocs/RELEASING.md create mode 100644 AllDocs/USER_GUIDE.md create mode 100644 AllDocs/migration-audit.md create mode 100644 docs/BETA_TESTER_GUIDE.md create mode 100644 docs/USER_GUIDE.md delete mode 100644 src/main/services/SemanticRenameService.js create mode 100644 src/renderer/components/search/useEdgeInteraction.js create mode 100644 src/renderer/utils/requestGuard.js delete mode 100644 test/SemanticRenameService.test.js diff --git a/AllDocs/ARCHITECTURE.md b/AllDocs/ARCHITECTURE.md new file mode 100644 index 00000000..a9620742 --- /dev/null +++ b/AllDocs/ARCHITECTURE.md @@ -0,0 +1,266 @@ +- ### Graph UX notes + - **Bridge files**: Cross-cluster edges include a small sample of "bridge files" picked by + similarity to opposing cluster centroids. This is best-effort and not an exhaustive list. + - **Guide intents**: "Expand from selection" and "bridges-only" intents are supported; the + bridges-only overlay dims non-bridge edges. + - **Filter visibility**: Active graph filters are surfaced as chips on the graph (top-left + overlay) and can be reset from there. + +# StratoSort Core Architecture + +## High-Level Information Flow + +This diagram illustrates the flow of data through the application, highlighting the separation +between the Renderer (UI), the IPC Bridge, and the specialized in-process services. + +```mermaid +graph LR + classDef frontend fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#0d47a1 + classDef backend fill:#f1f8e9,stroke:#33691e,stroke-width:2px,color:#33691e + classDef core fill:#eceff1,stroke:#455a64,stroke-width:2px,color:#455a64 + classDef ai fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#4a148c + + subgraph UI_Layer["Renderer Process"] + direction TB + AppEntry([App Entry]) + GraphView["Knowledge Graph"] + ReduxStore[[Redux Store]] + + AppEntry --> ReduxStore + AppEntry --> GraphView + end + + subgraph Main_Process["Main Process"] + direction LR + + subgraph Services["Core Services"] + Watcher{{Smart Folder Watcher}} + ReRanker{{Re-Ranking Service}} + Matcher{{Folder Matcher}} + end + + subgraph Intelligence["AI & Analysis"] + Vision["Llama Vision"] + LLM["Llama Text"] + Embeddings["Vector Embeddings"] + + Watcher --> Vision + Watcher --> LLM + ReRanker --> LLM + end + + subgraph Data["Persistence"] + Orama[(Orama Vector DB)] + History["Analysis History"] + + Embeddings <--> Orama + Vision --> History + end + end + + ReduxStore <--> Services + GraphView -.-> Orama + Matcher --> Embeddings +``` + +## Key Architectural Components + +### 1. Smart Folder Watcher (`SmartFolderWatcher.js`) + +The watcher is the proactive heart of the system. Unlike a standard file watcher, it: + +- Monitors configured paths for new/modified files. +- **Debounces** events to ensure files are fully written. +- Dispatches files to **LlamaService** (Text or Vision) for analysis. +- Automatically generates embeddings and updates **Orama Vector DB**. +- Triggers notifications and auto-organization based on confidence thresholds. + +### 2. Semantic Search & Re-Ranking (`ReRankerService.js`) + +Search is a two-stage pipeline that spans two services: + +1. **Retrieval** (`SearchService` / `OramaVectorService`): Fetches top candidates using hybrid + search (vector similarity + BM25 full-text). +2. **Re-Ranking** (`ReRankerService`): Receives the pre-ranked candidates and uses a lightweight + LLM to score each one's relevance to the query (0-10 scale, normalized to 0-1), re-ordering them + to bubble up the best matches. + +`ReRankerService` itself does not perform retrieval -- it operates solely on candidates passed to +it. + +### 3. Unified Folder Matching (`FolderMatchingService.js`) + +This service acts as the bridge between raw AI analysis and your filesystem. It: + +- Manages the **Orama** connection. +- Generates embeddings for file content. +- Matches new files against existing folder "clusters" to suggest destinations. +- Handles deduplication of requests to prevent overloading the local AI. + +### 4. Knowledge Graph Visualization + +The Knowledge Graph is a visual representation of your vector database. + +- **Nodes**: Represent files, clusters, or search queries. +- **Edges**: Represent semantic similarity (distance in vector space). +- **Implementation**: Built with `React Flow` in the renderer, fetching live node data from the main + process via IPC. + +### 5. Service Container (`ServiceContainer.js`) + +The DI container that owns the lifecycle of every backend service: + +- **Registration**: Factory-based with three lifetimes -- `singleton`, `transient`, and `instance` + (pre-created objects registered as singletons). +- **Resolution**: Async resolution with in-flight deduplication (prevents duplicate initialization + of the same singleton). Circular dependency detection works for both synchronous `resolve()` (via + shared resolution stack) and `resolveAsync()` (via `AsyncLocalStorage` per-chain tracking). +- **Shutdown ordering**: Deterministic teardown defined in `SHUTDOWN_ORDER` -- watchers first, then + AI services (VisionService, LlamaService, OramaVector), then settings last. + +### 6. Startup Manager (`StartupManagerCore.js`) + +Orchestrates the boot sequence with progress reporting to the splash screen: + +- **5 phases**: Migration (5-10%) -> Services (15-65%) -> Models (70-75%) -> App Services (85%) -> + Ready (100%). +- **Timeout**: 60s default (`startupTimeout`), enforced via `AbortController` -- every phase checks + `signal.aborted` before proceeding. +- **Health monitoring**: After startup completes, a 2-minute interval polls service health. + +### 7. IPC Handlers (`src/main/ipc/`) + +19 handler modules organized by domain: + +- **Top-level** (15): `analysis`, `analysisHistory`, `chat`, `files`, `knowledge`, `llama`, + `organize`, `semantic`, `settings`, `smartFolders`, `suggestions`, `system`, `undoRedo`, + `vectordb`, `window`. +- **Files subdirectory** (4): `fileOperationHandlers`, `fileSelectionHandlers`, `folderHandlers`, + `shellHandlers`. +- **`IpcServiceContext`**: Groups service dependencies into a single context object, replacing + 28-parameter function signatures. +- **`ipcWrappers`**: Composable middleware (`withErrorLogging`, `withValidation`, + `withServiceCheck`) and a unified `createHandler` factory. + +### 8. AI & Analysis + +#### LlamaService (`LlamaService.js`) + +In-process GGUF inference via node-llama-cpp. Three model types: text (Mistral 7B Q4), vision +(config/coordination only, delegates to VisionService), embedding (nomic-embed Q8_0 768d). GPU +auto-detection (CUDA -> Vulkan -> Metal -> CPU), lazy loading via ModelMemoryManager (LRU eviction, +70% RAM budget). + +#### VisionService (`VisionService.js`) + +External llama-server process (separate binary, downloads from GitHub releases). OpenAI-compatible +`/v1/chat/completions` API, 120s per-image timeout, health check polling. + +#### Document Extractors (`src/main/analysis/documentExtractors.js`) + +PDF (unpdf + OCR fallback), DOCX (mammoth), XLSX (xlsx-populate), PPTX (officeparser), CSV, plain +text. OCR: Tesseract.js with Sharp rasterization, max 3 PDF pages / 10 office images. Memory limits: +100MB file cap, 500k char output, streaming for >50MB files. + +#### Concurrency & Resilience + +- **ModelAccessCoordinator**: per-type load queues (mutex), inference queue (1-4 concurrent, 100 + max) +- **LlamaResilience**: per-model-type circuit breaker (5-failure threshold, 30s recovery), 3 retries + with exponential backoff, GPU->CPU fallback +- **ModelMemoryManager**: LRU eviction, OOM prevention, estimates per model type +- **DegradationManager**: system readiness checks, determines recovery actions + +#### Model Download (`ModelDownloadManager.js`) + +Resume support (`.partial` + Range headers), SHA-256 verification, disk space checks. Auto-downloads +companion files (vision projectors), model catalog in `src/shared/modelRegistry.js`. + +#### Embedding Pipeline + +- **ParallelEmbeddingService**: semaphore-based concurrency (CPU-adaptive, max 10), dynamic + adjustment on error rate +- **embeddingQueue/**: separate stage queues for files vs folders, batch upsert with individual + fallback + +## Renderer / UI Layer + +### Entry & Routing + +**Entry:** `src/renderer/index.js` (React 19 + `createRoot`, prefetches docs path / redact paths / +smart folders / settings). + +Phase-based routing via `PhaseRenderer.jsx` (no React Router): WELCOME -> SETUP -> DISCOVER -> +ORGANIZE -> COMPLETE + +Each phase lazy-loaded with `React.lazy()` + `Suspense`, wrapped in `PhaseErrorBoundary`. + +### Redux Store (4 slices) + +| Slice | Responsibility | +| :-------------- | :---------------------------------------------------------- | +| `uiSlice` | Phase management, loading flags, settings cache, modals | +| `filesSlice` | File selection, smart folders, organization history, naming | +| `analysisSlice` | Analysis progress, results (max 5000), embedding state | +| `systemSlice` | CPU/memory metrics, service health, notifications, paths | + +**Middleware:** `ipcMiddleware` (event queue + Zod validation), `persistenceMiddleware` (1s +debounce, quota-aware degradation). + +### Knowledge Graph (`UnifiedSearchModal.jsx`, ~7400 lines) + +React Flow with ELK layout, max 300 nodes. + +- **4 node types:** file, folder, query, cluster +- **4 edge types:** similarity, queryMatch, smartStep, knowledge +- **Features:** Multi-hop discovery, drag-and-drop, keyboard nav, chat panel, cluster legend, guided + tour + +### Component Groups + +| Directory | Contents | +| :---------- | :---------------------------------------- | +| `discover/` | Drag-drop zone, analysis progress | +| `organize/` | Suggestions, virtualized grids | +| `search/` | Graph, chat panel, autocomplete | +| `settings/` | Model selection, embedding rebuild | +| `setup/` | Smart folder management (add/edit modals) | +| `ui/` | Design system (Button, Card, Modal, etc.) | +| `icons/` | Icon components | +| `layout/` | AppShell, ActionBar | + +Root-level components include `PhaseRenderer`, `NavigationBar`, `SettingsPanel`, `ModelSetupWizard`, +Toast system, and error boundaries. + +### IPC Bridge (`src/preload/preload.js`) + +Rate-limited (200 req/s), sanitized, channel-whitelisted, 30s-300s timeouts, retry with exponential +backoff. + +## Data Flow & Caching + +### Analysis Caching + +To respect local resources, we use a multi-tiered caching strategy: + +- **`AnalysisCacheService`**: Uses LRU caching for expensive AI responses (vision/text analysis). +- **`AnalysisHistory`**: Persisted log of all past analyses to prevent re-processing unchanged + files. +- **`GlobalDeduplicator`**: Prevents identical requests (e.g., same image analyzed twice in quick + succession) from hitting the LLM. + +### In-Flight Deduplication + +We maintain separate queues to manage different types of bottlenecks: + +- **LLM Queue**: Limits concurrent heavy AI tasks (via `ModelAccessCoordinator`). +- **DB Queue**: Manages read/write locks for Orama. + +## Code Standards + +For information on coding patterns, error handling, and dependency injection, refer to the other +documentation files: + +- [CODE_QUALITY_STANDARDS.md](CODE_QUALITY_STANDARDS.md) +- [DI_PATTERNS.md](DI_PATTERNS.md) +- [ERROR_HANDLING_GUIDE.md](ERROR_HANDLING_GUIDE.md) diff --git a/AllDocs/BETA_TESTER_GUIDE.md b/AllDocs/BETA_TESTER_GUIDE.md new file mode 100644 index 00000000..5d927674 --- /dev/null +++ b/AllDocs/BETA_TESTER_GUIDE.md @@ -0,0 +1,174 @@ +# StratoSort Core Beta Tester Guide + +This guide is for people who want to help test StratoSort Core without using the command line. + +If you can install an app, use it for normal work, and share clear bug reports, you can contribute. + +--- + +## Quick Links + +- **Download:** [Latest release installers](https://github.com/iLevyTate/StratoSortCore/releases) +- **Install help:** [INSTALL_GUIDE.md](./INSTALL_GUIDE.md) +- **Bug reports:** + [Open a bug report](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) +- **General issues:** [Issues board](https://github.com/iLevyTate/StratoSortCore/issues) + +--- + +## Who This Guide Is For + +- You want to help improve StratoSort Core. +- You prefer installers over building from source. +- You can spend a little time reproducing issues and reporting them clearly. + +--- + +## Part 1: Install (No CLI) + +Use the full install walkthrough here: [INSTALL_GUIDE.md](./INSTALL_GUIDE.md). + +### Windows + +1. Download `StratoSortCore-Setup-X.X.X.exe` from + [Releases](https://github.com/iLevyTate/StratoSortCore/releases). Use the **Setup** installer + (not the portable `.exe`) so you receive automatic updates. +2. Run the installer. +3. If SmartScreen appears, click **More info** then **Run anyway**. +4. Launch StratoSort Core. + +### macOS + +1. Download the matching `.dmg` from + [Releases](https://github.com/iLevyTate/StratoSortCore/releases): + - `mac-arm64` for Apple Silicon (M1/M2/M3/M4) + - `mac-x64` for Intel Macs +2. Drag StratoSort Core into Applications. +3. Open the app. +4. If macOS blocks it, right-click app -> **Open**, or use **System Settings -> Privacy & Security + -> Open Anyway**. + +### Linux + +1. Download `StratoSortCore-X.X.X-linux-x64.AppImage` from + [Releases](https://github.com/iLevyTate/StratoSortCore/releases). Use the **AppImage** (not the + `.deb`) so you receive automatic updates. +2. Make it executable: `chmod +x StratoSortCore-*.AppImage` +3. Double-click to run, or launch from a terminal. + +### First Launch + +1. In the setup wizard, choose a model profile (**Base Small** for most hardware, **Better Quality** + for modern hardware with 16GB+ RAM). +2. Wait until model download finishes. +3. Continue into the app workflow. + +--- + +## Part 2: Run a Useful Beta Test Session + +Use this checklist to create high-value feedback. Look for anything unexpected — wrong suggestions, +confusing UI, slow performance, or outright errors. + +1. **Setup phase** + - Add at least 3-5 Smart Folders with clear descriptions. + - Look for: Does the UI make it obvious how to add/edit/remove folders? +2. **Discover phase** + - Analyze a mixed batch (documents, images, screenshots, PDFs). + - Look for: Do analysis results make sense? Are categories accurate? How long does it take? +3. **Organize phase** + - Accept some suggestions, reject others, test rename options. + - Look for: Are suggested destinations correct? Do renames follow your naming rules? +4. **Search / Knowledge OS** + - Try natural-language queries in search (Ctrl+K / Cmd+K on macOS). + - Open the Knowledge Graph view and inspect relationships. + - Look for: Are results relevant? Does the graph show meaningful connections? +5. **Settings** + - Walk through each section: + - **AI Configuration** — model status, model selection, embedding rebuild + - **Performance** — auto-organize toggle, background mode + - **Default Locations** — naming conventions, default paths + - **Application** — log export, settings backup/restore + - Look for: Do all toggles work? Are labels clear? Anything confusing? +6. **Undo/Redo** + - Organize a few files, then undo. Re-do. Undo again. + - Look for: Do files actually move back? Is the history accurate? + +**Tip:** Real-world folders (Downloads, screenshots, invoices, project docs) produce better test +results than synthetic files. + +--- + +## Part 3: How To Report Bugs So They Are Actionable + +Submit reports with this template: +[Bug report form](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) + +### Include these every time + +- **Clear title:** what broke, where. +- **Reproduction steps:** exact step-by-step path. +- **Expected behavior:** what should happen. +- **Actual behavior:** what happened instead. +- **Environment details:** + - OS + version + - App version + - Install type (installer vs source) + - Hardware notes (RAM/GPU) if performance or AI related + +### Attach useful evidence + +- Screenshot or short screen recording. +- **Logs** (see below). +- Any visible error text (copy exact message). + +### How to get logs + +Open **Settings -> Application -> Troubleshooting Logs**: + +| Option | When to use | +| --------------- | ------------------------------------------------------------------------- | +| **Open Folder** | Browse log files directly on disk. Useful for picking specific log files. | +| **Export Logs** | Creates a shareable log file for bug reports. Attach to GitHub issue. | + +**Steps:** + +1. Open **Settings** -> **Application**. +2. Under **Troubleshooting Logs**, click **Export Logs**. +3. Save the file (e.g. to Desktop). +4. When filing a bug report, drag the file into the GitHub issue or use **Attach files**. +5. Add a note like: _"Logs attached. Error occurred when [brief context]."_ + +**Manual log locations** (if you prefer copying files yourself): + +- **Windows:** `%APPDATA%/stratosort/logs/` +- **macOS:** `~/Library/Logs/stratosort/` +- **Linux:** `~/.config/stratosort/logs/` + +### High-quality bug report example + +> **Title:** Knowledge OS search returns zero results after embedding model switch **Steps:** +> +> 1. Open Settings -> AI Configuration -> Default AI models +> 2. Change Embedding Model +> 3. Return to search and query "invoice from last month" **Expected:** Existing indexed files still +> appear, or app prompts to rebuild before search **Actual:** Empty results + warning about model +> mismatch **Environment:** Windows 11, app 2.0.1 installer, RTX 3060, 32GB RAM **Logs:** +> attached export (Settings -> Export Logs) + +--- + +## Part 4: Other Ways To Contribute (No Coding Required) + +- Confirm bugs reported by others (same issue, same version, same/different OS). +- Test a new release and report regressions. +- Suggest UX improvements with screenshots and concrete before/after notes. +- Improve docs when something feels unclear. + +--- + +## Part 5: If You Do Want To Contribute Code Later + +Start here: [CONTRIBUTING.md](../CONTRIBUTING.md) + +You can help as a tester today and become a code contributor later. Both are valuable. diff --git a/AllDocs/CODE_QUALITY_STANDARDS.md b/AllDocs/CODE_QUALITY_STANDARDS.md new file mode 100644 index 00000000..20771d78 --- /dev/null +++ b/AllDocs/CODE_QUALITY_STANDARDS.md @@ -0,0 +1,688 @@ +# Code Quality Standards and Style Guide + +## Overview + +This document establishes coding standards for consistency, readability, and maintainability across +the StratoSort Core codebase. + +## Table of Contents + +1. [Naming Conventions](#naming-conventions) +2. [Error Handling Standards](#error-handling-standards) +3. [Promise Handling](#promise-handling) +4. [Code Formatting](#code-formatting) +5. [JSDoc Documentation](#jsdoc-documentation) +6. [Function Length and Complexity](#function-length-and-complexity) +7. [Import Organization](#import-organization) + +## Naming Conventions + +### Variables and Functions + +#### Use Descriptive Names + +```javascript +// Bad: Ambiguous, unclear purpose +const d = new Date(); +const x = files.length; +function proc(f) { ... } + +// Good: Clear, descriptive +const currentDate = new Date(); +const fileCount = files.length; +function processFileAnalysis(filePath) { ... } +``` + +#### Naming Patterns by Type + +**Boolean Variables** - Use `is`, `has`, `should` prefixes + +```javascript +const isAnalyzing = false; +const hasResults = results.length > 0; +const shouldRetry = attempt < maxAttempts; +``` + +**Arrays and Collections** - Use plural nouns + +```javascript +const files = []; +const results = []; +const analysisErrors = new Map(); +``` + +**Functions** - Use verb + noun pattern + +```javascript +function analyzeFile(filePath) {} +function getSettings() {} +function validateInput(data) {} +function createFolder(path) {} +``` + +**Async Functions** - Consider async prefix for clarity + +```javascript +async function fetchAnalysisResults() {} +async function loadConfiguration() {} +``` + +**Event Handlers** - Use `handle` or `on` prefix + +```javascript +function handleFileSelect(event) {} +function onAnalysisComplete(results) {} +``` + +**Class Names** - Use PascalCase nouns + +```javascript +class FileAnalysisService {} +class OramaVectorService {} +class ErrorHandler {} +``` + +**Constants** - Use UPPER_SNAKE_CASE for true constants + +```javascript +const MAX_FILE_SIZE = 50 * 1024 * 1024; +const DEFAULT_TIMEOUT = 5000; +const ERROR_CODES = { ... }; +``` + +**Private Methods** - Use underscore prefix (convention, not enforcement) + +```javascript +class Service { + publicMethod() {} + _privateHelper() {} +} +``` + +### Avoid Ambiguous Names + +```javascript +// Bad: Unclear what these represent +const data = getInfo(); +const temp = process(input); +const result = doStuff(); + +// Good: Clear purpose +const analysisResults = getFileAnalysis(); +const temporaryFilePath = createTempFile(input); +const folderMatchScore = calculateSimilarity(); +``` + +## Error Handling Standards + +**See `docs/ERROR_HANDLING_GUIDE.md` for comprehensive error handling patterns and decision tree.** + +### Use Centralized Error Utilities + +```javascript +const { + createErrorResponse, + createSuccessResponse, + withErrorHandling +} = require('../shared/errorHandlingUtils'); + +// Wrap IPC handlers +const handler = withErrorHandling( + async (filePath) => { + const result = await analyzeFile(filePath); + return result; + }, + { + context: 'FileAnalysis', + operation: 'analyze-file' + } +); +``` + +### Standardized Error Format + +```javascript +// Always return consistent error structure +try { + const result = await operation(); + return createSuccessResponse(result); +} catch (error) { + logger.error('Operation failed', { error: error.message }); + return createErrorResponse(error.message, ERROR_CODES.OPERATION_FAILED, { + originalError: error.name + }); +} +``` + +### Error Logging Pattern + +```javascript +// Standard error logging format +logger.error('Failed to analyze file', { + filePath, + error: error.message, + stack: error.stack, + code: error.code, + context: 'additional context' +}); +``` + +### Never Swallow Errors + +```javascript +// Bad: Silent failure +try { + await riskyOperation(); +} catch (error) { + // Nothing - error is lost! +} + +// Good: Log and handle +try { + await riskyOperation(); +} catch (error) { + logger.error('Risky operation failed', { error: error.message }); + // Rethrow, return error, or provide fallback + throw error; +} +``` + +## Promise Handling + +### Prefer async/await Over .then() + +```javascript +// Bad: Promise chains with .then() +function processFile(filePath) { + return readFile(filePath) + .then((content) => extractText(content)) + .then((text) => analyzeText(text)) + .then((results) => saveResults(results)) + .catch((error) => handleError(error)); +} + +// Good: async/await for clarity +async function processFile(filePath) { + try { + const content = await readFile(filePath); + const text = await extractText(content); + const results = await analyzeText(text); + await saveResults(results); + return results; + } catch (error) { + handleError(error); + throw error; + } +} +``` + +### Parallel vs Sequential + +```javascript +// Sequential (when order matters or operations depend on each other) +async function processInOrder() { + const file1 = await readFile('file1.txt'); + const file2 = await readFile('file2.txt'); // Waits for file1 + return [file1, file2]; +} + +// Parallel (when operations are independent) +async function processInParallel() { + const [file1, file2] = await Promise.all([ + readFile('file1.txt'), + readFile('file2.txt') // Runs concurrently + ]); + return [file1, file2]; +} +``` + +### Always Handle Promise Rejections + +```javascript +// Bad: Unhandled rejection +someAsyncFunction(); // If this rejects, it's unhandled + +// Good: Proper handling +someAsyncFunction().catch((error) => { + logger.error('Async operation failed', { error: error.message }); +}); + +// Better: Use try-catch in async context +async function caller() { + try { + await someAsyncFunction(); + } catch (error) { + logger.error('Async operation failed', { error: error.message }); + } +} +``` + +## Code Formatting + +StratoSort Core standardizes formatting with Prettier and linting with ESLint. Use the following +scripts before committing: + +```bash +npm run format +npm run lint +``` + +CI enforces `format:check` and `lint`, so ensure both pass locally. + +### Indentation + +- **Standard**: 2 spaces (already configured in project) +- Use consistent indentation across all files +- Configure editor to show whitespace + +### Semicolons + +- **Standard**: Use semicolons (project convention) +- Prevents ASI (Automatic Semicolon Insertion) bugs + +```javascript +// Good: Explicit semicolons +const value = getValue(); +doSomething(value); + +// Bad: Missing semicolons (can cause issues) +const value = getValue(); +doSomething(value); +``` + +### Line Length + +- **Target**: 80-100 characters +- **Maximum**: 120 characters +- Break long lines for readability + +```javascript +// Bad: Too long +const result = await someVeryLongFunctionName( + parameter1, + parameter2, + parameter3, + parameter4, + parameter5 +); + +// Good: Broken for readability +const result = await someVeryLongFunctionName( + parameter1, + parameter2, + parameter3, + parameter4, + parameter5 +); +``` + +### Object and Array Formatting + +```javascript +// Short objects: Single line +const point = { x: 10, y: 20 }; + +// Long objects: Multi-line with trailing comma +const config = { + timeout: 5000, + retries: 3, + model: 'llama3.2:1b', + verbose: true +}; + +// Arrays: Multi-line for multiple items +const supportedFormats = ['.pdf', '.docx', '.txt', '.jpg']; +``` + +### Blank Lines + +```javascript +// Use blank lines to separate logical sections +function processFile(filePath) { + // Validation + if (!filePath) { + throw new Error('File path required'); + } + + // Processing + const content = readFile(filePath); + const analysis = analyzeContent(content); + + // Return results + return { + filePath, + analysis, + timestamp: Date.now() + }; +} +``` + +## JSDoc Documentation + +### Document All Public Methods + +```javascript +/** + * Analyzes a file and generates organization suggestions + * @param {string} filePath - Absolute path to the file + * @param {Object} options - Analysis options + * @param {boolean} [options.skipCache=false] - Skip cache lookup + * @param {number} [options.timeout=30000] - Timeout in milliseconds + * @returns {Promise} Analysis results with suggestions + * @throws {Error} If file cannot be read or analysis fails + * @example + * const result = await analyzeFile('/path/to/file.pdf', { skipCache: true }); + */ +async function analyzeFile(filePath, options = {}) { + // Implementation +} +``` + +### Type Definitions + +```javascript +/** + * @typedef {Object} AnalysisResult + * @property {string} filePath - Path to analyzed file + * @property {string} category - Detected category + * @property {string[]} keywords - Extracted keywords + * @property {number} confidence - Confidence score (0-1) + * @property {string} suggestedFolder - Suggested destination folder + */ + +/** + * @typedef {Object} FileMetadata + * @property {string} name - File name + * @property {number} size - File size in bytes + * @property {Date} created - Creation date + * @property {Date} modified - Last modified date + */ +``` + +### Class Documentation + +```javascript +/** + * Service for analyzing files using AI models + * Handles document and image analysis with caching + */ +class FileAnalysisService { + /** + * Creates a new FileAnalysisService + * @param {LlamaService} llamaService - Llama service instance + */ + constructor(llamaService) { + this.llamaService = llamaService; + } + + /** + * Analyzes a document file + * @param {string} filePath - Path to document + * @returns {Promise} Analysis results + */ + async analyzeDocument(filePath) { + // Implementation + } +} +``` + +## Function Length and Complexity + +### Function Length Guidelines + +- **Target**: < 50 lines +- **Warning**: 50-100 lines +- **Refactor**: > 100 lines + +```javascript +// Bad: Long function doing too much (150+ lines) +async function processAndOrganizeFiles(files) { + // Validation (20 lines) + // File reading (30 lines) + // Analysis (40 lines) + // Suggestion generation (30 lines) + // Organization (30 lines) + // Error handling (20 lines) +} + +// Good: Split into focused functions +async function processAndOrganizeFiles(files) { + validateFiles(files); + const contents = await readFiles(files); + const analyses = await analyzeFiles(contents); + const suggestions = generateSuggestions(analyses); + await organizeFiles(suggestions); +} +``` + +### Reduce Nesting Depth + +- **Target**: < 3 levels +- **Warning**: 3-4 levels +- **Refactor**: > 4 levels + +```javascript +// Bad: Deep nesting (5 levels) +function processItem(item) { + if (item) { + if (item.isValid) { + if (item.hasData) { + if (item.data.length > 0) { + if (item.data[0].isReady) { + return process(item.data[0]); + } + } + } + } + } +} + +// Good: Early returns (2 levels max) +function processItem(item) { + if (!item) return null; + if (!item.isValid) return null; + if (!item.hasData) return null; + if (item.data.length === 0) return null; + if (!item.data[0].isReady) return null; + + return process(item.data[0]); +} +``` + +### Cyclomatic Complexity + +- **Target**: < 10 +- **Warning**: 10-15 +- **Refactor**: > 15 + +```javascript +// Bad: High complexity (many branches) +function determineAction(type, status, user) { + if (type === 'A') { + if (status === 'active') { + if (user.isAdmin) { + return 'admin-action-A'; + } else { + return 'user-action-A'; + } + } else { + return 'inactive-A'; + } + } else if (type === 'B') { + // More branches... + } + // ... many more conditions +} + +// Good: Use lookup tables or strategy pattern +const ACTION_MAP = { + 'A-active-admin': 'admin-action-A', + 'A-active-user': 'user-action-A', + 'A-inactive': 'inactive-A' + // ... +}; + +function determineAction(type, status, user) { + const role = user.isAdmin ? 'admin' : 'user'; + const key = `${type}-${status}-${role}`; + return ACTION_MAP[key] || 'default-action'; +} +``` + +## Import Organization + +### Import Path Standards + +**Standard:** Use consistent relative paths based on file location. + +**Main Process (CommonJS):** + +- From `src/main/`: `require('../shared/logger')` +- From `src/main/services/`, `src/main/utils/`, etc.: `require('../../shared/logger')` + +**Renderer Process (ES6):** + +- From `src/renderer/`: `import { logger } from '../shared/logger'` +- From `src/renderer/components/`, `src/renderer/phases/`: + `import { logger } from '../../shared/logger'` +- From `src/renderer/utils/`, `src/renderer/contexts/`: `import { logger } from '../shared/logger'` +- From `src/renderer/components/ui/`, `src/renderer/components/organize/`: + `import { logger } from '../../../shared/logger'` + +The examples above demonstrate the standard import path patterns used throughout the codebase. + +### Import Order + +1. Node built-ins +2. External packages +3. Internal absolute imports +4. Internal relative imports + +```javascript +// 1. Node built-ins +const fs = require('fs').promises; +const path = require('path'); + +// 2. External packages +const { getInstance: getLlamaService } = require('../services/LlamaService'); +const sharp = require('sharp'); + +// 3. Internal absolute imports (from src/) +const { logger } = require('../shared/logger'); +const { ERROR_CODES } = require('../shared/errorHandlingUtils'); + +// 4. Internal relative imports +const { extractText } = require('./documentExtractors'); +const { analyzeImage } = require('./imageAnalysis'); +``` + +### Remove Unused Imports + +```javascript +// Bad: Unused imports +const fs = require('fs'); // Not used +const path = require('path'); // Used +const { logger } = require('../shared/logger'); // Not used + +// Good: Only what's needed +const path = require('path'); +``` + +### Group Related Imports + +```javascript +// Good: Grouped by purpose +// File operations +const fs = require('fs').promises; +const path = require('path'); + +// AI services +const { getInstance: getLlamaService } = require('../services/LlamaService'); +const { analyzeWithLLM } = require('./llmService'); + +// Utilities +const { logger } = require('../shared/logger'); +const { sanitizePath } = require('../shared/pathSanitization'); +``` + +## Code Review Checklist + +Before submitting code, verify: + +### Naming + +- [ ] Variables have descriptive names +- [ ] Functions use verb+noun pattern +- [ ] Boolean variables use is/has/should prefix +- [ ] Constants use UPPER_SNAKE_CASE +- [ ] No single-letter variables (except loop counters) + +### Error Handling + +- [ ] All promises have .catch() or try-catch +- [ ] Errors are logged with context +- [ ] Error responses use standard format +- [ ] No swallowed errors + +### Formatting + +- [ ] Consistent 2-space indentation +- [ ] Semicolons used consistently +- [ ] Line length < 120 characters +- [ ] Blank lines separate logical sections + +### Documentation + +- [ ] Public functions have JSDoc +- [ ] Complex logic has inline comments +- [ ] Type definitions for complex objects +- [ ] Examples for non-obvious usage + +### Function Quality + +- [ ] Functions < 100 lines +- [ ] Nesting depth < 4 levels +- [ ] Single responsibility principle +- [ ] Extracted helper functions for repeated logic + +### Imports + +- [ ] Organized by category +- [ ] No unused imports +- [ ] No commented-out imports + +### Testing + +- [ ] Unit tests for new functions +- [ ] Edge cases covered +- [ ] Error cases tested + +## Automated Checks + +Configure ESLint rules: + +```json +{ + "rules": { + "max-len": ["warn", { "code": 120 }], + "max-lines-per-function": ["warn", 100], + "max-depth": ["warn", 4], + "complexity": ["warn", 15], + "no-unused-vars": "error", + "semi": ["error", "always"], + "indent": ["error", 2] + } +} +``` + +Configure Prettier: + +```json +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "printWidth": 100, + "trailingComma": "es5" +} +``` diff --git a/AllDocs/CONFIG.md b/AllDocs/CONFIG.md new file mode 100644 index 00000000..e866c595 --- /dev/null +++ b/AllDocs/CONFIG.md @@ -0,0 +1,271 @@ +# Configuration Reference + +This document lists all environment variables and configuration options available in StratoSort +Core. + +## Model & OCR Setup + +The AI stack runs in-process (node-llama-cpp + Orama). You only need models and OCR: + +### Setup Commands + +```bash +npm run setup:deps # Download models (best-effort) +npm run setup:models # Download GGUF models +npm run setup:models:check # Verify models +``` + +### Setup Script Flags (Advanced) + +These variables affect setup scripts and `postinstall` behavior: + +| Variable | Default | Description | +| ---------------------- | ------- | --------------------------------------------------------------------- | +| `SKIP_APP_DEPS` | `0` | Skip `postinstall` native rebuild and all setup scripts | +| `SKIP_TESSERACT_SETUP` | `0` | Skip Tesseract setup (used by setup scripts and background setup) | +| `MINIMAL_SETUP` | `0` | Skip optional model downloads during setup (`setup:models --minimal`) | + +### Default AI Models + +StratoSort Core uses these GGUF models out of the box (configurable in Settings → AI Configuration): + +| Role | Default Model | Purpose | +| ------------- | ----------------------------------- | ----------------------------------------------------- | +| **Text** | `Qwen2.5-7B-Instruct-Q4_K_M.gguf` | Document analysis, chat, smart folders (128K context) | +| **Vision** | `llava-v1.6-mistral-7b-Q4_K_M.gguf` | Image understanding and OCR | +| **Embedding** | `nomic-embed-text-v1.5-Q8_0.gguf` | Semantic search (768 dimensions) | + +To change defaults before install, edit `src/shared/aiModelConfig.js` or set env vars: +`STRATOSORT_TEXT_MODEL`, `STRATOSORT_VISION_MODEL`, `STRATOSORT_EMBEDDING_MODEL`. See +`src/shared/modelRegistry.js` for the full catalog. + +### Installing Additional Models from Settings + +Users can add new models via **Settings → Local AI Engine**: + +- **Download Base Models** — One-click download of the three default models (text, vision, + embedding) +- **Model Management → Add Model** — Install any model from the registry by typing its exact + filename (e.g. `Qwen2.5-7B-Instruct-Q4_K_M.gguf`, `all-MiniLM-L6-v2-Q4_K_M.gguf`) +- **Default AI Models** — Select which installed model to use for text, vision, and embeddings + +All installable models must be in `modelRegistry.js`; the catalog lists download URLs and metadata. + +### Fastest Models with Best Performance (2025) + +Based on node-llama-cpp docs and llama.cpp ecosystem: + +| Category | Fastest options | Best quality-speed balance | +| ------------- | ----------------------------------------------------------- | ---------------------------------------------------- | +| **Text** | `Llama-3.2-3B-Instruct-Q4_K_M`, `Phi-3-mini-4k-instruct-q4` | `Qwen2.5-7B-Instruct-Q4_K_M` (default, 128K context) | +| **Vision** | `llava-phi-3-mini-Q4_K_M` (smaller, CPU-friendly) | `llava-v1.6-mistral-7b-Q4_K_M` (default) | +| **Embedding** | `all-MiniLM-L6-v2-Q4_K_M.gguf` (~21MB, 384 dim) | `nomic-embed-text-v1.5-Q8_0.gguf` (default, 768 dim) | + +**Quantization:** Per node-llama-cpp, `Q4_K_M` offers the best balance between compression and +quality; `Q5_K_M` is a close second. `Q8_0` is higher quality but slower; smaller models (3B, Phi-3) +are faster than 7B on limited hardware. + +**Embedding dimensions:** Changing embedding models changes vector dimensions (e.g. 384 for +All-MiniLM vs 768 for Nomic). The index must be rebuilt when switching; the UI prompts for this. + +## Environment Variables + +### OCR (Tesseract) + +| Variable | Default | Description | +| ----------------- | -------- | ---------------------------------------------------------------- | +| `TESSERACT_PATH` | - | Override path to the Tesseract binary (skips auto-install logic) | +| `TESSDATA_PREFIX` | Auto-set | Path to tessdata directory; auto-set for embedded runtime | + +### Performance Tuning + +| Variable | Default | Description | +| -------------------------- | ------- | ----------------------------------------------- | +| `MAX_IMAGE_CACHE` | `300` | Maximum cached image analyses (50-1000) | +| `AUTO_ORGANIZE_BATCH_SIZE` | `10` | Files processed per auto-organize batch (1-100) | + +### Startup & Health Checks + +| Variable | Default | Description | +| ----------------------- | ------- | ------------------------------------------------ | +| `SERVICE_CHECK_TIMEOUT` | `2000` | Timeout (ms) for preflight service health checks | + +### GPU Configuration + +| Variable | Default | Description | +| --------------------------------- | ------- | ----------------------------------------- | +| `STRATOSORT_FORCE_SOFTWARE_GPU` | `0` | Set to `1` to force software rendering | +| `ELECTRON_FORCE_SOFTWARE` | `0` | Alternative software rendering flag | +| `ANGLE_BACKEND` | `d3d11` | ANGLE backend for GPU rendering (Windows) | +| `STRATOSORT_GL_IMPLEMENTATION` | - | Override OpenGL implementation | +| `STRATOSORT_IGNORE_GPU_BLOCKLIST` | `0` | Ignore Electron GPU blocklist (advanced) | + +### Feature Flags + +| Variable | Default | Description | +| ------------------------------------- | ------- | ---------------------------------------------- | +| `STRATOSORT_DEBUG` | `0` | Enable debug mode with verbose logging | +| `STRATOSORT_ENABLE_TELEMETRY` | `0` | Enable anonymous telemetry collection | +| `STRATOSORT_REDACT_PATHS` | `0` | Redact file/folder paths in the UI (demo-safe) | +| `STRATOSORT_GRAPH_ENABLED` | `1` | Master toggle for graph visualization | +| `STRATOSORT_GRAPH_CLUSTERS` | `1` | Cluster visualization | +| `STRATOSORT_GRAPH_SIMILARITY_EDGES` | `1` | File-to-file similarity edges | +| `STRATOSORT_GRAPH_MULTI_HOP` | `1` | Multi-hop expansion | +| `STRATOSORT_GRAPH_PROGRESSIVE_LAYOUT` | `1` | Progressive disclosure for large graphs | +| `STRATOSORT_GRAPH_KEYBOARD_NAV` | `1` | Keyboard navigation in graph | +| `STRATOSORT_GRAPH_CONTEXT_MENUS` | `1` | Right-click context menus on nodes | + +### Logging + +| Variable | Default | Description | +| ------------------------- | ------- | ---------------------------------------------------- | +| `STRATOSORT_CONSOLE_LOGS` | `0` | Enable console logging in production (`1` to enable) | + +### Development + +| Variable | Default | Description | +| ------------------------------------ | ------------ | --------------------------------------------- | +| `NODE_ENV` | `production` | Set to `development` for dev mode features | +| `REACT_DEVTOOLS` | `false` | Set to `true` to enable React DevTools in dev | +| `STRATOSORT_SCAN_STRUCTURE_DELAY_MS` | - | Dev-only delay for folder scan IPC (ms) | + +### Runtime Configuration + +| Variable | Default | Description | +| ------------------------ | ------- | ------------------------------------------------------------------------------- | +| `STRATOSORT_RUNTIME_DIR` | - | Override bundled runtime root (deprecated; only used for embedded OCR binaries) | + +The AI stack runs fully in-process. No external runtime staging is required. + +## Performance Constants + +All timing and tuning constants are centralized in `src/shared/performanceConstants.js`. These are +organized into categories: + +### TIMEOUTS (milliseconds) + +| Constant | Value | Description | +| -------------------- | ------- | -------------------------------------- | +| `DEBOUNCE_INPUT` | 300 | Input debounce delay | +| `AI_ANALYSIS_SHORT` | 30,000 | Short AI analysis timeout | +| `AI_ANALYSIS_MEDIUM` | 60,000 | Medium AI analysis timeout | +| `AI_ANALYSIS_LONG` | 120,000 | Long AI analysis timeout | +| `AI_ANALYSIS_BATCH` | 300,000 | Batch analysis timeout (5 min) | +| `ANALYSIS_LOCK` | 300,000 | Max lock duration before force release | +| `GLOBAL_ANALYSIS` | 600,000 | Max total analysis time (10 min) | + +### RETRY Configuration + +| Constant | Value | Description | +| --------------------- | ---------- | ------------------------------ | +| `MAX_ATTEMPTS_LOW` | 2 | Low-priority retry attempts | +| `MAX_ATTEMPTS_MEDIUM` | 3 | Medium-priority retry attempts | +| `MAX_ATTEMPTS_HIGH` | 5 | High-priority retry attempts | +| `FILE_OPERATION` | 3 attempts | File operation retry config | +| `AI_ANALYSIS` | 2 attempts | AI analysis retry config | + +### CACHE Limits + +| Constant | Value | Description | +| --------------------- | ----- | ------------------------- | +| `MAX_FILE_CACHE` | 500 | Maximum cached files | +| `MAX_IMAGE_CACHE` | 300 | Maximum cached images | +| `MAX_EMBEDDING_CACHE` | 1000 | Maximum cached embeddings | +| `MAX_ANALYSIS_CACHE` | 200 | Maximum cached analyses | + +### BATCH Processing + +| Constant | Value | Description | +| ------------------------- | ----- | -------------------------------- | +| `MAX_CONCURRENT_FILES` | 5 | Max files processed concurrently | +| `MAX_CONCURRENT_ANALYSIS` | 3 | Max concurrent AI analyses | +| `EMBEDDING_BATCH_SIZE` | 50 | Embeddings per batch | +| `EMBEDDING_PARALLEL_SIZE` | 10 | Parallel embedding operations | + +### THRESHOLDS + +| Constant | Value | Description | +| ---------------------------------- | ----- | ----------------------------- | +| `CONFIDENCE_LOW` | 0.3 | Low confidence threshold | +| `CONFIDENCE_MEDIUM` | 0.6 | Medium confidence threshold | +| `CONFIDENCE_HIGH` | 0.8 | High confidence threshold | +| `DEFAULT_CONFIDENCE_PERCENT` | 70 | Default document confidence | +| `DEFAULT_IMAGE_CONFIDENCE_PERCENT` | 75 | Default image confidence | +| `FOLDER_MATCH_CONFIDENCE` | 0.55 | Min score for folder matching | + +### FILE_SIZE Limits + +| Constant | Value | Description | +| ---------------------- | ------ | --------------------------------- | +| `MAX_DOCUMENT_SIZE` | 50 MB | Maximum document file size | +| `MAX_IMAGE_SIZE` | 20 MB | Maximum image file size | +| `MAX_UPLOAD_SIZE` | 100 MB | Maximum upload size | +| `LARGE_FILE_THRESHOLD` | 10 MB | Threshold for large file handling | + +### CONCURRENCY + +| Constant | Value | Description | +| ----------------- | ----- | --------------------------------- | +| `MIN_WORKERS` | 1 | Minimum analysis workers | +| `DEFAULT_WORKERS` | 3 | Default analysis workers | +| `MAX_WORKERS` | 8 | Maximum analysis workers | +| `FOLDER_SCAN` | 50 | Concurrent folder scan operations | + +## Configuration Files + +### User Settings + +User settings are persisted in the application's data directory: + +- **Windows**: `%APPDATA%/stratosort/settings.json` +- **macOS**: `~/Library/Application Support/stratosort/settings.json` +- **Linux**: `~/.config/stratosort/settings.json` + +### Configuration Schema + +Configuration validation is defined in `src/shared/config/configSchema.js`. The schema ensures type +safety and provides default values for all settings. + +## Modifying Configuration + +### Via Environment Variables + +Set environment variables before launching the application: + +```bash +# Linux/macOS +export MAX_IMAGE_CACHE=500 +./stratosort + +# Windows (PowerShell) +$env:MAX_IMAGE_CACHE = "500" +.\stratosort.exe +``` + +### Via Settings UI + +Most user-facing settings can be configured through the Settings phase in the application UI. + +## Troubleshooting + +### GPU Issues + +If experiencing rendering problems: + +```bash +export STRATOSORT_FORCE_SOFTWARE_GPU=1 +``` + +### Model Download Issues + +If AI analysis fails due to missing models: + +```bash +npm run setup:models +npm run setup:models:check +``` + +### Vector DB Issues + +The Orama index is stored locally and rebuilt on demand. If results look stale, re-run analysis or +trigger a rebuild from the app UI. diff --git a/AllDocs/DEPENDENCY_BOUNDARIES.md b/AllDocs/DEPENDENCY_BOUNDARIES.md new file mode 100644 index 00000000..873dffc6 --- /dev/null +++ b/AllDocs/DEPENDENCY_BOUNDARIES.md @@ -0,0 +1,21 @@ +# Dependency Boundaries + +This document defines main/renderer/shared boundaries to prevent cross-layer coupling. + +## Rules + +1. **Renderer must not import main-only modules** (e.g., `src/main/*`). +2. **Main must not import renderer-only modules** (e.g., `src/renderer/*`). +3. **Shared modules** (`src/shared/*`) must avoid Node APIs that are not available in the renderer. +4. **IPC is the only bridge** between main and renderer. Use typed IPC contracts. + +## Allowed Imports + +- Renderer: `src/shared/*`, `src/renderer/*` +- Main: `src/shared/*`, `src/main/*` +- Shared: `src/shared/*` only + +## Enforcement + +- Prefer IPC channels and payload schemas for cross-process data. +- Add lint rules for import boundaries when feasible. diff --git a/AllDocs/DI_PATTERNS.md b/AllDocs/DI_PATTERNS.md new file mode 100644 index 00000000..f2fe1687 --- /dev/null +++ b/AllDocs/DI_PATTERNS.md @@ -0,0 +1,152 @@ +# Dependency Injection Patterns + +This document describes the dependency injection (DI) patterns used in the StratoSort Core codebase. + +## Overview + +The codebase uses a centralized DI container (`ServiceContainer`) for managing service dependencies. +All services should be accessed through the container rather than direct instantiation or +`getInstance()` calls. + +## ServiceContainer + +The `ServiceContainer` class (`src/main/services/ServiceContainer.js`) provides: + +- **Singleton services**: Created once and reused +- **Transient services**: Created fresh each time +- **Lazy initialization**: Services created on first request +- **Circular dependency detection** +- **Graceful shutdown** + +## Service IDs + +All services are registered with unique identifiers in `ServiceIds`: + +```javascript +const { container, ServiceIds } = require('./ServiceContainer'); + +// Available service IDs: +ServiceIds.ORAMA_VECTOR; // Orama vector database +ServiceIds.SETTINGS; // Application settings +ServiceIds.LLAMA_SERVICE; // Llama LLM service +ServiceIds.PARALLEL_EMBEDDING; // Parallel embedding processor +ServiceIds.EMBEDDING_CACHE; // Embedding cache +ServiceIds.FOLDER_MATCHING; // Folder matching service +ServiceIds.ORGANIZATION_SUGGESTION; // Organization suggestions +ServiceIds.AUTO_ORGANIZE; // Auto-organize service +ServiceIds.ANALYSIS_HISTORY; // Analysis history +ServiceIds.UNDO_REDO; // Undo/redo service +ServiceIds.PROCESSING_STATE; // Processing state tracker +``` + +## Usage Patterns + +### Recommended: Container Resolution + +```javascript +const { container, ServiceIds } = require('./services/ServiceContainer'); + +// Resolve a service +const vectorDb = container.resolve(ServiceIds.ORAMA_VECTOR); +const llama = container.resolve(ServiceIds.LLAMA_SERVICE); +``` + +### Legacy: getInstance() (Deprecated) + +Some services still export `getInstance()` for backward compatibility. **Do not use in new code**: + +```javascript +// DEPRECATED - avoid in new code +const { getInstance } = require('./LlamaService'); +const llama = getInstance(); +``` + +## Registering New Services + +### Singleton Services + +```javascript +container.registerSingleton(ServiceIds.MY_SERVICE, (c) => { + // c is the container - use it to resolve dependencies + return new MyService({ + vectorDb: c.resolve(ServiceIds.ORAMA_VECTOR), + settings: c.resolve(ServiceIds.SETTINGS) + }); +}); +``` + +### Transient Services + +```javascript +container.registerTransient('myTransient', () => { + return new TransientService(); +}); +``` + +## Service Integration + +The `ServiceIntegration` class (`src/main/services/ServiceIntegration.js`) handles: + +1. Registering all core services with the container +2. Initializing services in dependency order +3. Providing backward-compatible property access +4. Coordinating service shutdown + +### Initialization + +```javascript +const ServiceIntegration = require('./services/ServiceIntegration'); + +const integration = new ServiceIntegration(); +await integration.initialize(); + +// Access via container (recommended) +const vectorDb = container.resolve(ServiceIds.ORAMA_VECTOR); + +// Or via integration properties (backward compatible) +const vectorDb = integration.oramaVectorService; +``` + +## Testing + +The DI container makes testing easier by allowing mock injection: + +```javascript +// In tests, register mocks before resolving +container.registerInstance(ServiceIds.ORAMA_VECTOR, mockVectorDb); + +// Your service will receive the mock +const folderMatching = container.resolve(ServiceIds.FOLDER_MATCHING); +``` + +## Migration Guide + +When migrating from `getInstance()` to container resolution: + +1. Import the container and ServiceIds +2. Replace `getInstance()` calls with `container.resolve()` +3. Ensure the service is registered in `ServiceIntegration._registerCoreServices()` + +### Before + +```javascript +const { getInstance } = require('./LlamaService'); +const llama = getInstance(); +await llama.generateEmbedding(text); +``` + +### After + +```javascript +const { container, ServiceIds } = require('./ServiceContainer'); +const llama = container.resolve(ServiceIds.LLAMA_SERVICE); +await llama.generateEmbedding(text); +``` + +## Best Practices + +1. **Always use the container** for service access in new code +2. **Accept dependencies via constructor** rather than grabbing singletons +3. **Register services in ServiceIntegration** for proper lifecycle management +4. **Use ServiceIds** constants instead of string literals +5. **Mock services in tests** by registering test instances diff --git a/AllDocs/ERROR_HANDLING_GUIDE.md b/AllDocs/ERROR_HANDLING_GUIDE.md new file mode 100644 index 00000000..4f0f68fe --- /dev/null +++ b/AllDocs/ERROR_HANDLING_GUIDE.md @@ -0,0 +1,674 @@ +# Error Handling Guide + +## Overview + +This guide provides comprehensive patterns and best practices for error handling across the +StratoSort Core codebase. It consolidates all error handling utilities and provides clear guidance +on when to use each pattern. + +--- + +## Table of Contents + +1. [Error Handling Utilities](#error-handling-utilities) +2. [When to Use Each Pattern](#when-to-use-each-pattern) +3. [Decision Tree](#decision-tree) +4. [Code Examples](#code-examples) +5. [Best Practices](#best-practices) +6. [Common Patterns](#common-patterns) + +--- + +## Error Handling Utilities + +### 1. `errorHandlingUtils.js` (Shared) + +**Location:** `src/shared/errorHandlingUtils.js` + +**Purpose:** Centralized error handling for general async operations + +**Functions:** + +- `createErrorResponse(message, code, details)` - Standardized error response +- `createSuccessResponse(data)` - Standardized success response +- `withErrorHandling(fn, options)` - Wrapper for async functions +- `withRetry(fn, options)` - Retry logic with exponential backoff +- `withTimeout(promise, timeoutMs, operationName)` - Promise timeout wrapper + +**Use When:** + +- General async operations +- Service-level error handling +- Operations that need retry logic +- Operations that need timeout protection + +### 2. `withErrorLogging.js` (IPC) + +**Location:** `src/main/ipc/withErrorLogging.js` + +**Purpose:** IPC handler-specific error handling + +**Functions:** + +- `withErrorLogging(logger, fn)` - Wraps IPC handlers with error logging +- `withValidation(logger, schema, handler)` - Adds validation to IPC handlers +- `createErrorResponse(error, context)` - IPC-specific error response +- `createSuccessResponse(data)` - IPC-specific success response + +**Use When:** + +- IPC handlers (`ipcMain.handle`) +- Need validation with Zod schemas +- IPC-specific error formatting required + +### 3. `promiseUtils.js` (Main Utils) + +**Location:** `src/main/utils/promiseUtils.js` + +**Purpose:** Promise-specific utilities + +**Functions:** + +- `withTimeout(promise, timeoutMs, operationName)` - Promise timeout +- `withRetry(fn, options)` - Retry with exponential backoff +- `delay(ms)` - Simple delay utility + +**Use When:** + +- Need timeout protection for promises +- Need retry logic for operations +- Simple delay needed + +### 4. `safeAccess.js` (Main Utils) + +**Location:** `src/main/utils/safeAccess.js` + +**Purpose:** Safe property access and function execution + +**Functions:** + +- `safeGet(obj, path, defaultValue)` - Safe nested property access +- `safeCall(fn, args, defaultValue)` - Safe function execution +- `safeFilePath(path)` - Safe file path validation + +**Use When:** + +- Accessing nested object properties +- Calling functions that might throw +- Validating file paths + +### 5. Error Boundaries (Renderer) + +**Locations:** + +- `src/renderer/components/GlobalErrorBoundary.jsx` +- `src/renderer/components/PhaseErrorBoundary.jsx` +- `src/renderer/components/ErrorBoundary.jsx` + +**Purpose:** React error boundaries for UI error handling + +**Use When:** + +- React component errors +- Need graceful UI fallback +- Phase-specific error handling + +--- + +## When to Use Each Pattern + +### Pattern 1: Standard Async Function Wrapper + +**Utility:** `withErrorHandling` from `errorHandlingUtils.js` + +**Use When:** + +- General async operations +- Service methods +- Operations that should return standardized responses + +**Example:** + +```javascript +const { withErrorHandling } = require('../../shared/errorHandlingUtils'); + +const analyzeFile = withErrorHandling( + async (filePath) => { + const result = await performAnalysis(filePath); + return result; + }, + { + context: 'FileAnalysis', + operation: 'analyze-file' + } +); +``` + +### Pattern 2: IPC Handler Wrapper + +**Utility:** `withErrorLogging` from `withErrorLogging.js` + +**Use When:** + +- IPC handlers (`ipcMain.handle`) +- Need automatic error logging +- IPC communication + +**Example:** + +```javascript +const { withErrorLogging } = require('./withErrorLogging'); + +ipcMain.handle( + IPC_CHANNELS.ANALYSIS.ANALYZE_DOCUMENT, + withErrorLogging(logger, async (event, filePath) => { + const result = await analyzeFile(filePath); + return createSuccessResponse(result); + }) +); +``` + +### Pattern 3: IPC Handler with Validation + +**Utility:** `withValidation` from `withErrorLogging.js` + +**Use When:** + +- IPC handlers need input validation +- Using Zod schemas +- Need structured validation errors + +**Example:** + +```javascript +const { withValidation } = require('./withErrorLogging'); +const z = require('zod'); + +const schema = z.object({ + filePath: z.string().min(1), + options: z.object({}).optional() +}); + +ipcMain.handle( + IPC_CHANNELS.ANALYSIS.ANALYZE_DOCUMENT, + withValidation(logger, schema, async (event, { filePath, options }) => { + const result = await analyzeFile(filePath, options); + return createSuccessResponse(result); + }) +); +``` + +### Pattern 4: Retry Logic + +**Utility:** `withRetry` from `errorHandlingUtils.js` or `promiseUtils.js` + +**Use When:** + +- Network operations +- Transient failures expected +- Operations that can be safely retried + +**Example:** + +```javascript +const { withRetry } = require('../../shared/errorHandlingUtils'); + +const result = await withRetry( + async () => { + return await fetchDataFromAPI(); + }, + { + maxAttempts: 3, + delay: 1000, + backoff: 2, + operationName: 'FetchData', + shouldRetry: (error) => { + return error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT'; + } + } +); +``` + +### Pattern 5: Timeout Protection + +**Utility:** `withTimeout` from `promiseUtils.js` or `errorHandlingUtils.js` + +**Use When:** + +- Long-running operations +- Network requests +- Operations that might hang + +**Example:** + +```javascript +const { withTimeout } = require('../utils/promiseUtils'); + +const result = await withTimeout( + performLongOperation(), + 30000, // 30 seconds + 'LongOperation' +); +``` + +### Pattern 6: Safe Property Access + +**Utility:** `safeGet` from `safeAccess.js` + +**Use When:** + +- Accessing nested object properties +- Properties might not exist +- Need default values + +**Example:** + +```javascript +const { safeGet } = require('../utils/safeAccess'); + +const userName = safeGet(user, 'profile.name', 'Anonymous'); +const settings = safeGet(config, 'app.settings', {}); +``` + +### Pattern 7: Safe Function Execution + +**Utility:** `safeCall` from `safeAccess.js` + +**Use When:** + +- Calling functions that might throw +- Need fallback values +- Optional operations + +**Example:** + +```javascript +const { safeCall } = require('../utils/safeAccess'); + +const result = await safeCall(async () => await riskyOperation(), [], { + default: 'fallback' +}); +``` + +### Pattern 8: React Error Boundaries + +**Utility:** Error Boundary components + +**Use When:** + +- React component errors +- Need UI fallback +- Prevent full app crash + +**Example:** + +```javascript +import GlobalErrorBoundary from './components/GlobalErrorBoundary'; + +function App() { + return ( + + + + ); +} +``` + +--- + +## Decision Tree + +``` +Start: Need error handling? +│ +├─ Is it an IPC handler? +│ ├─ Yes → Use `withErrorLogging` or `withValidation` +│ │ └─ Need validation? → Use `withValidation` +│ │ └─ No validation? → Use `withErrorLogging` +│ │ +│ └─ No → Continue +│ +├─ Is it a React component? +│ ├─ Yes → Use Error Boundary +│ │ └─ Global? → `GlobalErrorBoundary` +│ │ └─ Phase-specific? → `PhaseErrorBoundary` +│ │ +│ └─ No → Continue +│ +├─ Need retry logic? +│ ├─ Yes → Use `withRetry` +│ │ └─ Also need timeout? → Combine with `withTimeout` +│ │ +│ └─ No → Continue +│ +├─ Need timeout protection? +│ ├─ Yes → Use `withTimeout` +│ │ +│ └─ No → Continue +│ +├─ Accessing nested properties? +│ ├─ Yes → Use `safeGet` +│ │ +│ └─ No → Continue +│ +├─ Calling function that might throw? +│ ├─ Yes → Use `safeCall` or try/catch +│ │ +│ └─ No → Continue +│ +└─ General async operation? + └─ Yes → Use `withErrorHandling` +``` + +--- + +## Code Examples + +### Example 1: Service Method with Error Handling + +```javascript +const { withErrorHandling } = require('../../shared/errorHandlingUtils'); + +class FileAnalysisService { + async analyzeFile(filePath) { + return withErrorHandling( + async () => { + // Your logic here + const result = await performAnalysis(filePath); + return result; + }, + { + context: 'FileAnalysisService', + operation: 'analyzeFile', + onError: (error) => { + // Custom error handling + logger.error('Custom error handling', { filePath }); + } + } + )(); + } +} +``` + +### Example 2: IPC Handler with Validation + +```javascript +const { withValidation } = require('./withErrorLogging'); +const z = require('zod'); + +const analyzeSchema = z.object({ + filePath: z.string().min(1), + options: z + .object({ + includeMetadata: z.boolean().optional() + }) + .optional() +}); + +ipcMain.handle( + IPC_CHANNELS.ANALYSIS.ANALYZE_DOCUMENT, + withValidation(logger, analyzeSchema, async (event, { filePath, options }) => { + const result = await analyzeFile(filePath, options); + return createSuccessResponse(result); + }) +); +``` + +### Example 3: Retry with Timeout + +```javascript +const { withRetry } = require('../../shared/errorHandlingUtils'); +const { withTimeout } = require('../utils/promiseUtils'); + +async function fetchWithRetryAndTimeout() { + return await withRetry( + async () => { + return await withTimeout( + fetchDataFromAPI(), + 10000, // 10 second timeout + 'FetchData' + ); + }, + { + maxAttempts: 3, + delay: 1000, + operationName: 'FetchData' + } + ); +} +``` + +### Example 4: Safe Property Access + +```javascript +const { safeGet } = require('../utils/safeAccess'); + +function getUserDisplayName(user) { + // Safe access with fallback + return safeGet(user, 'profile.displayName', safeGet(user, 'email', 'Anonymous')); +} +``` + +### Example 5: Error Boundary Usage + +```javascript +import PhaseErrorBoundary from './components/PhaseErrorBoundary'; + +function DiscoverPhase() { + return ( + + + + ); +} +``` + +--- + +## Best Practices + +### 1. Always Log Errors + +```javascript +// Bad: Silent failure +try { + await operation(); +} catch (error) { + // Nothing +} + +// Good: Log errors +try { + await operation(); +} catch (error) { + logger.error('Operation failed', { + error: error.message, + stack: error.stack + }); + throw error; +} +``` + +### 2. Use Standardized Responses + +```javascript +// Bad: Inconsistent response format +return { success: true, data: result }; +return { ok: true, result }; +return result; + +// Good: Standardized format +return createSuccessResponse(result); +return createErrorResponse('Error message', ERROR_CODES.FILE_NOT_FOUND); +``` + +### 3. Provide Context in Errors + +```javascript +// Bad: Generic error +throw new Error('Failed'); + +// Good: Contextual error +throw new Error(`Failed to analyze file: ${filePath}`, { + filePath, + errorCode: ERROR_CODES.ANALYSIS_FAILED +}); +``` + +### 4. Use Appropriate Error Codes + +```javascript +// Use ERROR_CODES from errorHandlingUtils.js +const { ERROR_CODES } = require('../../shared/errorHandlingUtils'); + +return createErrorResponse('File not found', ERROR_CODES.FILE_NOT_FOUND, { + filePath +}); +``` + +### 5. Handle Errors at the Right Level + +```javascript +// Bad: Catching and swallowing at low level +function helper() { + try { + riskyOperation(); + } catch (error) { + // Swallowed + } +} + +// Good: Let errors bubble up to appropriate handler +function helper() { + return riskyOperation(); // Let caller handle +} +``` + +### 6. Use Error Boundaries for UI Errors + +```javascript +// Always wrap components that might error + + + +``` + +--- + +## Common Patterns + +### Pattern: Try-Catch with Logging + +```javascript +try { + const result = await operation(); + return result; +} catch (error) { + logger.error('Operation failed', { + error: error.message, + stack: error.stack, + context: 'additional context' + }); + throw error; // Re-throw or return error response +} +``` + +### Pattern: Validation Before Operation + +```javascript +if (!filePath || typeof filePath !== 'string') { + return createErrorResponse('Invalid file path', ERROR_CODES.INVALID_INPUT, { + filePath + }); +} + +try { + const result = await processFile(filePath); + return createSuccessResponse(result); +} catch (error) { + logger.error('File processing failed', { filePath, error: error.message }); + return createErrorResponse(error.message, ERROR_CODES.FILE_READ_ERROR); +} +``` + +### Pattern: Retry with Exponential Backoff + +```javascript +const { withRetry } = require('../../shared/errorHandlingUtils'); + +const result = await withRetry(async () => await networkOperation(), { + maxAttempts: 3, + delay: 1000, // Initial delay: 1 second + backoff: 2 // Double each time + // Attempt 1: 1s delay + // Attempt 2: 2s delay + // Attempt 3: 4s delay +}); +``` + +### Pattern: Timeout with Fallback + +```javascript +const { withTimeout } = require('../utils/promiseUtils'); + +try { + const result = await withTimeout( + slowOperation(), + 5000, // 5 second timeout + 'SlowOperation' + ); + return result; +} catch (error) { + if (error.message.includes('timed out')) { + logger.warn('Operation timed out, using fallback'); + return fallbackValue; + } + throw error; +} +``` + +--- + +## Error Response Format + +All error responses follow this structure: + +```javascript +{ + success: false, + error: "Error message", + code: "ERROR_CODE", + details: { + // Additional context + } +} +``` + +All success responses follow this structure: + +```javascript +{ + success: true, + data: { + // Response data + } +} +``` + +--- + +## Summary + +1. **IPC Handlers** → Use `withErrorLogging` or `withValidation` +2. **React Components** → Use Error Boundaries +3. **General Async** → Use `withErrorHandling` +4. **Need Retry** → Use `withRetry` +5. **Need Timeout** → Use `withTimeout` +6. **Safe Access** → Use `safeGet` or `safeCall` +7. **Always Log** → Never swallow errors silently +8. **Standardize** → Use `createErrorResponse` and `createSuccessResponse` + +--- + +**Last Updated:** 2026-02-09 +**Version:** 1.0 diff --git a/AllDocs/FEATURES_GRAPH.md b/AllDocs/FEATURES_GRAPH.md new file mode 100644 index 00000000..232878e7 --- /dev/null +++ b/AllDocs/FEATURES_GRAPH.md @@ -0,0 +1,57 @@ +# Knowledge Graph Features + +## Overview + +The **Knowledge Graph** in StratoSort Core provides an interactive visualization of your document +organization. It moves beyond simple file lists to show you how your documents are connected +semantically. + +## Core Features + +### 🕸️ Interactive Visualization + +- **React Flow Integration**: Smooth, zoomable canvas interface. +- **Auto-Layout**: Uses ELK algorithms to organize nodes intelligently. +- **Context Menus**: Right-click on any node for quick actions (Open, Reveal in Explorer, Organize). + +### 🧠 Semantic Clustering + +The graph doesn't just show files; it shows _meaning_. + +- **Similarity Edges**: Lines connect files that are semantically similar, with thickness indicating + strength of relationship. +- **Clusters**: Files are automatically grouped into color-coded clusters based on topic. +- **Query Nodes**: When you search, your query appears as a central node, showing exactly which + files match your intent. + +### 🔍 Exploration Tools + +- **Focus Mode**: Double-click any node to center the graph on it and reveal its specific + connections. +- **Expansion**: Dynamically load more connections as you explore "hops" away from your starting + point. +- **Filtering**: Use the legend to toggle visibility of clusters, file types, or confidence levels. + +## Technical Implementation + +### Frontend (`Renderer`) + +- `GraphView.jsx`: Main container component. +- `useGraphState.js`: Manages the complex state of nodes and edges. +- `UnifiedSearchModal.jsx`: Bridges the search experience with graph visualization. + +### Backend (`Main Process`) + +- `GraphService.js`: Handles the heavy lifting of graph construction. +- `OramaVectorService`: Provides the raw vector data and similarity scores. +- `ReRankerService`: Refines connections to ensure high-quality edges. + +## Usage Guide + +1. **Search**: Start by typing a query in the search bar. +2. **Visual Results**: Switch to the "Graph" tab to see your results visualized. +3. **Explore**: + - **Click** a node to select it. + - **Double-click** to focus and expand. + - **Drag** nodes to rearrange your view. +4. **Action**: Right-click to open files or apply organization suggestions. diff --git a/AllDocs/GETTING_STARTED.md b/AllDocs/GETTING_STARTED.md new file mode 100644 index 00000000..1d2583a1 --- /dev/null +++ b/AllDocs/GETTING_STARTED.md @@ -0,0 +1,214 @@ +# Getting Started with StratoSort Core + +

+ Setup Time + Difficulty +

+ +This guide will walk you through setting up **StratoSort Core** on your local machine. + +## Table of Contents + +- [System Requirements](#system-requirements) +- [Installation](#installation) +- [Detailed Setup Instructions](#detailed-setup-instructions) +- [Release Process (Developers)](#release-process-developers) +- [Troubleshooting](#troubleshooting) + +## System Requirements + +Before you begin, ensure your system meets the following requirements. StratoSort Core runs a fully +local, in-process AI stack and does not rely on external servers. + +### System Dependencies Chart + +| Component | Purpose | Requirement / Setup | +| :-------------- | :----------------------- | :-------------------------------------------------------- | +| **Node.js** | Core Application Runtime | v18+ (Included in installer; dev needs node installed) | +| **GGUF Models** | Local AI Inference | Auto-downloaded on first use (`npm run setup:models`) | +| **Orama** | Vector Search DB | Bundled in the app (no external install) | +| **Tesseract** | Image Text Recognition | Auto-installed (Win/Mac/Linux) with tesseract.js fallback | +| **GPU** | AI Acceleration | Optional but recommended (4GB+ VRAM) | + +--- + +## Installation + +### 1. Clone the Repository + +```bash +git clone https://github.com/iLevyTate/StratoSortCore.git +cd StratoSortCore +``` + +### 2. Install Dependencies + +```bash +npm ci +``` + +> Note: `npm install`/`npm ci` runs `postinstall`, which rebuilds native modules and runs setup +> scripts (best-effort). To skip, set `SKIP_APP_DEPS=1` before installing. + +### 3. Start the Application + +```bash +npm run dev +``` + +--- + +## Detailed Setup Instructions + +On the first launch, the application will attempt to automatically set up all necessary AI +dependencies (models + OCR). + +### Platform Notes + +- **Windows:** Models download on first use. Tesseract auto-installs via `winget`/`chocolatey` and + falls back to bundled `tesseract.js`. Click “Install All (Background)” on first launch to download + models. +- **macOS/Linux:** Models download on first use. Tesseract auto-installs via brew/apt and falls back + to `tesseract.js` if native install is unavailable. +- **In-app setup:** The Settings panel lets you configure models and verify OCR status. + +### Setup Workflow (No-CLI first run) + +The following flowchart illustrates the automated setup process that runs on first launch: + +```mermaid +graph TD + Start([Start App / Setup]) --> CheckModels{Models Found?} + CheckModels -- No --> DownloadModels[Download Models] + CheckModels -- Yes --> CheckTess + DownloadModels --> CheckTess + + %% Tesseract Flow + CheckTess{Tesseract OCR?} + CheckTess -- No --> AutoTess[Try Auto-Install
(winget/brew/apt)] + AutoTess -- Success --> Ready + AutoTess -- Failed --> ManualTess[User Action Required:
Manual Install] + CheckTess -- Yes --> Ready([App Ready 🚀]) + ManualTess --> Ready + + classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px; + classDef success fill:#e1f5fe,stroke:#01579b,stroke-width:2px; + classDef warning fill:#fff3e0,stroke:#e65100,stroke-width:2px; + + class Ready success; + class ManualTess warning; +``` + +### Startup Preflight Checks + +On each launch, StratoSort Core runs preflight checks to verify model availability, vector DB +readiness, and OCR health. If a service is slow to respond, you can raise the timeout using +`SERVICE_CHECK_TIMEOUT` (ms). Check logs if the app reports missing or unreachable dependencies. + +### Tesseract OCR Setup + +StratoSort Core uses **Tesseract OCR** to read text from images. The setup script attempts to +install Tesseract automatically and falls back to the bundled `tesseract.js` implementation if +native install is unavailable: + +- **Windows**: Uses `winget` or `chocolatey` +- **macOS**: Uses `brew` +- **Linux**: Uses `apt-get` + +#### Language Data (tessdata) + +Tesseract requires language data files to recognize text. The app configures this automatically: + +- **System-installed Tesseract**: Uses the system tessdata directory (e.g. + `/usr/share/tesseract-ocr/5/tessdata`, `/opt/homebrew/share/tessdata`, or + `C:\Program Files\Tesseract-OCR\tessdata`). + +To use additional languages beyond English, install language packs for your Tesseract installation. + +If automatic installation fails, please install Tesseract manually: + +- **Windows**: [Install Tesseract via UB-Mannheim](https://github.com/UB-Mannheim/tesseract/wiki) or + run `winget install Tesseract-OCR.Tesseract` +- **macOS**: `brew install tesseract` +- **Linux**: `sudo apt-get install tesseract-ocr` + +After manual installation: + +1. Restart the application. +2. If Tesseract is installed in a non-standard location, set `TESSERACT_PATH` to the binary path. +3. If language data is in a non-standard location, set `TESSDATA_PREFIX` to the tessdata directory. + +#### Fallback Behavior + +If native Tesseract is unavailable, the app falls back to `tesseract.js`. This fallback: + +- Works without a system install +- Supports English only (`eng`) +- Can be slower on large images + +Check logs for `[OCR]` messages to confirm which backend is active. + +#### Adding Additional Languages + +To use languages other than English: + +1. **System Tesseract**: + - Windows: Download `.traineddata` files from https://github.com/tesseract-ocr/tessdata and place + them in `C:\Program Files\Tesseract-OCR\tessdata\`. + - macOS: `brew install tesseract-lang` + - Linux: `sudo apt-get install tesseract-ocr-[lang]` (e.g., `tesseract-ocr-fra`) +2. **Verify**: Run `tesseract --list-langs` to see available languages. + +Note: The app currently defaults to English (`eng`). Multi-language selection is planned. + +### One-click background setup + +On first launch, open the AI Setup modal and click **Install All (Background)**. This will: + +- Download the recommended text/vision/embedding GGUF models +- Ensure OCR is available (bundled Tesseract or JS fallback) + +The UI stays usable while installs/downloads run in the background. + +--- + +## Release Process (Developers) + +For release steps (runtime staging, checksums, and notes), see the [Release Guide](RELEASING.md). + +## Troubleshooting + +If you encounter issues during setup: + +1. **Check Logs**: + - Windows: `%APPDATA%/stratosort/logs/` + - macOS: `~/Library/Logs/stratosort/` + - Linux: `~/.config/stratosort/logs/` + +2. **Verify Models**: + - Run `npm run setup:models:check` to verify models are available. + +3. **Run Setup Script Manually**: + - You can trigger dependency setup manually via: + ```bash + npm run setup:deps + npm run setup:models + ``` + +--- + +## Next Steps + +Once setup is complete, you're ready to start organizing your files: + +1. **Add Smart Folders** - Configure folders with keywords and descriptions +2. **Enable Auto-Organization** - Turn on folder watching in Settings +3. **Explore the Knowledge Graph** - Visualize relationships between your files + +For more information, see the [main README](../README.md) or explore the [documentation](README.md). + +--- + +

+ Need help? Open an issue on GitHub. +

diff --git a/AllDocs/INSTALL_GUIDE.md b/AllDocs/INSTALL_GUIDE.md new file mode 100644 index 00000000..5f92e5d1 --- /dev/null +++ b/AllDocs/INSTALL_GUIDE.md @@ -0,0 +1,153 @@ +# StratoSort Core — Install Guide + +**One download. No command line. No extra software.** + +This guide is for people who download the installer from +[Releases](https://github.com/iLevyTate/StratoSortCore/releases). If you're a developer building +from source, see [GETTING_STARTED.md](GETTING_STARTED.md). + +--- + +## Before You Start + +| Requirement | Details | +| :------------- | :-------------------------------------------------------- | +| **Windows** | Windows 10 or 11 (64-bit) | +| **macOS** | macOS 10.15 or later (Intel or Apple Silicon) | +| **RAM** | 8GB minimum, 16GB recommended | +| **Disk space** | ~5GB for AI models (downloaded on first run) | +| **Internet** | Needed once to download AI models; app runs offline after | + +--- + +## Step 1: Download + +1. Go to [StratoSort Core Releases](https://github.com/iLevyTate/StratoSortCore/releases) +2. Download the installer for your system: + - **Windows:** `StratoSortCore-Setup-X.X.X.exe` or `StratoSortCore-X.X.X-win-x64.exe` + - **macOS:** `StratoSortCore-X.X.X-mac-arm64.dmg` (Apple Silicon) or + `StratoSortCore-X.X.X-mac-x64.dmg` (Intel) +3. (Optional but recommended) Download the checksum file for your platform: + - **Windows:** `checksums-windows.sha256` + - **macOS:** `checksums-macos.sha256` + +--- + +## Optional: Verify Download Integrity + +### Windows (PowerShell) + +```powershell +Get-FileHash .\StratoSortCore-Setup-X.X.X.exe -Algorithm SHA256 +``` + +Compare the hash output to the matching entry in `checksums-windows.sha256`. + +### macOS (Terminal) + +```bash +shasum -a 256 StratoSortCore-X.X.X-mac-arm64.dmg +``` + +Compare the hash output to the matching entry in `checksums-macos.sha256`. + +--- + +## Step 2: Run the Installer + +### Windows + +1. Double-click the downloaded file. +2. If Windows SmartScreen shows **"Windows protected your PC"**: + - Click **"More info"** + - Click **"Run anyway"** +3. Follow the installer (choose install location, shortcuts, etc.). +4. Launch StratoSort Core from the Start menu or desktop shortcut. + +> **Why this warning?** The app is not code-signed yet. SmartScreen flags unsigned apps. You can +> review the [source code](https://github.com/iLevyTate/StratoSortCore) to verify it before running. + +### macOS + +1. Double-click the downloaded DMG. +2. Drag **StratoSort Core** to Applications. +3. Eject the DMG and open StratoSort Core from Applications. +4. If you see **"StratoSort Core cannot be opened because the developer cannot be verified"**: + - **Option A:** Right-click the app → **Open** → **Open** in the dialog. + - **Option B:** Open **System Settings** → **Privacy & Security** → scroll down → click **Open + Anyway** next to StratoSort Core. + +> **Why this warning?** The app is not notarized by Apple yet. Gatekeeper blocks unsigned apps by +> default. You can review the [source code](https://github.com/iLevyTate/StratoSortCore) before +> running. + +--- + +## Step 3: First Launch — Download AI Models + +On first launch, StratoSort will ask you to download AI models. **This is the only download you +approve in the app.** + +1. When the setup wizard appears, click **"Download Base Models"** or **"Download recommended + models"**. +2. Wait for the models to download (~3–5GB). Progress is shown in the app. +3. When complete, you can start using StratoSort. + +**What gets downloaded?** Text (Qwen2.5 7B), vision (LLaVA 1.6 Mistral), and embedding (Nomic) +models in GGUF format (~5GB total). They are stored locally and never sent anywhere. The vision +runtime is already bundled—no extra download for that. + +--- + +## Summary + +| Step | What you do | +| :------------ | :------------------------------------------ | +| **Download** | One installer file from GitHub Releases | +| **Install** | Run it; allow it if your OS shows a warning | +| **First run** | Approve model download in the app | +| **Done** | Use StratoSort; everything runs locally | + +**No terminal. No Python. No Docker. No API keys.** + +--- + +## Troubleshooting + +### Windows: "This app has been blocked for your protection" + +- Click **More info** → **Run anyway**. +- If you don't see "Run anyway", your organization may have blocked unsigned apps. You would need to + install on a personal device or ask your IT admin. + +### macOS: App won't open even after Right-click → Open + +- Go to **System Settings** → **Privacy & Security**. +- Scroll to the **Security** section. +- Look for a message about StratoSort Core being blocked and click **Open Anyway**. + +### Models failed to download + +- Check your internet connection. +- Try again from **Settings** → **Model management** → **Download Base Models**. +- Ensure you have ~5GB free disk space. + +### Vision / image analysis not working + +- The vision runtime is bundled. If it still fails, check **Settings** → **Llama** for GPU or model + status. +- Ensure the vision model was downloaded (part of the base models). + +--- + +## Security + +StratoSort Core: + +- Runs 100% locally after model download +- Does not collect or send any data +- Is open source: you can inspect the code at + [github.com/iLevyTate/StratoSortCore](https://github.com/iLevyTate/StratoSortCore) + +The "developer cannot be verified" / SmartScreen warnings appear because the app is not yet signed +with a publisher certificate. That affects trust prompts only—not how the app works. diff --git a/AllDocs/IPC_CONTRACTS.md b/AllDocs/IPC_CONTRACTS.md new file mode 100644 index 00000000..6daaa499 --- /dev/null +++ b/AllDocs/IPC_CONTRACTS.md @@ -0,0 +1,43 @@ +# IPC Contracts + +This document describes the IPC payload contracts used between the renderer and main processes. +Schemas are defined in code and should be treated as the source of truth. + +## Sources of Truth + +- Input validation schemas: `src/main/ipc/validationSchemas.js` +- Event payload schemas: `src/shared/ipcEventSchemas.js` +- IPC handlers and channel registration: `src/main/ipc/*` +- Renderer event validation: `src/renderer/store/middleware/ipcMiddleware.js` + +## Input Validation (Renderer -> Main) + +Most IPC handlers use `createHandler()` or `withValidation()` to validate inputs. When Zod is +available, `validationSchemas.js` defines the expected shapes for: + +- Settings payloads +- File operations (single + batch) +- Analysis inputs (single + batch) +- Suggestions and feedback payloads +- Embeddings search and query inputs + +If Zod is not available, a fallback validator performs basic checks for critical fields. + +## Event Payloads (Main -> Renderer) + +Event payloads are validated in `ipcMiddleware.js` using schemas in `ipcEventSchemas.js`. If a +schema exists for a channel, invalid payloads are logged and passed through to avoid breaking +functionality. + +## Adding or Modifying a Contract + +1. Add/modify a schema in `src/main/ipc/validationSchemas.js` or `src/shared/ipcEventSchemas.js`. +2. Update handlers to reference the schema (or to normalize inputs). +3. Update tests if the payload shape changes. +4. Ensure new payloads are documented here. + +## Conventions + +- Use normalized paths and IDs (see `src/shared/pathSanitization.js`). +- Keep query strings trimmed and bounded in length. +- Prefer structured errors with `errorType` and `errorCode`. diff --git a/AllDocs/LEARNING_GUIDE.md b/AllDocs/LEARNING_GUIDE.md new file mode 100644 index 00000000..12afca69 --- /dev/null +++ b/AllDocs/LEARNING_GUIDE.md @@ -0,0 +1,557 @@ +# StratoSort Core Codebase Learning Guide + +Welcome to the StratoSort Core codebase! This guide is designed to serve as a comprehensive map for +understanding what you have built. It breaks down the software from multiple engineering +perspectives, ranging from high-level architecture to specific design patterns and critical system +concepts. + +--- + +## Table of Contents + +1. [Architecture View](#1-architecture-view) (The Blueprint) +2. [Design Pattern View](#2-design-pattern-view) (The Building Blocks) +3. [Data Engineering View](#3-data-engineering-view) (The Flow) +4. [AI & ML View](#4-ai--ml-view) (The Brain) +5. [Resilience Engineering View](#5-resilience-engineering-view) (The Safety Nets) +6. [Security View](#6-security-view) (The Shields) +7. [Glossary of Terms](#7-glossary-of-terms) +8. [Code Examples](#8-code-examples) + +--- + +## 1. Architecture View + +**Pattern:** **Multi-Process Architecture (Electron)** This is not a standard web app. It is a +distributed system running locally on one machine. + +- **Main Process (Node.js):** + - **Role:** The "Server" or "Backend". It has full OS access (files, processes). + - **Responsibility:** It orchestrates everything—launching AI models, reading files, managing the + database, and creating windows. + - **Key File:** `src/main/simple-main.js` (The Entry Point). + +- **Renderer Process (React/Chrome):** + - **Role:** The "Client" or "Frontend". It lives in a sandboxed web page. + - **Responsibility:** Displaying UI, managing user state (Redux), and asking the Main process to + do heavy lifting. + - **Key File:** `src/renderer/App.js`. + +- **IPC (Inter-Process Communication):** + - **Role:** The "Network Bridge". Since Main and Renderer are separate processes (with separate + memory), they cannot share variables. They must send messages to each other. + - **Mechanism:** Asynchronous message passing (like HTTP requests but internal). + +**Diagram:** + +``` +[Renderer Process (UI)] <===> [IPC Bridge (Security)] <===> [Main Process (Backend)] +(React, Redux) (preload.js) (Node.js, Services, DB) +``` + +--- + +## 2. Design Pattern View + +Your codebase isn't just a script; it uses established "Gang of Four" (GoF) design patterns to solve +common software problems. + +### A. Singleton Pattern + +**Concept:** Ensure a class has only one instance and provide a global point of access to it. +**Usage:** Essential for managing shared resources like database connections or AI models. +**Examples in Code:** + +- **`ServiceContainer.js`**: A massive Registry that holds Singletons. It ensures we don't create 10 + vector DB instances, but reuse the same one everywhere. +- **`LlamaService.js`**: The AI client is a Singleton (`getInstance`). We only want one in-process + model manager at a time. + +### B. Observer Pattern + +**Concept:** An object (Subject) maintains a list of dependents (Observers) and notifies them of +state changes. **Usage:** Decoupling components. The component changing the settings doesn't need to +know _who_ is listening, just that it changed. **Examples in Code:** + +- **`OramaVectorService`**: Extends `EventEmitter`. It emits `'online'`, `'dimension-mismatch'`, and + `'embedding-blocked'`. The UI listens for these events to show the status connection badge. +- **`SettingsService`**: When `settings.json` changes on disk, it emits an event so the app updates + live without a restart. + +### C. Strategy Pattern + +**Concept:** Define a family of algorithms, encapsulate each one, and make them interchangeable. +**Usage:** Handling different file types without a giant `if/else` block. **Examples in Code:** + +- **`documentExtractors.js`**: We have different "strategies" for extracting text. + - _PDF Strategy_: `extractTextFromPdf` + - _Word Strategy_: `extractTextFromDocx` + - _Image Strategy_: `ocrPdfIfNeeded` (OCR) The main analysis service just says "Extract", and the + correct strategy is chosen based on the file extension. + +### D. Factory Pattern + +**Concept:** Create objects without specifying the exact class of object that will be created. +**Usage:** Simplifying complex setup logic. **Examples in Code:** + +- **`ServiceContainer.js`**: Uses "Factory Functions" (`registerSingleton('name', factoryFn)`) to + lazy-load services only when they are needed. +- **`createWindow.js`**: A factory that produces a configured Browser Window with all the correct + security settings and event listeners attached. + +--- + +## 3. Data Engineering View + +How does data move and persist? + +**A. State Management (Redux)** + +- **Concept:** Single Source of Truth. +- **Implementation:** The frontend doesn't store data in random variables. It stores it in a giant + tree called the **Store**. +- **Flow:** `Action (User Clicks)` -> `Reducer (Updates State)` -> `View (Re-renders)`. + +**B. Vector Database (Orama)** + +- **Concept:** High-dimensional data storage. Standard databases (SQL) store text. Vector DBs store + _meaning_. +- **Data:** We store "Embeddings" (arrays of floating-point numbers like `[0.12, -0.98, 0.33...]`). +- **Querying:** We don't search for "keyword matches". We search for "Cosine Similarity" + (mathematical closeness). +- **Key File:** `src/main/services/OramaVectorService.js`. + +**C. Caching Strategy** + +- **Concept:** Don't do the same work twice. +- **Implementation:** + - **File Analysis Cache:** `FileAnalysisService.js` keeps a map of `path + size + mtime`. If a + file hasn't changed, we return the previous AI result instantly (0ms) instead of re-running the + LLM (3000ms). + - **Query Cache:** `OramaVectorService` caches vector search results to keep the UI snappy. + +--- + +## 4. AI & ML View + +This is the "Brain" of the operation. + +**A. RAG (Retrieval Augmented Generation)** + +- **Concept:** Giving the AI "memory" by retrieving relevant data before asking it a question. +- **Flow:** + 1. User asks: "Where are my tax documents?" + 2. App converts question to Vector. + 3. App queries Orama for files with similar vectors (Retrieval). + 4. App sends the _question_ + _file summaries_ to the Llama engine (Generation). +- **Code:** `FolderMatchingService.js` implements the retrieval part of this flow. + +**B. Embeddings** + +- **Concept:** Translating human language into machine language (vectors). +- **Implementation:** We use GGUF embedding models via node-llama-cpp to turn file content into + vectors. + +**C. Local Inference** + +- **Concept:** Running AI on the user's GPU, not in the cloud. +- **Engineering Challenge:** This is resource-intensive. +- **Solution:** `ParallelEmbeddingService.js` manages concurrency. It ensures we don't crash the + user's computer by trying to process 100 files at once. It uses a semaphore/queue system to limit + active jobs. + +**D. Knowledge Visualization (Explainable AI)** + +- **Concept:** Making the "black box" of AI decisions transparent to the user. +- **Implementation:** The "Knowledge Graph" visualizes high-dimensional vector relationships in 2D + space. +- **Key Engineering Decisions:** + - **Brandes-Koepf Layout:** We use the `BRANDES_KOEPF` algorithm (via ELK.js) instead of standard + force-directed layouts. This forces nodes into clean, straight lines and prioritized ranks, + preventing the "hairball" or "outlier" effect common in graph visualizations. + - **Metadata Injection:** The edges (lines) connecting nodes are not just lines; they carry + metadata (`category`, `commonTags`). This allows the UI to display "Relationship Analysis" + tooltips explaining _why_ two files are connected (e.g., "Both Images", "95% Similar"). + - **Color Encoding:** Nodes are programmatically color-coded by file type (using a shared + `FileCategory` logic) to turn the graph into an instant visual map. + +--- + +## 5. Resilience Engineering View + +How does the software handle failure? (This distinguishes "scripts" from "systems"). + +**A. Circuit Breaker Pattern** + +- **Problem:** If the vector DB fails, asking it for data 100 times a second will just generate 100 + errors and maybe freeze the app. +- **Solution:** The `CircuitBreaker` (`CircuitBreaker.js`) monitors failures. + - _Closed (Normal):_ Requests go through. + - _Open (Broken):_ If 5 errors happen in a row, the breaker "trips". Requests fail _immediately_ + without trying the DB. + - _Half-Open (Recovery):_ After 30s, it lets one request through to test if the DB is back. + +**B. Deferred Retry Pattern** + +- **Problem:** A transient storage or analysis failure could drop an operation. +- **Solution:** The in-process Orama storage reduces external dependency failures. When a transient + error does occur, operations return actionable errors and are retried via bounded queues (e.g., + embedding queues) rather than a persistent offline queue on disk. + +**C. Dead Letter Handling** + +- **Concept:** What happens to items that _never_ succeed? +- **Implementation:** If a file fails analysis repeatedly, it is marked with a specific error state + rather than crashing the batch processor. + +--- + +## 6. Security View + +**A. Context Isolation** + +- **Concept:** The "Sandbox". +- **Implementation:** The renderer (web page) **cannot** require Node.js modules. It doesn't know + `fs` (filesystem) exists. It can only use `window.electronAPI`. + +**B. The Preload Bridge** + +- **Key File:** `src/preload/preload.js`. +- **Mechanism:** + - It "Preloads" before the website runs. + - It has access to both Node.js and the DOM. + - It creates a safe API (`contextBridge.exposeInMainWorld`). +- **Sanitization:** The `SecureIPCManager` strips dangerous characters from file paths to prevent + "Path Traversal Attacks" (e.g., trying to read `../../../../etc/passwd`). + +--- + +## 7. Glossary of Terms + +### General Software Engineering + +- **Async/Await:** Modern JavaScript syntax for handling operations that take time (like reading a + file or querying a database) without freezing the application. Used extensively in the Main + Process (e.g., `await fs.readFile()`). + +- **Dependency Injection (DI):** A design pattern where a class receives its dependencies from the + outside rather than creating them itself. Our `ServiceContainer` injects services like + `OramaVectorService` into `FolderMatchingService`, making testing easier. + +- **Memoization:** An optimization technique where the result of a function is cached. If the + function is called again with the same inputs, the cached result is returned instantly. Used in + React (`React.memo`) and backend (`FileAnalysisService` caches results). + +- **Singleton:** A pattern ensuring a class has only one instance. Used for `LlamaService` (one AI + engine) and `SettingsService` (one source of truth). + +- **Circuit Breaker:** A resilience pattern that detects failures and prevents cascading errors. If + the vector DB fails repeatedly, the breaker "trips" and stops requests for a recovery period. + +### Electron & Architecture + +- **Main Process:** The entry point of an Electron app running in Node.js with full OS access. + Handles file I/O, spawning processes, managing windows, and IPC events. + +- **Renderer Process:** The web page displayed in the application window running Chromium. + Responsible for UI (React), user interactions, and local state (Redux). Sandboxed for security. + +- **IPC (Inter-Process Communication):** The communication mechanism between Main and Renderer + processes using named channels (e.g., `files:analyze`). Methods include `invoke` (request/reply) + and `send` (fire and forget). + +- **Preload Script:** A script that runs before the web page loads with access to both Node.js APIs + and the DOM. Creates a secure bridge (`contextBridge`) to expose safe methods to the Renderer. + +- **Context Bridge:** An Electron API that isolates the Renderer from the Main process context, + preventing security attacks. We expose `window.electronAPI` via the Context Bridge. + +### AI & Data Science + +- **LLM (Large Language Model):** An AI model trained on vast amounts of text to understand and + generate human language. We use GGUF models via node-llama-cpp. + +- **Inference:** Running live data through a trained AI model to get a prediction. When you click + "Analyze", the app performs local inference on your GPU. + +- **Embedding (Vector):** A representation of text as a list of numbers (e.g., `[0.1, -0.5, ...]`). + Similar concepts have mathematically similar vectors, enabling semantic search. + +- **RAG (Retrieval-Augmented Generation):** A technique where an AI is given relevant external data + (retrieved from a database) to help it answer accurately. We retrieve similar folders from Orama, + then ask the AI where a file belongs. + +- **Cosine Similarity:** A metric measuring how similar two vectors are. Used by Orama to rank + folder matches. + +- **Brandes-Koepf:** An algorithm used in graph visualization to minimize edge crossings and + straighten long edges in layered graphs. We use this to keep the Knowledge Graph clean and + legible. + +- **node-llama-cpp:** A native binding to llama.cpp used for in-process local inference. + +### Frontend & UI (React/Redux) + +- **Component:** A reusable, self-contained piece of UI code (e.g., `Button.jsx`, `FileList.jsx`). + +- **Hook:** A special React function (starting with `use`) that lets you access React features like + state. Examples: `useState`, `useEffect`, `useSelector`. + +- **Redux Store:** A centralized container for the entire application's state. Holds files, + settings, and analysis status. + +- **Slice:** A portion of the Redux store dedicated to a specific feature (e.g., `filesSlice`, + `uiSlice`). + +- **Tailwind CSS:** A utility-first CSS framework using pre-defined classes like `flex`, `p-4`, + `text-red-500`. + +### Project-Specific + +- **Smart Folder:** A folder configuration that includes a Vector Embedding, acting as a "magnet" + for semantically similar files. + +- **ServiceContainer:** Our custom Dependency Injection system in + `src/main/services/ServiceContainer.js` managing service lifecycle. + +- **OramaVectorService:** The service wrapper for the Orama vector database, handling embedding + validation and search caching. + +- **File Signature:** A unique string (`path + size + lastModifiedTime`) used as a cache key to + detect file changes. + +- **Zod Schema:** A data validation definition ensuring IPC data is correct before use. + +### Infrastructure & Tools + +- **Webpack:** A module bundler that takes JS, CSS, and images and bundles them into optimized + files. + +- **Jest:** JavaScript testing framework for unit tests. + +- **Playwright:** End-to-end testing tool that launches the app and simulates user interactions. + +- **ESLint / Prettier:** Code quality tools. ESLint finds bugs; Prettier formats code consistently. + +--- + +## 8. Code Examples + +This section provides concrete code snippets for common patterns in the codebase. + +### 8.1 Backend Services (Main Process) + +#### Defining a Service + +```javascript +// src/main/services/MyNewService.js +const { logger } = require('../../shared/logger'); + +class MyNewService { + constructor(dependencyA, dependencyB) { + this.depA = dependencyA; + this.depB = dependencyB; + this.initialized = false; + } + + async initialize() { + if (this.initialized) return; + logger.info('[MyNewService] Initializing...'); + // ... setup logic ... + this.initialized = true; + } + + doSomething(data) { + if (!this.initialized) throw new Error('Service not initialized'); + return this.depA.process(data); + } +} + +module.exports = MyNewService; +``` + +#### Registering with ServiceContainer + +```javascript +// src/main/services/ServiceIntegration.js +const { container, ServiceIds } = require('./ServiceContainer'); +const MyNewService = require('./MyNewService'); + +// Inside _registerCoreServices(): +if (!container.has('myNewService')) { + container.registerSingleton('myNewService', (c) => { + const depA = c.resolve(ServiceIds.ORAMA_VECTOR); + const depB = c.resolve(ServiceIds.SETTINGS); + return new MyNewService(depA, depB); + }); +} +``` + +#### Accessing a Service + +```javascript +const { container, ServiceIds } = require('./ServiceContainer'); + +// Standard Resolution (throws if missing) +const myService = container.resolve('myNewService'); + +// Safe Resolution (returns null if missing) +const maybeService = container.tryResolve('myNewService'); +if (maybeService) { + maybeService.doSomething(); +} +``` + +### 8.2 IPC (Inter-Process Communication) + +#### Creating a Handler (Backend) + +```javascript +// src/main/ipc/myFeature.js +const { createHandler } = require('./ipcWrappers'); + +function registerMyFeatureIpc({ ipcMain, IPC_CHANNELS, logger }) { + // Standard Request/Response + createHandler(ipcMain, 'my-feature:get-data', async (event, params) => { + logger.info('Received request for data', params); + const result = await someDatabaseCall(params.id); + return { success: true, data: result }; + }); + + // Streaming/Events (Backend -> Frontend) + createHandler(ipcMain, 'my-feature:start-job', async (event, params) => { + event.sender.send('my-feature:progress', { percent: 0 }); + await doLongTask(); + event.sender.send('my-feature:progress', { percent: 100 }); + return { success: true }; + }); +} + +module.exports = registerMyFeatureIpc; +``` + +#### Exposing to Frontend (Preload) + +```javascript +// src/preload/preload.js +contextBridge.exposeInMainWorld('electronAPI', { + myFeature: { + getData: (id) => ipcRenderer.invoke('my-feature:get-data', { id }), + onProgress: (callback) => { + const subscription = (event, data) => callback(data); + ipcRenderer.on('my-feature:progress', subscription); + return () => ipcRenderer.removeListener('my-feature:progress', subscription); + } + } +}); +``` + +#### Using in React (Frontend) + +```jsx +// src/renderer/components/MyComponent.jsx +import React, { useEffect, useState } from 'react'; + +export const MyComponent = () => { + const [data, setData] = useState(null); + + useEffect(() => { + const fetchData = async () => { + const result = await window.electronAPI.myFeature.getData(123); + if (result.success) setData(result.data); + }; + fetchData(); + + const unsubscribe = window.electronAPI.myFeature.onProgress((progress) => { + console.log(`Job is ${progress.percent}% done`); + }); + return () => unsubscribe(); + }, []); + + if (!data) return
Loading...
; + return
{data.name}
; +}; +``` + +### 8.3 AI & Llama Integration + +```javascript +const { container, ServiceIds } = require('./ServiceContainer'); + +async function summarizeText(text) { + const llamaService = container.resolve(ServiceIds.LLAMA_SERVICE); + const response = await llamaService.generateText({ + prompt: `Summarize this: ${text}`, + maxTokens: 512, + temperature: 0.3 + }); + return response.response; +} + +async function getVector(text) { + const embeddingService = container.resolve(ServiceIds.PARALLEL_EMBEDDING); + return await embeddingService.generateEmbedding(text); +} +``` + +### 8.4 Orama Vector Operations + +```javascript +const { container, ServiceIds } = require('./ServiceContainer'); + +async function findSimilarFolders(fileContent) { + const vectorDb = container.resolve(ServiceIds.ORAMA_VECTOR); + const embeddingService = container.resolve(ServiceIds.PARALLEL_EMBEDDING); + + const queryVector = await embeddingService.generateEmbedding(fileContent); + return await vectorDb.queryFoldersByEmbedding(queryVector, 5); +} +``` + +### 8.5 Redux State Management + +#### Creating a Slice + +```javascript +// src/renderer/store/slices/mySlice.js +import { createSlice } from '@reduxjs/toolkit'; + +const mySlice = createSlice({ + name: 'myFeature', + initialState: { items: [], loading: false }, + reducers: { + setLoading: (state, action) => { + state.loading = action.payload; + }, + addItems: (state, action) => { + state.items.push(...action.payload); + } + } +}); + +export const { setLoading, addItems } = mySlice.actions; +export default mySlice.reducer; +``` + +#### Using in Components + +```jsx +import { useDispatch, useSelector } from 'react-redux'; +import { setLoading } from '../store/slices/mySlice'; + +const MyButton = () => { + const dispatch = useDispatch(); + const isLoading = useSelector((state) => state.myFeature.loading); + + return ( + + ); +}; +``` + +--- + +_This document acts as the engineering manual for StratoSort Core. It covers the "Why" and "How" +behind the code architecture._ diff --git a/AllDocs/README.md b/AllDocs/README.md new file mode 100644 index 00000000..7e4194bd --- /dev/null +++ b/AllDocs/README.md @@ -0,0 +1,91 @@ +# StratoSort Core Documentation Index + +This directory contains comprehensive documentation for the StratoSort codebase. Use this guide to +find the right documentation for your needs. + +## Quick Links + +| Document | Description | Audience | +| -------------------------------------------------------- | ----------------------------------------------------- | -------------- | +| [CONFIG.md](./CONFIG.md) | Installation, dependencies & configuration | All users | +| [ARCHITECTURE.md](./ARCHITECTURE.md) | High-level system design and data flow | All developers | +| [LEARNING_GUIDE.md](./LEARNING_GUIDE.md) | Codebase learning guide with glossary & code examples | New developers | +| [CODE_QUALITY_STANDARDS.md](./CODE_QUALITY_STANDARDS.md) | Coding standards and style guide | All developers | +| [ERROR_HANDLING_GUIDE.md](./ERROR_HANDLING_GUIDE.md) | Error handling patterns and best practices | All developers | + +## Installation & Dependencies + +> **Note**: Model setup is handled by `setup:models` (OCR uses system Tesseract or the JS fallback). +> See [CONFIG.md](./CONFIG.md#model--ocr-setup) for details. + +- **[CONFIG.md](./CONFIG.md)** - Complete setup guide including: + - Default AI models (text, vision, embedding) + - Model download and OCR setup + - Environment variable reference + - Troubleshooting tips + +## Architecture & Design + +- **[ARCHITECTURE.md](./ARCHITECTURE.md)** - System architecture diagram showing the relationship + between Renderer, IPC, and Main processes +- **[DI_PATTERNS.md](./DI_PATTERNS.md)** - Dependency injection patterns and ServiceContainer usage +- **[LEARNING_GUIDE.md](./LEARNING_GUIDE.md)** - Comprehensive developer onboarding guide covering: + - Architecture, design patterns, and data flow + - AI/ML concepts and resilience engineering + - Expanded glossary of terms + - Code examples for common patterns + +## Development Standards + +- **[CODE_QUALITY_STANDARDS.md](./CODE_QUALITY_STANDARDS.md)** - Comprehensive style guide covering: + - Naming conventions + - Function length and complexity guidelines + - JSDoc documentation standards + - Code review checklist + +- **[ERROR_HANDLING_GUIDE.md](./ERROR_HANDLING_GUIDE.md)** - Centralized error handling patterns and + utilities + +## Testing + +- **[TESTING.md](../TESTING.md)** - **Single Source of Truth** for: + - Quick Manual QA Checklist + - Automated Test Commands + - Critical Path Strategy + - Debugging Tips + +## Active Development + +- **[GRAPH_INTEGRATION_PLAN.md](./GRAPH_INTEGRATION_PLAN.md)** - Graph visualization feature roadmap + and implementation status + +## Configuration + +Environment variables and configuration are centralized in: + +- `src/shared/performanceConstants.js` - All timing and performance tuning constants +- `src/shared/config/configSchema.js` - Configuration schema definitions +- See [CONFIG.md](./CONFIG.md) for environment variable reference + +## Directory Structure + +``` +docs/ +├── README.md # This index file +├── ARCHITECTURE.md # System design +├── CODE_QUALITY_STANDARDS.md # Style guide +├── CONFIG.md # Environment variables +├── DI_PATTERNS.md # Dependency injection +├── ERROR_HANDLING_GUIDE.md # Error patterns +├── GRAPH_INTEGRATION_PLAN.md # Graph feature roadmap +└── LEARNING_GUIDE.md # Developer onboarding (glossary + examples) +``` + +## Contributing + +When adding new documentation: + +1. Follow the naming convention: `UPPERCASE_WITH_UNDERSCORES.md` +2. Add an entry to this README.md index +3. Include a clear description of the document's purpose +4. Link to related documents where appropriate diff --git a/AllDocs/RELEASING.md b/AllDocs/RELEASING.md new file mode 100644 index 00000000..2817c883 --- /dev/null +++ b/AllDocs/RELEASING.md @@ -0,0 +1,97 @@ +# Releasing StratoSort Core + +This guide covers release packaging for both Windows and macOS, plus how GitHub Actions publishes +artifacts. + +## Release Checklist + +1. **Update release metadata** + - Bump `version` in `package.json` + - Move release notes from `CHANGELOG.md` **[Unreleased]** to the new version section +2. **Run quality gates** + - `npm run ci` +3. **Smoke-test installers** + - Windows: run `npm run dist:win`, install `StratoSortCore-Setup-*.exe` + - macOS: run `npm run dist:mac`, open `StratoSortCore-*.dmg` +4. **Verify first-run experience** + - AI setup confirms bundled OCR runtime availability + - Base-model download works from the app UI +5. **Confirm docs** + - Ensure `docs/INSTALL_GUIDE.md` matches current installer names and OS prompts + +## Tag-Triggered Releases (Recommended) + +Push a semver tag: + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +This triggers both workflows: + +- `release.yml` (Windows) +- `mac-release.yml` (macOS) + +Both upload artifacts to the GitHub release for the tag. + +## Published Artifacts + +Windows workflow publishes: + +- `StratoSortCore-Setup-*.exe` +- `StratoSortCore-*-win-*.exe` (portable) +- `latest.yml` +- `*.blockmap` +- `checksums-windows.sha256` + +macOS workflow publishes: + +- `StratoSortCore-*.dmg` +- `StratoSortCore-*.zip` +- `latest*.yml` (for updater metadata) +- `checksums-macos.sha256` + +## Manual Dist Workflow + +Use `.github/workflows/manual-dist.yml` (`workflow_dispatch`) when you need: + +- Windows-only or macOS-only rebuilds +- A draft release with combined artifacts +- Ad-hoc release testing without tagging + +## Local Dist Commands + +```powershell +npm ci +npm run dist:win +npm run dist:mac +``` + +All artifacts are written to `release/build/`. + +## Local Checksum Commands + +Windows checksum file: + +```powershell +Get-ChildItem release/build -File | + Where-Object { $_.Name -match '^StratoSortCore-.*\.(exe|blockmap)$|^latest\.yml$' } | + ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash + "$hash *$($_.Name)" + } | Out-File release/build/checksums-windows.sha256 -Encoding ASCII +``` + +macOS checksum file: + +```bash +cd release/build +shasum -a 256 StratoSortCore-*.dmg StratoSortCore-*.zip > checksums-macos.sha256 +``` + +## Notes on AI Packaging + +- The AI stack (node-llama-cpp + Orama) runs in-process. +- AI models are **not** bundled in installers; users download them in-app. +- OCR runtime support is bundled/fallback-ready via packaged runtime assets. diff --git a/AllDocs/USER_GUIDE.md b/AllDocs/USER_GUIDE.md new file mode 100644 index 00000000..21de9601 --- /dev/null +++ b/AllDocs/USER_GUIDE.md @@ -0,0 +1,223 @@ +# StratoSort Core User Guide + +This guide explains how to use StratoSort Core day-to-day, including Smart Folders, Knowledge OS, +and key settings. + +For installation help, use [INSTALL_GUIDE.md](./INSTALL_GUIDE.md). For beta testing and bug +reporting, use [BETA_TESTER_GUIDE.md](./BETA_TESTER_GUIDE.md). + +--- + +## 1) What StratoSort Does + +StratoSort uses local AI to: + +- Analyze your files by content (not just filename) +- Suggest where files belong +- Rename files using your naming rules +- Help you search by meaning using Knowledge OS +- Visualize relationships in the Knowledge Graph + +Everything runs locally after model download. + +--- + +## 2) Typical Workflow + +StratoSort is organized into five phases. You move through them in order: + +1. **Welcome** — Start screen. Choose your flow. +2. **Setup** — Create Smart Folders (the destination folders the AI routes files into). Give each + folder a plain-language description so the AI knows what belongs there. +3. **Discover** — Drag and drop files (or select a folder). The AI analyzes content, extracts + meaning, and generates organization suggestions. +4. **Organize** — Review each suggestion. Accept, reject, or edit the destination and filename. Use + Undo/Redo if you change your mind. +5. **Complete** — See a summary of everything that was organized. Undo is still available here. + +--- + +## 3) Smart Folders (Most Important First Setup) + +Smart Folders are destination folders that the AI routes files into. Each Smart Folder has a +**description** that tells the AI what kind of files belong there. The AI compares your file's +content against these descriptions to decide where it fits best. + +### Best practices + +- Create folders around real outcomes (for example: `Invoices`, `Receipts`, `Screenshots`, + `Contracts`). +- Write descriptions in plain language — pretend you're telling a coworker what goes in each folder. +- Be specific. "Monthly bills and vendor invoices" works better than "financial stuff." +- Start with 3-5 clear folders before adding more. Overlapping descriptions confuse the AI. + +### Good folder description examples + +| Folder | Description | +| :---------------- | :----------------------------------------------------- | +| **Invoices** | "Bills from vendors and monthly service invoices." | +| **Tax Documents** | "W-2, 1099, tax forms, receipts needed for filing." | +| **Project Specs** | "Requirements, architecture docs, and project briefs." | +| **Screenshots** | "Screen captures, app screenshots, and UI mockups." | + +--- + +## 4) Naming Conventions + +Open **Settings -> Default Locations -> File naming defaults**. + +You can control: + +- **Convention** + - `subject-date` + - `date-subject` + - `project-subject-date` + - `category-subject` + - `keep-original` +- **Date Format** + - `YYYY-MM-DD` + - `MM-DD-YYYY` + - `DD-MM-YYYY` + - `YYYYMMDD` +- **Case** + - `kebab-case`, `snake_case`, `camelCase`, `PascalCase`, `lowercase`, `UPPERCASE` +- **Separator** + - Use safe separators like `-` or `_` + +Tip: If you need maximum compatibility across apps and systems, prefer `kebab-case` plus +`YYYY-MM-DD`. + +--- + +## 5) Knowledge OS Search and Knowledge Graph + +Open search with **Ctrl+K** (Windows/Linux) or **Cmd+K** (macOS) — or click the Knowledge OS button +in the Discover phase — and use natural language queries like: + +- "Show invoices from last quarter" +- "Find screenshots related to onboarding" +- "Documents about pricing changes" + +### Knowledge OS tips + +- Be specific in your query (topic + time period + file type). +- If results are weak, rephrase with clearer intent. +- If semantic results seem empty, check embedding/model health in Settings. + +### Knowledge Graph tips + +- Use graph view to inspect relationships between files. +- Great for finding clusters, duplicates, and concept neighborhoods. +- Use it as an exploration tool, then open/reveal files directly. + +--- + +## 6) Settings Walkthrough + +Open **Settings** and focus on these sections: + +### AI Configuration + +- **Local AI Engine**: Check model and GPU status. +- **Default AI models**: Set text, vision, and embedding models. +- **Model Management**: Download base models or add individual models from the registry. +- **Embedding behavior / rebuild**: Rebuild index when embedding model changes. +- **Chat Persona**: Customize how the AI assistant responds. + +### Performance + +- **Auto-organize**: Enable automatic routing from downloads. +- **Background Mode**: Configure background processing behavior. +- **Graph Retrieval**: Tune graph expansion and contextual chunk settings. + +### Default Locations + +- Set where Smart Folders are created by default. +- Configure file naming defaults (convention, date format, case, separator). + +### Application + +- Launch on Startup toggle. +- Notification behavior. +- Troubleshooting Logs (Open Folder, Export Logs). +- Settings backup/restore (create, export, import). + +### Analysis History + +- View past analysis results and statistics. + +--- + +## 7) AI Model Profiles + +On first launch, the setup wizard offers two profiles: + +| Profile | Text Model | Vision Model | Embedding Model | Best For | +| :---------------------- | :----------- | :------------------- | :-------------------- | :---------------------------------- | +| **Base (Small & Fast)** | Llama 3.2 3B | LLaVA Phi-3 Mini | all-MiniLM-L6-v2 | All computers, CPU-only, low memory | +| **Better Quality** | Qwen2.5 7B | LLaVA 1.6 Mistral 7B | nomic-embed-text v1.5 | Modern hardware, 16GB+ RAM, GPU | + +You can switch models later in **Settings -> AI Configuration -> Default AI Models**. + +Changing the embedding model requires an index rebuild. The app prompts you when this is needed. + +--- + +## 8) Recommended Starter Configuration + +If you want a safe default profile: + +- Start with the **Base (Small & Fast)** model profile +- Enable **Auto-organize** +- Keep confidence around **75-85%** +- Use naming convention `subject-date` +- Use date format `YYYY-MM-DD` +- Keep separators simple (`-`) + +Then run a small batch first and review outcomes before scaling up. + +--- + +## 9) Daily Usage Pattern + +1. Collect files in your intake location (for example your Downloads folder). +2. Open StratoSort and go to the **Discover** phase. Drag files in or select a folder. +3. Review the AI's suggested destinations and names. +4. Move to **Organize** and approve, edit, or reject each suggestion. +5. After organizing, use **Knowledge OS** (Ctrl+K / Cmd+K) to search and verify file placement. +6. Use **Undo/Redo** any time in the Organize or Complete phase if something doesn't look right. + +--- + +## 10) Troubleshooting Quick Fixes + +### Search is weak or empty + +- Check **Settings -> AI Configuration** for model status. +- Confirm embeddings exist and rebuild if needed. +- Retry with a more specific query. + +### Auto-organize feels too risky + +- Increase confidence threshold. +- Keep Smart Folders tightly defined. +- Start with manual review before fully trusting automation. + +### File names are not what you expect + +- Review naming defaults in **Settings -> Default Locations**. +- Confirm convention/date/case/separator values. + +### Need to see logs + +- Open **Settings -> Application -> Troubleshooting Logs**. +- Use **Open Folder** to browse logs or **Export Logs** to create a shareable file. + +--- + +## 11) Reporting Problems + +Use the beta guide for full reporting instructions: [BETA_TESTER_GUIDE.md](./BETA_TESTER_GUIDE.md) + +Direct bug form: +[Open a bug report](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) diff --git a/AllDocs/migration-audit.md b/AllDocs/migration-audit.md new file mode 100644 index 00000000..8d4e1a54 --- /dev/null +++ b/AllDocs/migration-audit.md @@ -0,0 +1,23 @@ +# StratoSort Migration Audit (Closed) + +**Status:** Complete +**Audit date:** 2026-02-04 +**Scope:** Full codebase scan + plan reconciliation + +## Summary + +The migration to the fully in-process AI and vector stack is complete. Legacy external service +references and compatibility shims have been removed from the runtime code, settings, IPC contracts, +setup scripts, and documentation. + +## Verification + +- Runtime: All services, IPC handlers, and settings align with the in-process stack. +- UI: Configuration and setup flows point to in-process model management only. +- Scripts: Setup and build scripts no longer reference legacy external services. +- Docs: Configuration and architecture references now reflect the current stack. + +## Next Steps + +- Run the full test suite (`npm test`) and fix any regressions. +- Validate e2e flows for first-run setup, model downloads, and semantic search. diff --git a/README.md b/README.md index f3b7e10c..05d74a62 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@

Features • + Download • + SupportQuick StartDocumentationContributing • @@ -31,10 +33,35 @@ --- -**StratoSort Core** transforms file chaos into intelligent order using privacy-first local AI. It -automatically categorizes, tags, and organizes your documents completely offline—leveraging -**built-in AI** (node-llama-cpp) for intelligence and **Orama** for semantic search—ensuring your -data never leaves your machine. **Zero external dependencies required.** +StratoSort Core helps you organize messy files with local AI that runs on your machine. It analyzes +content (not just filenames), suggests where files belong, and gives you semantic search with +Knowledge OS and graph tools. Your data stays local, and you can start with a normal installer - no +CLI setup required. + +## Download + +### End Users (No CLI) + +

+ Download Latest Release +

+ +- **Windows/macOS installers:** + [GitHub Releases](https://github.com/iLevyTate/StratoSortCore/releases) +- **Step-by-step install help:** [Install Guide](docs/INSTALL_GUIDE.md) +- **How to use the app:** [User Guide](docs/USER_GUIDE.md) +- **Want to help test?** [Beta Tester Guide](docs/BETA_TESTER_GUIDE.md) + +## Support and Feedback + +If you run into an issue or have an idea, these links are the fastest way to help: + +- **Issues tab:** [View all issues](https://github.com/iLevyTate/StratoSortCore/issues) +- **Report a bug:** + [Open bug report template](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) +- **Request a feature:** + [Open feature request issue](https://github.com/iLevyTate/StratoSortCore/issues/new) +- **Contributing guide:** [CONTRIBUTING.md](CONTRIBUTING.md) ## Demo @@ -84,7 +111,7 @@ original repository. macOS. 2. **Run it** — allow the app if your OS shows a security prompt (see [Install Guide](docs/INSTALL_GUIDE.md)). -3. **First launch** — approve model download in the app (~5GB, one-time). +3. **First launch** — choose a model profile and approve the download (~2-5GB, one-time). No terminal, Python, Docker, or API keys. See the full **[Install Guide](docs/INSTALL_GUIDE.md)** for step-by-step instructions on both platforms and how to handle unsigned-app prompts. @@ -95,7 +122,7 @@ for step-by-step instructions on both platforms and how to handle unsigned-app p | :------------------- | :-------------------------------------------------------------- | | **Operating System** | Windows 10/11 (64-bit), macOS 10.15+, or Linux | | **Memory** | 8GB RAM minimum (16GB recommended for best performance) | -| **Storage** | ~5GB for AI models | +| **Storage** | ~2-5GB for AI models (depends on profile chosen) | | **GPU (Optional)** | NVIDIA CUDA, Apple Metal, or Vulkan-compatible for acceleration | ### Developers — Build from Source @@ -110,9 +137,9 @@ npm run dev **First Launch:** The app automatically downloads required AI models (GGUF format) on first run. GPU acceleration is auto-detected. -**Default Models:** Qwen2.5 7B (text), LLaVA 1.6 Mistral 7B (vision), nomic-embed-text v1.5 -(embeddings). Change defaults in `src/shared/aiModelConfig.js`. See [docs/CONFIG.md](docs/CONFIG.md) -for details. +**Default Models (Base Small):** Llama 3.2 3B (text), LLaVA Phi-3 Mini (vision), all-MiniLM-L6-v2 +(embeddings). A "Better Quality" profile with larger models is available during setup. Change +defaults in `src/shared/aiModelConfig.js`. See [docs/CONFIG.md](docs/CONFIG.md) for details. ## Advanced Capabilities @@ -146,15 +173,16 @@ See **[SECURITY.md](SECURITY.md)** for the complete security policy. ## Documentation -| Document | Description | -| :--------------------------------------------- | :--------------------------------------- | -| **[Install Guide](docs/INSTALL_GUIDE.md)** | End-user install (Windows & Mac, no CLI) | -| **[Getting Started](docs/GETTING_STARTED.md)** | Developer setup and build guide | -| **[Architecture](docs/ARCHITECTURE.md)** | System design and data flow | -| **[Learning Guide](docs/LEARNING_GUIDE.md)** | Codebase onboarding | -| **[Graph Features](docs/FEATURES_GRAPH.md)** | Knowledge Graph capabilities | -| **[IPC Contracts](docs/IPC_CONTRACTS.md)** | IPC communication specifications | -| **[Release Guide](docs/RELEASING.md)** | Release process and checks | +| Document | Description | +| :------------------------------------------------- | :--------------------------------------- | +| **[Install Guide](docs/INSTALL_GUIDE.md)** | End-user install (Windows & Mac, no CLI) | +| **[User Guide](docs/USER_GUIDE.md)** | Feature walkthrough for everyday use | +| **[Beta Tester Guide](docs/BETA_TESTER_GUIDE.md)** | Testing + bug reporting for contributors | +| **[Getting Started](docs/GETTING_STARTED.md)** | Developer setup and build guide | +| **[Architecture](docs/ARCHITECTURE.md)** | System design and data flow | +| **[Graph Features](docs/FEATURES_GRAPH.md)** | Knowledge Graph capabilities | +| **[IPC Contracts](docs/IPC_CONTRACTS.md)** | IPC communication specifications | +| **[Release Guide](docs/RELEASING.md)** | Release process and checks | ## Contributing diff --git a/docs/BETA_TESTER_GUIDE.md b/docs/BETA_TESTER_GUIDE.md new file mode 100644 index 00000000..6c63d876 --- /dev/null +++ b/docs/BETA_TESTER_GUIDE.md @@ -0,0 +1,191 @@ +# StratoSort Core Beta Tester Guide + +This guide is for people who want to help test StratoSort Core without using the command line. + +If you can install an app, use it for normal work, and share clear bug reports, you can contribute. + +--- + +## Quick Links + +- **Download:** [Latest release installers](https://github.com/iLevyTate/StratoSortCore/releases) +- **Install help:** [INSTALL_GUIDE.md](./INSTALL_GUIDE.md) +- **Bug reports:** + [Open a bug report](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) +- **General issues:** [Issues board](https://github.com/iLevyTate/StratoSortCore/issues) + +--- + +## Who This Guide Is For + +- You want to help improve StratoSort Core. +- You prefer installers over building from source. +- You can spend a little time reproducing issues and reporting them clearly. + +--- + +## Part 1: Install + +Use the full install walkthrough here: [INSTALL_GUIDE.md](./INSTALL_GUIDE.md). The short version is +below. + +### Step 1 — Download the right file + +Go to **[Releases](https://github.com/iLevyTate/StratoSortCore/releases)** and download **one file** +for your system. Always pick the **installer** version — it includes automatic updates so you always +test the latest build. + +| Your computer | Download this file | +| :--------------------------------------------- | :-------------------------------------------- | +| **Windows PC** (any 64-bit) | **`StratoSortCore-Setup-X.X.X.exe`** | +| **Mac with Apple Silicon** (M1 / M2 / M3 / M4) | **`StratoSortCore-X.X.X-mac-arm64.dmg`** | +| **Mac with Intel chip** | **`StratoSortCore-X.X.X-mac-x64.dmg`** | +| **Linux** (any 64-bit distro) | **`StratoSortCore-X.X.X-linux-x64.AppImage`** | + +> **Not sure which Mac you have?** Click the **Apple menu** (top-left) → **About This Mac**. If it +> says **M1, M2, M3, M4** (or any "M" chip), download the `arm64` version. If it says **Intel**, +> download the `x64` version. + +> **Why the installer and not the portable/deb?** The installer versions include automatic updates. +> As a beta tester you want the latest fixes delivered automatically — no need to manually +> re-download after every release. + +### Step 2 — Install and run + +**Windows:** + +1. Double-click `StratoSortCore-Setup-X.X.X.exe`. +2. If SmartScreen appears, click **More info** → **Run anyway**. +3. Follow the installer and launch from the Start menu or desktop shortcut. + +**macOS:** + +1. Double-click the `.dmg` and drag **StratoSort Core** into Applications. +2. Open the app. If macOS blocks it: right-click the app → **Open** → **Open** in the dialog. + +**Linux:** + +1. Make executable: `chmod +x StratoSortCore-*.AppImage` +2. Double-click to run, or launch from a terminal. + +### Step 3 — First launch model setup + +1. The **AI Model Setup** wizard appears on first launch. +2. Choose a profile: + - **Base (Small & Fast)** — works on all hardware, ~2 GB download. + - **Better Quality (Larger)** — better results on 16 GB+ RAM with GPU, ~5 GB download. +3. Wait for models to download. Progress is shown in the app. +4. Click **Get Started** when complete. + +Models are stored locally and never sent anywhere. You can change profiles later in **Settings** → +**AI Configuration**. + +--- + +## Part 2: Run a Useful Beta Test Session + +Use this checklist to create high-value feedback. Look for anything unexpected — wrong suggestions, +confusing UI, slow performance, or outright errors. + +1. **Setup phase** + - Add at least 3-5 Smart Folders with clear descriptions. + - Look for: Does the UI make it obvious how to add/edit/remove folders? +2. **Discover phase** + - Analyze a mixed batch (documents, images, screenshots, PDFs). + - Look for: Do analysis results make sense? Are categories accurate? How long does it take? +3. **Organize phase** + - Accept some suggestions, reject others, test rename options. + - Look for: Are suggested destinations correct? Do renames follow your naming rules? +4. **Search / Knowledge OS** + - Try natural-language queries in search (Ctrl+K / Cmd+K on macOS). + - Open the Knowledge Graph view and inspect relationships. + - Look for: Are results relevant? Does the graph show meaningful connections? +5. **Settings** + - Walk through each section: + - **AI Configuration** — model status, model selection, embedding rebuild + - **Performance** — auto-organize toggle, background mode + - **Default Locations** — naming conventions, default paths + - **Application** — log export, settings backup/restore + - Look for: Do all toggles work? Are labels clear? Anything confusing? +6. **Undo/Redo** + - Organize a few files, then undo. Re-do. Undo again. + - Look for: Do files actually move back? Is the history accurate? + +**Tip:** Real-world folders (Downloads, screenshots, invoices, project docs) produce better test +results than synthetic files. + +--- + +## Part 3: How To Report Bugs So They Are Actionable + +Submit reports with this template: +[Bug report form](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) + +### Include these every time + +- **Clear title:** what broke, where. +- **Reproduction steps:** exact step-by-step path. +- **Expected behavior:** what should happen. +- **Actual behavior:** what happened instead. +- **Environment details:** + - OS + version (e.g. Windows 11 23H2, macOS 15.2 Sequoia, Ubuntu 24.04) + - App version (shown in **Settings** → **About** or the title bar) + - Install type: installer or portable + - Hardware notes (RAM / GPU) if the issue is performance or AI related + +### Attach useful evidence + +- Screenshot or short screen recording. +- **Logs** (see below). +- Any visible error text (copy the exact message). + +### How to get logs + +Open **Settings** → **Application** → **Troubleshooting Logs**: + +| Option | When to use | +| :-------------- | :------------------------------------------------------------------------ | +| **Open Folder** | Browse log files directly on disk. Useful for picking specific log files. | +| **Export Logs** | Creates a shareable log file for bug reports. Attach to GitHub issue. | + +**Steps:** + +1. Open **Settings** → **Application**. +2. Under **Troubleshooting Logs**, click **Export Logs**. +3. Save the file (e.g. to Desktop). +4. When filing a bug report, drag the file into the GitHub issue or use **Attach files**. +5. Add a note like: _"Logs attached. Error occurred when [brief context]."_ + +**Manual log locations** (if you prefer copying files yourself): + +- **Windows:** `%APPDATA%\StratoSort Core\logs\` +- **macOS:** `~/Library/Application Support/StratoSort Core/logs/` +- **Linux:** `~/.config/StratoSort Core/logs/` + +### High-quality bug report example + +> **Title:** Knowledge OS search returns zero results after embedding model switch **Steps:** +> +> 1. Open Settings → AI Configuration → Default AI models +> 2. Change Embedding Model +> 3. Return to search and query "invoice from last month" **Expected:** Existing indexed files still +> appear, or app prompts to rebuild before search **Actual:** Empty results + warning about model +> mismatch **Environment:** Windows 11, app 2.0.1 installer, RTX 3060, 32 GB RAM **Logs:** +> attached export (Settings → Export Logs) + +--- + +## Part 4: Other Ways To Contribute (No Coding Required) + +- Confirm bugs reported by others (same issue, same version, same/different OS). +- Test a new release and report regressions. +- Suggest UX improvements with screenshots and concrete before/after notes. +- Improve docs when something feels unclear. + +--- + +## Part 5: If You Do Want To Contribute Code Later + +Start here: [CONTRIBUTING.md](../CONTRIBUTING.md) + +You can help as a tester today and become a code contributor later. Both are valuable. diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index 5f92e5d1..3bf7ac7b 100644 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -14,99 +14,230 @@ from source, see [GETTING_STARTED.md](GETTING_STARTED.md). | :------------- | :-------------------------------------------------------- | | **Windows** | Windows 10 or 11 (64-bit) | | **macOS** | macOS 10.15 or later (Intel or Apple Silicon) | -| **RAM** | 8GB minimum, 16GB recommended | -| **Disk space** | ~5GB for AI models (downloaded on first run) | +| **Linux** | 64-bit distribution (Ubuntu 20.04+, Fedora 38+, etc.) | +| **RAM** | 8 GB minimum, 16 GB recommended | +| **Disk space** | ~500 MB for the app + ~2-5 GB for AI models | | **Internet** | Needed once to download AI models; app runs offline after | --- -## Step 1: Download +## Step 1: Download the Right File -1. Go to [StratoSort Core Releases](https://github.com/iLevyTate/StratoSortCore/releases) -2. Download the installer for your system: - - **Windows:** `StratoSortCore-Setup-X.X.X.exe` or `StratoSortCore-X.X.X-win-x64.exe` - - **macOS:** `StratoSortCore-X.X.X-mac-arm64.dmg` (Apple Silicon) or - `StratoSortCore-X.X.X-mac-x64.dmg` (Intel) -3. (Optional but recommended) Download the checksum file for your platform: - - **Windows:** `checksums-windows.sha256` - - **macOS:** `checksums-macos.sha256` +Go to **[StratoSort Core Releases](https://github.com/iLevyTate/StratoSortCore/releases)** and +download **one file** for your system. Pick the installer version — it includes **automatic +updates** so you always have the latest features and fixes without re-downloading. + +### Windows + +> **Download: `StratoSortCore-Setup-X.X.X.exe`** (recommended) + +This is the full installer with automatic updates built in. It creates a Start Menu shortcut, a +desktop shortcut, and keeps itself up to date in the background. + +| File | What it is | Auto-updates? | +| :----------------------------------- | :--------------------------------- | :--------------------------------------------------- | +| **`StratoSortCore-Setup-X.X.X.exe`** | **Full installer** | **Yes** — updates download and install automatically | +| `StratoSortCore-X.X.X-win-x64.exe` | Portable (runs without installing) | No — you must re-download each new version manually | + +Only use the portable version if you specifically cannot install software on your machine (e.g. +restricted work computer). The portable version does not auto-update. + +### macOS — Which file do I need? + +macOS has two versions because Apple makes two types of processors. **You need to pick the one that +matches your Mac.** + +**How to check which Mac you have:** + +1. Click the **Apple menu** (top-left corner) and select **About This Mac**. +2. Look for the **Chip** or **Processor** line: + - If it says **Apple M1, M2, M3, M4** (or any "M" chip) — you have **Apple Silicon**. + - If it says **Intel** — you have an **Intel Mac**. + +| Your Mac | Download this file | Auto-updates? | +| :------------------------------------------- | :--------------------------------------- | :------------ | +| **Apple Silicon** (M1, M2, M3, M4, or newer) | **`StratoSortCore-X.X.X-mac-arm64.dmg`** | **Yes** | +| **Intel** (any Intel processor) | **`StratoSortCore-X.X.X-mac-x64.dmg`** | **Yes** | + +Both are full installers with automatic updates. Just pick the one that matches your chip. + +> **What happens if I pick the wrong one?** The app may not open, or macOS will show an error. Just +> delete it and download the correct version — no harm done. + +### Linux + +> **Download: `StratoSortCore-X.X.X-linux-x64.AppImage`** (recommended) + +The AppImage is a single file that runs on virtually any 64-bit Linux distribution. It includes +automatic updates. + +| File | What it is | Auto-updates? | +| :-------------------------------------------- | :----------------------- | :--------------------------------------------------- | +| **`StratoSortCore-X.X.X-linux-x64.AppImage`** | **AppImage (universal)** | **Yes** — updates download and install automatically | +| `StratoSortCore-X.X.X-linux-x64.deb` | Debian/Ubuntu package | No — you must re-download each new version manually | + +Only use the `.deb` if you specifically prefer managing packages with `apt`/`dpkg`. The `.deb` does +not auto-update. + +### Quick reference — which file to download + +| Your computer | Download this one file | +| :--------------------------------------- | :---------------------------------------- | +| **Windows PC** (any 64-bit) | `StratoSortCore-Setup-X.X.X.exe` | +| **Mac with Apple Silicon** (M1/M2/M3/M4) | `StratoSortCore-X.X.X-mac-arm64.dmg` | +| **Mac with Intel chip** | `StratoSortCore-X.X.X-mac-x64.dmg` | +| **Linux** (any 64-bit distro) | `StratoSortCore-X.X.X-linux-x64.AppImage` | + +All four of these are full installers with automatic updates included. --- ## Optional: Verify Download Integrity +You can verify that the file you downloaded wasn't corrupted or tampered with. This is optional. + +Download the matching checksum file from the same Releases page: + +- **Windows:** `checksums-windows.sha256` +- **macOS:** `checksums-macos.sha256` +- **Linux:** `checksums-linux.sha256` + +Then run the appropriate command: + ### Windows (PowerShell) ```powershell Get-FileHash .\StratoSortCore-Setup-X.X.X.exe -Algorithm SHA256 ``` -Compare the hash output to the matching entry in `checksums-windows.sha256`. - ### macOS (Terminal) ```bash shasum -a 256 StratoSortCore-X.X.X-mac-arm64.dmg ``` -Compare the hash output to the matching entry in `checksums-macos.sha256`. +### Linux (Terminal) + +```bash +sha256sum StratoSortCore-X.X.X-linux-x64.AppImage +``` + +Compare the hash output to the matching entry in the checksum file. They should match exactly. --- -## Step 2: Run the Installer +## Step 2: Install and Run ### Windows -1. Double-click the downloaded file. -2. If Windows SmartScreen shows **"Windows protected your PC"**: +1. Double-click **`StratoSortCore-Setup-X.X.X.exe`**. +2. **If Windows SmartScreen shows "Windows protected your PC":** - Click **"More info"** - Click **"Run anyway"** -3. Follow the installer (choose install location, shortcuts, etc.). -4. Launch StratoSort Core from the Start menu or desktop shortcut. +3. Follow the installer prompts (choose install location, shortcuts, etc.). +4. Launch **StratoSort Core** from the Start menu or desktop shortcut. -> **Why this warning?** The app is not code-signed yet. SmartScreen flags unsigned apps. You can +> **Why the SmartScreen warning?** The app is not code-signed yet. SmartScreen flags all unsigned +> apps — this is normal for open-source software distributed outside the Microsoft Store. You can > review the [source code](https://github.com/iLevyTate/StratoSortCore) to verify it before running. ### macOS -1. Double-click the downloaded DMG. -2. Drag **StratoSort Core** to Applications. -3. Eject the DMG and open StratoSort Core from Applications. -4. If you see **"StratoSort Core cannot be opened because the developer cannot be verified"**: - - **Option A:** Right-click the app → **Open** → **Open** in the dialog. +1. Double-click the downloaded **`.dmg`** file. +2. Drag **StratoSort Core** into the **Applications** folder. +3. Eject the DMG (right-click it in Finder sidebar and select Eject). +4. Open **StratoSort Core** from Applications. +5. **If macOS says "StratoSort Core cannot be opened because the developer cannot be verified":** + - **Option A (easiest):** Right-click the app in Applications, click **Open**, then click + **Open** again in the dialog. - **Option B:** Open **System Settings** → **Privacy & Security** → scroll down → click **Open Anyway** next to StratoSort Core. -> **Why this warning?** The app is not notarized by Apple yet. Gatekeeper blocks unsigned apps by -> default. You can review the [source code](https://github.com/iLevyTate/StratoSortCore) before -> running. +> **Why the macOS warning?** The app is not notarized by Apple yet. Gatekeeper blocks all +> un-notarized apps by default. You can review the +> [source code](https://github.com/iLevyTate/StratoSortCore) before running. + +### Linux (AppImage) + +1. Make the AppImage executable: + +```bash +chmod +x StratoSortCore-X.X.X-linux-x64.AppImage +``` + +2. Double-click to run, or launch from a terminal: + +```bash +./StratoSortCore-X.X.X-linux-x64.AppImage +``` + +### Linux (Debian package) + +1. Install: + +```bash +sudo dpkg -i StratoSortCore-X.X.X-linux-x64.deb +``` + +2. Launch **StratoSort Core** from your application menu. +3. **Important:** The `.deb` version does not auto-update. To get a new version, download and + install the latest `.deb` from [Releases](https://github.com/iLevyTate/StratoSortCore/releases). + +--- + +## Step 3: First Launch — AI Model Setup + +When you open StratoSort for the first time, it will ask you to download AI models. **This is a +one-time download.** After this, the app runs completely offline. + +1. The **AI Model Setup** wizard appears automatically. +2. Choose a model profile: + +| Profile | Best for | Download size | What you get | +| :-------------------------- | :----------------------------------------------------------- | :------------ | :------------------------------------------ | +| **Base (Small & Fast)** | All computers, including older machines and CPU-only systems | ~2 GB | Smaller, faster models that work everywhere | +| **Better Quality (Larger)** | Modern hardware with 16 GB+ RAM and a dedicated GPU | ~5 GB | Larger models with higher quality analysis | + +3. Click **Download Models** and wait for the progress bars to complete. +4. When all models finish, click **Get Started**. + +**What gets downloaded?** Three AI model files for text analysis, image understanding, and semantic +search (GGUF format). They are stored locally on your computer and never sent anywhere. + +**Can I change this later?** Yes. Go to **Settings** → **AI Configuration** → **Default AI Models** +to switch profiles or download additional models at any time. + +**Can I keep using the app while models download?** Yes. Click **Continue while downloading** if you +want to explore the app immediately. AI features will become available once the download finishes. --- -## Step 3: First Launch — Download AI Models +## How Automatic Updates Work -On first launch, StratoSort will ask you to download AI models. **This is the only download you -approve in the app.** +If you installed using the recommended installer (Setup `.exe`, `.dmg`, or `.AppImage`), the app +checks for updates automatically: -1. When the setup wizard appears, click **"Download Base Models"** or **"Download recommended - models"**. -2. Wait for the models to download (~3–5GB). Progress is shown in the app. -3. When complete, you can start using StratoSort. +1. When you launch the app, it checks GitHub Releases for a newer version in the background. +2. If an update is available, it downloads silently. +3. The update installs the next time you restart the app. +4. You don't need to do anything — it just works. -**What gets downloaded?** Text (Qwen2.5 7B), vision (LLaVA 1.6 Mistral), and embedding (Nomic) -models in GGUF format (~5GB total). They are stored locally and never sent anywhere. The vision -runtime is already bundled—no extra download for that. +> **Note:** Automatic updates only work with the installer versions. If you chose the portable +> `.exe` or `.deb` package, you must check +> [Releases](https://github.com/iLevyTate/StratoSortCore/releases) periodically and download new +> versions manually. --- ## Summary -| Step | What you do | -| :------------ | :------------------------------------------ | -| **Download** | One installer file from GitHub Releases | -| **Install** | Run it; allow it if your OS shows a warning | -| **First run** | Approve model download in the app | -| **Done** | Use StratoSort; everything runs locally | +| Step | What you do | +| :------------ | :------------------------------------------------------------------------------- | +| **Download** | One file from GitHub Releases (pick the installer for your OS — see table above) | +| **Install** | Run the installer; allow it if your OS shows a security warning | +| **First run** | Choose a model profile and download AI models (~2-5 GB, one time) | +| **Updates** | Automatic — the app handles it in the background | +| **Done** | Use StratoSort; everything runs locally on your machine | **No terminal. No Python. No Docker. No API keys.** @@ -126,17 +257,45 @@ runtime is already bundled—no extra download for that. - Scroll to the **Security** section. - Look for a message about StratoSort Core being blocked and click **Open Anyway**. +### macOS: "StratoSort Core is damaged and can't be opened" + +This can happen if the quarantine attribute wasn't cleared. Run this in Terminal: + +```bash +xattr -cr /Applications/StratoSort\ Core.app +``` + +Then try opening the app again. + ### Models failed to download - Check your internet connection. -- Try again from **Settings** → **Model management** → **Download Base Models**. -- Ensure you have ~5GB free disk space. +- Try again from **Settings** → **AI Configuration** → **Model Management** → **Download Models**. +- Ensure you have at least 5 GB of free disk space. +- If downloads keep failing, check if a firewall or VPN is blocking connections to `huggingface.co`. ### Vision / image analysis not working -- The vision runtime is bundled. If it still fails, check **Settings** → **Llama** for GPU or model - status. -- Ensure the vision model was downloaded (part of the base models). +- Open **Settings** → **AI Configuration** and check that the vision model shows as downloaded. +- If the vision model is missing, download it from the model management section. + +### App feels slow or unresponsive + +- The "Base (Small & Fast)" model profile uses less RAM and runs faster on older hardware. +- Close other memory-heavy applications while using StratoSort. +- If you have a dedicated GPU (NVIDIA, AMD, or Apple Silicon), StratoSort will use it automatically. + +--- + +## Where does StratoSort store data? + +| Data | Location | +| :--------------- | :-------------------------------------------------------------------------------------------------------------------------- | +| **App settings** | Your OS app data folder (automatic) | +| **AI models** | Inside the app data folder under `models/` (~2-5 GB) | +| **Your files** | StratoSort never copies your files. It reads them in place and only moves them when you approve an organization suggestion. | + +StratoSort never sends data over the internet after the initial model download. --- @@ -150,4 +309,11 @@ StratoSort Core: [github.com/iLevyTate/StratoSortCore](https://github.com/iLevyTate/StratoSortCore) The "developer cannot be verified" / SmartScreen warnings appear because the app is not yet signed -with a publisher certificate. That affects trust prompts only—not how the app works. +with a publisher certificate. That affects trust prompts only — not how the app works. + +--- + +## Next Steps + +- **Learn the app:** [User Guide](./USER_GUIDE.md) +- **Help test and report bugs:** [Beta Tester Guide](./BETA_TESTER_GUIDE.md) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 00000000..21de9601 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,223 @@ +# StratoSort Core User Guide + +This guide explains how to use StratoSort Core day-to-day, including Smart Folders, Knowledge OS, +and key settings. + +For installation help, use [INSTALL_GUIDE.md](./INSTALL_GUIDE.md). For beta testing and bug +reporting, use [BETA_TESTER_GUIDE.md](./BETA_TESTER_GUIDE.md). + +--- + +## 1) What StratoSort Does + +StratoSort uses local AI to: + +- Analyze your files by content (not just filename) +- Suggest where files belong +- Rename files using your naming rules +- Help you search by meaning using Knowledge OS +- Visualize relationships in the Knowledge Graph + +Everything runs locally after model download. + +--- + +## 2) Typical Workflow + +StratoSort is organized into five phases. You move through them in order: + +1. **Welcome** — Start screen. Choose your flow. +2. **Setup** — Create Smart Folders (the destination folders the AI routes files into). Give each + folder a plain-language description so the AI knows what belongs there. +3. **Discover** — Drag and drop files (or select a folder). The AI analyzes content, extracts + meaning, and generates organization suggestions. +4. **Organize** — Review each suggestion. Accept, reject, or edit the destination and filename. Use + Undo/Redo if you change your mind. +5. **Complete** — See a summary of everything that was organized. Undo is still available here. + +--- + +## 3) Smart Folders (Most Important First Setup) + +Smart Folders are destination folders that the AI routes files into. Each Smart Folder has a +**description** that tells the AI what kind of files belong there. The AI compares your file's +content against these descriptions to decide where it fits best. + +### Best practices + +- Create folders around real outcomes (for example: `Invoices`, `Receipts`, `Screenshots`, + `Contracts`). +- Write descriptions in plain language — pretend you're telling a coworker what goes in each folder. +- Be specific. "Monthly bills and vendor invoices" works better than "financial stuff." +- Start with 3-5 clear folders before adding more. Overlapping descriptions confuse the AI. + +### Good folder description examples + +| Folder | Description | +| :---------------- | :----------------------------------------------------- | +| **Invoices** | "Bills from vendors and monthly service invoices." | +| **Tax Documents** | "W-2, 1099, tax forms, receipts needed for filing." | +| **Project Specs** | "Requirements, architecture docs, and project briefs." | +| **Screenshots** | "Screen captures, app screenshots, and UI mockups." | + +--- + +## 4) Naming Conventions + +Open **Settings -> Default Locations -> File naming defaults**. + +You can control: + +- **Convention** + - `subject-date` + - `date-subject` + - `project-subject-date` + - `category-subject` + - `keep-original` +- **Date Format** + - `YYYY-MM-DD` + - `MM-DD-YYYY` + - `DD-MM-YYYY` + - `YYYYMMDD` +- **Case** + - `kebab-case`, `snake_case`, `camelCase`, `PascalCase`, `lowercase`, `UPPERCASE` +- **Separator** + - Use safe separators like `-` or `_` + +Tip: If you need maximum compatibility across apps and systems, prefer `kebab-case` plus +`YYYY-MM-DD`. + +--- + +## 5) Knowledge OS Search and Knowledge Graph + +Open search with **Ctrl+K** (Windows/Linux) or **Cmd+K** (macOS) — or click the Knowledge OS button +in the Discover phase — and use natural language queries like: + +- "Show invoices from last quarter" +- "Find screenshots related to onboarding" +- "Documents about pricing changes" + +### Knowledge OS tips + +- Be specific in your query (topic + time period + file type). +- If results are weak, rephrase with clearer intent. +- If semantic results seem empty, check embedding/model health in Settings. + +### Knowledge Graph tips + +- Use graph view to inspect relationships between files. +- Great for finding clusters, duplicates, and concept neighborhoods. +- Use it as an exploration tool, then open/reveal files directly. + +--- + +## 6) Settings Walkthrough + +Open **Settings** and focus on these sections: + +### AI Configuration + +- **Local AI Engine**: Check model and GPU status. +- **Default AI models**: Set text, vision, and embedding models. +- **Model Management**: Download base models or add individual models from the registry. +- **Embedding behavior / rebuild**: Rebuild index when embedding model changes. +- **Chat Persona**: Customize how the AI assistant responds. + +### Performance + +- **Auto-organize**: Enable automatic routing from downloads. +- **Background Mode**: Configure background processing behavior. +- **Graph Retrieval**: Tune graph expansion and contextual chunk settings. + +### Default Locations + +- Set where Smart Folders are created by default. +- Configure file naming defaults (convention, date format, case, separator). + +### Application + +- Launch on Startup toggle. +- Notification behavior. +- Troubleshooting Logs (Open Folder, Export Logs). +- Settings backup/restore (create, export, import). + +### Analysis History + +- View past analysis results and statistics. + +--- + +## 7) AI Model Profiles + +On first launch, the setup wizard offers two profiles: + +| Profile | Text Model | Vision Model | Embedding Model | Best For | +| :---------------------- | :----------- | :------------------- | :-------------------- | :---------------------------------- | +| **Base (Small & Fast)** | Llama 3.2 3B | LLaVA Phi-3 Mini | all-MiniLM-L6-v2 | All computers, CPU-only, low memory | +| **Better Quality** | Qwen2.5 7B | LLaVA 1.6 Mistral 7B | nomic-embed-text v1.5 | Modern hardware, 16GB+ RAM, GPU | + +You can switch models later in **Settings -> AI Configuration -> Default AI Models**. + +Changing the embedding model requires an index rebuild. The app prompts you when this is needed. + +--- + +## 8) Recommended Starter Configuration + +If you want a safe default profile: + +- Start with the **Base (Small & Fast)** model profile +- Enable **Auto-organize** +- Keep confidence around **75-85%** +- Use naming convention `subject-date` +- Use date format `YYYY-MM-DD` +- Keep separators simple (`-`) + +Then run a small batch first and review outcomes before scaling up. + +--- + +## 9) Daily Usage Pattern + +1. Collect files in your intake location (for example your Downloads folder). +2. Open StratoSort and go to the **Discover** phase. Drag files in or select a folder. +3. Review the AI's suggested destinations and names. +4. Move to **Organize** and approve, edit, or reject each suggestion. +5. After organizing, use **Knowledge OS** (Ctrl+K / Cmd+K) to search and verify file placement. +6. Use **Undo/Redo** any time in the Organize or Complete phase if something doesn't look right. + +--- + +## 10) Troubleshooting Quick Fixes + +### Search is weak or empty + +- Check **Settings -> AI Configuration** for model status. +- Confirm embeddings exist and rebuild if needed. +- Retry with a more specific query. + +### Auto-organize feels too risky + +- Increase confidence threshold. +- Keep Smart Folders tightly defined. +- Start with manual review before fully trusting automation. + +### File names are not what you expect + +- Review naming defaults in **Settings -> Default Locations**. +- Confirm convention/date/case/separator values. + +### Need to see logs + +- Open **Settings -> Application -> Troubleshooting Logs**. +- Use **Open Folder** to browse logs or **Export Logs** to create a shareable file. + +--- + +## 11) Reporting Problems + +Use the beta guide for full reporting instructions: [BETA_TESTER_GUIDE.md](./BETA_TESTER_GUIDE.md) + +Direct bug form: +[Open a bug report](https://github.com/iLevyTate/StratoSortCore/issues/new?template=bug_report.md) diff --git a/scripts/setup-models.js b/scripts/setup-models.js index 62536a9d..1d1939ac 100644 --- a/scripts/setup-models.js +++ b/scripts/setup-models.js @@ -48,9 +48,31 @@ function buildRecommendedModels() { // Model registry (derived from shared model registry) const RECOMMENDED_MODELS = buildRecommendedModels(); -// Get user data directory +/** + * Resolve the app name from package.json `name` field. + * This matches Electron's dev-mode `app.name` (which reads `name` from package.json). + * In production builds, electron-builder injects `productName` and the app itself + * handles model downloads via ModelDownloadManager (not this script). + */ +function getAppName() { + try { + const pkg = require('../package.json'); + return pkg.name || 'stratosort-core'; + } catch { + return 'stratosort-core'; + } +} + +/** + * Get the models directory, matching Electron's `app.getPath('userData')/models`. + * + * Platform conventions (mirrors Electron's path resolution): + * Windows: %APPDATA%//models + * macOS: ~/Library/Application Support//models + * Linux: $XDG_CONFIG_HOME//models (defaults to ~/.config) + */ function getModelsPath() { - const appName = 'stratosort-core'; + const appName = getAppName(); const home = process.env.HOME || process.env.USERPROFILE; if (process.platform === 'win32') { @@ -62,11 +84,8 @@ function getModelsPath() { } else if (process.platform === 'darwin') { return path.join(home, 'Library', 'Application Support', appName, 'models'); } else { - return path.join( - process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), - appName, - 'models' - ); + // Electron uses XDG_CONFIG_HOME (not XDG_DATA_HOME) for app.getPath('userData') + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), appName, 'models'); } } @@ -119,11 +138,10 @@ async function downloadFile(url, destPath) { const headers = startByte > 0 ? { Range: `bytes=${startByte}-` } : {}; const request = https.get(url, { followRedirect: false, headers }, (response) => { - // Handle redirects + // Handle redirects — preserve partial file for resume support if (response.statusCode === 301 || response.statusCode === 302) { const redirectUrl = response.headers.location; file.close(); - fs.unlink(destPath).catch(() => {}); downloadFile(redirectUrl, destPath).then(resolve).catch(reject); return; } diff --git a/src/main/core/applicationMenu.js b/src/main/core/applicationMenu.js index b1930168..0393ede9 100644 --- a/src/main/core/applicationMenu.js +++ b/src/main/core/applicationMenu.js @@ -9,6 +9,7 @@ const { Menu, shell, app } = require('electron'); const { getQuitAccelerator, isMacOS } = require('../../shared/platformUtils'); +const { IPC_EVENTS } = require('../../shared/constants'); // FIX: Import safeSend for validated IPC event sending const { safeSend } = require('../ipc/ipcWrappers'); @@ -29,7 +30,7 @@ function createApplicationMenu(getMainWindow) { click: () => { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'menu-action', 'select-files'); + safeSend(mainWindow.webContents, IPC_EVENTS.MENU_ACTION, 'select-files'); } } }, @@ -39,7 +40,7 @@ function createApplicationMenu(getMainWindow) { click: () => { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'menu-action', 'select-folder'); + safeSend(mainWindow.webContents, IPC_EVENTS.MENU_ACTION, 'select-folder'); } } } @@ -57,7 +58,7 @@ function createApplicationMenu(getMainWindow) { click: () => { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'menu-action', 'open-settings'); + safeSend(mainWindow.webContents, IPC_EVENTS.MENU_ACTION, 'open-settings'); } } }, @@ -144,7 +145,7 @@ function createApplicationMenu(getMainWindow) { click: () => { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'menu-action', 'show-about'); + safeSend(mainWindow.webContents, IPC_EVENTS.MENU_ACTION, 'show-about'); } } }); @@ -162,7 +163,7 @@ function createApplicationMenu(getMainWindow) { click: () => { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'menu-action', 'show-about'); + safeSend(mainWindow.webContents, IPC_EVENTS.MENU_ACTION, 'show-about'); } } }, @@ -173,7 +174,7 @@ function createApplicationMenu(getMainWindow) { click: () => { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'menu-action', 'open-settings'); + safeSend(mainWindow.webContents, IPC_EVENTS.MENU_ACTION, 'open-settings'); } } }, diff --git a/src/main/core/autoUpdater.js b/src/main/core/autoUpdater.js index de094b02..b5f9643a 100644 --- a/src/main/core/autoUpdater.js +++ b/src/main/core/autoUpdater.js @@ -9,6 +9,7 @@ const { BrowserWindow } = require('electron'); const { autoUpdater } = require('electron-updater'); +const { IPC_EVENTS } = require('../../shared/constants'); const { createLogger } = require('../../shared/logger'); // FIX: Import safeSend for validated IPC event sending const { safeSend } = require('../ipc/ipcWrappers'); @@ -41,7 +42,7 @@ function notifyRenderer(payload, getMainWindow) { return; } - safeSend(win.webContents, 'app:update', updatePayload); + safeSend(win.webContents, IPC_EVENTS.APP_UPDATE, updatePayload); } } catch (error) { logger.error('[UPDATER] Failed to send update message:', error); @@ -100,7 +101,7 @@ function handleUpdateProgress(progressObj, getMainWindow) { try { const win = (getMainWindow ? getMainWindow() : null) || BrowserWindow.getAllWindows()[0]; if (win && !win.isDestroyed()) { - safeSend(win.webContents, 'app:update', { + safeSend(win.webContents, IPC_EVENTS.APP_UPDATE, { status: 'downloading', progress: progressObj.percent }); diff --git a/src/main/core/backgroundSetup.js b/src/main/core/backgroundSetup.js index 3e0ee4ea..628851b0 100644 --- a/src/main/core/backgroundSetup.js +++ b/src/main/core/backgroundSetup.js @@ -23,7 +23,7 @@ const { createLogger } = require('../../shared/logger'); const { safeSend } = require('../ipc/ipcWrappers'); const { getInstance: getModelDownloadManager } = require('../services/ModelDownloadManager'); const { getInstance: getLlamaService } = require('../services/LlamaService'); -const { AI_DEFAULTS } = require('../../shared/constants'); +const { AI_DEFAULTS, IPC_EVENTS } = require('../../shared/constants'); const { getModel } = require('../../shared/modelRegistry'); const logger = createLogger('BackgroundSetup'); @@ -54,7 +54,10 @@ function emitDependencyProgress(payload) { const win = BrowserWindow.getAllWindows()[0]; if (win && !win.isDestroyed()) { // FIX: Use safeSend for validated IPC event sending - safeSend(win.webContents, 'operation-progress', { type: 'dependency', ...(payload || {}) }); + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { + type: 'dependency', + ...(payload || {}) + }); } } catch (error) { logger.debug('[BACKGROUND] Could not emit dependency progress:', error.message); @@ -153,7 +156,7 @@ async function downloadMissingModels() { try { const win = BrowserWindow.getAllWindows()[0]; if (win && !win.isDestroyed()) { - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'model-download', model: modelName, percent: progress.percent, diff --git a/src/main/core/jumpList.js b/src/main/core/jumpList.js index 4ae78ca0..02fcc6b0 100644 --- a/src/main/core/jumpList.js +++ b/src/main/core/jumpList.js @@ -9,6 +9,7 @@ const { app, BrowserWindow, shell } = require('electron'); const { isWindows } = require('../../shared/platformUtils'); +const { IPC_EVENTS } = require('../../shared/constants'); const { createLogger } = require('../../shared/logger'); // FIX: Import safeSend for validated IPC event sending const { safeSend } = require('../ipc/ipcWrappers'); @@ -38,7 +39,7 @@ function handleCommandLineTasks(args) { try { // FIX: Use safeSend for validated IPC event sending if (!win.isDestroyed()) { - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'hint', message: 'Use Select Directory to analyze a folder' }); diff --git a/src/main/core/systemTray.js b/src/main/core/systemTray.js index 9f15ed72..e94cfc35 100644 --- a/src/main/core/systemTray.js +++ b/src/main/core/systemTray.js @@ -10,6 +10,7 @@ const { app, BrowserWindow, Menu, Tray, nativeImage, globalShortcut } = require('electron'); const path = require('path'); const { isWindows, isMacOS } = require('../../shared/platformUtils'); +const { IPC_EVENTS } = require('../../shared/constants'); const { createLogger } = require('../../shared/logger'); // FIX: Import safeSend for validated IPC event sending const { safeSend } = require('../ipc/ipcWrappers'); @@ -121,7 +122,7 @@ async function openSemanticSearch() { // FIX: Store timer ID and clear on window close to prevent stale callback const searchTimerId = setTimeout(() => { if (!win.isDestroyed()) { - safeSend(win.webContents, 'open-semantic-search'); + safeSend(win.webContents, IPC_EVENTS.OPEN_SEMANTIC_SEARCH); } }, 100); win.once('closed', () => clearTimeout(searchTimerId)); diff --git a/src/main/core/userDataMigration.js b/src/main/core/userDataMigration.js index f3278d14..87b797b4 100644 --- a/src/main/core/userDataMigration.js +++ b/src/main/core/userDataMigration.js @@ -5,7 +5,11 @@ const { createLogger } = require('../../shared/logger'); const logger = createLogger('UserDataMigration'); // Legacy folder names are intentionally preserved here for backward-compatible migrations. +// Includes 'stratosort-core' (package.json name used in dev) so that models downloaded +// by the setup script are discoverable when running production builds (which use +// productName 'StratoSort Core' from electron-builder.json). const LEGACY_USERDATA_FOLDERS = [ + 'stratosort-core', 'StratoSort', 'stratosort', 'elstratosort', diff --git a/src/main/errors/ErrorHandler.js b/src/main/errors/ErrorHandler.js index ca122678..b0bec2e7 100644 --- a/src/main/errors/ErrorHandler.js +++ b/src/main/errors/ErrorHandler.js @@ -6,7 +6,7 @@ const { app, dialog, BrowserWindow } = require('electron'); const fs = require('fs').promises; const path = require('path'); -const { ERROR_TYPES } = require('../../shared/constants'); +const { ERROR_TYPES, IPC_EVENTS } = require('../../shared/constants'); const { createLogger, sanitizeLogData } = require('../../shared/logger'); const { parseJsonLines } = require('../../shared/safeJsonOps'); const { safeSend } = require('../ipc/ipcWrappers'); @@ -311,7 +311,7 @@ class ErrorHandler { if (mainWindow && !mainWindow.isDestroyed()) { // Send to renderer process with validated payload - safeSend(mainWindow.webContents, 'app:error', { + safeSend(mainWindow.webContents, IPC_EVENTS.APP_ERROR, { message, type, timestamp: new Date().toISOString() diff --git a/src/main/ipc/analysisHistory.js b/src/main/ipc/analysisHistory.js index 0a48b387..4315aca0 100644 --- a/src/main/ipc/analysisHistory.js +++ b/src/main/ipc/analysisHistory.js @@ -51,7 +51,8 @@ function registerAnalysisHistoryIpc(servicesOrParams) { context, serviceName: 'analysisHistory', getService: getHistoryService, - fallbackResponse: {}, + wrapResponse: true, + fallbackResponse: { success: true }, handler: async (event, service) => { try { return (await service.getStatistics()) || {}; diff --git a/src/main/ipc/files/batchOrganizeHandler.js b/src/main/ipc/files/batchOrganizeHandler.js index 1522dfed..e64bee2f 100644 --- a/src/main/ipc/files/batchOrganizeHandler.js +++ b/src/main/ipc/files/batchOrganizeHandler.js @@ -24,7 +24,11 @@ const { executeRollback } = require('./batchRollback'); const { sendOperationProgress, sendChunkedResults } = require('./batchProgressReporter'); const { getInstance: getFileOperationTracker } = require('../../../shared/fileOperationTracker'); const { syncEmbeddingForMove, removeEmbeddingsForPathBestEffort } = require('./embeddingSync'); -const { computeFileChecksum, handleDuplicateMove } = require('../../utils/fileDedup'); +const { + computeFileChecksum, + handleDuplicateMove, + findSemanticDuplicates +} = require('../../utils/fileDedup'); const logger = typeof createLogger === 'function' ? createLogger('IPC:Files:BatchOrganize') : baseLogger; @@ -444,19 +448,25 @@ async function handleBatchOrganize(params) { originalDestination: operation.operations[i].destination }); - results.push({ + const result = { success: true, source: op.source, destination: op.destination, operation: op.type || 'move' - }); + }; + // Surface semantic duplicate warning if detected during pre-move check + if (op._semanticDuplicate) { + result.semanticDuplicate = op._semanticDuplicate; + } + results.push(result); successCount++; log.debug('[FILE-OPS] Operation success', { batchId, index: i, source: op.source, - destination: op.destination + destination: op.destination, + semanticDuplicate: op._semanticDuplicate ? 'yes' : 'no' }); // Send progress to renderer @@ -664,6 +674,42 @@ async function performFileMove(op, log, checksumFn) { }); if (duplicateResult) return duplicateResult; + // Semantic duplicate check: warn if a highly similar file already exists + // at the destination (catches near-duplicates that differ in binary content + // but are semantically identical, e.g., different PDF exports of the same doc). + try { + const { getSemanticFileId } = require('../../../shared/fileIdUtils'); + const sourceFileId = getSemanticFileId(op.source); + const destDir = path.dirname(op.destination); + const semanticResult = await findSemanticDuplicates({ + sourceFileId, + destinationDir: destDir, + threshold: 0.95, // High threshold for batch organize to avoid false positives + topK: 1, + logger: log + }); + if (semanticResult.hasDuplicates) { + const match = semanticResult.matches[0]; + log.info('[FILE-OPS] Semantic near-duplicate detected at destination', { + source: op.source, + similarFile: match.metadata?.filePath || match.id, + similarity: match.score.toFixed(3), + action: 'proceeding_with_move' + }); + // Attach warning metadata to op for caller visibility. + // We proceed with the move but callers can surface the warning to the user. + op._semanticDuplicate = { + similarFile: match.metadata?.filePath || match.id, + similarity: match.score + }; + } + } catch (semanticErr) { + // Non-fatal: semantic check is advisory, don't block the move + log.debug('[FILE-OPS] Semantic duplicate check failed (non-fatal)', { + error: semanticErr?.message + }); + } + let counter = 0; let uniqueDestination = op.destination; const ext = path.extname(op.destination); @@ -868,27 +914,71 @@ async function recordUndoAndUpdateDatabase( }); } - // Sync embeddings based on final smart folder destinations (background, best effort) + // Sync embeddings based on final smart folder destinations. + // Uses setImmediate to yield before heavy work, but awaits completion to + // ensure embeddings are consistent before the batch result is returned. + // Includes retry for individual failures to prevent transient errors from + // leaving embeddings in an inconsistent state. if (pathChanges.length > 0) { - setImmediate(() => { - const syncBatchSize = 2; - batchProcess( + await new Promise((resolve) => setImmediate(resolve)); // Yield to event loop + const syncBatchSize = 2; + const syncResults = { synced: 0, failed: 0, errors: [] }; + try { + await batchProcess( pathChanges, - (change) => - syncEmbeddingForMove({ - sourcePath: change.oldPath, - destPath: change.newPath, - operation: 'move', - log - }), + async (change) => { + const MAX_SYNC_RETRIES = 2; + for (let attempt = 0; attempt <= MAX_SYNC_RETRIES; attempt++) { + try { + await syncEmbeddingForMove({ + sourcePath: change.oldPath, + destPath: change.newPath, + operation: 'move', + log + }); + syncResults.synced++; + return; + } catch (syncErr) { + if (attempt < MAX_SYNC_RETRIES) { + log.debug('[FILE-OPS] Embedding sync retry', { + attempt: attempt + 1, + file: path.basename(change.newPath), + error: syncErr.message + }); + await delay(200 * (attempt + 1)); + } else { + syncResults.failed++; + syncResults.errors.push({ + file: path.basename(change.newPath), + error: syncErr.message + }); + } + } + } + }, syncBatchSize - ).catch((syncErr) => { - log.debug('[FILE-OPS] Batch embedding sync failed (non-fatal):', { - error: syncErr.message, - batchId - }); + ); + } catch (batchSyncErr) { + log.warn('[FILE-OPS] Batch embedding sync had failures', { + batchId, + error: batchSyncErr.message, + ...syncResults }); - }); + } + + if (syncResults.failed > 0) { + log.warn('[FILE-OPS] Some embedding syncs failed after retries', { + batchId, + synced: syncResults.synced, + failed: syncResults.failed, + errors: syncResults.errors.slice(0, 5) // Limit logged errors + }); + } else if (syncResults.synced > 0) { + log.debug('[FILE-OPS] Batch embedding sync complete', { + batchId, + synced: syncResults.synced + }); + } } // FIX P1-1: Await the rebuild with timeout to ensure search consistency @@ -899,21 +989,22 @@ async function recordUndoAndUpdateDatabase( const searchService = getSearchServiceInstance?.(); if (searchService?.invalidateAndRebuild) { // FIX: Await with timeout to ensure search consistency without blocking too long - const REBUILD_TIMEOUT_MS = 5000; // 5 second max wait const rebuildPromise = searchService.invalidateAndRebuild({ immediate: true, reason: 'batch-organize' }); // Wait for rebuild but with timeout to prevent blocking UI - await withTimeout(rebuildPromise, REBUILD_TIMEOUT_MS, 'BM25 rebuild after batch').catch( - (rebuildErr) => { - log.warn('[FILE-OPS] BM25 rebuild failed or timed out after batch', { - error: rebuildErr?.message, - batchId - }); - } - ); + await withTimeout( + rebuildPromise, + TIMEOUTS.SEARCH_INDEX_REBUILD, + 'BM25 rebuild after batch' + ).catch((rebuildErr) => { + log.warn('[FILE-OPS] BM25 rebuild failed or timed out after batch', { + error: rebuildErr?.message, + batchId + }); + }); } } catch (invalidateErr) { log.warn('[FILE-OPS] Failed to trigger search index rebuild after batch', { diff --git a/src/main/ipc/files/batchProgressReporter.js b/src/main/ipc/files/batchProgressReporter.js index eb0f8661..b43cb1d9 100644 --- a/src/main/ipc/files/batchProgressReporter.js +++ b/src/main/ipc/files/batchProgressReporter.js @@ -4,12 +4,13 @@ * Handles IPC progress updates and chunked result delivery. */ +const { IPC_EVENTS } = require('../../../shared/constants'); const { safeSend } = require('../ipcWrappers'); function sendOperationProgress(getMainWindow, payload) { const win = getMainWindow(); if (win && !win.isDestroyed()) { - safeSend(win.webContents, 'operation-progress', payload); + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, payload); } } @@ -40,7 +41,7 @@ async function sendChunkedResults(getMainWindow, batchId, results, maxPerChunk) const chunk = results.slice(i, i + maxPerChunk); const chunkIndex = Math.floor(i / maxPerChunk); - safeSend(win.webContents, 'batch-results-chunk', { + safeSend(win.webContents, IPC_EVENTS.BATCH_RESULTS_CHUNK, { batchId, chunk, chunkIndex, diff --git a/src/main/ipc/files/embeddingSync.js b/src/main/ipc/files/embeddingSync.js index 49b63de1..cb61897d 100644 --- a/src/main/ipc/files/embeddingSync.js +++ b/src/main/ipc/files/embeddingSync.js @@ -410,8 +410,99 @@ async function syncEmbeddingForMove({ return { action: 'enqueued', smartFolder: smartFolder.name }; } +/** + * Verify that embeddings are correctly stored after a file move. + * Checks that the destination file ID has a valid embedding and that + * no stale embedding remains at the source path. + * + * @param {Object} params + * @param {string} params.sourcePath - Original file path (before move) + * @param {string} params.destPath - Destination file path (after move) + * @param {string} [params.operation='move'] - Operation type ('move' or 'copy') + * @param {Object} [params.log] - Logger instance + * @returns {Promise<{valid: boolean, destHasEmbedding: boolean, sourceCleared: boolean, issues: string[]}>} + */ +async function verifyEmbeddingAfterMove({ + sourcePath, + destPath, + operation = 'move', + log = logger +}) { + const issues = []; + let destHasEmbedding = false; + let sourceCleared = true; + + if (!destPath) { + return { valid: false, destHasEmbedding, sourceCleared, issues: ['missing-dest-path'] }; + } + + const services = resolveServices(); + const vectorDbService = services.vectorDbService; + + if (!vectorDbService) { + return { + valid: false, + destHasEmbedding: false, + sourceCleared: true, + issues: ['vector-db-unavailable'] + }; + } + + try { + const destId = getSemanticFileId(destPath); + + // Check destination has an embedding + if (typeof vectorDbService.getFile === 'function') { + const destDoc = await vectorDbService.getFile(destId); + if (destDoc) { + destHasEmbedding = true; + // Verify the path metadata matches the actual destination + const storedPath = destDoc.filePath || ''; + const normalizedStoredPath = storedPath.replace(/\\/g, '/'); + const normalizedDestPath = destPath.replace(/\\/g, '/'); + if (normalizedStoredPath !== normalizedDestPath) { + issues.push(`path-mismatch: stored="${storedPath}" expected="${destPath}"`); + } + } else { + issues.push('dest-embedding-missing'); + } + } + + // For moves (not copies), verify source embedding was removed + if (operation !== 'copy' && sourcePath && sourcePath !== destPath) { + const sourceId = getSemanticFileId(sourcePath); + if (typeof vectorDbService.getFile === 'function') { + const sourceDoc = await vectorDbService.getFile(sourceId); + if (sourceDoc && !sourceDoc.isOrphaned) { + sourceCleared = false; + issues.push(`stale-source-embedding: ${sourceId}`); + } + } + } + } catch (error) { + issues.push(`verification-error: ${error.message}`); + log.debug('[EmbeddingSync] Post-move embedding verification error (non-fatal)', { + error: error.message, + destPath + }); + } + + const valid = destHasEmbedding && sourceCleared && issues.length === 0; + if (!valid && issues.length > 0) { + log.warn('[EmbeddingSync] Post-move embedding verification found issues', { + sourcePath: sourcePath ? path.basename(sourcePath) : null, + destPath: path.basename(destPath), + operation, + issues + }); + } + + return { valid, destHasEmbedding, sourceCleared, issues }; +} + module.exports = { syncEmbeddingForMove, removeEmbeddingsForPath, - removeEmbeddingsForPathBestEffort + removeEmbeddingsForPathBestEffort, + verifyEmbeddingAfterMove }; diff --git a/src/main/ipc/files/fileOperationHandlers.js b/src/main/ipc/files/fileOperationHandlers.js index 9354e6bd..bebd9dc6 100644 --- a/src/main/ipc/files/fileOperationHandlers.js +++ b/src/main/ipc/files/fileOperationHandlers.js @@ -8,7 +8,7 @@ const path = require('path'); const fs = require('fs').promises; -const { ACTION_TYPES } = require('../../../shared/constants'); +const { ACTION_TYPES, IPC_EVENTS } = require('../../../shared/constants'); // FIX: Added safeSend import for validated IPC event sending const { createHandler, safeHandle, safeSend, z } = require('../ipcWrappers'); const { logger: baseLogger, createLogger } = require('../../../shared/logger'); @@ -36,9 +36,14 @@ const { getInstance: getLearningFeedbackService, FEEDBACK_SOURCES } = require('../../services/organization/learningFeedback'); -const { syncEmbeddingForMove } = require('./embeddingSync'); +const { + syncEmbeddingForMove, + removeEmbeddingsForPathBestEffort, + verifyEmbeddingAfterMove +} = require('./embeddingSync'); const { withTimeout } = require('../../../shared/promiseUtils'); const { crossDeviceMove } = require('../../../shared/atomicFileOperations'); +const { handleDuplicateMove } = require('../../utils/fileDedup'); // Alias for backward compatibility const operationSchema = schemas?.fileOperation || null; @@ -138,13 +143,14 @@ function getAnalysisHistoryService() { async function syncEmbeddingsBestEffort({ sourcePath, destPath, log, context }) { const timeoutMs = 5000; + const operation = context === 'copy' ? 'copy' : 'move'; const label = context ? `Embedding sync (${context})` : 'Embedding sync'; try { await withTimeout( syncEmbeddingForMove({ sourcePath, destPath, - operation: context === 'copy' ? 'copy' : 'move', + operation, log }), timeoutMs, @@ -155,6 +161,28 @@ async function syncEmbeddingsBestEffort({ sourcePath, destPath, log, context }) error: syncErr.message }); } + + // Post-sync verification: ensure the embedding is correctly stored at the + // destination and no stale embedding lingers at the source. + try { + const verification = await withTimeout( + verifyEmbeddingAfterMove({ sourcePath, destPath, operation, log }), + 3000, + 'Post-move embedding verification' + ); + if (!verification.valid && verification.issues.length > 0) { + log.warn(`[FILE-OPS] Embedding verification after ${context} found issues`, { + sourcePath: sourcePath ? path.basename(sourcePath) : null, + destPath: path.basename(destPath), + issues: verification.issues + }); + } + } catch (verifyErr) { + // Non-fatal: verification is advisory + log.debug('[FILE-OPS] Post-move embedding verification timed out or failed', { + error: verifyErr?.message + }); + } } /** @@ -304,6 +332,33 @@ function createPerformOperationHandler({ logger: log, getServiceIntegration, get PathChangeReason.USER_MOVE ); + // Check for identical content at destination before moving. + // Prevents creating duplicates when the same file already exists there. + const duplicateResult = await handleDuplicateMove({ + sourcePath: moveValidation.source, + destinationPath: moveValidation.destination, + logger: log, + logPrefix: '[FILE-OPS]', + dedupContext: 'singleMove', + removeEmbeddings: removeEmbeddingsForPathBestEffort, + unlinkFn: fs.unlink + }); + if (duplicateResult) { + traceMoveComplete( + moveValidation.source, + duplicateResult.destination, + 'fileOperationHandlers', + true + ); + return { + success: true, + message: `Duplicate detected - source removed, identical file already at ${duplicateResult.destination}`, + skipped: true, + reason: 'duplicate', + destination: duplicateResult.destination + }; + } + try { await fs.rename(moveValidation.source, moveValidation.destination); } catch (renameError) { @@ -375,7 +430,7 @@ function createPerformOperationHandler({ logger: log, getServiceIntegration, get const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { // FIX: Use safeSend for validated IPC event sending - safeSend(mainWindow.webContents, 'file-operation-complete', { + safeSend(mainWindow.webContents, IPC_EVENTS.FILE_OPERATION_COMPLETE, { operation: 'move', oldPath: moveValidation.source, newPath: moveValidation.destination @@ -593,7 +648,7 @@ function createPerformOperationHandler({ logger: log, getServiceIntegration, get const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { // FIX: Use safeSend for validated IPC event sending - safeSend(mainWindow.webContents, 'file-operation-complete', { + safeSend(mainWindow.webContents, IPC_EVENTS.FILE_OPERATION_COMPLETE, { operation: 'delete', oldPath: deleteValidation.source }); @@ -1083,7 +1138,7 @@ function registerFileOperationHandlers(servicesOrParams) { try { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - safeSend(mainWindow.webContents, 'file-operation-complete', { + safeSend(mainWindow.webContents, IPC_EVENTS.FILE_OPERATION_COMPLETE, { operation: 'move', oldPath: validatedPath, newPath: actualPath diff --git a/src/main/ipc/llama.js b/src/main/ipc/llama.js index da37a57d..83edbd1f 100644 --- a/src/main/ipc/llama.js +++ b/src/main/ipc/llama.js @@ -11,7 +11,7 @@ const { IpcServiceContext, createFromLegacyParams } = require('./IpcServiceConte const { createHandler, safeHandle, safeSend, z } = require('./ipcWrappers'); const { container: serviceContainer, ServiceIds } = require('../services/ServiceContainer'); const { TIMEOUTS } = require('../../shared/performanceConstants'); -const { AI_DEFAULTS } = require('../../shared/constants'); +const { AI_DEFAULTS, IPC_EVENTS } = require('../../shared/constants'); const { withTimeout } = require('../../shared/promiseUtils'); /** @@ -83,7 +83,7 @@ function registerLlamaIpc(servicesOrParams) { // Get available models safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.GET_MODELS || 'llama:get-models', + IPC_CHANNELS.LLAMA.GET_MODELS, createHandler({ logger, context, @@ -214,7 +214,7 @@ function registerLlamaIpc(servicesOrParams) { // Get configuration safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.GET_CONFIG || 'llama:get-config', + IPC_CHANNELS.LLAMA.GET_CONFIG, createHandler({ logger, context, @@ -236,7 +236,7 @@ function registerLlamaIpc(servicesOrParams) { // Update configuration safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.UPDATE_CONFIG || 'llama:update-config', + IPC_CHANNELS.LLAMA.UPDATE_CONFIG, createHandler({ logger, context, @@ -263,7 +263,7 @@ function registerLlamaIpc(servicesOrParams) { // Test connection / health check safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.TEST_CONNECTION || 'llama:test-connection', + IPC_CHANNELS.LLAMA.TEST_CONNECTION, createHandler({ logger, context, @@ -311,10 +311,10 @@ function registerLlamaIpc(servicesOrParams) { }) ); - // Download model + // Download model (non-blocking: starts download and returns immediately) safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.DOWNLOAD_MODEL || 'llama:download-model', + IPC_CHANNELS.LLAMA.DOWNLOAD_MODEL, createHandler({ logger, context, @@ -335,10 +335,19 @@ function registerLlamaIpc(servicesOrParams) { const manager = getModelDownloadManager(); const win = typeof getMainWindow === 'function' ? getMainWindow() : null; + // If backgroundSetup already started this download, piggyback on it + // instead of throwing a noisy error. + if (manager.isDownloading(normalizedName)) { + logger.debug('[IPC:Llama] Download already in progress, deferring to background', { + model: normalizedName + }); + return { success: true, alreadyInProgress: true }; + } + // Set up progress callback const onProgress = (progress) => { if (win && !win.isDestroyed()) { - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'model-download', model: normalizedName, progress @@ -346,20 +355,41 @@ function registerLlamaIpc(servicesOrParams) { } }; - // If backgroundSetup already started this download, piggyback on it - // instead of throwing a noisy error. - if (manager.isDownloading(normalizedName)) { - logger.debug('[IPC:Llama] Download already in progress, deferring to background', { - model: normalizedName + // Start download in background — do NOT await. + // Downloads can take minutes/hours; awaiting would exceed IPC timeout. + // Completion and errors are reported via OPERATION_PROGRESS events. + manager + .downloadModel(normalizedName, { onProgress }) + .then(() => { + logger.info('[IPC:Llama] Model download completed', { model: normalizedName }); + if (win && !win.isDestroyed()) { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { + type: 'model-download-complete', + model: normalizedName, + success: true + }); + } + }) + .catch((error) => { + // Handle race condition: download may have started concurrently + if (error?.message?.includes('already in progress')) { + logger.debug('[IPC:Llama] Download started concurrently, deferring to background', { + model: normalizedName + }); + return; + } + logger.error('[IPC:Llama] Model download failed:', error); + if (win && !win.isDestroyed()) { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { + type: 'model-download-error', + model: normalizedName, + error: error?.message || 'Download failed' + }); + } }); - return { success: true, alreadyInProgress: true }; - } - const result = await manager.downloadModel(normalizedName, { onProgress }); - return result; + return { success: true, started: true }; } catch (error) { - // Handle race condition: download may have started between the - // isDownloading check and the downloadModel call. if (error?.message?.includes('already in progress')) { logger.debug('[IPC:Llama] Download started concurrently, deferring to background', { model: normalizedName @@ -376,7 +406,7 @@ function registerLlamaIpc(servicesOrParams) { // Delete model safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.DELETE_MODEL || 'llama:delete-model', + IPC_CHANNELS.LLAMA.DELETE_MODEL, createHandler({ logger, context, @@ -408,7 +438,7 @@ function registerLlamaIpc(servicesOrParams) { // Get download status safeHandle( ipcMain, - IPC_CHANNELS.LLAMA?.GET_DOWNLOAD_STATUS || 'llama:get-download-status', + IPC_CHANNELS.LLAMA.GET_DOWNLOAD_STATUS, createHandler({ logger, context, diff --git a/src/main/ipc/semantic.js b/src/main/ipc/semantic.js index c9ff18e9..e82ffbe1 100644 --- a/src/main/ipc/semantic.js +++ b/src/main/ipc/semantic.js @@ -1770,7 +1770,8 @@ function registerEmbeddingsIpc(servicesOrParams) { } const result = await smartFolderWatcher.reanalyzeFile(validation.normalizedPath, { - applyNaming + applyNaming, + force: true // User-initiated reanalysis — allow files outside smart folders }); if (!result?.queued) { return { @@ -2540,11 +2541,32 @@ function registerEmbeddingsIpc(servicesOrParams) { const service = await getClusteringService(); const result = await service.computeClusters(k); - // Optionally generate LLM labels for clusters - if (result.success && generateLabels && result.clusters.length > 0) { - await service.generateClusterLabels(); - // Update result with labels - result.clusters = service.getClustersForGraph(); + if (result.success && result.clusters.length > 0) { + // Phase 1: Generate fast metadata-based labels (no LLM) so + // the graph has meaningful labels immediately. + await service.generateClusterLabels({ skipLLM: true }); + + // Build the full response with clusters + cross-cluster edges + // so the frontend can render without an extra getClusters call. + const clusters = service.getClustersForGraph(); + const crossClusterEdges = service.findCrossClusterEdges( + THRESHOLDS.SIMILARITY_EDGE_DEFAULT + ); + + // Phase 2: Fire LLM label refinement in the background (non-blocking). + // Labels will be available on the next getClusters call. + if (generateLabels) { + service.generateClusterLabels({ skipLLM: false }).catch((err) => { + logger.warn('[EMBEDDINGS] Background LLM label generation failed:', err.message); + }); + } + + return { + ...result, + clusters, + crossClusterEdges, + stale: false + }; } return result; diff --git a/src/main/ipc/validationSchemas.js b/src/main/ipc/validationSchemas.js index e0567d08..ef970069 100644 --- a/src/main/ipc/validationSchemas.js +++ b/src/main/ipc/validationSchemas.js @@ -297,6 +297,7 @@ if (!z) { // Embedding workflow controls embeddingTiming: z.enum(['during_analysis', 'after_organize', 'manual']).nullish(), defaultEmbeddingPolicy: z.enum(['embed', 'skip', 'web_only']).nullish(), + embeddingScope: z.enum(['all_analyzed', 'smart_folders_only']).nullish(), chatPersona: chatPersonaSchema, chatResponseMode: z.enum(['fast', 'deep']).nullish(), // Application Behavior @@ -375,6 +376,23 @@ if (!z) { workflowRestoreMaxAge: z.number().int().min(60000).nullish(), saveDebounceMs: z.number().int().min(100).nullish(), + // Graph-aware retrieval (GraphRAG-lite) + graphExpansionEnabled: z.boolean().nullish(), + graphExpansionWeight: z.number().min(0).max(1).nullish(), + graphExpansionMaxNeighbors: z.number().int().min(10).max(500).nullish(), + chunkContextEnabled: z.boolean().nullish(), + chunkContextMaxNeighbors: z.number().int().min(0).max(3).nullish(), + + // Llama engine tuning + llamaGpuLayers: z.number().int().min(-1).nullish(), + llamaContextSize: z.number().int().min(512).max(131072).nullish(), + + // Vector DB persistence (relative to userData) + vectorDbPersistPath: z.string().min(1).max(200).nullish(), + + // Internal schema version for settings migrations + settingsSchemaVersion: z.number().int().min(1).nullish(), + // Deprecated settings (kept for backward compatibility) smartFolderWatchEnabled: z.boolean().nullish() }) diff --git a/src/main/ipc/vectordb.js b/src/main/ipc/vectordb.js index 18aaf19c..861190c0 100644 --- a/src/main/ipc/vectordb.js +++ b/src/main/ipc/vectordb.js @@ -52,7 +52,7 @@ function registerVectorDbIpc(servicesOrParams) { // Get status safeHandle( ipcMain, - IPC_CHANNELS.VECTOR_DB?.GET_STATUS || 'vectordb:get-status', + IPC_CHANNELS.VECTOR_DB.GET_STATUS, createHandler({ logger, context, @@ -113,7 +113,7 @@ function registerVectorDbIpc(servicesOrParams) { // Get stats safeHandle( ipcMain, - IPC_CHANNELS.VECTOR_DB?.GET_STATS || 'vectordb:get-stats', + IPC_CHANNELS.VECTOR_DB.GET_STATS, createHandler({ logger, context, @@ -152,7 +152,7 @@ function registerVectorDbIpc(servicesOrParams) { // Health check safeHandle( ipcMain, - IPC_CHANNELS.VECTOR_DB?.HEALTH_CHECK || 'vectordb:health-check', + IPC_CHANNELS.VECTOR_DB.HEALTH_CHECK, createHandler({ logger, context, diff --git a/src/main/services/ChatService.js b/src/main/services/ChatService.js index 0afedcf9..43ce39a0 100644 --- a/src/main/services/ChatService.js +++ b/src/main/services/ChatService.js @@ -18,6 +18,17 @@ const HOLISTIC_MIN_CHUNK_WEIGHT = 0.35; const MAX_SESSIONS = 50; +// Minimum *raw* cosine similarity (from embeddings) for a source to be +// included in the chat prompt. The fused score after min-max normalization +// is unreliable for quality gating because normalization stretches the best- +// of-a-bad-bunch to 1.0. Raw cosine similarity is an absolute quality signal: +// ≥ 0.55 strong semantic match +// 0.35–0.55 moderate match +// < 0.25 effectively random for most embedding models +// 0.25 is intentionally higher than the global MIN_SIMILARITY_SCORE (0.15) +// because chat/RAG requires genuinely relevant context. +const CHAT_MIN_SEMANTIC_SCORE = 0.25; + const RESPONSE_MODES = { fast: { chunkTopK: 10, @@ -87,17 +98,25 @@ class ChatService { const history = await this._getHistoryText(memory); const prompt = ` -You are StratoSort, a helpful document assistant. +You are StratoSort, a friendly local AI assistant that helps users explore, search, and understand the files on their machine. Everything runs 100% on-device. + The user said: "${cleanQuery}" Conversation history: ${history || '(none)'} -Respond naturally and friendly. If they are greeting you, greet them back and offer to help find documents. +Guidelines: +- Be warm, concise, and conversational — like a helpful colleague, not a research paper. +- If they greet you, greet them back naturally. +- If they ask what you can do or how to use you, explain your capabilities in plain language: + you can search their indexed documents by meaning, answer questions about file contents, + find related files, summarize documents, and help organize their workspace. +- Suggest 2-3 specific things they could try, phrased as natural questions. + Return ONLY valid JSON: { "modelAnswer": [{ "text": "Your conversational response here." }], "documentAnswer": [], - "followUps": ["What projects are active?", "Find my tax returns", "Show me recent images"] + "followUps": ["A natural follow-up the user might ask"] }`; try { @@ -198,21 +217,26 @@ Return ONLY valid JSON: if (!retrieval?.sources?.length) { parsed.documentAnswer = []; } - let assistantForMemory = this._formatForMemory(parsed); + let assistantForMemory = this._formatForMemory(parsed, retrieval.sources); - // FIX: Smart fallback if model returns nothing (improves UX) + // Smart fallback if model returns nothing (improves UX) if (parsed.documentAnswer.length === 0 && parsed.modelAnswer.length === 0) { - if (retrieval.sources.length === 0) { + const dropped = retrieval.meta?.droppedLowRelevance || 0; + if (retrieval.sources.length === 0 && dropped === 0) { + parsed.modelAnswer.push({ + text: "I didn't find any documents matching your query. Try rephrasing with different keywords, or ask about a topic that's in your indexed files." + }); + } else if (retrieval.sources.length === 0 && dropped > 0) { parsed.modelAnswer.push({ - text: "I couldn't find any documents matching your query. You might try:\n• Checking for typos\n• Using broader keywords\n• Asking about a topic present in your indexed files" + text: `I found ${dropped} document${dropped > 1 ? 's' : ''} but none were relevant enough to your question. Try more specific keywords or a different angle.` }); } else { parsed.modelAnswer.push({ - text: `I found ${retrieval.sources.length} potentially relevant documents, but I couldn't find a specific answer to your question in them. You can check the sources list below to explore them directly.` + text: `I found ${retrieval.sources.length} related document${retrieval.sources.length > 1 ? 's' : ''} but couldn't extract a specific answer. You can explore the sources below directly.` }); } // Re-format for memory since we added a fallback response - assistantForMemory = this._formatForMemory(parsed); + assistantForMemory = this._formatForMemory(parsed, retrieval.sources); await this._saveMemoryTurn(memory, cleanQuery, assistantForMemory); } else { await this._saveMemoryTurn(memory, cleanQuery, assistantForMemory); @@ -295,7 +319,8 @@ Return ONLY valid JSON: } _isConversational(query) { - const conversational = new Set([ + // Exact-match greetings / closers + const exactPhrases = new Set([ 'hello', 'hi', 'hey', @@ -305,8 +330,31 @@ Return ONLY valid JSON: 'good afternoon', 'good evening', 'who are you', - 'what can you do' + 'what can you do', + 'what do you do', + 'help', + 'help me', + 'yo', + 'sup', + 'howdy', + 'bye', + 'goodbye', + 'see ya' ]); + + // Pattern-match capability / meta questions (safe, bounded patterns) + const capabilityPatterns = [ + /^what can you\b/, + /^what (?:do|does|will) you\b/, + /^how (?:can|do) you help\b/, + /^how does this work\b/, + /^what (?:are|is) you(?:r)?\b/, + /^(?:can|could) you help\b/, + /^tell me (?:about yourself|what you do)\b/, + /^what (?:kind|type) of\b.*\bhelp\b/, + /^how do i use\b/ + ]; + // FIX: Truncate before regex to prevent ReDoS on very long untrusted input. // Chat queries shouldn't be conversational if they're over 100 chars. if (query.length > 100) return false; @@ -314,7 +362,8 @@ Return ONLY valid JSON: .toLowerCase() .replace(/[^\w\s]/g, '') .trim(); - return conversational.has(clean); + if (exactPhrases.has(clean)) return true; + return capabilityPatterns.some((rx) => rx.test(clean)); } _isHolisticSynthesisQuery(query) { @@ -475,6 +524,14 @@ Return ONLY valid JSON: meta.contextBoosted = true; } + // Enrich ALL results (including context-boosted files that bypass the search + // pipeline) with the full analysis metadata from the documentMap. This ensures + // the LLM prompt gets summary, purpose, entity, extractedText, etc. — not just + // the bare-bones fields stored in the vector DB. + if (this.searchService && typeof this.searchService.enrichResults === 'function') { + this.searchService.enrichResults(finalResults); + } + const sources = finalResults.map((result, index) => { const fileId = result?.id; const metadata = result?.metadata || {}; @@ -503,6 +560,23 @@ Return ONLY valid JSON: const isImage = metadata.type === 'image'; + // ── Raw semantic score ── + // The fused score (result.score) is min-max normalized and can inflate + // terrible matches to 1.0. The raw vector/chunk cosine similarity is + // the actual measure of semantic relevance. Expose it as semanticScore + // so the LLM prompt, relevance gate, and UI all use the real signal. + const hybrid = result?.matchDetails?.hybrid || {}; + const rawSemantic = + typeof hybrid.vectorRawScore === 'number' + ? hybrid.vectorRawScore + : typeof hybrid.chunkRawScore === 'number' + ? hybrid.chunkRawScore + : null; + // If neither vector nor chunk search found this result (BM25-only), + // rawSemantic is null → semanticScore falls back to 0, signaling + // that there is no embedding-backed relevance. + const semanticScore = rawSemantic !== null ? rawSemantic : 0; + return { id: `doc-${index + 1}`, fileId, @@ -526,6 +600,8 @@ Return ONLY valid JSON: entities: metadata.keyEntities || [], dates: metadata.dates || [], score: result?.score || 0, + // Raw cosine similarity from embedding search (0-1, absolute quality) + semanticScore, confidence: metadata.confidence || 0, matchDetails: result?.matchDetails || {}, // Image-specific fields @@ -536,18 +612,40 @@ Return ONLY valid JSON: }; }); + // ── Relevance gate (semantic) ── + // Gate on raw cosine similarity instead of the fused score. The fused + // score is min-max normalized and can make garbage look like gold. + // Raw cosine similarity is an absolute quality signal: < 0.25 is + // effectively random for most embedding models. + const relevantSources = sources.filter((s) => s.semanticScore >= CHAT_MIN_SEMANTIC_SCORE); + const droppedCount = sources.length - relevantSources.length; + if (droppedCount > 0) { + logger.debug( + `[ChatService] Dropped ${droppedCount} low-relevance sources (semantic < ${CHAT_MIN_SEMANTIC_SCORE})`, + { + kept: relevantSources.length, + droppedScores: sources + .filter((s) => s.semanticScore < CHAT_MIN_SEMANTIC_SCORE) + .map((s) => ({ fused: s.score.toFixed(3), semantic: s.semanticScore.toFixed(3) })) + .slice(0, 3) + } + ); + } + const searchMeta = searchResults.meta || null; const fallbackReason = searchMeta?.fallbackReason; const isFallback = Boolean(searchMeta?.fallback || searchResults.mode === 'bm25-fallback'); return { - sources, + sources: relevantSources, meta: { ...meta, mode: searchResults.mode || mode, queryMeta: searchResults.queryMeta || null, searchMeta, - resultCount: sources.length, + resultCount: relevantSources.length, + totalRetrieved: sources.length, + droppedLowRelevance: droppedCount, ...(isFallback ? { fallback: true, @@ -598,13 +696,15 @@ Return ONLY valid JSON: if (!Array.isArray(vec) || vec.length === 0) continue; if (vec.length !== queryVector.length) continue; + const similarity = cosineSimilarity(queryVector, vec); scored.push({ id: cleanIds[i], - score: cosineSimilarity(queryVector, vec), + score: similarity, metadata: doc ? { path: doc.filePath, filePath: doc.filePath, + name: doc.fileName, fileName: doc.fileName, fileType: doc.fileType, analyzedAt: doc.analyzedAt, @@ -613,7 +713,15 @@ Return ONLY valid JSON: tags: doc.tags, extractionMethod: doc.extractionMethod } - : {} + : {}, + // Provide matchDetails so the source builder and relevance gate + // can read the raw cosine similarity as vectorRawScore. Without + // this, semanticScore falls back to 0 and the relevance gate + // silently drops every context-boosted file. + matchDetails: { + hybrid: { vectorRawScore: similarity }, + sources: ['context'] + } }); } @@ -642,10 +750,17 @@ Return ONLY valid JSON: } _buildPrompt({ query, history, sources, persona, intent = {} }) { - // Build comprehensive source context for richer conversations + // Build source context using the raw semantic score (actual cosine + // similarity) for all quality decisions. High-similarity sources get + // full extracted text; marginal sources get metadata only. const sourcesText = sources .map((s) => { - const lines = [`[${s.id}] ${s.name} ${s.isImage ? '(Image)' : '(Document)'}`]; + // Use semantic (raw cosine) score — not the inflated fused score — + // so the LLM can accurately judge source quality. + const semPct = Math.round((s.semanticScore ?? s.score ?? 0) * 100); + const lines = [ + `[${s.id}] ${s.name} ${s.isImage ? '(Image)' : '(Document)'} (semantic relevance: ${semPct}%)` + ]; if (s.path) lines.push(`Path: ${s.path}`); if (s.category) lines.push(`Category: ${s.category}`); if (s.documentType) lines.push(`Type: ${s.documentType}`); @@ -653,7 +768,6 @@ Return ONLY valid JSON: if (s.entity) lines.push(`Entity: ${s.entity}`); if (s.project) lines.push(`Project: ${s.project}`); if (s.purpose) lines.push(`Purpose: ${s.purpose}`); - if (s.reasoning) lines.push(`Classification reason: ${s.reasoning}`); if (s.snippet) lines.push(`Summary: ${s.snippet}`); if (s.tags?.length > 0) lines.push(`Tags: ${s.tags.join(', ')}`); // Image-specific context @@ -662,9 +776,14 @@ Return ONLY valid JSON: if (s.hasText) lines.push(`Contains text: Yes`); if (s.colors?.length > 0) lines.push(`Color palette: ${s.colors.slice(0, 5).join(', ')}`); } - // Include extracted text for deeper context if available - if (s.extractedText) - lines.push(`Content excerpt: ${s.extractedText.substring(0, 1000)}...`); + // Only include extracted text for sources with meaningful semantic + // similarity to avoid flooding the context window with irrelevant + // content. Use semanticScore (raw cosine) for the threshold. + const semScore = s.semanticScore ?? s.score ?? 0; + if (s.extractedText && semScore >= 0.4) { + const maxChars = semScore >= 0.6 ? 1200 : 600; + lines.push(`Content excerpt: ${s.extractedText.substring(0, maxChars)}`); + } return lines.join('\n'); }) .join('\n\n---\n\n'); @@ -691,11 +810,9 @@ Correction constraints: : ''; return ` -You are StratoSort, an intelligent and helpful local document assistant. -Your goal is to help the user understand their documents and find information quickly. +You are StratoSort, a friendly local AI assistant that helps users explore and understand their documents. Everything runs 100% on-device. -Persona guidance: -${personaText} +Style: ${personaText} Conversation history: ${history || '(none)'} @@ -718,16 +835,15 @@ Return ONLY valid JSON with this shape: } Rules: -1. Synthesize information from the provided documents to answer the user's question directly. -2. Use 'documentAnswer' for any statements backed by the sources, and include the relevant citations. -3. Use 'modelAnswer' for: - - General knowledge or explanations not found in the docs. - - Conversational transitions or friendly closing remarks. - - Responses to greetings or off-topic chitchat. -4. Use document metadata (Project, Entity, Date, Type) to add useful context to your answer. -5. Be concise but helpful. Avoid robotic repetition. -6. If the documents don't answer the question, say so clearly in 'modelAnswer' and offer general advice if applicable. -7. Generate 1-3 natural follow-up questions that help the user explore their data further. +1. Answer the user's question directly and naturally — like a knowledgeable colleague, not a research report. +2. Each source has a relevance percentage. Prioritize high-relevance sources; treat low-relevance ones (< 50%) as background context only. +3. Use 'documentAnswer' for claims directly backed by sources, with citations. Use 'modelAnswer' for general knowledge, helpful context, or conversational responses. +4. Write in flowing prose. NEVER start with "The provided documents do not..." or similar hedging. +5. If no sources are provided, or they are clearly unrelated to the question, respond helpfully in 'modelAnswer': acknowledge you did not find matching documents and suggest what the user could try instead. Do NOT fabricate document-based claims. +6. Weave document metadata (Project, Entity, Date, Type) into your answer naturally when it adds value. +7. Be concise. One clear paragraph is better than multiple fragmented bullet sections. +8. Generate 1-3 natural follow-up questions grounded in what you know about the user's documents. Avoid generic questions like "Can you provide more context?" — instead suggest specific things they might search for. +9. If the query is casual or about your capabilities, respond warmly in 'modelAnswer' and leave 'documentAnswer' empty. ${synthesisRules} ${correctionRules} `.trim(); @@ -769,11 +885,23 @@ ${correctionRules} }; } - _formatForMemory(parsed) { + _formatForMemory(parsed, sources = []) { const docs = parsed.documentAnswer?.map((d) => d.text).filter(Boolean) || []; const model = parsed.modelAnswer?.map((d) => d.text).filter(Boolean) || []; const combined = [...docs, ...model].join('\n'); - return combined || 'No answer produced.'; + if (!combined) return 'No answer produced.'; + + // Include source names so follow-up questions like "tell me more about + // the tax return" can be grounded. Without this, the flat history loses + // all context about which documents were referenced. + const sourceNames = (sources || []) + .filter((s) => s?.name) + .slice(0, 6) + .map((s) => s.name); + if (sourceNames.length > 0) { + return `${combined}\n[Referenced: ${sourceNames.join(', ')}]`; + } + return combined; } } diff --git a/src/main/services/ClusteringService.js b/src/main/services/ClusteringService.js index a0d3ec4b..a839d952 100644 --- a/src/main/services/ClusteringService.js +++ b/src/main/services/ClusteringService.js @@ -782,9 +782,14 @@ class ClusteringService { /** * Generate a label for a single cluster using LLM * Uses metadata as context to help LLM generate better names + * * @private + * @param {Object} cluster - Cluster object with members + * @param {Object} options - Label generation options + * @param {boolean} [options.skipLLM=false] - Skip LLM inference, use metadata-based labels only */ - async _generateSingleClusterLabel(cluster) { + async _generateSingleClusterLabel(cluster, options = {}) { + const { skipLLM = false } = options; const members = cluster.members || []; if (members.length === 0) { return { @@ -803,8 +808,8 @@ class ClusteringService { // 2. Extract common tags (appear in >40% of files) const commonTags = this._getCommonTags(members, 0.4); - // 3. Always use LLM to generate cluster name (if available) - if (this.llama) { + // 3. Use LLM to generate cluster name (if available and not skipped) + if (this.llama && !skipLLM) { try { const fileNames = members .slice(0, 8) @@ -926,6 +931,7 @@ Examples of good names: "Q4 Financial Reports", "Employee Onboarding Materials", * * @param {Object} options - Label generation options * @param {number} [options.concurrency=3] - Max concurrent LLM calls + * @param {boolean} [options.skipLLM=false] - Skip LLM inference, use only metadata-based labels (fast) * @returns {Promise<{success: boolean, labels: Map}>} */ async generateClusterLabels(options = {}) { @@ -933,7 +939,7 @@ Examples of good names: "Q4 Financial Reports", "Employee Onboarding Materials", return { success: false, error: 'No clusters computed yet' }; } - const { concurrency = 3 } = options; + const { concurrency = 3, skipLLM = false } = options; try { const labels = new Map(); @@ -943,7 +949,7 @@ Examples of good names: "Q4 Financial Reports", "Employee Onboarding Materials", const batch = this.clusters.slice(i, i + concurrency); const batchResults = await Promise.allSettled( - batch.map((cluster) => this._generateSingleClusterLabel(cluster)) + batch.map((cluster) => this._generateSingleClusterLabel(cluster, { skipLLM })) ); // Process batch results diff --git a/src/main/services/DownloadWatcher.js b/src/main/services/DownloadWatcher.js index c57feb90..7cb36abb 100644 --- a/src/main/services/DownloadWatcher.js +++ b/src/main/services/DownloadWatcher.js @@ -21,7 +21,7 @@ const { findContainingSmartFolder } = require('../../shared/folderUtils'); const { getCanonicalFileId } = require('../../shared/pathSanitization'); const { getInstance: getFileOperationTracker } = require('../../shared/fileOperationTracker'); const { isUNCPath } = require('../../shared/crossPlatformUtils'); -const { isTemporaryFile, RETRY } = require('../../shared/performanceConstants'); +const { isTemporaryFile, RETRY, DEBOUNCE } = require('../../shared/performanceConstants'); const { delay } = require('../../shared/promiseUtils'); const { shouldEmbed } = require('./embedding/embeddingGate'); const { computeFileChecksum, handleDuplicateMove } = require('../utils/fileDedup'); @@ -59,7 +59,7 @@ class DownloadWatcher { this._startPromise = null; // Mutex: concurrent start() callers await the same promise this.restartAttempts = 0; this.maxRestartAttempts = RETRY.MAX_ATTEMPTS_MEDIUM; - this.restartDelay = 5000; // 5 seconds between restart attempts + this.restartDelay = DEBOUNCE.WATCHER_RESTART_BASE; this.lastError = null; this.processingFiles = new Set(); // Track files being processed to avoid duplicates this.debounceTimers = new Map(); // Debounce timers for each file diff --git a/src/main/services/LlamaService.js b/src/main/services/LlamaService.js index 0165ca0b..10b87623 100644 --- a/src/main/services/LlamaService.js +++ b/src/main/services/LlamaService.js @@ -19,6 +19,7 @@ const { AI_DEFAULTS, DEFAULT_AI_MODELS } = require('../../shared/constants'); const { getModel } = require('../../shared/modelRegistry'); const { resolveEmbeddingDimension } = require('../../shared/embeddingDimensions'); const { ERROR_CODES } = require('../../shared/errorCodes'); +const { attachErrorCode } = require('../../shared/errorHandlingUtils'); const { categorizeModel } = require('../../shared/modelCategorization'); const { capEmbeddingInput } = require('../utils/embeddingInput'); const SettingsService = require('./SettingsService'); @@ -40,18 +41,6 @@ const { delay } = require('../../shared/promiseUtils'); const logger = createLogger('LlamaService'); -const attachErrorCode = (error, code) => { - if (error && typeof error === 'object') { - if (!error.code) { - error.code = code; - } - return error; - } - const wrapped = new Error(String(error || 'Unknown error')); - wrapped.code = code; - return wrapped; -}; - const isOutOfMemoryError = (error) => { const message = String(error?.message || error || '').toLowerCase(); return message.includes('out of memory') || message.includes('oom'); diff --git a/src/main/services/ModelManager.js b/src/main/services/ModelManager.js index cce8cde3..994bb91b 100644 --- a/src/main/services/ModelManager.js +++ b/src/main/services/ModelManager.js @@ -8,11 +8,7 @@ const { TIMEOUTS } = require('../../shared/performanceConstants'); const { getInstance: getLlamaService } = require('./LlamaService'); const { createSingletonHelpers } = require('../../shared/singletonFactory'); const { ERROR_CODES } = require('../../shared/errorCodes'); - -const attachErrorCode = (error, code) => { - error.code = code; - return error; -}; +const { attachErrorCode } = require('../../shared/errorHandlingUtils'); const { MODEL_CATEGORY_PREFIXES, FALLBACK_MODEL_PREFERENCES diff --git a/src/main/services/NotificationService.js b/src/main/services/NotificationService.js index f9179b06..ac30c2a6 100644 --- a/src/main/services/NotificationService.js +++ b/src/main/services/NotificationService.js @@ -13,6 +13,7 @@ const { Notification, BrowserWindow } = require('electron'); const { randomUUID } = require('crypto'); const { createLogger } = require('../../shared/logger'); +const { IPC_EVENTS } = require('../../shared/constants'); const { safeSend } = require('../ipc/ipcWrappers'); const { NotificationType, @@ -123,7 +124,7 @@ class NotificationService { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { if (win && !win.isDestroyed()) { - safeSend(win.webContents, 'notification', standardized); + safeSend(win.webContents, IPC_EVENTS.NOTIFICATION, standardized); } } diff --git a/src/main/services/OrganizeResumeService.js b/src/main/services/OrganizeResumeService.js index 4d760860..5a31404a 100644 --- a/src/main/services/OrganizeResumeService.js +++ b/src/main/services/OrganizeResumeService.js @@ -1,6 +1,7 @@ const path = require('path'); const fs = require('fs').promises; const { crossDeviceMove } = require('../../shared/atomicFileOperations'); +const { IPC_EVENTS } = require('../../shared/constants'); // FIX: Import safeSend for validated IPC event sending const { safeSend } = require('../ipc/ipcWrappers'); const { computeFileChecksum, handleDuplicateMove } = require('../utils/fileDedup'); @@ -44,7 +45,7 @@ async function resumeIncompleteBatches(serviceIntegration, logger, getMainWindow const win = getMainWindow?.(); if (win && !win.isDestroyed()) { // FIX: Use safeSend for validated IPC event sending - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'batch_organize', current: i + 1, total, @@ -68,7 +69,7 @@ async function resumeIncompleteBatches(serviceIntegration, logger, getMainWindow const win = getMainWindow?.(); if (win && !win.isDestroyed()) { // FIX: Use safeSend for validated IPC event sending - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'batch_organize', current: i + 1, total, @@ -104,7 +105,7 @@ async function resumeIncompleteBatches(serviceIntegration, logger, getMainWindow const win = getMainWindow?.(); if (win && !win.isDestroyed()) { - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'batch_organize', current: i + 1, total, @@ -163,7 +164,7 @@ async function resumeIncompleteBatches(serviceIntegration, logger, getMainWindow const win = getMainWindow?.(); if (win && !win.isDestroyed()) { // FIX: Use safeSend for validated IPC event sending - safeSend(win.webContents, 'operation-progress', { + safeSend(win.webContents, IPC_EVENTS.OPERATION_PROGRESS, { type: 'batch_organize', current: i + 1, total, diff --git a/src/main/services/SearchService.js b/src/main/services/SearchService.js index b73d3f6f..5195cf52 100644 --- a/src/main/services/SearchService.js +++ b/src/main/services/SearchService.js @@ -373,7 +373,8 @@ class SearchService { indexDocs.push(indexDoc); - // Store document metadata for result enrichment + // Store document metadata for result enrichment (including extractedText + // so the chat/RAG pipeline can feed actual document content to the LLM) nextDocumentMap.set(canonicalId, { id: canonicalId, analysisId: doc.id, @@ -392,7 +393,8 @@ class SearchService { reasoning: analysis.reasoning || null, confidence: analysis.confidence, keyEntities: normalizedKeyEntities, - dates: analysis.dates || [] + dates: analysis.dates || [], + extractedText: this._truncateText(analysis.extractedText, 3000) || null }); } @@ -1090,13 +1092,13 @@ class SearchService { } /** - * Enrich results with metadata from documentMap (AnalysisHistory) - * This ensures we have the latest category/confidence even if the search source (e.g. vector) is stale + * Enrich results with metadata from documentMap (AnalysisHistory). + * This ensures we have the latest category, confidence, extractedText, etc. + * even if the search source (e.g. vector DB) only stores basic fields. * - * @param {Array} results - Search results to enrich - * @private + * @param {Array} results - Search results to enrich (mutated in place) */ - _enrichResults(results) { + enrichResults(results) { if (!results || !Array.isArray(results) || this.documentMap.size === 0) return; for (const r of results) { @@ -1104,12 +1106,17 @@ class SearchService { const doc = this.documentMap.get(r.id); if (doc) { // Merge metadata, preferring documentMap (latest analysis) over vector metadata (embed time) - // This ensures category, confidence, etc are up to date + // This ensures category, confidence, extractedText, etc. are up to date r.metadata = { ...r.metadata, ...doc }; } } } + /** @private @deprecated Use enrichResults instead */ + _enrichResults(results) { + return this.enrichResults(results); + } + /** * Normalize scores to [0, 1] range using min-max scaling * @@ -1129,9 +1136,13 @@ class SearchService { } const range = maxScore - minScore; - // If all scores are the same, return with score 1.0 + // If all scores are identical, preserve the actual score clamped to [0, 1]. + // Returning 1.0 unconditionally would make five 0.1-similarity results look + // like perfect matches — destroying the absolute quality signal that + // downstream consumers (especially chat/RAG) rely on. if (range === 0) { - return results.map((r) => ({ ...r, score: 1.0, originalScore: r.score })); + const clamped = Math.min(1.0, Math.max(0, maxScore)); + return results.map((r) => ({ ...r, score: clamped, originalScore: r.score })); } return results.map((r) => ({ diff --git a/src/main/services/SemanticRenameService.js b/src/main/services/SemanticRenameService.js deleted file mode 100644 index e69cb70b..00000000 --- a/src/main/services/SemanticRenameService.js +++ /dev/null @@ -1,89 +0,0 @@ -const path = require('path'); -const { processTemplate, makeUniqueFileName } = require('./autoOrganize/namingUtils'); -const { createLogger } = require('../../shared/logger'); -const logger = createLogger('SemanticRenameService'); - -/** - * Service for semantic file renaming. - * Orchestrates the generation of new filenames based on AI analysis and user templates. - */ -class SemanticRenameService { - constructor() { - this.usedNames = new Map(); // Cache for collision detection within a batch - } - - /** - * Reset the internal collision cache. - * Call this before starting a new batch operation. - */ - resetCache() { - this.usedNames.clear(); - } - - /** - * Generate a new filename for a given file based on analysis results and a template. - * - * @param {string} filePath - The full path to the original file. - * @param {Object} analysisResult - The analysis result object (ExtendedAnalysisSchema). - * @param {string} template - The naming template string (e.g., "{date}_{entity}_{type}"). - * @returns {string} The new full file path (or original if no change). - */ - generateNewName(filePath, analysisResult, template) { - try { - const originalDir = path.dirname(filePath); - const originalName = path.basename(filePath); - const extension = path.extname(originalName); - - // 1. Process the template using the analysis result - const context = { - originalName, - analysis: analysisResult, - extension - }; - - const baseNewName = processTemplate(template, context); - - // 2. Ensure uniqueness (collision handling) - // Note: This only handles collisions within the current batch session's memory. - // Real file system checks should happen at the point of actual renaming (fs.access), - // but this helps pre-calculate unique names for UI preview. - const uniqueName = makeUniqueFileName(baseNewName, this.usedNames); - const newPath = path.join(originalDir, uniqueName); - - // Log rename decision with before/after for full traceability - const renamed = uniqueName !== originalName; - if (renamed) { - logger.info('[SemanticRename] Name generated', { - originalName, - newName: uniqueName, - template, - category: analysisResult?.category, - entity: analysisResult?.entity, - date: analysisResult?.date, - project: analysisResult?.project - }); - } else { - logger.debug('[SemanticRename] Name unchanged (template produced same name)', { - originalName, - template - }); - } - - return newPath; - } catch (error) { - logger.error('[SemanticRename] Failed to generate new name', { - file: filePath, - error: error.message - }); - return filePath; // Fallback to original path on error - } - } -} - -// Singleton instance -const semanticRenameService = new SemanticRenameService(); - -module.exports = { - SemanticRenameService, - semanticRenameService -}; diff --git a/src/main/services/SettingsService.js b/src/main/services/SettingsService.js index c7255327..5c8849c0 100644 --- a/src/main/services/SettingsService.js +++ b/src/main/services/SettingsService.js @@ -11,6 +11,7 @@ const { createSingletonHelpers } = require('../../shared/singletonFactory'); const { LIMITS, DEBOUNCE, TIMEOUTS, RETRY } = require('../../shared/performanceConstants'); const { delay } = require('../../shared/promiseUtils'); const { SettingsBackupService } = require('./SettingsBackupService'); +const { IPC_EVENTS } = require('../../shared/constants'); // FIX: Import safeSend for validated IPC event sending const { safeSend } = require('../ipc/ipcWrappers'); @@ -339,7 +340,7 @@ class SettingsService { // Retry backup creation with exponential backoff let backupResult = null; const maxBackupRetries = RETRY.MAX_ATTEMPTS_MEDIUM; - const initialBackupDelay = 100; // Start with 100ms + const initialBackupDelay = RETRY.SETTINGS_BACKUP.initialDelay; for (let attempt = 0; attempt < maxBackupRetries; attempt++) { try { @@ -403,7 +404,7 @@ class SettingsService { // Bug #42: Retry logic for file lock handling with exponential backoff // FIX: Increased retry count and delay for Windows antivirus/indexing const maxSaveRetries = RETRY.MAX_ATTEMPTS_HIGH; - const baseSaveDelay = 200; // Was 100ms - Total window: 200+400+800+1600=3000ms + const baseSaveDelay = RETRY.SETTINGS_SAVE.initialDelay; for (let attempt = 0; attempt < maxSaveRetries; attempt++) { try { @@ -799,7 +800,8 @@ class SettingsService { // Attempt to restart watcher with exponential backoff // Uses restart count for backoff factor (5s, 10s, 20s, 40s, ...) - const backoffDelay = 5000 * Math.pow(2, this._watcherRestartCount || 0); + const backoffDelay = + DEBOUNCE.WATCHER_RESTART_BASE * Math.pow(2, this._watcherRestartCount || 0); const jitteredDelay = backoffDelay * (0.9 + Math.random() * 0.2); this._restartTimer = setTimeout(() => { this._restartTimer = null; @@ -949,7 +951,7 @@ class SettingsService { if (win && !win.isDestroyed() && win.webContents) { try { // FIX: Use safeSend for validated IPC event sending - safeSend(win.webContents, 'settings-changed-external', eventPayload); + safeSend(win.webContents, IPC_EVENTS.SETTINGS_CHANGED_EXTERNAL, eventPayload); } catch (error) { logger.warn( `[SettingsService] Failed to send settings-changed event: ${error.message}` diff --git a/src/main/services/SmartFolderWatcher.js b/src/main/services/SmartFolderWatcher.js index 85c0f011..adcb9dc0 100644 --- a/src/main/services/SmartFolderWatcher.js +++ b/src/main/services/SmartFolderWatcher.js @@ -8,7 +8,12 @@ * - Watches all configured smart folders * - Auto-analyzes new files added to smart folders * - Re-analyzes files when modified (based on mtime) + * - Content updates (file edited & saved) only refresh details + * (summary, keywords, embeddings) — naming and folder/category + * assignment are preserved * - Debouncing to wait for file saves to complete + * - Startup reconciliation: detects files deleted while the watcher + * was stopped and cleans up orphaned embeddings/history * - Opt-in via settings * * @module services/SmartFolderWatcher @@ -151,6 +156,7 @@ class SmartFolderWatcher { filesReanalyzed: 0, errors: 0, queueDropped: 0, + orphansReconciled: 0, lastActivity: null }; } @@ -295,6 +301,12 @@ class SmartFolderWatcher { // Start queue processor this._startQueueProcessor(); + // Run startup reconciliation in background — removes orphaned + // embeddings/history for files deleted while the watcher was stopped. + this._reconcileOrphanedEntries().catch((err) => { + logger.debug('[SMART-FOLDER-WATCHER] Startup reconciliation failed:', err.message); + }); + return true; } catch (error) { logger.error('[SMART-FOLDER-WATCHER] Failed to start:', error.message); @@ -1013,6 +1025,10 @@ class SmartFolderWatcher { const { filePath, eventType, applyNaming } = item; const isReanalyze = eventType === 'reanalyze'; + // Content update: file was modified in-place (e.g. user edited & saved). + // Only update details (summary, keywords, embeddings) — preserve the + // existing name and smart-folder/category assignment. + const isContentUpdate = eventType === 'change'; if (this.processingFiles.has(filePath)) { return; @@ -1025,7 +1041,8 @@ class SmartFolderWatcher { const fileStats = await fs.stat(filePath); logger.info('[SMART-FOLDER-WATCHER] Analyzing file:', filePath, { - applyNaming: applyNaming !== false + applyNaming: applyNaming !== false, + isContentUpdate }); // Get smart folders for categorization @@ -1089,7 +1106,7 @@ class SmartFolderWatcher { // Apply user's naming convention to the suggested name (unless explicitly disabled) // Default behavior is to apply naming (backward compatibility) - const shouldApplyNaming = applyNaming !== false && !isEmbeddingRetry; + const shouldApplyNaming = applyNaming !== false && !isEmbeddingRetry && !isContentUpdate; if (shouldApplyNaming) { try { @@ -1222,16 +1239,31 @@ class SmartFolderWatcher { : derivedConfidence; const isImage = isImagePath(filePath); + // For content updates (file edited & saved in place), preserve the + // existing smart-folder assignment instead of using the LLM's + // re-categorization — the user intentionally placed the file here. + let resolvedCategory = analysis.category || analysis.folder || 'uncategorized'; + let resolvedSmartFolderName = analysis.smartFolder || analysis.folder || null; + + if (isContentUpdate) { + const learningService = getLearningFeedbackService(); + const containingFolder = learningService?.findContainingSmartFolder?.(filePath); + if (containingFolder) { + resolvedCategory = containingFolder.name; + resolvedSmartFolderName = containingFolder.name; + } + } + const historyPayload = { // The history utility uses suggestedName as the subject fallback. // Prefer any naming-convention output; otherwise keep the original basename. suggestedName: analysis.suggestedName || path.basename(filePath), - category: analysis.category || analysis.folder || 'uncategorized', + category: resolvedCategory, keywords, confidence: historyConfidence, summary: analysis.summary || analysis.description || '', extractedText: analysis.extractedText || null, - smartFolder: analysis.smartFolder || analysis.folder || null, + smartFolder: resolvedSmartFolderName, model: analysis.model || result.model || (isImage ? 'vision' : 'llm'), // Extended fields for document/image conversations // CRITICAL: Use field names that match AnalysisHistoryServiceCore expectations @@ -1942,6 +1974,82 @@ class SmartFolderWatcher { } } + /** + * Reconcile orphaned entries on startup. + * + * When the watcher is stopped (e.g. app closed), files may be deleted or + * moved out of smart folders externally. On next startup, this sweep checks + * analysis history for files within watched smart folders that no longer + * exist on disk and cleans up their embeddings, chunks, and history. + * + * Runs in the background with yielding so it never blocks the main thread + * or delays normal watcher operation. + * @private + */ + async _reconcileOrphanedEntries() { + // Let the system settle after startup before sweeping + await new Promise((r) => setTimeout(r, 5000)); + + if (this._isStopping || !this.isRunning) return; + + const entries = this.analysisHistoryService?.analysisHistory?.entries; + if (!entries || typeof entries !== 'object') { + logger.debug('[SMART-FOLDER-WATCHER] Reconciliation skipped — no history entries available'); + return; + } + + // Snapshot the entries to avoid mutation during iteration + const allEntries = Object.values(entries); + let checked = 0; + let removed = 0; + + for (const entry of allEntries) { + if (this._isStopping) break; + + // Resolve the file's current path (actual if moved, otherwise original) + const filePath = entry?.organization?.actual || entry?.originalPath; + if (!filePath) continue; + + // Only reconcile files within watched smart folders + if (!this._isInWatchedPath(filePath)) continue; + + checked++; + + try { + await fs.access(filePath); + } catch { + // File no longer exists — clean up embeddings, chunks, and history + try { + await this._finalizeDeletion(filePath); + removed++; + logger.debug('[SMART-FOLDER-WATCHER] Reconciled orphaned entry:', { + file: path.basename(filePath) + }); + } catch (cleanupErr) { + logger.debug('[SMART-FOLDER-WATCHER] Reconciliation cleanup failed:', { + file: path.basename(filePath), + error: cleanupErr.message + }); + } + } + + // Yield every 50 files to avoid blocking the event loop + if (checked % 50 === 0) { + await new Promise((r) => setTimeout(r, 100)); + } + } + + if (removed > 0) { + this.stats.orphansReconciled += removed; + this.stats.lastActivity = new Date().toISOString(); + logger.info('[SMART-FOLDER-WATCHER] Startup reconciliation complete', { checked, removed }); + } else { + logger.debug('[SMART-FOLDER-WATCHER] Startup reconciliation — no orphans found', { + checked + }); + } + } + /** * Handle watcher errors * @private @@ -2058,6 +2166,8 @@ class SmartFolderWatcher { * Queue a single file for reanalysis. * @param {string} filePath * @param {Object} [options] + * @param {boolean} [options.force] - Skip the watched-path check (for user-initiated reanalysis) + * @param {boolean} [options.applyNaming] - Apply naming conventions to the result * @returns {Promise<{queued: boolean, error?: string, errorCode?: string}>} */ async reanalyzeFile(filePath, options = {}) { @@ -2065,7 +2175,7 @@ class SmartFolderWatcher { return { queued: false, error: 'filePath is required', errorCode: 'MISSING_FILE_PATH' }; } - if (!this._isInWatchedPath(filePath)) { + if (!options.force && !this._isInWatchedPath(filePath)) { return { queued: false, error: 'File is not inside a watched smart folder', diff --git a/src/main/simple-main.js b/src/main/simple-main.js index fdd5543e..4dafb83a 100644 --- a/src/main/simple-main.js +++ b/src/main/simple-main.js @@ -61,7 +61,7 @@ const ServiceIntegration = require('./services/ServiceIntegration'); const { getStartupManager } = require('./services/startup'); // Import shared constants -const { IPC_CHANNELS } = require('../shared/constants'); +const { IPC_CHANNELS, IPC_EVENTS } = require('../shared/constants'); // Import path sanitization for security checks const { isPathDangerous } = require('../shared/pathSanitization'); @@ -958,7 +958,7 @@ app.whenReady().then(async () => { const win = BrowserWindow.getAllWindows()[0]; if (!win || win.isDestroyed()) return; const metrics = await systemAnalytics.collectMetrics(); - safeSend(win.webContents, 'system-metrics', metrics); + safeSend(win.webContents, IPC_EVENTS.SYSTEM_METRICS, metrics); } catch (error) { logger.error('[METRICS] Failed to collect or send metrics:', error); } diff --git a/src/main/utils/fileDedup.js b/src/main/utils/fileDedup.js index 39919b81..54c400f9 100644 --- a/src/main/utils/fileDedup.js +++ b/src/main/utils/fileDedup.js @@ -265,8 +265,79 @@ async function handleDuplicateMove({ return { skipped: true, destination: duplicatePath, reason: 'duplicate' }; } +/** + * Check if a file has semantic (embedding-based) duplicates at a destination directory. + * Uses the vector DB to find files with similar content, complementing the checksum-based + * exact-match detection. Requires the OramaVectorService to be available and the source + * file to have been analyzed/embedded. + * + * @param {Object} params + * @param {string} params.sourceFileId - Semantic file ID (e.g., "file:/path/to/source") + * @param {string} params.destinationDir - Directory to check for similar files + * @param {number} [params.threshold=0.9] - Cosine similarity threshold (0..1). Default 0.9. + * @param {number} [params.topK=5] - Max similar files to return + * @param {Object} [params.vectorDbService] - OramaVectorService instance (resolved lazily if omitted) + * @param {Object} [params.logger] - Logger instance + * @returns {Promise<{hasDuplicates: boolean, matches: Array<{id: string, score: number, metadata: Object}>}>} + */ +async function findSemanticDuplicates({ + sourceFileId, + destinationDir, + threshold = 0.9, + topK = 5, + vectorDbService, + logger +}) { + const emptyResult = { hasDuplicates: false, matches: [] }; + + if (!sourceFileId || !destinationDir) return emptyResult; + + // Resolve vector DB service lazily if not provided + let vdb = vectorDbService; + if (!vdb) { + try { + const { container, ServiceIds } = require('../services/ServiceContainer'); + vdb = + typeof container.tryResolve === 'function' + ? container.tryResolve(ServiceIds.ORAMA_VECTOR) + : container.resolve(ServiceIds.ORAMA_VECTOR); + } catch { + if (logger?.debug) { + logger.debug('[DEDUP] Vector DB service unavailable for semantic duplicate check'); + } + return emptyResult; + } + } + + if (!vdb || typeof vdb.findSimilarInDirectory !== 'function') { + return emptyResult; + } + + try { + const matches = await vdb.findSimilarInDirectory(sourceFileId, destinationDir, { + threshold, + topK + }); + + return { + hasDuplicates: matches.length > 0, + matches + }; + } catch (error) { + if (logger?.debug) { + logger.debug('[DEDUP] Semantic duplicate check failed (non-fatal)', { + sourceFileId, + destinationDir, + error: error?.message + }); + } + return emptyResult; + } +} + module.exports = { computeFileChecksum, findDuplicateForDestination, - handleDuplicateMove + handleDuplicateMove, + findSemanticDuplicates }; diff --git a/src/renderer/components/AnalysisHistoryModal.jsx b/src/renderer/components/AnalysisHistoryModal.jsx index 18663a66..4ca7854a 100644 --- a/src/renderer/components/AnalysisHistoryModal.jsx +++ b/src/renderer/components/AnalysisHistoryModal.jsx @@ -9,6 +9,7 @@ import Card from './ui/Card'; import { Heading, Text } from './ui/Typography'; import { StatusBadge, StateMessage } from './ui'; import { Inline, Stack } from './layout'; +import { nextRequestId, isCurrentRequest } from '../utils/requestGuard'; const logger = createLogger('AnalysisHistoryModal'); function AnalysisHistoryModal({ onClose, analysisStats, setAnalysisStats }) { @@ -18,6 +19,7 @@ function AnalysisHistoryModal({ onClose, analysisStats, setAnalysisStats }) { const [selectedTab, setSelectedTab] = useState('statistics'); const hasLoadedRef = React.useRef(false); const isMountedRef = React.useRef(true); + const loadRequestIdRef = React.useRef(0); const [isClearing, setIsClearing] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [isExporting, setIsExporting] = useState(false); @@ -30,12 +32,14 @@ function AnalysisHistoryModal({ onClose, analysisStats, setAnalysisStats }) { }, []); const loadAnalysisData = useCallback(async () => { + const requestId = nextRequestId(loadRequestIdRef); setIsLoading(true); let loadedCount = 0; const totalLoads = 2; const markLoaded = () => { + if (!isCurrentRequest(loadRequestIdRef, requestId)) return; loadedCount++; if (loadedCount >= totalLoads && isMountedRef.current) { setIsLoading(false); @@ -46,12 +50,12 @@ function AnalysisHistoryModal({ onClose, analysisStats, setAnalysisStats }) { if (statsPromise && typeof statsPromise.then === 'function') { statsPromise .then((stats) => { - if (isMountedRef.current) { + if (isMountedRef.current && isCurrentRequest(loadRequestIdRef, requestId)) { setAnalysisStats(stats); } }) .catch((error) => { - if (isMountedRef.current) { + if (isMountedRef.current && isCurrentRequest(loadRequestIdRef, requestId)) { logger.warn('Failed to load statistics', { error: error?.message }); } }) @@ -64,7 +68,7 @@ function AnalysisHistoryModal({ onClose, analysisStats, setAnalysisStats }) { if (historyPromise && typeof historyPromise.then === 'function') { historyPromise .then((history) => { - if (!isMountedRef.current) return; + if (!isMountedRef.current || !isCurrentRequest(loadRequestIdRef, requestId)) return; if (Array.isArray(history)) { setHistoryData(history); } else { @@ -76,7 +80,7 @@ function AnalysisHistoryModal({ onClose, analysisStats, setAnalysisStats }) { } }) .catch((error) => { - if (isMountedRef.current) { + if (isMountedRef.current && isCurrentRequest(loadRequestIdRef, requestId)) { addNotification('Failed to load analysis history', 'error'); logger.warn('Failed to load history', { error: error?.message }); } diff --git a/src/renderer/components/SettingsPanel.jsx b/src/renderer/components/SettingsPanel.jsx index 7f3725c6..0fb98bbd 100644 --- a/src/renderer/components/SettingsPanel.jsx +++ b/src/renderer/components/SettingsPanel.jsx @@ -17,6 +17,7 @@ import { createLogger } from '../../shared/logger'; import { sanitizeSettings } from '../../shared/settingsValidation'; import { DEFAULT_AI_MODELS } from '../../shared/constants'; import { DEFAULT_SETTINGS } from '../../shared/defaultSettings'; +import { DEBOUNCE } from '../../shared/performanceConstants'; import { useNotification } from '../contexts/NotificationContext'; import { getElectronAPI, eventsIpc, llamaIpc, settingsIpc } from '../services/ipc'; import { useAppDispatch, useAppSelector } from '../store/hooks'; @@ -276,7 +277,7 @@ const SettingsPanel = React.memo(function SettingsPanel() { let mounted = true; setIsHydrating(true); - const HYDRATION_TIMEOUT_MS = 10000; // 10 second timeout + const HYDRATION_TIMEOUT_MS = DEBOUNCE.SETTINGS_HYDRATION; const loadSettingsIfMounted = async () => { if (mounted) { @@ -544,29 +545,81 @@ const SettingsPanel = React.memo(function SettingsPanel() { try { setIsAddingModel(true); + + // Clean up any stale progress subscription before creating a new one + if (progressUnsubRef.current) { + try { + progressUnsubRef.current(); + } catch { + // Non-fatal + } + } + + // Track which models we're waiting on so we know when all are done + const pendingModels = new Set(modelsToDownload); + + // Subscribe to progress/completion/error events for ALL requested downloads + progressUnsubRef.current = eventsIpc.onOperationProgress((evt) => { + if (evt?.type === 'model-download' && pendingModels.has(evt.model)) { + setPullProgress({ + modelName: evt.model, + percent: evt.progress?.percent || 0 + }); + } else if (evt?.type === 'model-download-complete' && pendingModels.has(evt.model)) { + pendingModels.delete(evt.model); + addNotification(`Model "${evt.model}" downloaded successfully`, 'success'); + loadModels(); + if (pendingModels.size === 0) { + setPullProgress(null); + setIsAddingModel(false); + } + } else if (evt?.type === 'model-download-error' && pendingModels.has(evt.model)) { + pendingModels.delete(evt.model); + addNotification( + `Failed to download "${evt.model}": ${evt.error || 'unknown error'}`, + 'error' + ); + if (pendingModels.size === 0) { + setPullProgress(null); + setIsAddingModel(false); + } + } + }); + + // Kick off all downloads (handler returns immediately now) const errors = []; for (const modelName of modelsToDownload) { const res = await llamaIpc.downloadModel(modelName); if (res?.alreadyInProgress) { - // Download started by background setup — not an error + pendingModels.delete(modelName); continue; } - if (!res?.success) { - errors.push(res?.error || `Failed to download ${modelName}`); + if (!res?.success && !res?.started) { + pendingModels.delete(modelName); + errors.push(res?.error || `Failed to start ${modelName}`); } } + if (errors.length > 0) { - addNotification(`Some downloads failed: ${errors.join('; ')}`, 'warning'); + addNotification(`Some downloads failed to start: ${errors.join('; ')}`, 'warning'); + } + + // Show immediate feedback + const startedCount = pendingModels.size; + if (startedCount > 0) { + addNotification( + `Downloading ${startedCount} model${startedCount > 1 ? 's' : ''}… Progress is shown above.`, + 'info' + ); } else { - addNotification('Recommended models downloaded', 'success'); + // All were already in progress or failed to start — reset loading state + setIsAddingModel(false); } - await loadModels(); } catch (error) { addNotification( `Failed to download recommended models: ${error?.message || 'unknown error'}`, 'error' ); - } finally { setIsAddingModel(false); } }, [ @@ -744,6 +797,7 @@ const SettingsPanel = React.memo(function SettingsPanel() { { height: 10, background: 'transparent', border: 'none', - zIndex: -1 + zIndex: 0 }} /> @@ -243,7 +243,7 @@ const ClusterNode = memo(({ data, selected }) => { /> ) : (
{label} @@ -260,7 +260,7 @@ const ClusterNode = memo(({ data, selected }) => { {/* Top terms / why (kept inside the hub to avoid clipping) */} {topTerms.length > 0 && (
{topTerms.join(' • ')} @@ -298,7 +298,7 @@ const ClusterNode = memo(({ data, selected }) => { height: 10, background: 'transparent', border: 'none', - zIndex: -1 + zIndex: 0 }} />
diff --git a/src/renderer/components/search/KnowledgeEdge.jsx b/src/renderer/components/search/KnowledgeEdge.jsx index 1b071dad..2129ffb7 100644 --- a/src/renderer/components/search/KnowledgeEdge.jsx +++ b/src/renderer/components/search/KnowledgeEdge.jsx @@ -1,7 +1,8 @@ -import React, { memo, useCallback, useMemo, useState } from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; -import { BaseEdge, getSmoothStepPath } from 'reactflow'; +import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath } from 'reactflow'; import BaseEdgeTooltip from './BaseEdgeTooltip'; +import { useElkPath, useEdgeHover } from './useEdgeInteraction'; /** * KnowledgeEdge @@ -22,25 +23,8 @@ const KnowledgeEdge = memo( style, markerEnd }) => { - const [isHovered, setIsHovered] = useState(false); - - const elkPath = useMemo(() => { - const sections = data?.elkSections; - if (!sections || sections.length === 0) return null; - - return sections - .map((section) => { - let pathStr = `M ${section.startPoint.x},${section.startPoint.y}`; - if (section.bendPoints) { - section.bendPoints.forEach((bp) => { - pathStr += ` L ${bp.x},${bp.y}`; - }); - } - pathStr += ` L ${section.endPoint.x},${section.endPoint.y}`; - return pathStr; - }) - .join(' '); - }, [data?.elkSections]); + const { isHovered, handleMouseEnter, handleMouseLeave } = useEdgeHover(); + const elkPath = useElkPath(data); const [smoothPath, labelX, labelY] = getSmoothStepPath({ sourceX, @@ -61,9 +45,6 @@ const KnowledgeEdge = memo( const targetLabel = data?.targetData?.label || ''; const tooltipsEnabled = data?.showEdgeTooltips !== false; - const handleMouseEnter = useCallback(() => setIsHovered(true), []); - const handleMouseLeave = useCallback(() => setIsHovered(false), []); - const edgeStyle = { stroke: '#22c55e', strokeWidth: isHovered ? 2.5 : 1.5, @@ -76,6 +57,8 @@ const KnowledgeEdge = memo( const conceptPreview = concepts.slice(0, 4); + const showCompactBadge = !tooltipsEnabled; + return ( <> {tooltipsEnabled && ( @@ -90,6 +73,24 @@ const KnowledgeEdge = memo( /> )} + {showCompactBadge && ( + +
+ + K:{weight} + +
+
+ )} {tooltipsEnabled && ( { - const [isHovered, setIsHovered] = useState(false); + const { isHovered, handleMouseEnter, handleMouseLeave } = useEdgeHover(); // Get the edge path // Position label 75% along the path (much closer to target file) to clearly associate score with file @@ -99,9 +100,6 @@ const QueryMatchEdge = memo( return reasons; }, [matchDetails, sources]); - const handleMouseEnter = useCallback(() => setIsHovered(true), []); - const handleMouseLeave = useCallback(() => setIsHovered(false), []); - // Dynamic styling based on hover const edgeStyle = { ...style, @@ -124,6 +122,8 @@ const QueryMatchEdge = memo( default: 'text-system-gray-500' }; + const showCompactBadge = !tooltipsEnabled; + return ( <> {/* Invisible wider path for easier hovering */} @@ -142,6 +142,26 @@ const QueryMatchEdge = memo( {/* Visible edge */} + {/* Lightweight fallback badge for large graphs */} + {showCompactBadge && ( + +
+ + {scorePercent}% + +
+
+ )} + {/* Edge label and tooltip */} {tooltipsEnabled && ( { - const [isHovered, setIsHovered] = useState(false); - - // Get the edge path - // Prefer ELK-routed path if available for collision avoidance - const elkPath = useMemo(() => { - const sections = data?.elkSections; - if (!sections || sections.length === 0) return null; - - return sections - .map((section) => { - let pathStr = `M ${section.startPoint.x},${section.startPoint.y}`; - if (section.bendPoints) { - section.bendPoints.forEach((bp) => { - pathStr += ` L ${bp.x},${bp.y}`; - }); - } - pathStr += ` L ${section.endPoint.x},${section.endPoint.y}`; - return pathStr; - }) - .join(' '); - }, [data?.elkSections]); + const { isHovered, handleMouseEnter, handleMouseLeave } = useEdgeHover(); + const elkPath = useElkPath(data); // Fallback to ReactFlow's path routing if ELK path is missing const [smoothPath, smoothLabelX, smoothLabelY] = getSmoothStepPath({ @@ -199,9 +181,6 @@ const SimilarityEdge = memo( // Default to true if not specified (legacy behavior) const showLabel = logicalShowLabel && (data?.showEdgeLabels ?? true); - const handleMouseEnter = useCallback(() => setIsHovered(true), []); - const handleMouseLeave = useCallback(() => setIsHovered(false), []); - // Dynamic styling based on hover and relationship strength const baseWidth = 1 + relationshipStrength * 0.5; const edgeStyle = { @@ -214,6 +193,18 @@ const SimilarityEdge = memo( transition: 'all 0.2s ease' }; + const compactLabelText = + primaryType === 'cross' + ? `Bridge ${similarityPercent}%` + : primaryType === 'tag' + ? `Tag ${similarityPercent}%` + : primaryType === 'category' + ? `${sourceCategory || 'Category'}` + : `${similarityPercent}%`; + const showCompactBadge = + !tooltipsEnabled && + (isCrossCluster || commonTags.length > 0 || sameCategory || similarityPercent >= 65); + return ( <> {/* Invisible wider path for easier hovering */} @@ -232,6 +223,26 @@ const SimilarityEdge = memo( {/* Visible edge */} + {/* Lightweight fallback badge for large graphs */} + {showCompactBadge && ( + +
+ + {compactLabelText} + +
+
+ )} + {/* Persistent Verbal Label (only for strong connections) */} {showLabel && ( diff --git a/src/renderer/components/search/SmartStepEdge.jsx b/src/renderer/components/search/SmartStepEdge.jsx index ed3551c5..a4c7ac47 100644 --- a/src/renderer/components/search/SmartStepEdge.jsx +++ b/src/renderer/components/search/SmartStepEdge.jsx @@ -1,6 +1,7 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; import { BaseEdge, getSmoothStepPath, EdgeLabelRenderer } from 'reactflow'; +import { useElkPath } from './useEdgeInteraction'; /** * SmartStepEdge @@ -22,25 +23,8 @@ const SmartStepEdge = ({ label = null, data }) => { - // Get the edge path // Prefer ELK-routed path if available for collision avoidance - const elkPath = useMemo(() => { - const sections = data?.elkSections; - if (!sections || sections.length === 0) return null; - - return sections - .map((section) => { - let pathStr = `M ${section.startPoint.x},${section.startPoint.y}`; - if (section.bendPoints) { - section.bendPoints.forEach((bp) => { - pathStr += ` L ${bp.x},${bp.y}`; - }); - } - pathStr += ` L ${section.endPoint.x},${section.endPoint.y}`; - return pathStr; - }) - .join(' '); - }, [data?.elkSections]); + const elkPath = useElkPath(data); const [smoothPath, smoothLabelX, smoothLabelY] = getSmoothStepPath({ sourceX, diff --git a/src/renderer/components/search/nodes/FileNode.jsx b/src/renderer/components/search/nodes/FileNode.jsx index 4bee3032..497c2e1b 100644 --- a/src/renderer/components/search/nodes/FileNode.jsx +++ b/src/renderer/components/search/nodes/FileNode.jsx @@ -293,14 +293,18 @@ const FileNode = memo(({ data, selected }) => { {(tags.length > 0 || displaySuggestedFolder) && (
{displaySuggestedFolder && ( - + 📂 {displaySuggestedFolder} )} {tags.map((tag, i) => ( #{tag} diff --git a/src/renderer/components/search/useEdgeInteraction.js b/src/renderer/components/search/useEdgeInteraction.js new file mode 100644 index 00000000..406c3a95 --- /dev/null +++ b/src/renderer/components/search/useEdgeInteraction.js @@ -0,0 +1,60 @@ +/** + * useEdgeInteraction + * + * Shared hooks and utilities for ReactFlow edge components. + * Consolidates the ELK path calculation and hover interaction logic + * that was previously duplicated across KnowledgeEdge, SimilarityEdge, + * QueryMatchEdge, and SmartStepEdge. + * + * @module search/useEdgeInteraction + */ + +import { useState, useCallback, useMemo } from 'react'; + +/** + * Build an SVG path string from ELK-routed edge sections. + * Returns null if no valid sections are provided, signalling + * the caller should fall back to ReactFlow's built-in pathing. + * + * @param {Array|null|undefined} elkSections - ELK edge sections from layout + * @returns {string|null} SVG path string or null + */ +export function buildElkPath(elkSections) { + if (!elkSections || elkSections.length === 0) return null; + + return elkSections + .map((section) => { + let pathStr = `M ${section.startPoint.x},${section.startPoint.y}`; + if (section.bendPoints) { + section.bendPoints.forEach((bp) => { + pathStr += ` L ${bp.x},${bp.y}`; + }); + } + pathStr += ` L ${section.endPoint.x},${section.endPoint.y}`; + return pathStr; + }) + .join(' '); +} + +/** + * Hook that memoizes the ELK path calculation for an edge. + * + * @param {object|null} data - Edge data from ReactFlow (must contain elkSections) + * @returns {string|null} Memoized SVG path string or null + */ +export function useElkPath(data) { + return useMemo(() => buildElkPath(data?.elkSections), [data?.elkSections]); +} + +/** + * Hook that provides hover state and stable callbacks for edge interaction. + * + * @returns {{ isHovered: boolean, handleMouseEnter: Function, handleMouseLeave: Function }} + */ +export function useEdgeHover() { + const [isHovered, setIsHovered] = useState(false); + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); + + return { isHovered, handleMouseEnter, handleMouseLeave }; +} diff --git a/src/renderer/components/settings/GraphRetrievalSection.jsx b/src/renderer/components/settings/GraphRetrievalSection.jsx index 348eedc4..dc3a62f9 100644 --- a/src/renderer/components/settings/GraphRetrievalSection.jsx +++ b/src/renderer/components/settings/GraphRetrievalSection.jsx @@ -114,9 +114,9 @@ function GraphRetrievalSection({ settings, setSettings }) { } else { setStats(null); } - if (historyResponse && !historyResponse?.success) { - setHistoryStats(null); - } else if (historyResponse) { + // Check for explicit failure (success: false), not absence of success flag. + // Statistics may arrive without a success wrapper from older IPC paths. + if (historyResponse && historyResponse.success !== false) { setHistoryStats(historyResponse); } else { setHistoryStats(null); @@ -258,8 +258,19 @@ function GraphRetrievalSection({ settings, setSettings }) { >
- - {stats?.edgeCount != null ? `${stats.edgeCount} edges (max 2000)` : 'No index yet'} + = 2000 + ? 'text-amber-600 dark:text-amber-400' + : 'text-system-gray-700' + } + > + {stats?.edgeCount != null + ? stats.edgeCount >= 2000 + ? `${stats.edgeCount} edges (at capacity)` + : `${stats.edgeCount} edges (max 2000)` + : 'No index yet'}
)} @@ -257,6 +265,7 @@ function LlamaConfigSection({ LlamaConfigSection.propTypes = { llamaHealth: PropTypes.object, isRefreshingModels: PropTypes.bool, + isDownloading: PropTypes.bool, downloadProgress: PropTypes.object, modelList: PropTypes.array, showAllModels: PropTypes.bool.isRequired, diff --git a/src/renderer/store/index.js b/src/renderer/store/index.js index a057cd8c..c6140184 100644 --- a/src/renderer/store/index.js +++ b/src/renderer/store/index.js @@ -281,28 +281,11 @@ const loadState = () => { analysisProgress: { current: 0, total: 0, lastActivity: 0 }, currentAnalysisFile: '', // Serialize dates in analysis results too - results: serializeLoadedFiles(parsed.analysis?.results || []), - stats: parsed.analysis?.stats || null - }, - // FIX CRIT-4: Add systemSlice defaults - this was completely missing from loadState - system: { - metrics: { cpu: 0, memory: 0, uptime: 0 }, - health: { - // In-process services (Orama, node-llama-cpp) are always online after init. - // No IPC event updates these, so they must match systemSlice initialState. - vectorDb: 'online', - llama: 'online' - }, - notifications: [], // Don't restore notifications - they're transient - unreadNotificationCount: 0, - version: '1.0.0', - documentsPath: parsed.system?.documentsPath || null, - documentsPathLoading: false, - documentsPathError: null, - redactPaths: null, - redactPathsLoading: false, - redactPathsError: null + results: serializeLoadedFiles(parsed.analysis?.results || []) } + // System slice is NOT restored from persistence. It uses its own initialState + // defaults (systemSlice.js) and re-fetches documentsPath/redactPaths via IPC + // thunks dispatched in index.js on every startup. }; } catch { return undefined; diff --git a/src/renderer/store/middleware/persistenceMiddleware.js b/src/renderer/store/middleware/persistenceMiddleware.js index 9edc1007..26f6d163 100644 --- a/src/renderer/store/middleware/persistenceMiddleware.js +++ b/src/renderer/store/middleware/persistenceMiddleware.js @@ -22,6 +22,8 @@ let lastSavedFileStatesCount = -1; let lastSavedFileStatesRef = null; // FIX: Track UI state changes let lastSavedSidebarOpen = null; +// Track namingConvention reference so naming-only changes trigger saves +let lastSavedNamingConventionRef = null; // NOTE: showSettings is intentionally NOT persisted (transient overlay state). // FIX: Re-entry guard to prevent infinite loops if save triggers actions @@ -288,6 +290,8 @@ const persistenceMiddleware = (store) => { const currentFileStatesCount = Object.keys(state.files.fileStates || {}).length; const currentFileStatesRef = getFileStatesRef(state.files.fileStates); + const currentNamingConventionRef = state.files.namingConvention; + const hasRelevantChange = currentPhase !== lastSavedPhase || currentFilesCount !== lastSavedFilesCount || @@ -299,7 +303,9 @@ const persistenceMiddleware = (store) => { currentFileStatesCount !== lastSavedFileStatesCount || currentFileStatesRef !== lastSavedFileStatesRef || // FIX: Check UI state changes (sidebar only; settings overlay is transient) - sidebarOpen !== lastSavedSidebarOpen; + sidebarOpen !== lastSavedSidebarOpen || + // FIX: Track naming convention changes (setNamingConvention creates new ref) + currentNamingConventionRef !== lastSavedNamingConventionRef; if (!hasRelevantChange) { return result; @@ -376,6 +382,7 @@ const persistenceMiddleware = (store) => { lastSavedFileStatesCount = Object.keys(freshState.files.fileStates || {}).length; lastSavedFileStatesRef = getFileStatesRef(freshState.files.fileStates); lastSavedSidebarOpen = freshState.ui.sidebarOpen; + lastSavedNamingConventionRef = freshState.files.namingConvention; firstPendingRequestTime = 0; // Reset so next debounce cycle tracks fresh } } finally { @@ -427,6 +434,7 @@ export const cleanupPersistence = (store) => { lastSavedFileStatesCount = -1; lastSavedFileStatesRef = null; lastSavedSidebarOpen = null; + lastSavedNamingConventionRef = null; firstPendingRequestTime = 0; }; diff --git a/src/renderer/store/migrations.js b/src/renderer/store/migrations.js index 73bc91db..e87f39b5 100644 --- a/src/renderer/store/migrations.js +++ b/src/renderer/store/migrations.js @@ -27,6 +27,9 @@ const migrations = { if (!migrated.ui || typeof migrated.ui !== 'object') migrated.ui = {}; if (!migrated.files || typeof migrated.files !== 'object') migrated.files = {}; if (!migrated.analysis || typeof migrated.analysis !== 'object') migrated.analysis = {}; + // System slice is not persisted (uses initialState defaults), but validate + // defensively in case stale data exists from an earlier version. + if (migrated.system && typeof migrated.system !== 'object') delete migrated.system; // Ensure critical arrays are arrays if (!Array.isArray(migrated.files.selectedFiles)) migrated.files.selectedFiles = []; diff --git a/src/renderer/store/slices/analysisSlice.js b/src/renderer/store/slices/analysisSlice.js index 51cd16fb..04bb0117 100644 --- a/src/renderer/store/slices/analysisSlice.js +++ b/src/renderer/store/slices/analysisSlice.js @@ -38,8 +38,7 @@ const initialState = { total: 0, lastActivity: 0 }, - results: [], // Analysis results - stats: null // Historical stats + results: [] // Analysis results }; const analysisSlice = createSlice({ @@ -123,9 +122,6 @@ const analysisSlice = createSlice({ state.results = action.payload; enforceResultsLimit(state.results); }, - setAnalysisStats: (state, action) => { - state.stats = action.payload; - }, updateAnalysisResult: (state, action) => { const { path, changes } = action.payload; const index = state.results.findIndex((r) => r.path === path); @@ -225,7 +221,6 @@ export const { analysisFailure, stopAnalysis, setAnalysisResults, - setAnalysisStats, updateAnalysisResult, updateEmbeddingState, resetAnalysisState, diff --git a/src/renderer/utils/requestGuard.js b/src/renderer/utils/requestGuard.js new file mode 100644 index 00000000..b9420544 --- /dev/null +++ b/src/renderer/utils/requestGuard.js @@ -0,0 +1,19 @@ +/** + * Small helpers for request-version guards. + * Use with useRef(0) counters to prevent stale async responses + * from applying state updates after newer requests have started. + */ + +export function nextRequestId(counterRef) { + counterRef.current += 1; + return counterRef.current; +} + +export function isCurrentRequest(counterRef, requestId) { + return counterRef.current === requestId; +} + +export function invalidateRequests(counterRef) { + counterRef.current += 1; + return counterRef.current; +} diff --git a/src/shared/constants.js b/src/shared/constants.js index 72bad032..9bdde92b 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -173,6 +173,28 @@ const IPC_CHANNELS = { } }; +/** + * IPC Event Channels (main -> renderer, receive-only) + * + * These are push events sent via webContents.send() from the main process. + * Renderer listens via ipcRenderer.on() (exposed through preload's safeOn). + * They do NOT use invoke/handle and are NOT part of IPC_CHANNELS above. + */ +const IPC_EVENTS = { + SYSTEM_METRICS: 'system-metrics', + OPERATION_PROGRESS: 'operation-progress', + APP_ERROR: 'app:error', + APP_UPDATE: 'app:update', + MENU_ACTION: 'menu-action', + OPEN_SEMANTIC_SEARCH: 'open-semantic-search', + SETTINGS_CHANGED_EXTERNAL: 'settings-changed-external', + FILE_OPERATION_COMPLETE: 'file-operation-complete', + NOTIFICATION: 'notification', + UNDO_REDO_STATE_CHANGED: 'undo-redo:state-changed', + BATCH_RESULTS_CHUNK: 'batch-results-chunk', + OPERATION_FAILED: 'operation-failed' +}; + /** * Action Types for Undo/Redo */ @@ -246,6 +268,8 @@ const UI_VIRTUALIZATION = { MEASUREMENT_PADDING: 16, // px PROCESSED_FILES_ITEM_HEIGHT: 64, // px TARGET_FOLDER_ITEM_HEIGHT: 56, // px + SEARCH_RESULTS_ITEM_HEIGHT: 140, // px - height for search result row in UnifiedSearchModal + SEARCH_RESULTS_ITEM_GAP: 8, // px OVERSCAN_COUNT: 5 // Number of items to render outside visible area }; @@ -548,6 +572,7 @@ const RENDERER_LIMITS = { module.exports = { IPC_CHANNELS, + IPC_EVENTS, ACTION_TYPES, INSTALL_MODEL_PROFILES, DEFAULT_AI_MODELS, diff --git a/src/shared/errorHandlingUtils.js b/src/shared/errorHandlingUtils.js index fe77c568..c2d3c328 100644 --- a/src/shared/errorHandlingUtils.js +++ b/src/shared/errorHandlingUtils.js @@ -269,11 +269,33 @@ function getErrorMessage(error, fallback = 'Unknown error') { } } +/** + * Safely attach an error code to an error object. + * If the error is not an object, wraps it in a new Error. + * Preserves any existing error code to avoid masking the root cause. + * + * @param {*} error - The error (or error-like value) to attach a code to + * @param {string} code - The error code to attach + * @returns {Error} The error object with the code attached + */ +function attachErrorCode(error, code) { + if (error && typeof error === 'object') { + if (!error.code) { + error.code = code; + } + return error; + } + const wrapped = new Error(String(error || 'Unknown error')); + wrapped.code = code; + return wrapped; +} + module.exports = { ERROR_CODES, createErrorResponse, createSuccessResponse, withRetry, logFallback, - getErrorMessage + getErrorMessage, + attachErrorCode }; diff --git a/src/shared/ipcEventSchemas.js b/src/shared/ipcEventSchemas.js index fe995e4b..dd58a3af 100644 --- a/src/shared/ipcEventSchemas.js +++ b/src/shared/ipcEventSchemas.js @@ -22,7 +22,7 @@ try { z = null; } -const { IPC_CHANNELS } = require('./constants'); +const { IPC_CHANNELS, IPC_EVENTS } = require('./constants'); // Only define schemas if Zod is available const schemas = z @@ -38,6 +38,8 @@ const schemas = z 'batch_organize', 'batch_analyze', 'model-download', + 'model-download-complete', + 'model-download-error', 'dependency', 'hint', 'analyze' @@ -289,21 +291,21 @@ const schemas = z */ const EVENT_SCHEMAS = z ? { - 'operation-progress': schemas.operationProgressSchema, + [IPC_EVENTS.OPERATION_PROGRESS]: schemas.operationProgressSchema, 'operation-complete': schemas.operationCompleteSchema, 'operation-error': schemas.operationErrorSchema, - 'file-operation-complete': schemas.fileOperationCompleteSchema, - 'system-metrics': schemas.systemMetricsSchema, - notification: schemas.notificationSchema, - 'app:error': schemas.appErrorSchema, - 'settings-changed-external': schemas.settingsChangedExternalSchema, + [IPC_EVENTS.FILE_OPERATION_COMPLETE]: schemas.fileOperationCompleteSchema, + [IPC_EVENTS.SYSTEM_METRICS]: schemas.systemMetricsSchema, + [IPC_EVENTS.NOTIFICATION]: schemas.notificationSchema, + [IPC_EVENTS.APP_ERROR]: schemas.appErrorSchema, + [IPC_EVENTS.SETTINGS_CHANGED_EXTERNAL]: schemas.settingsChangedExternalSchema, [IPC_CHANNELS.VECTOR_DB.STATUS_CHANGED]: schemas.vectorDbStatusChangedSchema, - 'menu-action': schemas.menuActionSchema, - 'app:update': schemas.appUpdateSchema, - 'open-semantic-search': schemas.openSemanticSearchSchema, - 'batch-results-chunk': schemas.batchResultsChunkSchema, - 'undo-redo:state-changed': schemas.undoRedoStateChangedSchema, - 'operation-failed': schemas.operationFailedSchema + [IPC_EVENTS.MENU_ACTION]: schemas.menuActionSchema, + [IPC_EVENTS.APP_UPDATE]: schemas.appUpdateSchema, + [IPC_EVENTS.OPEN_SEMANTIC_SEARCH]: schemas.openSemanticSearchSchema, + [IPC_EVENTS.BATCH_RESULTS_CHUNK]: schemas.batchResultsChunkSchema, + [IPC_EVENTS.UNDO_REDO_STATE_CHANGED]: schemas.undoRedoStateChangedSchema, + [IPC_EVENTS.OPERATION_FAILED]: schemas.operationFailedSchema } : {}; diff --git a/src/shared/performanceConstants.js b/src/shared/performanceConstants.js index 114c9a2e..58ba2a1f 100644 --- a/src/shared/performanceConstants.js +++ b/src/shared/performanceConstants.js @@ -76,6 +76,7 @@ const TIMEOUTS = { PROCESS_KILL_VERIFY: 500, // Delay to verify process termination IPC_HANDLER_RETRY_BASE: 100, IPC_HANDLER_MAX_WAIT: 2000, + SEARCH_INDEX_REBUILD: 5000, // Max wait for BM25 index rebuild after batch operations SEMANTIC_QUERY: 30000, FLUSH_MAX_WAIT: 30000, WINDOW_LOAD_DELAY: 100, // Delay before loading window content @@ -110,6 +111,8 @@ const RETRY = { IPC_HANDLER: { maxAttempts: 5, initialDelay: 100 }, LLAMA_API: { maxAttempts: 3, initialDelay: 1000, maxDelay: 4000 }, VECTOR_DB: { maxAttempts: 3, initialDelay: 500, maxDelay: 5000 }, + SETTINGS_BACKUP: { initialDelay: 100 }, // Start delay for exponential backoff during settings backup + SETTINGS_SAVE: { initialDelay: 200 }, // Start delay for exponential backoff during settings save DATABASE_OFFLINE_MAX: 10, ITEM_MAX_RETRIES: 3, BACKOFF_BASE_MS: 5000, @@ -315,11 +318,13 @@ const NETWORK = { const DEBOUNCE = { SETTINGS_SAVE: 1000, + SETTINGS_HYDRATION: 10000, // Max wait for settings hydration from main process PATTERN_SAVE_THROTTLE: 5000, CACHE_BATCH_WAIT: 100, CACHE_BATCH_MAX_WAIT: 5000, REFRESH_INTERVAL: 60000, ERROR_RETRY_INTERVAL: 5000, + WATCHER_RESTART_BASE: 5000, // Base delay for watcher restart exponential backoff // FIX LOW-10: Centralized learning/feedback throttling constants LEARNING_DEDUPE_WINDOW: 5000, // Prevent duplicate learning for same file within 5s FEEDBACK_MEMORY_SAVE: 5000, // Throttle feedback memory persistence diff --git a/src/shared/securityConfig.js b/src/shared/securityConfig.js index 797b0494..72b17278 100644 --- a/src/shared/securityConfig.js +++ b/src/shared/securityConfig.js @@ -327,22 +327,25 @@ const RATE_LIMITS = { }; /** - * IPC receive channels that are safe to expose to renderer - * Includes vector DB status tracking (legacy + current) + * IPC receive channels that are safe to expose to renderer. + * Uses centralized IPC_EVENTS from constants.js plus the vectordb:status-changed + * channel from IPC_CHANNELS (which doubles as both an invoke and event channel). */ +const { IPC_CHANNELS, IPC_EVENTS } = require('./constants'); + const ALLOWED_RECEIVE_CHANNELS = [ - 'system-metrics', - 'operation-progress', - 'app:error', - 'app:update', - 'menu-action', - 'open-semantic-search', - 'settings-changed-external', - 'file-operation-complete', // File move/delete notifications for search invalidation - 'vectordb:status-changed', // Reserved: defined in constants but not yet sent from main - 'notification', // Toast notifications from main process - 'undo-redo:state-changed', // FIX: Undo/redo state change notifications - 'batch-results-chunk' // FIX: Batch results streaming for progressive UI updates + IPC_EVENTS.SYSTEM_METRICS, + IPC_EVENTS.OPERATION_PROGRESS, + IPC_EVENTS.APP_ERROR, + IPC_EVENTS.APP_UPDATE, + IPC_EVENTS.MENU_ACTION, + IPC_EVENTS.OPEN_SEMANTIC_SEARCH, + IPC_EVENTS.SETTINGS_CHANGED_EXTERNAL, + IPC_EVENTS.FILE_OPERATION_COMPLETE, + IPC_CHANNELS.VECTOR_DB.STATUS_CHANGED, + IPC_EVENTS.NOTIFICATION, + IPC_EVENTS.UNDO_REDO_STATE_CHANGED, + IPC_EVENTS.BATCH_RESULTS_CHUNK ]; /** diff --git a/test/ChatService.extended.test.js b/test/ChatService.extended.test.js index 2103bad6..5d7722ad 100644 --- a/test/ChatService.extended.test.js +++ b/test/ChatService.extended.test.js @@ -51,7 +51,8 @@ function createTestService(overrides = {}) { path: '/docs/test.pdf', summary: 'A test document', type: 'document' - } + }, + matchDetails: { hybrid: { vectorRawScore: 0.85 } } } ], meta: { mode: 'hybrid' } @@ -397,7 +398,8 @@ describe('ChatService – extended coverage', () => { { id: 'file-1', score: 0.91, - metadata: { name: 'notes.pdf', path: '/docs/notes.pdf', summary: 'Summary text' } + metadata: { name: 'notes.pdf', path: '/docs/notes.pdf', summary: 'Summary text' }, + matchDetails: { hybrid: { vectorRawScore: 0.88 } } } ], meta: { mode: 'hybrid' } diff --git a/test/ChatService.test.js b/test/ChatService.test.js index 55250879..ed75380a 100644 --- a/test/ChatService.test.js +++ b/test/ChatService.test.js @@ -66,7 +66,8 @@ describe('ChatService', () => { { id: 'file-1', score: 0.9, - metadata: { name: 'Doc', path: 'C:\\doc.txt', summary: 'Snippet' } + metadata: { name: 'Doc', path: 'C:\\doc.txt', summary: 'Snippet' }, + matchDetails: { hybrid: { vectorRawScore: 0.85 } } } ], meta: { mode: 'hybrid' } diff --git a/test/ErrorHandler.extended.test.js b/test/ErrorHandler.extended.test.js index ad39c3c7..155eac22 100644 --- a/test/ErrorHandler.extended.test.js +++ b/test/ErrorHandler.extended.test.js @@ -100,6 +100,20 @@ jest.mock('../src/shared/constants', () => ({ }, IPC_CHANNELS: { VECTOR_DB: { STATUS_CHANGED: 'vectordb:status-changed' } + }, + IPC_EVENTS: { + SYSTEM_METRICS: 'system-metrics', + OPERATION_PROGRESS: 'operation-progress', + APP_ERROR: 'app:error', + APP_UPDATE: 'app:update', + MENU_ACTION: 'menu-action', + OPEN_SEMANTIC_SEARCH: 'open-semantic-search', + SETTINGS_CHANGED_EXTERNAL: 'settings-changed-external', + FILE_OPERATION_COMPLETE: 'file-operation-complete', + NOTIFICATION: 'notification', + UNDO_REDO_STATE_CHANGED: 'undo-redo:state-changed', + BATCH_RESULTS_CHUNK: 'batch-results-chunk', + OPERATION_FAILED: 'operation-failed' } })); diff --git a/test/ErrorHandler.test.js b/test/ErrorHandler.test.js index f3095f0d..d917fca7 100644 --- a/test/ErrorHandler.test.js +++ b/test/ErrorHandler.test.js @@ -74,6 +74,20 @@ jest.mock('../src/shared/constants', () => ({ VECTOR_DB: { STATUS_CHANGED: 'vectordb:status-changed' } + }, + IPC_EVENTS: { + SYSTEM_METRICS: 'system-metrics', + OPERATION_PROGRESS: 'operation-progress', + APP_ERROR: 'app:error', + APP_UPDATE: 'app:update', + MENU_ACTION: 'menu-action', + OPEN_SEMANTIC_SEARCH: 'open-semantic-search', + SETTINGS_CHANGED_EXTERNAL: 'settings-changed-external', + FILE_OPERATION_COMPLETE: 'file-operation-complete', + NOTIFICATION: 'notification', + UNDO_REDO_STATE_CHANGED: 'undo-redo:state-changed', + BATCH_RESULTS_CHUNK: 'batch-results-chunk', + OPERATION_FAILED: 'operation-failed' } })); diff --git a/test/OramaVectorService.lruCache.test.js b/test/OramaVectorService.lruCache.test.js index b39b51f9..8a2a7612 100644 --- a/test/OramaVectorService.lruCache.test.js +++ b/test/OramaVectorService.lruCache.test.js @@ -1,5 +1,5 @@ /** - * Tests for OramaVectorService query cache true-LRU fix. + * Tests for OramaVectorService query cache (backed by shared LRUCache). * Verifies that cache hits promote entries (true LRU), * so frequently-accessed items survive eviction. */ @@ -38,6 +38,7 @@ jest.mock('../src/main/llamaUtils', () => ({ loadLlamaConfig: jest.fn().mockResolvedValue({ selectedEmbeddingModel: 'test-model.gguf' }) })); +const { LRUCache } = require('../src/shared/LRUCache'); const { OramaVectorService } = require('../src/main/services/OramaVectorService'); describe('OramaVectorService – query cache LRU', () => { @@ -45,54 +46,63 @@ describe('OramaVectorService – query cache LRU', () => { beforeEach(() => { service = new OramaVectorService({ dataPath: '/mock/orama-test' }); - service._queryCacheMaxSize = 3; - service._queryCacheTtlMs = 60000; + // Replace the default cache with a small one for testing eviction + service._queryCache = new LRUCache({ + maxSize: 3, + ttlMs: 60000, + lruStrategy: 'access', + name: 'OramaQueryCache-test' + }); }); test('cache hit promotes entry to end of eviction order', () => { + const cache = service._queryCache; + // Fill cache: A, B, C (A is oldest in insertion order) - service._setCachedQuery('A', { data: 'a' }); - service._setCachedQuery('B', { data: 'b' }); - service._setCachedQuery('C', { data: 'c' }); + cache.set('A', { data: 'a' }); + cache.set('B', { data: 'b' }); + cache.set('C', { data: 'c' }); // Access A – should promote it to the end - const hitA = service._getCachedQuery('A'); + const hitA = cache.get('A'); expect(hitA).toEqual({ data: 'a' }); // Insert D – should evict B (now the oldest), not A - service._setCachedQuery('D', { data: 'd' }); + cache.set('D', { data: 'd' }); - expect(service._getCachedQuery('B')).toBeNull(); // evicted - expect(service._getCachedQuery('A')).toEqual({ data: 'a' }); // still present - expect(service._getCachedQuery('C')).not.toBeNull(); - expect(service._getCachedQuery('D')).toEqual({ data: 'd' }); + expect(cache.get('B')).toBeNull(); // evicted + expect(cache.get('A')).toEqual({ data: 'a' }); // still present + expect(cache.get('C')).not.toBeNull(); + expect(cache.get('D')).toEqual({ data: 'd' }); }); test('expired entries are evicted on access', () => { - service._setCachedQuery('old', { data: 'stale' }); + const cache = service._queryCache; + cache.set('old', { data: 'stale' }); // Manually backdate the timestamp to simulate expiry - const entry = service._queryCache.get('old'); - entry.timestamp = Date.now() - service._queryCacheTtlMs - 1; + const entry = cache.cache.get('old'); + entry.timestamp = Date.now() - cache.ttlMs - 1; - expect(service._getCachedQuery('old')).toBeNull(); - expect(service._queryCache.has('old')).toBe(false); + expect(cache.get('old')).toBeNull(); + expect(cache.has('old')).toBe(false); }); test('FIFO eviction when no hits have occurred', () => { - service._setCachedQuery('X', { data: 'x' }); - service._setCachedQuery('Y', { data: 'y' }); - service._setCachedQuery('Z', { data: 'z' }); + const cache = service._queryCache; + cache.set('X', { data: 'x' }); + cache.set('Y', { data: 'y' }); + cache.set('Z', { data: 'z' }); // No accesses – X is oldest - service._setCachedQuery('W', { data: 'w' }); + cache.set('W', { data: 'w' }); - expect(service._getCachedQuery('X')).toBeNull(); // evicted (oldest) - expect(service._getCachedQuery('Y')).not.toBeNull(); + expect(cache.get('X')).toBeNull(); // evicted (oldest) + expect(cache.get('Y')).not.toBeNull(); }); test('clearQueryCache empties everything', () => { - service._setCachedQuery('K', { data: 'k' }); + service._queryCache.set('K', { data: 'k' }); service.clearQueryCache(); expect(service._queryCache.size).toBe(0); }); diff --git a/test/SearchService.test.js b/test/SearchService.test.js index 3e79fcf3..d530fab8 100644 --- a/test/SearchService.test.js +++ b/test/SearchService.test.js @@ -61,7 +61,7 @@ describe('SearchService', () => { expect(scoreMap.b.originalScore).toBe(0.75); }); - test('returns score 1.0 when range is zero', () => { + test('preserves actual score when range is zero (no inflation)', () => { const service = createService(); const results = [ { id: 'a', score: 0.5 }, @@ -70,10 +70,27 @@ describe('SearchService', () => { const normalized = service._normalizeScores(results); normalized.forEach((r) => { - expect(r.score).toBe(1.0); + // When all scores are identical, the normalized score should equal + // the clamped original — NOT blindly 1.0. + expect(r.score).toBe(0.5); expect(r.originalScore).toBe(0.5); }); }); + + test('clamps equal-score results to 1.0 when raw scores exceed 1', () => { + const service = createService(); + // BM25 scores can be > 1; clamping should cap at 1.0 + const results = [ + { id: 'a', score: 3.5 }, + { id: 'b', score: 3.5 } + ]; + + const normalized = service._normalizeScores(results); + normalized.forEach((r) => { + expect(r.score).toBe(1.0); + expect(r.originalScore).toBe(3.5); + }); + }); }); describe('reciprocalRankFusion', () => { diff --git a/test/SemanticRenameService.test.js b/test/SemanticRenameService.test.js deleted file mode 100644 index 5406a4a8..00000000 --- a/test/SemanticRenameService.test.js +++ /dev/null @@ -1,80 +0,0 @@ -const path = require('path'); -const { SemanticRenameService } = require('../src/main/services/SemanticRenameService'); - -// Mock logger to prevent console noise during tests -jest.mock('../src/shared/logger', () => { - const logger = { - error: jest.fn(), - info: jest.fn(), - warn: jest.fn() - }; - return { logger, createLogger: jest.fn(() => logger) }; -}); - -describe('SemanticRenameService', () => { - let service; - - beforeEach(() => { - service = new SemanticRenameService(); - service.resetCache(); - }); - - const mockAnalysis = { - date: '2023-10-15', - entity: 'Amazon', - type: 'Invoice', - project: 'Office Supplies', - category: 'Expenses', - summary: 'Invoice for monitor', - keywords: ['monitor', 'screen'], - confidence: 95 - }; - - test('should generate name based on simple template', () => { - const template = '{date}_{entity}_{type}'; - const filePath = '/docs/scan001.pdf'; - - const result = service.generateNewName(filePath, mockAnalysis, template); - // Path separator handling for cross-platform test compatibility - const expectedEnd = `2023-10-15_Amazon_Invoice.pdf`; - expect(result.endsWith(expectedEnd)).toBe(true); - }); - - test('should handle missing fields gracefully', () => { - const template = '{date}_{entity}_{type}'; - const filePath = '/docs/scan001.pdf'; - const incompleteAnalysis = { ...mockAnalysis, entity: null, type: null }; - - const result = service.generateNewName(filePath, incompleteAnalysis, template); - // Default fallback values from namingUtils (formatDate(today) for date, 'Unknown' for entity) - expect(result).toContain('Unknown'); - expect(result).toContain('Document'); // Default type - }); - - test('should handle collisions within the same batch', () => { - const template = '{entity}_{type}'; - const filePath1 = '/docs/file1.pdf'; - const filePath2 = '/docs/file2.pdf'; - - const result1 = service.generateNewName(filePath1, mockAnalysis, template); - const result2 = service.generateNewName(filePath2, mockAnalysis, template); - - expect(result1).not.toEqual(result2); - // Expect numeric suffix for the second one - expect(result2).toMatch(/-2\.pdf$/); - }); - - test('should sanitize illegal characters', () => { - const template = '{entity}_{type}'; - const filePath = '/docs/file1.pdf'; - const dirtyAnalysis = { ...mockAnalysis, entity: 'Acme/Corp:Inc' }; - - const result = service.generateNewName(filePath, dirtyAnalysis, template); - expect(result).not.toContain('/'); - expect(result).not.toContain(':'); - // Spaces are collapsed in processTemplate: "Acme/Corp:Inc" -> "Acme Corp Inc" -> "Acme_Corp_Inc" - // or just removed if they are delimiters. - // The implementation removes illegal chars: "Acme/Corp:Inc" -> "AcmeCorpInc" - expect(result).toContain('AcmeCorpInc'); - }); -}); diff --git a/test/SettingsService.test.js b/test/SettingsService.test.js index 227b7cae..b669beab 100644 --- a/test/SettingsService.test.js +++ b/test/SettingsService.test.js @@ -65,14 +65,17 @@ jest.mock('../src/shared/performanceConstants', () => ({ WATCHER_RESTART_WINDOW: 10_000 }, DEBOUNCE: { - SETTINGS_SAVE: 5 + SETTINGS_SAVE: 5, + WATCHER_RESTART_BASE: 100 }, TIMEOUTS: { SERVICE_STARTUP: 250 }, RETRY: { MAX_ATTEMPTS_MEDIUM: 3, - MAX_ATTEMPTS_HIGH: 5 + MAX_ATTEMPTS_HIGH: 5, + SETTINGS_BACKUP: { initialDelay: 10 }, + SETTINGS_SAVE: { initialDelay: 10 } } })); diff --git a/test/analysisSlice.test.js b/test/analysisSlice.test.js index 5d6cf17e..b8b54f95 100644 --- a/test/analysisSlice.test.js +++ b/test/analysisSlice.test.js @@ -20,7 +20,6 @@ import analysisReducer, { analysisFailure, stopAnalysis, setAnalysisResults, - setAnalysisStats, resetAnalysisState } from '../src/renderer/store/slices/analysisSlice'; @@ -33,8 +32,7 @@ describe('analysisSlice', () => { total: 0, lastActivity: 0 }, - results: [], - stats: null + results: [] }; describe('initial state', () => { @@ -43,7 +41,6 @@ describe('analysisSlice', () => { expect(result.isAnalyzing).toBe(false); expect(result.results).toEqual([]); - expect(result.stats).toBeNull(); }); }); @@ -228,28 +225,13 @@ describe('analysisSlice', () => { }); }); - describe('setAnalysisStats', () => { - test('sets analysis stats', () => { - const stats = { - totalAnalyses: 100, - successRate: 0.95, - averageConfidence: 0.85 - }; - - const result = analysisReducer(initialState, setAnalysisStats(stats)); - - expect(result.stats).toEqual(stats); - }); - }); - describe('resetAnalysisState', () => { test('resets to initial state', () => { const modifiedState = { isAnalyzing: true, currentAnalysisFile: '/file.pdf', analysisProgress: { current: 5, total: 10, lastActivity: Date.now() }, - results: [{ path: '/file.pdf' }], - stats: { total: 100 } + results: [{ path: '/file.pdf' }] }; const result = analysisReducer(modifiedState, resetAnalysisState()); @@ -257,7 +239,6 @@ describe('analysisSlice', () => { expect(result.isAnalyzing).toBe(false); expect(result.currentAnalysisFile).toBe(''); expect(result.results).toEqual([]); - expect(result.stats).toBeNull(); }); }); }); diff --git a/test/fileSelectionHandlers.test.js b/test/fileSelectionHandlers.test.js index 15090539..766e5700 100644 --- a/test/fileSelectionHandlers.test.js +++ b/test/fileSelectionHandlers.test.js @@ -35,6 +35,23 @@ jest.mock('../src/shared/constants', () => ({ TEXT_ANALYSIS: 'qwen3:0.6b', IMAGE_ANALYSIS: 'gemma3:latest', EMBEDDING: 'embeddinggemma' + }, + IPC_CHANNELS: { + VECTOR_DB: { STATUS_CHANGED: 'vectordb:status-changed' } + }, + IPC_EVENTS: { + SYSTEM_METRICS: 'system-metrics', + OPERATION_PROGRESS: 'operation-progress', + APP_ERROR: 'app:error', + APP_UPDATE: 'app:update', + MENU_ACTION: 'menu-action', + OPEN_SEMANTIC_SEARCH: 'open-semantic-search', + SETTINGS_CHANGED_EXTERNAL: 'settings-changed-external', + FILE_OPERATION_COMPLETE: 'file-operation-complete', + NOTIFICATION: 'notification', + UNDO_REDO_STATE_CHANGED: 'undo-redo:state-changed', + BATCH_RESULTS_CHUNK: 'batch-results-chunk', + OPERATION_FAILED: 'operation-failed' } })); diff --git a/test/llamaIpc.handlers.test.js b/test/llamaIpc.handlers.test.js index de9f41c5..41521e96 100644 --- a/test/llamaIpc.handlers.test.js +++ b/test/llamaIpc.handlers.test.js @@ -61,7 +61,17 @@ describe('llama IPC – extended handlers', () => { registerLlamaIpc({ ipcMain: {}, - IPC_CHANNELS: { LLAMA: {} }, + IPC_CHANNELS: { + LLAMA: { + GET_MODELS: 'llama:get-models', + GET_CONFIG: 'llama:get-config', + UPDATE_CONFIG: 'llama:update-config', + TEST_CONNECTION: 'llama:test-connection', + DOWNLOAD_MODEL: 'llama:download-model', + DELETE_MODEL: 'llama:delete-model', + GET_DOWNLOAD_STATUS: 'llama:get-download-status' + } + }, logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, systemAnalytics: {}, getMainWindow: () => null @@ -156,25 +166,33 @@ describe('llama IPC – extended handlers', () => { expect(backslashResult.error).toContain('Invalid'); }); - test('downloads model successfully', async () => { + test('starts model download and returns immediately', async () => { const handler = getHandler('download-model'); const result = await handler({}, 'llama-3.gguf'); expect(result.success).toBe(true); + expect(result.started).toBe(true); expect(mockDownloadManager.downloadModel).toHaveBeenCalledWith( 'llama-3.gguf', expect.objectContaining({ onProgress: expect.any(Function) }) ); }); - test('returns error on download failure', async () => { + test('returns success immediately even when download will fail (non-blocking)', async () => { + // The download handler is non-blocking: it starts the download in the + // background and returns { success: true, started: true } immediately. + // Errors are reported via IPC OPERATION_PROGRESS events, not the return value. const handler = getHandler('download-model'); mockDownloadManager.downloadModel.mockRejectedValueOnce(new Error('network error')); const result = await handler({}, 'model.gguf'); - expect(result.success).toBe(false); - expect(result.error).toBe('network error'); + expect(result.success).toBe(true); + expect(result.started).toBe(true); + expect(mockDownloadManager.downloadModel).toHaveBeenCalledWith( + 'model.gguf', + expect.objectContaining({ onProgress: expect.any(Function) }) + ); }); });