diff --git a/config/changelog.js b/config/changelog.js index c2538551..65a66b56 100644 --- a/config/changelog.js +++ b/config/changelog.js @@ -51,6 +51,15 @@ const RELEASES = [ 'Removed: Legacy data/.env migration notice on the settings page', ], }, + { + version: 'v2026.07.04', + entries: [ + 'Fix: Quickstart OCR detection now suggests and lists dedicated OCR models (e.g. Mistral\'s mistral-ocr-latest) instead of requiring vision-capable naming, in both the Setup Wizard and Settings page', + 'Fix: Setup wizard no longer leaves a stale AI provider selected when switching from Quickstart to manual AI configuration', + 'Fix: AI response/prompt log files resolve relative to the working directory on native (non-Docker) installs', + 'Improvement: Quickstart\'s "use this service for OCR" option is now a proper ON/OFF switch, matching the rest of the settings UI', + ], + }, ]; const latestRelease = RELEASES[RELEASES.length - 1]; diff --git a/config/config.js b/config/config.js index eaf2f758..dff1c55a 100644 --- a/config/config.js +++ b/config/config.js @@ -352,7 +352,7 @@ startupLog(logLevel, 'info', 'Configuration loaded:', { }); module.exports = { - PAPERLESS_AI_VERSION: 'v2026.07.03', + PAPERLESS_AI_VERSION: 'v2026.07.04', CONFIGURED: false, configSourceMode: CONFIG_SOURCE_MODE, getApiKey, diff --git a/public/js/settings.js b/public/js/settings.js index 4142558d..fba64764 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1193,9 +1193,13 @@ function initializeFormHandlers() { const textModels = Array.isArray(quickstartDetection?.textModels) ? quickstartDetection.textModels : []; - const visionModels = Array.isArray(quickstartDetection?.visionModels) - ? quickstartDetection.visionModels - : []; + // Every detected model that isn't embedding-only is a valid OCR + // candidate (textModels already excludes embedding-only models). + // Gating on the vision name-heuristic instead misses real + // OCR-capable models with unfamiliar names, e.g. Mistral's + // dedicated mistral-ocr-* models. Mirrors the Quickstart fix in + // public/js/setup.js. + const ocrCandidateModels = textModels; populateModelSelect( quickstartAiModelSelect, @@ -1210,10 +1214,10 @@ function initializeFormHandlers() { populateModelSelect( quickstartOcrModelSelect, - visionModels, - visionModels.length > 0 + ocrCandidateModels, + ocrCandidateModels.length > 0 ? 'Select OCR model' - : 'No vision-capable models found' + : 'No models found' ); if ( quickstartDetection?.suggestedOcrModel && @@ -1224,8 +1228,8 @@ function initializeFormHandlers() { } if (quickstartEnableOcrCheckbox) { - quickstartEnableOcrCheckbox.disabled = visionModels.length === 0; - quickstartEnableOcrCheckbox.checked = visionModels.length > 0; + quickstartEnableOcrCheckbox.disabled = ocrCandidateModels.length === 0; + quickstartEnableOcrCheckbox.checked = ocrCandidateModels.length > 0; } if (quickstartHint) { @@ -1329,7 +1333,9 @@ function initializeFormHandlers() { if (applyOcr && selectedOcrModel) { setSwitchValue(ocrEnabledSelect, 'yes'); - if (ocrProviderSelect) ocrProviderSelect.value = 'custom'; + if (ocrProviderSelect) { + ocrProviderSelect.value = quickstartDetection.ocrProvider || 'custom'; + } if (ocrApiUrlInput) ocrApiUrlInput.value = String( quickstartDetection.resolvedOcrApiUrl || '' diff --git a/public/js/setup.js b/public/js/setup.js index 0666f961..94b7e1d2 100644 --- a/public/js/setup.js +++ b/public/js/setup.js @@ -1,3 +1,4 @@ +/* global Swal */ class SetupWizard { constructor() { this.bootstrap = window.__SETUP_BOOTSTRAP__ || {}; @@ -926,6 +927,7 @@ class SetupWizard { applyPreset(preset) { if (!preset) { + this.aiProvider.value = 'custom'; this.aiPresetHint.textContent = 'Manual mode: choose provider and enter values yourself. Token is optional for custom endpoints.'; return; } @@ -979,7 +981,14 @@ class SetupWizard { this.quickstartState.detection = detection; const textModels = Array.isArray(detection.textModels) ? detection.textModels : []; - const visionModels = Array.isArray(detection.visionModels) ? detection.visionModels : []; + // Every detected model that isn't embedding-only is a valid OCR + // candidate (textModels already excludes embedding-only models - + // see quickstartService.classifyModelName). Rather than also + // gating on the vision name-heuristic (which can miss real + // OCR-capable models with unfamiliar names, e.g. dedicated OCR + // models), offer the full list here and let the user pick; a + // heuristic "suggested" default is still pre-selected below. + const ocrCandidateModels = textModels; this.setModelSelectOptions(this.quickstartAiModel, textModels, textModels.length > 0 ? 'Select AI model' : 'No text-capable models found'); this.quickstartAiModel.disabled = textModels.length === 0; @@ -987,20 +996,20 @@ class SetupWizard { this.quickstartAiModel.value = detection.suggestedAiModel; } - this.setModelSelectOptions(this.quickstartOcrModel, visionModels, visionModels.length > 0 ? 'Select OCR model' : 'No vision-capable models found'); - this.quickstartOcrModel.disabled = visionModels.length === 0; + this.setModelSelectOptions(this.quickstartOcrModel, ocrCandidateModels, ocrCandidateModels.length > 0 ? 'Select OCR model' : 'No models found'); + this.quickstartOcrModel.disabled = ocrCandidateModels.length === 0; if (detection.suggestedOcrModel) { this.quickstartOcrModel.value = detection.suggestedOcrModel; } - const hasVisionModels = visionModels.length > 0; - this.quickstartEnableOcr.disabled = !hasVisionModels; - this.quickstartEnableOcr.checked = hasVisionModels; + const hasOcrCandidateModels = ocrCandidateModels.length > 0; + this.quickstartEnableOcr.disabled = !hasOcrCandidateModels; + this.quickstartEnableOcr.checked = hasOcrCandidateModels; if (this.quickstartOcrHint) { - this.quickstartOcrHint.classList.toggle('hidden', hasVisionModels); - this.quickstartOcrHint.textContent = hasVisionModels + this.quickstartOcrHint.classList.toggle('hidden', hasOcrCandidateModels); + this.quickstartOcrHint.textContent = hasOcrCandidateModels ? '' - : 'No vision-capable model found — OCR fallback stays disabled.'; + : 'No models found — OCR fallback stays disabled.'; } if (this.quickstartHint) { @@ -1066,7 +1075,7 @@ class SetupWizard { if (enableOcr && selectedOcrModel) { this.mistralOcrEnabled.value = 'yes'; - this.ocrProvider.value = 'custom'; + this.ocrProvider.value = detection.ocrProvider || 'custom'; this.ocrApiUrl.value = detection.resolvedOcrApiUrl || ''; this.ocrApiKey.value = quickstartKey; this.setModelSelectOptions(this.mistralOcrModel, [selectedOcrModel], 'Select OCR model'); @@ -1665,7 +1674,7 @@ class SetupWizard { try { await navigator.clipboard.writeText(this.envPreview.value); await this.showPopup({ icon: 'success', title: 'Copied', text: 'Environment keys copied to clipboard.' }); - } catch (_error) { + } catch { this.envPreview.select(); document.execCommand('copy'); await this.showPopup({ icon: 'success', title: 'Copied', text: 'Environment keys copied to clipboard.' }); @@ -1707,7 +1716,6 @@ class SetupWizard { async finalizeSetup(options = {}) { const validations = []; for (let index = 0; index <= 5; index += 1) { - // eslint-disable-next-line no-await-in-loop const valid = await this.validateStepBeforeContinue(index); if (!valid) { validations.push(index); diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 0a626e3a..e7a58759 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -38,6 +38,7 @@ const TESTS = { 'injected-env-priority': 'test-injected-env-priority.js', 'log-level-config': 'test-log-level-config.js', 'log-level-logger': 'test-log-level-logger.js', + 'native-install-log-paths': 'test-native-install-log-paths.js', 'login-mfa-flow': 'test-login-mfa-flow.js', 'ocr-fallback-ai-errors': 'test-ocr-fallback-ai-errors.js', 'ocr-startup-recovery': 'test-ocr-startup-recovery.js', @@ -46,6 +47,7 @@ const TESTS = { 'quickstart-model-classification': 'test-quickstart-model-classification.js', 'quickstart-endpoint-protection': 'test-quickstart-endpoint-protection.js', 'setup-wizard-quickstart': 'test-setup-wizard-quickstart.js', + 'setup-preset-manual-reset': 'test-setup-preset-manual-reset.js', 'rate-limiting': 'test-rate-limiting.js', 'scan-stop-flow': 'test-scan-stop-flow.js', 'setup-auth-endpoint-protection': 'test-setup-auth-endpoint-protection.js', @@ -102,7 +104,11 @@ const AREAS = { 'setup-ocr-disabled-skip', 'setupservice-ocr-validation', ], - observability: ['log-level-config', 'log-level-logger'], + observability: [ + 'log-level-config', + 'log-level-logger', + 'native-install-log-paths', + ], processing: [ 'document-type-restriction', 'ignore-tags-filter', @@ -126,6 +132,7 @@ const AREAS = { 'quickstart-endpoint-protection', 'runtime-first-setup-state', 'setup-wizard-tag-default', + 'setup-preset-manual-reset', ], security: [ 'setup-remote-guard', diff --git a/services/azureService.js b/services/azureService.js index d9ec8859..4e213777 100644 --- a/services/azureService.js +++ b/services/azureService.js @@ -14,7 +14,7 @@ const fs = require('fs').promises; const path = require('path'); const { THUMBNAIL_CACHE_DIR, getThumbnailCachePath } = require('./thumbnailCachePaths'); const RestrictionPromptService = require('./restrictionPromptService'); -const responseLogPath = path.join('/app', 'data', 'logs', 'response.txt'); +const responseLogPath = path.join(process.cwd(), 'data', 'logs', 'response.txt'); class AzureOpenAIService { constructor() { diff --git a/services/customService.js b/services/customService.js index 95eb4989..9a85fe02 100644 --- a/services/customService.js +++ b/services/customService.js @@ -15,7 +15,7 @@ const fs = require('fs').promises; const path = require('path'); const { THUMBNAIL_CACHE_DIR, getThumbnailCachePath } = require('./thumbnailCachePaths'); const RestrictionPromptService = require('./restrictionPromptService'); -const responseLogPath = path.join('/app', 'data', 'logs', 'response.txt'); +const responseLogPath = path.join(process.cwd(), 'data', 'logs', 'response.txt'); const CUSTOM_PROVIDER_FALLBACK_API_KEY = 'no-auth-required'; class CustomOpenAIService { diff --git a/services/openaiService.js b/services/openaiService.js index 414ac46f..6b4366d5 100644 --- a/services/openaiService.js +++ b/services/openaiService.js @@ -13,7 +13,7 @@ const path = require('path'); const { THUMBNAIL_CACHE_DIR, getThumbnailCachePath } = require('./thumbnailCachePaths'); const { model } = require('./ollamaService'); const RestrictionPromptService = require('./restrictionPromptService'); -const responseLogPath = path.join('/app', 'data', 'logs', 'response.txt'); +const responseLogPath = path.join(process.cwd(), 'data', 'logs', 'response.txt'); class OpenAIService { constructor() { diff --git a/services/quickstartService.js b/services/quickstartService.js index 3cd9ba2c..e88dcdca 100644 --- a/services/quickstartService.js +++ b/services/quickstartService.js @@ -36,6 +36,13 @@ class QuickstartService { // classified as vision. The suggestion is a dropdown default the user // can always override. this.visionNameHints = ['llava', 'bakllava', '-vl', 'vl-', 'vlm', 'vision', 'minicpm-v', 'moondream', 'pixtral', 'gemma3', 'internvl', 'smolvlm']; + // Dedicated OCR models (e.g. Mistral's mistral-ocr-latest) classify as + // plain ['text'] under classifyModelName - none of the vision hints + // above match "ocr" naming. suggestModels() needs its own hint list so + // these still get suggested as the default OCR model instead of being + // invisible to the suggestion logic (see classifyModelName's untouched + // hint lists; this only affects which text-capable model is suggested). + this.ocrNameHints = ['ocr']; } // ── Classification helpers (pure, offline-testable) ────────────────────── @@ -112,6 +119,9 @@ class QuickstartService { suggestModels(classifiedModels = []) { const textCandidates = classifiedModels.filter((model) => model.capabilities.includes('text')); const visionCandidates = classifiedModels.filter((model) => model.capabilities.includes('vision')); + const ocrNameCandidates = textCandidates.filter( + (model) => this.ocrNameHints.some((hint) => model.id.toLowerCase().includes(hint)) + ); let suggestedAiModel = null; let bestAiScore = -1; @@ -139,10 +149,14 @@ class QuickstartService { suggestedAiModel = visionCandidates[0].id; } + // Dedicated OCR models (named "*ocr*") are purpose-built for this and + // take priority over generic vision models when present. + const ocrCandidates = ocrNameCandidates.length > 0 ? ocrNameCandidates : visionCandidates; + let suggestedOcrModel = null; let bestOcrScore = -1; let bestOcrSize = Number.POSITIVE_INFINITY; - visionCandidates.forEach((model) => { + ocrCandidates.forEach((model) => { const score = model.state === 'loaded' ? 2 : 0; const size = this.parseParameterSizeBillions(model.parameterSize); const effectiveSize = size == null ? Number.POSITIVE_INFINITY : size; @@ -181,6 +195,17 @@ class QuickstartService { return headers; } + // Which OCR provider to default the wizard to. This is deliberately not a + // per-model capability guess (those can miss real OCR-capable models with + // unfamiliar names); it only checks the one endpoint this codebase already + // knows has a dedicated /ocr path (see + // setupService.getMistralUrlValidationOptions). The "OCR fallback" wizard + // step always lets the user override this before finishing setup. + resolveOcrProviderDefault(bareBaseUrl) { + const normalized = String(bareBaseUrl || '').trim().toLowerCase(); + return normalized.includes('api.mistral.ai') ? 'mistral' : 'custom'; + } + buildLoopbackBlockedError(validationError) { return new Error( `URL validation failed: ${validationError} — localhost URLs are blocked by default. ` @@ -371,9 +396,7 @@ class QuickstartService { flavor: probeResult.flavor, aiProvider: isOllama ? 'ollama' : 'custom', resolvedAiApiUrl: isOllama ? urls.bareBaseUrl : urls.versionedBaseUrl, - // The wizard/settings OCR provider is always "custom" for local - // endpoints (the backend aliases custom -> ollama internally). - ocrProvider: 'custom', + ocrProvider: this.resolveOcrProviderDefault(urls.bareBaseUrl), resolvedOcrApiUrl: isOllama ? urls.bareBaseUrl : urls.versionedBaseUrl, models: models.map((m) => ({ id: m.id, diff --git a/services/serviceUtils.js b/services/serviceUtils.js index 9928a315..903815d0 100644 --- a/services/serviceUtils.js +++ b/services/serviceUtils.js @@ -163,7 +163,7 @@ async function truncateToTokenLimit(text, maxTokens, model = process.env.OPENAI_ } // Write prompt and content to a file with size management -async function writePromptToFile(systemPrompt, truncatedContent, filePath = '/app/data/logs/prompt.txt', maxSize = 10 * 1024 * 1024) { +async function writePromptToFile(systemPrompt, truncatedContent, filePath = path.join(process.cwd(), 'data', 'logs', 'prompt.txt'), maxSize = 10 * 1024 * 1024) { try { // Ensure the logs directory exists await fs.mkdir(path.dirname(filePath), { recursive: true }); diff --git a/tests/test-native-install-log-paths.js b/tests/test-native-install-log-paths.js new file mode 100644 index 00000000..4adf6f74 --- /dev/null +++ b/tests/test-native-install-log-paths.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const path = require('path'); + +function assertIncludes(content, snippet, message) { + if (!content.includes(snippet)) { + throw new Error(message); + } +} + +function assertNotIncludes(content, snippet, message) { + if (content.includes(snippet)) { + throw new Error(message); + } +} + +function run() { + console.log('\n=== Native Install Log Path Checks ==='); + + const filesUsingResponseLogPath = [ + 'services/openaiService.js', + 'services/customService.js', + 'services/azureService.js', + ]; + + filesUsingResponseLogPath.forEach((relativePath) => { + const fullPath = path.join(process.cwd(), relativePath); + const content = fs.readFileSync(fullPath, 'utf8'); + + assertNotIncludes( + content, + "path.join('/app'", + `${relativePath} must not hardcode the Docker '/app' path for AI response logging` + ); + assertIncludes( + content, + 'path.join(process.cwd()', + `${relativePath} must resolve the AI response log path relative to process.cwd()` + ); + }); + + const serviceUtilsPath = path.join( + process.cwd(), + 'services', + 'serviceUtils.js' + ); + const serviceUtilsContent = fs.readFileSync(serviceUtilsPath, 'utf8'); + + assertNotIncludes( + serviceUtilsContent, + "'/app/data/logs/prompt.txt'", + "serviceUtils.js's writePromptToFile() must not default to a hardcoded '/app' path" + ); + assertIncludes( + serviceUtilsContent, + "path.join(process.cwd(), 'data', 'logs', 'prompt.txt')", + "serviceUtils.js's writePromptToFile() must default to a process.cwd()-relative prompt log path" + ); + + console.log('✅ All native install log path checks passed'); +} + +run(); +console.log('✅ test-native-install-log-paths passed'); diff --git a/tests/test-quickstart-model-classification.js b/tests/test-quickstart-model-classification.js index 419e0927..7320feb5 100644 --- a/tests/test-quickstart-model-classification.js +++ b/tests/test-quickstart-model-classification.js @@ -114,6 +114,22 @@ const textOnly = quickstartService.suggestModels([ ]); assert.strictEqual(textOnly.suggestedOcrModel, null, 'No vision model should yield a null OCR suggestion'); +// Dedicated OCR-named model (classifies as plain ['text'], no vision hint +// matches "ocr") must still be suggested for OCR instead of yielding null. +const dedicatedOcrModel = quickstartService.suggestModels([ + { id: 'mistral-medium-2505', capabilities: ['text'], state: null, parameterSize: null }, + { id: 'mistral-ocr-latest', capabilities: ['text'], state: null, parameterSize: null } +]); +assert.strictEqual(dedicatedOcrModel.suggestedAiModel, 'mistral-medium-2505', 'The non-OCR text model should be preferred for AI analysis'); +assert.strictEqual(dedicatedOcrModel.suggestedOcrModel, 'mistral-ocr-latest', 'A dedicated OCR-named model should be suggested for OCR even without a vision classification'); + +// Dedicated OCR-named models take priority over generic vision models when both are present +const ocrNameBeatsVision = quickstartService.suggestModels([ + { id: 'llava:13b', capabilities: ['text', 'vision'], state: null, parameterSize: null }, + { id: 'mistral-ocr-latest', capabilities: ['text'], state: null, parameterSize: null } +]); +assert.strictEqual(ocrNameBeatsVision.suggestedOcrModel, 'mistral-ocr-latest', 'A dedicated OCR model should be preferred over a generic vision model for OCR suggestion'); + // Vision-only host: VLM falls back as AI suggestion const visionOnly = quickstartService.suggestModels([ { id: 'llava:13b', capabilities: ['text', 'vision'], state: null, parameterSize: null } @@ -153,4 +169,40 @@ assert.deepStrictEqual( ); assert.strictEqual(quickstartService.normalizeBaseUrls(' '), null, 'Blank input should return null'); +// ── resolveOcrProviderDefault ──────────────────────────────────────────────── +// Regression coverage for issue #236: the OCR dropdown must never be gated on +// the vision name-heuristic (a dedicated OCR model like Mistral's +// `mistral-ocr-latest` matches no vision hint and would classify as +// ['text'] below, exactly like a plain chat model). Only the OCR +// *provider* default is host-based, and only for the one endpoint this +// codebase already special-cases elsewhere (services/setupService.js +// getMistralUrlValidationOptions). + +assert.deepStrictEqual( + quickstartService.classifyModelName('mistral-ocr-latest'), + ['text'], + 'A dedicated OCR model with an unfamiliar name must not be silently excluded from the OCR dropdown just because it fails the vision heuristic' +); + +assert.strictEqual( + quickstartService.resolveOcrProviderDefault('https://api.mistral.ai'), + 'mistral', + 'The detected Mistral API host should default the OCR provider to the dedicated Mistral OCR path' +); +assert.strictEqual( + quickstartService.resolveOcrProviderDefault('https://api.mistral.ai/v1'), + 'mistral', + 'A versioned Mistral URL should still resolve to the mistral OCR provider default' +); +assert.strictEqual( + quickstartService.resolveOcrProviderDefault('http://192.168.1.5:1234'), + 'custom', + 'A local/non-Mistral host should default to the custom (chat-completions) OCR provider' +); +assert.strictEqual( + quickstartService.resolveOcrProviderDefault(''), + 'custom', + 'A blank host should fall back to the custom OCR provider default' +); + console.log('✅ test-quickstart-model-classification passed'); diff --git a/tests/test-setup-preset-manual-reset.js b/tests/test-setup-preset-manual-reset.js new file mode 100644 index 00000000..e0aa21f8 --- /dev/null +++ b/tests/test-setup-preset-manual-reset.js @@ -0,0 +1,214 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const setupJsPath = path.join(__dirname, '..', 'public', 'js', 'setup.js'); + +function createMockClassList() { + const classes = new Set(); + return { + add: (...items) => items.forEach((item) => classes.add(item)), + remove: (...items) => items.forEach((item) => classes.delete(item)), + toggle: (item, force) => { + if (force === undefined ? !classes.has(item) : force) { + classes.add(item); + return true; + } + classes.delete(item); + return false; + }, + contains: (item) => classes.has(item) + }; +} + +function createMockElement(id) { + const listeners = {}; + return { + id, + value: '', + checked: false, + disabled: false, + textContent: '', + innerHTML: '', + className: '', + style: {}, + dataset: {}, + classList: createMockClassList(), + appendChild: () => {}, + addEventListener: (event, handler) => { + if (!listeners[event]) { + listeners[event] = []; + } + listeners[event].push(handler); + }, + dispatchEvent: (eventName) => { + (listeners[eventName] || []).forEach((fn) => fn()); + }, + focus: () => {}, + select: () => {}, + setAttribute: () => {}, + removeAttribute: () => {} + }; +} + +const elementIds = [ + 'setupWizardForm', + 'setupProgressFill', + 'setupStepLabel', + 'adminUsername', + 'adminPassword', + 'confirmPassword', + 'passwordHint', + 'enableMfa', + 'mfaSetupPanel', + 'startMfaSetupBtn', + 'mfaProvisioningBox', + 'setupMfaQrImage', + 'setupMfaSecret', + 'setupMfaCode', + 'confirmMfaCodeBtn', + 'mfaStatusHint', + 'paperlessUrl', + 'paperlessUsername', + 'paperlessToken', + 'testPaperlessBtn', + 'paperlessTestState', + 'fetchMetadataBtn', + 'metadataLoadState', + 'documentsCount', + 'correspondentsCount', + 'tagsCount', + 'scanAllDocuments', + 'includeTag', + 'excludeTagInput', + 'addExcludeTagBtn', + 'excludeTagsContainer', + 'processedTag', + 'excludeProcessedTagBtn', + 'automaticScanEnabled', + 'scanInterval', + 'paperlessTagsDatalist', + 'aiPreset', + 'aiPresetHint', + 'aiProvider', + 'aiApiUrl', + 'aiToken', + 'aiModel', + 'fetchAiModelsBtn', + 'aiValidationTimeout', + 'testAiBtn', + 'aiTestState', + 'aiModeQuickstartBtn', + 'aiModeManualBtn', + 'aiQuickstartPanel', + 'aiManualPanel', + 'quickstartBaseUrl', + 'quickstartApiKey', + 'quickstartDetectBtn', + 'quickstartDetectState', + 'quickstartHint', + 'quickstartAiModel', + 'quickstartOcrModel', + 'quickstartEnableOcr', + 'quickstartOcrHint', + 'quickstartSaveRow', + 'quickstartSaveBtn', + 'ocrQuickstartNotice', + 'mistralOcrEnabled', + 'mistralFields', + 'ocrProvider', + 'ocrApiUrl', + 'ocrApiUrlContainer', + 'ocrApiKeyContainer', + 'ocrApiKey', + 'mistralOcrModel', + 'fetchOcrModelsBtn', + 'ocrValidationTimeout', + 'testOcrBtn', + 'ocrTestState', + 'envPreview', + 'copyEnvPreviewBtn', + 'finalizeSetupBtn', + 'prevStepBtn', + 'nextStepBtn' +]; + +const elements = new Map(elementIds.map((id) => [id, createMockElement(id)])); + +const steps = Array.from({ length: 7 }, (_unused, index) => ({ + dataset: { stepTitle: `Step ${index + 1}` }, + classList: createMockClassList(), + style: {}, + disabled: false +})); + +global.window = { + __SETUP_BOOTSTRAP__: { config: {}, defaults: {}, aiProviderPresets: [] }, + fetch: async () => ({}) +}; + +global.document = { + addEventListener: (_event, callback) => callback(), + querySelectorAll: (selector) => (selector === '.setup-step' ? steps : []), + querySelector: (selector) => { + if (selector === 'meta[name="csrf-token"]') { + return { getAttribute: () => '' }; + } + return null; + }, + getElementById: (id) => elements.get(id) || null, + createElement: (tagName) => createMockElement(tagName) +}; + +global.Swal = { + fire: async () => ({ isConfirmed: false }), + update: () => {}, + close: () => {} +}; + +global.navigator = { + clipboard: { + writeText: async () => {} + } +}; + +global.Headers = class Headers {}; +global.setInterval = () => 1; +global.clearInterval = () => {}; + +const source = fs.readFileSync(setupJsPath, 'utf8'); +vm.runInThisContext(source, { filename: setupJsPath }); + +const wizard = window.setupWizard; +assert.ok(wizard, 'Setup wizard should initialize'); + +// Manual mode from a clean start already yields 'custom' +assert.strictEqual(wizard.aiProvider.value, 'custom', 'Fresh manual mode should default aiProvider to custom'); + +// Selecting a named preset (e.g. OpenAI) sets the hidden aiProvider field +const openAiPreset = { + id: 'openai', + label: 'OpenAI (ChatGPT)', + provider: 'openai', + apiUrl: 'https://api.openai.com/v1', + model: 'gpt-4o-mini', + tokenPlaceholder: 'sk-...' +}; +wizard.applyPreset(openAiPreset); +assert.strictEqual(wizard.aiProvider.value, 'openai', 'Selecting the OpenAI preset should set aiProvider to openai'); +assert.strictEqual(wizard.aiApiUrl.value, 'https://api.openai.com/v1'); + +// Switching back to "Manual custom configuration" (preset === null) must reset aiProvider +wizard.applyPreset(null); +assert.strictEqual( + wizard.aiProvider.value, + 'custom', + 'Switching back to manual configuration must reset the stale aiProvider value to custom' +); +assert.ok( + wizard.aiPresetHint.textContent.includes('Manual mode'), + 'Manual mode hint should be shown after clearing the preset' +); + +console.log('✅ test-setup-preset-manual-reset passed'); diff --git a/tests/test-setup-wizard-quickstart.js b/tests/test-setup-wizard-quickstart.js index d7f588dd..06f26564 100644 --- a/tests/test-setup-wizard-quickstart.js +++ b/tests/test-setup-wizard-quickstart.js @@ -165,6 +165,32 @@ const detectionResponse = { message: 'Detected LM Studio: 3 models (2 text, 1 vision, 1 embedding).' }; +// Regression fixture for issue #236: a dedicated OCR model (like Mistral's +// mistral-ocr-latest) matches no vision name-heuristic hint, so it only ever +// shows up in textModels, never visionModels. The OCR dropdown must still +// offer it, and a detected api.mistral.ai host must default the OCR +// provider to 'mistral' instead of 'custom'. +const mistralDetectionResponse = { + success: true, + detection: { + flavor: 'openai-compatible', + aiProvider: 'custom', + resolvedAiApiUrl: 'https://api.mistral.ai/v1', + ocrProvider: 'mistral', + resolvedOcrApiUrl: 'https://api.mistral.ai/v1', + models: [ + { id: 'mistral-small-latest', capabilities: ['text'], state: null, source: 'heuristic' }, + { id: 'mistral-ocr-latest', capabilities: ['text'], state: null, source: 'heuristic' } + ], + textModels: ['mistral-small-latest', 'mistral-ocr-latest'], + visionModels: [], + embeddingModels: [], + suggestedAiModel: 'mistral-small-latest', + suggestedOcrModel: null + }, + message: 'Detected an OpenAI-compatible API: 2 models (2 text, 0 vision, 0 embedding).' +}; + let lastFetchUrl = null; let lastFetchBody = null; const fetchedUrls = []; @@ -328,6 +354,26 @@ wizard.quickstartApiKey.value = 'test-key'; assert.ok(fetchedUrls.includes('/api/setup/ai/test'), 'Failing save flow should still run the AI test'); assert.ok(!fetchedUrls.includes('/api/setup/complete'), 'Failing AI test must not finalize setup'); + // Regression for #236: a dedicated OCR model with no vision-heuristic + // match must still be selectable, and a detected Mistral host must + // default the OCR provider to 'mistral' instead of always 'custom'. + responsesByUrl['/api/setup/quickstart/detect'] = mistralDetectionResponse; + wizard.quickstartBaseUrl.value = 'https://api.mistral.ai/v1'; + wizard.quickstartApiKey.value = 'mistral-key'; + await wizard.runQuickstartDetect(); + + assert.strictEqual(wizard.quickstartOcrModel.disabled, false, 'OCR model dropdown must stay enabled even when no model matches the vision name-heuristic, as long as candidate models exist'); + assert.strictEqual(wizard.quickstartEnableOcr.disabled, false, 'Enable-OCR checkbox must stay enabled when any candidate model exists'); + assert.strictEqual(wizard.quickstartEnableOcr.checked, true, 'Enable-OCR checkbox should default to checked when candidate models exist'); + + // No vision-heuristic match means no suggestedOcrModel; the user picks + // the dedicated OCR model manually. + wizard.quickstartOcrModel.value = 'mistral-ocr-latest'; + wizard.applyQuickstartToManualFields(); + + assert.strictEqual(wizard.mistralOcrModel.value, 'mistral-ocr-latest', 'Manually selecting the dedicated OCR model should flow through to the manual OCR model field'); + assert.strictEqual(wizard.ocrProvider.value, 'mistral', 'A detected api.mistral.ai host should default the OCR provider to mistral'); + console.log('✅ test-setup-wizard-quickstart passed'); })().catch((error) => { console.error('❌ test-setup-wizard-quickstart failed:', error.message); diff --git a/views/settings.ejs b/views/settings.ejs index f1e6710b..7512a5e8 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -328,13 +328,17 @@