chore: stabilize Zhinian pilot delivery
This commit is contained in:
355
electron/utils/office-skill-runtime.ts
Normal file
355
electron/utils/office-skill-runtime.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user