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:
@@ -223,6 +223,49 @@ describe("useFeaturesStore (expanded)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetEnvironment", () => {
|
||||
it("posts to the reset endpoint and refreshes bundles on success", async () => {
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
apiGetMock.mockResolvedValueOnce({ bundles: [] });
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/reset", {});
|
||||
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
|
||||
expect(useFeaturesStore.getState().resetError).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale installing/errors/queued state on success", async () => {
|
||||
useFeaturesStore.setState({
|
||||
installing: { ocr: { percent: 40, stage: "downloading" } },
|
||||
errors: { ocr: "some old error" },
|
||||
queued: ["ocr"],
|
||||
startTimes: { ocr: Date.now() },
|
||||
});
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
apiGetMock.mockResolvedValueOnce({ bundles: [] });
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
const state = useFeaturesStore.getState();
|
||||
expect(state.installing).toEqual({});
|
||||
expect(state.errors).toEqual({});
|
||||
expect(state.queued).toEqual([]);
|
||||
expect(state.startTimes).toEqual({});
|
||||
});
|
||||
|
||||
it("sets resetError on failure and does not refresh bundles", async () => {
|
||||
apiPostMock.mockRejectedValueOnce(new Error("a bundle install is already in progress"));
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
expect(useFeaturesStore.getState().resetError).toBe(
|
||||
"a bundle install is already in progress",
|
||||
);
|
||||
expect(apiGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearError", () => {
|
||||
it("does not affect other errors when clearing one", () => {
|
||||
useFeaturesStore.setState({
|
||||
|
||||
Reference in New Issue
Block a user