Refine desktop setup and remove bundled app center apps

This commit is contained in:
inman
2026-06-04 09:58:58 +08:00
parent 6153579b90
commit 84128dbe23
73 changed files with 3888 additions and 2024 deletions

View File

@@ -1,9 +1,7 @@
import { useEffect, useMemo } from 'react';
import {
ArrowUpRight,
Clapperboard,
LayoutGrid,
ShoppingBag,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -17,9 +15,7 @@ import { useAppCenterStore } from '@/stores/app-center';
import type { AppCenterItem } from '@/types/app-center';
const APP_ICONS = {
Clapperboard,
LayoutGrid,
ShoppingBag,
};
function getAppIcon(icon: string) {

View File

@@ -1,258 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { AlertTriangle, ArrowLeft, Film, FolderClock, Loader2, RefreshCcw, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { hostApiFetch } from '@/lib/host-api';
const DEFAULT_NIANXX_PLAY_URL = 'http://127.0.0.1:3000';
type NianxxPlayRoute = '/' | '/projects' | '/planning';
type EmbeddedLanguage = 'zh' | 'en';
type ServiceState = 'checking' | 'starting' | 'running' | 'error';
type NianxxPlayServiceStatus = {
success: boolean;
running: boolean;
starting: boolean;
managed: boolean;
baseUrl: string;
port: number;
projectDir?: string;
pid?: number;
error?: string;
};
const NIANXX_PLAY_NAV: Array<{
path: NianxxPlayRoute;
labelKey: string;
icon: ComponentType<{ className?: string }>;
}> = [
{ path: '/', labelKey: 'host.nav.create', icon: Film },
{ path: '/projects', labelKey: 'host.nav.projects', icon: FolderClock },
{ path: '/planning', labelKey: 'host.nav.planning', icon: Sparkles },
];
export function resolveNianxxPlayEmbeddedLanguage(language?: string): EmbeddedLanguage {
return language?.toLowerCase().startsWith('en') ? 'en' : 'zh';
}
export function buildEmbeddedSrc(
baseUrl: string,
route: NianxxPlayRoute,
reloadKey: number,
language: string = 'zh',
) {
const embeddedLanguage = resolveNianxxPlayEmbeddedLanguage(language);
try {
const url = new URL(route, baseUrl);
url.searchParams.set('zhinianEmbed', '1');
url.searchParams.set('zhinianHostReload', String(reloadKey));
url.searchParams.set('zhinianLang', embeddedLanguage);
return url.toString();
} catch {
const normalizedBase = baseUrl.replace(/\/$/, '');
return `${normalizedBase}${route}?zhinianEmbed=1&zhinianHostReload=${reloadKey}&zhinianLang=${embeddedLanguage}`;
}
}
export function NianxxPlay() {
const { t, i18n } = useTranslation('appCenter');
const navigate = useNavigate();
const [webviewKey, setWebviewKey] = useState(0);
const [currentRoute, setCurrentRoute] = useState<NianxxPlayRoute>('/');
const [loadError, setLoadError] = useState<string | null>(null);
const configuredAppUrl = useMemo(() => (
import.meta.env.VITE_NIANXX_PLAY_URL?.trim() || ''
), []);
const [serviceState, setServiceState] = useState<ServiceState>(configuredAppUrl ? 'running' : 'checking');
const [serviceError, setServiceError] = useState<string | null>(null);
const [appUrl, setAppUrl] = useState(configuredAppUrl || DEFAULT_NIANXX_PLAY_URL);
const embeddedLanguage = resolveNianxxPlayEmbeddedLanguage(i18n.resolvedLanguage || i18n.language);
const initialSrc = useMemo(
() => buildEmbeddedSrc(appUrl, currentRoute, webviewKey, embeddedLanguage),
[appUrl, currentRoute, webviewKey, embeddedLanguage],
);
const ensureService = useCallback(async () => {
if (configuredAppUrl) {
setAppUrl(configuredAppUrl);
setServiceState('running');
setServiceError(null);
return true;
}
setServiceState('checking');
setServiceError(null);
try {
const status = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/status');
if (status.running) {
setAppUrl(status.baseUrl);
setServiceState('running');
return true;
}
setServiceState('starting');
const started = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/start', {
method: 'POST',
});
setAppUrl(started.baseUrl || DEFAULT_NIANXX_PLAY_URL);
if (started.running) {
setServiceState('running');
setServiceError(null);
return true;
}
setServiceState('error');
setServiceError(started.error || t('host.serviceFailed'));
return false;
} catch (error) {
setServiceState('error');
setServiceError(error instanceof Error ? error.message : String(error));
return false;
}
}, [configuredAppUrl, t]);
useEffect(() => {
let cancelled = false;
void (async () => {
const ok = await ensureService();
if (cancelled || !ok) return;
setWebviewKey((value) => value + 1);
})();
return () => {
cancelled = true;
};
}, [ensureService]);
const reloadApp = async () => {
setLoadError(null);
const ok = await ensureService();
if (ok) {
setWebviewKey((value) => value + 1);
}
};
const openRoute = (route: NianxxPlayRoute) => {
setLoadError(null);
setCurrentRoute(route);
setWebviewKey((value) => value + 1);
};
return (
<div data-testid="nianxx-play-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate('/app-center')}
data-testid="nianxx-play-back"
className="shrink-0"
>
<ArrowLeft className="h-4 w-4" />
{t('host.back')}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={reloadApp}
aria-label={t('host.reload')}
title={t('host.reload')}
className="h-9 w-9 shrink-0"
>
<RefreshCcw className="h-4 w-4" />
</Button>
<div className="min-w-0">
<h1 className="truncate text-base font-semibold tracking-normal">
{t('host.title')}
</h1>
</div>
</div>
<nav className="flex shrink-0 items-center gap-1 justify-self-center rounded-lg border border-slate-200/70 bg-white/60 p-1 dark:border-white/10 dark:bg-white/5" aria-label={t('host.nav.label')}>
{NIANXX_PLAY_NAV.map((item) => {
const Icon = item.icon;
const active = currentRoute === item.path;
return (
<button
key={item.path}
type="button"
onClick={() => openRoute(item.path)}
data-testid={`nianxx-play-nav-${item.path === '/' ? 'create' : item.path.slice(1)}`}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors',
active
? 'bg-white text-[#075985] shadow-sm dark:bg-slate-950 dark:text-blue-200'
: 'text-slate-600 hover:bg-white/70 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white',
)}
>
<Icon className="h-3.5 w-3.5" />
{t(item.labelKey)}
</button>
);
})}
</nav>
<div aria-hidden className="min-w-0" />
</header>
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
{serviceState === 'running' && (
<iframe
key={webviewKey}
src={initialSrc}
title={t('host.title')}
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
onLoad={() => setLoadError(null)}
onError={() => setLoadError(t('host.loadFailed'))}
/>
)}
{serviceState !== 'running' && (
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
<div className="max-w-md">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
{serviceState === 'error'
? <AlertTriangle className="h-5 w-5" />
: <Loader2 className="h-5 w-5 animate-spin" />}
</div>
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
{serviceState === 'error' ? t('host.serviceFailed') : t('host.serviceStarting')}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{serviceError || t('host.serviceStartingDesc')}
</p>
<div className="mt-4 flex justify-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
<RefreshCcw className="h-4 w-4" />
{t('host.reload')}
</Button>
</div>
</div>
</div>
)}
{serviceState === 'running' && loadError && (
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
<div className="max-w-md">
<div className="text-base font-semibold text-slate-950 dark:text-slate-50">
{t('host.loadFailed')}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{loadError}
</p>
<div className="mt-4 flex justify-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
<RefreshCcw className="h-4 w-4" />
{t('host.reload')}
</Button>
</div>
</div>
</div>
)}
</section>
</div>
);
}
export default NianxxPlay;

View File

@@ -1,143 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Loader2, RefreshCcw, ShoppingBag } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useYinianStore } from '@/stores/yinian';
import { buildProductCenterSrc, getProductCenterBaseUrl } from './url';
const PRODUCT_CENTER_PARTITION = 'persist:yinian-travel-resource-ordering';
type ProductCenterWebviewElement = HTMLElement & {
reload?: () => void;
};
export function ProductCenter() {
const { t } = useTranslation('appCenter');
const navigate = useNavigate();
const session = useYinianStore((state) => state.session);
const webviewRef = useRef<ProductCenterWebviewElement | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const baseUrl = useMemo(() => getProductCenterBaseUrl(), []);
const embeddedSrc = useMemo(() => buildProductCenterSrc({
baseUrl,
reloadKey,
session,
}), [baseUrl, reloadKey, session]);
useEffect(() => {
const webview = webviewRef.current;
if (!webview) return;
const handleStart = () => setIsLoading(true);
const handleStop = () => setIsLoading(false);
webview.addEventListener('did-start-loading', handleStart);
webview.addEventListener('did-stop-loading', handleStop);
webview.addEventListener('did-fail-load', handleStop);
return () => {
webview.removeEventListener('did-start-loading', handleStart);
webview.removeEventListener('did-stop-loading', handleStop);
webview.removeEventListener('did-fail-load', handleStop);
};
}, [reloadKey]);
const reloadApp = () => {
setIsLoading(true);
if (typeof webviewRef.current?.reload === 'function') {
webviewRef.current.reload();
return;
}
setReloadKey((value) => value + 1);
};
const openExternal = () => {
void window.electron.openExternal(baseUrl);
};
return (
<div data-testid="product-center-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate('/app-center')}
data-testid="product-center-back"
className="shrink-0"
>
<ArrowLeft className="h-4 w-4" />
{t('productCenter.back')}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={reloadApp}
aria-label={t('productCenter.reload')}
title={t('productCenter.reload')}
className="h-9 w-9 shrink-0"
>
<RefreshCcw className="h-4 w-4" />
</Button>
<div className="min-w-0">
<h1 className="truncate text-base font-semibold tracking-normal">
{t('productCenter.title')}
</h1>
</div>
</div>
<div className="flex min-w-0 items-center justify-self-center rounded-lg border border-slate-200/70 bg-white/60 px-3 py-1.5 text-xs font-medium text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
<ShoppingBag className="mr-1.5 h-3.5 w-3.5 text-[#075985] dark:text-blue-200" />
<span className="truncate">{t('productCenter.badge')}</span>
</div>
<div className="flex min-w-0 justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={openExternal}
data-testid="product-center-open-external"
className="shrink-0"
>
<ExternalLink className="h-4 w-4" />
{t('productCenter.openExternal')}
</Button>
</div>
</header>
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
<webview
key={reloadKey}
ref={webviewRef}
data-testid="product-center-frame"
src={embeddedSrc}
partition={PRODUCT_CENTER_PARTITION}
title={t('productCenter.frameTitle')}
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
allowpopups={false}
webpreferences="contextIsolation=yes,nodeIntegration=no"
/>
{isLoading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white/92 p-6 text-center dark:bg-slate-950/92">
<div className="max-w-md">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
{t('productCenter.loading')}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t('productCenter.loadingDesc')}
</p>
</div>
</div>
)}
</section>
</div>
);
}
export default ProductCenter;

View File

@@ -1,55 +0,0 @@
import type { YinianSessionState } from '../../../shared/yinian';
const DEFAULT_PRODUCT_CENTER_URL = 'https://ticket.nianxx.cn/';
export interface ProductCenterLaunchOptions {
baseUrl?: string;
reloadKey: number;
session: YinianSessionState;
// Future SSO should use a short-lived one-time ticket, never a long-lived desktop credential.
ssoToken?: string;
}
export function getProductCenterBaseUrl() {
return import.meta.env.VITE_PRODUCT_CENTER_URL?.trim() || DEFAULT_PRODUCT_CENTER_URL;
}
export function buildProductCenterSrc(options: ProductCenterLaunchOptions) {
const baseUrl = options.baseUrl?.trim() || DEFAULT_PRODUCT_CENTER_URL;
try {
const url = new URL(baseUrl);
url.searchParams.set('zhinianEmbed', '1');
url.searchParams.set('zhinianApp', 'product-center');
url.searchParams.set('zhinianHostReload', String(options.reloadKey));
if (options.session.authenticated) {
url.searchParams.set('workspaceId', options.session.currentHotelId);
url.searchParams.set('userId', options.session.user.id);
}
if (options.ssoToken) {
url.searchParams.set('ssoToken', options.ssoToken);
}
return url.toString();
} catch {
const normalizedBase = baseUrl.replace(/\/$/, '');
const params = new URLSearchParams({
zhinianEmbed: '1',
zhinianApp: 'product-center',
zhinianHostReload: String(options.reloadKey),
});
if (options.session.authenticated) {
params.set('workspaceId', options.session.currentHotelId);
params.set('userId', options.session.user.id);
}
if (options.ssoToken) {
params.set('ssoToken', options.ssoToken);
}
return `${normalizedBase}/?${params.toString()}`;
}
}

View File

@@ -33,6 +33,7 @@ import { useGatewayStore } from '@/stores/gateway';
import { useUpdateStore } from '@/stores/update';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { AgentSystemDocumentsSettings } from '@/components/settings/AgentSystemDocumentsSettings';
import {
getGatewayWsDiagnosticEnabled,
invokeIpc,
@@ -136,8 +137,8 @@ type OfficeRuntimeDiagnostics = {
}>;
};
type SettingsTab = 'account' | 'channels' | 'skills' | 'preferences' | 'runtime';
const SETTINGS_TABS = new Set<SettingsTab>(['account', 'channels', 'skills', 'preferences', 'runtime']);
type SettingsTab = 'account' | 'channels' | 'skills' | 'system-docs' | 'preferences' | 'runtime';
const SETTINGS_TABS = new Set<SettingsTab>(['account', 'channels', 'skills', 'system-docs', 'preferences', 'runtime']);
const preferencesPanelClass = 'yinian-panel max-w-4xl overflow-hidden';
const preferencesRowClass = 'p-5 sm:p-6';
@@ -714,7 +715,7 @@ export function Settings() {
<div data-testid="settings-page" className="flex h-[calc(100vh-2.5rem)] flex-col overflow-hidden -m-6 bg-[#F7FAFC] text-slate-950 dark:bg-background dark:text-slate-50">
<div className="w-full max-w-6xl mx-auto flex flex-col h-full p-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-5 grid h-auto w-full shrink-0 grid-cols-2 gap-1 md:grid-cols-5">
<TabsList className="mb-5 grid h-auto w-full shrink-0 grid-cols-2 gap-1 md:grid-cols-6">
<TabsTrigger value="account" className="h-10 gap-2 rounded-md text-[14px]">
<BriefcaseBusiness className="h-4 w-4" />
{t('tabs.account')}
@@ -727,6 +728,10 @@ export function Settings() {
<Puzzle className="h-4 w-4" />
{t('tabs.skills')}
</TabsTrigger>
<TabsTrigger value="system-docs" className="h-10 gap-2 rounded-md text-[14px]">
<FileText className="h-4 w-4" />
{t('tabs.systemDocs')}
</TabsTrigger>
<TabsTrigger value="preferences" className="h-10 gap-2 rounded-md text-[14px]">
<SlidersHorizontal className="h-4 w-4" />
{t('tabs.preferences')}
@@ -893,6 +898,10 @@ export function Settings() {
<YinianSkills />
</TabsContent>
<TabsContent value="system-docs" className="mt-0 h-full min-h-0 overflow-y-auto pr-2 pb-8 -mr-2">
<AgentSystemDocumentsSettings />
</TabsContent>
<TabsContent value="preferences" className="mt-0 h-full min-h-0 overflow-y-auto pr-2 pb-8 -mr-2">
<div className={preferencesPanelClass}>
<div className="border-b border-slate-200/80 p-5 dark:border-white/10 sm:p-6">
@@ -1208,19 +1217,25 @@ export function Settings() {
<div className="space-y-2">
<div className="text-[12px] font-medium text-muted-foreground">{t('developer.modelConfigProviders')}</div>
<div className="space-y-2">
{modelDiagnostics.providers.map((provider) => (
<div key={provider.key} className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-[12px] dark:border-white/10 dark:bg-card">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-foreground">{provider.key}</span>
<Badge variant={provider.configured ? 'outline' : 'destructive'} className="rounded-full px-2 py-0 text-[11px]">
{provider.configured ? t('developer.modelConfigConfigured') : t('developer.modelConfigMissing')}
</Badge>
</div>
<div className="mt-1 truncate text-muted-foreground">
{provider.api || '-'} · {provider.modelCount} models
</div>
{modelDiagnostics.providers.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3 text-[12px] text-muted-foreground dark:border-white/10 dark:bg-card">
{t('developer.modelConfigProvidersEmpty')}
</div>
))}
) : (
modelDiagnostics.providers.map((provider) => (
<div key={provider.key} className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-[12px] dark:border-white/10 dark:bg-card">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-foreground">{provider.key}</span>
<Badge variant={provider.configured ? 'outline' : 'destructive'} className="rounded-full px-2 py-0 text-[11px]">
{provider.configured ? t('developer.modelConfigConfigured') : t('developer.modelConfigMissing')}
</Badge>
</div>
<div className="mt-1 truncate text-muted-foreground">
{provider.api || '-'} · {provider.modelCount} models
</div>
</div>
))
)}
</div>
</div>

View File

@@ -28,6 +28,14 @@ import { SUPPORTED_LANGUAGES } from '@/i18n';
import { toast } from 'sonner';
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import {
calculateInitializationProgress,
describeInitializationFailure,
mapInitializationSteps,
type InitializationResult,
type InstallStatus,
type SkillInstallState,
} from './initialization';
interface SetupStep {
id: string;
@@ -91,6 +99,7 @@ export function Setup() {
// Setup state
// Installation state for the Installing step
const [installedSkills, setInstalledSkills] = useState<string[]>([]);
const [installedModel, setInstalledModel] = useState<string | undefined>();
const steps = getSteps(t);
const safeStepIndex = Number.isInteger(currentStep)
? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1)
@@ -136,8 +145,9 @@ export function Setup() {
};
// Auto-proceed when installation is complete
const handleInstallationComplete = useCallback((skills: string[]) => {
const handleInstallationComplete = useCallback((skills: string[], model?: string) => {
setInstalledSkills(skills);
setInstalledModel(model);
// Auto-proceed to next step after a short delay
setTimeout(() => {
setCurrentStep((i) => i + 1);
@@ -210,6 +220,7 @@ export function Setup() {
{safeStepIndex === STEP.COMPLETE && (
<CompleteContent
installedSkills={installedSkills}
installedModel={installedModel}
/>
)}
</div>
@@ -637,20 +648,9 @@ void RuntimeContent;
// NOTE: ProviderContent component removed - configure providers via Settings > AI Providers
// Initialization status for each first-run task
type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed';
interface SkillInstallState {
id: string;
name: string;
description: string;
status: InstallStatus;
message?: string;
}
interface InstallingContentProps {
skills: DefaultSkill[];
onComplete: (installedSkills: string[]) => void;
onComplete: (installedSkills: string[], model?: string) => void;
onSkip: () => void;
}
@@ -661,7 +661,7 @@ function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingCo
[
{ 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: 'model', name: '准备模型 API 配置', description: '模型 API 可在设置中自定义配置', status: 'pending' as InstallStatus },
{ id: 'python', name: '准备文档能力', description: '准备知识库与文档处理所需的本地环境标记', status: 'pending' as InstallStatus },
]
);
@@ -679,44 +679,24 @@ function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingCo
setSkillStates(prev => prev.map((s, index) => ({ ...s, status: index === 0 ? 'installing' : 'pending' })));
setOverallProgress(10);
const result = await invokeIpc('yinian:setup:initialize') as {
initialized: boolean;
openclawDir?: string;
model?: string;
steps?: Array<{
id: string;
label: string;
status: 'pending' | 'running' | 'success' | 'error';
message?: string;
}>;
};
const mappedSteps = (result.steps ?? []).map((step) => ({
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,
}));
const result = await invokeIpc('yinian:setup:initialize') as InitializationResult;
const mappedSteps = mapInitializationSteps(result.steps);
if (mappedSteps.length > 0) {
setSkillStates(mappedSteps);
setOverallProgress(result.initialized ? 100 : calculateInitializationProgress(mappedSteps));
}
if (result.initialized) {
setOverallProgress(100);
await new Promise((resolve) => setTimeout(resolve, 800));
onComplete((result.steps ?? []).map((s) => s.id));
onComplete((result.steps ?? []).map((s) => s.id), result.model);
} else {
setSkillStates(prev => prev.map(s => s.status === 'completed' ? s : { ...s, status: 'failed' }));
setErrorMessage('初始化未完成,请查看失败项后重试。');
if (mappedSteps.length === 0) {
setSkillStates(prev => prev.map(s => s.status === 'installing' ? { ...s, status: 'failed' } : s));
}
setErrorMessage(describeInitializationFailure(result));
toast.error('初始化失败');
}
} catch (err) {
@@ -852,10 +832,12 @@ function InstallingContent({ skills: _skills, onComplete, onSkip }: InstallingCo
}
interface CompleteContentProps {
installedSkills: string[];
installedModel?: string;
}
function CompleteContent({ installedSkills }: CompleteContentProps) {
function CompleteContent({ installedSkills, installedModel }: CompleteContentProps) {
const { t } = useTranslation(['setup', 'settings']);
const modelLabel = installedModel?.split('/').pop() || installedModel || '可在设置中配置';
return (
<div className="text-center space-y-6">
@@ -864,7 +846,7 @@ function CompleteContent({ installedSkills }: CompleteContentProps) {
</div>
<h2 className="text-xl font-semibold">{t('complete.title')}</h2>
<p className="text-muted-foreground">
API
</p>
<div className="space-y-3 text-left max-w-md mx-auto">
@@ -873,8 +855,8 @@ function CompleteContent({ installedSkills }: CompleteContentProps) {
<span className="text-green-400">{installedSkills.length || 4} </span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<span></span>
<span className="text-green-400">MiniMax M2.7</span>
<span> API</span>
<span className="text-green-400">{modelLabel}</span>
</div>
</div>

View File

@@ -0,0 +1,58 @@
export type InitializationStepStatus = 'pending' | 'running' | 'success' | 'error';
export type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed';
export interface InitializationStepResult {
id: string;
label: string;
status: InitializationStepStatus;
message?: string;
}
export interface InitializationResult {
initialized: boolean;
openclawDir?: string;
model?: string;
steps?: InitializationStepResult[];
}
export interface SkillInstallState {
id: string;
name: string;
description: string;
status: InstallStatus;
message?: string;
}
export function mapInitializationSteps(steps: InitializationStepResult[] = []): SkillInstallState[] {
return steps.map((step) => ({
id: step.id,
name: step.label,
description: step.message || '',
status: step.status === 'success'
? 'completed'
: step.status === 'error'
? 'failed'
: step.status === 'running'
? 'installing'
: 'pending',
message: step.message,
}));
}
export function calculateInitializationProgress(steps: SkillInstallState[], fallbackProgress = 10): number {
if (steps.length === 0) return fallbackProgress;
const completed = steps.filter((step) => step.status === 'completed').length;
const runningBonus = steps.some((step) => step.status === 'installing') ? 0.5 : 0;
return Math.max(fallbackProgress, Math.min(99, Math.round(((completed + runningBonus) / steps.length) * 100)));
}
export function describeInitializationFailure(result: InitializationResult): string {
const failedSteps = mapInitializationSteps(result.steps).filter((step) => step.status === 'failed');
if (failedSteps.length === 0) {
return '初始化未完成,请查看失败项后重试。';
}
return failedSteps
.map((step) => `${step.name}${step.message || '未完成'}`)
.join('\n');
}