From 10bdc24a4a079104f02c3ee4eb1fe3a64a8e7147 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sun, 19 Apr 2026 19:52:14 +0800 Subject: [PATCH] feat: on-demand AI feature install with progress indicators --- .../components/common/before-after-slider.tsx | 9 +- .../layout/ai-install-indicator.tsx | 70 ++++ apps/web/src/components/layout/app-layout.tsx | 4 + .../settings/ai-features-section.tsx | 359 +++++++++--------- apps/web/src/stores/features-store.ts | 246 ++++++++++-- docker/feature-manifest.json | 12 +- packages/ai/python/install_feature.py | 7 +- packages/shared/src/constants.ts | 2 +- packages/shared/src/features.ts | 4 +- 9 files changed, 488 insertions(+), 225 deletions(-) create mode 100644 apps/web/src/components/layout/ai-install-indicator.tsx diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index d30cca9f..975c6fcb 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -83,7 +83,7 @@ export function BeforeAfterSlider({ : null; return ( -
+
{/* Slider container */}
{/* Before image (full width, bottom layer) */} - Original + Original {/* After image (clipped, top layer) */}
{ + 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 ( + + ); +} + +function IndicatorContent({ + name, + percent, + completedCount, + totalBundles, + queuedCount, +}: { + name: string; + percent: number; + completedCount: number; + totalBundles: number; + queuedCount: number; +}) { + return ( +
+
+ +

Installing {name}

+
+
+
+
+
+
+ + {percent}% +
+ + {completedCount}/{totalBundles} installed + {queuedCount > 0 && ` ยท ${queuedCount} queued`} + +
+
+ ); +} diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx index 42f7e000..262f2bc1 100644 --- a/apps/web/src/components/layout/app-layout.tsx +++ b/apps/web/src/components/layout/app-layout.tsx @@ -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 */} setHelpOpen(false)} /> + + {/* Global AI install progress */} +
); } diff --git a/apps/web/src/components/settings/ai-features-section.tsx b/apps/web/src/components/settings/ai-features-section.tsx index 7654ddb9..f8a60ded 100644 --- a/apps/web/src/components/settings/ai-features-section.tsx +++ b/apps/web/src/components/settings/ai-features-section.tsx @@ -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>({}); - const [errors, setErrors] = useState>({}); - const [diskUsage, setDiskUsage] = useState(null); - const [installAllActive, setInstallAllActive] = useState(false); - const esRefs = useRef>({}); - const pollRefs = useRef>>({}); +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(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((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 (
- {/* Header */}

AI Features

@@ -206,7 +104,7 @@ export function AiFeaturesSection() {
- {/* Bundle cards */}
{bundles.map((bundle) => ( 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} /> ))}
- {/* Disk usage footer */} {diskUsage !== null && (

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 (

@@ -269,7 +202,6 @@ function BundleCard({

- {/* Status indicator */}
{status === "installed" && ( <> @@ -283,6 +215,12 @@ function BundleCard({ Not installed )} + {status === "queued" && ( + <> + + Queued + + )} {status === "installing" && progress && ( <> @@ -299,7 +237,6 @@ function BundleCard({ )}
- {/* Action button */} {status === "not_installed" && !error && ( + {status === "installed" && !confirming && ( +
+ + +
+ )} + {status === "installed" && confirming && ( +
+ + +
)} {status === "installing" && (
+ {status === "installing" && progress && ( +
+
+
+
+
+

+ {PROGRESS_MESSAGES[messageIndex]} +

+ {eta &&

{eta}

} +
+
+ )}
); } diff --git a/apps/web/src/stores/features-store.ts b/apps/web/src/stores/features-store.ts index 2d833a7a..ce89a843 100644 --- a/apps/web/src/stores/features-store.ts +++ b/apps/web/src/stores/features-store.ts @@ -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; + errors: Record; + queued: string[]; + installAllActive: boolean; + startTimes: Record; + fetch: () => Promise; refresh: () => Promise; isToolInstalled: (toolId: string) => boolean; getBundleForTool: (toolId: string) => FeatureBundleState | null; + installBundle: (bundleId: string) => Promise; + uninstallBundle: (bundleId: string) => Promise; + reinstallBundle: (bundleId: string) => Promise; + installAll: () => Promise; + clearError: (bundleId: string) => void; } -export const useFeaturesStore = create((set, get) => ({ - bundles: [], - loaded: false, +export const useFeaturesStore = create((set, get) => { + const esRefs: Record = {}; + const pollRefs: Record> = {}; + const completionRefs: Record 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((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 }); + }, + }; +}); diff --git a/docker/feature-manifest.json b/docker/feature-manifest.json index a2fa641c..9468254b 100644 --- a/docker/feature-manifest.json +++ b/docker/feature-manifest.json @@ -7,7 +7,7 @@ "background-removal": { "name": "Background Removal", "description": "Remove image backgrounds with AI", - "estimatedSize": "700 MB - 1 GB", + "estimatedSize": "3-4 GB", "packages": { "common": ["rembg==2.0.62"], "amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"], @@ -76,9 +76,9 @@ "object-eraser-colorize": { "name": "Object Eraser & Colorize", "description": "Erase objects from photos and colorize B&W images", - "estimatedSize": "600-800 MB", + "estimatedSize": "1-2 GB", "packages": { - "common": [], + "common": ["huggingface-hub"], "amd64": ["onnxruntime-gpu==1.20.1"], "arm64": ["onnxruntime==1.20.1"] }, @@ -130,7 +130,7 @@ "description": "AI upscaling, face enhancement, and noise removal", "estimatedSize": "4-5 GB", "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"], "arm64": ["realesrgan==0.3.0"] }, @@ -199,7 +199,7 @@ "description": "Restore old or damaged photos", "estimatedSize": "800 MB - 1 GB", "packages": { - "common": ["codeformer-pip==0.0.4", "lpips"], + "common": ["codeformer-pip==0.0.4", "lpips", "huggingface-hub"], "amd64": [ "onnxruntime-gpu==1.20.1", "mediapipe==0.10.21", @@ -272,7 +272,7 @@ "description": "Extract text from images", "estimatedSize": "3-4 GB", "packages": { - "common": [], + "common": ["huggingface-hub"], "amd64": [ "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" diff --git a/packages/ai/python/install_feature.py b/packages/ai/python/install_feature.py index b0161f7d..ca3e738c 100644 --- a/packages/ai/python/install_feature.py +++ b/packages/ai/python/install_feature.py @@ -252,7 +252,12 @@ def download_hf_snapshot(model: dict, models_dir: str) -> None: return 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 if target_file and min_size > 0: diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index dc75f1a8..3de51a53 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -4,11 +4,11 @@ export const CATEGORIES: CategoryInfo[] = [ { id: "essentials", name: "Essentials", icon: "Layers", color: "#3B82F6" }, { id: "optimization", name: "Optimization", icon: "Zap", color: "#10B981" }, { 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: "utilities", name: "Utilities", icon: "Wrench", color: "#6366F1" }, { id: "layout", name: "Layout & Composition", icon: "LayoutGrid", color: "#EC4899" }, { id: "format", name: "Format & Conversion", icon: "FileType", color: "#14B8A6" }, + { id: "ai", name: "AI Tools", icon: "Sparkles", color: "#F59E0B" }, ]; export const TOOLS: Tool[] = [ diff --git a/packages/shared/src/features.ts b/packages/shared/src/features.ts index e6be16e2..dbbe1da4 100644 --- a/packages/shared/src/features.ts +++ b/packages/shared/src/features.ts @@ -25,7 +25,7 @@ export const FEATURE_BUNDLES: Record = { id: "background-removal", name: "Background Removal", description: "Remove image backgrounds with AI", - estimatedSize: "700 MB - 1 GB", + estimatedSize: "3-4 GB", enablesTools: ["remove-background", "passport-photo"], }, "face-detection": { @@ -39,7 +39,7 @@ export const FEATURE_BUNDLES: Record = { id: "object-eraser-colorize", name: "Object Eraser & Colorize", description: "Erase objects from photos and colorize B&W images", - estimatedSize: "600-800 MB", + estimatedSize: "1-2 GB", enablesTools: ["erase-object", "colorize"], }, "upscale-enhance": {