mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: on-demand AI feature install with progress indicators
This commit is contained in:
@@ -83,7 +83,7 @@ export function BeforeAfterSlider({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Slider container */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -105,7 +105,12 @@ export function BeforeAfterSlider({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Before image (full width, bottom layer) */}
|
{/* 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) */}
|
{/* After image (clipped, top layer) */}
|
||||||
<div
|
<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 { GemLogo } from "../common/gem-logo";
|
||||||
import { HelpDialog } from "../help/help-dialog";
|
import { HelpDialog } from "../help/help-dialog";
|
||||||
import { SettingsDialog } from "../settings/settings-dialog";
|
import { SettingsDialog } from "../settings/settings-dialog";
|
||||||
|
import { AiInstallIndicator } from "./ai-install-indicator";
|
||||||
import { Footer } from "./footer";
|
import { Footer } from "./footer";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
import { ToolPanel } from "./tool-panel";
|
import { ToolPanel } from "./tool-panel";
|
||||||
@@ -155,6 +156,9 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
|
|
||||||
{/* Help dialog */}
|
{/* Help dialog */}
|
||||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
|
|
||||||
|
{/* Global AI install progress */}
|
||||||
|
<AiInstallIndicator />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import type { FeatureBundleState } from "@ashim/shared";
|
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import { useFeaturesStore } from "@/stores/features-store";
|
import { useFeaturesStore } from "@/stores/features-store";
|
||||||
|
|
||||||
interface BundleProgress {
|
|
||||||
percent: number;
|
|
||||||
stage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
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`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AiFeaturesSection() {
|
function formatTimeRemaining(ms: number): string {
|
||||||
const { bundles, fetch, refresh } = useFeaturesStore();
|
if (ms < 60000) return "Less than a minute left";
|
||||||
const [installing, setInstalling] = useState<Record<string, BundleProgress>>({});
|
const mins = Math.ceil(ms / 60000);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
if (mins === 1) return "~1 minute left";
|
||||||
const [diskUsage, setDiskUsage] = useState<number | null>(null);
|
return `~${mins} minutes left`;
|
||||||
const [installAllActive, setInstallAllActive] = useState(false);
|
}
|
||||||
const esRefs = useRef<Record<string, EventSource>>({});
|
|
||||||
const pollRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
const PROGRESS_MESSAGES = [
|
||||||
fetch();
|
"Almost there... probably...",
|
||||||
loadDiskUsage();
|
"Good things take time...",
|
||||||
return () => {
|
"Still faster than watching paint dry...",
|
||||||
for (const es of Object.values(esRefs.current)) es.close();
|
"Your patience is truly inspiring...",
|
||||||
for (const id of Object.values(pollRefs.current)) clearInterval(id);
|
"Working harder than it looks...",
|
||||||
};
|
"This is the exciting part, trust me...",
|
||||||
}, [fetch]);
|
"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 () => {
|
const loadDiskUsage = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ totalBytes: number }>("/v1/admin/features/disk-usage");
|
const data = await apiGet<{ totalBytes: number }>("/v1/admin/features/disk-usage");
|
||||||
setDiskUsage(data.totalBytes);
|
setDiskUsage(data.totalBytes);
|
||||||
} catch {
|
} catch {}
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startPolling = useCallback(
|
useEffect(() => {
|
||||||
(bundleId: string) => {
|
fetch();
|
||||||
if (pollRefs.current[bundleId]) return;
|
loadDiskUsage();
|
||||||
pollRefs.current[bundleId] = setInterval(async () => {
|
}, [fetch, loadDiskUsage]);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const listenToProgress = useCallback(
|
const prevInstallingKeys = useRef(new Set(Object.keys(installing)));
|
||||||
(bundleId: string, jobId: string) => {
|
useEffect(() => {
|
||||||
const es = new EventSource(`/api/v1/jobs/${jobId}/progress`);
|
const currentKeys = new Set(Object.keys(installing));
|
||||||
esRefs.current[bundleId] = es;
|
for (const key of prevInstallingKeys.current) {
|
||||||
|
if (!currentKeys.has(key)) {
|
||||||
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();
|
|
||||||
loadDiskUsage();
|
loadDiskUsage();
|
||||||
} catch (err) {
|
break;
|
||||||
setErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[bundleId]: err instanceof Error ? err.message : "Uninstall failed",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[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);
|
prevInstallingKeys.current = currentKeys;
|
||||||
}, [bundles, installBundle]);
|
}, [installing, loadDiskUsage]);
|
||||||
|
|
||||||
const anyInstalling = Object.keys(installing).length > 0;
|
const anyInstalling = Object.keys(installing).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
|
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
|
||||||
@@ -206,7 +104,7 @@ export function AiFeaturesSection() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInstallAll}
|
onClick={installAll}
|
||||||
disabled={
|
disabled={
|
||||||
anyInstalling || installAllActive || bundles.every((b) => b.status === "installed")
|
anyInstalling || installAllActive || bundles.every((b) => b.status === "installed")
|
||||||
}
|
}
|
||||||
@@ -217,7 +115,6 @@ export function AiFeaturesSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bundle cards */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{bundles.map((bundle) => (
|
{bundles.map((bundle) => (
|
||||||
<BundleCard
|
<BundleCard
|
||||||
@@ -227,12 +124,14 @@ export function AiFeaturesSection() {
|
|||||||
error={errors[bundle.id] ?? null}
|
error={errors[bundle.id] ?? null}
|
||||||
onInstall={() => installBundle(bundle.id)}
|
onInstall={() => installBundle(bundle.id)}
|
||||||
onUninstall={() => uninstallBundle(bundle.id)}
|
onUninstall={() => uninstallBundle(bundle.id)}
|
||||||
|
onReinstall={() => reinstallBundle(bundle.id)}
|
||||||
isInstalling={!!installing[bundle.id]}
|
isInstalling={!!installing[bundle.id]}
|
||||||
|
isQueued={queued.includes(bundle.id)}
|
||||||
|
startTime={startTimes[bundle.id] ?? null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disk usage footer */}
|
|
||||||
{diskUsage !== null && (
|
{diskUsage !== null && (
|
||||||
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
|
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
|
||||||
Disk usage: {formatBytes(diskUsage)}
|
Disk usage: {formatBytes(diskUsage)}
|
||||||
@@ -242,22 +141,56 @@ export function AiFeaturesSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BundleProgress {
|
||||||
|
percent: number;
|
||||||
|
stage: string;
|
||||||
|
}
|
||||||
|
|
||||||
function BundleCard({
|
function BundleCard({
|
||||||
bundle,
|
bundle,
|
||||||
progress,
|
progress,
|
||||||
error,
|
error,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
|
onReinstall,
|
||||||
isInstalling,
|
isInstalling,
|
||||||
|
isQueued,
|
||||||
|
startTime,
|
||||||
}: {
|
}: {
|
||||||
bundle: FeatureBundleState;
|
bundle: FeatureBundleState;
|
||||||
progress: BundleProgress | null;
|
progress: BundleProgress | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
|
onReinstall: () => void;
|
||||||
isInstalling: boolean;
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-border p-4">
|
<div className="rounded-lg border border-border p-4">
|
||||||
@@ -269,7 +202,6 @@ function BundleCard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-4">
|
<div className="flex items-center gap-3 shrink-0 ml-4">
|
||||||
{/* Status indicator */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{status === "installed" && (
|
{status === "installed" && (
|
||||||
<>
|
<>
|
||||||
@@ -283,6 +215,12 @@ function BundleCard({
|
|||||||
<span className="text-xs text-muted-foreground">Not installed</span>
|
<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 && (
|
{status === "installing" && progress && (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||||
@@ -299,7 +237,6 @@ function BundleCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action button */}
|
|
||||||
{status === "not_installed" && !error && (
|
{status === "not_installed" && !error && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -310,15 +247,47 @@ function BundleCard({
|
|||||||
Install
|
Install
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{status === "installed" && (
|
{status === "installed" && !confirming && (
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={onUninstall}
|
type="button"
|
||||||
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"
|
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"
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
>
|
||||||
Uninstall
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</button>
|
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" && (
|
{status === "installing" && (
|
||||||
<button
|
<button
|
||||||
@@ -330,7 +299,7 @@ function BundleCard({
|
|||||||
Installing...
|
Installing...
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(status === "error" || error) && !isInstalling && (
|
{(status === "error" || error) && !isInstalling && !isQueued && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onInstall}
|
onClick={onInstall}
|
||||||
@@ -342,6 +311,22 @@ function BundleCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,242 @@
|
|||||||
import type { FeatureBundleState } from "@ashim/shared";
|
import type { FeatureBundleState } from "@ashim/shared";
|
||||||
import { TOOL_BUNDLE_MAP } from "@ashim/shared";
|
import { TOOL_BUNDLE_MAP } from "@ashim/shared";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
|
||||||
|
interface BundleProgress {
|
||||||
|
percent: number;
|
||||||
|
stage: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FeaturesState {
|
interface FeaturesState {
|
||||||
bundles: FeatureBundleState[];
|
bundles: FeatureBundleState[];
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
|
installing: Record<string, BundleProgress>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
queued: string[];
|
||||||
|
installAllActive: boolean;
|
||||||
|
startTimes: Record<string, number>;
|
||||||
|
|
||||||
fetch: () => Promise<void>;
|
fetch: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
isToolInstalled: (toolId: string) => boolean;
|
isToolInstalled: (toolId: string) => boolean;
|
||||||
getBundleForTool: (toolId: string) => FeatureBundleState | null;
|
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) => ({
|
export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||||
bundles: [],
|
const esRefs: Record<string, EventSource> = {};
|
||||||
loaded: false,
|
const pollRefs: Record<string, ReturnType<typeof setInterval>> = {};
|
||||||
|
const completionRefs: Record<string, () => void> = {};
|
||||||
|
|
||||||
fetch: async () => {
|
const resolveCompletion = (bundleId: string) => {
|
||||||
if (get().loaded) return;
|
if (completionRefs[bundleId]) {
|
||||||
try {
|
completionRefs[bundleId]();
|
||||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
delete completionRefs[bundleId];
|
||||||
set({ bundles: data.bundles, loaded: true });
|
|
||||||
} catch {
|
|
||||||
set({ loaded: true });
|
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
refresh: async () => {
|
const refreshBundles = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
||||||
set({ bundles: data.bundles, loaded: true });
|
set({ bundles: data.bundles, loaded: true });
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
};
|
||||||
|
|
||||||
isToolInstalled: (toolId: string) => {
|
const startPolling = (bundleId: string) => {
|
||||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
if (pollRefs[bundleId]) return;
|
||||||
if (!bundleId) return true;
|
pollRefs[bundleId] = setInterval(async () => {
|
||||||
const bundle = get().bundles.find((b) => b.id === bundleId);
|
try {
|
||||||
return bundle?.status === "installed";
|
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 installing = { ...get().installing };
|
||||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
delete installing[bundleId];
|
||||||
if (!bundleId) return null;
|
set({ installing });
|
||||||
return get().bundles.find((b) => b.id === bundleId) ?? null;
|
|
||||||
},
|
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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"background-removal": {
|
"background-removal": {
|
||||||
"name": "Background Removal",
|
"name": "Background Removal",
|
||||||
"description": "Remove image backgrounds with AI",
|
"description": "Remove image backgrounds with AI",
|
||||||
"estimatedSize": "700 MB - 1 GB",
|
"estimatedSize": "3-4 GB",
|
||||||
"packages": {
|
"packages": {
|
||||||
"common": ["rembg==2.0.62"],
|
"common": ["rembg==2.0.62"],
|
||||||
"amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"],
|
"amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"],
|
||||||
@@ -76,9 +76,9 @@
|
|||||||
"object-eraser-colorize": {
|
"object-eraser-colorize": {
|
||||||
"name": "Object Eraser & Colorize",
|
"name": "Object Eraser & Colorize",
|
||||||
"description": "Erase objects from photos and colorize B&W images",
|
"description": "Erase objects from photos and colorize B&W images",
|
||||||
"estimatedSize": "600-800 MB",
|
"estimatedSize": "1-2 GB",
|
||||||
"packages": {
|
"packages": {
|
||||||
"common": [],
|
"common": ["huggingface-hub"],
|
||||||
"amd64": ["onnxruntime-gpu==1.20.1"],
|
"amd64": ["onnxruntime-gpu==1.20.1"],
|
||||||
"arm64": ["onnxruntime==1.20.1"]
|
"arm64": ["onnxruntime==1.20.1"]
|
||||||
},
|
},
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"description": "AI upscaling, face enhancement, and noise removal",
|
"description": "AI upscaling, face enhancement, and noise removal",
|
||||||
"estimatedSize": "4-5 GB",
|
"estimatedSize": "4-5 GB",
|
||||||
"packages": {
|
"packages": {
|
||||||
"common": ["codeformer-pip==0.0.4", "lpips"],
|
"common": ["codeformer-pip==0.0.4", "lpips", "huggingface-hub"],
|
||||||
"amd64": ["realesrgan==0.3.0 --extra-index-url https://download.pytorch.org/whl/cu126"],
|
"amd64": ["realesrgan==0.3.0 --extra-index-url https://download.pytorch.org/whl/cu126"],
|
||||||
"arm64": ["realesrgan==0.3.0"]
|
"arm64": ["realesrgan==0.3.0"]
|
||||||
},
|
},
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
"description": "Restore old or damaged photos",
|
"description": "Restore old or damaged photos",
|
||||||
"estimatedSize": "800 MB - 1 GB",
|
"estimatedSize": "800 MB - 1 GB",
|
||||||
"packages": {
|
"packages": {
|
||||||
"common": ["codeformer-pip==0.0.4", "lpips"],
|
"common": ["codeformer-pip==0.0.4", "lpips", "huggingface-hub"],
|
||||||
"amd64": [
|
"amd64": [
|
||||||
"onnxruntime-gpu==1.20.1",
|
"onnxruntime-gpu==1.20.1",
|
||||||
"mediapipe==0.10.21",
|
"mediapipe==0.10.21",
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
"description": "Extract text from images",
|
"description": "Extract text from images",
|
||||||
"estimatedSize": "3-4 GB",
|
"estimatedSize": "3-4 GB",
|
||||||
"packages": {
|
"packages": {
|
||||||
"common": [],
|
"common": ["huggingface-hub"],
|
||||||
"amd64": [
|
"amd64": [
|
||||||
"paddlepaddle-gpu>=3.2.1 --extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu126/",
|
"paddlepaddle-gpu>=3.2.1 --extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu126/",
|
||||||
"paddleocr[doc-parser]>=3.4.0,<3.5.0"
|
"paddleocr[doc-parser]>=3.4.0,<3.5.0"
|
||||||
|
|||||||
@@ -252,7 +252,12 @@ def download_hf_snapshot(model: dict, models_dir: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
from huggingface_hub import snapshot_download
|
from huggingface_hub import snapshot_download
|
||||||
snapshot_download(repo_id=repo_id, local_dir=local_dir, repo_type=repo_type)
|
|
||||||
|
kwargs: dict = {"repo_id": repo_id, "local_dir": local_dir, "repo_type": repo_type}
|
||||||
|
if target_file:
|
||||||
|
kwargs["allow_patterns"] = [target_file]
|
||||||
|
|
||||||
|
snapshot_download(**kwargs)
|
||||||
|
|
||||||
# Verify file size if applicable
|
# Verify file size if applicable
|
||||||
if target_file and min_size > 0:
|
if target_file and min_size > 0:
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ export const CATEGORIES: CategoryInfo[] = [
|
|||||||
{ id: "essentials", name: "Essentials", icon: "Layers", color: "#3B82F6" },
|
{ id: "essentials", name: "Essentials", icon: "Layers", color: "#3B82F6" },
|
||||||
{ id: "optimization", name: "Optimization", icon: "Zap", color: "#10B981" },
|
{ id: "optimization", name: "Optimization", icon: "Zap", color: "#10B981" },
|
||||||
{ id: "adjustments", name: "Adjustments", icon: "SlidersHorizontal", color: "#8B5CF6" },
|
{ id: "adjustments", name: "Adjustments", icon: "SlidersHorizontal", color: "#8B5CF6" },
|
||||||
{ id: "ai", name: "AI Tools", icon: "Sparkles", color: "#F59E0B" },
|
|
||||||
{ id: "watermark", name: "Watermark & Overlay", icon: "Stamp", color: "#EF4444" },
|
{ id: "watermark", name: "Watermark & Overlay", icon: "Stamp", color: "#EF4444" },
|
||||||
{ id: "utilities", name: "Utilities", icon: "Wrench", color: "#6366F1" },
|
{ id: "utilities", name: "Utilities", icon: "Wrench", color: "#6366F1" },
|
||||||
{ id: "layout", name: "Layout & Composition", icon: "LayoutGrid", color: "#EC4899" },
|
{ id: "layout", name: "Layout & Composition", icon: "LayoutGrid", color: "#EC4899" },
|
||||||
{ id: "format", name: "Format & Conversion", icon: "FileType", color: "#14B8A6" },
|
{ id: "format", name: "Format & Conversion", icon: "FileType", color: "#14B8A6" },
|
||||||
|
{ id: "ai", name: "AI Tools", icon: "Sparkles", color: "#F59E0B" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TOOLS: Tool[] = [
|
export const TOOLS: Tool[] = [
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
|
|||||||
id: "background-removal",
|
id: "background-removal",
|
||||||
name: "Background Removal",
|
name: "Background Removal",
|
||||||
description: "Remove image backgrounds with AI",
|
description: "Remove image backgrounds with AI",
|
||||||
estimatedSize: "700 MB - 1 GB",
|
estimatedSize: "3-4 GB",
|
||||||
enablesTools: ["remove-background", "passport-photo"],
|
enablesTools: ["remove-background", "passport-photo"],
|
||||||
},
|
},
|
||||||
"face-detection": {
|
"face-detection": {
|
||||||
@@ -39,7 +39,7 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
|
|||||||
id: "object-eraser-colorize",
|
id: "object-eraser-colorize",
|
||||||
name: "Object Eraser & Colorize",
|
name: "Object Eraser & Colorize",
|
||||||
description: "Erase objects from photos and colorize B&W images",
|
description: "Erase objects from photos and colorize B&W images",
|
||||||
estimatedSize: "600-800 MB",
|
estimatedSize: "1-2 GB",
|
||||||
enablesTools: ["erase-object", "colorize"],
|
enablesTools: ["erase-object", "colorize"],
|
||||||
},
|
},
|
||||||
"upscale-enhance": {
|
"upscale-enhance": {
|
||||||
|
|||||||
Reference in New Issue
Block a user