fix: tool page race condition on refresh and install error reporting

- Subscribe to features store reactively in ToolPage so refresh shows
  install prompt correctly instead of the tool UI
- Show loading state while features are being fetched for AI tools
- Capture stdout from install script for better error messages
- Keep last 20 stderr lines for error context instead of just exit code
- Clear install progress on success (was leaving stale state)
- Pass PIP_CACHE_DIR to install subprocess
This commit is contained in:
ashim-hq
2026-04-18 10:55:22 +08:00
parent 3b2612ac72
commit 3eab3d5391
2 changed files with 41 additions and 11 deletions
+16 -6
View File
@@ -133,27 +133,35 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
const modelsDir = getModelsDir(); const modelsDir = getModelsDir();
const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], { const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], {
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: { env: {
...process.env, ...process.env,
BUNDLE_ID: bundleId, BUNDLE_ID: bundleId,
PIP_CACHE_DIR: join(getAiDir(), "pip-cache"),
}, },
}); });
let stderrBuffer = ""; let stderrBuffer = "";
let stdoutBuffer = "";
const lastStderrLines: string[] = [];
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => { child.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString(); stderrBuffer += chunk.toString();
// Process complete lines
const lines = stderrBuffer.split("\n"); const lines = stderrBuffer.split("\n");
// Keep the last incomplete line in the buffer
stderrBuffer = lines.pop() ?? ""; stderrBuffer = lines.pop() ?? "";
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
lastStderrLines.push(trimmed);
if (lastStderrLines.length > 20) lastStderrLines.shift();
try { try {
const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string }; const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string };
if (typeof parsed.progress === "number") { if (typeof parsed.progress === "number") {
@@ -170,7 +178,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
}); });
} }
} catch { } 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<void>
if (code === 0) { if (code === 0) {
invalidateCache(); invalidateCache();
shutdownDispatcher(); shutdownDispatcher();
setInstallProgress(bundleId, { percent: 100, stage: "Complete" }, null); setInstallProgress(null, null, null);
updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" }); updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" });
} else { } 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); setInstallProgress(bundleId, null, errorMsg);
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg }); updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
} }
+25 -5
View File
@@ -1,4 +1,4 @@
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@ashim/shared"; import { PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@ashim/shared";
import { import {
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
@@ -110,12 +110,22 @@ export function ToolPage() {
[toolId], [toolId],
); );
const isAiTool = toolId ? (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId) : false; const isAiTool = toolId ? (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId) : false;
const getBundleForTool = useFeaturesStore((s) => s.getBundleForTool); const featuresLoaded = useFeaturesStore((s) => s.loaded);
const isToolInstalled = useFeaturesStore((s) => s.isToolInstalled); const featureBundles = useFeaturesStore((s) => s.bundles);
const featureBundle = toolId ? getBundleForTool(toolId) : null; const fetchFeatures = useFeaturesStore((s) => s.fetch);
const toolInstalled = toolId ? isToolInstalled(toolId) : true; 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 { hasPermission } = useAuth();
const isAdmin = hasPermission("settings:write"); const isAdmin = hasPermission("settings:write");
useEffect(() => {
if (isAiTool) fetchFeatures();
}, [isAiTool, fetchFeatures]);
const { const {
files, files,
entries, entries,
@@ -244,6 +254,16 @@ export function ToolPage() {
); );
} }
if (isAiTool && !featuresLoaded) {
return (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
</AppLayout>
);
}
if (isAiTool && !toolInstalled && featureBundle) { if (isAiTool && !toolInstalled && featureBundle) {
return ( return (
<AppLayout> <AppLayout>