mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(ai): add a Reset AI Environment admin feature for the upgrade gap (#459)
Uninstalling a bundle only deletes its downloaded model weights, never the
shared venv's site-packages, so self-hosters who already hit an AI bundle
conflict (e.g. the scipy ABI strand) have no clean self-service path via
uninstall+reinstall: reinstalling just overlays corrected files on top of
stale ones. Adds POST /api/v1/admin/features/reset, which wipes
/data/ai/{venv,models,pip-cache}, resets installed.json, and reseeds a real
working venv from the image's baked /opt/venv (extracted docker/reseed-ai-venv.sh,
now shared with entrypoint.sh's existing base-venv-upgrade bootstrap instead
of duplicating that logic) -- leaving an empty venv directory here would
make the very next install fail with "spawn .../python3 ENOENT", caught by
testing this live rather than assuming it. Ships with a matching Settings UI
section (inline confirm, same pattern as per-bundle uninstall) and strings
across all 21 locales.
Verified against a real snapotter/snapotter:1.17.2 image migrated to 2.0.0,
with real multi-GB bundles installed (background-removal + OCR): confirmed
the migrated instance's inherited python3.11 venv (2.0.0 itself uses 3.12)
still imports the fixed scipy/numpy/paddleocr correctly, then reset + real
reinstall + actual tool execution (remove-background, verified output image)
all worked end-to-end.
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import type { FeatureBundleState } from "@snapotter/shared";
|
||||
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Download,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { apiGet, formatHeaders } from "@/lib/api";
|
||||
@@ -60,6 +69,8 @@ export function AiFeaturesSection() {
|
||||
uninstallBundle,
|
||||
reinstallBundle,
|
||||
installAll,
|
||||
resetEnvironment,
|
||||
resetError,
|
||||
} = useFeaturesStore();
|
||||
const [diskUsage, setDiskUsage] = useState<number | null>(null);
|
||||
|
||||
@@ -134,6 +145,98 @@ export function AiFeaturesSection() {
|
||||
loadDiskUsage();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResetEnvironmentSection
|
||||
onReset={resetEnvironment}
|
||||
error={resetError}
|
||||
onResetDone={loadDiskUsage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetEnvironmentSection({
|
||||
onReset,
|
||||
error,
|
||||
onResetDone,
|
||||
}: {
|
||||
onReset: () => Promise<void>;
|
||||
error: string | null;
|
||||
onResetDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const handleReset = async () => {
|
||||
setConfirming(false);
|
||||
setResetting(true);
|
||||
try {
|
||||
await onReset();
|
||||
onResetDone();
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t.settings.aiFeatures.resetTitle}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t.settings.aiFeatures.resetDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!confirming && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetting}
|
||||
onClick={() => setConfirming(true)}
|
||||
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-destructive/10 hover:text-destructive transition-colors disabled:opacity-50"
|
||||
>
|
||||
{resetting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t.settings.aiFeatures.resetButton}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{confirming && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-destructive">{t.settings.aiFeatures.resetConfirmMessage}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t.common.confirm}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{format(t.settings.aiFeatures.resetFailed, { error })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ interface FeaturesState {
|
||||
reinstallBundle: (bundleId: string) => Promise<void>;
|
||||
installAll: () => Promise<void>;
|
||||
clearError: (bundleId: string) => void;
|
||||
resetEnvironment: () => Promise<void>;
|
||||
resetError: string | null;
|
||||
}
|
||||
|
||||
export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
@@ -249,6 +251,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
queued: [],
|
||||
installAllActive: false,
|
||||
startTimes: {},
|
||||
resetError: null,
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded && !get().loadError) {
|
||||
@@ -397,5 +400,16 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
delete errors[bundleId];
|
||||
set({ errors });
|
||||
},
|
||||
|
||||
resetEnvironment: async () => {
|
||||
set({ resetError: null });
|
||||
try {
|
||||
await apiPost("/v1/admin/features/reset", {});
|
||||
set({ installing: {}, errors: {}, queued: [], startTimes: {} });
|
||||
await refreshBundles();
|
||||
} catch (err) {
|
||||
set({ resetError: err instanceof Error ? err.message : "Reset failed" });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user