feat: on-demand AI feature install with progress indicators

This commit is contained in:
ashim-hq
2026-04-19 19:52:14 +08:00
parent 771f786528
commit 10bdc24a4a
9 changed files with 488 additions and 225 deletions
@@ -83,7 +83,7 @@ export function BeforeAfterSlider({
: null;
return (
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto h-full min-h-0">
{/* Slider container */}
<div
ref={containerRef}
@@ -105,7 +105,12 @@ export function BeforeAfterSlider({
}}
>
{/* Before image (full width, bottom layer) */}
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
<img
src={beforeSrc}
alt="Original"
className="block w-full max-h-[70vh] object-contain"
draggable={false}
/>
{/* After image (clipped, top layer) */}
<div
@@ -0,0 +1,70 @@
import { Download, Loader2 } from "lucide-react";
import { useEffect } from "react";
import { useFeaturesStore } from "@/stores/features-store";
export function AiInstallIndicator() {
const { bundles, installing, queued, fetch } = useFeaturesStore();
useEffect(() => {
fetch();
}, [fetch]);
const activeIds = Object.keys(installing);
const totalPending = activeIds.length + queued.length;
if (totalPending === 0) return null;
const activeBundle = bundles.find((b) => installing[b.id]);
const progress = activeBundle ? installing[activeBundle.id] : null;
const completedCount = bundles.filter((b) => b.status === "installed").length;
const totalBundles = bundles.length;
return (
<IndicatorContent
name={activeBundle?.name ?? "AI Feature"}
percent={progress?.percent ?? 0}
completedCount={completedCount}
totalBundles={totalBundles}
queuedCount={queued.length}
/>
);
}
function IndicatorContent({
name,
percent,
completedCount,
totalBundles,
queuedCount,
}: {
name: string;
percent: number;
completedCount: number;
totalBundles: number;
queuedCount: number;
}) {
return (
<div className="fixed bottom-16 right-4 z-40 bg-background border border-border rounded-xl shadow-lg px-4 py-3 min-w-[260px] max-w-[320px]">
<div className="flex items-center gap-2 mb-2">
<Download className="h-4 w-4 text-primary shrink-0" />
<p className="text-sm font-medium text-foreground truncate">Installing {name}</p>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-1.5">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">{percent}%</span>
</div>
<span className="text-xs text-muted-foreground">
{completedCount}/{totalBundles} installed
{queuedCount > 0 && ` · ${queuedCount} queued`}
</span>
</div>
</div>
);
}
@@ -8,6 +8,7 @@ import { Dropzone } from "../common/dropzone";
import { GemLogo } from "../common/gem-logo";
import { HelpDialog } from "../help/help-dialog";
import { SettingsDialog } from "../settings/settings-dialog";
import { AiInstallIndicator } from "./ai-install-indicator";
import { Footer } from "./footer";
import { Sidebar } from "./sidebar";
import { ToolPanel } from "./tool-panel";
@@ -155,6 +156,9 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{/* Help dialog */}
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
{/* Global AI install progress */}
<AiInstallIndicator />
</div>
);
}
@@ -1,14 +1,9 @@
import type { FeatureBundleState } from "@ashim/shared";
import { Download, Loader2, RotateCcw, Trash2 } from "lucide-react";
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { apiGet } from "@/lib/api";
import { useFeaturesStore } from "@/stores/features-store";
interface BundleProgress {
percent: number;
stage: string;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -16,187 +11,90 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function AiFeaturesSection() {
const { bundles, fetch, refresh } = useFeaturesStore();
const [installing, setInstalling] = useState<Record<string, BundleProgress>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [diskUsage, setDiskUsage] = useState<number | null>(null);
const [installAllActive, setInstallAllActive] = useState(false);
const esRefs = useRef<Record<string, EventSource>>({});
const pollRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({});
function formatTimeRemaining(ms: number): string {
if (ms < 60000) return "Less than a minute left";
const mins = Math.ceil(ms / 60000);
if (mins === 1) return "~1 minute left";
return `~${mins} minutes left`;
}
useEffect(() => {
fetch();
loadDiskUsage();
return () => {
for (const es of Object.values(esRefs.current)) es.close();
for (const id of Object.values(pollRefs.current)) clearInterval(id);
};
}, [fetch]);
const PROGRESS_MESSAGES = [
"Almost there... probably...",
"Good things take time...",
"Still faster than watching paint dry...",
"Your patience is truly inspiring...",
"Working harder than it looks...",
"This is the exciting part, trust me...",
"Doing important behind-the-scenes stuff...",
"If you're reading this, it's working...",
"Preparing something awesome...",
"Worth every second, pinky promise...",
"The suspense is part of the experience...",
"Teaching your computer new tricks...",
"Setting up your superpowers...",
"Your images will thank you later...",
"Loading... but make it fancy...",
"This would be a great time for coffee...",
"Rome wasn't built in a day either...",
"Shhh... genius at work...",
"Making your photos jealous of what's coming...",
"Assembling the dream team...",
"Unpacking awesomeness...",
"Almost done thinking about starting... just kidding...",
"Plot twist: this is actually doing something...",
"Warming up the creative engines...",
"Imagination loading...",
"Not a screensaver, we promise...",
"Great art takes time to install...",
"Your future self will thank you...",
"Grabbing some really smart files...",
"Hang tight, the best is yet to come...",
];
export function AiFeaturesSection() {
const {
bundles,
fetch,
installing,
errors,
queued,
installAllActive,
startTimes,
installBundle,
uninstallBundle,
reinstallBundle,
installAll,
} = useFeaturesStore();
const [diskUsage, setDiskUsage] = useState<number | null>(null);
const loadDiskUsage = useCallback(async () => {
try {
const data = await apiGet<{ totalBytes: number }>("/v1/admin/features/disk-usage");
setDiskUsage(data.totalBytes);
} catch {
/* ignore */
}
} catch {}
}, []);
const startPolling = useCallback(
(bundleId: string) => {
if (pollRefs.current[bundleId]) return;
pollRefs.current[bundleId] = setInterval(async () => {
try {
await refresh();
const updated = useFeaturesStore.getState().bundles.find((b) => b.id === bundleId);
if (!updated || updated.status !== "installing") {
clearInterval(pollRefs.current[bundleId]);
delete pollRefs.current[bundleId];
setInstalling((prev) => {
const next = { ...prev };
delete next[bundleId];
return next;
});
if (updated?.status === "error") {
setErrors((prev) => ({
...prev,
[bundleId]: updated.error ?? "Installation failed",
}));
}
loadDiskUsage();
} else if (updated.progress) {
setInstalling((prev) => ({ ...prev, [bundleId]: updated.progress! }));
}
} catch {
/* ignore */
}
}, 3000);
},
[refresh, loadDiskUsage],
);
useEffect(() => {
fetch();
loadDiskUsage();
}, [fetch, loadDiskUsage]);
const listenToProgress = useCallback(
(bundleId: string, jobId: string) => {
const es = new EventSource(`/api/v1/jobs/${jobId}/progress`);
esRefs.current[bundleId] = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as {
phase: string;
percent: number;
stage: string;
error?: string;
};
if (data.phase === "complete") {
es.close();
delete esRefs.current[bundleId];
setInstalling((prev) => {
const next = { ...prev };
delete next[bundleId];
return next;
});
refresh();
loadDiskUsage();
return;
}
if (data.phase === "failed") {
es.close();
delete esRefs.current[bundleId];
setInstalling((prev) => {
const next = { ...prev };
delete next[bundleId];
return next;
});
setErrors((prev) => ({ ...prev, [bundleId]: data.error ?? "Installation failed" }));
return;
}
setInstalling((prev) => ({
...prev,
[bundleId]: { percent: data.percent, stage: data.stage },
}));
} catch {
/* ignore */
}
};
es.onerror = () => {
es.close();
delete esRefs.current[bundleId];
startPolling(bundleId);
};
},
[refresh, loadDiskUsage, startPolling],
);
const installBundle = useCallback(
async (bundleId: string) => {
setErrors((prev) => {
const next = { ...prev };
delete next[bundleId];
return next;
});
setInstalling((prev) => ({ ...prev, [bundleId]: { percent: 0, stage: "Starting..." } }));
try {
const result = await apiPost<{ jobId: string }>(`/v1/admin/features/${bundleId}/install`);
listenToProgress(bundleId, result.jobId);
} catch (err) {
setInstalling((prev) => {
const next = { ...prev };
delete next[bundleId];
return next;
});
setErrors((prev) => ({
...prev,
[bundleId]: err instanceof Error ? err.message : "Failed to start installation",
}));
}
},
[listenToProgress],
);
const uninstallBundle = useCallback(
async (bundleId: string) => {
try {
await apiPost(`/v1/admin/features/${bundleId}/uninstall`);
await refresh();
const prevInstallingKeys = useRef(new Set(Object.keys(installing)));
useEffect(() => {
const currentKeys = new Set(Object.keys(installing));
for (const key of prevInstallingKeys.current) {
if (!currentKeys.has(key)) {
loadDiskUsage();
} catch (err) {
setErrors((prev) => ({
...prev,
[bundleId]: err instanceof Error ? err.message : "Uninstall failed",
}));
break;
}
},
[refresh, loadDiskUsage],
);
const handleInstallAll = useCallback(async () => {
setInstallAllActive(true);
const notInstalled = bundles.filter((b) => b.status === "not_installed");
for (const bundle of notInstalled) {
await installBundle(bundle.id);
// Wait for this bundle to finish before starting next
await new Promise<void>((resolve) => {
const check = setInterval(() => {
const current = useFeaturesStore.getState().bundles.find((b) => b.id === bundle.id);
if (!current || current.status !== "installing") {
clearInterval(check);
resolve();
}
}, 2000);
});
}
setInstallAllActive(false);
}, [bundles, installBundle]);
prevInstallingKeys.current = currentKeys;
}, [installing, loadDiskUsage]);
const anyInstalling = Object.keys(installing).length > 0;
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
@@ -206,7 +104,7 @@ export function AiFeaturesSection() {
</div>
<button
type="button"
onClick={handleInstallAll}
onClick={installAll}
disabled={
anyInstalling || installAllActive || bundles.every((b) => b.status === "installed")
}
@@ -217,7 +115,6 @@ export function AiFeaturesSection() {
</button>
</div>
{/* Bundle cards */}
<div className="space-y-3">
{bundles.map((bundle) => (
<BundleCard
@@ -227,12 +124,14 @@ export function AiFeaturesSection() {
error={errors[bundle.id] ?? null}
onInstall={() => installBundle(bundle.id)}
onUninstall={() => uninstallBundle(bundle.id)}
onReinstall={() => reinstallBundle(bundle.id)}
isInstalling={!!installing[bundle.id]}
isQueued={queued.includes(bundle.id)}
startTime={startTimes[bundle.id] ?? null}
/>
))}
</div>
{/* Disk usage footer */}
{diskUsage !== null && (
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
Disk usage: {formatBytes(diskUsage)}
@@ -242,22 +141,56 @@ export function AiFeaturesSection() {
);
}
interface BundleProgress {
percent: number;
stage: string;
}
function BundleCard({
bundle,
progress,
error,
onInstall,
onUninstall,
onReinstall,
isInstalling,
isQueued,
startTime,
}: {
bundle: FeatureBundleState;
progress: BundleProgress | null;
error: string | null;
onInstall: () => void;
onUninstall: () => void;
onReinstall: () => void;
isInstalling: boolean;
isQueued: boolean;
startTime: number | null;
}) {
const status = isInstalling ? "installing" : bundle.status;
const [confirming, setConfirming] = useState(false);
const [messageIndex, setMessageIndex] = useState(() =>
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
);
const [now, setNow] = useState(Date.now());
const status = isQueued ? "queued" : isInstalling ? "installing" : bundle.status;
useEffect(() => {
if (!isInstalling) return;
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROGRESS_MESSAGES.length);
setNow(Date.now());
}, 3000);
return () => clearInterval(interval);
}, [isInstalling]);
const eta = (() => {
if (!progress || !startTime || progress.percent <= 2) return null;
const elapsed = now - startTime;
const rate = progress.percent / elapsed;
if (rate <= 0) return null;
const remaining = (100 - progress.percent) / rate;
return formatTimeRemaining(remaining);
})();
return (
<div className="rounded-lg border border-border p-4">
@@ -269,7 +202,6 @@ function BundleCard({
</p>
</div>
<div className="flex items-center gap-3 shrink-0 ml-4">
{/* Status indicator */}
<div className="flex items-center gap-1.5">
{status === "installed" && (
<>
@@ -283,6 +215,12 @@ function BundleCard({
<span className="text-xs text-muted-foreground">Not installed</span>
</>
)}
{status === "queued" && (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Queued</span>
</>
)}
{status === "installing" && progress && (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
@@ -299,7 +237,6 @@ function BundleCard({
)}
</div>
{/* Action button */}
{status === "not_installed" && !error && (
<button
type="button"
@@ -310,15 +247,47 @@ function BundleCard({
Install
</button>
)}
{status === "installed" && (
<button
type="button"
onClick={onUninstall}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Uninstall
</button>
{status === "installed" && !confirming && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={onReinstall}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
>
<RefreshCw className="h-3.5 w-3.5" />
Repair
</button>
<button
type="button"
onClick={() => setConfirming(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Uninstall
</button>
</div>
)}
{status === "installed" && confirming && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setConfirming(false);
onUninstall();
}}
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" />
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"
>
Cancel
</button>
</div>
)}
{status === "installing" && (
<button
@@ -330,7 +299,7 @@ function BundleCard({
Installing...
</button>
)}
{(status === "error" || error) && !isInstalling && (
{(status === "error" || error) && !isInstalling && !isQueued && (
<button
type="button"
onClick={onInstall}
@@ -342,6 +311,22 @@ function BundleCard({
)}
</div>
</div>
{status === "installing" && progress && (
<div className="mt-3 space-y-1.5">
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${progress.percent}%` }}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground italic">
{PROGRESS_MESSAGES[messageIndex]}
</p>
{eta && <p className="text-xs text-muted-foreground shrink-0 ml-2">{eta}</p>}
</div>
</div>
)}
</div>
);
}
+220 -26
View File
@@ -1,48 +1,242 @@
import type { FeatureBundleState } from "@ashim/shared";
import { TOOL_BUNDLE_MAP } from "@ashim/shared";
import { create } from "zustand";
import { apiGet } from "@/lib/api";
import { apiGet, apiPost } from "@/lib/api";
interface BundleProgress {
percent: number;
stage: string;
}
interface FeaturesState {
bundles: FeatureBundleState[];
loaded: boolean;
installing: Record<string, BundleProgress>;
errors: Record<string, string>;
queued: string[];
installAllActive: boolean;
startTimes: Record<string, number>;
fetch: () => Promise<void>;
refresh: () => Promise<void>;
isToolInstalled: (toolId: string) => boolean;
getBundleForTool: (toolId: string) => FeatureBundleState | null;
installBundle: (bundleId: string) => Promise<void>;
uninstallBundle: (bundleId: string) => Promise<void>;
reinstallBundle: (bundleId: string) => Promise<void>;
installAll: () => Promise<void>;
clearError: (bundleId: string) => void;
}
export const useFeaturesStore = create<FeaturesState>((set, get) => ({
bundles: [],
loaded: false,
export const useFeaturesStore = create<FeaturesState>((set, get) => {
const esRefs: Record<string, EventSource> = {};
const pollRefs: Record<string, ReturnType<typeof setInterval>> = {};
const completionRefs: Record<string, () => void> = {};
fetch: async () => {
if (get().loaded) return;
try {
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
set({ bundles: data.bundles, loaded: true });
} catch {
set({ loaded: true });
const resolveCompletion = (bundleId: string) => {
if (completionRefs[bundleId]) {
completionRefs[bundleId]();
delete completionRefs[bundleId];
}
},
};
refresh: async () => {
const refreshBundles = async () => {
try {
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
set({ bundles: data.bundles, loaded: true });
} catch {}
},
};
isToolInstalled: (toolId: string) => {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return true;
const bundle = get().bundles.find((b) => b.id === bundleId);
return bundle?.status === "installed";
},
const startPolling = (bundleId: string) => {
if (pollRefs[bundleId]) return;
pollRefs[bundleId] = setInterval(async () => {
try {
await refreshBundles();
const updated = get().bundles.find((b) => b.id === bundleId);
if (!updated || updated.status !== "installing") {
clearInterval(pollRefs[bundleId]);
delete pollRefs[bundleId];
getBundleForTool: (toolId: string) => {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return null;
return get().bundles.find((b) => b.id === bundleId) ?? null;
},
}));
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing });
if (updated?.status === "error") {
set({
errors: { ...get().errors, [bundleId]: updated.error ?? "Installation failed" },
});
}
resolveCompletion(bundleId);
} else if (updated.progress) {
set({ installing: { ...get().installing, [bundleId]: updated.progress } });
}
} catch {}
}, 3000);
};
const listenToProgress = (bundleId: string, jobId: string) => {
const es = new EventSource(`/api/v1/jobs/${jobId}/progress`);
esRefs[bundleId] = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as {
phase: string;
percent: number;
stage: string;
error?: string;
};
if (data.phase === "complete") {
es.close();
delete esRefs[bundleId];
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing });
refreshBundles();
resolveCompletion(bundleId);
return;
}
if (data.phase === "failed") {
es.close();
delete esRefs[bundleId];
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing });
set({ errors: { ...get().errors, [bundleId]: data.error ?? "Installation failed" } });
resolveCompletion(bundleId);
return;
}
set({
installing: {
...get().installing,
[bundleId]: { percent: data.percent, stage: data.stage },
},
});
} catch {}
};
es.onerror = () => {
es.close();
delete esRefs[bundleId];
startPolling(bundleId);
};
};
const recoverActiveInstalls = () => {
for (const bundle of get().bundles) {
if (bundle.status === "installing" && !get().installing[bundle.id]) {
set({
installing: {
...get().installing,
[bundle.id]: bundle.progress ?? { percent: 0, stage: "Resuming..." },
},
startTimes: { ...get().startTimes, [bundle.id]: Date.now() },
});
startPolling(bundle.id);
}
}
};
return {
bundles: [],
loaded: false,
installing: {},
errors: {},
queued: [],
installAllActive: false,
startTimes: {},
fetch: async () => {
if (get().loaded) return;
try {
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
set({ bundles: data.bundles, loaded: true });
recoverActiveInstalls();
} catch {
set({ loaded: true });
}
},
refresh: refreshBundles,
isToolInstalled: (toolId: string) => {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return true;
const bundle = get().bundles.find((b) => b.id === bundleId);
return bundle?.status === "installed";
},
getBundleForTool: (toolId: string) => {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return null;
return get().bundles.find((b) => b.id === bundleId) ?? null;
},
installBundle: async (bundleId: string) => {
const errors = { ...get().errors };
delete errors[bundleId];
set({
errors,
installing: { ...get().installing, [bundleId]: { percent: 5, stage: "Starting..." } },
startTimes: { ...get().startTimes, [bundleId]: Date.now() },
});
try {
const result = await apiPost<{ jobId: string }>(`/v1/admin/features/${bundleId}/install`);
listenToProgress(bundleId, result.jobId);
} catch (err) {
const installing = { ...get().installing };
delete installing[bundleId];
set({
installing,
errors: {
...get().errors,
[bundleId]: err instanceof Error ? err.message : "Failed to start installation",
},
});
resolveCompletion(bundleId);
}
},
uninstallBundle: async (bundleId: string) => {
try {
await apiPost(`/v1/admin/features/${bundleId}/uninstall`);
await refreshBundles();
} catch (err) {
set({
errors: {
...get().errors,
[bundleId]: err instanceof Error ? err.message : "Uninstall failed",
},
});
}
},
reinstallBundle: async (bundleId: string) => {
await get().uninstallBundle(bundleId);
await get().installBundle(bundleId);
},
installAll: async () => {
set({ installAllActive: true });
const notInstalled = get().bundles.filter((b) => b.status === "not_installed");
set({ queued: notInstalled.map((b) => b.id) });
for (const bundle of notInstalled) {
set({ queued: get().queued.filter((id) => id !== bundle.id) });
await new Promise<void>((resolve) => {
completionRefs[bundle.id] = resolve;
get().installBundle(bundle.id);
});
}
set({ queued: [], installAllActive: false });
},
clearError: (bundleId: string) => {
const errors = { ...get().errors };
delete errors[bundleId];
set({ errors });
},
};
});