feat: update desktop workflows and app center
This commit is contained in:
@@ -37,8 +37,11 @@ interface CronRunLogEntry {
|
||||
jobId?: string;
|
||||
action?: string;
|
||||
status?: string;
|
||||
resolvedStatus?: string;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
resolvedSummary?: string;
|
||||
recoveredError?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
ts?: number;
|
||||
@@ -56,12 +59,17 @@ interface CronSessionKeyParts {
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'assistant' | 'system';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface CronTrajectoryArtifacts {
|
||||
finalStatus?: string;
|
||||
assistantText?: string;
|
||||
}
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
@@ -102,12 +110,22 @@ function formatDuration(durationMs: number | undefined): string | null {
|
||||
return `${Math.round(durationMs / 1000)}s`;
|
||||
}
|
||||
|
||||
function getCronRunStatus(entry: CronRunLogEntry): string {
|
||||
const status = entry.resolvedStatus || entry.status || '';
|
||||
return typeof status === 'string' ? status.toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getCronRunSummary(entry: CronRunLogEntry): string {
|
||||
const summary = entry.resolvedSummary || entry.summary || '';
|
||||
return typeof summary === 'string' ? summary.trim() : '';
|
||||
}
|
||||
|
||||
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
|
||||
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
|
||||
if (!timestamp) return null;
|
||||
|
||||
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
const status = getCronRunStatus(entry);
|
||||
const summary = getCronRunSummary(entry);
|
||||
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
|
||||
let content = summary || error;
|
||||
|
||||
@@ -135,7 +153,7 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
|
||||
|
||||
return {
|
||||
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
|
||||
role: status === 'error' ? 'system' : 'assistant',
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp,
|
||||
...(status === 'error' ? { isError: true } : {}),
|
||||
@@ -163,6 +181,65 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extractTrajectoryArtifacts(raw: string): CronTrajectoryArtifacts | undefined {
|
||||
let latest: CronTrajectoryArtifacts | undefined;
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (entry.type !== 'trace.artifacts' || !entry.data || typeof entry.data !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = entry.data as Record<string, unknown>;
|
||||
const finalStatus = typeof data.finalStatus === 'string'
|
||||
? data.finalStatus
|
||||
: undefined;
|
||||
const assistantTexts = Array.isArray(data.assistantTexts)
|
||||
? data.assistantTexts.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
|
||||
: [];
|
||||
latest = {
|
||||
...(finalStatus ? { finalStatus } : {}),
|
||||
...(assistantTexts.length > 0 ? { assistantText: assistantTexts[assistantTexts.length - 1].trim() } : {}),
|
||||
};
|
||||
} catch {
|
||||
// Ignore malformed trajectory lines; cron logs are still useful without them.
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function readCronTrajectoryArtifacts(agentId: string, sessionId: string | undefined): Promise<CronTrajectoryArtifacts | undefined> {
|
||||
if (!sessionId) return undefined;
|
||||
const trajectoryPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.trajectory.jsonl`);
|
||||
const raw = await readFile(trajectoryPath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return undefined;
|
||||
return extractTrajectoryArtifacts(raw);
|
||||
}
|
||||
|
||||
async function resolveCronRunEntry(agentId: string, entry: CronRunLogEntry): Promise<CronRunLogEntry> {
|
||||
if (getCronRunStatus(entry) !== 'error') return entry;
|
||||
|
||||
const artifacts = await readCronTrajectoryArtifacts(agentId, entry.sessionId);
|
||||
if (artifacts?.finalStatus?.toLowerCase() !== 'success') return entry;
|
||||
|
||||
const recoveredError = entry.error || entry.summary;
|
||||
return {
|
||||
...entry,
|
||||
resolvedStatus: 'ok',
|
||||
...(artifacts.assistantText ? { resolvedSummary: artifacts.assistantText } : {}),
|
||||
...(recoveredError ? { recoveredError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCronRunEntries(agentId: string, entries: CronRunLogEntry[]): Promise<CronRunLogEntry[]> {
|
||||
return Promise.all(entries.map((entry) => resolveCronRunEntry(agentId, entry)));
|
||||
}
|
||||
|
||||
async function readSessionStoreEntry(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
@@ -227,13 +304,12 @@ export function buildCronSessionFallbackMessages(params: {
|
||||
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
|
||||
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
|
||||
|
||||
if (taskName || prompt) {
|
||||
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
|
||||
if (prompt) lines.push(`Prompt: ${prompt}`);
|
||||
if (prompt || taskName) {
|
||||
const content = prompt || `Scheduled task: ${taskName}`;
|
||||
messages.push({
|
||||
id: `cron-meta-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: lines.join('\n'),
|
||||
id: `cron-query-${parsed.jobId}-${firstRelevantTimestamp ?? 'pending'}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
|
||||
});
|
||||
}
|
||||
@@ -248,15 +324,15 @@ export function buildCronSessionFallbackMessages(params: {
|
||||
if (runningAt) {
|
||||
messages.push({
|
||||
id: `cron-running-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
|
||||
role: 'assistant',
|
||||
content: '任务正在执行,完成后会自动在这里显示结果。',
|
||||
timestamp: runningAt,
|
||||
});
|
||||
} else if (messages.length === 0) {
|
||||
messages.push({
|
||||
id: `cron-empty-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'No chat transcript is available for this scheduled task yet.',
|
||||
role: 'assistant',
|
||||
content: '这个任务还没有可显示的执行记录。',
|
||||
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -364,7 +440,53 @@ function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, un
|
||||
return patch;
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob) {
|
||||
function findLatestCronRunEntry(runs: CronRunLogEntry[], lastRunAtMs: number | undefined): CronRunLogEntry | undefined {
|
||||
const withTime = runs
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
timestamp: normalizeTimestampMs(entry.runAtMs) ?? normalizeTimestampMs(entry.ts) ?? 0,
|
||||
}))
|
||||
.filter((item) => item.timestamp > 0);
|
||||
if (withTime.length === 0) return runs[runs.length - 1];
|
||||
|
||||
if (lastRunAtMs) {
|
||||
const exact = withTime.find((item) => item.timestamp === lastRunAtMs);
|
||||
if (exact) return exact.entry;
|
||||
}
|
||||
|
||||
return withTime.sort((a, b) => b.timestamp - a.timestamp)[0]?.entry;
|
||||
}
|
||||
|
||||
async function resolveLatestCronRun(job: GatewayCronJob, agentId: string): Promise<CronRunLogEntry | undefined> {
|
||||
if (!job.state?.lastRunAtMs || job.state.lastStatus === 'ok') return undefined;
|
||||
const runs = await readCronRunLog(job.id);
|
||||
const latest = findLatestCronRunEntry(runs, job.state.lastRunAtMs);
|
||||
return latest ? resolveCronRunEntry(agentId, latest) : undefined;
|
||||
}
|
||||
|
||||
async function resolveRecoveredCronRunAfterError(ctx: HostApiContext, jobId: string): Promise<Record<string, unknown> | undefined> {
|
||||
const jobsResult = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
|
||||
.catch(() => ({ jobs: [] as GatewayCronJob[] }));
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === jobId);
|
||||
const agentId = (job as unknown as { agentId?: string } | undefined)?.agentId || 'main';
|
||||
const runs = await readCronRunLog(jobId);
|
||||
const latest = findLatestCronRunEntry(runs, job?.state?.lastRunAtMs);
|
||||
if (!latest) return undefined;
|
||||
|
||||
const resolvedRun = await resolveCronRunEntry(agentId, latest);
|
||||
if (getCronRunStatus(resolvedRun) !== 'ok') return undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recovered: true,
|
||||
...(getCronRunSummary(resolvedRun) ? { summary: getCronRunSummary(resolvedRun) } : {}),
|
||||
...(resolvedRun.recoveredError ? { warning: resolvedRun.recoveredError } : {}),
|
||||
...(job ? { job: transformCronJob(job, resolvedRun) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob, resolvedLastRun?: CronRunLogEntry) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const gatewayDelivery = normalizeCronDelivery(job.delivery);
|
||||
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
|
||||
@@ -379,12 +501,15 @@ function transformCronJob(job: GatewayCronJob) {
|
||||
recipient: delivery.to,
|
||||
}
|
||||
: undefined;
|
||||
const resolvedRunStatus = resolvedLastRun ? getCronRunStatus(resolvedLastRun) : '';
|
||||
const effectiveLastStatus = resolvedRunStatus || job.state?.lastStatus;
|
||||
const lastRun = job.state?.lastRunAtMs
|
||||
? {
|
||||
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||
success: job.state.lastStatus === 'ok',
|
||||
error: job.state.lastError,
|
||||
success: effectiveLastStatus === 'ok',
|
||||
...(effectiveLastStatus === 'ok' ? {} : { error: job.state.lastError }),
|
||||
duration: job.state.lastDurationMs,
|
||||
...(resolvedLastRun?.recoveredError ? { warning: resolvedLastRun.recoveredError, reconciled: true } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const nextRun = job.state?.nextRunAtMs
|
||||
@@ -430,12 +555,13 @@ export async function handleCronRoutes(
|
||||
: 200;
|
||||
|
||||
try {
|
||||
const [jobsResult, runs, sessionEntry] = await Promise.all([
|
||||
const [jobsResult, rawRuns, sessionEntry] = await Promise.all([
|
||||
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
|
||||
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
|
||||
readCronRunLog(parsedSession.jobId),
|
||||
readSessionStoreEntry(parsedSession.agentId, sessionKey),
|
||||
]);
|
||||
const runs = await resolveCronRunEntries(parsedSession.agentId, rawRuns);
|
||||
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === parsedSession.jobId);
|
||||
@@ -589,7 +715,13 @@ export async function handleCronRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })));
|
||||
const responseJobs = await Promise.all(jobs.map(async (job) => {
|
||||
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
|
||||
const resolvedLastRun = await resolveLatestCronRun(job, agentId);
|
||||
return { ...transformCronJob(job, resolvedLastRun), ...(usedFallback ? { _fromFallback: true } : {}) };
|
||||
}));
|
||||
|
||||
sendJson(res, 200, responseJobs);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
@@ -684,7 +816,16 @@ export async function handleCronRoutes(
|
||||
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
try {
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
} catch (runError) {
|
||||
const recovered = await resolveRecoveredCronRunAfterError(ctx, body.id);
|
||||
if (recovered) {
|
||||
sendJson(res, 200, { ...recovered, triggerWarning: String(runError) });
|
||||
return true;
|
||||
}
|
||||
throw runError;
|
||||
}
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
|
||||
28
electron/api/routes/local-preferences.ts
Normal file
28
electron/api/routes/local-preferences.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getAllLocalPreferences, patchLocalPreferences, type AppLocalPreferences } from '../../utils/local-preferences';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleLocalPreferenceRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/local-preferences' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllLocalPreferences());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/local-preferences' && req.method === 'PUT') {
|
||||
try {
|
||||
const patch = await parseJsonBody<AppLocalPreferences>(req);
|
||||
sendJson(res, 200, await patchLocalPreferences(patch));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { handleKnowledgeRoutes } from './routes/knowledge';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
import { handleCronRoutes } from './routes/cron';
|
||||
import { handleDiagnosticsRoutes } from './routes/diagnostics';
|
||||
import { handleLocalPreferenceRoutes } from './routes/local-preferences';
|
||||
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
|
||||
|
||||
type RouteHandler = (
|
||||
@@ -41,6 +42,7 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleKnowledgeRoutes,
|
||||
handleSessionRoutes,
|
||||
handleCronRoutes,
|
||||
handleLocalPreferenceRoutes,
|
||||
handleDiagnosticsRoutes,
|
||||
handleLogRoutes,
|
||||
handleUsageRoutes,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
import { buildDotnetEnv } from '../utils/dotnet-runtime';
|
||||
import { buildPlaywrightRuntimeEnv, ensureYinianPlaywrightRuntimeDirs } from '../utils/playwright-runtime';
|
||||
import { copyPluginFromNodeModules, ensureCloudSyncPluginInstalled, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
|
||||
import { stripSystemdSupervisorEnv } from './config-sync-env';
|
||||
import { ensureYinianModelRuntimeConfigured } from '../utils/model-diagnostics';
|
||||
@@ -556,6 +557,8 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
? prependPathEntry(baseEnvRecord, binPath).env
|
||||
: baseEnvRecord;
|
||||
const baseEnvWithDotnet = buildDotnetEnv(baseEnvPatched);
|
||||
ensureYinianPlaywrightRuntimeDirs();
|
||||
const playwrightEnv = buildPlaywrightRuntimeEnv(baseEnvWithDotnet);
|
||||
const nodePath = buildNodePathEnv(baseEnvPatched, [
|
||||
join(process.cwd(), 'node_modules'),
|
||||
join(app.getAppPath(), 'node_modules'),
|
||||
@@ -567,6 +570,7 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
...playwrightEnv,
|
||||
...bundledPackageManagerEnv,
|
||||
...(nodePath ? { NODE_PATH: nodePath } : {}),
|
||||
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
|
||||
|
||||
41
electron/utils/local-preferences.ts
Normal file
41
electron/utils/local-preferences.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Application-level local preferences.
|
||||
*
|
||||
* Renderer localStorage is origin-scoped, so dev server port changes can make
|
||||
* user preferences appear to disappear. Keep business-local preferences here
|
||||
* as the stable source shared by all renderer origins.
|
||||
*/
|
||||
|
||||
export interface AppLocalPreferences {
|
||||
quickTasks?: unknown[];
|
||||
channelAccountRemarks?: Record<string, string>;
|
||||
desktopUserName?: string;
|
||||
workspaceDisplayName?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let localPreferencesStoreInstance: any = null;
|
||||
|
||||
async function getLocalPreferencesStore() {
|
||||
if (!localPreferencesStoreInstance) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
localPreferencesStoreInstance = new Store<AppLocalPreferences>({
|
||||
name: 'local-preferences',
|
||||
});
|
||||
}
|
||||
return localPreferencesStoreInstance;
|
||||
}
|
||||
|
||||
export async function getAllLocalPreferences(): Promise<AppLocalPreferences> {
|
||||
const store = await getLocalPreferencesStore();
|
||||
return { ...(store.store ?? {}) };
|
||||
}
|
||||
|
||||
export async function patchLocalPreferences(patch: AppLocalPreferences): Promise<AppLocalPreferences> {
|
||||
const store = await getLocalPreferencesStore();
|
||||
for (const [key, value] of Object.entries(patch) as Array<[keyof AppLocalPreferences, unknown]>) {
|
||||
if (value === undefined) continue;
|
||||
store.set(key, value);
|
||||
}
|
||||
return { ...(store.store ?? {}) };
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
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';
|
||||
|
||||
@@ -45,6 +50,15 @@ export interface OfficeSkillRuntimeDiagnostics {
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -74,6 +88,8 @@ const NODE_MODULES = [
|
||||
'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;
|
||||
|
||||
@@ -194,6 +210,66 @@ function resolveNodeModule(name: string): string | null {
|
||||
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)})`]);
|
||||
@@ -245,6 +321,7 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
|
||||
|
||||
if (repairAttempted) {
|
||||
await setupManagedPython();
|
||||
ensureYinianPlaywrightRuntimeDirs();
|
||||
}
|
||||
|
||||
let pythonPath = await findManagedPythonPath();
|
||||
@@ -281,6 +358,7 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
|
||||
};
|
||||
});
|
||||
const dotnet = await checkDotnet();
|
||||
const playwright = checkPlaywrightRuntime();
|
||||
|
||||
const missingPythonPackages = packageResults.filter((pkg) => !pkg.installed);
|
||||
const missingNodeModules = nodeModules.filter((mod) => !mod.installed);
|
||||
@@ -315,6 +393,16 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
|
||||
? `.NET ${dotnet.version}`
|
||||
: '未找到 .NET,docx 高级 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 {
|
||||
@@ -329,6 +417,7 @@ export async function buildOfficeSkillRuntimeDiagnostics(options: {
|
||||
modules: nodeModules,
|
||||
},
|
||||
dotnet,
|
||||
playwright,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
74
electron/utils/playwright-runtime.ts
Normal file
74
electron/utils/playwright-runtime.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getOpenClawConfigDir } from './paths';
|
||||
|
||||
const PLAYWRIGHT_BROWSERS_DIR = 'ms-playwright';
|
||||
const PLAYWRIGHT_LOCKS_DIR = 'locks';
|
||||
const PLAYWRIGHT_COMPLETE_MARKER = 'INSTALLATION_COMPLETE';
|
||||
|
||||
function resolveDefaultPlaywrightBrowsersPath(): string | null {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(homedir(), 'Library', 'Caches', 'ms-playwright');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA?.trim();
|
||||
return localAppData ? path.join(localAppData, 'ms-playwright') : null;
|
||||
}
|
||||
return path.join(homedir(), '.cache', 'ms-playwright');
|
||||
}
|
||||
|
||||
export function resolveYinianPlaywrightBrowsersPath(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'runtime', PLAYWRIGHT_BROWSERS_DIR);
|
||||
}
|
||||
|
||||
export function resolveYinianPlaywrightInstallLockPath(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'runtime', PLAYWRIGHT_LOCKS_DIR, 'playwright-chromium-install.lock');
|
||||
}
|
||||
|
||||
function mirrorCompletedDefaultBrowsers(targetRoot: string): void {
|
||||
const defaultRoot = resolveDefaultPlaywrightBrowsersPath();
|
||||
if (!defaultRoot || defaultRoot === targetRoot || !existsSync(defaultRoot)) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(defaultRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('chromium')) continue;
|
||||
|
||||
const sourceDir = path.join(defaultRoot, entry.name);
|
||||
if (!existsSync(path.join(sourceDir, PLAYWRIGHT_COMPLETE_MARKER))) continue;
|
||||
|
||||
const targetDir = path.join(targetRoot, entry.name);
|
||||
if (existsSync(targetDir)) continue;
|
||||
|
||||
try {
|
||||
symlinkSync(sourceDir, targetDir, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// Best effort. A missing browser remains a warning-level diagnostic.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureYinianPlaywrightRuntimeDirs(): void {
|
||||
const browsersPath = resolveYinianPlaywrightBrowsersPath();
|
||||
mkdirSync(browsersPath, { recursive: true });
|
||||
mkdirSync(path.dirname(resolveYinianPlaywrightInstallLockPath()), { recursive: true });
|
||||
mirrorCompletedDefaultBrowsers(browsersPath);
|
||||
}
|
||||
|
||||
export function buildPlaywrightRuntimeEnv(
|
||||
baseEnv: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
const explicitBrowsersPath = baseEnv.PLAYWRIGHT_BROWSERS_PATH?.trim();
|
||||
return {
|
||||
PLAYWRIGHT_BROWSERS_PATH: explicitBrowsersPath || resolveYinianPlaywrightBrowsersPath(),
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: baseEnv.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD ?? '1',
|
||||
YINIAN_PLAYWRIGHT_INSTALL_LOCK_PATH: baseEnv.YINIAN_PLAYWRIGHT_INSTALL_LOCK_PATH
|
||||
?? resolveYinianPlaywrightInstallLockPath(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user