mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: reliable, self-healing AI feature-bundle installs (#472)
Make on-demand AI feature-bundle installs reliable and self-healing, closing the failure modes behind most "some tool doesn't work" reports. Multi-bundle installs: tools needing more than one bundle (Passport Photo, Enhance Faces) install every required bundle from one action and stay not-installed until all are present. Verified across all 19 AI tools. Downloads: self-heal the accelerated Hugging Face (Xet) client so an upgraded venv no longer silently falls back to slow urllib; restart instead of corrupting a resumed partial when a proxy ignores Range and returns 200; verify the completed size; fail fast on disk-full and HTTP 4xx; retry transient errors five times; add hf_transfer fallback and document Xet egress. Install integrity: crash-atomic venv writes so a killed or out-of-space install can no longer tear the shared venv and break other tools; a boot breadcrumb reseeds a torn venv to a clean state automatically; a post-install smoke import test refuses to record a bundle whose libraries cannot load; an install watchdog stops a wedged installer that would otherwise hold the venv writer lock forever. Adds unit and end-to-end tests for every failure mode above.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import type { Tool } from "@snapotter/shared";
|
||||
import { PYTHON_SIDECAR_TOOLS, SECTIONS, TOOL_BUNDLE_MAP, toolSection } from "@snapotter/shared";
|
||||
import {
|
||||
getRequiredBundlesForTool,
|
||||
PYTHON_SIDECAR_TOOLS,
|
||||
SECTIONS,
|
||||
toolSection,
|
||||
} from "@snapotter/shared";
|
||||
import { Clock, Download, FileImage, Loader2, Pin } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -63,12 +68,15 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin
|
||||
const queued = useFeaturesStore((s) => s.queued);
|
||||
const aiStatus = useMemo(() => {
|
||||
if (!isAiTool) return "installed";
|
||||
const bundleId = TOOL_BUNDLE_MAP[tool.id];
|
||||
if (!bundleId) return "installed";
|
||||
if (queued.includes(bundleId)) return "queued";
|
||||
if (installing[bundleId]) return "installing";
|
||||
const bundle = bundles.find((b) => b.id === bundleId);
|
||||
return bundle?.status === "installed" ? "installed" : "not_installed";
|
||||
const requiredBundleIds = getRequiredBundlesForTool(tool.id);
|
||||
if (requiredBundleIds.length === 0) return "installed";
|
||||
if (requiredBundleIds.some((bundleId) => queued.includes(bundleId))) return "queued";
|
||||
if (requiredBundleIds.some((bundleId) => installing[bundleId])) return "installing";
|
||||
return requiredBundleIds.every(
|
||||
(bundleId) => bundles.find((bundle) => bundle.id === bundleId)?.status === "installed",
|
||||
)
|
||||
? "installed"
|
||||
: "not_installed";
|
||||
}, [isAiTool, tool.id, bundles, installing, queued]);
|
||||
|
||||
const section = toolSection(tool);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { FeatureBundleState } from "@snapotter/shared";
|
||||
import {
|
||||
FEATURE_BUNDLES,
|
||||
type FeatureBundleState,
|
||||
getRequiredBundlesForTool,
|
||||
} from "@snapotter/shared";
|
||||
import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
@@ -48,18 +52,45 @@ function formatTimeRemaining(ms: number): string {
|
||||
interface FeatureInstallPromptProps {
|
||||
bundle: FeatureBundleState;
|
||||
isAdmin: boolean;
|
||||
toolId?: string;
|
||||
toolName?: string;
|
||||
toolDescription?: string;
|
||||
}
|
||||
|
||||
function fallbackBundleState(bundleId: string): FeatureBundleState | null {
|
||||
const info = FEATURE_BUNDLES[bundleId];
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
description: info.description,
|
||||
status: "not_installed",
|
||||
installedVersion: null,
|
||||
estimatedSize: info.estimatedSize,
|
||||
enablesTools: info.enablesTools,
|
||||
progress: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function FeatureInstallPrompt({
|
||||
bundle,
|
||||
isAdmin,
|
||||
toolId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
}: FeatureInstallPromptProps) {
|
||||
const { t } = useTranslation();
|
||||
const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore();
|
||||
const {
|
||||
bundles,
|
||||
installBundle,
|
||||
installTool,
|
||||
clearError,
|
||||
installing,
|
||||
errors,
|
||||
startTimes,
|
||||
queued,
|
||||
} = useFeaturesStore();
|
||||
const progress = installing[bundle.id] ?? null;
|
||||
const error = errors[bundle.id] ?? null;
|
||||
const isInstalling = !!progress;
|
||||
@@ -68,6 +99,37 @@ export function FeatureInstallPrompt({
|
||||
const displayName = toolName || bundle.name;
|
||||
const displayDescription = toolDescription || bundle.description;
|
||||
const isRepair = bundle.status === "error";
|
||||
const requiredBundleIds = toolId ? getRequiredBundlesForTool(toolId) : [bundle.id];
|
||||
const requiredBundles = requiredBundleIds
|
||||
.map(
|
||||
(bundleId) =>
|
||||
bundles.find((candidate) => candidate.id === bundleId) ??
|
||||
(bundle.id === bundleId ? bundle : fallbackBundleState(bundleId)),
|
||||
)
|
||||
.filter((candidate): candidate is FeatureBundleState => candidate !== null);
|
||||
const bundlesNeedingDownload = requiredBundles.filter(
|
||||
(candidate) => candidate.status !== "installed",
|
||||
);
|
||||
const downloadSizeLabel =
|
||||
bundlesNeedingDownload
|
||||
.map((candidate) =>
|
||||
candidate.downloadBytes ? formatFileSize(candidate.downloadBytes) : candidate.estimatedSize,
|
||||
)
|
||||
.join(" + ") ||
|
||||
(bundle.downloadBytes ? formatFileSize(bundle.downloadBytes) : bundle.estimatedSize);
|
||||
// Show the per-bundle breakdown for any multi-bundle tool, including the
|
||||
// repair state: when one bundle of a multi-bundle tool (e.g. passport-photo)
|
||||
// errors, the user still needs to see that the sibling bundle is installing,
|
||||
// queued, or already done. Hiding it during repair is exactly when it hurts.
|
||||
const showBundleBreakdown = toolId !== undefined && requiredBundles.length > 1;
|
||||
|
||||
function bundleStatusLabel(candidate: FeatureBundleState): string {
|
||||
if (installing[candidate.id]) return t.settings.aiFeatures.installing;
|
||||
if (queued.includes(candidate.id)) return t.settings.aiFeatures.queued;
|
||||
if (candidate.status === "installed") return t.settings.aiFeatures.installed;
|
||||
if (candidate.status === "error") return t.settings.aiFeatures.repair;
|
||||
return t.settings.aiFeatures.notInstalled;
|
||||
}
|
||||
|
||||
const [messageIndex, setMessageIndex] = useState(() =>
|
||||
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
|
||||
@@ -94,7 +156,11 @@ export function FeatureInstallPrompt({
|
||||
|
||||
function handleInstall() {
|
||||
clearError(bundle.id);
|
||||
installBundle(bundle.id);
|
||||
if (toolId) {
|
||||
installTool(toolId);
|
||||
} else {
|
||||
installBundle(bundle.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive guard: if the bundle is already installed (status may have
|
||||
@@ -126,14 +192,49 @@ export function FeatureInstallPrompt({
|
||||
{!isRepair && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{format(t.features.requiresDownload, {
|
||||
size: bundle.downloadBytes
|
||||
? formatFileSize(bundle.downloadBytes)
|
||||
: bundle.estimatedSize,
|
||||
size: downloadSizeLabel,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showBundleBreakdown && (
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-background text-start overflow-hidden">
|
||||
{requiredBundles.map((candidate) => {
|
||||
const isCandidateInstalling = !!installing[candidate.id];
|
||||
const isCandidateQueued = queued.includes(candidate.id);
|
||||
const isCandidateInstalled = candidate.status === "installed";
|
||||
const statusClass = isCandidateInstalled
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: isCandidateInstalling || isCandidateQueued
|
||||
? "bg-primary/10 text-primary"
|
||||
: candidate.status === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<div
|
||||
key={candidate.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{candidate.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{candidate.downloadBytes
|
||||
? formatFileSize(candidate.downloadBytes)
|
||||
: candidate.estimatedSize}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${statusClass}`}
|
||||
>
|
||||
{bundleStatusLabel(candidate)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || (isRepair && bundle.error)) && (
|
||||
<div className="flex items-center gap-2 bg-destructive/10 text-destructive rounded-lg px-4 py-3 max-w-md w-full">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
|
||||
@@ -600,6 +600,7 @@ export function ToolPage() {
|
||||
<FeatureInstallPrompt
|
||||
bundle={featureBundle}
|
||||
isAdmin={isAdmin}
|
||||
toolId={toolId}
|
||||
toolName={tool?.name}
|
||||
toolDescription={tool?.description}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,13 @@ interface BundleProgress {
|
||||
stage: string;
|
||||
}
|
||||
|
||||
interface ToolBundleInstallResult {
|
||||
bundleId: string;
|
||||
jobId?: string;
|
||||
queued?: boolean;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
interface FeaturesState {
|
||||
bundles: FeatureBundleState[];
|
||||
loaded: boolean;
|
||||
@@ -35,6 +42,7 @@ interface FeaturesState {
|
||||
isToolInstalled: (toolId: string) => boolean;
|
||||
getBundleForTool: (toolId: string) => FeatureBundleState | null;
|
||||
installBundle: (bundleId: string) => Promise<void>;
|
||||
installTool: (toolId: string) => Promise<void>;
|
||||
uninstallBundle: (bundleId: string) => Promise<void>;
|
||||
reinstallBundle: (bundleId: string) => Promise<void>;
|
||||
installAll: () => Promise<void>;
|
||||
@@ -346,6 +354,74 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
installTool: async (toolId: string) => {
|
||||
const required = requiredBundlesForTool(toolId);
|
||||
const targets = required.filter(
|
||||
(bundleId) => get().bundles.find((b) => b.id === bundleId)?.status !== "installed",
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const errors = { ...get().errors };
|
||||
const installing = { ...get().installing };
|
||||
const startTimes = { ...get().startTimes };
|
||||
const now = Date.now();
|
||||
|
||||
for (const bundleId of targets) {
|
||||
delete errors[bundleId];
|
||||
if (!get().queued.includes(bundleId)) {
|
||||
installing[bundleId] = installing[bundleId] ?? { percent: 5, stage: "Starting..." };
|
||||
}
|
||||
startTimes[bundleId] = startTimes[bundleId] ?? now;
|
||||
}
|
||||
set({ errors, installing, startTimes });
|
||||
|
||||
try {
|
||||
const result = await apiPost<{ bundles: ToolBundleInstallResult[] }>(
|
||||
`/v1/admin/tools/${toolId}/features/install`,
|
||||
{},
|
||||
);
|
||||
|
||||
for (const item of result.bundles) {
|
||||
if (item.skipped) {
|
||||
stopTracking(item.bundleId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.queued) {
|
||||
const nextInstalling = { ...get().installing };
|
||||
delete nextInstalling[item.bundleId];
|
||||
set({
|
||||
installing: nextInstalling,
|
||||
queued: get().queued.includes(item.bundleId)
|
||||
? get().queued
|
||||
: [...get().queued, item.bundleId],
|
||||
});
|
||||
startPolling(item.bundleId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.jobId) {
|
||||
listenToProgress(item.bundleId, item.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.bundles.every((item) => item.skipped)) {
|
||||
await refreshBundles();
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to start installation";
|
||||
const nextErrors = { ...get().errors };
|
||||
|
||||
for (const bundleId of targets) {
|
||||
stopTracking(bundleId);
|
||||
nextErrors[bundleId] = message;
|
||||
}
|
||||
|
||||
set({ errors: nextErrors });
|
||||
maybeFinishInstallAll();
|
||||
}
|
||||
},
|
||||
|
||||
uninstallBundle: async (bundleId: string) => {
|
||||
try {
|
||||
await apiPost(`/v1/admin/features/${bundleId}/uninstall`, {});
|
||||
|
||||
Reference in New Issue
Block a user