Files
realtime_voice_bot/src/python-runtime.ts

204 lines
5.2 KiB
TypeScript

import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import type { AppConfig } from "./config.js";
export interface PythonCommandSpec {
command: string;
args: string[];
viaCmdShell?: boolean;
}
function shouldUseCmdShell(command: string): boolean {
if (process.platform !== "win32") {
return false;
}
const lower = command.toLowerCase();
return !path.isAbsolute(command) || lower.endsWith(".bat") || lower.endsWith(".cmd");
}
function splitCommand(command: string): string[] {
const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
return parts.map((part) => part.replace(/^"(.*)"$/, "$1"));
}
function quoteWindowsCmdArg(value: string): string {
if (!/[ \t"&()<>^|]/.test(value)) {
return value;
}
return `"${value.replace(/"/g, '""')}"`;
}
function buildWindowsCommandLine(parts: string[]): string {
return parts.map((part) => quoteWindowsCmdArg(part)).join(" ");
}
export function buildPythonInvocation(spec: PythonCommandSpec, extraArgs: string[]): PythonCommandSpec {
if (process.platform === "win32" && spec.viaCmdShell) {
return {
command: "cmd.exe",
args: ["/d", "/s", "/c", buildWindowsCommandLine([spec.command, ...spec.args, ...extraArgs])],
};
}
return {
command: spec.command,
args: [...spec.args, ...extraArgs],
};
}
async function canRun(command: string, args: string[], viaCmdShell = false): Promise<boolean> {
const invocation = viaCmdShell
? {
command: "cmd.exe",
args: ["/d", "/s", "/c", buildWindowsCommandLine([command, ...args, "--version"])],
}
: {
command,
args: [...args, "--version"],
};
return await new Promise<boolean>((resolve) => {
const child = spawn(invocation.command, invocation.args, {
stdio: ["ignore", "ignore", "ignore"],
windowsHide: true,
});
child.on("error", () => {
resolve(false);
});
child.on("exit", (code) => {
resolve(code === 0);
});
});
}
async function captureStdout(command: string, args: string[]): Promise<string | null> {
return await new Promise<string | null>((resolve) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
let stdout = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.on("error", () => {
resolve(null);
});
child.on("exit", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
resolve(null);
});
});
}
async function resolveWindowsExecutable(name: string): Promise<string | null> {
const stdout = await captureStdout("cmd.exe", ["/d", "/s", "/c", `where ${name}`]);
if (!stdout) {
return null;
}
const candidates = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const candidate of candidates) {
try {
await access(candidate, fsConstants.F_OK);
return candidate;
} catch {
// ignore
}
}
return null;
}
async function fileExists(target: string): Promise<boolean> {
try {
await access(target, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
export async function resolvePythonCommand(config: AppConfig): Promise<{ command: string; args: string[] }> {
return await resolveWorkerPythonCommand(config);
}
export async function resolveBasePythonCommand(config: AppConfig): Promise<PythonCommandSpec> {
const configured = config.LOCAL_AI_PYTHON?.trim();
if (configured) {
const [command, ...args] = splitCommand(configured);
if (!command) {
throw new Error("LOCAL_AI_PYTHON 값이 비어 있습니다.");
}
return {
command,
args,
viaCmdShell: shouldUseCmdShell(command),
};
}
const venvPath = resolveVenvPythonPath(config);
if (await fileExists(venvPath)) {
return { command: venvPath, args: [] };
}
if (process.platform === "win32") {
return {
command: "python",
args: [],
viaCmdShell: true,
};
}
const unixCandidates = [
{ command: "python3", args: [] as string[] },
{ command: "python", args: [] as string[] },
];
for (const candidate of unixCandidates) {
if (await canRun(candidate.command, candidate.args)) {
return candidate;
}
}
throw new Error("사용 가능한 Python 실행기를 찾지 못했습니다. `python3 --version` 또는 `python --version` 이 먼저 동작해야 합니다.");
}
export async function resolveWorkerPythonCommand(config: AppConfig): Promise<PythonCommandSpec> {
const venvPath = resolveVenvPythonPath(config);
if (await fileExists(venvPath)) {
return { command: venvPath, args: [] };
}
return await resolveBasePythonCommand(config);
}
export function resolveVenvPythonPath(config: AppConfig): string {
const root = path.resolve(process.cwd(), config.LOCAL_AI_VENV_PATH);
if (process.platform === "win32") {
return path.join(root, "Scripts", "python.exe");
}
return path.join(root, "bin", "python");
}
export function resolveWorkerScript(name: string): string {
return path.resolve(process.cwd(), "python", name);
}