From 645324cde60eba825d7f2f0eabcd3c0f0bb02f8f Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sat, 18 Apr 2026 02:54:44 +0800 Subject: [PATCH] feat: add AI Features settings panel for managing feature bundles --- .../settings/ai-features-section.tsx | 347 ++++++++++++++++++ .../components/settings/settings-dialog.tsx | 5 + 2 files changed, 352 insertions(+) create mode 100644 apps/web/src/components/settings/ai-features-section.tsx diff --git a/apps/web/src/components/settings/ai-features-section.tsx b/apps/web/src/components/settings/ai-features-section.tsx new file mode 100644 index 00000000..7654ddb9 --- /dev/null +++ b/apps/web/src/components/settings/ai-features-section.tsx @@ -0,0 +1,347 @@ +import type { FeatureBundleState } from "@ashim/shared"; +import { Download, Loader2, RotateCcw, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiGet, apiPost } 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`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + 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>>({}); + + 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 loadDiskUsage = useCallback(async () => { + try { + const data = await apiGet<{ totalBytes: number }>("/v1/admin/features/disk-usage"); + setDiskUsage(data.totalBytes); + } catch { + /* ignore */ + } + }, []); + + 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], + ); + + 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(); + loadDiskUsage(); + } catch (err) { + 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((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]); + + const anyInstalling = Object.keys(installing).length > 0; + + return ( +
+ {/* Header */} +
+
+

AI Features

+

+ Manage AI model bundles for advanced image processing. +

+
+ +
+ + {/* Bundle cards */} +
+ {bundles.map((bundle) => ( + installBundle(bundle.id)} + onUninstall={() => uninstallBundle(bundle.id)} + isInstalling={!!installing[bundle.id]} + /> + ))} +
+ + {/* Disk usage footer */} + {diskUsage !== null && ( +

+ Disk usage: {formatBytes(diskUsage)} +

+ )} +
+ ); +} + +function BundleCard({ + bundle, + progress, + error, + onInstall, + onUninstall, + isInstalling, +}: { + bundle: FeatureBundleState; + progress: BundleProgress | null; + error: string | null; + onInstall: () => void; + onUninstall: () => void; + isInstalling: boolean; +}) { + const status = isInstalling ? "installing" : bundle.status; + + return ( +
+
+
+

{bundle.name}

+

+ {bundle.description} (~{bundle.estimatedSize}) +

+
+
+ {/* Status indicator */} +
+ {status === "installed" && ( + <> + + Installed + + )} + {status === "not_installed" && !error && ( + <> + + Not installed + + )} + {status === "installing" && progress && ( + <> + + {progress.percent}% + + )} + {(status === "error" || error) && ( + <> + + + {error ?? bundle.error} + + + )} +
+ + {/* Action button */} + {status === "not_installed" && !error && ( + + )} + {status === "installed" && ( + + )} + {status === "installing" && ( + + )} + {(status === "error" || error) && !isInstalling && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index fac31d2d..a1950fad 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -15,6 +15,7 @@ import { Search, Settings, Shield, + Sparkles, Trash2, UserPlus, Users, @@ -27,6 +28,7 @@ import { useAuth } from "@/hooks/use-auth"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { cn, copyToClipboard } from "@/lib/utils"; import { GemLogo } from "../common/gem-logo"; +import { AiFeaturesSection } from "./ai-features-section"; interface SettingsDialogProps { open: boolean; @@ -40,6 +42,7 @@ type Section = | "people" | "teams" | "api-keys" + | "ai-features" | "tools" | "about"; @@ -57,6 +60,7 @@ const NAV_ITEMS: NavItem[] = [ { id: "people", label: "People", icon: Users, requiredPermission: "users:manage" }, { id: "teams", label: "Teams", icon: UsersRound, requiredPermission: "teams:manage" }, { id: "api-keys", label: "API Keys", icon: Key }, + { id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" }, { id: "tools", label: "Tools", icon: Wrench }, { id: "about", label: "About", icon: Info }, ]; @@ -131,6 +135,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "people" && } {section === "teams" && } {section === "api-keys" && } + {section === "ai-features" && } {section === "tools" && } {section === "about" && }