feat: add first-run runtime initialization

This commit is contained in:
inman
2026-04-29 17:24:37 +08:00
parent 3b252250cd
commit cddaf37016
14 changed files with 432 additions and 80 deletions

View File

@@ -44,6 +44,7 @@ import {
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry';
import { getYinianInitializationStatus, initializeYinianRuntime } from '../utils/yinian-initializer';
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { applyProxySettings } from './proxy';
@@ -98,6 +99,9 @@ export function registerIpcHandlers(
// OpenClaw handlers
registerOpenClawHandlers(gatewayManager);
// YINIAN first-run initialization
registerYinianSetupHandlers();
// Provider handlers
registerProviderHandlers(gatewayManager);
@@ -144,6 +148,11 @@ export function registerIpcHandlers(
registerFileHandlers();
}
function registerYinianSetupHandlers(): void {
ipcMain.handle('yinian:setup:status', async () => getYinianInitializationStatus());
ipcMain.handle('yinian:setup:initialize', async () => initializeYinianRuntime());
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const providerService = getProviderService();
const handleProxySettingsChange = async () => {

View File

@@ -12,11 +12,12 @@ import { logger } from '../utils/logger';
import { EventEmitter } from 'events';
import { setQuitting } from './app-state';
/** Base CDN URL (without trailing channel path) */
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
/** Update feed URL. Disabled unless explicitly configured for an environment. */
const UPDATE_FEED_BASE_URL = process.env.YINIAN_UPDATE_FEED_URL?.trim() || '';
const UPDATES_DISABLED_MESSAGE = '内测阶段暂未启用在线更新,请使用服务端下发的新版安装包。';
export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
status: 'idle' | 'disabled' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
@@ -43,7 +44,9 @@ function detectChannel(version: string): string {
export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' };
private status: UpdateStatus = UPDATE_FEED_BASE_URL
? { status: 'idle' }
: { status: 'disabled', error: UPDATES_DISABLED_MESSAGE };
private autoInstallTimer: NodeJS.Timeout | null = null;
private autoInstallCountdown = 0;
@@ -69,13 +72,16 @@ export class AppUpdater extends EventEmitter {
debug: (msg: string) => logger.debug('[Updater]', msg),
};
// Override feed URL for prerelease channels so that
// alpha -> /alpha/alpha-mac.yml, beta -> /beta/beta-mac.yml, etc.
const version = app.getVersion();
const channel = detectChannel(version);
const feedUrl = `${OSS_BASE_URL}/${channel}`;
const feedUrl = UPDATE_FEED_BASE_URL ? `${UPDATE_FEED_BASE_URL.replace(/\/+$/, '')}/${channel}` : '';
logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`);
logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl || 'disabled'}`);
if (!UPDATE_FEED_BASE_URL) {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
return;
}
// Set channel so electron-updater requests the correct yml filename.
// e.g. channel "alpha" → requests alpha-mac.yml, channel "latest" → requests latest-mac.yml
@@ -174,6 +180,11 @@ export class AppUpdater extends EventEmitter {
* final status so the UI never gets stuck in 'checking'.
*/
async checkForUpdates(): Promise<UpdateInfo | null> {
if (!UPDATE_FEED_BASE_URL) {
this.updateStatus({ status: 'disabled', error: UPDATES_DISABLED_MESSAGE });
return null;
}
try {
const result = await autoUpdater.checkForUpdates();
@@ -205,6 +216,11 @@ export class AppUpdater extends EventEmitter {
* Download available update
*/
async downloadUpdate(): Promise<void> {
if (!UPDATE_FEED_BASE_URL) {
this.updateStatus({ status: 'disabled', error: UPDATES_DISABLED_MESSAGE });
throw new Error(UPDATES_DISABLED_MESSAGE);
}
try {
await autoUpdater.downloadUpdate();
} catch (error) {
@@ -225,6 +241,11 @@ export class AppUpdater extends EventEmitter {
* the window cleanly while ShipIt runs independently to replace the app.
*/
quitAndInstall(): void {
if (!UPDATE_FEED_BASE_URL) {
this.updateStatus({ status: 'disabled', error: UPDATES_DISABLED_MESSAGE });
return;
}
logger.info('[Updater] quitAndInstall called');
setQuitting();
autoUpdater.quitAndInstall();
@@ -273,7 +294,7 @@ export class AppUpdater extends EventEmitter {
* Set auto-download preference
*/
setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable;
autoUpdater.autoDownload = UPDATE_FEED_BASE_URL ? enable : false;
}
/**

View File

@@ -65,6 +65,8 @@ const electronAPI = {
'yinian:skills:listLocal',
'yinian:skills:getRegistry',
'yinian:server:status',
'yinian:setup:status',
'yinian:setup:initialize',
// Window controls
'window:minimize',
'window:maximize',

View File

@@ -303,6 +303,18 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
return cachedOpenClawRuntime;
}
if (hasRequiredOpenClawContextModules(managedDir)) {
cachedOpenClawRuntime = {
dir: managedDir,
source: 'managed',
version: readOpenClawVersion(managedDir),
bundledDir,
managedDir,
};
logOpenClawRuntime('[openclaw-runtime] Using managed OpenClaw runtime', cachedOpenClawRuntime);
return cachedOpenClawRuntime;
}
const externalDir = findExternalOpenClawDir([bundledDir, managedDir]);
if (externalDir) {
cachedOpenClawRuntime = {
@@ -312,7 +324,7 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
bundledDir,
managedDir,
};
logOpenClawRuntime('[openclaw-runtime] Using existing OpenClaw installation', cachedOpenClawRuntime);
logOpenClawRuntime('[openclaw-runtime] Using existing OpenClaw installation before managed runtime is installed', cachedOpenClawRuntime);
return cachedOpenClawRuntime;
}
@@ -391,6 +403,30 @@ export function getOpenClawDir(): string {
return resolveOpenClawRuntime().dir;
}
export function reinstallManagedOpenClawRuntime(): OpenClawRuntimeResolution {
cachedOpenClawRuntime = null;
const bundledDir = getBundledOpenClawDir();
const managedDir = getManagedOpenClawDir();
rmSync(fsPath(managedDir), { recursive: true, force: true });
let installedFromBundled = false;
if (isValidOpenClawPackageDir(bundledDir)) {
installedFromBundled = installBundledOpenClawToManagedRuntime(bundledDir, managedDir);
}
cachedOpenClawRuntime = {
dir: hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir,
source: hasRequiredOpenClawContextModules(managedDir) ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
version: readOpenClawVersion(hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir),
bundledDir,
managedDir,
installedFromBundled,
};
logOpenClawRuntime('[openclaw-runtime] Reinstalled managed OpenClaw runtime for first-run initialization', cachedOpenClawRuntime);
return cachedOpenClawRuntime;
}
/**
* Get OpenClaw package directory resolved to a real path.
* Useful when consumers need deterministic module resolution under pnpm symlinks.

View File

@@ -52,6 +52,10 @@ export interface AppSettings {
sidebarCollapsed: boolean;
devModeUnlocked: boolean;
// First-run initialization
setupComplete: boolean;
openclawInitializedAt?: number;
// Presets
selectedBundles: string[];
enabledSkills: string[];
@@ -95,7 +99,7 @@ function createDefaultSettings(): AppSettings {
// Update
updateChannel: 'stable',
autoCheckUpdate: true,
autoCheckUpdate: false,
autoDownloadUpdate: false,
skippedVersions: [],
@@ -103,6 +107,10 @@ function createDefaultSettings(): AppSettings {
sidebarCollapsed: false,
devModeUnlocked: false,
// First-run initialization
setupComplete: false,
openclawInitializedAt: undefined,
// Presets
selectedBundles: ['productivity', 'developer'],
enabledSkills: [],

View File

@@ -0,0 +1,213 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { getAllSettings, setSetting } from './store';
import { getOpenClawConfigDir, reinstallManagedOpenClawRuntime } from './paths';
import { logger } from './logger';
type JsonObject = Record<string, unknown>;
export type YinianInitializationStepStatus = 'pending' | 'running' | 'success' | 'error';
export interface YinianInitializationStep {
id: 'runtime' | 'workspace' | 'model' | 'python';
label: string;
status: YinianInitializationStepStatus;
message?: string;
}
export interface YinianInitializationStatus {
initialized: boolean;
initializedAt?: number;
openclawDir?: string;
model?: string;
steps: YinianInitializationStep[];
}
const INTERNAL_PROVIDER_KEY = 'minimax';
const INTERNAL_MODEL_ID = 'MiniMax-M2.7';
const INTERNAL_MODEL_REF = `${INTERNAL_PROVIDER_KEY}/${INTERNAL_MODEL_ID}`;
let initializationInFlight: Promise<YinianInitializationStatus> | null = null;
const DEFAULT_STEPS: YinianInitializationStep[] = [
{ id: 'runtime', label: '安装运行环境', status: 'pending' },
{ id: 'workspace', label: '准备本地工作区', status: 'pending' },
{ id: 'model', label: '写入内测模型配置', status: 'pending' },
{ id: 'python', label: '准备文档处理环境', status: 'pending' },
];
export async function getYinianInitializationStatus(): Promise<YinianInitializationStatus> {
const settings = await getAllSettings();
const initialized = settings.setupComplete === true;
return {
initialized,
initializedAt: settings.openclawInitializedAt,
openclawDir: initialized ? join(getOpenClawConfigDir(), 'runtime', 'openclaw') : undefined,
model: initialized ? INTERNAL_MODEL_REF : undefined,
steps: DEFAULT_STEPS.map((step) => ({
...step,
status: initialized ? 'success' : 'pending',
message: initialized ? '已完成' : undefined,
})),
};
}
export async function initializeYinianRuntime(): Promise<YinianInitializationStatus> {
if (initializationInFlight) return initializationInFlight;
initializationInFlight = runYinianRuntimeInitialization();
try {
return await initializationInFlight;
} finally {
initializationInFlight = null;
}
}
async function runYinianRuntimeInitialization(): Promise<YinianInitializationStatus> {
const steps = DEFAULT_STEPS.map((step) => ({ ...step }));
const setStep = (
id: YinianInitializationStep['id'],
status: YinianInitializationStepStatus,
message?: string,
) => {
const step = steps.find((item) => item.id === id);
if (step) {
step.status = status;
step.message = message;
}
};
try {
setStep('runtime', 'running', '正在重装内置运行环境');
const runtime = reinstallManagedOpenClawRuntime();
if (runtime.source === 'missing') {
throw new Error('内置 OpenClaw 运行环境不可用');
}
setStep('runtime', 'success', runtime.dir);
setStep('workspace', 'running', '正在创建本地工作区');
await ensureWorkspaceFiles();
setStep('workspace', 'success', '本地工作区已准备');
setStep('model', 'running', '正在写入内测模型配置');
await seedInternalModelConfig();
setStep('model', 'success', INTERNAL_MODEL_REF);
setStep('python', 'running', '正在准备文档处理环境');
await ensureAuxiliaryRuntimeMarkers();
setStep('python', 'success', '文档处理环境已准备');
const initializedAt = Date.now();
await setSetting('setupComplete', true);
await setSetting('openclawInitializedAt', initializedAt);
logger.info('[yinian-init] First-run initialization completed', {
openclawDir: runtime.dir,
model: INTERNAL_MODEL_REF,
});
return {
initialized: true,
initializedAt,
openclawDir: runtime.dir,
model: INTERNAL_MODEL_REF,
steps,
};
} catch (error) {
const running = steps.find((step) => step.status === 'running');
if (running) {
running.status = 'error';
running.message = error instanceof Error ? error.message : String(error);
}
logger.error('[yinian-init] First-run initialization failed', error);
return {
initialized: false,
steps,
};
}
}
async function ensureWorkspaceFiles(): Promise<void> {
const openclawDir = getOpenClawConfigDir();
const workspaceDir = join(openclawDir, 'workspace');
await mkdir(workspaceDir, { recursive: true });
await mkdir(join(openclawDir, 'agents', 'main', 'agent'), { recursive: true });
}
async function seedInternalModelConfig(): Promise<void> {
const configDir = getOpenClawConfigDir();
const configPath = join(configDir, 'openclaw.json');
await mkdir(configDir, { recursive: true });
const config = await readJsonFile(configPath);
const models = asObject(config.models);
const providers = asObject(models.providers);
providers[INTERNAL_PROVIDER_KEY] = {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
authHeader: true,
models: [
{
id: INTERNAL_MODEL_ID,
name: 'MiniMax M2.7',
reasoning: true,
input: ['text', 'image'],
contextWindow: 204800,
maxTokens: 131072,
},
],
};
models.mode = 'merge';
models.providers = providers;
config.models = models;
const agents = asObject(config.agents);
const defaults = asObject(agents.defaults);
defaults.model = {
primary: INTERNAL_MODEL_REF,
fallbacks: ['minimax/MiniMax-M2.5'],
};
defaults.workspace = join(homedir(), '.openclaw', 'workspace');
agents.defaults = defaults;
if (!Array.isArray(agents.list)) {
agents.list = [
{
id: 'main',
name: '智念助手',
default: true,
workspace: join(homedir(), '.openclaw', 'workspace'),
agentDir: '~/.openclaw/agents/main/agent',
},
];
}
config.agents = agents;
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
}
async function ensureAuxiliaryRuntimeMarkers(): Promise<void> {
const dir = join(getOpenClawConfigDir(), 'runtime');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'yinian-initialized.json'), JSON.stringify({
initializedAt: Date.now(),
documentRuntime: 'bundled',
}, null, 2), 'utf8');
}
async function readJsonFile(filePath: string): Promise<JsonObject> {
if (!existsSync(filePath)) return {};
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return asObject(parsed);
} catch {
return {};
}
}
function asObject(value: unknown): JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as JsonObject
: {};
}