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'; import { YINIAN_MODEL_ENTRY, YINIAN_MODEL_PROVIDER_KEY, } from '../../shared/yinian-model'; type JsonObject = Record; type InternalModelAuthSeedResult = | { status: 'seeded'; path: string; config: ModelRuntimeConfig } | { status: 'skipped'; path?: string; reason: string; modelRef?: string }; interface ModelRuntimeConfig { providerKey: string; modelId: string; modelName: string; modelRef: string; baseUrl: string; api: string; authHeader?: boolean; fallbackModelRefs: string[]; authProfileId: string; } 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 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', 'SOUL.md'), join('docs', 'reference', 'templates', 'IDENTITY.md'), join('docs', 'reference', 'templates', 'USER.md'), join('docs', 'reference', 'templates', 'AGENTS.md'), join('docs', 'reference', 'templates', 'TOOLS.md'), join('docs', 'reference', 'templates', 'HEARTBEAT.md'), join('docs', 'reference', 'templates', 'BOOT.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: '准备模型 API 配置', 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 currentModelRef = modelConfigReady ? await getConfiguredPrimaryModelRef() : undefined; const modelReady = modelConfigReady; 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, officeMarkerReady, }); } return { initialized, initializedAt: settings.openclawInitializedAt, openclawDir: runtimeStatus.runtimeDir, model: initialized ? currentModelRef : 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' : 'pending', message: modelReady ? currentModelRef || '模型 API 可在设置中配置' : 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', '正在准备模型 API 配置'); const authSeedResult = await seedModelApiConfiguration(); const modelRef = authSeedResult.status === 'seeded' ? authSeedResult.config.modelRef : authSeedResult.modelRef; setStep('model', 'success', modelRef || '模型 API 可在设置中配置'); 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: modelRef, modelSeedStatus: authSeedResult.status, }); return { initialized: true, initializedAt, openclawDir: runtime.dir, model: modelRef, 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 seedModelApiConfiguration(): Promise { const bundledAuthPath = resolveBundledModelAuthPath(); if (!bundledAuthPath) { logger.warn('[yinian-init] Model API auth bundle was not found'); const modelRef = await ensureBaseOpenClawConfig(); return { status: 'skipped', reason: '安装包缺少模型 API 凭据资源', modelRef, }; } const bundled = await readJsonFile(bundledAuthPath); if (bundled.bundled !== true) { const reason = typeof bundled.reason === 'string' && bundled.reason.trim() ? bundled.reason.trim() : '构建时未启用模型 API 凭据打包'; logger.warn('[yinian-init] Model API auth bundle is disabled', { bundledAuthPath, reason, }); const modelRef = await ensureBaseOpenClawConfig(); return { status: 'skipped', path: bundledAuthPath, reason, modelRef, }; } let runtimeConfig: ModelRuntimeConfig; try { runtimeConfig = resolveModelRuntimeConfig(bundled); } catch (error) { const reason = error instanceof Error ? error.message : String(error); logger.warn('[yinian-init] Model API runtime config bundle is incomplete; skipping bundled model seed', { bundledAuthPath, reason, }); const modelRef = await ensureBaseOpenClawConfig(); return { status: 'skipped', path: bundledAuthPath, reason, modelRef, }; } const hasBundledAuth = hasUsableBundledModelAuthProfile(bundled, runtimeConfig); const hasExistingAuth = await hasYinianModelAuthProfile(runtimeConfig.providerKey); if (!hasBundledAuth && !hasExistingAuth) { logger.warn('[yinian-init] Model API auth bundle is incomplete; skipping bundled model seed', { bundledAuthPath }); const modelRef = await ensureBaseOpenClawConfig(); return { status: 'skipped', path: bundledAuthPath, reason: '模型 API 凭据资源不完整', modelRef, }; } await seedModelApiConfig(runtimeConfig); if (hasBundledAuth) { await seedModelApiAuthProfiles(bundledAuthPath, bundled, runtimeConfig); } return { status: 'seeded', path: bundledAuthPath, config: runtimeConfig, }; } async function seedModelApiConfig(runtimeConfig: ModelRuntimeConfig): Promise { await writeBaseOpenClawConfig(runtimeConfig); } async function ensureBaseOpenClawConfig(): Promise { return writeBaseOpenClawConfig(); } async function writeBaseOpenClawConfig(runtimeConfig?: ModelRuntimeConfig): 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); if (runtimeConfig) { providers[runtimeConfig.providerKey] = { baseUrl: runtimeConfig.baseUrl, api: runtimeConfig.api, ...(typeof runtimeConfig.authHeader === 'boolean' ? { authHeader: runtimeConfig.authHeader } : {}), models: [ { ...YINIAN_MODEL_ENTRY, id: runtimeConfig.modelId, name: runtimeConfig.modelName, }, ], }; } 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); if (runtimeConfig) { defaults.model = { primary: runtimeConfig.modelRef, fallbacks: [...runtimeConfig.fallbackModelRefs], }; } 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'); return getNonEmptyString(asObject(defaults.model).primary); } async function seedModelApiAuthProfiles( bundledAuthPath: string, bundled: JsonObject, runtimeConfig: ModelRuntimeConfig, ): Promise { const bundledStore = asObject(bundled.store); const bundledProfiles = asObject(bundledStore.profiles); const bundledDefault = asObject(bundledProfiles[runtimeConfig.authProfileId]); const fallbackBundledDefault = Object.values(bundledProfiles) .map(asObject) .find((profile) => isUsableModelApiKeyProfile(profile)); if (!isUsableModelApiKeyProfile(bundledDefault) && !fallbackBundledDefault) { logger.warn('[yinian-init] Model API auth bundle is incomplete', { bundledAuthPath }); throw new Error('模型 API 凭据资源不完整'); } 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 (!isUsableModelApiKeyProfile(bundledProfile)) continue; const targetProfileId = profileId === runtimeConfig.authProfileId ? runtimeConfig.authProfileId : profileId.startsWith(`${runtimeConfig.providerKey}:`) ? profileId : runtimeConfig.authProfileId; if (!isUsableModelApiKeyProfile(asObject(profiles[targetProfileId]), runtimeConfig.providerKey)) { profiles[targetProfileId] = { type: 'api_key', provider: runtimeConfig.providerKey, key: bundledProfile.key, }; changed = true; } } const order = asObject(current.order) as Record; const currentOrder = Array.isArray(order[runtimeConfig.providerKey]) ? (order[runtimeConfig.providerKey] as unknown[]).filter((value): value is string => typeof value === 'string') : []; if (!currentOrder.includes(runtimeConfig.authProfileId)) { order[runtimeConfig.providerKey] = [ runtimeConfig.authProfileId, ...currentOrder.filter((profileId) => profileId !== runtimeConfig.authProfileId), ]; changed = true; } const lastGood = asObject(current.lastGood) as Record; if (lastGood[runtimeConfig.providerKey] !== runtimeConfig.authProfileId) { lastGood[runtimeConfig.providerKey] = runtimeConfig.authProfileId; 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)); } function hasUsableBundledModelAuthProfile(bundle: JsonObject, runtimeConfig: ModelRuntimeConfig): boolean { const bundledStore = asObject(bundle.store); const bundledProfiles = asObject(bundledStore.profiles); const bundledDefault = asObject(bundledProfiles[runtimeConfig.authProfileId]); if (isUsableModelApiKeyProfile(bundledDefault)) { return true; } return Object.values(bundledProfiles) .map(asObject) .some((profile) => isUsableModelApiKeyProfile(profile)); } function resolveModelRuntimeConfig(bundle: JsonObject): ModelRuntimeConfig { const model = asObject(bundle.model); const providerKey = getNonEmptyString(model.providerKey); const modelId = getNonEmptyString(model.modelId); const baseUrl = getNonEmptyString(model.baseUrl); const api = getNonEmptyString(model.api); const missingFields = [ !providerKey ? 'model.providerKey' : '', !modelId ? 'model.modelId' : '', !baseUrl ? 'model.baseUrl' : '', !api ? 'model.api' : '', ].filter(Boolean); if (missingFields.length > 0) { throw new Error(`模型 API 配置资源不完整:缺少 ${missingFields.join(', ')}`); } const modelName = getNonEmptyString(model.modelName) || getNonEmptyString(model.name) || modelId; const authProfileId = getNonEmptyString(model.authProfileId) || `${providerKey}:default`; const fallbackModelRefs = readModelRefList(model.fallbackModelRefs) .concat(readModelRefList(model.fallbacks)) .filter((value, index, list) => list.indexOf(value) === index); return { providerKey, modelId, modelName, modelRef: `${providerKey}/${modelId}`, baseUrl, api, authHeader: typeof model.authHeader === 'boolean' ? model.authHeader : undefined, fallbackModelRefs, authProfileId, }; } function getNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function readModelRefList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value .map((item) => typeof item === 'string' ? item.trim() : '') .filter(Boolean); } async function getConfiguredPrimaryModelRef(): Promise { const configPath = join(getOpenClawConfigDir(), 'openclaw.json'); const config = await readJsonFile(configPath); const agents = asObject(config.agents); const defaults = asObject(agents.defaults); const model = asObject(defaults.model); return getNonEmptyString(model.primary); } function splitProviderKey(modelRef: string): string | undefined { const separatorIndex = modelRef.indexOf('/'); if (separatorIndex <= 0) return undefined; return modelRef.slice(0, separatorIndex); } async function hasYinianModelAuthProfile(providerKey = YINIAN_MODEL_PROVIDER_KEY): 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) => isUsableModelApiKeyProfile(asObject(profile), providerKey)); } function isUsableModelApiKeyProfile(profile: JsonObject, providerKey?: string): boolean { return profile.type === 'api_key' && (!providerKey || profile.provider === providerKey) && 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]; }