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:
@@ -252,6 +252,50 @@ export function getInstallingBundle(): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the shared AI venv, downloaded models, and pip cache, then reset
|
||||
* installed.json to empty. For self-hosters whose venv already has stale or
|
||||
* conflicting package files from a previous bundle version (the class of bug
|
||||
* in project_ai_bundle_numpy_abi_strand): uninstalling a bundle only removes
|
||||
* its model weights, never the shared site-packages it wrote into, so a
|
||||
* reinstall just overlays corrected files on top of the old ones rather than
|
||||
* replacing them. This is the blunt, reliable alternative: everything AI
|
||||
* related is deleted and every bundle needs reinstalling (a fresh download
|
||||
* from the HuggingFace bundle repo), but there is no partial/stale state left
|
||||
* to reason about afterward.
|
||||
*/
|
||||
export function resetAiEnvironment(): void {
|
||||
if (!acquireInstallLock("__reset__")) {
|
||||
const installing = getInstallingBundle();
|
||||
throw new Error(
|
||||
`Cannot reset: a bundle install is already in progress (${installing?.bundleId ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(MODELS_DIR, { recursive: true, force: true });
|
||||
rmSync(join(AI_DIR, "pip-cache"), { recursive: true, force: true });
|
||||
writeInstalled({ bundles: {} });
|
||||
invalidateCache();
|
||||
|
||||
// Reseed the venv from the image's baked /opt/venv (base packages: numpy,
|
||||
// Pillow, opencv) via the same script the entrypoint uses on a base-venv
|
||||
// upgrade. A fresh install right after a reset needs a real, working venv
|
||||
// to install into -- leaving an empty directory (no python3 binary) would
|
||||
// make the very next install fail with "spawn .../python3 ENOENT".
|
||||
if (existsSync("/opt/venv")) {
|
||||
execFileSync("/usr/local/bin/reseed-ai-venv.sh", { stdio: "ignore", timeout: 120_000 });
|
||||
} else {
|
||||
// Not a Docker image build (local dev, or a test fixture): nothing to
|
||||
// reseed from, just leave an empty directory.
|
||||
rmSync(join(AI_DIR, "venv"), { recursive: true, force: true });
|
||||
}
|
||||
ensureAiDirs();
|
||||
} finally {
|
||||
releaseInstallLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress tracking (in-memory, for SSE) ──────────────────────────────
|
||||
|
||||
let currentProgress: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* GET /api/v1/features - List feature bundles and their statuses
|
||||
* POST /api/v1/admin/features/:bundleId/install - Install a feature bundle (async)
|
||||
* POST /api/v1/admin/features/:bundleId/uninstall - Uninstall a feature bundle
|
||||
* POST /api/v1/admin/features/reset - Wipe the AI venv/models, reset all bundles
|
||||
* GET /api/v1/admin/features/disk-usage - Get AI model disk usage
|
||||
* POST /api/v1/admin/features/import - Import an offline bundle archive
|
||||
*/
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
isFeatureInstalled,
|
||||
markUninstalled,
|
||||
releaseInstallLock,
|
||||
resetAiEnvironment,
|
||||
setInstallProgress,
|
||||
verifyBundleModels,
|
||||
} from "../lib/feature-status.js";
|
||||
@@ -449,6 +451,36 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/v1/admin/features/reset - Wipe the AI venv/models/pip-cache and
|
||||
// reset every bundle to not-installed. Existing installs can't self-heal a
|
||||
// stale/conflicting venv via uninstall+reinstall alone (uninstall only
|
||||
// removes model weights), so this is the reliable full reset.
|
||||
app.post(
|
||||
"/api/v1/admin/features/reset",
|
||||
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await requirePermission("features:manage")(_request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
try {
|
||||
resetAiEnvironment();
|
||||
} catch (err) {
|
||||
return reply.status(409).send({
|
||||
error: err instanceof Error ? err.message : "Reset failed",
|
||||
});
|
||||
}
|
||||
shutdownDispatcher();
|
||||
|
||||
trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
|
||||
bundle_id: "all",
|
||||
action: "reset_environment",
|
||||
duration_ms: 0,
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/v1/admin/features/disk-usage - Get AI model disk usage
|
||||
app.get(
|
||||
"/api/v1/admin/features/disk-usage",
|
||||
|
||||
Reference in New Issue
Block a user