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 | 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; timeoutMs?: number; shell?: boolean; } = {}, ): Promise { 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 { 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 { 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); 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 { 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 { 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; }); }