452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
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];
|
||
}
|