Files
NianToB/electron/utils/yinian-initializer.ts
2026-05-12 19:44:44 +08:00

452 lines
16 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';
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}`;
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<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 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<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', '正在写入内测模型配置');
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<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;
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<void> {
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<string, unknown>;
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<string, unknown>;
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<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) => 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<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];
}