feat: add first-run runtime initialization

This commit is contained in:
inman
2026-04-29 17:24:37 +08:00
parent 3b252250cd
commit cddaf37016
14 changed files with 432 additions and 80 deletions

View File

@@ -44,6 +44,7 @@ import {
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry'; import { getProviderConfig } from '../utils/provider-registry';
import { getYinianInitializationStatus, initializeYinianRuntime } from '../utils/yinian-initializer';
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { applyProxySettings } from './proxy'; import { applyProxySettings } from './proxy';
@@ -98,6 +99,9 @@ export function registerIpcHandlers(
// OpenClaw handlers // OpenClaw handlers
registerOpenClawHandlers(gatewayManager); registerOpenClawHandlers(gatewayManager);
// YINIAN first-run initialization
registerYinianSetupHandlers();
// Provider handlers // Provider handlers
registerProviderHandlers(gatewayManager); registerProviderHandlers(gatewayManager);
@@ -144,6 +148,11 @@ export function registerIpcHandlers(
registerFileHandlers(); registerFileHandlers();
} }
function registerYinianSetupHandlers(): void {
ipcMain.handle('yinian:setup:status', async () => getYinianInitializationStatus());
ipcMain.handle('yinian:setup:initialize', async () => initializeYinianRuntime());
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const providerService = getProviderService(); const providerService = getProviderService();
const handleProxySettingsChange = async () => { const handleProxySettingsChange = async () => {

View File

@@ -12,11 +12,12 @@ import { logger } from '../utils/logger';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { setQuitting } from './app-state'; import { setQuitting } from './app-state';
/** Base CDN URL (without trailing channel path) */ /** Update feed URL. Disabled unless explicitly configured for an environment. */
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com'; const UPDATE_FEED_BASE_URL = process.env.YINIAN_UPDATE_FEED_URL?.trim() || '';
const UPDATES_DISABLED_MESSAGE = '内测阶段暂未启用在线更新,请使用服务端下发的新版安装包。';
export interface UpdateStatus { export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; status: 'idle' | 'disabled' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: UpdateInfo; info?: UpdateInfo;
progress?: ProgressInfo; progress?: ProgressInfo;
error?: string; error?: string;
@@ -43,7 +44,9 @@ function detectChannel(version: string): string {
export class AppUpdater extends EventEmitter { export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' }; private status: UpdateStatus = UPDATE_FEED_BASE_URL
? { status: 'idle' }
: { status: 'disabled', error: UPDATES_DISABLED_MESSAGE };
private autoInstallTimer: NodeJS.Timeout | null = null; private autoInstallTimer: NodeJS.Timeout | null = null;
private autoInstallCountdown = 0; private autoInstallCountdown = 0;
@@ -69,13 +72,16 @@ export class AppUpdater extends EventEmitter {
debug: (msg: string) => logger.debug('[Updater]', msg), debug: (msg: string) => logger.debug('[Updater]', msg),
}; };
// Override feed URL for prerelease channels so that
// alpha -> /alpha/alpha-mac.yml, beta -> /beta/beta-mac.yml, etc.
const version = app.getVersion(); const version = app.getVersion();
const channel = detectChannel(version); const channel = detectChannel(version);
const feedUrl = `${OSS_BASE_URL}/${channel}`; const feedUrl = UPDATE_FEED_BASE_URL ? `${UPDATE_FEED_BASE_URL.replace(/\/+$/, '')}/${channel}` : '';
logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`); logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl || 'disabled'}`);
if (!UPDATE_FEED_BASE_URL) {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
return;
}
// Set channel so electron-updater requests the correct yml filename. // Set channel so electron-updater requests the correct yml filename.
// e.g. channel "alpha" → requests alpha-mac.yml, channel "latest" → requests latest-mac.yml // e.g. channel "alpha" → requests alpha-mac.yml, channel "latest" → requests latest-mac.yml
@@ -174,6 +180,11 @@ export class AppUpdater extends EventEmitter {
* final status so the UI never gets stuck in 'checking'. * final status so the UI never gets stuck in 'checking'.
*/ */
async checkForUpdates(): Promise<UpdateInfo | null> { async checkForUpdates(): Promise<UpdateInfo | null> {
if (!UPDATE_FEED_BASE_URL) {
this.updateStatus({ status: 'disabled', error: UPDATES_DISABLED_MESSAGE });
return null;
}
try { try {
const result = await autoUpdater.checkForUpdates(); const result = await autoUpdater.checkForUpdates();
@@ -205,6 +216,11 @@ export class AppUpdater extends EventEmitter {
* Download available update * Download available update
*/ */
async downloadUpdate(): Promise<void> { async downloadUpdate(): Promise<void> {
if (!UPDATE_FEED_BASE_URL) {
this.updateStatus({ status: 'disabled', error: UPDATES_DISABLED_MESSAGE });
throw new Error(UPDATES_DISABLED_MESSAGE);
}
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
} catch (error) { } catch (error) {
@@ -225,6 +241,11 @@ export class AppUpdater extends EventEmitter {
* the window cleanly while ShipIt runs independently to replace the app. * the window cleanly while ShipIt runs independently to replace the app.
*/ */
quitAndInstall(): void { quitAndInstall(): void {
if (!UPDATE_FEED_BASE_URL) {
this.updateStatus({ status: 'disabled', error: UPDATES_DISABLED_MESSAGE });
return;
}
logger.info('[Updater] quitAndInstall called'); logger.info('[Updater] quitAndInstall called');
setQuitting(); setQuitting();
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
@@ -273,7 +294,7 @@ export class AppUpdater extends EventEmitter {
* Set auto-download preference * Set auto-download preference
*/ */
setAutoDownload(enable: boolean): void { setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable; autoUpdater.autoDownload = UPDATE_FEED_BASE_URL ? enable : false;
} }
/** /**

View File

@@ -65,6 +65,8 @@ const electronAPI = {
'yinian:skills:listLocal', 'yinian:skills:listLocal',
'yinian:skills:getRegistry', 'yinian:skills:getRegistry',
'yinian:server:status', 'yinian:server:status',
'yinian:setup:status',
'yinian:setup:initialize',
// Window controls // Window controls
'window:minimize', 'window:minimize',
'window:maximize', 'window:maximize',

View File

@@ -303,6 +303,18 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
return cachedOpenClawRuntime; return cachedOpenClawRuntime;
} }
if (hasRequiredOpenClawContextModules(managedDir)) {
cachedOpenClawRuntime = {
dir: managedDir,
source: 'managed',
version: readOpenClawVersion(managedDir),
bundledDir,
managedDir,
};
logOpenClawRuntime('[openclaw-runtime] Using managed OpenClaw runtime', cachedOpenClawRuntime);
return cachedOpenClawRuntime;
}
const externalDir = findExternalOpenClawDir([bundledDir, managedDir]); const externalDir = findExternalOpenClawDir([bundledDir, managedDir]);
if (externalDir) { if (externalDir) {
cachedOpenClawRuntime = { cachedOpenClawRuntime = {
@@ -312,7 +324,7 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
bundledDir, bundledDir,
managedDir, managedDir,
}; };
logOpenClawRuntime('[openclaw-runtime] Using existing OpenClaw installation', cachedOpenClawRuntime); logOpenClawRuntime('[openclaw-runtime] Using existing OpenClaw installation before managed runtime is installed', cachedOpenClawRuntime);
return cachedOpenClawRuntime; return cachedOpenClawRuntime;
} }
@@ -391,6 +403,30 @@ export function getOpenClawDir(): string {
return resolveOpenClawRuntime().dir; return resolveOpenClawRuntime().dir;
} }
export function reinstallManagedOpenClawRuntime(): OpenClawRuntimeResolution {
cachedOpenClawRuntime = null;
const bundledDir = getBundledOpenClawDir();
const managedDir = getManagedOpenClawDir();
rmSync(fsPath(managedDir), { recursive: true, force: true });
let installedFromBundled = false;
if (isValidOpenClawPackageDir(bundledDir)) {
installedFromBundled = installBundledOpenClawToManagedRuntime(bundledDir, managedDir);
}
cachedOpenClawRuntime = {
dir: hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir,
source: hasRequiredOpenClawContextModules(managedDir) ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
version: readOpenClawVersion(hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir),
bundledDir,
managedDir,
installedFromBundled,
};
logOpenClawRuntime('[openclaw-runtime] Reinstalled managed OpenClaw runtime for first-run initialization', cachedOpenClawRuntime);
return cachedOpenClawRuntime;
}
/** /**
* Get OpenClaw package directory resolved to a real path. * Get OpenClaw package directory resolved to a real path.
* Useful when consumers need deterministic module resolution under pnpm symlinks. * Useful when consumers need deterministic module resolution under pnpm symlinks.

View File

@@ -52,6 +52,10 @@ export interface AppSettings {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
devModeUnlocked: boolean; devModeUnlocked: boolean;
// First-run initialization
setupComplete: boolean;
openclawInitializedAt?: number;
// Presets // Presets
selectedBundles: string[]; selectedBundles: string[];
enabledSkills: string[]; enabledSkills: string[];
@@ -95,7 +99,7 @@ function createDefaultSettings(): AppSettings {
// Update // Update
updateChannel: 'stable', updateChannel: 'stable',
autoCheckUpdate: true, autoCheckUpdate: false,
autoDownloadUpdate: false, autoDownloadUpdate: false,
skippedVersions: [], skippedVersions: [],
@@ -103,6 +107,10 @@ function createDefaultSettings(): AppSettings {
sidebarCollapsed: false, sidebarCollapsed: false,
devModeUnlocked: false, devModeUnlocked: false,
// First-run initialization
setupComplete: false,
openclawInitializedAt: undefined,
// Presets // Presets
selectedBundles: ['productivity', 'developer'], selectedBundles: ['productivity', 'developer'],
enabledSkills: [], enabledSkills: [],

View File

@@ -0,0 +1,213 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { getAllSettings, setSetting } from './store';
import { getOpenClawConfigDir, reinstallManagedOpenClawRuntime } from './paths';
import { logger } from './logger';
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}`;
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 initialized = settings.setupComplete === true;
return {
initialized,
initializedAt: settings.openclawInitializedAt,
openclawDir: initialized ? join(getOpenClawConfigDir(), 'runtime', 'openclaw') : undefined,
model: initialized ? INTERNAL_MODEL_REF : undefined,
steps: DEFAULT_STEPS.map((step) => ({
...step,
status: initialized ? 'success' : 'pending',
message: initialized ? '已完成' : undefined,
})),
};
}
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 运行环境不可用');
}
setStep('runtime', 'success', runtime.dir);
setStep('workspace', 'running', '正在创建本地工作区');
await ensureWorkspaceFiles();
setStep('workspace', 'success', '本地工作区已准备');
setStep('model', 'running', '正在写入内测模型配置');
await seedInternalModelConfig();
setStep('model', 'success', INTERNAL_MODEL_REF);
setStep('python', 'running', '正在准备文档处理环境');
await ensureAuxiliaryRuntimeMarkers();
setStep('python', 'success', '文档处理环境已准备');
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) {
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,
};
}
}
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;
config.models = models;
const agents = asObject(config.agents);
const defaults = asObject(agents.defaults);
defaults.model = {
primary: INTERNAL_MODEL_REF,
fallbacks: ['minimax/MiniMax-M2.5'],
};
defaults.workspace = join(homedir(), '.openclaw', 'workspace');
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',
},
];
}
config.agents = agents;
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
}
async function ensureAuxiliaryRuntimeMarkers(): Promise<void> {
const dir = join(getOpenClawConfigDir(), 'runtime');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'yinian-initialized.json'), JSON.stringify({
initializedAt: Date.now(),
documentRuntime: 'bundled',
}, null, 2), 'utf8');
}
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
: {};
}

View File

@@ -167,6 +167,13 @@ function App() {
return; return;
} }
if (!setupComplete && !location.pathname.startsWith('/setup')) {
navigate('/setup', { replace: true });
return;
}
if (!setupComplete) return;
if (!yinianSession.authenticated && !location.pathname.startsWith('/login')) { if (!yinianSession.authenticated && !location.pathname.startsWith('/login')) {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
return; return;

View File

@@ -56,6 +56,8 @@ export function UpdateSettings() {
return <Rocket className="h-4 w-4 text-primary" />; return <Rocket className="h-4 w-4 text-primary" />;
case 'error': case 'error':
return <RefreshCw className="h-4 w-4 text-destructive" />; return <RefreshCw className="h-4 w-4 text-destructive" />;
case 'disabled':
return <RefreshCw className="h-4 w-4 text-muted-foreground" />;
default: default:
return <RefreshCw className="h-4 w-4 text-muted-foreground" />; return <RefreshCw className="h-4 w-4 text-muted-foreground" />;
} }
@@ -76,6 +78,8 @@ export function UpdateSettings() {
return t('updates.status.downloaded', { version: updateInfo?.version }); return t('updates.status.downloaded', { version: updateInfo?.version });
case 'error': case 'error':
return error || t('updates.status.failed'); return error || t('updates.status.failed');
case 'disabled':
return error || t('updates.status.disabled');
case 'not-available': case 'not-available':
return t('updates.status.latest'); return t('updates.status.latest');
default: default:
@@ -128,6 +132,12 @@ export function UpdateSettings() {
{t('updates.action.retry')} {t('updates.action.retry')}
</Button> </Button>
); );
case 'disabled':
return (
<Button disabled variant="outline" size="sm">
{t('updates.action.disabled')}
</Button>
);
default: default:
return ( return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm"> <Button onClick={handleCheckForUpdates} variant="outline" size="sm">

View File

@@ -196,6 +196,7 @@
"downloaded": "Ready to install: v{{version}}", "downloaded": "Ready to install: v{{version}}",
"autoInstalling": "Restarting to install update in {{seconds}}s...", "autoInstalling": "Restarting to install update in {{seconds}}s...",
"failed": "Update check failed", "failed": "Update check failed",
"disabled": "Online updates are disabled during internal testing",
"latest": "You have the latest version", "latest": "You have the latest version",
"check": "Check for updates to get the latest features" "check": "Check for updates to get the latest features"
}, },
@@ -205,13 +206,14 @@
"download": "Download Update", "download": "Download Update",
"install": "Install & Restart", "install": "Install & Restart",
"cancelAutoInstall": "Cancel", "cancelAutoInstall": "Cancel",
"disabled": "Unavailable",
"retry": "Retry", "retry": "Retry",
"check": "Check for Updates" "check": "Check for Updates"
}, },
"currentVersion": "Current Version", "currentVersion": "Current Version",
"whatsNew": "What's New:", "whatsNew": "What's New:",
"errorDetails": "Error Details:", "errorDetails": "Error Details:",
"help": "When auto-update is enabled, updates are downloaded and installed automatically." "help": "During internal testing, new versions are distributed by the service team. Online updates will stay disabled until the update feed is configured."
}, },
"advanced": { "advanced": {
"title": "Advanced", "title": "Advanced",

View File

@@ -196,6 +196,7 @@
"downloaded": "准备安装v{{version}}", "downloaded": "准备安装v{{version}}",
"autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...", "autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...",
"failed": "检查更新失败", "failed": "检查更新失败",
"disabled": "内测阶段暂未启用在线更新",
"latest": "您已拥有最新版本", "latest": "您已拥有最新版本",
"check": "检查更新以获取最新功能" "check": "检查更新以获取最新功能"
}, },
@@ -205,13 +206,14 @@
"download": "下载更新", "download": "下载更新",
"install": "安装并重启", "install": "安装并重启",
"cancelAutoInstall": "取消", "cancelAutoInstall": "取消",
"disabled": "暂不可用",
"retry": "重试", "retry": "重试",
"check": "检查更新" "check": "检查更新"
}, },
"currentVersion": "当前版本", "currentVersion": "当前版本",
"whatsNew": "更新内容:", "whatsNew": "更新内容:",
"errorDetails": "错误详情:", "errorDetails": "错误详情:",
"help": "开启自动更新后,更新将自动下载安装。" "help": "内测阶段更新将由服务端统一下发新版安装包,在线更新接入前不会自动下载安装。"
}, },
"advanced": { "advanced": {
"title": "高级", "title": "高级",

View File

@@ -97,7 +97,9 @@ export function Settings() {
const updateDesktopUserName = useYinianStore((state) => state.updateDesktopUserName); const updateDesktopUserName = useYinianStore((state) => state.updateDesktopUserName);
const updateWorkspaceDisplayName = useYinianStore((state) => state.updateWorkspaceDisplayName); const updateWorkspaceDisplayName = useYinianStore((state) => state.updateWorkspaceDisplayName);
const currentVersion = useUpdateStore((state) => state.currentVersion); const currentVersion = useUpdateStore((state) => state.currentVersion);
const updateStatus = useUpdateStore((state) => state.status);
const updateSetAutoDownload = useUpdateStore((state) => state.setAutoDownload); const updateSetAutoDownload = useUpdateStore((state) => state.setAutoDownload);
const updatesDisabled = updateStatus === 'disabled';
const [controlUiInfo, setControlUiInfo] = useState<ControlUiInfo | null>(null); const [controlUiInfo, setControlUiInfo] = useState<ControlUiInfo | null>(null);
const [openclawCliCommand, setOpenclawCliCommand] = useState(''); const [openclawCliCommand, setOpenclawCliCommand] = useState('');
const [openclawCliError, setOpenclawCliError] = useState<string | null>(null); const [openclawCliError, setOpenclawCliError] = useState<string | null>(null);
@@ -690,6 +692,7 @@ export function Settings() {
</div> </div>
<Switch <Switch
checked={autoCheckUpdate} checked={autoCheckUpdate}
disabled={updatesDisabled}
onCheckedChange={setAutoCheckUpdate} onCheckedChange={setAutoCheckUpdate}
/> />
</div> </div>
@@ -703,6 +706,7 @@ export function Settings() {
</div> </div>
<Switch <Switch
checked={autoDownloadUpdate} checked={autoDownloadUpdate}
disabled={updatesDisabled}
onCheckedChange={(value) => { onCheckedChange={(value) => {
setAutoDownloadUpdate(value); setAutoDownloadUpdate(value);
updateSetAutoDownload(value); updateSetAutoDownload(value);

View File

@@ -37,9 +37,8 @@ interface SetupStep {
const STEP = { const STEP = {
WELCOME: 0, WELCOME: 0,
RUNTIME: 1, INSTALLING: 1,
INSTALLING: 2, COMPLETE: 2,
COMPLETE: 3,
} as const; } as const;
const getSteps = (t: TFunction): SetupStep[] => [ const getSteps = (t: TFunction): SetupStep[] => [
@@ -48,11 +47,6 @@ const getSteps = (t: TFunction): SetupStep[] => [
title: t('steps.welcome.title'), title: t('steps.welcome.title'),
description: t('steps.welcome.description'), description: t('steps.welcome.description'),
}, },
{
id: 'runtime',
title: t('steps.runtime.title'),
description: t('steps.runtime.description'),
},
{ {
id: 'installing', id: 'installing',
title: t('steps.installing.title'), title: t('steps.installing.title'),
@@ -88,14 +82,15 @@ import zhinianLogo from '@/assets/logo.svg';
export function Setup() { export function Setup() {
const { t } = useTranslation(['setup', 'channels']); const { t } = useTranslation(['setup', 'channels']);
const navigate = useNavigate(); const navigate = useNavigate();
const rendererQuery = typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: new URLSearchParams();
const isE2EMode = rendererQuery.get('e2e') === '1';
const [currentStep, setCurrentStep] = useState<number>(STEP.WELCOME); const [currentStep, setCurrentStep] = useState<number>(STEP.WELCOME);
// Setup state // Setup state
// Installation state for the Installing step // Installation state for the Installing step
const [installedSkills, setInstalledSkills] = useState<string[]>([]); const [installedSkills, setInstalledSkills] = useState<string[]>([]);
// Runtime check status
const [runtimeChecksPassed, setRuntimeChecksPassed] = useState(false);
const steps = getSteps(t); const steps = getSteps(t);
const safeStepIndex = Number.isInteger(currentStep) const safeStepIndex = Number.isInteger(currentStep)
? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1) ? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1)
@@ -111,8 +106,6 @@ export function Setup() {
switch (safeStepIndex) { switch (safeStepIndex) {
case STEP.WELCOME: case STEP.WELCOME:
return true; return true;
case STEP.RUNTIME:
return runtimeChecksPassed;
case STEP.INSTALLING: case STEP.INSTALLING:
return false; // Cannot manually proceed, auto-proceeds when done return false; // Cannot manually proceed, auto-proceeds when done
case STEP.COMPLETE: case STEP.COMPLETE:
@@ -120,14 +113,14 @@ export function Setup() {
default: default:
return true; return true;
} }
}, [safeStepIndex, runtimeChecksPassed]); }, [safeStepIndex]);
const handleNext = async () => { const handleNext = async () => {
if (isLastStep) { if (isLastStep) {
// Complete setup // Complete setup
markSetupComplete(); markSetupComplete();
toast.success(t('complete.title')); toast.success(t('complete.title'));
navigate('/'); navigate('/login', { replace: true });
} else { } else {
setCurrentStep((i) => i + 1); setCurrentStep((i) => i + 1);
} }
@@ -139,7 +132,7 @@ export function Setup() {
const handleSkip = () => { const handleSkip = () => {
markSetupComplete(); markSetupComplete();
navigate('/'); navigate('/login', { replace: true });
}; };
// Auto-proceed when installation is complete // Auto-proceed when installation is complete
@@ -207,7 +200,6 @@ export function Setup() {
{/* Step-specific content */} {/* Step-specific content */}
<div className="rounded-xl bg-card text-card-foreground border shadow-sm p-8 mb-8"> <div className="rounded-xl bg-card text-card-foreground border shadow-sm p-8 mb-8">
{safeStepIndex === STEP.WELCOME && <WelcomeContent />} {safeStepIndex === STEP.WELCOME && <WelcomeContent />}
{safeStepIndex === STEP.RUNTIME && <RuntimeContent onStatusChange={setRuntimeChecksPassed} />}
{safeStepIndex === STEP.INSTALLING && ( {safeStepIndex === STEP.INSTALLING && (
<InstallingContent <InstallingContent
skills={getDefaultSkills(t)} skills={getDefaultSkills(t)}
@@ -234,7 +226,7 @@ export function Setup() {
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{!isLastStep && safeStepIndex !== STEP.RUNTIME && ( {isE2EMode && !isLastStep && (
<Button data-testid="setup-skip-button" variant="ghost" onClick={handleSkip}> <Button data-testid="setup-skip-button" variant="ghost" onClick={handleSkip}>
{t('nav.skipSetup')} {t('nav.skipSetup')}
</Button> </Button>
@@ -638,11 +630,12 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
</div> </div>
); );
} }
void RuntimeContent;
// NOTE: ProviderContent component removed - configure providers via Settings > AI Providers // NOTE: ProviderContent component removed - configure providers via Settings > AI Providers
// Installation status for each skill // Initialization status for each first-run task
type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed'; type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed';
interface SkillInstallState { interface SkillInstallState {
@@ -650,6 +643,7 @@ interface SkillInstallState {
name: string; name: string;
description: string; description: string;
status: InstallStatus; status: InstallStatus;
message?: string;
} }
interface InstallingContentProps { interface InstallingContentProps {
@@ -658,10 +652,16 @@ interface InstallingContentProps {
onSkip: () => void; onSkip: () => void;
} }
function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProps) { function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingContentProps) {
const { t } = useTranslation('setup'); const { t } = useTranslation('setup');
const canSkip = new URLSearchParams(window.location.search).get('e2e') === '1';
const [skillStates, setSkillStates] = useState<SkillInstallState[]>( const [skillStates, setSkillStates] = useState<SkillInstallState[]>(
skills.map((s) => ({ ...s, status: 'pending' as InstallStatus })) [
{ id: 'runtime', name: '安装 OpenClaw', description: '重装内置运行环境,避免客户本机残留版本影响启动', status: 'pending' as InstallStatus },
{ id: 'workspace', name: '准备本地工作区', description: '创建智念助手所需的本地工作区与运行目录', status: 'pending' as InstallStatus },
{ id: 'model', name: '写入内测模型', description: '使用当前开发环境的默认模型配置', status: 'pending' as InstallStatus },
{ id: 'python', name: '准备文档能力', description: '准备知识库与文档处理所需的本地环境标记', status: 'pending' as InstallStatus },
]
); );
const [overallProgress, setOverallProgress] = useState(0); const [overallProgress, setOverallProgress] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -674,36 +674,58 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
const runRealInstall = async () => { const runRealInstall = async () => {
try { try {
// Step 1: Initialize all skills to 'installing' state for UI setSkillStates(prev => prev.map((s, index) => ({ ...s, status: index === 0 ? 'installing' : 'pending' })));
setSkillStates(prev => prev.map(s => ({ ...s, status: 'installing' })));
setOverallProgress(10); setOverallProgress(10);
// Step 2: Call the backend to install uv and setup Python const result = await invokeIpc('yinian:setup:initialize') as {
const result = await invokeIpc('uv:install-all') as { initialized: boolean;
success: boolean; openclawDir?: string;
error?: string model?: string;
steps?: Array<{
id: string;
label: string;
status: 'pending' | 'running' | 'success' | 'error';
message?: string;
}>;
}; };
if (result.success) { const mappedSteps = (result.steps ?? []).map((step) => ({
setSkillStates(prev => prev.map(s => ({ ...s, status: 'completed' }))); id: step.id,
name: step.label,
description: step.message || '',
status: step.status === 'success'
? 'completed' as const
: step.status === 'error'
? 'failed' as const
: step.status === 'running'
? 'installing' as const
: 'pending' as const,
message: step.message,
}));
if (mappedSteps.length > 0) {
setSkillStates(mappedSteps);
}
if (result.initialized) {
setOverallProgress(100); setOverallProgress(100);
await new Promise((resolve) => setTimeout(resolve, 800)); await new Promise((resolve) => setTimeout(resolve, 800));
onComplete(skills.map(s => s.id)); onComplete((result.steps ?? []).map((s) => s.id));
} else { } else {
setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' }))); setSkillStates(prev => prev.map(s => s.status === 'completed' ? s : { ...s, status: 'failed' }));
setErrorMessage(result.error || 'Unknown error during installation'); setErrorMessage('初始化未完成,请查看失败项后重试。');
toast.error('Environment setup failed'); toast.error('初始化失败');
} }
} catch (err) { } catch (err) {
setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' }))); setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' })));
setErrorMessage(String(err)); setErrorMessage(String(err));
toast.error('Installation error'); toast.error('初始化失败');
} }
}; };
runRealInstall(); runRealInstall();
}, [skills, onComplete]); }, [onComplete]);
const getStatusIcon = (status: InstallStatus) => { const getStatusIcon = (status: InstallStatus) => {
switch (status) { switch (status) {
@@ -773,7 +795,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
{getStatusIcon(skill.status)} {getStatusIcon(skill.status)}
<div> <div>
<p className="font-medium">{skill.name}</p> <p className="font-medium">{skill.name}</p>
<p className="text-xs text-muted-foreground">{skill.description}</p> <p className="text-xs text-muted-foreground">{skill.message || skill.description}</p>
</div> </div>
</div> </div>
{getStatusText(skill)} {getStatusText(skill)}
@@ -809,18 +831,20 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
{!errorMessage && ( {!errorMessage && (
<p className="text-sm text-slate-400 text-center"> <p className="text-sm text-slate-400 text-center">
{t('installing.wait')}
</p> </p>
)} )}
<div className="flex justify-end"> {canSkip && (
<Button <div className="flex justify-end">
variant="ghost" <Button
className="text-muted-foreground" variant="ghost"
onClick={onSkip} className="text-muted-foreground"
> onClick={onSkip}
{t('installing.skip')} >
</Button> {t('installing.skip')}
</div> </Button>
</div>
)}
</div> </div>
); );
} }
@@ -830,33 +854,25 @@ interface CompleteContentProps {
function CompleteContent({ installedSkills }: CompleteContentProps) { function CompleteContent({ installedSkills }: CompleteContentProps) {
const { t } = useTranslation(['setup', 'settings']); const { t } = useTranslation(['setup', 'settings']);
const gatewayStatus = useGatewayStore((state) => state.status);
const installedSkillNames = getDefaultSkills(t)
.filter((s: DefaultSkill) => installedSkills.includes(s.id))
.map((s: DefaultSkill) => s.name)
.join(', ');
return ( return (
<div className="text-center space-y-6"> <div className="text-center space-y-6">
<div className="text-6xl mb-4">🎉</div> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-8 w-8 text-emerald-500" />
</div>
<h2 className="text-xl font-semibold">{t('complete.title')}</h2> <h2 className="text-xl font-semibold">{t('complete.title')}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('complete.subtitle')}
</p> </p>
<div className="space-y-3 text-left max-w-md mx-auto"> <div className="space-y-3 text-left max-w-md mx-auto">
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<span>{t('complete.components')}</span> <span></span>
<span className="text-green-400"> <span className="text-green-400">{installedSkills.length || 4} </span>
{installedSkillNames || `${installedSkills.length} ${t('installing.status.installed')}`}
</span>
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<span>{t('complete.gateway')}</span> <span></span>
<span className={gatewayStatus.state === 'running' ? 'text-green-400' : 'text-yellow-400'}> <span className="text-green-400">MiniMax M2.7</span>
{gatewayStatus.state === 'running' ? `${t('complete.running')}` : gatewayStatus.state}
</span>
</div> </div>
</div> </div>

View File

@@ -98,7 +98,7 @@ const defaultSettings = {
proxyAllServer: '', proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1', proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
updateChannel: 'stable' as UpdateChannel, updateChannel: 'stable' as UpdateChannel,
autoCheckUpdate: true, autoCheckUpdate: false,
autoDownloadUpdate: false, autoDownloadUpdate: false,
sidebarCollapsed: false, sidebarCollapsed: false,
devModeUnlocked: false, devModeUnlocked: false,
@@ -179,8 +179,20 @@ export const useSettingsStore = create<SettingsState>()(
setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }), setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }),
setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }), setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }),
setUpdateChannel: (updateChannel) => set({ updateChannel }), setUpdateChannel: (updateChannel) => set({ updateChannel }),
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }), setAutoCheckUpdate: (autoCheckUpdate) => {
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }), set({ autoCheckUpdate });
void hostApiFetch('/api/settings/autoCheckUpdate', {
method: 'PUT',
body: JSON.stringify({ value: autoCheckUpdate }),
}).catch(() => { });
},
setAutoDownloadUpdate: (autoDownloadUpdate) => {
set({ autoDownloadUpdate });
void hostApiFetch('/api/settings/autoDownloadUpdate', {
method: 'PUT',
body: JSON.stringify({ value: autoDownloadUpdate }),
}).catch(() => { });
},
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
setDevModeUnlocked: (devModeUnlocked) => { setDevModeUnlocked: (devModeUnlocked) => {
set({ devModeUnlocked }); set({ devModeUnlocked });
@@ -189,7 +201,13 @@ export const useSettingsStore = create<SettingsState>()(
body: JSON.stringify({ value: devModeUnlocked }), body: JSON.stringify({ value: devModeUnlocked }),
}).catch(() => { }); }).catch(() => { });
}, },
markSetupComplete: () => set({ setupComplete: true }), markSetupComplete: () => {
set({ setupComplete: true });
void hostApiFetch('/api/settings/setupComplete', {
method: 'PUT',
body: JSON.stringify({ value: true }),
}).catch(() => { });
},
resetSettings: () => { resetSettings: () => {
const language = applyLanguage(defaultSettings.language); const language = applyLanguage(defaultSettings.language);
set({ ...defaultSettings, language }); set({ ...defaultSettings, language });

View File

@@ -22,6 +22,7 @@ export interface ProgressInfo {
export type UpdateStatus = export type UpdateStatus =
| 'idle' | 'idle'
| 'disabled'
| 'checking' | 'checking'
| 'available' | 'available'
| 'not-available' | 'not-available'
@@ -139,7 +140,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
} }
// Auto-check for updates on startup (respects user toggle) // Auto-check for updates on startup (respects user toggle)
if (autoCheckUpdate) { if (autoCheckUpdate && get().status !== 'disabled') {
setTimeout(() => { setTimeout(() => {
get().checkForUpdates().catch(() => {}); get().checkForUpdates().catch(() => {});
}, 10000); }, 10000);
@@ -147,6 +148,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
}, },
checkForUpdates: async () => { checkForUpdates: async () => {
if (get().status === 'disabled') return;
set({ status: 'checking', error: null }); set({ status: 'checking', error: null });
let completedWithoutUpdaterEvent = false; let completedWithoutUpdaterEvent = false;
@@ -189,6 +191,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
}, },
downloadUpdate: async () => { downloadUpdate: async () => {
if (get().status === 'disabled') return;
set({ status: 'downloading', error: null }); set({ status: 'downloading', error: null });
try { try {
@@ -206,6 +209,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
}, },
installUpdate: () => { installUpdate: () => {
if (get().status === 'disabled') return;
void invokeIpc('update:install'); void invokeIpc('update:install');
}, },