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:
SnapOtter
2026-07-07 14:12:00 +08:00
committed by GitHub
parent 60d01ab2dd
commit fb96cf8743
31 changed files with 640 additions and 6 deletions
@@ -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>
);
}
+14
View File
@@ -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" });
}
},
};
});