mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(ai): rewrite bridge.ts to stream stderr progress via spawn
This commit is contained in:
+113
-51
@@ -1,22 +1,19 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { resolve, dirname } from "node:path";
|
import { resolve, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const PYTHON_DIR = resolve(__dirname, "../python");
|
const PYTHON_DIR = resolve(__dirname, "../python");
|
||||||
|
|
||||||
/** Try venv first, then system python. */
|
/** Try venv first, then system python. */
|
||||||
function getPythonPath(): string {
|
function getPythonPath(): string {
|
||||||
const venvPath = process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
|
const venvPath =
|
||||||
|
process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
|
||||||
return `${venvPath}/bin/python3`;
|
return `${venvPath}/bin/python3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a user-friendly error from a Python process error.
|
* Extract a user-friendly error from a Python process error.
|
||||||
* Python scripts print JSON to stderr/stdout on failure — try to parse it.
|
|
||||||
*/
|
*/
|
||||||
function extractPythonError(error: unknown): string {
|
function extractPythonError(error: unknown): string {
|
||||||
if (error && typeof error === "object") {
|
if (error && typeof error === "object") {
|
||||||
@@ -25,14 +22,12 @@ function extractPythonError(error: unknown): string {
|
|||||||
stdout?: string;
|
stdout?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
// Try stdout first (Python scripts write JSON errors there), then stderr
|
|
||||||
for (const output of [execError.stdout, execError.stderr]) {
|
for (const output of [execError.stdout, execError.stderr]) {
|
||||||
if (output) {
|
if (output) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(output.trim());
|
const parsed = JSON.parse(output.trim());
|
||||||
if (parsed.error) return parsed.error;
|
if (parsed.error) return parsed.error;
|
||||||
} catch {
|
} catch {
|
||||||
// Not JSON, check for human-readable content
|
|
||||||
const trimmed = output.trim();
|
const trimmed = output.trim();
|
||||||
if (trimmed && !trimmed.startsWith("Traceback")) {
|
if (trimmed && !trimmed.startsWith("Traceback")) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
@@ -45,6 +40,114 @@ function extractPythonError(error: unknown): string {
|
|||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProgressCallback {
|
||||||
|
(percent: number, stage: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a Python script with real-time progress streaming via stderr.
|
||||||
|
* Falls back to system python3 if the venv is not available.
|
||||||
|
*
|
||||||
|
* Python scripts emit progress as JSON lines to stderr:
|
||||||
|
* {"progress": 50, "stage": "Processing..."}
|
||||||
|
*
|
||||||
|
* Non-JSON stderr lines are collected as error output (backward compatible).
|
||||||
|
*/
|
||||||
|
export function runPythonWithProgress(
|
||||||
|
scriptName: string,
|
||||||
|
args: string[],
|
||||||
|
options: {
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
timeout?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const scriptPath = resolve(PYTHON_DIR, scriptName);
|
||||||
|
const timeout = options.timeout ?? 300000;
|
||||||
|
|
||||||
|
return new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
const trySpawn = (pythonBin: string, isFallback: boolean) => {
|
||||||
|
const child = spawn(pythonBin, [scriptPath, ...args], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
const stderrLines: string[] = [];
|
||||||
|
let stderrBuffer = "";
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderrBuffer += chunk.toString();
|
||||||
|
const lines = stderrBuffer.split("\n");
|
||||||
|
stderrBuffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (
|
||||||
|
typeof parsed.progress === "number" &&
|
||||||
|
typeof parsed.stage === "string"
|
||||||
|
) {
|
||||||
|
options.onProgress?.(parsed.progress, parsed.stage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON — collect as regular stderr
|
||||||
|
}
|
||||||
|
stderrLines.push(trimmed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.code === "ENOENT" && !isFallback) {
|
||||||
|
trySpawn("python3", true);
|
||||||
|
} else {
|
||||||
|
rejectPromise(new Error(extractPythonError(err)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (stderrBuffer.trim()) {
|
||||||
|
stderrLines.push(stderrBuffer.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
rejectPromise(new Error("Python script timed out"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderr = stderrLines.join("\n");
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errorText =
|
||||||
|
extractPythonError({ stdout: stdout.trim(), stderr }) ||
|
||||||
|
`Python script exited with code ${code}`;
|
||||||
|
rejectPromise(new Error(errorText));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePromise({ stdout: stdout.trim(), stderr });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
trySpawn(getPythonPath(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a Python script from packages/ai/python/ with the given arguments.
|
* Run a Python script from packages/ai/python/ with the given arguments.
|
||||||
* Falls back to system python3 if the venv is not available.
|
* Falls back to system python3 if the venv is not available.
|
||||||
@@ -52,48 +155,7 @@ function extractPythonError(error: unknown): string {
|
|||||||
export async function runPythonScript(
|
export async function runPythonScript(
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
timeoutMs = 300000, // 5 min default
|
timeoutMs = 300000,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
const scriptPath = resolve(PYTHON_DIR, scriptName);
|
return runPythonWithProgress(scriptName, args, { timeout: timeoutMs });
|
||||||
const pythonPath = getPythonPath();
|
|
||||||
|
|
||||||
const execOpts = {
|
|
||||||
timeout: timeoutMs,
|
|
||||||
maxBuffer: 50 * 1024 * 1024, // 50MB
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execFileAsync(
|
|
||||||
pythonPath,
|
|
||||||
[scriptPath, ...args],
|
|
||||||
execOpts,
|
|
||||||
);
|
|
||||||
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
||||||
} catch (venvError: unknown) {
|
|
||||||
// Only fall back to system python if the venv python binary doesn't exist
|
|
||||||
// (ENOENT). If the script itself failed, re-throw — don't hide the error.
|
|
||||||
const isNotFound =
|
|
||||||
venvError &&
|
|
||||||
typeof venvError === "object" &&
|
|
||||||
"code" in venvError &&
|
|
||||||
(venvError as { code?: string }).code === "ENOENT";
|
|
||||||
|
|
||||||
if (!isNotFound) {
|
|
||||||
const message = extractPythonError(venvError);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// venv python not found — try system python3 as fallback
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execFileAsync(
|
|
||||||
"python3",
|
|
||||||
[scriptPath, ...args],
|
|
||||||
execOpts,
|
|
||||||
);
|
|
||||||
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
||||||
} catch (fallbackError: unknown) {
|
|
||||||
const message = extractPythonError(fallbackError);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user