import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { existsSync } from 'node:fs'; import { getAllSettings, setSetting } from './store'; import { getOpenClawConfigDir, reinstallManagedOpenClawRuntime } from './paths'; import { logger } from './logger'; import { ensureOfficeSkillRuntimeReady } from './office-skill-runtime'; type JsonObject = Record; 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}`; const INTERNAL_AUTH_PROFILE_ID = 'minimax:default'; const DESKTOP_TOOLS_PROFILE = 'coding'; const YINIAN_FALLBACK_SKILL_IDS = ['docx', 'pdf', 'pptx', 'xlsx', 'design', 'image-search', 'web-search']; const REQUIRED_RUNTIME_FILES = [ 'package.json', 'openclaw.mjs', join('docs', 'reference', 'templates', 'AGENTS.md'), join('docs', 'reference', 'templates', 'TOOLS.md'), join('docs', 'reference', 'templates', 'HEARTBEAT.md'), join('node_modules', 'openclaw', 'package.json'), ] as const; let initializationInFlight: Promise | 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 { const settings = await getAllSettings(); const runtimeStatus = getManagedRuntimeVerification(); const workspaceReady = existsSync(join(getOpenClawConfigDir(), 'workspace')); const modelConfigReady = existsSync(join(getOpenClawConfigDir(), 'openclaw.json')); const modelAuthReady = await hasYinianModelAuthProfile(); const modelReady = modelConfigReady && modelAuthReady; const officeMarkerReady = existsSync(join(getOpenClawConfigDir(), 'runtime', 'yinian-initialized.json')); const initialized = settings.setupComplete === true && runtimeStatus.ok && workspaceReady && modelReady && officeMarkerReady; if (settings.setupComplete === true && !initialized) { await setSetting('setupComplete', false); logger.warn('[yinian-init] Existing setupComplete flag was reset because runtime verification failed', { runtimeMissing: runtimeStatus.missing, workspaceReady, modelConfigReady, modelAuthReady, officeMarkerReady, }); } return { initialized, initializedAt: settings.openclawInitializedAt, openclawDir: runtimeStatus.runtimeDir, model: initialized ? INTERNAL_MODEL_REF : undefined, steps: DEFAULT_STEPS.map((step) => { if (step.id === 'runtime') { return { ...step, status: runtimeStatus.ok ? 'success' : (settings.setupComplete === true ? 'error' : 'pending'), message: runtimeStatus.ok ? runtimeStatus.runtimeDir : runtimeStatus.missing.length > 0 ? `缺少运行文件:${runtimeStatus.missing.join(', ')}` : undefined, }; } if (step.id === 'workspace') { return { ...step, status: workspaceReady ? 'success' : 'pending', message: workspaceReady ? '本地工作区已准备' : undefined }; } if (step.id === 'model') { return { ...step, status: modelReady ? 'success' : (settings.setupComplete === true && modelConfigReady ? 'error' : 'pending'), message: modelReady ? INTERNAL_MODEL_REF : modelConfigReady && !modelAuthReady ? '缺少内测模型调用凭据' : undefined, }; } if (step.id === 'python') { return { ...step, status: officeMarkerReady ? 'success' : 'pending', message: officeMarkerReady ? '文档处理环境已准备' : undefined }; } return { ...step }; }), }; } export async function initializeYinianRuntime(): Promise { if (initializationInFlight) return initializationInFlight; initializationInFlight = runYinianRuntimeInitialization(); try { return await initializationInFlight; } finally { initializationInFlight = null; } } async function runYinianRuntimeInitialization(): Promise { 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 运行环境不可用'); } const runtimeStatus = getManagedRuntimeVerification(); if (!runtimeStatus.ok) { throw new Error(`OpenClaw 运行环境不完整:${runtimeStatus.missing.join(', ')}`); } setStep('runtime', 'success', runtime.dir); setStep('workspace', 'running', '正在创建本地工作区'); await ensureWorkspaceFiles(); setStep('workspace', 'success', '本地工作区已准备'); setStep('model', 'running', '正在写入内测模型配置'); await seedInternalModelConfig(); await seedInternalModelAuthProfiles(); if (!(await hasYinianModelAuthProfile())) { throw new Error('内测模型调用凭据未配置,请使用内测包或联系管理员配置模型服务'); } setStep('model', 'success', INTERNAL_MODEL_REF); setStep('python', 'running', '正在准备文档与办公能力环境'); const officeRuntime = await ensureAuxiliaryRuntimeMarkers(); if (!officeRuntime.ok) { const failed = officeRuntime.checks.filter((check) => check.status === 'error'); throw new Error(`文档处理环境未完成:${failed.map((check) => check.detail).join(';')}`); } const dotnetCheck = officeRuntime.checks.find((check) => check.id === 'dotnet'); setStep('python', 'success', dotnetCheck?.status === 'warning' ? `文档处理环境已准备;${dotnetCheck.detail}` : '文档处理环境已准备'); 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) { await setSetting('setupComplete', false); 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, }; } } function getManagedRuntimeVerification(): { runtimeDir: string; ok: boolean; missing: string[] } { const runtimeDir = join(getOpenClawConfigDir(), 'runtime', 'openclaw'); const missing = REQUIRED_RUNTIME_FILES.filter((relativePath) => ( !existsSync(join(runtimeDir, relativePath)) )); return { runtimeDir, ok: missing.length === 0, missing, }; } async function ensureWorkspaceFiles(): Promise { 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 { 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; delete models.pricing; config.models = models; const tools = asObject(config.tools); tools.profile = DESKTOP_TOOLS_PROFILE; const sessions = asObject(tools.sessions); sessions.visibility = 'all'; tools.sessions = sessions; config.tools = tools; const agents = asObject(config.agents); const defaults = asObject(agents.defaults); const enabledSkillIds = resolveYinianEnabledSkillIds(config); defaults.model = { primary: INTERNAL_MODEL_REF, fallbacks: ['minimax/MiniMax-M2.5'], }; defaults.workspace = join(homedir(), '.openclaw', 'workspace'); defaults.skills = enabledSkillIds; defaults.heartbeat = { ...asObject(defaults.heartbeat), every: '0m', }; 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', skills: enabledSkillIds, tools: { profile: DESKTOP_TOOLS_PROFILE }, }, ]; } else { agents.list = agents.list.map((entry) => { const agent = asObject(entry); if (agent.id !== 'main') return entry; const currentSkills = Array.isArray(agent.skills) ? agent.skills.filter((value): value is string => typeof value === 'string') : []; return { ...agent, skills: arraysEqual(currentSkills, enabledSkillIds) ? currentSkills : enabledSkillIds, tools: { ...asObject(agent.tools), profile: DESKTOP_TOOLS_PROFILE, }, }; }); } config.agents = agents; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); } async function seedInternalModelAuthProfiles(): Promise { const bundledAuthPath = resolveBundledModelAuthPath(); if (!bundledAuthPath) return; const bundled = await readJsonFile(bundledAuthPath); if (bundled.bundled !== true) return; const bundledStore = asObject(bundled.store); const bundledProfiles = asObject(bundledStore.profiles); const bundledDefault = asObject(bundledProfiles[INTERNAL_AUTH_PROFILE_ID]); if (!isUsableMinimaxAuthProfile(bundledDefault)) { throw new Error('内测模型凭据资源不完整'); } const authProfilesPath = join(getOpenClawConfigDir(), 'agents', 'main', 'agent', 'auth-profiles.json'); await mkdir(dirname(authProfilesPath), { recursive: true }); const current = await readJsonFile(authProfilesPath); const profiles = asObject(current.profiles); let changed = false; for (const [profileId, profile] of Object.entries(bundledProfiles)) { const bundledProfile = asObject(profile); if (!isUsableMinimaxAuthProfile(bundledProfile)) continue; if (!isUsableMinimaxAuthProfile(asObject(profiles[profileId]))) { profiles[profileId] = { type: 'api_key', provider: INTERNAL_PROVIDER_KEY, key: bundledProfile.key, }; changed = true; } } const order = asObject(current.order) as Record; const minimaxOrder = Array.isArray(order[INTERNAL_PROVIDER_KEY]) ? (order[INTERNAL_PROVIDER_KEY] as unknown[]).filter((value): value is string => typeof value === 'string') : []; if (!minimaxOrder.includes(INTERNAL_AUTH_PROFILE_ID)) { order[INTERNAL_PROVIDER_KEY] = [ INTERNAL_AUTH_PROFILE_ID, ...minimaxOrder.filter((profileId) => profileId !== INTERNAL_AUTH_PROFILE_ID), ]; changed = true; } const lastGood = asObject(current.lastGood) as Record; if (lastGood[INTERNAL_PROVIDER_KEY] !== INTERNAL_AUTH_PROFILE_ID) { lastGood[INTERNAL_PROVIDER_KEY] = INTERNAL_AUTH_PROFILE_ID; changed = true; } if (!changed) return; current.version = typeof current.version === 'number' ? current.version : 1; current.profiles = profiles; current.order = order; current.lastGood = lastGood; await writeFile(authProfilesPath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); } function resolveBundledModelAuthPath(): string | undefined { const candidates = [ process.resourcesPath ? join(process.resourcesPath, 'resources', 'yinian-internal', 'model-auth-profiles.json') : '', join(process.cwd(), 'build', 'yinian-internal', 'model-auth-profiles.json'), ].filter(Boolean); return candidates.find((candidate) => existsSync(candidate)); } async function hasYinianModelAuthProfile(): Promise { const authProfilesPath = join(getOpenClawConfigDir(), 'agents', 'main', 'agent', 'auth-profiles.json'); const store = await readJsonFile(authProfilesPath); const profiles = asObject(store.profiles); return Object.values(profiles).some((profile) => isUsableMinimaxAuthProfile(asObject(profile))); } function isUsableMinimaxAuthProfile(profile: JsonObject): boolean { return profile.type === 'api_key' && profile.provider === INTERNAL_PROVIDER_KEY && typeof profile.key === 'string' && profile.key.trim().length >= 8; } async function ensureAuxiliaryRuntimeMarkers(): Promise>> { const dir = join(getOpenClawConfigDir(), 'runtime'); await mkdir(dir, { recursive: true }); const officeRuntime = await ensureOfficeSkillRuntimeReady(); await writeFile(join(dir, 'yinian-initialized.json'), JSON.stringify({ initializedAt: Date.now(), documentRuntime: 'bundled', officeRuntime: { ok: officeRuntime.ok, checks: officeRuntime.checks, python: { executable: officeRuntime.python.executable, packages: officeRuntime.python.packages.map((pkg) => ({ packageName: pkg.packageName, installed: pkg.installed, })), }, dotnet: officeRuntime.dotnet, }, }, null, 2), 'utf8'); return officeRuntime; } async function readJsonFile(filePath: string): Promise { 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 : {}; } function arraysEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } function resolveYinianEnabledSkillIds(config: JsonObject): string[] { const skills = asObject(config.skills); const entries = asObject(skills.entries); const enabled = Object.entries(entries) .filter(([, value]) => asObject(value).enabled !== false) .map(([id]) => id.trim()) .filter(Boolean); const unique = [...new Set(enabled)]; const ordered = [ ...YINIAN_FALLBACK_SKILL_IDS.filter((id) => unique.includes(id)), ...unique.filter((id) => !YINIAN_FALLBACK_SKILL_IDS.includes(id)).sort((left, right) => left.localeCompare(right)), ]; return ordered.length > 0 ? ordered : [...YINIAN_FALLBACK_SKILL_IDS]; }