From f786de9afad1554079faf1fd75301a15c4ccfa5a Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:53:20 -0500 Subject: [PATCH] fix: duplicate analysis runs, model swapping, stale cloud overrides, OCR-fallback caching, folder cards, icons Found while watching a live run on an RTX 4050 (6 GB): - Discover: a run whose batch request is refused because this window's earlier batch is still running (Discover re-mounted mid-batch) now waits for it and reruns from the cache, instead of starting the per-file pool in parallel. That fallback analyzed the same files two or three times at once, showed duplicate notifications, kept the progress count low, restarted in a loop, and left no memory for the vision model. - Smart-folder watcher: files a user-started analysis is working on are deferred and re-checked afterwards (userAnalysisInFlight registry, marked by the batch and per-file analysis handlers). - Memory: on a GPU that cannot hold both, the embedding model loads on the CPU beside the resident text model (keepBeside) instead of evicting it and reloading the 7B text model for every file (~4-5 s per file). - Keyframe consent: overrides of deleted smart folders no longer count as a cloud Analysis route. - Image analysis: a text result that stands in for a failed vision step is not cached, so the next analysis tries vision again. - Target Smart Folders: long paths wrap inside the card; quoted descriptions are not shown in doubled quotes. - The sparkle icon is replaced everywhere with icons for what each place does. --- CHANGELOG.md | 21 +++ scripts/verify-checkjs-ratchet.js | 2 +- src/main/analysis/imageAnalysis.js | 14 +- src/main/analysis/userAnalysisInFlight.js | 96 ++++++++++++ src/main/ipc/analysis.js | 17 ++- src/main/services/LlamaService.js | 30 +++- src/main/services/ModelMemoryManager.js | 8 +- src/main/services/SmartFolderWatcher.js | 21 ++- src/main/services/liveLogActivity.js | 2 +- src/renderer/components/AnalysisDetails.jsx | 4 +- .../components/dashboard/DashboardView.jsx | 13 +- .../diagnostics/LiveActivityPanel.jsx | 6 +- .../components/organize/TargetFolderList.jsx | 25 +++- .../organize/UncategorizedActionsBar.jsx | 6 +- .../components/search/ClusterNode.jsx | 6 +- .../search/EmptyEmbeddingsBanner.jsx | 4 +- .../components/search/GraphTabContent.jsx | 12 +- .../search/RelatedDocumentsPanel.jsx | 4 +- .../components/search/SearchTabContent.jsx | 4 +- .../settings/ModelManagementSection.jsx | 6 +- .../settings/ProcessingLimitsSection.jsx | 23 ++- .../components/setup/AddSmartFolderModal.jsx | 8 +- .../components/setup/SmartFolderItem.jsx | 7 +- .../components/triage/DownloadsTriageView.jsx | 6 +- src/renderer/phases/DiscoverPhase.jsx | 6 +- src/renderer/phases/OrganizePhase.jsx | 6 +- src/renderer/phases/WelcomePhase.jsx | 6 +- src/renderer/phases/discover/batchBusy.js | 92 ++++++++++++ src/renderer/phases/discover/useAnalysis.js | 20 ++- ...artFolderWatcher.userAnalysisDefer.test.js | 137 ++++++++++++++++++ .../BetaWorkflowInteraction.test.js | 14 +- test/components/DiscoverPhase.test.js | 16 +- .../components/ModelManagementSection.test.js | 16 +- test/components/OrganizePhase.test.js | 16 +- ...ocessingLimitsSection.frameConsent.test.js | 74 +++++++++- test/components/RelatedDocumentsPanel.test.js | 2 +- test/components/WelcomePhase.test.js | 2 +- test/components/unifiedSearchModal.harness.js | 13 +- test/discoverBatchBusy.test.js | 116 +++++++++++++++ test/embeddingBesideText.test.js | 109 ++++++++++++++ test/imageAnalysis.behavior.test.js | 3 + test/main/services/liveLogActivity.test.js | 6 +- 42 files changed, 912 insertions(+), 87 deletions(-) create mode 100644 src/main/analysis/userAnalysisInFlight.js create mode 100644 src/renderer/phases/discover/batchBusy.js create mode 100644 test/SmartFolderWatcher.userAnalysisDefer.test.js create mode 100644 test/discoverBatchBusy.test.js create mode 100644 test/embeddingBesideText.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c15498..113c509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,8 @@ Changes you will notice: ### Changed +- The star/sparkle icon is gone from every screen; each place now uses an icon for what it does + (analysis, suggestion, description, embeddings, recommended model and so on). - **StratoSort no longer registers itself as a viewer for PDF, Word, Excel, PowerPoint, text and Markdown files.** Opening a file that way did nothing, so the entry in "Open with" is gone. Existing installs drop it on the next install or update. @@ -201,6 +203,25 @@ Changes you will notice: ### Fixed +- **Files are no longer analyzed two or three times at once.** Leaving Discover and coming back + while a batch ran started a new run that the window's still-running batch refused; the new run + then analyzed the same files again file by file, in parallel, and restarted itself when it ended. + That doubled GPU work, showed each "analyzed" notification more than once, kept the progress count + low, and could leave no room for the vision model, so an image fell back to OCR. A new run now + waits for the earlier batch and reuses its cached results. +- **An image whose vision step failed is no longer cached with its OCR-only result.** The next + analysis of that image (within the 30-minute cache window) tries the vision model again. +- **The smart-folder watcher waits for files you are analyzing in Discover** instead of analyzing + them at the same time, then skips them once the analysis history has them. +- **On a GPU too small for both models, the text model stays loaded between files.** Loading the + ~200 MB embedding model used to evict a 7B text model on a 6 GB card and reload it for the next + file, about 4-5 s per file. The embedding model now runs on the CPU beside it. +- **Turning on "Describe video keyframes" no longer asks for cloud consent because of a deleted + smart folder.** A deleted folder's AI provider override stayed in settings and counted as a cloud + route; only overrides of folders that still exist count now. +- **Target Smart Folders** wraps long folder paths inside each card instead of running past it, and + a description the user typed in quotes is no longer shown in doubled quotes. + - **An index file too large to load can no longer be overwritten by an automatic repair.** It stays protected until you reset or rebuild that index. A session without the compression library no longer saves over the compressed index or its vectors either. diff --git a/scripts/verify-checkjs-ratchet.js b/scripts/verify-checkjs-ratchet.js index fdadd24..e36c531 100644 --- a/scripts/verify-checkjs-ratchet.js +++ b/scripts/verify-checkjs-ratchet.js @@ -43,7 +43,7 @@ const COVERED_DIRS = [ 'src/renderer/utils', 'src/renderer/components' ]; -const GLOBAL_DIRECTIVE_FLOOR = 489; +const GLOBAL_DIRECTIVE_FLOOR = 491; // ──────────────────────────────────────────────────────────────────────── const DIRECTIVE_SCAN_LINES = 5; diff --git a/src/main/analysis/imageAnalysis.js b/src/main/analysis/imageAnalysis.js index 06e6298..e5e7778 100644 --- a/src/main/analysis/imageAnalysis.js +++ b/src/main/analysis/imageAnalysis.js @@ -1770,6 +1770,9 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) { const fallbackAnalysis = await tryTextFallback(); if (fallbackAnalysis) { fallbackAnalysis.analysisWarning = analysis.error; + // Usable, but only what OCR text could tell: never serve it from the + // cache, so the next analysis of this image tries the vision model again. + fallbackAnalysis._skipCache = true; analysis = fallbackAnalysis; } else { analysis = createVisionUnavailableFallback( @@ -2143,7 +2146,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) { if (extractedTextForStorage) { result.extractedText = extractedTextForStorage; } - if (!bypassCache && !result?._isFallback && !result?.isFallback) { + if (!bypassCache && !result?._isFallback && !result?.isFallback && !result?._skipCache) { try { setImageCache(signature, result); } catch (cacheError) { @@ -2223,7 +2226,12 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) { extractionMethod: 'vision' }); - if (!bypassCache && !normalized?._isFallback && !normalized?.isFallback) { + if ( + !bypassCache && + !normalized?._isFallback && + !normalized?.isFallback && + !normalized?._skipCache + ) { try { setImageCache(signature, normalized); } catch (cacheError) { @@ -2258,7 +2266,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) { if (extractedTextForStorage) { result.extractedText = extractedTextForStorage; } - if (!bypassCache && !result?._isFallback && !result?.isFallback) { + if (!bypassCache && !result?._isFallback && !result?.isFallback && !result?._skipCache) { try { setImageCache(signature, result); } catch (cacheError) { diff --git a/src/main/analysis/userAnalysisInFlight.js b/src/main/analysis/userAnalysisInFlight.js new file mode 100644 index 0000000..8f52138 --- /dev/null +++ b/src/main/analysis/userAnalysisInFlight.js @@ -0,0 +1,96 @@ +// @ts-check +/** + * Files a user-started analysis (Discover's batch or per-file requests) is + * working on right now. + * + * The smart-folder watcher analyzes files it finds in smart folders on its + * own. When the user analyzes the same files from Discover at the same time, + * both ran a full analysis: twice the GPU work, and an "analyzed" notification + * from each. The watcher now defers a file listed here and checks it again + * afterwards, when the analysis history already has it. + * + * Counts, not a set: two overlapping requests for one file must both finish + * before the file is free again. + * + * @module analysis/userAnalysisInFlight + */ + +const { normalizePathForIndex } = require('../../shared/pathSanitization'); + +/** @type {Map} */ +const counts = new Map(); + +/** + * The registry only steers the watcher; it must never break an analysis, so + * an unusable normalizer falls back to the path as given. + * @param {string} filePath + */ +function keyOf(filePath) { + try { + return ( + (typeof normalizePathForIndex === 'function' && normalizePathForIndex(filePath)) || filePath + ); + } catch { + return filePath; + } +} + +/** + * Mark files as being analyzed by the user. Returns the release function; + * calling it more than once is harmless. + * @param {string | string[]} filePaths + * @returns {() => void} + */ +function claimUserAnalysis(filePaths) { + const keys = (Array.isArray(filePaths) ? filePaths : [filePaths]) + .filter((p) => typeof p === 'string' && p) + .map(keyOf); + for (const key of keys) counts.set(key, (counts.get(key) || 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + for (const key of keys) { + const next = (counts.get(key) || 0) - 1; + if (next > 0) counts.set(key, next); + else counts.delete(key); + } + }; +} + +/** + * @param {string} filePath + * @returns {boolean} + */ +function isUserAnalysisInFlight(filePath) { + if (typeof filePath !== 'string' || !filePath) return false; + return counts.has(keyOf(filePath)); +} + +/** + * Wrap one analysis call so its file is marked for the call's duration. + * @template T + * @param {string} filePath + * @param {() => Promise} run + * @returns {Promise} + */ +async function withUserAnalysis(filePath, run) { + const release = claimUserAnalysis(filePath); + try { + return await run(); + } finally { + release(); + } +} + +/** Test seam. */ +function _resetUserAnalysisInFlight() { + counts.clear(); +} + +module.exports = { + claimUserAnalysis, + isUserAnalysisInFlight, + withUserAnalysis, + _resetUserAnalysisInFlight +}; diff --git a/src/main/ipc/analysis.js b/src/main/ipc/analysis.js index 2d89c4a..0b409a2 100644 --- a/src/main/ipc/analysis.js +++ b/src/main/ipc/analysis.js @@ -21,6 +21,7 @@ const { getFolderCategories } = require('./analysisUtils'); const BatchAnalysisService = require('../services/BatchAnalysisService'); +const { claimUserAnalysis, withUserAnalysis } = require('../analysis/userAnalysisInFlight'); const { sendOperationProgress, sendFileComplete } = require('./files/batchProgressReporter'); const { createMediaAnalyzerAdapter } = require('../services/mediaAnalyzerAdapter'); const { getInstance: getWhisperService } = require('../services/WhisperService'); @@ -554,7 +555,9 @@ function registerAnalysisIpc(servicesOrParams) { schema: analyzeFilePayloadSchema, handler: (event, payload) => { const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload); - return performDocumentAnalysis(filePath, event?.abortSignal, namingOverride); + return withUserAnalysis(filePath, () => + performDocumentAnalysis(filePath, event?.abortSignal, namingOverride) + ); } }); @@ -674,7 +677,9 @@ function registerAnalysisIpc(servicesOrParams) { schema: analyzeFilePayloadSchema, handler: (event, payload) => { const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload); - return performImageAnalysis(filePath, event?.abortSignal, namingOverride); + return withUserAnalysis(filePath, () => + performImageAnalysis(filePath, event?.abortSignal, namingOverride) + ); } }); @@ -800,7 +805,9 @@ function registerAnalysisIpc(servicesOrParams) { schema: analyzeFilePayloadSchema, handler: (event, payload) => { const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload); - return performMediaAnalysis(filePath, event?.abortSignal, namingOverride); + return withUserAnalysis(filePath, () => + performMediaAnalysis(filePath, event?.abortSignal, namingOverride) + ); } }); @@ -982,6 +989,9 @@ function registerAnalysisIpc(servicesOrParams) { } } + // The smart-folder watcher defers these files until the batch is done, + // so a file it also sees is not analyzed twice at once. + const releaseUserAnalysis = claimUserAnalysis(validatedPaths); try { // BatchAnalysisService.analyzeFiles' JSDoc enumerates only a subset of // the options it consumes and elides successful/cancelled from its @@ -1220,6 +1230,7 @@ function registerAnalysisIpc(servicesOrParams) { batchId }; } finally { + releaseUserAnalysis(); removeAbortListener?.(); activeBatchRuns.delete(batchId); } diff --git a/src/main/services/LlamaService.js b/src/main/services/LlamaService.js index 706396d..2ecb09b 100644 --- a/src/main/services/LlamaService.js +++ b/src/main/services/LlamaService.js @@ -1754,7 +1754,8 @@ class LlamaService extends EventEmitter { } } - const loadOptions = this._loadOptionsBesideVisionBatch(modelType); + const loadOptions = + this._loadOptionsBesideVisionBatch(modelType) || this._loadOptionsBesideText(modelType); // A text request that reaches here during a vision batch is either the // batch's own work or one that already waited out the batch once. Let // the memory manager evict the runtime between images if the text model @@ -1766,7 +1767,9 @@ class LlamaService extends EventEmitter { // Set after the load, too: a batch that ended while this load was in // flight could not unload a model that was not registered yet, so the // next embedding load must still move it back to the GPU. - if (loadOptions?.cpuByDesign) this._embeddingCpuForBatch = true; + if (loadOptions?.cpuByDesign && loadOptions.besideResident === VISION_RUNTIME_MEMORY_TYPE) { + this._embeddingCpuForBatch = true; + } return context; } finally { restorePin?.(); @@ -4234,6 +4237,29 @@ class LlamaService extends EventEmitter { } } + /** + * Load options for the embedding model while the text model holds a GPU + * too small for both (a 7B text model on a 6 GB card). Without them every + * file evicted the text model to load the ~200 MB embedding model and then + * reloaded the text model, about 4-5 s per file. The embedding model runs + * on the CPU beside it instead, without the "running on CPU" warning; it + * moves to the GPU again the next time it loads with room to spare. + * @private + * @param {string} modelType + * @returns {Record | undefined} + */ + _loadOptionsBesideText(modelType) { + if (modelType !== 'embedding') return undefined; + if (!this._gpuBackend || this._gpuBackend === 'cpu') return undefined; + const memory = this._modelMemoryManager; + if (!memory?.getLoadedContext?.('text') || memory.getLoadedContext?.('embedding')) { + return undefined; + } + // Both fit on the GPU: load the embedding model there as usual. + if (memory.canLoadModel?.('embedding') !== false) return undefined; + return { gpuLayersOverride: 0, besideResident: 'text', keepBeside: true, cpuByDesign: true }; + } + /** * Load options for a model while a vision batch holds the runtime: the * embedding model runs on the CPU beside it instead of evicting it. diff --git a/src/main/services/ModelMemoryManager.js b/src/main/services/ModelMemoryManager.js index 3a328af..0705b49 100644 --- a/src/main/services/ModelMemoryManager.js +++ b/src/main/services/ModelMemoryManager.js @@ -324,7 +324,13 @@ class ModelMemoryManager { typeof besideType === 'string' && /** @type {any} */ (options)?.gpuLayersOverride === 0 ? this._loadedModels.get(besideType) : null; - const besidePinned = Boolean(besideEntry && (this._activeRefs.get(besideType) || 0) > 0); + // keepBeside: the caller chose this CPU load so an idle resident (the + // text model between files) stays; without it only a pinned one does. + const besidePinned = Boolean( + besideEntry && + ((this._activeRefs.get(besideType) || 0) > 0 || + /** @type {any} */ (options)?.keepBeside === true) + ); const fits = () => besidePinned ? this._currentMemoryUsage - (besideEntry?.sizeBytes || 0) + estimatedSize < diff --git a/src/main/services/SmartFolderWatcher.js b/src/main/services/SmartFolderWatcher.js index be21c20..88c0119 100644 --- a/src/main/services/SmartFolderWatcher.js +++ b/src/main/services/SmartFolderWatcher.js @@ -60,6 +60,11 @@ const { resolveRealPaths } = require('../../shared/pathSanitization'); const { isImagePath, isMediaPath } = require('../../shared/fileIdUtils'); +const { isUserAnalysisInFlight } = require('../analysis/userAnalysisInFlight'); + +// How long a file Discover is analyzing right now waits before the watcher +// checks it again. +const USER_ANALYSIS_DEFER_MS = 15000; const { isUNCPath } = require('../../shared/crossPlatformUtils'); const { normalizeKeywords } = require('../../shared/normalization'); const { @@ -2600,6 +2605,20 @@ class SmartFolderWatcher { return; } + // The user is analyzing this same file from Discover right now. Running + // our own analysis beside it doubled the GPU work and the "analyzed" + // notification. Check again after it finishes; by then the history + // normally has the file and the re-check below skips it. + if (!isReanalyze && isUserAnalysisInFlight(filePath)) { + logger.debug('[SMART-FOLDER-WATCHER] Deferring a file Discover is analyzing', { filePath }); + this._enqueueAnalysisItem({ + ...item, + recheck: true, + notBefore: Date.now() + USER_ANALYSIS_DEFER_MS + }); + return; + } + this.processingFiles.add(processingKey); try { @@ -2625,7 +2644,7 @@ class SmartFolderWatcher { // spending a full LLM pass. Done lazily here, not at restore time, so a // 50k-item backlog doesn't stat and hash every file up front. Explicit // reanalyze requests intentionally bypass the gate. - if (item.restored && !isReanalyze) { + if ((item.restored || item.recheck) && !isReanalyze) { const stillNeedsAnalysis = await this._needsAnalysis(filePath, item.mtime, fileStats); if (!stillNeedsAnalysis) { logger.debug( diff --git a/src/main/services/liveLogActivity.js b/src/main/services/liveLogActivity.js index 04e2952..78940a2 100644 --- a/src/main/services/liveLogActivity.js +++ b/src/main/services/liveLogActivity.js @@ -433,7 +433,7 @@ const folderSuggestion = (input) => { const file = baseName(input.data?.fileName); return activity( 'folder-suggestion', - 'sparkles', + 'lightbulb', 'info', file ? `Suggested a new folder "${folder}" for ${file}` : `Suggested a new folder "${folder}"`, file diff --git a/src/renderer/components/AnalysisDetails.jsx b/src/renderer/components/AnalysisDetails.jsx index 597611f..b6ffeb5 100644 --- a/src/renderer/components/AnalysisDetails.jsx +++ b/src/renderer/components/AnalysisDetails.jsx @@ -1,6 +1,6 @@ import React, { memo, useState, useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { FileText, Info, Sparkles, Lightbulb, Tag, AlignLeft, Film } from 'lucide-react'; +import { FileText, Info, Lightbulb, Tag, AlignLeft, Film, ScrollText } from 'lucide-react'; import { Text, Code, Caption } from './ui/Typography'; import { Stack } from './layout'; import StatusBadge from './ui/StatusBadge'; @@ -379,7 +379,7 @@ const AnalysisDetails = memo(function AnalysisDetails({ {analysis.summary && ( } + title={} defaultOpen className="rounded-xl border border-border-soft/70 bg-gradient-to-br from-system-gray-50/80 to-system-gray-100" collapsedPreview={buildCollapsedPreview(analysis.summary, { maxLength: 220 })} diff --git a/src/renderer/components/dashboard/DashboardView.jsx b/src/renderer/components/dashboard/DashboardView.jsx index 32dfa68..fda83e1 100644 --- a/src/renderer/components/dashboard/DashboardView.jsx +++ b/src/renderer/components/dashboard/DashboardView.jsx @@ -6,11 +6,12 @@ import { FilePlus, FolderPlus, FolderTree, - Sparkles, ArrowRight, FolderInput, CheckCircle2, - AlertTriangle + AlertTriangle, + ScanSearch, + ListChecks } from 'lucide-react'; import { useAppSelector } from '../../store/hooks'; import { @@ -339,7 +340,7 @@ function PipelineCard({
{queue.map((file, idx) => { - const StatusIcon = file.status === 'ready' ? FolderInput : Sparkles; + const StatusIcon = file.status === 'ready' ? FolderInput : ScanSearch; const statusLabel = file.status === 'ready' ? 'Ready' : 'Awaiting'; const statusClass = file.status === 'ready' ? 'text-stratosort-blue' : 'text-stratosort-warning'; @@ -693,7 +694,7 @@ export default function DashboardView() { return { label: `${stats.failed} need attention`, variant: 'error', icon: AlertTriangle }; } if (stats.pending > 0) { - return { label: `${stats.pending} awaiting analysis`, variant: 'info', icon: Sparkles }; + return { label: `${stats.pending} awaiting analysis`, variant: 'info', icon: ScanSearch }; } if (stats.ready > 0) { return { label: `${stats.ready} ready to organize`, variant: 'info', icon: ArrowRight }; @@ -830,7 +831,7 @@ export default function DashboardView() { tone="blue" /> + String(text || '') + .trim() + .replace(/^[s"'“”‘’]+|[s"'“”‘’]+$/g, ''); + const getListHeight = (folderCount, viewportHeight) => { const maxFraction = folderCount <= 10 ? 0.35 : 0.45; const contentHeight = folderCount * ITEM_HEIGHT; @@ -25,26 +32,32 @@ const FolderItem = memo(function FolderItem({ folder, defaultLocation, style }) // PERF: Use memoized selector instead of inline Boolean coercion const redactPaths = useSelector(selectRedactPaths); const displayPath = formatDisplayPath(fullPath, { redact: redactPaths, segments: 2 }); + const description = unquoteDescription(folder.description); return ( -
+
{folder.name} -
- - +
+ + {/* min-w-0 lets the flex item shrink; break-all wraps paths, which have no spaces */} + {displayPath}
- {folder.description && ( + {description && ( - “{folder.description}” + “{description}” )} diff --git a/src/renderer/components/organize/UncategorizedActionsBar.jsx b/src/renderer/components/organize/UncategorizedActionsBar.jsx index 581941a..3a0825d 100644 --- a/src/renderer/components/organize/UncategorizedActionsBar.jsx +++ b/src/renderer/components/organize/UncategorizedActionsBar.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { FolderPlus, RefreshCw, Sparkles } from 'lucide-react'; +import { FolderPlus, RefreshCw, Lightbulb } from 'lucide-react'; import { Button } from '../ui'; import { Text } from '../ui/Typography'; import Card from '../ui/Card'; @@ -245,7 +245,7 @@ function UncategorizedActionsBar({
{suggestion ? ( - + {suggestion.files.length} are from{' '} {suggestion.displayName} — want @@ -267,7 +267,7 @@ function UncategorizedActionsBar({ size="sm" onClick={() => openCreateModal(suggestion.displayName)} > - + Create folder for {suggestion.displayName} )} diff --git a/src/renderer/components/search/ClusterNode.jsx b/src/renderer/components/search/ClusterNode.jsx index 7cf7bec..4516bc3 100644 --- a/src/renderer/components/search/ClusterNode.jsx +++ b/src/renderer/components/search/ClusterNode.jsx @@ -16,8 +16,8 @@ import { ExternalLink, Pencil, Search, - Sparkles, - Tag + Tag, + Bot } from 'lucide-react'; import { IconButton } from '../ui'; import { sanitizeSemanticTerms } from '../../utils/semanticTerms'; @@ -301,7 +301,7 @@ const ClusterNode = memo(({ data, selected }) => { {/* Member Count Badge */}
{memberCount} - {isAutoGenerated && } + {isAutoGenerated && }
{/* Confidence Ring (Visual Indicator) */} diff --git a/src/renderer/components/search/EmptyEmbeddingsBanner.jsx b/src/renderer/components/search/EmptyEmbeddingsBanner.jsx index 3ae263c..08d635f 100644 --- a/src/renderer/components/search/EmptyEmbeddingsBanner.jsx +++ b/src/renderer/components/search/EmptyEmbeddingsBanner.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Sparkles } from 'lucide-react'; +import { Database } from 'lucide-react'; import { Button, StateMessage } from '../ui'; function EmptyEmbeddingsBanner({ @@ -76,7 +76,7 @@ function EmptyEmbeddingsBanner({ return ( 0, so a fresh graph shows a compact set and grows as insights accumulate. */} toggleSidebarSection('insights')} @@ -1704,7 +1706,7 @@ export default function GraphTabContent({
- {selectedNode ? ( @@ -2613,7 +2615,7 @@ export default function GraphTabContent({ ) : (
- +
No Selection diff --git a/src/renderer/components/search/RelatedDocumentsPanel.jsx b/src/renderer/components/search/RelatedDocumentsPanel.jsx index 795ef70..c5751cf 100644 --- a/src/renderer/components/search/RelatedDocumentsPanel.jsx +++ b/src/renderer/components/search/RelatedDocumentsPanel.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { Link, Sparkles, Layers, X } from 'lucide-react'; +import { Link, Layers, X, Tags } from 'lucide-react'; import Button from '../ui/Button'; import { Heading, Text } from '../ui/Typography'; import FileIcon from '../ui/FileIcon'; @@ -20,7 +20,7 @@ const logger = createLogger('RelatedDocumentsPanel'); const REASON_META = { shared_concepts: { label: 'Shared concepts', - Icon: Sparkles, + Icon: Tags, iconClass: 'text-stratosort-indigo' }, content_similarity: { diff --git a/src/renderer/components/search/SearchTabContent.jsx b/src/renderer/components/search/SearchTabContent.jsx index f9ba926..53fd57a 100644 --- a/src/renderer/components/search/SearchTabContent.jsx +++ b/src/renderer/components/search/SearchTabContent.jsx @@ -16,7 +16,7 @@ import { MessageSquare, Network, RefreshCw, - Sparkles + SpellCheck } from 'lucide-react'; import { Button } from '../ui'; import { Heading, Text } from '../ui/Typography'; @@ -217,7 +217,7 @@ export default function SearchTabContent({ {/* Query correction feedback - "Did you mean?" */} {queryMeta?.corrections?.length > 0 && searchResults.length > 0 && (
- + Showing results for{' '} diff --git a/src/renderer/components/settings/ModelManagementSection.jsx b/src/renderer/components/settings/ModelManagementSection.jsx index 74e51e1..6953769 100644 --- a/src/renderer/components/settings/ModelManagementSection.jsx +++ b/src/renderer/components/settings/ModelManagementSection.jsx @@ -4,11 +4,11 @@ import { Languages, Eye, Layers, - Sparkles, CheckCircle2, Download, HardDrive, - Loader2 + Loader2, + BadgeCheck } from 'lucide-react'; import Button from '../ui/Button'; import Input from '../ui/Input'; @@ -112,7 +112,7 @@ function ModelCatalogRow({ model, installed, busy, onDownload, Icon, downloadPro {model.recommended && ( - )} {Number.isFinite(model.size) && ( diff --git a/src/renderer/components/settings/ProcessingLimitsSection.jsx b/src/renderer/components/settings/ProcessingLimitsSection.jsx index 5a20de5..2957cfc 100644 --- a/src/renderer/components/settings/ProcessingLimitsSection.jsx +++ b/src/renderer/components/settings/ProcessingLimitsSection.jsx @@ -33,16 +33,27 @@ function mbToBytes(mb) { /** * The cloud provider that video analysis could send frames to: the Analysis - * provider when it is a cloud one, else the first smart-folder override that - * is. Null when every analysis stays local. + * provider when it is a cloud one, else the first override of a smart folder + * that still exists and uses one. Null when every analysis stays local. + * + * Deleting a smart folder leaves its override in settings; such an override + * can never route a file, so it must not make the keyframe switch ask for + * cloud consent. * @param {any} aiSettings + * @param {any[] | null | undefined} smartFolders * @returns {string | null} */ -function cloudAnalysisProviderOf(aiSettings) { +export function cloudAnalysisProviderOf(aiSettings, smartFolders) { const analyze = resolveOperationProviderId(aiSettings, 'analyze'); if (isCloudProviderId(analyze)) return analyze; - const perFolder = Object.values(aiSettings?.routing?.perFolder || {}); - return perFolder.find((id) => isCloudProviderId(id)) || null; + const liveIds = new Set( + (Array.isArray(smartFolders) ? smartFolders : []) + .map((folder) => (folder?.id == null ? null : String(folder.id))) + .filter(Boolean) + ); + const overrides = Object.entries(aiSettings?.routing?.perFolder || {}); + const live = overrides.find(([folderId, id]) => liveIds.has(folderId) && isCloudProviderId(id)); + return live ? live[1] : null; } /** @@ -54,7 +65,7 @@ function cloudAnalysisProviderOf(aiSettings) { function ProcessingLimitsSection({ settings, setSettings }) { const [recommendedConcurrency, setRecommendedConcurrency] = useState(null); const cloudAnalysisProvider = useAppSelector((s) => - cloudAnalysisProviderOf(s.aiProvider?.settings) + cloudAnalysisProviderOf(s.aiProvider?.settings, s.files?.smartFolders) ); const consentCurrent = useAppSelector((s) => hasAcknowledgedCloudAI(s)); // Cloud provider awaiting the user's answer before frames may be sent. diff --git a/src/renderer/components/setup/AddSmartFolderModal.jsx b/src/renderer/components/setup/AddSmartFolderModal.jsx index 1f38d8f..e700a87 100644 --- a/src/renderer/components/setup/AddSmartFolderModal.jsx +++ b/src/renderer/components/setup/AddSmartFolderModal.jsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef, useId } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { Sparkles, FolderOpen } from 'lucide-react'; +import { FolderOpen, PenLine, Info } from 'lucide-react'; import { Button, Input, Textarea } from '../ui'; import { Text } from '../ui/Typography'; import Modal from '../ui/Modal'; @@ -462,7 +462,7 @@ function AddSmartFolderModal({ isLoading={isGeneratingDescription} variant="subtle" size="sm" - leftIcon={!isGeneratingDescription ? : null} + leftIcon={!isGeneratingDescription ? : null} className="text-stratosort-blue bg-stratosort-blue/10 border-stratosort-blue/20 hover:bg-stratosort-blue/20" > {!isGeneratingDescription @@ -497,7 +497,7 @@ function AddSmartFolderModal({ : 'border-stratosort-blue/20 bg-stratosort-blue/5 text-stratosort-blue' ].join(' ')} > -
)} diff --git a/src/renderer/components/setup/SmartFolderItem.jsx b/src/renderer/components/setup/SmartFolderItem.jsx index 4342a9b..ede679b 100644 --- a/src/renderer/components/setup/SmartFolderItem.jsx +++ b/src/renderer/components/setup/SmartFolderItem.jsx @@ -8,7 +8,8 @@ import { ChevronDown, ChevronUp, FolderPlus, - Sparkles + PenLine, + ScrollText } from 'lucide-react'; import Button from '../ui/Button'; import IconButton from '../ui/IconButton'; @@ -232,7 +233,7 @@ const SmartFolderItem = memo(function SmartFolderItem({ isGeneratingDescription ? ( ) : ( - + ) } size="sm" @@ -321,7 +322,7 @@ const SmartFolderItem = memo(function SmartFolderItem({ {folder.description && (
- + AI Context
{folder.description} diff --git a/src/renderer/components/triage/DownloadsTriageView.jsx b/src/renderer/components/triage/DownloadsTriageView.jsx index 39941cd..9e7e64a 100644 --- a/src/renderer/components/triage/DownloadsTriageView.jsx +++ b/src/renderer/components/triage/DownloadsTriageView.jsx @@ -5,10 +5,10 @@ import { FolderOpen, EyeOff, Play, - Sparkles, AlertTriangle, Filter, - Download + Download, + ScanSearch } from 'lucide-react'; import Modal, { ConfirmModal } from '../ui/Modal'; import Button from '../ui/Button'; @@ -765,7 +765,7 @@ export default function DownloadsTriageView({ isOpen, onClose }) { onClick={handleDeepScan} disabled={isDeepScanning} isLoading={isDeepScanning} - leftIcon={} + leftIcon={} > {`Deep scan ${buckets.ambiguous.length}`} diff --git a/src/renderer/phases/DiscoverPhase.jsx b/src/renderer/phases/DiscoverPhase.jsx index 66923b0..3fe5ac9 100644 --- a/src/renderer/phases/DiscoverPhase.jsx +++ b/src/renderer/phases/DiscoverPhase.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { AlertTriangle, X, Sparkles, RefreshCw, Network, FolderOpen, Settings } from 'lucide-react'; +import { AlertTriangle, X, RefreshCw, Network, FolderOpen, Settings, Database } from 'lucide-react'; import { PHASES } from '../../shared/constants'; import { TIMEOUTS } from '../../shared/performanceConstants'; import { createLogger } from '../../shared/logger'; @@ -804,7 +804,7 @@ function DiscoverPhase() { >
- +
@@ -829,7 +829,7 @@ function DiscoverPhase() { ) : ( <> - + Build embeddings )} diff --git a/src/renderer/phases/OrganizePhase.jsx b/src/renderer/phases/OrganizePhase.jsx index c50f091..080f301 100644 --- a/src/renderer/phases/OrganizePhase.jsx +++ b/src/renderer/phases/OrganizePhase.jsx @@ -18,9 +18,9 @@ import { BarChart3, CheckCircle2, Inbox, - Sparkles, AlertTriangle, - FileText + FileText, + FolderInput } from 'lucide-react'; import { ActionBar, Inline, Stack } from '../components/layout'; import { @@ -847,7 +847,7 @@ function OrganizePhase() { 'Organizing...' ) : ( <> - + {selectedFiles.size > 0 ? `Move ${selectedFiles.size} selected file${selectedFiles.size !== 1 ? 's' : ''}` diff --git a/src/renderer/phases/WelcomePhase.jsx b/src/renderer/phases/WelcomePhase.jsx index 5287a01..8cb7724 100644 --- a/src/renderer/phases/WelcomePhase.jsx +++ b/src/renderer/phases/WelcomePhase.jsx @@ -4,7 +4,7 @@ import { FolderOpen, Settings, Search, - Sparkles, + Cpu, FolderCheck, CheckCircle2, ListChecks, @@ -441,7 +441,7 @@ function WelcomePhase() {
- +
@@ -511,7 +511,7 @@ function WelcomePhase() { className="w-full justify-center h-full py-4" aria-describedby="ai-setup-help" > - + AI & model settings diff --git a/src/renderer/phases/discover/batchBusy.js b/src/renderer/phases/discover/batchBusy.js new file mode 100644 index 0000000..b42f6ce --- /dev/null +++ b/src/renderer/phases/discover/batchBusy.js @@ -0,0 +1,92 @@ +// @ts-check +/** + * Waiting out a batch this window already has running. + * + * The preload allows one `analysis:analyze-batch` request per window at a + * time and rejects a second with `CONCURRENCY_LIMIT` ("Operation already in + * progress"). That happens when Discover re-mounts (navigation, React + * StrictMode in development) while the batch it started earlier is still + * running in the main process: the new run cannot see the old request. + * + * Treating that rejection as a batch failure used to start the per-file + * worker pool, which analyzed the same files a second time in parallel with + * the running batch: duplicate notifications, two runs fighting over the GPU, + * and a restart loop when the second run requeued its files. Instead the run + * waits for the earlier batch to finish and then runs its own; files the + * earlier batch finished come back from the analysis cache. + */ + +/** How often a waiting run tries the batch again. */ +export const BATCH_BUSY_RETRY_MS = 3000; + +/** + * @param {any} error + * @returns {boolean} the batch request was refused because one is already running + */ +export function isBatchAlreadyRunning(error) { + if (!error) return false; + if (error.code === 'CONCURRENCY_LIMIT') return true; + return /Operation already in progress: analysis:analyze-batch/.test(String(error.message || '')); +} + +/** + * @param {number} ms + * @param {AbortSignal | null | undefined} signal + * @returns {Promise} + */ +function wait(ms, signal) { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal?.removeEventListener?.('abort', done); + resolve(); + } + signal?.addEventListener?.('abort', done, { once: true }); + }); +} + +/** + * Run the batch path, waiting while this window's earlier batch is still in + * progress. Returns without running when this run is superseded or aborted + * while it waits. Any other error is rethrown for the caller's fallback. + * + * @param {{ + * runBatchPath: () => Promise, + * isActiveRun: () => boolean, + * abortSignal?: AbortSignal | null, + * onWaiting?: () => void, + * retryMs?: number, + * sleep?: (ms: number, signal?: AbortSignal | null) => Promise + * }} options + * @returns {Promise<'ran' | 'superseded'>} + */ +export async function runBatchPathWhenFree({ + runBatchPath, + isActiveRun, + abortSignal = null, + onWaiting, + retryMs = BATCH_BUSY_RETRY_MS, + sleep = wait +}) { + let announced = false; + for (;;) { + try { + await runBatchPath(); + return 'ran'; + } catch (error) { + if (!isBatchAlreadyRunning(error)) throw error; + } + if (!isActiveRun() || abortSignal?.aborted) return 'superseded'; + if (!announced) { + announced = true; + onWaiting?.(); + } + await sleep(retryMs, abortSignal); + if (!isActiveRun() || abortSignal?.aborted) return 'superseded'; + } +} diff --git a/src/renderer/phases/discover/useAnalysis.js b/src/renderer/phases/discover/useAnalysis.js index 43418ef..8ac2ec3 100644 --- a/src/renderer/phases/discover/useAnalysis.js +++ b/src/renderer/phases/discover/useAnalysis.js @@ -32,6 +32,7 @@ import { import { mapErrorToNotification } from '../../utils/errorMapping'; import { normalizeNamingConventionState } from '../../../shared/namingConventions'; import { toCanonicalPath } from '../../utils/canonicalPath'; +import { isBatchAlreadyRunning, runBatchPathWhenFree } from './batchBusy'; const logger = createLogger('DiscoverPhase:Analysis'); const BATCH_ANALYSIS_MIN_FILES = 40; @@ -1475,7 +1476,11 @@ export function useAnalysis(options = {}) { // fallback then starts the per-file worker pool — without this // cancel, both would run the same files concurrently, doubling // inference load on exactly the slow machines that time out). - requestMainBatchCancel('batch-path-failed'); + // A request the preload refused because this window's earlier + // batch is still running never started anything in main. + if (!isBatchAlreadyRunning(batchPathError)) { + requestMainBatchCancel('batch-path-failed'); + } throw batchPathError; } finally { window.removeEventListener(BATCH_ANALYSIS_PROGRESS_EVENT, onBatchProgress); @@ -1619,7 +1624,18 @@ export function useAnalysis(options = {}) { systemRecommended, lowVramBatch: systemRecommended === 1 }); - await runBatchPath(); + await runBatchPathWhenFree({ + runBatchPath, + isActiveRun, + abortSignal, + onWaiting: () => + addNotification( + 'Analysis from before is still finishing. Continuing when it is done.', + 'info', + 4000, + 'analysis-batch-busy' + ) + }); } catch (batchError) { const isLegacyBatchTimeout = String(batchError?.message || '').includes('30000ms') && diff --git a/test/SmartFolderWatcher.userAnalysisDefer.test.js b/test/SmartFolderWatcher.userAnalysisDefer.test.js new file mode 100644 index 0000000..b13174f --- /dev/null +++ b/test/SmartFolderWatcher.userAnalysisDefer.test.js @@ -0,0 +1,137 @@ +/** + * @jest-environment node + * + * In a live run the smart-folder watcher analyzed the same PDFs the user was + * analyzing from Discover, at the same time: twice the GPU work and an + * "analyzed" notification from each. Files a user-started analysis is + * working on are now deferred by the watcher and re-checked afterwards. + */ +jest.mock('fs', () => jest.requireActual('fs')); +jest.mock('fs/promises', () => jest.requireActual('fs/promises')); +jest.mock('os', () => jest.requireActual('os')); +jest.mock('chokidar', () => ({ + watch: jest.fn(() => ({ + on: jest.fn().mockReturnThis(), + once: jest.fn(), + close: jest.fn().mockResolvedValue() + })) +})); +jest.mock('../src/shared/logger', () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + setContext: jest.fn() + }; + return { logger, createLogger: () => logger }; +}); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const SmartFolderWatcher = require('../src/main/services/SmartFolderWatcher'); +const { + claimUserAnalysis, + isUserAnalysisInFlight, + withUserAnalysis, + _resetUserAnalysisInFlight +} = require('../src/main/analysis/userAnalysisInFlight'); + +let root; +let file; + +beforeEach(() => { + _resetUserAnalysisInFlight(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sfw-user-')); + file = path.join(root, 'invoice.pdf'); + fs.writeFileSync(file, 'synthetic'); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('userAnalysisInFlight', () => { + test('marks a file for the duration of a claim', () => { + const release = claimUserAnalysis([file]); + expect(isUserAnalysisInFlight(file)).toBe(true); + release(); + release(); + expect(isUserAnalysisInFlight(file)).toBe(false); + }); + + test('overlapping claims keep the file marked until both end', () => { + const a = claimUserAnalysis(file); + const b = claimUserAnalysis([file]); + a(); + expect(isUserAnalysisInFlight(file)).toBe(true); + b(); + expect(isUserAnalysisInFlight(file)).toBe(false); + }); + + test('withUserAnalysis releases on failure too', async () => { + await expect( + withUserAnalysis(file, async () => { + expect(isUserAnalysisInFlight(file)).toBe(true); + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + expect(isUserAnalysisInFlight(file)).toBe(false); + }); +}); + +function makeWatcher({ needsAnalysis = true } = {}) { + const watcher = new SmartFolderWatcher({ + getSmartFolders: () => [{ id: 'f1', name: 'Finance', path: root }], + analysisHistoryService: { + recordAnalysis: jest.fn().mockResolvedValue(undefined), + getAnalysisByPath: jest.fn().mockResolvedValue(null) + }, + settingsService: { load: jest.fn().mockResolvedValue({}) }, + analyzeDocumentFile: jest.fn().mockRejectedValue(new Error('stop after analyzer call')), + analyzeImageFile: jest.fn() + }); + watcher.isRunning = true; + watcher.watchedPaths = new Set([root]); + watcher._needsAnalysis = jest.fn().mockResolvedValue(needsAnalysis); + return watcher; +} + +describe('SmartFolderWatcher defers files Discover is analyzing', () => { + test('a file in a user analysis is requeued with a delay, not analyzed', async () => { + const watcher = makeWatcher(); + const release = claimUserAnalysis(file); + const before = Date.now(); + await watcher._analyzeFile({ filePath: file, eventType: 'add', mtime: Date.now() }); + release(); + + expect(watcher.analyzeDocumentFile).not.toHaveBeenCalled(); + const queued = watcher.analysisQueue.find((q) => q.filePath === file); + expect(queued).toEqual(expect.objectContaining({ recheck: true })); + expect(queued.notBefore).toBeGreaterThanOrEqual(before + 10000); + }); + + test('the re-check skips the file once the history has it', async () => { + const watcher = makeWatcher({ needsAnalysis: false }); + await watcher._analyzeFile({ filePath: file, eventType: 'add', recheck: true }); + expect(watcher._needsAnalysis).toHaveBeenCalled(); + expect(watcher.analyzeDocumentFile).not.toHaveBeenCalled(); + }); + + test('a file nobody else is analyzing goes straight to the analyzer', async () => { + const watcher = makeWatcher(); + await watcher + ._analyzeFile({ filePath: file, eventType: 'add', mtime: Date.now() }) + .catch(() => {}); + expect(watcher.analyzeDocumentFile).toHaveBeenCalled(); + }); + + test('an explicit reanalyze request is never deferred', async () => { + const watcher = makeWatcher(); + const release = claimUserAnalysis(file); + await watcher._analyzeFile({ filePath: file, eventType: 'reanalyze' }).catch(() => {}); + release(); + expect(watcher.analyzeDocumentFile).toHaveBeenCalled(); + }); +}); diff --git a/test/components/BetaWorkflowInteraction.test.js b/test/components/BetaWorkflowInteraction.test.js index 5817235..622517d 100644 --- a/test/components/BetaWorkflowInteraction.test.js +++ b/test/components/BetaWorkflowInteraction.test.js @@ -143,7 +143,19 @@ jest.mock('lucide-react', () => ({ LayoutGrid: (props) => , List: (props) => , Rocket: () => , - Sparkles: () => , + Database: () => , + FolderInput: () => , + ScanSearch: () => , + Lightbulb: () => , + Cpu: () => , + PenLine: () => , + ScrollText: () => , + ListChecks: () => , + BadgeCheck: () => , + Tags: () => , + SpellCheck: () => , + MousePointerClick: () => , + Bot: () => , FolderCheck: () => , Plus: () => , Trash2: () => , diff --git a/test/components/DiscoverPhase.test.js b/test/components/DiscoverPhase.test.js index 39afcd1..aa3b821 100644 --- a/test/components/DiscoverPhase.test.js +++ b/test/components/DiscoverPhase.test.js @@ -186,7 +186,21 @@ jest.mock('../../src/renderer/phases/discover', () => ({ jest.mock('lucide-react', () => ({ AlertTriangle: () => , X: () => , - Sparkles: () => , + Database: () => , + FolderInput: () => , + ScanSearch: () => , + Lightbulb: () => , + FolderPlus: () => , + Cpu: () => , + PenLine: () => , + ScrollText: () => , + ListChecks: () => , + BadgeCheck: () => , + Tags: () => , + SpellCheck: () => , + Info: () => , + MousePointerClick: () => , + Bot: () => , RefreshCw: () => , Network: () => , FolderOpen: () => , diff --git a/test/components/ModelManagementSection.test.js b/test/components/ModelManagementSection.test.js index d63bfd9..9486805 100644 --- a/test/components/ModelManagementSection.test.js +++ b/test/components/ModelManagementSection.test.js @@ -15,7 +15,21 @@ jest.mock('lucide-react', () => { Languages: Icon, Eye: Icon, Layers: Icon, - Sparkles: Icon, + Database: Icon, + FolderInput: Icon, + ScanSearch: Icon, + Lightbulb: Icon, + FolderPlus: Icon, + Cpu: Icon, + PenLine: Icon, + ScrollText: Icon, + ListChecks: Icon, + BadgeCheck: Icon, + Tags: Icon, + SpellCheck: Icon, + Info: Icon, + MousePointerClick: Icon, + Bot: Icon, CheckCircle2: Icon, Download: Icon, HardDrive: Icon, diff --git a/test/components/OrganizePhase.test.js b/test/components/OrganizePhase.test.js index 4879f95..e0c0c2f 100644 --- a/test/components/OrganizePhase.test.js +++ b/test/components/OrganizePhase.test.js @@ -223,7 +223,21 @@ jest.mock('lucide-react', () => ({ BarChart3: () => , CheckCircle2: () => , Inbox: () => , - Sparkles: () => , + Database: () => , + FolderInput: () => , + ScanSearch: () => , + Lightbulb: () => , + FolderPlus: () => , + Cpu: () => , + PenLine: () => , + ScrollText: () => , + ListChecks: () => , + BadgeCheck: () => , + Tags: () => , + SpellCheck: () => , + Info: () => , + MousePointerClick: () => , + Bot: () => , AlertTriangle: () => , FileText: () => , ShieldCheck: () => diff --git a/test/components/ProcessingLimitsSection.frameConsent.test.js b/test/components/ProcessingLimitsSection.frameConsent.test.js index 04b7481..3eb5f78 100644 --- a/test/components/ProcessingLimitsSection.frameConsent.test.js +++ b/test/components/ProcessingLimitsSection.frameConsent.test.js @@ -8,7 +8,9 @@ import '@testing-library/jest-dom'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; -import ProcessingLimitsSection from '../../src/renderer/components/settings/ProcessingLimitsSection'; +import ProcessingLimitsSection, { + cloudAnalysisProviderOf +} from '../../src/renderer/components/settings/ProcessingLimitsSection'; import aiProviderReducer from '../../src/renderer/store/slices/aiProviderSlice'; const LEGACY_CONSENT = { acknowledgedCloudAt: '2026-01-01T00:00:00.000Z' }; @@ -100,3 +102,73 @@ describe('ProcessingLimitsSection — video keyframes and cloud consent', () => ).toBeInTheDocument(); }); }); + +describe('cloudAnalysisProviderOf — which cloud provider could receive frames', () => { + const folders = [ + { id: 'live-1', name: '3D print' }, + { id: 1769548408348, name: 'Finance' } + ]; + + test('an override of a deleted smart folder does not count', () => { + expect( + cloudAnalysisProviderOf( + { + routing: { + default: 'local', + perOperation: {}, + perFolder: { 'gone-9': 'openaiCompatible' } + } + }, + folders + ) + ).toBeNull(); + }); + + test('an override of a folder that still exists does count, numeric ids included', () => { + expect( + cloudAnalysisProviderOf( + { routing: { default: 'local', perFolder: { 'live-1': 'anthropic' } } }, + folders + ) + ).toBe('anthropic'); + expect( + cloudAnalysisProviderOf( + { routing: { default: 'local', perFolder: { 1769548408348: 'openaiCompatible' } } }, + folders + ) + ).toBe('openaiCompatible'); + }); + + test('a local override and a missing folder list leave every analysis local', () => { + expect( + cloudAnalysisProviderOf( + { routing: { default: 'local', perFolder: { 'live-1': 'local' } } }, + folders + ) + ).toBeNull(); + expect( + cloudAnalysisProviderOf({ + routing: { default: 'local', perFolder: { 'live-1': 'anthropic' } } + }) + ).toBeNull(); + }); + + test('a cloud Analysis provider counts whatever the folders are', () => { + expect( + cloudAnalysisProviderOf( + { routing: { default: 'local', perOperation: { analyze: 'anthropic' } } }, + [] + ) + ).toBe('anthropic'); + }); + + test('with only an orphaned cloud override, turning keyframes on applies at once', () => { + const latest = renderSection({ + routing: { default: 'local', perOperation: {}, perFolder: { 'gone-9': 'openaiCompatible' } }, + consent: LEGACY_CONSENT + }); + fireEvent.click(keyframeSwitch()); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(latest.settings.captionVideoKeyframes).toBe(true); + }); +}); diff --git a/test/components/RelatedDocumentsPanel.test.js b/test/components/RelatedDocumentsPanel.test.js index 3274826..5ae242f 100644 --- a/test/components/RelatedDocumentsPanel.test.js +++ b/test/components/RelatedDocumentsPanel.test.js @@ -10,7 +10,7 @@ import RelatedDocumentsPanel from '../../src/renderer/components/search/RelatedD jest.mock('lucide-react', () => { const React = require('react'); const I = (n) => (p) => React.createElement('span', { 'data-icon': n, ...p }); - return { Link: I('link'), Sparkles: I('sparkles'), Layers: I('layers'), X: I('x') }; + return { Link: I('link'), Tags: I('tags'), Layers: I('layers'), X: I('x') }; }); jest.mock('../../src/renderer/components/ui/FileIcon', () => { diff --git a/test/components/WelcomePhase.test.js b/test/components/WelcomePhase.test.js index 5d04b81..389151b 100644 --- a/test/components/WelcomePhase.test.js +++ b/test/components/WelcomePhase.test.js @@ -78,7 +78,7 @@ jest.mock('lucide-react', () => ({ FolderOpen: () => , Settings: () => , Search: () => , - Sparkles: () => , + Cpu: () => , FolderCheck: () => , Loader2: () => , CheckCircle2: () => , diff --git a/test/components/unifiedSearchModal.harness.js b/test/components/unifiedSearchModal.harness.js index 1f6d08b..484891a 100644 --- a/test/components/unifiedSearchModal.harness.js +++ b/test/components/unifiedSearchModal.harness.js @@ -143,7 +143,18 @@ jest.mock('lucide-react', () => ({ FolderPlus: () => FolderPlus, RefreshCw: () => RefreshCw, Search: () => Search, - Sparkles: () => Sparkles, + Database: () => Database, + ScanSearch: () => ScanSearch, + Cpu: () => Cpu, + PenLine: () => PenLine, + ScrollText: () => ScrollText, + ListChecks: () => ListChecks, + BadgeCheck: () => BadgeCheck, + Tags: () => Tags, + SpellCheck: () => SpellCheck, + Info: () => Info, + MousePointerClick: () => MousePointerClick, + Bot: () => Bot, Copy: () => Copy, Network: () => Network, Orbit: () => Orbit, diff --git a/test/discoverBatchBusy.test.js b/test/discoverBatchBusy.test.js new file mode 100644 index 0000000..8ad0787 --- /dev/null +++ b/test/discoverBatchBusy.test.js @@ -0,0 +1,116 @@ +/** + * src/renderer/phases/discover/batchBusy.js — a Discover run whose batch + * request is refused because this window's earlier batch is still running + * must wait for it, never start a second analysis of the same files. + */ +const { + isBatchAlreadyRunning, + runBatchPathWhenFree +} = require('../src/renderer/phases/discover/batchBusy'); + +const busy = () => + Object.assign(new Error('IPC Error: Operation already in progress: analysis:analyze-batch'), { + code: 'CONCURRENCY_LIMIT' + }); + +describe('isBatchAlreadyRunning', () => { + test.each([ + [busy(), true], + [new Error('IPC Error: Operation already in progress: analysis:analyze-batch'), true], + [Object.assign(new Error('x'), { code: 'CONCURRENCY_LIMIT' }), true], + [new Error('Batch analysis API not available'), false], + [new Error('timed out after 30000ms: analysis:analyze-batch'), false], + [null, false] + ])('%p -> %p', (error, expected) => { + expect(isBatchAlreadyRunning(error)).toBe(expected); + }); +}); + +describe('runBatchPathWhenFree', () => { + const noSleep = jest.fn(async () => {}); + + test('runs once when the batch is free', async () => { + const runBatchPath = jest.fn(async () => {}); + await expect( + runBatchPathWhenFree({ runBatchPath, isActiveRun: () => true, sleep: noSleep }) + ).resolves.toBe('ran'); + expect(runBatchPath).toHaveBeenCalledTimes(1); + }); + + test('waits while the earlier batch runs, announces once, then runs', async () => { + const runBatchPath = jest + .fn() + .mockRejectedValueOnce(busy()) + .mockRejectedValueOnce(busy()) + .mockResolvedValueOnce(undefined); + const onWaiting = jest.fn(); + await expect( + runBatchPathWhenFree({ + runBatchPath, + isActiveRun: () => true, + onWaiting, + sleep: noSleep + }) + ).resolves.toBe('ran'); + expect(runBatchPath).toHaveBeenCalledTimes(3); + expect(onWaiting).toHaveBeenCalledTimes(1); + }); + + test('a newer run supersedes a waiting one without running it', async () => { + let active = true; + const runBatchPath = jest.fn(async () => { + throw busy(); + }); + const sleep = jest.fn(async () => { + active = false; + }); + await expect( + runBatchPathWhenFree({ runBatchPath, isActiveRun: () => active, sleep }) + ).resolves.toBe('superseded'); + expect(runBatchPath).toHaveBeenCalledTimes(1); + }); + + test('an abort while waiting stops the run', async () => { + const controller = new AbortController(); + const runBatchPath = jest.fn(async () => { + throw busy(); + }); + const sleep = jest.fn(async () => controller.abort()); + await expect( + runBatchPathWhenFree({ + runBatchPath, + isActiveRun: () => true, + abortSignal: controller.signal, + sleep + }) + ).resolves.toBe('superseded'); + }); + + test('any other failure is rethrown for the per-file fallback', async () => { + const failure = new Error('Batch analysis API not available'); + await expect( + runBatchPathWhenFree({ + runBatchPath: jest.fn(async () => { + throw failure; + }), + isActiveRun: () => true, + sleep: noSleep + }) + ).rejects.toBe(failure); + }); + + test('the real wait resolves early when aborted', async () => { + const controller = new AbortController(); + const runBatchPath = jest.fn(async () => { + throw busy(); + }); + const pending = runBatchPathWhenFree({ + runBatchPath, + isActiveRun: () => true, + abortSignal: controller.signal, + retryMs: 60_000 + }); + controller.abort(); + await expect(pending).resolves.toBe('superseded'); + }); +}); diff --git a/test/embeddingBesideText.test.js b/test/embeddingBesideText.test.js new file mode 100644 index 0000000..0f5e24f --- /dev/null +++ b/test/embeddingBesideText.test.js @@ -0,0 +1,109 @@ +/** + * On a GPU too small for both models (Qwen2.5-7B, about 5.8 GB, on a 6 GB + * RTX 4050 with a 5 GB budget), every file used to evict the text model to + * load the embedding model and then reload the text model: about 4-5 s of + * churn per file in a live run. The embedding model now loads on the CPU + * beside the resident text model. + */ +jest.mock('os', () => ({ + totalmem: jest.fn(() => 16 * 1024 * 1024 * 1024), + freemem: jest.fn(() => 8 * 1024 * 1024 * 1024) +})); +jest.mock('../src/shared/logger', () => ({ + createLogger: () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }) +})); + +const { ModelMemoryManager } = require('../src/main/services/ModelMemoryManager'); + +const MiB = 1024 * 1024; +const GiB = 1024 * MiB; + +function managerWithIdleText() { + const llamaService = { + _models: { text: null, vision: null, embedding: null }, + _contexts: { text: null, vision: null, embedding: null }, + _loadModel: jest.fn().mockResolvedValue({ ctx: 'embedding' }), + _unloadModel: jest.fn().mockResolvedValue(undefined) + }; + const manager = new ModelMemoryManager(llamaService); + manager._maxMemoryUsage = 5 * GiB; + jest.spyOn(manager, '_refreshMemoryBudget').mockImplementation(() => {}); + manager._modelSizeEstimates.embedding = 181 * MiB; + manager._pinnedResidentWaitMs = 0; + // The text model is resident and idle between files (no active refs). + manager._loadedModels.set('text', { + context: { ctx: 'text' }, + lastUsed: 1, + sizeBytes: 5806 * MiB + }); + manager._currentMemoryUsage = 5806 * MiB; + const unloadSpy = jest.spyOn(manager, 'unloadModel'); + return { manager, llamaService, unloadSpy }; +} + +describe('ModelMemoryManager: keepBeside', () => { + test('a CPU embedding load with keepBeside leaves the idle text model resident', async () => { + const { manager, llamaService, unloadSpy } = managerWithIdleText(); + await manager.ensureModelLoaded('embedding', { + gpuLayersOverride: 0, + besideResident: 'text', + keepBeside: true + }); + expect(manager._loadedModels.has('text')).toBe(true); + expect(unloadSpy).not.toHaveBeenCalledWith('text', expect.anything()); + expect(llamaService._loadModel).toHaveBeenCalledWith( + 'embedding', + expect.objectContaining({ gpuLayersOverride: 0 }) + ); + }); + + test('without keepBeside the idle text model is still evicted (unchanged rule)', async () => { + const { manager } = managerWithIdleText(); + await manager.ensureModelLoaded('embedding', { gpuLayersOverride: 0, besideResident: 'text' }); + expect(manager._loadedModels.has('text')).toBe(false); + }); + + test('keepBeside does not exempt a GPU load', async () => { + const { manager } = managerWithIdleText(); + await manager.ensureModelLoaded('embedding', { besideResident: 'text', keepBeside: true }); + expect(manager._loadedModels.has('text')).toBe(false); + }); +}); + +describe('LlamaService._loadOptionsBesideText', () => { + const LlamaServiceModule = require('../src/main/services/LlamaService'); + const LlamaService = LlamaServiceModule.LlamaService || LlamaServiceModule; + const pick = LlamaService.prototype._loadOptionsBesideText; + + const service = ({ backend = 'cuda', text = true, embedding = false, fits = false } = {}) => ({ + _gpuBackend: backend, + _modelMemoryManager: { + getLoadedContext: (type) => + (type === 'text' && text) || (type === 'embedding' && embedding) ? {} : null, + canLoadModel: () => fits + } + }); + + test('embedding beside a GPU-filling text model goes to the CPU, without the warning', () => { + expect(pick.call(service(), 'embedding')).toEqual({ + gpuLayersOverride: 0, + besideResident: 'text', + keepBeside: true, + cpuByDesign: true + }); + }); + + test.each([ + ['both fit on the GPU', { fits: true }], + ['no text model resident', { text: false }], + ['embedding already loaded', { embedding: true }], + ['CPU backend', { backend: 'cpu' }], + ['backend not chosen yet', { backend: null }] + ])('no override when %s', (_name, opts) => { + expect(pick.call(service(opts), 'embedding')).toBeUndefined(); + }); + + test('only the embedding model is affected', () => { + expect(pick.call(service(), 'text')).toBeUndefined(); + }); +}); diff --git a/test/imageAnalysis.behavior.test.js b/test/imageAnalysis.behavior.test.js index 8dbcdcc..4b07e9b 100644 --- a/test/imageAnalysis.behavior.test.js +++ b/test/imageAnalysis.behavior.test.js @@ -612,6 +612,9 @@ describe('Image Analysis Behavior', () => { expect(result.category).toBe('Financial'); expect(result.analysisWarning).toMatch(/parse json|empty response/i); expect(result.reason).not.toBe('AI engine failed'); + // Only what OCR text could tell: a later analysis must try vision again + // instead of getting this back from the cache. + expect(mockCacheInstance.set).not.toHaveBeenCalled(); }); test('the OCR/text fallback carries the caller abort signal to the text request', async () => { diff --git a/test/main/services/liveLogActivity.test.js b/test/main/services/liveLogActivity.test.js index b40b27f..77bda89 100644 --- a/test/main/services/liveLogActivity.test.js +++ b/test/main/services/liveLogActivity.test.js @@ -159,7 +159,7 @@ const CASES = [ }, expected: { kind: 'folder-suggestion', - icon: 'sparkles', + icon: 'lightbulb', tone: 'info', text: 'Suggested a new folder "Pets" for IMG_2041.HEIC', file: 'IMG_2041.HEIC' @@ -175,7 +175,7 @@ const CASES = [ }, expected: { kind: 'folder-suggestion', - icon: 'sparkles', + icon: 'lightbulb', tone: 'info', text: 'Suggested a new folder "Housing" for lease.docx', file: 'lease.docx' @@ -191,7 +191,7 @@ const CASES = [ }, expected: { kind: 'folder-suggestion', - icon: 'sparkles', + icon: 'lightbulb', tone: 'info', text: 'Suggested a new folder "Recipes"' }