1380 lines
46 KiB
TypeScript
1380 lines
46 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useMemo,
|
||
useCallback,
|
||
createContext,
|
||
useContext,
|
||
} from "react";
|
||
import {
|
||
ConfigProvider,
|
||
Menu,
|
||
Badge,
|
||
Spin,
|
||
Button,
|
||
notification,
|
||
message,
|
||
} from "antd";
|
||
import type { PresetStatusColorType } from "antd/es/_util/colors";
|
||
import {
|
||
SettingOutlined,
|
||
DashboardOutlined,
|
||
FolderOutlined,
|
||
InfoCircleOutlined,
|
||
SafetyOutlined,
|
||
FileTextOutlined,
|
||
TeamOutlined,
|
||
ArrowLeftOutlined,
|
||
ReloadOutlined,
|
||
ApiOutlined,
|
||
} from "@ant-design/icons";
|
||
import {
|
||
setupService,
|
||
authService,
|
||
Step1Config,
|
||
DEFAULT_STEP1_CONFIG,
|
||
} from "./services/core/setup";
|
||
import {
|
||
syncConfigToServer,
|
||
normalizeServerHost,
|
||
loginAndRegister,
|
||
} from "./services/core/auth";
|
||
import {
|
||
APP_DISPLAY_NAME,
|
||
AUTH_KEYS,
|
||
normalizeAgentEngine,
|
||
} from "@shared/constants";
|
||
import type { QuickInitConfig } from "@shared/types/quickInit";
|
||
import type { UpdateState } from "@shared/types/updateTypes";
|
||
import { t, getCurrentLang } from "./services/core/i18n";
|
||
import SetupWizard from "./components/setup/SetupWizard";
|
||
import SetupDependencies from "./components/setup/SetupDependencies";
|
||
import ClientPage from "./components/pages/ClientPage";
|
||
import SettingsPage from "./components/pages/SettingsPage";
|
||
import DependenciesPage from "./components/pages/DependenciesPage";
|
||
import AboutPage from "./components/pages/AboutPage";
|
||
import LogViewer from "./components/pages/LogViewer";
|
||
import PermissionsPage from "./components/pages/PermissionsPage";
|
||
import SessionsPage from "./components/pages/SessionsPage";
|
||
import MCPSettings from "./components/settings/MCPSettings";
|
||
import type { WebviewHeaderActions } from "./components/pages/SessionsPage";
|
||
import { createLogger } from "./services/utils/rendererLog";
|
||
import styles from "./styles/components/App.module.css";
|
||
import { lightTheme, darkTheme } from "./styles/theme";
|
||
import { FEATURES } from "@shared/featureFlags";
|
||
|
||
// 主题类型
|
||
export type ThemeMode = "light" | "dark" | "system";
|
||
|
||
// 主题 Context
|
||
interface ThemeContextValue {
|
||
themeMode: ThemeMode;
|
||
isDarkMode: boolean;
|
||
setThemeMode: (mode: ThemeMode) => void;
|
||
}
|
||
|
||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||
|
||
// Hook to use theme context
|
||
export function useTheme(): ThemeContextValue {
|
||
const context = useContext(ThemeContext);
|
||
if (!context) {
|
||
throw new Error("useTheme must be used within App component");
|
||
}
|
||
return context;
|
||
}
|
||
|
||
// i18n 语言 Context
|
||
interface I18nContextValue {
|
||
lang: string;
|
||
updateLang: (lang: string) => void;
|
||
}
|
||
|
||
export const I18nContext = createContext<I18nContextValue | null>(null);
|
||
|
||
export function useI18nLang(): I18nContextValue {
|
||
const context = useContext(I18nContext);
|
||
if (!context) {
|
||
throw new Error("useI18nLang must be used within App component");
|
||
}
|
||
return context;
|
||
}
|
||
|
||
// Tab 类型定义(对齐 Tauri 客户端)
|
||
type TabKey =
|
||
| "client"
|
||
| "sessions"
|
||
| "mcp"
|
||
| "settings"
|
||
| "dependencies"
|
||
| "permissions"
|
||
| "logs"
|
||
| "about"
|
||
| "model";
|
||
|
||
// 状态配置(对齐 Tauri 客户端)
|
||
// 就绪、繁忙使用橙色(warning)、小点展示
|
||
const STATUS_CONFIG: Record<
|
||
string,
|
||
{ status: PresetStatusColorType; textKey: string }
|
||
> = {
|
||
idle: { status: "warning", textKey: "Claw.Agent.Status.idle" },
|
||
starting: { status: "processing", textKey: "Claw.Agent.Status.starting" },
|
||
running: { status: "success", textKey: "Claw.Agent.Status.running" },
|
||
busy: { status: "warning", textKey: "Claw.Agent.Status.busy" },
|
||
stopped: { status: "default", textKey: "Claw.Agent.Status.stopped" },
|
||
error: { status: "error", textKey: "Claw.Agent.Status.error" },
|
||
};
|
||
|
||
/** macOS/Linux 无 download-progress 时,header tag 用本地模拟进度避免长期显示 0%。 */
|
||
const HEADER_SIMULATED_PROGRESS_CAP = 90;
|
||
const HEADER_SIMULATED_PROGRESS_INTERVAL_MS = 500;
|
||
const HEADER_SIMULATED_DURATION_MS = 45_000;
|
||
|
||
// 服务状态接口(与 ClientPage 共享)
|
||
export interface ServiceItem {
|
||
key: string;
|
||
label: string;
|
||
description: string;
|
||
running: boolean;
|
||
pid?: number;
|
||
port?: number;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 将 quick init 配置静默写入 DB(覆盖旧值)
|
||
* 用于 setup 已完成时,每次启动优先使用配置文件/环境变量中的值
|
||
*/
|
||
async function applyQuickInitToDb(config: QuickInitConfig): Promise<void> {
|
||
// 1. 更新 step1 配置
|
||
const step1: Step1Config = {
|
||
...DEFAULT_STEP1_CONFIG,
|
||
serverHost: normalizeServerHost(config.serverHost),
|
||
agentPort: config.agentPort,
|
||
fileServerPort: config.fileServerPort,
|
||
workspaceDir: config.workspaceDir,
|
||
};
|
||
await setupService.saveStep1Config(step1);
|
||
|
||
// 2. 更新 savedKey
|
||
const domain = normalizeServerHost(config.serverHost);
|
||
await window.electronAPI?.settings.set(AUTH_KEYS.SAVED_KEY, config.savedKey);
|
||
if (config.username) {
|
||
try {
|
||
const domainKey = `${AUTH_KEYS.SAVED_KEYS_PREFIX}${new URL(domain).hostname}_${config.username}`;
|
||
await window.electronAPI?.settings.set(domainKey, config.savedKey);
|
||
} catch {
|
||
// domain 解析失败时跳过域名级 savedKey 存储
|
||
}
|
||
}
|
||
|
||
// 3. 静默重新注册(更新服务端设备信息)
|
||
try {
|
||
await loginAndRegister(config.username, "", {
|
||
suppressToast: true,
|
||
domain,
|
||
});
|
||
} catch (error) {
|
||
// 注册失败不阻塞启动,已有的 auth 信息仍可用
|
||
console.warn("[App] Quick init silent registration failed:", error);
|
||
}
|
||
}
|
||
|
||
function App() {
|
||
// ============================================
|
||
// 初始化向导状态
|
||
// ============================================
|
||
const [isSetupComplete, setIsSetupComplete] = useState<boolean | null>(null);
|
||
const setupJustCompleted = useRef(false);
|
||
// 内存变量:标记服务是否由登录流程启动(不持久化)
|
||
const loginStartedRef = useRef(false);
|
||
|
||
// ============================================
|
||
// 主题状态
|
||
// ============================================
|
||
const [themeMode, setThemeMode] = useState<ThemeMode>("system");
|
||
const [systemIsDark, setSystemIsDark] = useState(false);
|
||
|
||
// 计算实际使用的主题
|
||
const isDarkMode = useMemo(() => {
|
||
if (themeMode === "system") {
|
||
return systemIsDark;
|
||
}
|
||
return themeMode === "dark";
|
||
}, [themeMode, systemIsDark]);
|
||
|
||
const currentTheme = useMemo(
|
||
() => (isDarkMode ? darkTheme : lightTheme),
|
||
[isDarkMode],
|
||
);
|
||
|
||
// ============================================
|
||
// i18n 语言状态(响应式,供 Context 下发)
|
||
// ============================================
|
||
const [i18nLang, setI18nLang] = useState(getCurrentLang());
|
||
const handleI18nLangChange = useCallback((lang: string) => {
|
||
setI18nLang(lang);
|
||
}, []);
|
||
|
||
// 监听系统主题变化
|
||
useEffect(() => {
|
||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||
setSystemIsDark(mediaQuery.matches);
|
||
|
||
const handler = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
|
||
mediaQuery.addEventListener("change", handler);
|
||
return () => mediaQuery.removeEventListener("change", handler);
|
||
}, []);
|
||
|
||
// 加载保存的主题设置
|
||
useEffect(() => {
|
||
const loadThemeSetting = async () => {
|
||
try {
|
||
const saved = (await window.electronAPI?.settings.get(
|
||
"theme_mode",
|
||
)) as ThemeMode | null;
|
||
if (saved && ["light", "dark", "system"].includes(saved)) {
|
||
setThemeMode(saved);
|
||
}
|
||
} catch (e) {
|
||
console.warn("[App] Failed to load theme settings:", e);
|
||
}
|
||
};
|
||
loadThemeSetting();
|
||
}, []);
|
||
|
||
// 保存主题设置
|
||
const handleSetThemeMode = useCallback(async (mode: ThemeMode) => {
|
||
setThemeMode(mode);
|
||
try {
|
||
await window.electronAPI?.settings.set("theme_mode", mode);
|
||
} catch (e) {
|
||
console.warn("[App] Failed to save theme settings:", e);
|
||
}
|
||
}, []);
|
||
|
||
// 应用主题到 body
|
||
useEffect(() => {
|
||
document.body.setAttribute("data-theme", isDarkMode ? "dark" : "light");
|
||
}, [isDarkMode]);
|
||
|
||
/**
|
||
* 主界面下「必需依赖未完全安装」时是否强制进入依赖安装流程。
|
||
* - null: 进入主界面后尚未完成检查
|
||
* - true: 存在 missing/error 的必需依赖,全屏显示依赖安装,完成后回到主界面
|
||
* - false: 必需依赖均已安装(含 outdated,以当前真实安装版本为准,不强制重装)
|
||
*/
|
||
const [needsRequiredDepsReinstall, setNeedsRequiredDepsReinstall] = useState<
|
||
boolean | null
|
||
>(null);
|
||
/** 主进程初始化依赖同步是否仍在进行(客户端升级后后台安装新版本依赖) */
|
||
const [depsSyncInProgress, setDepsSyncInProgress] = useState<boolean>(false);
|
||
|
||
// 启动日志:便于快速确认渲染进程 feature flags 是否生效
|
||
useEffect(() => {
|
||
console.info("[FeatureFlags][renderer]", FEATURES);
|
||
window.electronAPI?.log
|
||
.write("info", "[FeatureFlags][renderer]", FEATURES)
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
/**
|
||
* 重启所有服务(使新安装的依赖/二进制生效)。
|
||
* restartAll 内部已包含停止逻辑,无需额外调用 stopAll。
|
||
*
|
||
* 重启前先调 reg 接口,将本次返回的最新 serverHost/serverPort 写入配置,
|
||
* 确保 lanproxy 使用最新服务端地址,而不是 SQLite 里的旧缓存值。
|
||
*/
|
||
const restartAllServices = useCallback(async () => {
|
||
try {
|
||
// 先 reg 拿最新 serverHost/serverPort 写入配置,成功后再重启服务。
|
||
// reg 失败(网络不通/token 过期)时中止重启,并弹出通知让用户手动重试。
|
||
await syncConfigToServer({ suppressToast: true });
|
||
} catch (e) {
|
||
console.error("[App] Reg sync failed, aborting service restart:", e);
|
||
const notifKey = "restartRegFailed";
|
||
notification.error({
|
||
key: notifKey,
|
||
message: t("Claw.App.ConfigSyncFailed"),
|
||
description: t("Claw.App.ConfigSyncFailedDetail"),
|
||
duration: 0,
|
||
placement: "bottomRight",
|
||
btn: (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
onClick={() => {
|
||
notification.destroy(notifKey);
|
||
restartAllServices();
|
||
}}
|
||
>
|
||
{t("Claw.App.Retry")}
|
||
</Button>
|
||
),
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
message.loading({
|
||
content: t("Claw.App.RestartingServices"),
|
||
key: "restart-services",
|
||
});
|
||
await window.electronAPI?.services.restartAll();
|
||
message.success({
|
||
content: t("Claw.App.RestartSuccess"),
|
||
key: "restart-services",
|
||
});
|
||
} catch (e) {
|
||
console.error("[App] Failed to restart services:", e);
|
||
message.error({
|
||
content: t("Claw.App.RestartFailed"),
|
||
key: "restart-services",
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
// ============================================
|
||
// 核心状态
|
||
// ============================================
|
||
const [activeTab, setActiveTab] = useState<TabKey>("client");
|
||
const [sessionsAutoOpen, setSessionsAutoOpen] = useState(false);
|
||
const [webviewActions, setWebviewActions] =
|
||
useState<WebviewHeaderActions | null>(null);
|
||
const [username, setUsername] = useState<string>("");
|
||
const [onlineStatus, setOnlineStatus] = useState<boolean | null>(null);
|
||
const [agentStatus, setAgentStatus] = useState<string>("idle");
|
||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||
const [servicesLoading, setServicesLoading] = useState(true);
|
||
const [guiMcpEnabled, setGuiMcpEnabled] = useState(false);
|
||
const [pollFailCount, setPollFailCount] = useState(0);
|
||
const [startingServices, setStartingServices] = useState<Set<string>>(
|
||
new Set(),
|
||
);
|
||
const servicesPollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
/** 递增后通知 ClientPage 刷新账号状态(用户名等),与 reg 返回保持一致 */
|
||
const [authRefreshTrigger, setAuthRefreshTrigger] = useState(0);
|
||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||
status: "idle",
|
||
});
|
||
const [headerSimulatedPercent, setHeaderSimulatedPercent] = useState(0);
|
||
const headerSimulatedIntervalRef = useRef<ReturnType<
|
||
typeof setInterval
|
||
> | null>(null);
|
||
const statusExpectedKeys = useMemo(() => {
|
||
const keys = ["mcpProxy", "agent", "fileServer", "lanproxy"];
|
||
if (FEATURES.ENABLE_GUI_AGENT_SERVER && guiMcpEnabled) {
|
||
keys.splice(3, 0, "guiServer");
|
||
}
|
||
return keys;
|
||
}, [guiMcpEnabled]);
|
||
const getStartupServiceKeys = useCallback(async (): Promise<string[]> => {
|
||
const keys = ["mcpProxy", "agent", "fileServer", "lanproxy"];
|
||
if (!FEATURES.ENABLE_GUI_AGENT_SERVER) return keys;
|
||
try {
|
||
const guiEnabledRes = await window.electronAPI?.guiServer?.isEnabled();
|
||
if (guiEnabledRes?.enabled) {
|
||
keys.splice(3, 0, "guiServer");
|
||
}
|
||
} catch (e) {
|
||
console.warn("[App] Failed to read GUI MCP enabled status:", e);
|
||
}
|
||
return keys;
|
||
}, []);
|
||
|
||
// ============================================
|
||
// 检查初始化向导状态(每次启动优先读取 quick init 配置)
|
||
// ============================================
|
||
useEffect(() => {
|
||
const log = createLogger("SetupCheck");
|
||
const checkSetup = async () => {
|
||
try {
|
||
const completed = await setupService.isSetupCompleted();
|
||
|
||
// 每次启动优先读取 quick init 配置
|
||
if (completed) {
|
||
try {
|
||
const qiConfig = await window.electronAPI?.quickInit.getConfig();
|
||
if (qiConfig) {
|
||
log.info("Applying quick init config");
|
||
await applyQuickInitToDb(qiConfig);
|
||
}
|
||
} catch (error) {
|
||
log.warn("Failed to read quick init config:", error);
|
||
}
|
||
}
|
||
|
||
log.info("isSetupComplete:", completed);
|
||
setIsSetupComplete(completed);
|
||
} catch (error) {
|
||
log.error("Failed to check setup status:", error);
|
||
setIsSetupComplete(true);
|
||
}
|
||
};
|
||
checkSetup();
|
||
}, []);
|
||
|
||
// ============================================
|
||
// 初始化主界面(setup 完成后执行)
|
||
// ============================================
|
||
useEffect(() => {
|
||
if (isSetupComplete !== true) return;
|
||
|
||
const init = async () => {
|
||
// 加载用户信息
|
||
const user = await authService.getAuthUser();
|
||
if (user) {
|
||
setUsername(
|
||
user.displayName || user.username || t("Claw.App.defaultUsername"),
|
||
);
|
||
}
|
||
|
||
// 加载在线状态
|
||
const online =
|
||
await window.electronAPI?.settings.get("auth.online_status");
|
||
setOnlineStatus(online as boolean | null);
|
||
};
|
||
|
||
init();
|
||
}, [isSetupComplete]);
|
||
|
||
// ============================================
|
||
// 子组件登录/注销后刷新顶部栏用户名
|
||
// ============================================
|
||
const handleAuthChange = useCallback(async () => {
|
||
const user = await authService.getAuthUser();
|
||
if (user) {
|
||
setUsername(
|
||
user.displayName || user.username || t("Claw.App.defaultUsername"),
|
||
);
|
||
} else {
|
||
setUsername("");
|
||
}
|
||
}, []);
|
||
|
||
// 标记服务由登录流程启动(内存变量,不持久化)
|
||
const handleLoginStarted = useCallback(() => {
|
||
loginStartedRef.current = true;
|
||
}, []);
|
||
|
||
// ============================================
|
||
// 主界面下必需依赖检查:仅当存在「未安装」或「错误」时进入依赖安装
|
||
// 版本以当前真实安装为准,outdated 不触发(用户可在依赖 Tab 手动升级)
|
||
// 同时检测主进程初始化依赖同步状态,避免服务启动时依赖尚未安装完成
|
||
// ============================================
|
||
useEffect(() => {
|
||
if (isSetupComplete !== true) return;
|
||
const log = createLogger("DepsCheck");
|
||
let cancelled = false;
|
||
|
||
// 先注册事件监听,再做 checkAll,避免事件在 checkAll 返回前触发而丢失
|
||
const handleDepsSyncCompleted = () => {
|
||
log.info("syncCompleted");
|
||
setDepsSyncInProgress(false);
|
||
};
|
||
window.electronAPI?.on(
|
||
"deps:syncCompleted",
|
||
handleDepsSyncCompleted as any,
|
||
);
|
||
|
||
const checkRequiredDeps = async () => {
|
||
try {
|
||
const result = await window.electronAPI?.dependencies.checkAll();
|
||
if (cancelled) return;
|
||
|
||
const deps = result?.results ?? [];
|
||
const hasMissingOrError = deps.some(
|
||
(d: { status: string; required?: boolean }) =>
|
||
d.required !== false &&
|
||
(d.status === "missing" || d.status === "error"),
|
||
);
|
||
const missingDeps = deps
|
||
.filter(
|
||
(d: { status: string; required?: boolean }) =>
|
||
d.required !== false &&
|
||
(d.status === "missing" || d.status === "error"),
|
||
)
|
||
.map((d: { name: string; status: string }) => d.name);
|
||
|
||
log.info("result:", {
|
||
hasMissingOrError,
|
||
missingDeps: missingDeps.length > 0 ? missingDeps : undefined,
|
||
syncInProgress: result?.syncInProgress,
|
||
});
|
||
|
||
setNeedsRequiredDepsReinstall(hasMissingOrError);
|
||
|
||
// 记录主进程初始化依赖同步状态
|
||
if (result?.syncInProgress) {
|
||
setDepsSyncInProgress(true);
|
||
// 防竞态:checkAll 返回 syncInProgress=true 但事件可能已经在 checkAll IPC 期间触发过了,
|
||
// 再次确认主进程当前真实状态,避免 depsSyncInProgress 永远卡在 true
|
||
const recheck = await window.electronAPI?.dependencies.checkAll();
|
||
if (cancelled) return;
|
||
if (!recheck?.syncInProgress) {
|
||
setDepsSyncInProgress(false);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
log.error("failed:", error);
|
||
if (!cancelled) {
|
||
setNeedsRequiredDepsReinstall(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
checkRequiredDeps();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.electronAPI?.off(
|
||
"deps:syncCompleted",
|
||
handleDepsSyncCompleted as any,
|
||
);
|
||
};
|
||
}, [isSetupComplete]);
|
||
|
||
// ============================================
|
||
// 服务状态轮询
|
||
// ============================================
|
||
const pollServicesStatus = useCallback(async () => {
|
||
try {
|
||
const items: ServiceItem[] = [];
|
||
const [
|
||
fsStatus,
|
||
lpStatus,
|
||
agentSvcStatus,
|
||
mcpStatus,
|
||
csStatus,
|
||
guiStatus,
|
||
guiEnabledRes,
|
||
] = await Promise.all([
|
||
window.electronAPI?.fileServer.status(),
|
||
window.electronAPI?.lanproxy.status(),
|
||
window.electronAPI?.agent.serviceStatus(),
|
||
window.electronAPI?.mcp.status(),
|
||
window.electronAPI?.computerServer.status(),
|
||
window.electronAPI?.guiServer?.status(),
|
||
window.electronAPI?.guiServer?.isEnabled(),
|
||
]);
|
||
const isGuiEnabled =
|
||
FEATURES.ENABLE_GUI_AGENT_SERVER && (guiEnabledRes?.enabled ?? false);
|
||
setGuiMcpEnabled(isGuiEnabled);
|
||
items.push({
|
||
key: "mcpProxy",
|
||
label: t("Claw.Service.mcp"),
|
||
description: t("Claw.Service.mcpDesc"),
|
||
running: mcpStatus?.running ?? false,
|
||
error: mcpStatus?.error,
|
||
});
|
||
|
||
// ComputerServer 是 Agent 的 HTTP 接口,仅当 Agent 本身在运行时才检查其状态
|
||
const agentRunning = agentSvcStatus?.running ?? false;
|
||
const csRunning = csStatus?.running ?? false;
|
||
let agentError: string | undefined;
|
||
if (agentRunning && !csRunning) {
|
||
agentError = csStatus?.error
|
||
? t("Claw.App.agentInterfaceFailed", csStatus.error)
|
||
: t("Claw.App.agentInterfaceNotRunning");
|
||
}
|
||
items.push({
|
||
key: "agent",
|
||
label: t("Claw.Service.agent"),
|
||
description: t("Claw.Service.agentDesc"),
|
||
running: agentRunning && csRunning,
|
||
error: agentError,
|
||
});
|
||
|
||
items.push({
|
||
key: "fileServer",
|
||
label: t("Claw.Service.file"),
|
||
description: t("Claw.Service.fileDesc"),
|
||
running: fsStatus?.running ?? false,
|
||
pid: fsStatus?.pid,
|
||
error: fsStatus?.error,
|
||
});
|
||
if (isGuiEnabled) {
|
||
items.push({
|
||
key: "guiServer",
|
||
label: t("Claw.Service.guiMcp"),
|
||
description: t("Claw.Service.guiMcpDesc"),
|
||
running: guiStatus?.running ?? false,
|
||
pid: guiStatus?.pid,
|
||
error: guiStatus?.error,
|
||
});
|
||
}
|
||
items.push({
|
||
key: "lanproxy",
|
||
label: t("Claw.Service.proxy"),
|
||
description: t("Claw.Service.proxyDesc"),
|
||
running: lpStatus?.running ?? false,
|
||
pid: lpStatus?.pid,
|
||
error: lpStatus?.error,
|
||
});
|
||
setServices(items);
|
||
setPollFailCount(0);
|
||
} catch (error) {
|
||
console.error("[App] pollServicesStatus failed:", error);
|
||
setPollFailCount((count) => count + 1);
|
||
} finally {
|
||
setServicesLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// ============================================
|
||
// 逐个启动服务(实时更新状态)
|
||
// ============================================
|
||
const startServicesSequentially = useCallback(
|
||
async (serviceKeys: string[]) => {
|
||
const log = createLogger("StartServices");
|
||
for (const key of serviceKeys) {
|
||
setStartingServices((prev) => new Set(prev).add(key));
|
||
try {
|
||
let result: { success: boolean; error?: string } | undefined;
|
||
|
||
if (key === "agent") {
|
||
const agentConfig = (await window.electronAPI?.settings.get(
|
||
"agent_config",
|
||
)) as any;
|
||
const step1 = (await window.electronAPI?.settings.get(
|
||
"step1_config",
|
||
)) as { workspaceDir?: string } | null;
|
||
result = await window.electronAPI?.agent.init({
|
||
engine: normalizeAgentEngine(agentConfig?.type),
|
||
apiKey: agentConfig?.apiKey,
|
||
baseUrl: agentConfig?.apiBaseUrl,
|
||
model: agentConfig?.model,
|
||
workspaceDir: step1?.workspaceDir || "",
|
||
});
|
||
log.info(
|
||
`agent: ${result?.success ? "ok" : "failed"}`,
|
||
result?.error,
|
||
);
|
||
// ComputerServer 是 Agent 的 HTTP 接口,随 Agent 一起启动
|
||
await window.electronAPI?.computerServer
|
||
.start()
|
||
.catch(() => undefined);
|
||
} else if (key === "fileServer") {
|
||
const step1 = (await window.electronAPI?.settings.get(
|
||
"step1_config",
|
||
)) as { fileServerPort?: number } | null;
|
||
const port = step1?.fileServerPort ?? 60000;
|
||
result = await window.electronAPI?.fileServer.start(port);
|
||
log.info(
|
||
`fileServer: ${result?.success ? "ok" : "failed"}`,
|
||
result?.error,
|
||
);
|
||
} else if (key === "guiServer") {
|
||
result = await window.electronAPI?.guiServer?.start();
|
||
} else if (key === "lanproxy") {
|
||
const clientKey = (await window.electronAPI?.settings.get(
|
||
"auth.saved_key",
|
||
)) as string | null;
|
||
const lpConfig = (await window.electronAPI?.settings.get(
|
||
"lanproxy_config",
|
||
)) as any;
|
||
const serverIp =
|
||
lpConfig?.serverIp ||
|
||
(
|
||
(await window.electronAPI?.settings.get(
|
||
"lanproxy.server_host",
|
||
)) as string
|
||
)?.replace(/^https?:\/\//, "");
|
||
const serverPort =
|
||
lpConfig?.serverPort ||
|
||
(await window.electronAPI?.settings.get("lanproxy.server_port"));
|
||
if (serverIp && clientKey && serverPort) {
|
||
result = await window.electronAPI?.lanproxy.start({
|
||
serverIp,
|
||
serverPort,
|
||
clientKey,
|
||
ssl: lpConfig?.ssl,
|
||
});
|
||
log.info(
|
||
`lanproxy: ${result?.success ? "ok" : "failed"}`,
|
||
result?.error,
|
||
);
|
||
} else {
|
||
log.warn("lanproxy: skipped (missing config)");
|
||
}
|
||
} else if (key === "mcpProxy") {
|
||
result = await window.electronAPI?.mcp.start();
|
||
log.info(
|
||
`mcpProxy: ${result?.success ? "ok" : "failed"}`,
|
||
result?.error,
|
||
);
|
||
}
|
||
|
||
await pollServicesStatus();
|
||
} catch (e) {
|
||
log.error(`${key} failed:`, e);
|
||
} finally {
|
||
setStartingServices((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(key);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
log.info("completed");
|
||
},
|
||
[pollServicesStatus],
|
||
);
|
||
|
||
// ============================================
|
||
// 自动重连(等待依赖检查及同步完成后再执行,避免竞态)
|
||
// ============================================
|
||
useEffect(() => {
|
||
if (isSetupComplete !== true) return;
|
||
if (needsRequiredDepsReinstall !== false) return;
|
||
if (depsSyncInProgress) return;
|
||
|
||
const log = createLogger("AutoReconnect");
|
||
const autoReconnect = async () => {
|
||
// 如果 ClientPage handleLogin 已经启动了服务,跳过自动重连
|
||
if (loginStartedRef.current) {
|
||
loginStartedRef.current = false;
|
||
log.info("skipped (login flow)");
|
||
return;
|
||
}
|
||
|
||
// 如果向导刚完成,启动所有服务
|
||
if (setupJustCompleted.current) {
|
||
setupJustCompleted.current = false;
|
||
log.info("setup completed, starting services");
|
||
await startServicesSequentially(await getStartupServiceKeys());
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const savedKey =
|
||
await window.electronAPI?.settings.get("auth.saved_key");
|
||
|
||
if (savedKey) {
|
||
// 用户已退出登录时(configKey 被清除),不自动重连
|
||
const configKey =
|
||
await window.electronAPI?.settings.get("auth.config_key");
|
||
if (!configKey) {
|
||
log.info("skipped (logged out)");
|
||
return;
|
||
}
|
||
|
||
const result = await syncConfigToServer({ suppressToast: true });
|
||
|
||
if (result) {
|
||
log.info("reg ok, starting services");
|
||
setOnlineStatus(result.online);
|
||
const user = await authService.getAuthUser();
|
||
if (user) {
|
||
setUsername(
|
||
user.displayName ||
|
||
user.username ||
|
||
t("Claw.App.defaultUsername"),
|
||
);
|
||
}
|
||
setAuthRefreshTrigger((v) => v + 1);
|
||
await startServicesSequentially(await getStartupServiceKeys());
|
||
} else {
|
||
log.warn("reg failed, using local config");
|
||
notification.info({
|
||
message: t("Claw.App.AutoReconnectFailed"),
|
||
description: t("Claw.App.AutoReconnectFailedDetail"),
|
||
duration: 8,
|
||
placement: "bottomRight",
|
||
});
|
||
await startServicesSequentially(await getStartupServiceKeys());
|
||
}
|
||
} else {
|
||
log.info("skipped (no savedKey)");
|
||
}
|
||
} catch (error) {
|
||
log.error("failed:", error);
|
||
}
|
||
};
|
||
|
||
autoReconnect();
|
||
}, [
|
||
isSetupComplete,
|
||
needsRequiredDepsReinstall,
|
||
depsSyncInProgress,
|
||
startServicesSequentially,
|
||
getStartupServiceKeys,
|
||
]);
|
||
|
||
// ============================================
|
||
// 根据服务状态计算 Agent 状态
|
||
// ============================================
|
||
// 根据服务状态计算 Agent 状态(对齐 Tauri 客户端逻辑)
|
||
useEffect(() => {
|
||
// 如果正在加载,保持当前状态不变(避免初始加载时的闪烁)
|
||
if (servicesLoading) {
|
||
return;
|
||
}
|
||
|
||
if (statusExpectedKeys.length === 0) {
|
||
setAgentStatus("idle");
|
||
return;
|
||
}
|
||
|
||
const serviceMap = new Map(services.map((s) => [s.key, s]));
|
||
const trackedServices = statusExpectedKeys.map((key) =>
|
||
serviceMap.get(key),
|
||
);
|
||
const runningCount = trackedServices.filter((s) => s?.running).length;
|
||
const totalCount = statusExpectedKeys.length;
|
||
const hasErrors = trackedServices.some((s) => !!s?.error);
|
||
const hasStartingServices = Array.from(startingServices).some((key) =>
|
||
statusExpectedKeys.includes(key),
|
||
);
|
||
const hasStaleServiceStatus = pollFailCount >= 2;
|
||
|
||
if (hasStaleServiceStatus) {
|
||
// 连续轮询失败时,避免继续展示可能过期的 running 状态。
|
||
setAgentStatus("busy");
|
||
} else if (hasErrors) {
|
||
setAgentStatus("error");
|
||
} else if (hasStartingServices) {
|
||
setAgentStatus("starting");
|
||
} else if (runningCount === totalCount && runningCount > 0) {
|
||
setAgentStatus("running");
|
||
} else if (runningCount > 0 && runningCount < totalCount) {
|
||
setAgentStatus("busy");
|
||
} else if (runningCount === 0) {
|
||
setAgentStatus("stopped");
|
||
} else {
|
||
setAgentStatus("idle");
|
||
}
|
||
}, [
|
||
services,
|
||
servicesLoading,
|
||
startingServices,
|
||
statusExpectedKeys,
|
||
pollFailCount,
|
||
]);
|
||
|
||
// 启动服务状态轮询
|
||
useEffect(() => {
|
||
if (isSetupComplete !== true) return;
|
||
|
||
// 立即执行一次
|
||
pollServicesStatus();
|
||
|
||
// 每 5 秒轮询一次
|
||
servicesPollTimer.current = setInterval(pollServicesStatus, 5000);
|
||
|
||
return () => {
|
||
if (servicesPollTimer.current) {
|
||
clearInterval(servicesPollTimer.current);
|
||
}
|
||
};
|
||
}, [isSetupComplete]);
|
||
|
||
// ============================================
|
||
// 监听更新状态(header tag 展示)
|
||
// ============================================
|
||
useEffect(() => {
|
||
const handler = (state: UpdateState) => {
|
||
if (state) setUpdateState(state);
|
||
};
|
||
window.electronAPI?.on("update:status", handler as any);
|
||
window.electronAPI?.app?.getUpdateState?.()?.then((state) => {
|
||
if (state) setUpdateState(state);
|
||
});
|
||
return () => {
|
||
window.electronAPI?.off("update:status", handler as any);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const isDownloading = updateState.status === "downloading";
|
||
const hasRealProgress = updateState.progress != null;
|
||
|
||
if (isDownloading && !hasRealProgress) {
|
||
setHeaderSimulatedPercent(0);
|
||
const increment =
|
||
(HEADER_SIMULATED_PROGRESS_CAP / HEADER_SIMULATED_DURATION_MS) *
|
||
HEADER_SIMULATED_PROGRESS_INTERVAL_MS;
|
||
const id = setInterval(() => {
|
||
setHeaderSimulatedPercent((prev) => {
|
||
const next = prev + increment;
|
||
return next >= HEADER_SIMULATED_PROGRESS_CAP
|
||
? HEADER_SIMULATED_PROGRESS_CAP
|
||
: next;
|
||
});
|
||
}, HEADER_SIMULATED_PROGRESS_INTERVAL_MS);
|
||
headerSimulatedIntervalRef.current = id;
|
||
return () => {
|
||
clearInterval(id);
|
||
headerSimulatedIntervalRef.current = null;
|
||
};
|
||
}
|
||
|
||
if (!isDownloading || hasRealProgress) {
|
||
if (headerSimulatedIntervalRef.current) {
|
||
clearInterval(headerSimulatedIntervalRef.current);
|
||
headerSimulatedIntervalRef.current = null;
|
||
}
|
||
setHeaderSimulatedPercent(0);
|
||
}
|
||
}, [updateState.status, updateState.progress]);
|
||
|
||
// ============================================
|
||
// 监听托盘/菜单事件
|
||
// ============================================
|
||
useEffect(() => {
|
||
if (!window.electronAPI) return;
|
||
|
||
const cleanupHandlers: (() => void)[] = [];
|
||
|
||
// 监听设置菜单
|
||
const handleSettings = () => {
|
||
console.log("[App] Received menu:settings event");
|
||
setActiveTab("settings");
|
||
};
|
||
window.electronAPI.on("menu:settings", handleSettings);
|
||
cleanupHandlers.push(() =>
|
||
window.electronAPI?.off("menu:settings", handleSettings),
|
||
);
|
||
|
||
// 监听依赖管理菜单
|
||
const handleDependencies = () => {
|
||
console.log("[App] Received menu:dependencies event");
|
||
setActiveTab("dependencies");
|
||
};
|
||
window.electronAPI.on("menu:dependencies", handleDependencies);
|
||
cleanupHandlers.push(() =>
|
||
window.electronAPI?.off("menu:dependencies", handleDependencies),
|
||
);
|
||
|
||
// 监听 MCP 设置菜单
|
||
const handleMcpSettings = () => {
|
||
console.log("[App] Received menu:mcp-settings event");
|
||
setActiveTab("settings");
|
||
};
|
||
window.electronAPI.on("menu:mcp-settings", handleMcpSettings);
|
||
cleanupHandlers.push(() =>
|
||
window.electronAPI?.off("menu:mcp-settings", handleMcpSettings),
|
||
);
|
||
|
||
// 监听新建会话菜单
|
||
const handleNewSession = () => {
|
||
console.log("[App] Received menu:new-session event");
|
||
setSessionsAutoOpen(true);
|
||
setActiveTab("sessions");
|
||
};
|
||
window.electronAPI.on("menu:new-session", handleNewSession);
|
||
cleanupHandlers.push(() =>
|
||
window.electronAPI?.off("menu:new-session", handleNewSession),
|
||
);
|
||
|
||
// 监听 Admin Server 服务正在重启
|
||
const handleServicesRestarting = () => {
|
||
console.log("[App] Received admin:servicesRestarting event");
|
||
message.loading({
|
||
content: t("Claw.App.ServicesRestarting"),
|
||
key: "admin-restart",
|
||
duration: 0,
|
||
});
|
||
};
|
||
window.electronAPI.on("admin:servicesRestarting", handleServicesRestarting);
|
||
cleanupHandlers.push(() =>
|
||
window.electronAPI?.off(
|
||
"admin:servicesRestarting",
|
||
handleServicesRestarting,
|
||
),
|
||
);
|
||
|
||
// 监听 Admin Server 服务重启完成
|
||
const handleServicesRestarted = (data: {
|
||
success: boolean;
|
||
results: Record<string, { success: boolean; error?: string }>;
|
||
}) => {
|
||
console.log("[App] Received admin:servicesRestarted event", data);
|
||
if (data.success) {
|
||
message.success({
|
||
content: t("Claw.App.ServicesRestartSuccess"),
|
||
key: "admin-restart",
|
||
duration: 3,
|
||
});
|
||
} else {
|
||
const failed = Object.entries(data.results)
|
||
.filter(([, v]) => !v.success)
|
||
.map(([k]) => k)
|
||
.join(", ");
|
||
message.error({
|
||
content: t("Claw.App.serviceRestartFailed", failed),
|
||
key: "admin-restart",
|
||
duration: 5,
|
||
});
|
||
}
|
||
};
|
||
window.electronAPI.on(
|
||
"admin:servicesRestarted",
|
||
handleServicesRestarted as any,
|
||
);
|
||
cleanupHandlers.push(() =>
|
||
window.electronAPI?.off(
|
||
"admin:servicesRestarted",
|
||
handleServicesRestarted as any,
|
||
),
|
||
);
|
||
|
||
return () => {
|
||
cleanupHandlers.forEach((fn) => fn());
|
||
};
|
||
}, []);
|
||
|
||
// ============================================
|
||
// 向导完成回调
|
||
// ============================================
|
||
const handleSetupComplete = () => {
|
||
setupJustCompleted.current = true;
|
||
setIsSetupComplete(true);
|
||
};
|
||
|
||
// ============================================
|
||
// 状态 Badge
|
||
// ============================================
|
||
const badge = STATUS_CONFIG[agentStatus] || STATUS_CONFIG.idle;
|
||
|
||
// ============================================
|
||
// 平台检测
|
||
// ============================================
|
||
const isMacOS = navigator.platform.toUpperCase().includes("MAC");
|
||
|
||
// ============================================
|
||
// 菜单配置(对齐 Tauri 客户端)
|
||
// ============================================
|
||
const menuItems = useMemo(() => {
|
||
const items = [
|
||
{
|
||
key: "client",
|
||
icon: <DashboardOutlined />,
|
||
label: t("Claw.Menu.client"),
|
||
},
|
||
{
|
||
key: "sessions",
|
||
icon: <TeamOutlined />,
|
||
label: t("Claw.Menu.session"),
|
||
},
|
||
{
|
||
key: "mcp",
|
||
icon: <ApiOutlined />,
|
||
label: t("Claw.Menu.mcp"),
|
||
},
|
||
{
|
||
key: "settings",
|
||
icon: <SettingOutlined />,
|
||
label: t("Claw.Menu.settings"),
|
||
},
|
||
{
|
||
key: "dependencies",
|
||
icon: <FolderOutlined />,
|
||
label: t("Claw.Menu.dependencies"),
|
||
},
|
||
];
|
||
if (isMacOS) {
|
||
items.push({
|
||
key: "permissions",
|
||
icon: <SafetyOutlined />,
|
||
label: t("Claw.Menu.authorization"),
|
||
});
|
||
}
|
||
items.push(
|
||
{ key: "logs", icon: <FileTextOutlined />, label: t("Claw.Menu.logs") },
|
||
{
|
||
key: "about",
|
||
icon: <InfoCircleOutlined />,
|
||
label: t("Claw.Menu.about"),
|
||
},
|
||
);
|
||
return items;
|
||
}, [isMacOS, i18nLang]);
|
||
|
||
// ============================================
|
||
// i18n Context value
|
||
// ============================================
|
||
const i18nContextValue = useMemo(
|
||
() => ({ lang: i18nLang, updateLang: handleI18nLangChange }),
|
||
[i18nLang, handleI18nLangChange],
|
||
);
|
||
|
||
// ============================================
|
||
// 渲染:加载中(含等待依赖检查完成)
|
||
// ============================================
|
||
if (
|
||
isSetupComplete === null ||
|
||
(isSetupComplete && needsRequiredDepsReinstall === null)
|
||
) {
|
||
return (
|
||
<I18nContext.Provider value={i18nContextValue}>
|
||
<ConfigProvider theme={currentTheme}>
|
||
<div className="app-loading">
|
||
<Spin size="large" />
|
||
<div className="app-loading-text">{t("Claw.App.Loading")}</div>
|
||
</div>
|
||
</ConfigProvider>
|
||
</I18nContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ============================================
|
||
// 渲染:初始化向导
|
||
// ============================================
|
||
if (!isSetupComplete) {
|
||
return (
|
||
<I18nContext.Provider value={i18nContextValue}>
|
||
<ConfigProvider theme={currentTheme}>
|
||
<SetupWizard onComplete={handleSetupComplete} />
|
||
</ConfigProvider>
|
||
</I18nContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ============================================
|
||
// 渲染:主界面下必需依赖未满足 → 全屏依赖安装,完成后重启服务回到主界面
|
||
// ============================================
|
||
if (needsRequiredDepsReinstall === true) {
|
||
return (
|
||
<I18nContext.Provider value={i18nContextValue}>
|
||
<ConfigProvider theme={currentTheme}>
|
||
<SetupDependencies
|
||
onComplete={async () => {
|
||
// 先回到主界面,再在后台重启服务(使新安装的依赖生效)
|
||
setNeedsRequiredDepsReinstall(false);
|
||
await restartAllServices();
|
||
}}
|
||
/>
|
||
</ConfigProvider>
|
||
</I18nContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ============================================
|
||
// 渲染:主界面
|
||
// ============================================
|
||
return (
|
||
<ConfigProvider theme={currentTheme}>
|
||
<I18nContext.Provider value={i18nContextValue}>
|
||
<ThemeContext.Provider
|
||
value={{ themeMode, isDarkMode, setThemeMode: handleSetThemeMode }}
|
||
>
|
||
<div className="app-container">
|
||
{/* 顶部栏 */}
|
||
<div className="app-header">
|
||
{webviewActions ? (
|
||
<div className={styles.headerWebviewActions}>
|
||
<Button
|
||
size="small"
|
||
icon={<ArrowLeftOutlined />}
|
||
onClick={webviewActions.onBack}
|
||
>
|
||
{t("Claw.App.back")}
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<ReloadOutlined />}
|
||
onClick={webviewActions.onReload}
|
||
>
|
||
{t("Claw.App.refresh")}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="app-header-logo">
|
||
<img
|
||
src="./32x32.png"
|
||
alt=""
|
||
style={{ width: 16, height: 16 }}
|
||
/>
|
||
<span className="app-header-title">{APP_DISPLAY_NAME}</span>
|
||
{updateState.status === "available" && (
|
||
<span
|
||
className="app-header-update-tag app-header-update-tag--available"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={async () => {
|
||
if (updateState.canAutoUpdate === false) {
|
||
await window.electronAPI?.app?.openReleasesPage?.();
|
||
return;
|
||
}
|
||
try {
|
||
setUpdateState((prev) => ({
|
||
...prev,
|
||
status: "downloading",
|
||
progress: undefined,
|
||
}));
|
||
const res =
|
||
await window.electronAPI?.app?.downloadUpdate?.();
|
||
if (!res || !res.success) {
|
||
message.error(
|
||
res?.error || t("Claw.About.downloadFailed"),
|
||
);
|
||
setUpdateState((prev) => ({
|
||
...prev,
|
||
status: "available",
|
||
}));
|
||
}
|
||
} catch {
|
||
message.error(t("Claw.About.downloadFailed"));
|
||
setUpdateState((prev) => ({
|
||
...prev,
|
||
status: "available",
|
||
}));
|
||
}
|
||
}}
|
||
onKeyDown={(e) =>
|
||
(e.key === "Enter" || e.key === " ") &&
|
||
(e.target as HTMLElement).click()
|
||
}
|
||
>
|
||
{updateState.canAutoUpdate === false
|
||
? t("Claw.App.UpdateTag.newVersion", {
|
||
version: updateState.version,
|
||
})
|
||
: t("Claw.App.UpdateTag.download", {
|
||
version: updateState.version,
|
||
})}
|
||
</span>
|
||
)}
|
||
{updateState.status === "downloading" && (
|
||
<span className="app-header-update-tag app-header-update-tag--downloading">
|
||
{t("Claw.App.UpdateTag.downloading", {
|
||
percent: Math.round(
|
||
updateState.progress?.percent ??
|
||
headerSimulatedPercent,
|
||
),
|
||
})}
|
||
</span>
|
||
)}
|
||
{updateState.status === "downloaded" && (
|
||
<span
|
||
className="app-header-update-tag app-header-update-tag--install"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={async () => {
|
||
try {
|
||
const res =
|
||
await window.electronAPI?.app?.installUpdate?.();
|
||
if (res && !res.success) {
|
||
message.error(
|
||
res.error || t("Claw.About.installFailed"),
|
||
);
|
||
}
|
||
} catch {
|
||
message.error(t("Claw.About.installFailed"));
|
||
}
|
||
}}
|
||
onKeyDown={(e) =>
|
||
(e.key === "Enter" || e.key === " ") &&
|
||
(e.target as HTMLElement).click()
|
||
}
|
||
>
|
||
{t("Claw.About.installUpdate")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className={styles.headerRight}>
|
||
{username && (
|
||
<span className={styles.username}>{username}</span>
|
||
)}
|
||
<Badge
|
||
status={badge.status}
|
||
className={
|
||
agentStatus === "idle" || agentStatus === "busy"
|
||
? styles.badgeIdle
|
||
: undefined
|
||
}
|
||
text={
|
||
<span className={styles.badgeText}>{t(badge.textKey)}</span>
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主体部分 */}
|
||
<div className="app-body">
|
||
{/* 左侧边栏 (hidden when webview is active) */}
|
||
{!webviewActions && (
|
||
<div
|
||
className={
|
||
// 英文菜单文案通常更长,侧边栏适当加宽以减少截断;其他语言保持默认宽度
|
||
i18nLang.toLowerCase().startsWith("en")
|
||
? "app-sider app-sider-en"
|
||
: "app-sider"
|
||
}
|
||
>
|
||
<Menu
|
||
mode="inline"
|
||
inlineIndent={0}
|
||
selectedKeys={[activeTab]}
|
||
items={menuItems.map((item) => ({
|
||
key: item.key,
|
||
icon: item.icon,
|
||
label: item.label,
|
||
onClick: () => setActiveTab(item.key as TabKey),
|
||
}))}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主内容区:flex 子撑满,便于日志等页占满高度 */}
|
||
<div
|
||
className={
|
||
webviewActions
|
||
? "app-content app-content-fullwidth"
|
||
: "app-content"
|
||
}
|
||
>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
background: "var(--color-bg-layout)",
|
||
}}
|
||
>
|
||
{activeTab === "client" && (
|
||
<ClientPage
|
||
onNavigate={(tab) => {
|
||
if (tab === "sessions") setSessionsAutoOpen(true);
|
||
setActiveTab(tab);
|
||
}}
|
||
services={services}
|
||
servicesLoading={servicesLoading}
|
||
startingServices={startingServices}
|
||
setStartingServices={setStartingServices}
|
||
onRefreshServices={pollServicesStatus}
|
||
authRefreshTrigger={authRefreshTrigger}
|
||
onAuthChange={handleAuthChange}
|
||
onLoginStarted={handleLoginStarted}
|
||
/>
|
||
)}
|
||
{activeTab === "sessions" && (
|
||
<SessionsPage
|
||
autoOpen={sessionsAutoOpen}
|
||
onAutoOpenConsumed={() => setSessionsAutoOpen(false)}
|
||
onWebviewChange={setWebviewActions}
|
||
/>
|
||
)}
|
||
{activeTab === "mcp" && <MCPSettings />}
|
||
{activeTab === "settings" && <SettingsPage />}
|
||
{activeTab === "dependencies" && <DependenciesPage />}
|
||
{activeTab === "permissions" && <PermissionsPage />}
|
||
{activeTab === "logs" && <LogViewer />}
|
||
{activeTab === "about" && <AboutPage />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ThemeContext.Provider>
|
||
</I18nContext.Provider>
|
||
</ConfigProvider>
|
||
);
|
||
}
|
||
|
||
export default App;
|