Files
qiming/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx

1380 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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;