Files
NianToB/electron/utils/yinian-initializer.ts

633 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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<YinianInitializationStatus> | 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<YinianInitializationStatus> {
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<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 运行环境不可用');
}
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<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 seedModelApiConfiguration(): Promise<InternalModelAuthSeedResult> {
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<void> {
await writeBaseOpenClawConfig(runtimeConfig);
}
async function ensureBaseOpenClawConfig(): Promise<string | undefined> {
return writeBaseOpenClawConfig();
}
async function writeBaseOpenClawConfig(runtimeConfig?: ModelRuntimeConfig): Promise<string | undefined> {
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<void> {
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<string, unknown>;
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<string, unknown>;
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<string | undefined> {
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<boolean> {
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<Awaited<ReturnType<typeof ensureOfficeSkillRuntimeReady>>> {
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<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
: {};
}
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];
}