From 26ff1639ac51a437e8e36579f648c3edbd3d6b43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:40:58 +0000 Subject: [PATCH 1/6] fix: reset hidden aiProvider when switching to manual AI configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background: Selecting a named AI provider preset (e.g. "OpenAI (ChatGPT)") in Step 5 of the setup wizard sets the hidden `aiProvider` input to that preset's provider. Switching back to "Manual custom configuration" only updated the hint text and left the stale provider value in place. Clicking "Test AI connection" then sent the stale provider (e.g. `openai`) alongside the user's custom URL/key/model. The backend dispatches purely on `aiProvider`, so `validateOpenAIConfig` ran with no `baseURL` and always hit api.openai.com, ignoring the custom endpoint entirely and returning a confusing 401 that references OpenAI's own docs. Changes: - `public/js/setup.js` `applyPreset()`: reset `aiProvider.value` to `'custom'` in the `if (!preset)` branch (manual mode) before updating the hint text. - Also cleaned up three pre-existing lint issues in the same file so the full file passes ESLint per this repo's changed-files CI gate: a missing `/* global Swal */` directive (SweetAlert2 is loaded via a script tag, not a standard global), an unused `catch (_error)` binding, and a stale `eslint-disable-next-line no-await-in-loop` comment for a rule that isn't enabled in this config. Testing: - Added `tests/test-setup-preset-manual-reset.js`: selects a named preset, then applies `null` (manual mode), and asserts `aiProvider` resets to `custom`. Registered in `scripts/run-tests.js`. - `node scripts/run-tests.js --all`: 43 passed, 7 skipped (server-dependent), 0 failed. - `npx eslint public/js/setup.js tests/test-setup-preset-manual-reset.js scripts/run-tests.js`: clean. Note: `npx prettier --check public/js/setup.js` still fails — this file predates the repo's Prettier/ESLint CI gate (added in 918801b, after this file's last edit) and has never been reformatted. A full reformat would touch ~4000 unrelated lines, so it was intentionally left out of this focused bugfix per discussion with the repo maintainer; a separate repo-wide formatting pass is a better fit for that. Impact: Any user who explores the preset dropdown and then switches to manual configuration for a custom OpenAI-compatible endpoint (Mistral, OpenRouter, DeepSeek, self-hosted vLLM, etc.) will now have the AI connection test actually exercise their configured endpoint instead of silently falling back to OpenAI's default endpoint. Upstream Status: new fix, not yet upstreamed to clusterzx/paperless-ai. Closes #235 --- public/js/setup.js | 5 +- scripts/run-tests.js | 2 + tests/test-setup-preset-manual-reset.js | 214 ++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tests/test-setup-preset-manual-reset.js diff --git a/public/js/setup.js b/public/js/setup.js index 0666f961..32268e85 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; } @@ -1665,7 +1667,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 +1709,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..4a893bfb 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -46,6 +46,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', @@ -126,6 +127,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/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'); From 7a6623d42bf6552126584a9f41eae40aca0dc84a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:44:33 +0000 Subject: [PATCH 2/6] fix: resolve AI response/prompt log paths relative to process.cwd() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background: services/openaiService.js, customService.js, and azureService.js each hardcode `path.join('/app', 'data', 'logs', 'response.txt')` for AI response logging, and services/serviceUtils.js's writePromptToFile() defaults its filePath parameter to the literal '/app/data/logs/prompt.txt'. These paths only exist inside the Docker image (Dockerfile.base sets WORKDIR /app). On a native/bare-metal install — explicitly documented as supported in the README, e.g. a systemd service or LXC container running from /opt/paperless-ai-next — process.cwd() is the app directory, not /app, so fs.mkdir('/app', ...) fails with ENOENT on every single document processed. The error is caught and logged as a warning, so processing still completes, but response.txt and prompt.txt are silently never written, and the journal fills up with the same repeated warning. Changes: - services/openaiService.js, customService.js, azureService.js: replaced the hardcoded `/app` segment with `process.cwd()` in the responseLogPath constant. - services/serviceUtils.js: writePromptToFile()'s default filePath now resolves to `path.join(process.cwd(), 'data', 'logs', 'prompt.txt')`. - This matches how config/config.js, models/document.js, and the rest of the codebase already resolve data/ paths, so Docker behavior (WORKDIR /app) is unchanged. Testing: - Added tests/test-native-install-log-paths.js: a static source-text check (fs.readFileSync + string assertions, no app boot required, following the existing convention in tests/test-history-xss-hardening.js) asserting none of the four files hardcode '/app' for these log paths and all resolve via process.cwd(). Registered in scripts/run-tests.js (observability area). - node scripts/run-tests.js --all: 43 passed, 7 skipped (server-dependent), 0 failed. - npx eslint/prettier clean on the new test file and scripts/run-tests.js. - node scripts/regen-openapi.js produces no diff (no API surface change). Note: openaiService.js, customService.js, and azureService.js already fail `npx eslint` with 16 pre-existing errors unrelated to this change (unused imports/vars, and a `preserve-caught-error` rule about missing `cause` on rethrown errors) — confirmed present on origin/main before this commit. Per discussion with the repo maintainer, those are left untouched here since fixing the `cause`-chain violations would mean touching real error-handling logic across all three files, well outside this fix's scope; a separate cleanup PR is a better fit. Impact: Native/bare-metal installs (systemd, LXC, etc.) now get working AI response and prompt logs, matching Docker behavior, and stop spamming the journal with the same mkdir warning on every processed document. Upstream Status: new fix, not yet upstreamed to clusterzx/paperless-ai. Closes #237 --- scripts/run-tests.js | 7 ++- services/azureService.js | 2 +- services/customService.js | 2 +- services/openaiService.js | 2 +- services/serviceUtils.js | 2 +- tests/test-native-install-log-paths.js | 63 ++++++++++++++++++++++++++ 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 tests/test-native-install-log-paths.js diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 0a626e3a..8a22a783 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', @@ -102,7 +103,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', 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/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'); From 420ad7e1bd5a8c1e8b102dea4950cc337c9db69f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:51:14 +0000 Subject: [PATCH 3/6] fix: stop gating Quickstart's OCR model dropdown on the vision heuristic Background: Quickstart auto-detect classifies models by name heuristics only (services/quickstartService.js classifyModelName()), and the "Suggested OCR model" dropdown/checkbox in Step 5 was gated entirely on the resulting `visionModels` list. A dedicated OCR model with an unfamiliar name - e.g. Mistral's `mistral-ocr-latest` - matches none of the hardcoded vision hints (llava, pixtral, vision, gemma3, ...), so it classifies as `['text']` and never appears in that list. The dropdown showed "No vision-capable models found" and the "enable OCR" checkbox was disabled, even though the model exists and works - forcing users into manual configuration for any provider whose naming doesn't match the hint list. detectAndClassify() also always returned `ocrProvider: 'custom'`, never the `'mistral'` OCR provider that manual setup already fully supports (services/setupService.js validateOcrConfig, and the "Mistral OCR API" option in the always-visible "OCR fallback" wizard step). We considered parsing the `capabilities` object some OpenAI-compatible `/v1/models` responses include (Mistral does; LM Studio, Ollama, and plain OpenAI-compatible servers don't) to classify models more accurately, but rejected it: that's an undocumented, vendor-specific extension, not part of the OpenAI /v1/models spec, and would only special-case one provider while leaving every other naming scheme on the same guesswork - the actual underlying problem. Changes: - services/quickstartService.js: added `resolveOcrProviderDefault(url)`, a pure host-string check (mirrors the existing api.mistral.ai check in setupService.getMistralUrlValidationOptions) used in detectAndClassify() to default `ocrProvider` to `'mistral'` when the detected host is api.mistral.ai, `'custom'` otherwise. No classification logic changed - classifyModelName/classifyLmStudioEntry/classifyOllamaShowPayload are untouched. - public/js/setup.js runQuickstartDetect(): the OCR dropdown and "enable OCR" checkbox are now driven by the same non-embedding candidate list as the AI dropdown (`textModels` - every model is already classified as exactly one of ['embedding'] / ['text'] / ['text','vision'], so "has text capability" already means "not embedding-only"), instead of the vision-heuristic-filtered `visionModels`. A heuristic `suggestedOcrModel` is still pre-selected when available; when it isn't, the user picks from the full list themselves rather than seeing an empty/disabled dropdown. - public/js/setup.js applyQuickstartToManualFields(): uses `detection.ocrProvider` (the host-based default above) instead of a hardcoded `'custom'` literal. - views/setup.ejs: updated the dropdown label from "Suggested OCR model (vision-capable)" to "OCR model", since it's no longer filtered to heuristically vision-classified models. - A wrong provider default is never a dead end: the "OCR fallback" wizard step (its own always-visible step, not nested under the Quickstart/Manual AI toggle) lets the user change the OCR provider dropdown themselves before finishing setup regardless. - Cleaned up the same three pre-existing ESLint issues in setup.js as PR #238 (missing `/* global Swal */`, unused `catch (_error)` binding, stale `no-await-in-loop` disable comment) so the full file passes this repo's changed-files ESLint gate. Testing: - tests/test-quickstart-model-classification.js: added classifyModelName('mistral-ocr-latest') === ['text'] (documents why the OCR dropdown must not gate on the vision heuristic) and resolveOcrProviderDefault() cases for a Mistral host, a versioned Mistral URL, a local/non-Mistral host, and a blank host. - tests/test-setup-wizard-quickstart.js: added a second detection fixture (openai-compatible, api.mistral.ai, a model present only in textModels) asserting the OCR dropdown/checkbox stay enabled, manually selecting the dedicated OCR model flows through to the manual OCR model field, and the OCR provider defaults to 'mistral'. Verified this new assertion actually fails without the fix (reverted the source changes locally, confirmed the test catches the regression, then restored). - node scripts/run-tests.js --all: 42 passed, 7 skipped (server-dependent), 0 failed. - npx eslint clean on all changed JS files. - node scripts/regen-openapi.js produces no diff (no API surface change - the quickstart/detect response gains no new field, `ocrProvider`'s value just changes). Note: as in PR #238/#239, npx prettier --check still fails on setup.js/quickstartService.js/the two test files - all pre-existing formatting drift confirmed present on origin/main before this change, intentionally left untouched per the same discussion with the repo maintainer. Impact: Any OpenAI-compatible provider whose model names don't match the hardcoded vision hints (Mistral's OCR models, and any future differently-named dedicated OCR/vision model from any provider) can now be selected as the OCR model through Quickstart instead of forcing manual configuration. Detecting Mistral's own API additionally pre-selects the dedicated Mistral OCR provider path instead of the generic chat-completions path, which doesn't work for Mistral's non-chat OCR models (`completion_chat: false`). Upstream Status: new fix, not yet upstreamed to clusterzx/paperless-ai. Closes #236 --- public/js/setup.js | 31 ++++++++----- services/quickstartService.js | 15 ++++-- tests/test-quickstart-model-classification.js | 36 +++++++++++++++ tests/test-setup-wizard-quickstart.js | 46 +++++++++++++++++++ views/setup.ejs | 2 +- 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/public/js/setup.js b/public/js/setup.js index 0666f961..e2c634c7 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__ || {}; @@ -979,7 +980,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 +995,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 +1074,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 +1673,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 +1715,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/services/quickstartService.js b/services/quickstartService.js index 3cd9ba2c..caee32f0 100644 --- a/services/quickstartService.js +++ b/services/quickstartService.js @@ -181,6 +181,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 +382,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/tests/test-quickstart-model-classification.js b/tests/test-quickstart-model-classification.js index 419e0927..694a0851 100644 --- a/tests/test-quickstart-model-classification.js +++ b/tests/test-quickstart-model-classification.js @@ -153,4 +153,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-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/setup.ejs b/views/setup.ejs index bf940850..ac653213 100644 --- a/views/setup.ejs +++ b/views/setup.ejs @@ -265,7 +265,7 @@
- + From 6c82268bb630b15a5156eb19a4015ce76c0c80ea Mon Sep 17 00:00:00 2001 From: Aaron Viehl Date: Sat, 18 Jul 2026 16:32:57 +0200 Subject: [PATCH 4/6] fix: suggest dedicated OCR models and fix Settings quickstart parity Background: PR #236 fixed the Setup Wizard's Quickstart OCR model dropdown to stop gating on the vision name-heuristic, since dedicated OCR models (e.g. Mistral's mistral-ocr-latest) classify as plain ['text'] and never matched the hardcoded vision hints. Two gaps remained: 1. quickstartService.suggestModels() still only considered visionCandidates when computing suggestedOcrModel, so even with the dropdown showing every text-capable model, nothing was ever pre-selected for hosts like api.mistral.ai - the dropdown looked empty/unhelpful in practice even though it wasn't. 2. The Settings page (views/settings.ejs / public/js/settings.js) has its own, separate copy of the Quickstart detect-and-apply flow that PR #236 never touched. It still filtered the OCR dropdown by visionModels ("No vision-capable models found") and hardcoded ocrProvider to 'custom' when applying quickstart results to the manual OCR fields - the exact bugs #236 fixed in the Setup Wizard. Changes: - services/quickstartService.js: added ocrNameHints (['ocr']) and ocrNameCandidates in suggestModels(); OCR-named text models now take priority over generic vision models when suggesting suggestedOcrModel, instead of only ever considering visionCandidates. - public/js/settings.js: quickstart OCR dropdown/checkbox now driven by textModels (aliased ocrCandidateModels) instead of visionModels, mirroring public/js/setup.js. Applying quickstart results to the manual OCR fields now uses quickstartDetection.ocrProvider instead of a hardcoded 'custom' literal. - views/settings.ejs: updated the OCR dropdown label from "Suggested OCR model (vision-capable)" to "OCR model", matching setup.ejs. Testing: - tests/test-quickstart-model-classification.js: added suggestModels() cases for a dedicated OCR-named model with no vision classification, and for an OCR-named model taking priority over a generic vision model. - node scripts/run-tests.js --all: 47 passed, 3 skipped (server-dependent), 1 failed (rate-limiting - pre-existing, server-dependent, unrelated to this change). - npx eslint clean on all changed JS files. - node scripts/regen-openapi.js produces no diff. Impact: Mistral (and any other provider with dedicated, non-vision-named OCR models) now gets a correct default OCR model suggestion in both the Setup Wizard and the Settings page's Quickstart flow, and the OCR provider defaults to 'mistral' instead of 'custom' in both places when the detected host is api.mistral.ai. Upstream Status: new fix, not yet upstreamed to clusterzx/paperless-ai. --- public/js/settings.js | 24 ++++++++++++------- services/quickstartService.js | 16 ++++++++++++- tests/test-quickstart-model-classification.js | 16 +++++++++++++ views/settings.ejs | 2 +- 4 files changed, 47 insertions(+), 11 deletions(-) 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/services/quickstartService.js b/services/quickstartService.js index caee32f0..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; diff --git a/tests/test-quickstart-model-classification.js b/tests/test-quickstart-model-classification.js index 694a0851..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 } diff --git a/views/settings.ejs b/views/settings.ejs index f1e6710b..fcf3c2c0 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -328,7 +328,7 @@
- +
From a706fa1a052b036e153554fc7cdefeddb6e8aae1 Mon Sep 17 00:00:00 2001 From: Aaron Viehl Date: Sat, 18 Jul 2026 17:47:32 +0200 Subject: [PATCH 5/6] polish: turn Quickstart's OCR checkbox into a real toggle switch Background: The Quickstart "also configure OCR" checkbox was a bare + text label pair, inconsistent with the rest of the settings UI, which already has an established on/off toggle visual (see externalApiEnabled in views/settings.ejs) using a sr-only-peer checkbox driving a pill-shaped slider via Tailwind peer-checked/peer-disabled variants. Changes: - views/setup.ejs and views/settings.ejs: quickstartEnableOcr / settingsQuickstartEnableOcr now render as that same toggle-switch pattern instead of a plain checkbox. Element ids are unchanged, so the existing JS (public/js/setup.js, public/js/settings.js) that reads .checked/.disabled on these elements needed no changes. - Relabeled from "Also configure OCR fallback with this endpoint" to "Use this service for OCR" (shorter, matches how the rest of the Quickstart panel refers to "this" detected endpoint). Testing: - npx eslint clean (views/*.ejs are outside ESLint's scope; the two JS files that read these elements are unaffected). - node scripts/run-tests.js --test setup-wizard-quickstart: passed. - Verified rendering by starting the dev server and curling http://localhost:3000/setup - the toggle markup renders as written, no EJS errors. /settings sits behind auth so it wasn't curled directly, but its markup is identical to the verified setup.ejs block. Impact: Purely visual/label change; no behavior change to the Quickstart detect-and-apply flow itself. Upstream Status: new fix, not yet upstreamed to clusterzx/paperless-ai. --- views/settings.ejs | 10 +++++++--- views/setup.ejs | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/views/settings.ejs b/views/settings.ejs index fcf3c2c0..7512a5e8 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -332,9 +332,13 @@ -
- - +
+ +
-
- - +
+ +