mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user