fix: make OCR portable and reliable across AMD64 and ARM64 (#519)

* fix: make OCR portable and reliable

* fix: harden OCR installation portability

* fix: pin OCR partials across downloads

* fix: make OCR execution reliably asynchronous

* fix: harden OCR portability and docs routes

* fix: preserve decoder and docs safeguards
This commit is contained in:
SnapOtter
2026-07-15 03:34:24 +08:00
committed by GitHub
parent 58121f205f
commit 991c981529
409 changed files with 67151 additions and 8076 deletions
@@ -73,6 +73,7 @@ export function AiFeaturesSection() {
resetError,
} = useFeaturesStore();
const [diskUsage, setDiskUsage] = useState<number | null>(null);
const installableBundles = bundles.filter((bundle) => bundle.compatibility !== "incompatible");
const loadDiskUsage = useCallback(async () => {
try {
@@ -108,7 +109,9 @@ export function AiFeaturesSection() {
<button
type="button"
onClick={installAll}
disabled={installAllActive || bundles.every((b) => b.status === "installed")}
disabled={
installAllActive || installableBundles.every((bundle) => bundle.status === "installed")
}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
@@ -243,18 +246,31 @@ function ResetEnvironmentSection({
function ImportBundleSection({ onImported }: { onImported: () => void }) {
const { t } = useTranslation();
const [mode, setMode] = useState<"ocr" | "legacy">("ocr");
const [importing, setImporting] = useState(false);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
null,
);
const fileRef = useRef<HTMLInputElement>(null);
const [ocrIndex, setOcrIndex] = useState<File | null>(null);
const [ocrArchive, setOcrArchive] = useState<File | null>(null);
const [legacyArchive, setLegacyArchive] = useState<File | null>(null);
const indexRef = useRef<HTMLInputElement>(null);
const ocrArchiveRef = useRef<HTMLInputElement>(null);
const legacyArchiveRef = useRef<HTMLInputElement>(null);
const canImport = mode === "ocr" ? !!ocrIndex && !!ocrArchive : !!legacyArchive;
const handleImport = async (file: File) => {
const handleImport = async () => {
if (!canImport) return;
setImporting(true);
setFeedback(null);
const formData = new FormData();
formData.append("file", file);
if (mode === "ocr" && ocrIndex && ocrArchive) {
formData.append("index", ocrIndex);
formData.append("archive", ocrArchive);
} else if (mode === "legacy" && legacyArchive) {
formData.append("file", legacyArchive);
}
try {
const res = await fetch("/api/v1/admin/features/import", {
@@ -269,6 +285,12 @@ function ImportBundleSection({ onImported }: { onImported: () => void }) {
}
setFeedback({ type: "success", message: t.settings.aiFeatures.importSuccess });
setOcrIndex(null);
setOcrArchive(null);
setLegacyArchive(null);
for (const input of [indexRef.current, ocrArchiveRef.current, legacyArchiveRef.current]) {
if (input) input.value = "";
}
onImported();
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
@@ -278,7 +300,6 @@ function ImportBundleSection({ onImported }: { onImported: () => void }) {
});
} finally {
setImporting(false);
if (fileRef.current) fileRef.current.value = "";
}
};
@@ -292,21 +313,84 @@ function ImportBundleSection({ onImported }: { onImported: () => void }) {
{t.settings.aiFeatures.importDescription}
</p>
</div>
<fieldset className="space-y-2">
<legend className="text-xs font-medium text-foreground">
{t.settings.aiFeatures.importType}
</legend>
<div className="flex flex-wrap gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="radio"
name="ai-bundle-import-type"
value="ocr"
checked={mode === "ocr"}
onChange={() => {
setMode("ocr");
setFeedback(null);
}}
/>
{t.settings.aiFeatures.importOcr}
</label>
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="radio"
name="ai-bundle-import-type"
value="legacy"
checked={mode === "legacy"}
onChange={() => {
setMode("legacy");
setFeedback(null);
}}
/>
{t.settings.aiFeatures.importLegacy}
</label>
</div>
</fieldset>
{mode === "ocr" ? (
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1 text-xs font-medium text-foreground">
<span>{t.settings.aiFeatures.importOcrIndex}</span>
<input
ref={indexRef}
type="file"
accept=".json,application/json"
disabled={importing}
onChange={(event) => setOcrIndex(event.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-muted-foreground file:me-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs file:font-medium file:text-foreground"
/>
</label>
<label className="space-y-1 text-xs font-medium text-foreground">
<span>{t.settings.aiFeatures.importOcrArchive}</span>
<input
ref={ocrArchiveRef}
type="file"
accept=".tar.gz,.tgz,application/gzip"
disabled={importing}
onChange={(event) => setOcrArchive(event.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-muted-foreground file:me-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs file:font-medium file:text-foreground"
/>
</label>
</div>
) : (
<label className="block space-y-1 text-xs font-medium text-foreground">
<span>{t.settings.aiFeatures.importLegacyArchive}</span>
<input
ref={legacyArchiveRef}
type="file"
accept=".tar.gz,.tgz,application/gzip"
disabled={importing}
onChange={(event) => setLegacyArchive(event.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-muted-foreground file:me-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs file:font-medium file:text-foreground"
/>
</label>
)}
<div className="flex items-center gap-3">
<input
ref={fileRef}
type="file"
accept=".tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImport(file);
}}
/>
<button
type="button"
disabled={importing}
onClick={() => fileRef.current?.click()}
disabled={importing || !canImport}
onClick={handleImport}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{importing ? (
@@ -319,6 +403,8 @@ function ImportBundleSection({ onImported }: { onImported: () => void }) {
</div>
{feedback && (
<p
role={feedback.type === "error" ? "alert" : "status"}
aria-live="polite"
className={`text-xs ${feedback.type === "success" ? "text-green-600 dark:text-green-400" : "text-destructive"}`}
>
{feedback.message}
@@ -356,6 +442,8 @@ function BundleCard({
}) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const incompatible = bundle.compatibility === "incompatible";
const displayedError = error ?? bundle.error;
const [messageIndex, setMessageIndex] = useState(() =>
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
);
@@ -406,7 +494,7 @@ function BundleCard({
</span>
</>
)}
{status === "not_installed" && !error && (
{status === "not_installed" && !displayedError && (
<>
<span className="bg-muted-foreground rounded-full h-2 w-2" />
<span className="text-xs text-muted-foreground">
@@ -428,17 +516,17 @@ function BundleCard({
<span className="text-xs text-muted-foreground">{progress.percent}%</span>
</>
)}
{(status === "error" || error) && (
{(status === "error" || displayedError) && (
<>
<span className="bg-destructive rounded-full h-2 w-2" />
<span className="text-xs text-destructive truncate max-w-[120px]">
{error ?? bundle.error}
{displayedError}
</span>
</>
)}
</div>
{status === "not_installed" && !error && (
{status === "not_installed" && !displayedError && !incompatible && (
<button
type="button"
onClick={onInstall}
@@ -500,16 +588,19 @@ function BundleCard({
{t.settings.aiFeatures.installing}
</button>
)}
{(status === "error" || error) && !isInstalling && !isQueued && (
<button
type="button"
onClick={onInstall}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
{t.common.retry}
</button>
)}
{(status === "error" || displayedError) &&
!isInstalling &&
!isQueued &&
!incompatible && (
<button
type="button"
onClick={onInstall}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
{t.common.retry}
</button>
)}
</div>
</div>
{status === "installing" && progress && (
@@ -124,7 +124,7 @@ function useNavItems() {
id: "ai-features",
label: t.settings.nav.aiFeatures,
icon: Sparkles,
requiredPermission: "settings:write",
requiredPermission: "features:manage",
},
{
id: "tools",
@@ -37,13 +37,16 @@ interface ProgressHandlers {
* flaky network, proxy buffering) the UI hung forever at the last percent
* (~25%) even though the backend job had finished and saved its result.
*
* This reconnects on tab refocus -- the progress endpoint replays the
* terminal frame from its 10-minute Redis cache, so a job that completed
* while SSE was dead still resolves -- and arms a stall timeout that fails
* gracefully instead of hanging. Returns a cleanup the caller must invoke on
* sync completion, error, or unmount.
* This reconnects on tab refocus -- the progress endpoint replays the terminal
* frame from Redis and, after that cache expires, from the durable job record,
* so a job that completed while SSE was dead still resolves -- and arms a stall
* timeout that fails gracefully instead of hanging. Returns a cleanup the
* caller must invoke on sync completion, error, or unmount.
*/
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
export function subscribeEraseObjectJobProgress(
clientJobId: string,
handlers: ProgressHandlers,
): () => void {
let es: EventSource | null = null;
let stall: ReturnType<typeof setTimeout> | null = null;
let done = false;
@@ -84,6 +87,10 @@ function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers):
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "heartbeat") {
resetStall();
return;
}
if (data.type !== "single") return;
resetStall();
if (data.phase === "complete" && data.result) {
@@ -174,7 +181,7 @@ export function EraseObjectSettings({
});
};
const stopProgress = subscribeJobProgress(clientJobId, {
const stopProgress = subscribeEraseObjectJobProgress(clientJobId, {
onProgress,
onComplete: (r) => {
applyResult(r);
@@ -283,7 +290,7 @@ export function EraseObjectSettings({
setProgressStage(null);
};
const stopProgress = subscribeJobProgress(clientJobId, {
const stopProgress = subscribeEraseObjectJobProgress(clientJobId, {
onProgress: (percent) => {
setProgressPhase("processing");
setProgressPercent(15 + (percent / 100) * 85);
@@ -329,7 +336,7 @@ export function EraseObjectSettings({
setProgressPercent(15);
};
xhr.onload = () => {
// 202 = async: subscribeJobProgress drives completion via SSE.
// 202 = async: the progress subscription drives completion via SSE.
if (xhr.status === 202) return;
stopProgress();
@@ -1,16 +1,11 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const QUALITY_OPTIONS = [
{ value: "fast", label: "Fast" },
{ value: "balanced", label: "Balanced" },
{ value: "best", label: "Best" },
] as const;
import { OcrQualityControl, useOcrQuality } from "./ocr-quality-control";
const LANGUAGE_OPTIONS = [
{ value: "auto", labelKey: "autoDetect" },
@@ -29,16 +24,22 @@ export function OcrPdfSettings() {
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("ocr-pdf");
const [quality, setQuality] = useState("balanced");
const [language, setLanguage] = useState("auto");
const { quality, setQuality, canRun } = useOcrQuality(language);
const [pages, setPages] = useState("all");
const [enhance, setEnhance] = useState(quality === "best");
const [enhanceManuallySet, setEnhanceManuallySet] = useState(false);
useEffect(() => {
if (!enhanceManuallySet) setEnhance(quality === "best");
}, [enhanceManuallySet, quality]);
const ts = t.toolSettings["ocr-pdf"];
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { quality, language, pages };
const settings = { quality, language, pages, enhance };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
@@ -58,24 +59,25 @@ export function OcrPdfSettings() {
<label htmlFor="ocrpdf-quality" className="mb-1.5 block text-sm font-medium">
{ts.quality}
</label>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={`rounded-lg border px-2 py-2 text-xs font-medium transition-colors ${
quality === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{opt.label}
</button>
))}
</div>
<OcrQualityControl quality={quality} language={language} onChange={setQuality} />
</div>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={enhance}
onChange={(event) => {
setEnhance(event.target.checked);
setEnhanceManuallySet(true);
}}
className="rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground">
{t.toolSettings.ocr.enhanceBeforeScanning}
</span>
<span className="sr-only">{t.toolSettings.ocr.enhanceHint}</span>
</label>
{/* Language */}
<div>
<label htmlFor="ocrpdf-language" className="mb-1.5 block text-sm font-medium">
@@ -124,7 +126,7 @@ export function OcrPdfSettings() {
) : (
<button
type="submit"
disabled={!hasFile || processing}
disabled={!hasFile || processing || !canRun}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted w-full rounded-md px-4 py-2 text-sm font-medium disabled:cursor-not-allowed"
>
{hasMultiple ? format(ts.submitBatch, { count: files.length }) : ts.submit}
@@ -0,0 +1,149 @@
import { Download, Loader2 } from "lucide-react";
import { useCallback, useId, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { format, formatFileSize } from "@/lib/format";
import { useFeaturesStore } from "@/stores/features-store";
export type OcrQuality = "fast" | "balanced" | "best";
export function useOcrQuality(language = "auto"): {
quality: OcrQuality;
setQuality: (quality: OcrQuality) => void;
canRun: boolean;
} {
const [selection, setSelection] = useState<{
language: string;
quality: OcrQuality;
} | null>(null);
const bundle = useFeaturesStore((state) => state.bundles.find((item) => item.id === "ocr"));
const bestAvailable = bundle?.availableQualities?.includes("best") ?? false;
const balancedAvailable = bundle?.availableQualities?.includes("balanced") ?? false;
const korean = language === "ko";
const selected = selection?.language === language ? selection.quality : null;
const accurateDefault: OcrQuality = bestAvailable
? "best"
: balancedAvailable
? "balanced"
: "best";
const ordinaryDefault: OcrQuality = bestAvailable
? "best"
: balancedAvailable
? "balanced"
: "fast";
const quality = korean
? selected && selected !== "fast"
? selected
: accurateDefault
: (selected ?? ordinaryDefault);
const canRun =
(!korean || quality !== "fast") &&
(quality === "fast" || (bundle?.availableQualities?.includes(quality) ?? false));
const setQuality = useCallback(
(nextQuality: OcrQuality) => {
if (korean && nextQuality === "fast") return;
setSelection({ language, quality: nextQuality });
},
[korean, language],
);
return { quality, setQuality, canRun };
}
export function OcrQualityControl({
quality,
language = "auto",
onChange,
}: {
quality: OcrQuality;
language?: string;
onChange: (quality: OcrQuality) => void;
}) {
const { t } = useTranslation();
const { hasPermission } = useAuth();
const bundle = useFeaturesStore((state) => state.bundles.find((item) => item.id === "ocr"));
const installBundle = useFeaturesStore((state) => state.installBundle);
const installing = useFeaturesStore((state) => state.installing.ocr);
const queued = useFeaturesStore((state) => state.queued.includes("ocr"));
const installError = useFeaturesStore((state) => state.errors.ocr);
const helpId = useId();
const korean = language === "ko";
const accurateSelected = quality !== "fast";
const accurateAvailable = bundle?.availableQualities?.includes(quality) ?? false;
const needsPack = accurateSelected && !accurateAvailable;
const isAdmin = hasPermission("features:manage");
const incompatible = bundle?.compatibility === "incompatible";
const bytes = bundle?.missingDownloadBytes ?? bundle?.downloadBytes;
const size = bytes
? formatFileSize(bytes)
: (bundle?.estimatedSize ?? "~208-234 MiB download / ~409-488 MiB installed");
const qualityTranslations = t.toolSettings["remove-gif-background"];
const qualityOptions: { value: OcrQuality; label: string }[] = [
{ value: "fast", label: qualityTranslations.qualityFast },
{ value: "balanced", label: qualityTranslations.qualityBalanced },
{ value: "best", label: qualityTranslations.qualityBest },
];
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-1.5">
{qualityOptions.map((option) => {
const fastUnsupported = korean && option.value === "fast";
return (
<button
key={option.value}
type="button"
aria-pressed={quality === option.value}
aria-describedby={fastUnsupported ? helpId : undefined}
disabled={fastUnsupported}
onClick={() => onChange(option.value)}
className={`rounded-lg border px-2 py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
quality === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{option.label}
</button>
);
})}
</div>
{korean && (
<p id={helpId} className="text-xs text-muted-foreground">
{t.toolSettings.ocr.fastKoreanUnsupported}
</p>
)}
{needsPack && (
<div className="rounded-lg border border-border bg-muted/40 p-3 text-start">
<p className="text-xs text-muted-foreground">
{incompatible
? (bundle?.error ?? bundle?.compatibilityReason ?? t.features.notEnabledDescription)
: format(t.features.requiresDownload, { size })}
</p>
{!incompatible && isAdmin && (
<button
type="button"
onClick={() => installBundle("ocr")}
disabled={!!installing || queued}
className="mt-2 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
>
{installing || queued ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing || queued
? t.settings.aiFeatures.installing
: format(t.features.enableButton, { name: bundle?.name ?? t.tools.ocr.name })}
</button>
)}
{!incompatible && !isAdmin && (
<p className="mt-1 text-xs text-muted-foreground">{t.features.notEnabledDescription}</p>
)}
{installError && <p className="mt-1 text-xs text-destructive">{installError}</p>}
</div>
)}
</div>
);
}
+117 -52
View File
@@ -1,19 +1,12 @@
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { copyToClipboard, generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
type OcrQuality = "fast" | "balanced" | "best";
const QUALITY_OPTIONS: { value: OcrQuality; label: string }[] = [
{ value: "fast", label: "Fast" },
{ value: "balanced", label: "Balanced" },
{ value: "best", label: "Best" },
];
import { type OcrQuality, OcrQualityControl, useOcrQuality } from "./ocr-quality-control";
const LANGUAGES = [
{ code: "auto", label: "Auto-detect" },
@@ -29,7 +22,9 @@ const LANGUAGES = [
const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = {
fast: false,
balanced: false,
best: false,
// Best evaluates the conservative contrast variant and keeps it only when
// the calibrated selector scores it above the original.
best: true,
};
function SectionLabel({ children }: { children: React.ReactNode }) {
@@ -40,28 +35,93 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
const OCR_ASYNC_STALL_TIMEOUT_MS = 5 * 60_000;
/** Send one file to the OCR API and return the extracted text. */
function ocrOneFile(
export function ocrOneFile(
file: File,
settings: { quality: string; language: string; enhance: boolean },
callbacks: {
onUploadProgress: (pct: number) => void;
onProcessingProgress: (pct: number, stage: string) => void;
},
messages: {
timeout?: string;
networkError?: string;
processingFailed?: string;
} = {},
): Promise<string> {
return new Promise((resolve, reject) => {
const clientJobId = generateId();
let settled = false;
let asyncMode = false;
let stallTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
const cleanup = () => {
if (stallTimer) clearTimeout(stallTimer);
stallTimer = null;
es?.close();
es = null;
};
const resolveOnce = (text: string) => {
if (settled) return;
settled = true;
cleanup();
resolve(text);
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const armStallTimer = () => {
if (settled) return;
if (stallTimer) clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
rejectOnce(
new Error(
messages.timeout ?? "OCR timed out with no progress. Try again or use a smaller image.",
),
);
}, OCR_ASYNC_STALL_TIMEOUT_MS);
};
try {
es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
} catch {
rejectOnce(new Error(messages.networkError ?? "Unable to subscribe to OCR progress"));
return;
}
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
if (data.type === "heartbeat") {
if (asyncMode) armStallTimer();
return;
}
if (data.type !== "single") return;
armStallTimer();
if (data.phase === "complete" && data.result) {
resolveOnce(typeof data.result.text === "string" ? data.result.text : "");
return;
}
if (data.phase === "failed") {
rejectOnce(new Error(typeof data.error === "string" ? data.error : "OCR failed"));
return;
}
if (typeof data.percent === "number") {
callbacks.onProcessingProgress(data.percent, data.stage);
}
} catch {}
};
es.onerror = () => es.close();
// EventSource reconnects automatically. The progress endpoint replays the
// terminal frame, so transient network loss must not discard a queued OCR.
es.onerror = () => {};
const formData = new FormData();
formData.append("file", file);
@@ -69,30 +129,37 @@ function ocrOneFile(
formData.append("clientJobId", clientJobId);
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) callbacks.onUploadProgress((e.loaded / e.total) * 100);
};
xhr.onload = () => {
es.close();
// The BullMQ worker owns long OCR jobs. Keep the progress subscription
// alive and resolve from buildLegacyResultPayload(resultPayload).text.
if (xhr.status === 202) {
asyncMode = true;
armStallTimer();
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText).text ?? "");
const body = JSON.parse(xhr.responseText);
resolveOnce(typeof body.text === "string" ? body.text : "");
} catch {
reject(new Error("Invalid response"));
rejectOnce(new Error(messages.processingFailed ?? "Invalid response"));
}
} else {
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.error || body.details || `Failed: ${xhr.status}`));
rejectOnce(new Error(body.error || body.details || `Failed: ${xhr.status}`));
} catch {
reject(new Error(`Processing failed: ${xhr.status}`));
rejectOnce(new Error(messages.processingFailed ?? `Processing failed: ${xhr.status}`));
}
}
};
xhr.onerror = () => {
es.close();
reject(new Error("Network error"));
};
xhr.onerror = () => rejectOnce(new Error(messages.networkError ?? "Network error"));
xhr.ontimeout = () => rejectOnce(new Error(messages.timeout ?? "OCR request timed out"));
xhr.onabort = () => rejectOnce(new Error(messages.processingFailed ?? "OCR request canceled"));
xhr.open("POST", "/api/v1/tools/image/ocr");
for (const [key, value] of formatHeaders()) {
xhr.setRequestHeader(key, value);
@@ -105,8 +172,8 @@ export function OcrSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [quality, setQuality] = useState<OcrQuality>("balanced");
const [language, setLanguage] = useState("auto");
const { quality, setQuality, canRun } = useOcrQuality(language);
const [enhance, setEnhance] = useState(false);
const [enhanceManuallySet, setEnhanceManuallySet] = useState(false);
const [langOpen, setLangOpen] = useState(false);
@@ -119,6 +186,10 @@ export function OcrSettings() {
const [elapsed, setElapsed] = useState(0);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!enhanceManuallySet) setEnhance(ENHANCE_DEFAULTS[quality]);
}, [enhanceManuallySet, quality]);
const handleQualityChange = (q: OcrQuality) => {
setQuality(q);
if (!enhanceManuallySet) setEnhance(ENHANCE_DEFAULTS[q]);
@@ -158,18 +229,27 @@ export function OcrSettings() {
const fileShare = 100 / total;
try {
const text = await ocrOneFile(file, settings, {
onUploadProgress: (pct) => {
setProgressPhase("uploading");
setProgressPercent(fileBase + (pct / 100) * fileShare * 0.15);
setProgressStage(`${prefix}Uploading...`);
const text = await ocrOneFile(
file,
settings,
{
onUploadProgress: (pct) => {
setProgressPhase("uploading");
setProgressPercent(fileBase + (pct / 100) * fileShare * 0.15);
setProgressStage(`${prefix}Uploading...`);
},
onProcessingProgress: (pct, stage) => {
setProgressPhase("processing");
setProgressPercent(fileBase + fileShare * 0.15 + (pct / 100) * fileShare * 0.85);
setProgressStage(`${prefix}${stage}`);
},
},
onProcessingProgress: (pct, stage) => {
setProgressPhase("processing");
setProgressPercent(fileBase + fileShare * 0.15 + (pct / 100) * fileShare * 0.85);
setProgressStage(`${prefix}${stage}`);
{
timeout: t.errors.timeout,
networkError: t.errors.networkError,
processingFailed: t.errors.processingFailed,
},
});
);
results.push(total > 1 ? `--- ${file.name} ---\n${text || "(no text detected)"}` : text);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -223,22 +303,7 @@ export function OcrSettings() {
<div className="space-y-3">
{/* Quality selector */}
<SectionLabel>{t.toolSettings.ocr.quality}</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleQualityChange(opt.value)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
quality === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{opt.label}
</button>
))}
</div>
<OcrQualityControl quality={quality} language={language} onChange={handleQualityChange} />
{/* Enhance toggle */}
<label className="flex items-center gap-2 cursor-pointer">
@@ -252,7 +317,7 @@ export function OcrSettings() {
{t.toolSettings.ocr.enhanceBeforeScanning}
</span>
<span
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
title={t.toolSettings.ocr.enhanceHint}
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground text-[10px] cursor-help"
>
<Info className="h-2.5 w-2.5" />
@@ -305,7 +370,7 @@ export function OcrSettings() {
type="button"
data-testid="ocr-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
disabled={!hasFile || processing || !canRun}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1
@@ -27,12 +27,16 @@ interface ProgressHandlers {
/**
* Subscribe to async (202) job progress with the same mobile-resilient recovery
* as the standard tool processor (PRs #203/#204). Reconnects on tab refocus (the
* progress endpoint replays the terminal frame from its Redis cache, so a job
* that finished while SSE was dead still resolves) and arms a stall timeout that
* fails gracefully instead of hanging at the last percent. Returns a cleanup the
* caller must invoke on sync completion, error, or unmount.
* progress endpoint replays the terminal frame from Redis and, after that cache
* expires, from the durable job record, so a job that finished while SSE was dead
* still resolves) and arms a stall timeout that fails gracefully instead of
* hanging at the last percent. Returns a cleanup the caller must invoke on sync
* completion, error, or unmount.
*/
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
export function subscribeSignPdfJobProgress(
clientJobId: string,
handlers: ProgressHandlers,
): () => void {
let es: EventSource | null = null;
let stall: ReturnType<typeof setTimeout> | null = null;
let done = false;
@@ -73,6 +77,10 @@ function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers):
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "heartbeat") {
resetStall();
return;
}
if (data.type !== "single") return;
resetStall();
if (data.phase === "complete" && data.result) {
@@ -154,7 +162,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
setProcessing(false);
};
const stopProgress = subscribeJobProgress(clientJobId, {
const stopProgress = subscribeSignPdfJobProgress(clientJobId, {
onProgress: (percent) => setProgress(percent),
onComplete: (r) => {
setDownloadUrl(r.downloadUrl as string);
@@ -187,7 +195,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
xhr.onload = () => {
// 202 = async: subscribeJobProgress drives completion via SSE.
// 202 = async: the progress subscription drives completion via SSE.
if (xhr.status === 202) return;
stopProgress();
progressCleanupRef.current = null;
+23
View File
@@ -132,6 +132,25 @@ export function useToolProcessor(toolId: string) {
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "heartbeat") {
if (asyncModeRef.current && stallTimerRef.current) {
clearTimeout(stallTimerRef.current);
stallTimerRef.current = setTimeout(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (elapsedRef.current) clearInterval(elapsedRef.current);
clearActiveJob();
setError(
"Processing timed out with no progress for 5 minutes. Try again or use a smaller file.",
);
setProcessing(false);
setProgress(IDLE_PROGRESS);
}, SSE_STALL_TIMEOUT_MS);
}
return;
}
if (data.type !== "single") return;
if (asyncModeRef.current && stallTimerRef.current) {
@@ -315,6 +334,10 @@ export function useToolProcessor(toolId: string) {
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "heartbeat") {
if (asyncMode) resetStallTimer();
return;
}
if (data.type !== "single") return;
if (asyncMode) resetStallTimer();
+7 -1
View File
@@ -168,11 +168,13 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as {
type?: string;
phase: string;
percent: number;
stage: string;
error?: string;
};
if (data.type === "heartbeat") return;
if (data.phase === "complete") {
es.close();
delete esRefs[bundleId];
@@ -446,7 +448,11 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
// queue: re-POSTing them just dedups server-side and used to leave a
// second progress subscription racing the first one's terminal events.
const pending = get().bundles.filter(
(b) => b.status !== "installed" && b.status !== "installing" && b.status !== "queued",
(b) =>
b.status !== "installed" &&
b.status !== "installing" &&
b.status !== "queued" &&
b.compatibility !== "incompatible",
);
if (pending.length === 0) return;