Files
NianToB/electron/utils/office-skill-runtime.ts

445 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import {
buildPlaywrightRuntimeEnv,
ensureYinianPlaywrightRuntimeDirs,
resolveYinianPlaywrightBrowsersPath,
} from './playwright-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;
};
playwright: {
moduleInstalled: boolean;
moduleName: string | null;
moduleResolvedPath: string | null;
browsersPath: string;
chromiumExecutablePath: string | null;
chromiumInstalled: boolean;
error: 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 PLAYWRIGHT_MODULE_CANDIDATES = ['playwright', 'playwright-core'] 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;
}
function checkPlaywrightRuntime(): OfficeSkillRuntimeDiagnostics['playwright'] {
const runtimeEnv = buildPlaywrightRuntimeEnv();
const browsersPath = runtimeEnv.PLAYWRIGHT_BROWSERS_PATH || resolveYinianPlaywrightBrowsersPath();
const previousBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
try {
for (const req of buildRequireCandidates()) {
for (const moduleName of PLAYWRIGHT_MODULE_CANDIDATES) {
try {
const moduleResolvedPath = req.resolve(moduleName);
const loaded = req(moduleName) as {
chromium?: {
executablePath?: () => string;
};
};
const chromiumExecutablePath = loaded.chromium?.executablePath?.() ?? null;
return {
moduleInstalled: true,
moduleName,
moduleResolvedPath,
browsersPath,
chromiumExecutablePath,
chromiumInstalled: Boolean(chromiumExecutablePath && existsSync(chromiumExecutablePath)),
error: null,
};
} catch {
// Try the next module/root.
}
}
}
return {
moduleInstalled: false,
moduleName: null,
moduleResolvedPath: null,
browsersPath,
chromiumExecutablePath: null,
chromiumInstalled: false,
error: 'Playwright runtime module not found',
};
} catch (error) {
return {
moduleInstalled: false,
moduleName: null,
moduleResolvedPath: null,
browsersPath,
chromiumExecutablePath: null,
chromiumInstalled: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
if (previousBrowsersPath === undefined) {
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
} else {
process.env.PLAYWRIGHT_BROWSERS_PATH = previousBrowsersPath;
}
}
}
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();
ensureYinianPlaywrightRuntimeDirs();
}
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 playwright = checkPlaywrightRuntime();
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}`
: '未找到 .NETdocx 高级 OpenXML 生成可能受限',
},
{
id: 'playwright-chromium',
label: '幻灯片浏览器预览',
status: playwright.moduleInstalled && playwright.chromiumInstalled ? 'ok' : 'warning',
detail: playwright.moduleInstalled
? (playwright.chromiumInstalled
? `Chromium 可用:${playwright.chromiumExecutablePath}`
: `未找到 Chromium 二进制文件;浏览器预览应跳过,不要在任务中运行 npx playwright install chromium。缓存路径${playwright.browsersPath}`)
: `未找到 Playwright 运行时;浏览器预览应跳过。${playwright.error ?? ''}`,
},
];
return {
capturedAt: Date.now(),
ok: checks.every((check) => check.status !== 'error'),
repairAttempted,
python: {
executable: pythonPath,
packages: packageResults,
},
node: {
modules: nodeModules,
},
dotnet,
playwright,
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;
});
}