diff --git a/apps/api/src/routes/features.ts b/apps/api/src/routes/features.ts index 6fbeb42c..10f67f19 100644 --- a/apps/api/src/routes/features.ts +++ b/apps/api/src/routes/features.ts @@ -133,27 +133,35 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise const modelsDir = getModelsDir(); const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], { - stdio: ["ignore", "ignore", "pipe"], + stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, BUNDLE_ID: bundleId, + PIP_CACHE_DIR: join(getAiDir(), "pip-cache"), }, }); let stderrBuffer = ""; + let stdoutBuffer = ""; + const lastStderrLines: string[] = []; + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBuffer += chunk.toString(); + }); child.stderr.on("data", (chunk: Buffer) => { stderrBuffer += chunk.toString(); - // Process complete lines const lines = stderrBuffer.split("\n"); - // Keep the last incomplete line in the buffer stderrBuffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; + lastStderrLines.push(trimmed); + if (lastStderrLines.length > 20) lastStderrLines.shift(); + try { const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string }; if (typeof parsed.progress === "number") { @@ -170,7 +178,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise }); } } catch { - // Not JSON — ignore non-progress stderr output + // Not JSON progress — rembg/pip output noise, keep in lastStderrLines for error reporting } } }); @@ -181,10 +189,12 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise if (code === 0) { invalidateCache(); shutdownDispatcher(); - setInstallProgress(bundleId, { percent: 100, stage: "Complete" }, null); + setInstallProgress(null, null, null); updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" }); } else { - const errorMsg = `Install failed with exit code ${code}`; + const errorDetail = + lastStderrLines.filter((l) => !l.startsWith("{")).join("\n") || stdoutBuffer.trim(); + const errorMsg = errorDetail || `Install failed with exit code ${code}`; setInstallProgress(bundleId, null, errorMsg); updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg }); } diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index cb4a56b0..25d47e99 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -1,4 +1,4 @@ -import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@ashim/shared"; +import { PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@ashim/shared"; import { CheckCircle2, ChevronLeft, @@ -110,12 +110,22 @@ export function ToolPage() { [toolId], ); const isAiTool = toolId ? (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId) : false; - const getBundleForTool = useFeaturesStore((s) => s.getBundleForTool); - const isToolInstalled = useFeaturesStore((s) => s.isToolInstalled); - const featureBundle = toolId ? getBundleForTool(toolId) : null; - const toolInstalled = toolId ? isToolInstalled(toolId) : true; + const featuresLoaded = useFeaturesStore((s) => s.loaded); + const featureBundles = useFeaturesStore((s) => s.bundles); + const fetchFeatures = useFeaturesStore((s) => s.fetch); + const featureBundle = useMemo(() => { + if (!toolId) return null; + const bundleId = TOOL_BUNDLE_MAP[toolId]; + if (!bundleId) return null; + return featureBundles.find((b) => b.id === bundleId) ?? null; + }, [toolId, featureBundles]); + const toolInstalled = featureBundle ? featureBundle.status === "installed" : !isAiTool; const { hasPermission } = useAuth(); const isAdmin = hasPermission("settings:write"); + + useEffect(() => { + if (isAiTool) fetchFeatures(); + }, [isAiTool, fetchFeatures]); const { files, entries, @@ -244,6 +254,16 @@ export function ToolPage() { ); } + if (isAiTool && !featuresLoaded) { + return ( + +
+ Loading... +
+
+ ); + } + if (isAiTool && !toolInstalled && featureBundle) { return (