356 lines
9.9 KiB
TypeScript
356 lines
9.9 KiB
TypeScript
import { app } from 'electron';
|
||
import { spawn } from 'node:child_process';
|
||
import { createRequire } from 'node:module';
|
||
import { existsSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { getUvMirrorEnv } from './uv-env';
|
||
import {
|
||
findManagedPythonPath,
|
||
resolveUvBin,
|
||
setupManagedPython,
|
||
} from './uv-setup';
|
||
import { logger } from './logger';
|
||
import { getOpenClawConfigDir, needsWinShell, quoteForCmd } from './paths';
|
||
import { buildDotnetEnv, resolveDotnetExecutable } from './dotnet-runtime';
|
||
|
||
export type OfficeRuntimeStatus = 'ok' | 'warning' | 'error';
|
||
|
||
export interface OfficeRuntimeCheck {
|
||
id: string;
|
||
label: string;
|
||
status: OfficeRuntimeStatus;
|
||
detail: string;
|
||
}
|
||
|
||
export interface OfficeSkillRuntimeDiagnostics {
|
||
capturedAt: number;
|
||
ok: boolean;
|
||
repairAttempted: boolean;
|
||
python: {
|
||
executable: string | null;
|
||
packages: Array<{
|
||
packageName: string;
|
||
importName: string;
|
||
installed: boolean;
|
||
}>;
|
||
};
|
||
node: {
|
||
modules: Array<{
|
||
name: string;
|
||
installed: boolean;
|
||
resolvedPath: string | null;
|
||
}>;
|
||
};
|
||
dotnet: {
|
||
available: boolean;
|
||
version: string | null;
|
||
};
|
||
checks: OfficeRuntimeCheck[];
|
||
}
|
||
|
||
type CommandResult = {
|
||
ok: boolean;
|
||
stdout: string;
|
||
stderr: string;
|
||
code: number | null;
|
||
error?: string;
|
||
};
|
||
|
||
const PYTHON_PACKAGES = [
|
||
{ packageName: 'pypdf', importName: 'pypdf' },
|
||
{ packageName: 'reportlab', importName: 'reportlab' },
|
||
{ packageName: 'pandas', importName: 'pandas' },
|
||
{ packageName: 'openpyxl', importName: 'openpyxl' },
|
||
{ packageName: 'matplotlib', importName: 'matplotlib' },
|
||
{ packageName: 'beautifulsoup4', importName: 'bs4' },
|
||
] as const;
|
||
|
||
const NODE_MODULES = [
|
||
'docx',
|
||
'pptxgenjs',
|
||
'react-icons',
|
||
'react',
|
||
'react-dom/server',
|
||
'sharp',
|
||
] as const;
|
||
|
||
const PYTHON_INSTALL_TIMEOUT_MS = 10 * 60_000;
|
||
const COMMAND_TIMEOUT_MS = 20_000;
|
||
|
||
let warmupPromise: Promise<OfficeSkillRuntimeDiagnostics> | null = null;
|
||
|
||
function appendOutput(current: string, chunk: Buffer): string {
|
||
const next = current + chunk.toString('utf8');
|
||
return next.length <= 12_000 ? next : next.slice(next.length - 12_000);
|
||
}
|
||
|
||
function runCommand(
|
||
command: string,
|
||
args: string[],
|
||
options: {
|
||
env?: Record<string, string | undefined>;
|
||
timeoutMs?: number;
|
||
shell?: boolean;
|
||
} = {},
|
||
): Promise<CommandResult> {
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
let stdout = '';
|
||
let stderr = '';
|
||
const finish = (result: CommandResult) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
resolve(result);
|
||
};
|
||
|
||
try {
|
||
const child = spawn(command, args, {
|
||
env: options.env,
|
||
shell: options.shell,
|
||
windowsHide: true,
|
||
});
|
||
const timeout = setTimeout(() => {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
finish({
|
||
ok: false,
|
||
stdout,
|
||
stderr,
|
||
code: null,
|
||
error: `Timed out after ${options.timeoutMs ?? COMMAND_TIMEOUT_MS}ms`,
|
||
});
|
||
}, options.timeoutMs ?? COMMAND_TIMEOUT_MS);
|
||
|
||
child.stdout?.on('data', (data) => {
|
||
stdout = appendOutput(stdout, data);
|
||
});
|
||
child.stderr?.on('data', (data) => {
|
||
stderr = appendOutput(stderr, data);
|
||
});
|
||
child.on('error', (error) => {
|
||
clearTimeout(timeout);
|
||
finish({
|
||
ok: false,
|
||
stdout,
|
||
stderr,
|
||
code: null,
|
||
error: error.message,
|
||
});
|
||
});
|
||
child.on('close', (code) => {
|
||
clearTimeout(timeout);
|
||
finish({
|
||
ok: code === 0,
|
||
stdout,
|
||
stderr,
|
||
code,
|
||
});
|
||
});
|
||
} catch (error) {
|
||
finish({
|
||
ok: false,
|
||
stdout,
|
||
stderr,
|
||
code: null,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildRequireCandidates(): NodeRequire[] {
|
||
const candidates: string[] = [
|
||
path.join(process.cwd(), 'package.json'),
|
||
path.join(app.getAppPath(), 'package.json'),
|
||
path.join(process.resourcesPath || '', 'app.asar', 'package.json'),
|
||
process.resourcesPath ? path.join(process.resourcesPath, 'openclaw', 'package.json') : '',
|
||
path.join(getOpenClawConfigDir(), 'runtime', 'openclaw', 'package.json'),
|
||
].filter(Boolean);
|
||
|
||
const requires: NodeRequire[] = [createRequire(import.meta.url)];
|
||
for (const candidate of candidates) {
|
||
try {
|
||
if (existsSync(candidate)) {
|
||
requires.push(createRequire(candidate));
|
||
}
|
||
} catch {
|
||
// ignore invalid require roots
|
||
}
|
||
}
|
||
return requires;
|
||
}
|
||
|
||
function resolveNodeModule(name: string): string | null {
|
||
for (const req of buildRequireCandidates()) {
|
||
try {
|
||
return req.resolve(name);
|
||
} catch {
|
||
// try next candidate
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function checkPythonPackage(pythonPath: string | null, importName: string): Promise<boolean> {
|
||
if (!pythonPath) return false;
|
||
const result = await runCommand(pythonPath, ['-c', `__import__(${JSON.stringify(importName)})`]);
|
||
return result.ok;
|
||
}
|
||
|
||
async function installPythonPackages(pythonPath: string, packageNames: string[]): Promise<void> {
|
||
if (packageNames.length === 0) return;
|
||
const { bin: uvBin } = resolveUvBin();
|
||
const useShell = needsWinShell(uvBin);
|
||
const uvEnv = await getUvMirrorEnv();
|
||
const args = ['pip', 'install', '--python', pythonPath, '--break-system-packages', ...packageNames];
|
||
const baseEnv = { ...process.env };
|
||
|
||
const first = await runCommand(useShell ? quoteForCmd(uvBin) : uvBin, args, {
|
||
shell: useShell,
|
||
env: { ...baseEnv, ...uvEnv },
|
||
timeoutMs: PYTHON_INSTALL_TIMEOUT_MS,
|
||
});
|
||
if (first.ok) return;
|
||
|
||
if (Object.keys(uvEnv).length > 0) {
|
||
const second = await runCommand(useShell ? quoteForCmd(uvBin) : uvBin, args, {
|
||
shell: useShell,
|
||
env: baseEnv,
|
||
timeoutMs: PYTHON_INSTALL_TIMEOUT_MS,
|
||
});
|
||
if (second.ok) return;
|
||
throw new Error(second.stderr || second.stdout || second.error || 'uv pip install failed');
|
||
}
|
||
|
||
throw new Error(first.stderr || first.stdout || first.error || 'uv pip install failed');
|
||
}
|
||
|
||
async function checkDotnet(): Promise<{ available: boolean; version: string | null }> {
|
||
const env = buildDotnetEnv(process.env as Record<string, string | undefined>);
|
||
const dotnetBin = resolveDotnetExecutable() ?? 'dotnet';
|
||
const result = await runCommand(dotnetBin, ['--version'], { env });
|
||
return {
|
||
available: result.ok,
|
||
version: result.ok ? result.stdout.trim() || null : null,
|
||
};
|
||
}
|
||
|
||
export async function buildOfficeSkillRuntimeDiagnostics(options: {
|
||
repair?: boolean;
|
||
} = {}): Promise<OfficeSkillRuntimeDiagnostics> {
|
||
const repairAttempted = options.repair === true;
|
||
|
||
if (repairAttempted) {
|
||
await setupManagedPython();
|
||
}
|
||
|
||
let pythonPath = await findManagedPythonPath();
|
||
let packageResults = await Promise.all(
|
||
PYTHON_PACKAGES.map(async (pkg) => ({
|
||
...pkg,
|
||
installed: await checkPythonPackage(pythonPath, pkg.importName),
|
||
})),
|
||
);
|
||
|
||
if (repairAttempted && pythonPath) {
|
||
const missing = packageResults
|
||
.filter((pkg) => !pkg.installed)
|
||
.map((pkg) => pkg.packageName);
|
||
if (missing.length > 0) {
|
||
logger.info(`[office-runtime] Installing missing Python document packages: ${missing.join(', ')}`);
|
||
await installPythonPackages(pythonPath, missing);
|
||
pythonPath = await findManagedPythonPath();
|
||
packageResults = await Promise.all(
|
||
PYTHON_PACKAGES.map(async (pkg) => ({
|
||
...pkg,
|
||
installed: await checkPythonPackage(pythonPath, pkg.importName),
|
||
})),
|
||
);
|
||
}
|
||
}
|
||
|
||
const nodeModules = NODE_MODULES.map((name) => {
|
||
const resolvedPath = resolveNodeModule(name);
|
||
return {
|
||
name,
|
||
installed: Boolean(resolvedPath),
|
||
resolvedPath,
|
||
};
|
||
});
|
||
const dotnet = await checkDotnet();
|
||
|
||
const missingPythonPackages = packageResults.filter((pkg) => !pkg.installed);
|
||
const missingNodeModules = nodeModules.filter((mod) => !mod.installed);
|
||
const checks: OfficeRuntimeCheck[] = [
|
||
{
|
||
id: 'python',
|
||
label: 'Python 运行环境',
|
||
status: pythonPath ? 'ok' : 'error',
|
||
detail: pythonPath ?? '未找到 uv 管理的 Python 3.12',
|
||
},
|
||
{
|
||
id: 'python-packages',
|
||
label: 'PDF/表格依赖',
|
||
status: missingPythonPackages.length === 0 ? 'ok' : 'error',
|
||
detail: missingPythonPackages.length === 0
|
||
? '已安装常用 PDF 与表格处理依赖'
|
||
: `缺少 ${missingPythonPackages.map((pkg) => pkg.packageName).join(', ')}`,
|
||
},
|
||
{
|
||
id: 'node-modules',
|
||
label: '文档生成依赖',
|
||
status: missingNodeModules.length === 0 ? 'ok' : 'error',
|
||
detail: missingNodeModules.length === 0
|
||
? '已找到项目内置文档生成依赖'
|
||
: `缺少 ${missingNodeModules.map((mod) => mod.name).join(', ')}`,
|
||
},
|
||
{
|
||
id: 'dotnet',
|
||
label: 'Word 高级生成',
|
||
status: dotnet.available ? 'ok' : 'warning',
|
||
detail: dotnet.available
|
||
? `.NET ${dotnet.version}`
|
||
: '未找到 .NET,docx 高级 OpenXML 生成可能受限',
|
||
},
|
||
];
|
||
|
||
return {
|
||
capturedAt: Date.now(),
|
||
ok: checks.every((check) => check.status !== 'error'),
|
||
repairAttempted,
|
||
python: {
|
||
executable: pythonPath,
|
||
packages: packageResults,
|
||
},
|
||
node: {
|
||
modules: nodeModules,
|
||
},
|
||
dotnet,
|
||
checks,
|
||
};
|
||
}
|
||
|
||
export async function ensureOfficeSkillRuntimeReady(): Promise<OfficeSkillRuntimeDiagnostics> {
|
||
return await buildOfficeSkillRuntimeDiagnostics({ repair: true });
|
||
}
|
||
|
||
export function warmupOfficeSkillRuntimeReadiness(): void {
|
||
if (warmupPromise) return;
|
||
warmupPromise = ensureOfficeSkillRuntimeReady()
|
||
.then((diagnostics) => {
|
||
const summary = diagnostics.checks.map((check) => `${check.id}:${check.status}`).join(', ');
|
||
logger.info(`[office-runtime] Warmup complete (${summary})`);
|
||
return diagnostics;
|
||
})
|
||
.catch((error) => {
|
||
logger.warn('[office-runtime] Warmup failed:', error);
|
||
throw error;
|
||
})
|
||
.finally(() => {
|
||
warmupPromise = null;
|
||
});
|
||
}
|