Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/changelog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 15 additions & 9 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 &&
Expand All @@ -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) {
Expand Down Expand Up @@ -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 || ''
Expand Down
32 changes: 20 additions & 12 deletions public/js/setup.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* global Swal */
class SetupWizard {
constructor() {
this.bootstrap = window.__SETUP_BOOTSTRAP__ || {};
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -979,28 +981,35 @@ 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;
if (detection.suggestedAiModel) {
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) {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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.' });
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion services/azureService.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
extractChatMessageContent
} = require('./serviceUtils');
const axios = require('axios');
const OpenAI = require('openai');

Check failure on line 9 in services/azureService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

'OpenAI' is assigned a value but never used
const AzureOpenAI = require('openai').AzureOpenAI;
const config = require('../config/config');
const paperlessService = require('./paperlessService');
Expand All @@ -14,7 +14,7 @@
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() {
Expand Down Expand Up @@ -47,7 +47,7 @@
try {
await fs.access(cachePath);
console.log('[DEBUG] Thumbnail already cached');
} catch (err) {

Check failure on line 50 in services/azureService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

'err' is defined but never used
console.log('Thumbnail not cached, fetching from Paperless');

const thumbnailData = await paperlessService.getThumbnailImage(id);
Expand Down Expand Up @@ -219,7 +219,7 @@
parsedResponse = JSON.parse(jsonContent);
} catch (error) {
console.error(`Failed to parse JSON response: ${error.message}`); console.debug(error);
throw new Error('Invalid JSON response from API');

Check failure on line 222 in services/azureService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

There is no `cause` attached to the symptom error being thrown
}

try {
Expand Down Expand Up @@ -352,7 +352,7 @@
parsedResponse = JSON.parse(jsonContent);
} catch (error) {
console.error(`Failed to parse JSON response: ${error.message}`); console.debug(error);
throw new Error('Invalid JSON response from API');

Check failure on line 355 in services/azureService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

There is no `cause` attached to the symptom error being thrown
}

// Validate response structure
Expand Down
2 changes: 1 addition & 1 deletion services/customService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@
calculateTokens,
calculateTotalPromptTokens,
truncateToTokenLimit,
writePromptToFile,

Check failure on line 5 in services/customService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

'writePromptToFile' is assigned a value but never used
extractChatMessageContent,
isTimeoutError,
buildTimeoutErrorMessage
} = require('./serviceUtils');
const OpenAI = require('openai');
const config = require('../config/config');
const tiktoken = require('tiktoken');

Check failure on line 12 in services/customService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

'tiktoken' is assigned a value but never used
const paperlessService = require('./paperlessService');
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 {
Expand Down Expand Up @@ -96,10 +96,10 @@
parsed: JSON.parse(sanitizedContent),
normalized: sanitizedContent
};
} catch (_directParseError) {

Check failure on line 99 in services/customService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

'_directParseError' is defined but never used
const extractedJson = this._extractFirstJsonValue(sanitizedContent);
if (!extractedJson) {
throw new Error('Invalid JSON response from API');

Check failure on line 102 in services/customService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

There is no `cause` attached to the symptom error being thrown
}

try {
Expand All @@ -107,8 +107,8 @@
parsed: JSON.parse(extractedJson),
normalized: extractedJson
};
} catch (_extractedParseError) {

Check failure on line 110 in services/customService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

'_extractedParseError' is defined but never used
throw new Error('Invalid JSON response from API');

Check failure on line 111 in services/customService.js

View workflow job for this annotation

GitHub Actions / Lint & format (changed files)

There is no `cause` attached to the symptom error being thrown
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion services/openaiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
31 changes: 27 additions & 4 deletions services/quickstartService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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. `
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion services/serviceUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
63 changes: 63 additions & 0 deletions tests/test-native-install-log-paths.js
Original file line number Diff line number Diff line change
@@ -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');
Loading