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"' }