mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
a04e72a267
The reported "OAuth access token disappears when enabling Codex proxy takeover" was a display artifact, not data loss: auth.json on disk kept the OAuth login the whole time. During takeover the edit dialog falls back to the stored provider config (so it does not surface the proxy placeholder), which for a third-party provider shows that provider's own key instead of the live auth.json, making the OAuth token look gone. Thread an isProxyTakeover flag from App through ProviderForm into the Codex editor and show an explicit notice plus storage-aware auth/config hints clarifying that the form displays the stored provider config while the live config is temporarily managed by the proxy. Drop the proxy-running condition so the notice shows whenever takeover is active, even with the proxy stopped. Add a regression test asserting the dialog does not read live settings during takeover and renders the database config. i18n synced across en/zh/ja/zh-TW.
1605 lines
55 KiB
TypeScript
1605 lines
55 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { toast } from "sonner";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Plus,
|
|
Settings,
|
|
ArrowLeft,
|
|
Minus,
|
|
Maximize2,
|
|
Minimize2,
|
|
X,
|
|
Book,
|
|
Brain,
|
|
Wrench,
|
|
RefreshCw,
|
|
History,
|
|
BarChart2,
|
|
Download,
|
|
FolderArchive,
|
|
Search,
|
|
FolderOpen,
|
|
KeyRound,
|
|
Shield,
|
|
Cpu,
|
|
LayoutDashboard,
|
|
} from "lucide-react";
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
import type { Provider, VisibleApps } from "@/types";
|
|
import type { EnvConflict } from "@/types/env";
|
|
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
|
|
import {
|
|
providersApi,
|
|
settingsApi,
|
|
type AppId,
|
|
type ProviderSwitchEvent,
|
|
} from "@/lib/api";
|
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
|
import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
|
|
import { hermesKeys, useOpenHermesWebUI } from "@/hooks/useHermes";
|
|
import { hermesApi } from "@/lib/api/hermes";
|
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
|
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
|
|
import { useTauriEvent } from "@/hooks/useTauriEvent";
|
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
|
import { deepClone } from "@/utils/deepClone";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
isWindows,
|
|
isLinux,
|
|
DRAG_REGION_ATTR,
|
|
DRAG_REGION_STYLE,
|
|
} from "@/lib/platform";
|
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
|
import { ProviderList } from "@/components/providers/ProviderList";
|
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
|
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
|
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
|
|
import { ClaudeDesktopRouteToggle } from "@/components/proxy/ClaudeDesktopRouteToggle";
|
|
import { FailoverToggle } from "@/components/proxy/FailoverToggle";
|
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
|
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
|
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
|
|
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
|
import { UniversalProviderPanel } from "@/components/universal";
|
|
import { McpIcon } from "@/components/BrandIcons";
|
|
import { Button } from "@/components/ui/button";
|
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
|
import {
|
|
useDisableCurrentOmo,
|
|
useDisableCurrentOmoSlim,
|
|
} from "@/lib/query/omo";
|
|
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
|
import EnvPanel from "@/components/openclaw/EnvPanel";
|
|
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
|
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
|
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
|
|
import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel";
|
|
|
|
type View =
|
|
| "providers"
|
|
| "settings"
|
|
| "prompts"
|
|
| "skills"
|
|
| "skillsDiscovery"
|
|
| "mcp"
|
|
| "agents"
|
|
| "universal"
|
|
| "sessions"
|
|
| "workspace"
|
|
| "openclawEnv"
|
|
| "openclawTools"
|
|
| "openclawAgents"
|
|
| "hermesMemory";
|
|
|
|
interface WebDavSyncStatusUpdatedPayload {
|
|
source?: string;
|
|
status?: string;
|
|
error?: string;
|
|
}
|
|
|
|
const DEFAULT_DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
|
const HEADER_HEIGHT = 64; // px
|
|
|
|
const STORAGE_KEY = "cc-switch-last-app";
|
|
const VALID_APPS: AppId[] = [
|
|
"claude",
|
|
"claude-desktop",
|
|
"codex",
|
|
"gemini",
|
|
"opencode",
|
|
"openclaw",
|
|
"hermes",
|
|
];
|
|
|
|
const getInitialApp = (): AppId => {
|
|
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
|
if (saved && VALID_APPS.includes(saved)) {
|
|
return saved;
|
|
}
|
|
return "claude";
|
|
};
|
|
|
|
const VIEW_STORAGE_KEY = "cc-switch-last-view";
|
|
const VALID_VIEWS: View[] = [
|
|
"providers",
|
|
"settings",
|
|
"prompts",
|
|
"skills",
|
|
"skillsDiscovery",
|
|
"mcp",
|
|
"agents",
|
|
"universal",
|
|
"sessions",
|
|
"workspace",
|
|
"openclawEnv",
|
|
"openclawTools",
|
|
"openclawAgents",
|
|
"hermesMemory",
|
|
];
|
|
|
|
const getInitialView = (): View => {
|
|
const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;
|
|
if (saved && VALID_VIEWS.includes(saved)) {
|
|
return saved;
|
|
}
|
|
return "providers";
|
|
};
|
|
|
|
function App() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
|
|
const sharedFeatureApp: AppId =
|
|
activeApp === "claude-desktop" ? "claude" : activeApp;
|
|
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
|
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
|
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
|
|
}, [currentView]);
|
|
|
|
const { data: settingsData } = useSettingsQuery();
|
|
const useAppWindowControls =
|
|
isLinux() && (settingsData?.useAppWindowControls ?? false);
|
|
const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT;
|
|
const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
|
|
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
|
claude: true,
|
|
"claude-desktop": true,
|
|
codex: true,
|
|
gemini: true,
|
|
opencode: true,
|
|
openclaw: true,
|
|
hermes: true,
|
|
};
|
|
|
|
const getFirstVisibleApp = (): AppId => {
|
|
if (visibleApps.claude) return "claude";
|
|
if (visibleApps["claude-desktop"]) return "claude-desktop";
|
|
if (visibleApps.codex) return "codex";
|
|
if (visibleApps.gemini) return "gemini";
|
|
if (visibleApps.opencode) return "opencode";
|
|
if (visibleApps.openclaw) return "openclaw";
|
|
if (visibleApps.hermes) return "hermes";
|
|
return "claude"; // fallback
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!visibleApps[activeApp]) {
|
|
setActiveApp(getFirstVisibleApp());
|
|
}
|
|
}, [visibleApps, activeApp]);
|
|
|
|
// Fallback from sessions view when switching to an app without session support
|
|
useEffect(() => {
|
|
if (
|
|
currentView === "sessions" &&
|
|
sharedFeatureApp !== "claude" &&
|
|
sharedFeatureApp !== "codex" &&
|
|
sharedFeatureApp !== "opencode" &&
|
|
sharedFeatureApp !== "openclaw" &&
|
|
sharedFeatureApp !== "gemini" &&
|
|
sharedFeatureApp !== "hermes"
|
|
) {
|
|
setCurrentView("providers");
|
|
}
|
|
}, [sharedFeatureApp, currentView]);
|
|
|
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
|
const [confirmAction, setConfirmAction] = useState<{
|
|
provider: Provider;
|
|
action: "remove" | "delete";
|
|
} | null>(null);
|
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
|
|
|
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
|
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
|
|
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
const isToolbarCompact = useAutoCompact(toolbarRef);
|
|
|
|
useUsageCacheBridge();
|
|
|
|
const promptPanelRef = useRef<any>(null);
|
|
const mcpPanelRef = useRef<any>(null);
|
|
const skillsPageRef = useRef<any>(null);
|
|
const unifiedSkillsPanelRef = useRef<any>(null);
|
|
const addActionButtonClass =
|
|
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
|
|
|
const {
|
|
isRunning: isProxyRunning,
|
|
takeoverStatus,
|
|
status: proxyStatus,
|
|
} = useProxyStatus();
|
|
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
|
const activeProviderId = useMemo(() => {
|
|
const target = proxyStatus?.active_targets?.find(
|
|
(t) => t.app_type === activeApp,
|
|
);
|
|
return target?.provider_id;
|
|
}, [proxyStatus?.active_targets, activeApp]);
|
|
|
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
|
isProxyRunning,
|
|
});
|
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
|
const currentProviderId = data?.currentProviderId ?? "";
|
|
const isOpenClawView =
|
|
activeApp === "openclaw" &&
|
|
(currentView === "providers" ||
|
|
currentView === "workspace" ||
|
|
currentView === "sessions" ||
|
|
currentView === "openclawEnv" ||
|
|
currentView === "openclawTools" ||
|
|
currentView === "openclawAgents");
|
|
const { data: openclawHealthWarnings = [] } =
|
|
useOpenClawHealth(isOpenClawView);
|
|
const hasSkillsSupport = sharedFeatureApp !== "openclaw";
|
|
const hasSessionSupport =
|
|
sharedFeatureApp === "claude" ||
|
|
sharedFeatureApp === "codex" ||
|
|
sharedFeatureApp === "opencode" ||
|
|
sharedFeatureApp === "openclaw" ||
|
|
sharedFeatureApp === "gemini" ||
|
|
sharedFeatureApp === "hermes";
|
|
|
|
const {
|
|
addProvider,
|
|
updateProvider,
|
|
switchProvider,
|
|
deleteProvider,
|
|
saveUsageScript,
|
|
setAsDefaultModel,
|
|
} = useProviderActions(
|
|
activeApp,
|
|
isProxyRunning,
|
|
isProxyRunning && isCurrentAppTakeoverActive,
|
|
);
|
|
|
|
const disableOmoMutation = useDisableCurrentOmo();
|
|
const handleDisableOmo = () => {
|
|
disableOmoMutation.mutate(undefined, {
|
|
onSuccess: () => {
|
|
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(
|
|
t("omo.disableFailed", {
|
|
defaultValue: "停用 OMO 失败: {{error}}",
|
|
error: extractErrorMessage(error),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
};
|
|
|
|
const disableOmoSlimMutation = useDisableCurrentOmoSlim();
|
|
const handleDisableOmoSlim = () => {
|
|
disableOmoSlimMutation.mutate(undefined, {
|
|
onSuccess: () => {
|
|
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(
|
|
t("omo.disableFailed", {
|
|
defaultValue: "停用 OMO 失败: {{error}}",
|
|
error: extractErrorMessage(error),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
let unsubscribe: (() => void) | undefined;
|
|
let active = true;
|
|
|
|
const setupListener = async () => {
|
|
try {
|
|
const off = await providersApi.onSwitched(
|
|
async (event: ProviderSwitchEvent) => {
|
|
if (event.appType === activeApp) {
|
|
await refetch();
|
|
}
|
|
},
|
|
);
|
|
if (!active) {
|
|
off();
|
|
return;
|
|
}
|
|
unsubscribe = off;
|
|
} catch (error) {
|
|
console.error("[App] Failed to subscribe provider switch event", error);
|
|
}
|
|
};
|
|
|
|
void setupListener();
|
|
return () => {
|
|
active = false;
|
|
unsubscribe?.();
|
|
};
|
|
}, [activeApp, refetch]);
|
|
|
|
useTauriEvent("universal-provider-synced", async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
|
try {
|
|
await providersApi.updateTrayMenu();
|
|
} catch (error) {
|
|
console.error("[App] Failed to update tray menu", error);
|
|
}
|
|
});
|
|
|
|
useTauriEvent<WebDavSyncStatusUpdatedPayload | null | undefined>(
|
|
"webdav-sync-status-updated",
|
|
async (payload) => {
|
|
const statusPayload = payload ?? {};
|
|
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
|
if (statusPayload.source !== "auto" || statusPayload.status !== "error") {
|
|
return;
|
|
}
|
|
toast.error(
|
|
t("settings.webdavSync.autoSyncFailedToast", {
|
|
error: statusPayload.error || t("common.unknown"),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
useTauriEvent<{ appType: string; providerName: string }>(
|
|
"proxy-official-warning",
|
|
(payload) => {
|
|
toast.warning(
|
|
t("notifications.proxyOfficialWarning", {
|
|
name: payload.providerName,
|
|
defaultValue: `当前供应商 ${payload.providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
|
|
}),
|
|
{ duration: 8000 },
|
|
);
|
|
},
|
|
);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
let unlistenResize: (() => void) | undefined;
|
|
|
|
const setupWindowStateSync = async () => {
|
|
try {
|
|
const currentWindow = getCurrentWindow();
|
|
const syncWindowMaximizedState = async () => {
|
|
const maximized = await currentWindow.isMaximized();
|
|
if (active) {
|
|
setIsWindowMaximized(maximized);
|
|
}
|
|
};
|
|
|
|
await syncWindowMaximizedState();
|
|
unlistenResize = await currentWindow.onResized(() => {
|
|
void syncWindowMaximizedState();
|
|
});
|
|
} catch (error) {
|
|
console.error("[App] Failed to sync window maximized state", error);
|
|
}
|
|
};
|
|
|
|
void setupWindowStateSync();
|
|
return () => {
|
|
active = false;
|
|
unlistenResize?.();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// settingsData 未加载时跳过,避免用 fallback false 覆盖 Rust 侧已设好的装饰状态
|
|
if (!settingsData) return;
|
|
|
|
const syncWindowDecorations = async () => {
|
|
try {
|
|
await getCurrentWindow().setDecorations(!useAppWindowControls);
|
|
} catch (error) {
|
|
console.error("[App] Failed to update window decorations", error);
|
|
}
|
|
};
|
|
|
|
void syncWindowDecorations();
|
|
}, [useAppWindowControls, settingsData]);
|
|
|
|
useEffect(() => {
|
|
const checkEnvOnStartup = async () => {
|
|
try {
|
|
const allConflicts = await checkAllEnvConflicts();
|
|
const flatConflicts = Object.values(allConflicts).flat();
|
|
|
|
if (flatConflicts.length > 0) {
|
|
setEnvConflicts(flatConflicts);
|
|
const dismissed = sessionStorage.getItem("env_banner_dismissed");
|
|
if (!dismissed) {
|
|
setShowEnvBanner(true);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to check environment conflicts on startup:",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
checkEnvOnStartup();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const checkMigration = async () => {
|
|
try {
|
|
const migrated = await invoke<boolean>("get_migration_result");
|
|
if (migrated) {
|
|
toast.success(
|
|
t("migration.success", { defaultValue: "配置迁移成功" }),
|
|
{ closeButton: true },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("[App] Failed to check migration result:", error);
|
|
}
|
|
};
|
|
|
|
checkMigration();
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
const checkSkillsMigration = async () => {
|
|
try {
|
|
const result = await invoke<{ count: number; error?: string } | null>(
|
|
"get_skills_migration_result",
|
|
);
|
|
if (result?.error) {
|
|
toast.error(t("migration.skillsFailed"), {
|
|
description: t("migration.skillsFailedDescription"),
|
|
closeButton: true,
|
|
});
|
|
console.error("[App] Skills SSOT migration failed:", result.error);
|
|
return;
|
|
}
|
|
if (result && result.count > 0) {
|
|
toast.success(t("migration.skillsSuccess", { count: result.count }), {
|
|
closeButton: true,
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: ["skills"] });
|
|
}
|
|
} catch (error) {
|
|
console.error("[App] Failed to check skills migration result:", error);
|
|
}
|
|
};
|
|
|
|
checkSkillsMigration();
|
|
}, [t, queryClient]);
|
|
|
|
useEffect(() => {
|
|
const checkEnvOnSwitch = async () => {
|
|
try {
|
|
const conflicts = await checkEnvConflicts(activeApp);
|
|
|
|
if (conflicts.length > 0) {
|
|
setEnvConflicts((prev) => {
|
|
const existingKeys = new Set(
|
|
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
|
);
|
|
const newConflicts = conflicts.filter(
|
|
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
|
|
);
|
|
return [...prev, ...newConflicts];
|
|
});
|
|
const dismissed = sessionStorage.getItem("env_banner_dismissed");
|
|
if (!dismissed) {
|
|
setShowEnvBanner(true);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to check environment conflicts on app switch:",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
checkEnvOnSwitch();
|
|
}, [activeApp]);
|
|
|
|
const currentViewRef = useRef(currentView);
|
|
|
|
useEffect(() => {
|
|
currentViewRef.current = currentView;
|
|
}, [currentView]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
setCurrentView("settings");
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
|
|
if (document.body.style.overflow === "hidden") return;
|
|
|
|
const view = currentViewRef.current;
|
|
if (view === "providers") return;
|
|
|
|
if (isTextEditableTarget(event.target)) return;
|
|
|
|
event.preventDefault();
|
|
setCurrentView(view === "skillsDiscovery" ? "skills" : "providers");
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
const [launchDashboardOpen, setLaunchDashboardOpen] = useState(false);
|
|
const openHermesWebUI = useOpenHermesWebUI(() =>
|
|
setLaunchDashboardOpen(true),
|
|
);
|
|
|
|
const handleOpenWebsite = async (url: string) => {
|
|
try {
|
|
await settingsApi.openExternal(url);
|
|
} catch (error) {
|
|
const detail =
|
|
extractErrorMessage(error) ||
|
|
t("notifications.openLinkFailed", {
|
|
defaultValue: "链接打开失败",
|
|
});
|
|
toast.error(detail);
|
|
}
|
|
};
|
|
|
|
const handleEditProvider = async ({
|
|
provider,
|
|
originalId,
|
|
}: {
|
|
provider: Provider;
|
|
originalId?: string;
|
|
}) => {
|
|
await updateProvider(provider, originalId);
|
|
setEditingProvider(null);
|
|
};
|
|
|
|
const handleConfirmAction = async () => {
|
|
if (!confirmAction) return;
|
|
const { provider, action } = confirmAction;
|
|
|
|
if (action === "remove") {
|
|
// Remove from live config only (for additive mode apps like OpenCode/OpenClaw)
|
|
// Does NOT delete from database - provider remains in the list
|
|
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
|
// Invalidate queries to refresh the isInConfig state
|
|
if (activeApp === "opencode") {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["opencodeLiveProviderIds"],
|
|
});
|
|
} else if (activeApp === "openclaw") {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: openclawKeys.liveProviderIds,
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: openclawKeys.health,
|
|
});
|
|
} else if (activeApp === "hermes") {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: hermesKeys.liveProviderIds,
|
|
});
|
|
}
|
|
toast.success(
|
|
t("notifications.removeFromConfigSuccess", {
|
|
defaultValue: "已从配置移除",
|
|
}),
|
|
{ closeButton: true },
|
|
);
|
|
} else {
|
|
await deleteProvider(provider.id);
|
|
}
|
|
setConfirmAction(null);
|
|
};
|
|
|
|
const generateUniqueProviderCopyKey = (
|
|
originalKey: string,
|
|
existingKeys: string[],
|
|
): string => {
|
|
const baseKey = `${originalKey}-copy`;
|
|
|
|
if (!existingKeys.includes(baseKey)) {
|
|
return baseKey;
|
|
}
|
|
|
|
let counter = 2;
|
|
while (existingKeys.includes(`${baseKey}-${counter}`)) {
|
|
counter++;
|
|
}
|
|
return `${baseKey}-${counter}`;
|
|
};
|
|
|
|
const handleDuplicateProvider = async (provider: Provider) => {
|
|
const newSortIndex =
|
|
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
|
|
|
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
|
|
providerKey?: string;
|
|
addToLive?: boolean;
|
|
} = {
|
|
name: `${provider.name} copy`,
|
|
settingsConfig: deepClone(provider.settingsConfig),
|
|
websiteUrl: provider.websiteUrl,
|
|
category: provider.category,
|
|
sortIndex: newSortIndex, // 复制原 sortIndex + 1
|
|
meta: provider.meta ? deepClone(provider.meta) : undefined,
|
|
icon: provider.icon,
|
|
iconColor: provider.iconColor,
|
|
};
|
|
|
|
if (
|
|
activeApp === "opencode" ||
|
|
activeApp === "openclaw" ||
|
|
activeApp === "hermes"
|
|
) {
|
|
let liveProviderIds: string[] = [];
|
|
try {
|
|
liveProviderIds =
|
|
activeApp === "opencode"
|
|
? await queryClient.ensureQueryData({
|
|
queryKey: ["opencodeLiveProviderIds"],
|
|
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
|
})
|
|
: activeApp === "openclaw"
|
|
? await queryClient.ensureQueryData({
|
|
queryKey: openclawKeys.liveProviderIds,
|
|
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
|
|
})
|
|
: await queryClient.ensureQueryData({
|
|
queryKey: hermesKeys.liveProviderIds,
|
|
queryFn: () => providersApi.getHermesLiveProviderIds(),
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to load live provider IDs for duplication",
|
|
error,
|
|
);
|
|
const errorMessage = extractErrorMessage(error);
|
|
toast.error(
|
|
t("provider.duplicateLiveIdsLoadFailed", {
|
|
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
|
|
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
|
);
|
|
return;
|
|
}
|
|
const existingKeys = Array.from(
|
|
new Set([...Object.keys(providers), ...liveProviderIds]),
|
|
);
|
|
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
|
|
provider.id,
|
|
existingKeys,
|
|
);
|
|
duplicatedProvider.addToLive = false;
|
|
}
|
|
|
|
if (provider.sortIndex !== undefined) {
|
|
const updates = Object.values(providers)
|
|
.filter(
|
|
(p) =>
|
|
p.sortIndex !== undefined &&
|
|
p.sortIndex >= newSortIndex! &&
|
|
p.id !== provider.id,
|
|
)
|
|
.map((p) => ({
|
|
id: p.id,
|
|
sortIndex: p.sortIndex! + 1,
|
|
}));
|
|
|
|
if (updates.length > 0) {
|
|
try {
|
|
await providersApi.updateSortOrder(updates, activeApp);
|
|
} catch (error) {
|
|
console.error("[App] Failed to update sort order", error);
|
|
toast.error(
|
|
t("provider.sortUpdateFailed", {
|
|
defaultValue: "排序更新失败",
|
|
}),
|
|
);
|
|
return; // 如果排序更新失败,不继续添加
|
|
}
|
|
}
|
|
}
|
|
|
|
await addProvider(duplicatedProvider);
|
|
};
|
|
|
|
const handleOpenTerminal = async (provider: Provider) => {
|
|
try {
|
|
const selectedDir = await settingsApi.pickDirectory();
|
|
if (!selectedDir) {
|
|
return;
|
|
}
|
|
|
|
await providersApi.openTerminal(provider.id, activeApp, {
|
|
cwd: selectedDir,
|
|
});
|
|
toast.success(
|
|
t("provider.terminalOpened", {
|
|
defaultValue: "终端已打开",
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
console.error("[App] Failed to open terminal", error);
|
|
const errorMessage = extractErrorMessage(error);
|
|
toast.error(
|
|
t("provider.terminalOpenFailed", {
|
|
defaultValue: "打开终端失败",
|
|
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleImportSuccess = async () => {
|
|
try {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["providers"],
|
|
refetchType: "all",
|
|
});
|
|
await queryClient.refetchQueries({
|
|
queryKey: ["providers"],
|
|
type: "all",
|
|
});
|
|
} catch (error) {
|
|
console.error("[App] Failed to refresh providers after import", error);
|
|
await refetch();
|
|
}
|
|
try {
|
|
await providersApi.updateTrayMenu();
|
|
} catch (error) {
|
|
console.error("[App] Failed to refresh tray menu", error);
|
|
}
|
|
};
|
|
|
|
const notifyWindowControlError = (error: unknown) => {
|
|
toast.error(
|
|
t("notifications.windowControlFailed", {
|
|
defaultValue: "窗口控制失败:{{error}}",
|
|
error: extractErrorMessage(error),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleWindowMinimize = async () => {
|
|
try {
|
|
await getCurrentWindow().minimize();
|
|
} catch (error) {
|
|
console.error("[App] Failed to minimize window", error);
|
|
notifyWindowControlError(error);
|
|
}
|
|
};
|
|
|
|
const handleWindowToggleMaximize = async () => {
|
|
try {
|
|
const currentWindow = getCurrentWindow();
|
|
await currentWindow.toggleMaximize();
|
|
setIsWindowMaximized(await currentWindow.isMaximized());
|
|
} catch (error) {
|
|
console.error("[App] Failed to toggle maximize", error);
|
|
notifyWindowControlError(error);
|
|
}
|
|
};
|
|
|
|
const handleWindowClose = async () => {
|
|
try {
|
|
await getCurrentWindow().close();
|
|
} catch (error) {
|
|
console.error("[App] Failed to close window", error);
|
|
notifyWindowControlError(error);
|
|
}
|
|
};
|
|
|
|
const renderContent = () => {
|
|
const content = (() => {
|
|
switch (currentView) {
|
|
case "settings":
|
|
return (
|
|
<SettingsPage
|
|
open={true}
|
|
onOpenChange={() => setCurrentView("providers")}
|
|
onImportSuccess={handleImportSuccess}
|
|
defaultTab={settingsDefaultTab}
|
|
/>
|
|
);
|
|
case "prompts":
|
|
return (
|
|
<PromptPanel
|
|
ref={promptPanelRef}
|
|
open={true}
|
|
onOpenChange={() => setCurrentView("providers")}
|
|
appId={sharedFeatureApp}
|
|
/>
|
|
);
|
|
case "hermesMemory":
|
|
return <HermesMemoryPanel />;
|
|
case "skills":
|
|
return (
|
|
<UnifiedSkillsPanel
|
|
ref={unifiedSkillsPanelRef}
|
|
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
|
currentApp={
|
|
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
|
}
|
|
/>
|
|
);
|
|
case "skillsDiscovery":
|
|
return (
|
|
<SkillsPage
|
|
ref={skillsPageRef}
|
|
initialApp={
|
|
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
|
}
|
|
/>
|
|
);
|
|
case "mcp":
|
|
return (
|
|
<UnifiedMcpPanel
|
|
ref={mcpPanelRef}
|
|
onOpenChange={() => setCurrentView("providers")}
|
|
/>
|
|
);
|
|
case "agents":
|
|
return (
|
|
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
|
);
|
|
case "universal":
|
|
return (
|
|
<div className="px-6 pt-4">
|
|
<UniversalProviderPanel />
|
|
</div>
|
|
);
|
|
|
|
case "sessions":
|
|
return (
|
|
<SessionManagerPage
|
|
key={sharedFeatureApp}
|
|
appId={sharedFeatureApp}
|
|
/>
|
|
);
|
|
case "workspace":
|
|
return <WorkspaceFilesPanel />;
|
|
case "openclawEnv":
|
|
return <EnvPanel />;
|
|
case "openclawTools":
|
|
return <ToolsPanel />;
|
|
case "openclawAgents":
|
|
return <AgentsDefaultsPanel />;
|
|
default:
|
|
return (
|
|
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={activeApp}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="space-y-4"
|
|
>
|
|
<ProviderList
|
|
providers={providers}
|
|
currentProviderId={currentProviderId}
|
|
appId={activeApp}
|
|
isLoading={isLoading}
|
|
isProxyRunning={isProxyRunning}
|
|
isProxyTakeover={
|
|
isProxyRunning && isCurrentAppTakeoverActive
|
|
}
|
|
activeProviderId={activeProviderId}
|
|
onSwitch={switchProvider}
|
|
onEdit={(provider) => {
|
|
setEditingProvider(provider);
|
|
}}
|
|
onDelete={(provider) =>
|
|
setConfirmAction({ provider, action: "delete" })
|
|
}
|
|
onRemoveFromConfig={
|
|
activeApp === "opencode" ||
|
|
activeApp === "openclaw" ||
|
|
activeApp === "hermes"
|
|
? (provider) =>
|
|
setConfirmAction({ provider, action: "remove" })
|
|
: undefined
|
|
}
|
|
onDisableOmo={
|
|
activeApp === "opencode" ? handleDisableOmo : undefined
|
|
}
|
|
onDisableOmoSlim={
|
|
activeApp === "opencode"
|
|
? handleDisableOmoSlim
|
|
: undefined
|
|
}
|
|
onDuplicate={handleDuplicateProvider}
|
|
onConfigureUsage={setUsageProvider}
|
|
onOpenWebsite={handleOpenWebsite}
|
|
onOpenTerminal={
|
|
activeApp === "claude" ? handleOpenTerminal : undefined
|
|
}
|
|
onCreate={() => setIsAddOpen(true)}
|
|
onSetAsDefault={
|
|
activeApp === "openclaw"
|
|
? setAsDefaultModel
|
|
: activeApp === "hermes"
|
|
? switchProvider
|
|
: undefined
|
|
}
|
|
/>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
})();
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={currentView}
|
|
className="flex-1 min-h-0"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{content}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30 pb-4"
|
|
style={{ overflowX: "hidden", paddingTop: contentTopOffset }}
|
|
>
|
|
{(dragBarHeight > 0 || useAppWindowControls) && (
|
|
<div
|
|
className="fixed top-0 left-0 right-0 z-[70] flex items-center justify-end px-2"
|
|
data-tauri-drag-region
|
|
style={{ WebkitAppRegion: "drag", height: dragBarHeight } as any}
|
|
>
|
|
{useAppWindowControls && (
|
|
<div
|
|
className="flex items-center gap-1"
|
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => void handleWindowMinimize()}
|
|
title={t("header.windowMinimize")}
|
|
className="h-7 w-7"
|
|
>
|
|
<Minus className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => void handleWindowToggleMaximize()}
|
|
title={
|
|
isWindowMaximized
|
|
? t("header.windowRestore")
|
|
: t("header.windowMaximize")
|
|
}
|
|
className="h-7 w-7"
|
|
>
|
|
{isWindowMaximized ? (
|
|
<Minimize2 className="w-4 h-4" />
|
|
) : (
|
|
<Maximize2 className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => void handleWindowClose()}
|
|
title={t("header.windowClose")}
|
|
className="h-7 w-7 hover:bg-red-500/15 hover:text-red-500"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showEnvBanner && envConflicts.length > 0 && (
|
|
<EnvWarningBanner
|
|
conflicts={envConflicts}
|
|
onDismiss={() => {
|
|
setShowEnvBanner(false);
|
|
sessionStorage.setItem("env_banner_dismissed", "true");
|
|
}}
|
|
onDeleted={async () => {
|
|
try {
|
|
const allConflicts = await checkAllEnvConflicts();
|
|
const flatConflicts = Object.values(allConflicts).flat();
|
|
setEnvConflicts(flatConflicts);
|
|
if (flatConflicts.length === 0) {
|
|
setShowEnvBanner(false);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to re-check conflicts after deletion:",
|
|
error,
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<header
|
|
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
|
|
{...DRAG_REGION_ATTR}
|
|
style={
|
|
{
|
|
...DRAG_REGION_STYLE,
|
|
top: dragBarHeight,
|
|
height: HEADER_HEIGHT,
|
|
} as any
|
|
}
|
|
>
|
|
<div
|
|
className="flex h-full items-center justify-between gap-2 px-6"
|
|
{...DRAG_REGION_ATTR}
|
|
style={{ ...DRAG_REGION_STYLE } as any}
|
|
>
|
|
<div
|
|
className="flex items-center gap-1"
|
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
|
>
|
|
{currentView !== "providers" ? (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() =>
|
|
setCurrentView(
|
|
currentView === "skillsDiscovery"
|
|
? "skills"
|
|
: "providers",
|
|
)
|
|
}
|
|
className="mr-2 rounded-lg"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
</Button>
|
|
<h1 className="text-lg font-semibold">
|
|
{currentView === "settings" && t("settings.title")}
|
|
{currentView === "prompts" &&
|
|
t("prompts.title", {
|
|
appName: t(`apps.${sharedFeatureApp}`),
|
|
})}
|
|
{currentView === "skills" && t("skills.title")}
|
|
{currentView === "skillsDiscovery" && t("skills.title")}
|
|
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
|
{currentView === "agents" && t("agents.title")}
|
|
{currentView === "universal" &&
|
|
t("universalProvider.title", {
|
|
defaultValue: "统一供应商",
|
|
})}
|
|
{currentView === "sessions" && t("sessionManager.title")}
|
|
{currentView === "workspace" && t("workspace.title")}
|
|
{currentView === "openclawEnv" && t("openclaw.env.title")}
|
|
{currentView === "openclawTools" && t("openclaw.tools.title")}
|
|
{currentView === "openclawAgents" &&
|
|
t("openclaw.agents.title")}
|
|
{currentView === "hermesMemory" && t("hermes.memory.title")}
|
|
</h1>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative inline-flex items-center">
|
|
<a
|
|
href="https://ccswitch.io"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={cn(
|
|
"text-xl font-semibold transition-colors",
|
|
isProxyRunning && isCurrentAppTakeoverActive
|
|
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
|
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
|
)}
|
|
>
|
|
CC Switch
|
|
</a>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setSettingsDefaultTab("general");
|
|
setCurrentView("settings");
|
|
}}
|
|
title={t("common.settings")}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
</Button>
|
|
<UpdateBadge
|
|
onClick={() => {
|
|
setSettingsDefaultTab("about");
|
|
setCurrentView("settings");
|
|
}}
|
|
/>
|
|
{isCurrentAppTakeoverActive && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setSettingsDefaultTab("usage");
|
|
setCurrentView("settings");
|
|
}}
|
|
title={t("usage.title", {
|
|
defaultValue: "使用统计",
|
|
})}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<BarChart2 className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-1 min-w-0 items-center justify-end gap-1.5">
|
|
{currentView === "providers" &&
|
|
activeApp !== "opencode" &&
|
|
activeApp !== "openclaw" &&
|
|
activeApp !== "hermes" && (
|
|
<div
|
|
className="flex shrink-0 items-center gap-1.5"
|
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
|
>
|
|
{activeApp === "claude-desktop" ? (
|
|
<ClaudeDesktopRouteToggle />
|
|
) : (
|
|
settingsData?.enableLocalProxy && (
|
|
<ProxyToggle activeApp={activeApp} />
|
|
)
|
|
)}
|
|
{activeApp !== "claude-desktop" &&
|
|
settingsData?.enableFailoverToggle && (
|
|
<FailoverToggle activeApp={activeApp} />
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={toolbarRef}
|
|
className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2"
|
|
>
|
|
<div
|
|
className="flex shrink-0 items-center gap-1.5 ml-auto"
|
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
|
>
|
|
{currentView === "prompts" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => promptPanelRef.current?.openAdd()}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t("prompts.add")}
|
|
</Button>
|
|
)}
|
|
{currentView === "mcp" && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => mcpPanelRef.current?.openImport()}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
{t("mcp.importExisting")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => mcpPanelRef.current?.openAdd()}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t("mcp.addMcp")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{currentView === "skills" && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
unifiedSkillsPanelRef.current?.openRestoreFromBackup()
|
|
}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<History className="w-4 h-4 mr-2" />
|
|
{t("skills.restoreFromBackup.button")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
|
}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<FolderArchive className="w-4 h-4 mr-2" />
|
|
{t("skills.installFromZip.button")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
unifiedSkillsPanelRef.current?.openImport()
|
|
}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
{t("skills.import")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("skillsDiscovery")}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Search className="w-4 h-4 mr-2" />
|
|
{t("skills.discover")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{currentView === "skillsDiscovery" && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => skillsPageRef.current?.refresh()}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
{t("skills.refresh")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => skillsPageRef.current?.openRepoManager()}
|
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
{t("skills.repoManager")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{currentView === "providers" && (
|
|
<>
|
|
<AppSwitcher
|
|
activeApp={activeApp}
|
|
onSwitch={setActiveApp}
|
|
visibleApps={visibleApps}
|
|
compact={isToolbarCompact}
|
|
/>
|
|
|
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={
|
|
activeApp === "openclaw"
|
|
? "openclaw"
|
|
: activeApp === "hermes"
|
|
? "hermes"
|
|
: "default"
|
|
}
|
|
className="flex items-center gap-1"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
{activeApp === "hermes" ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("skills")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("skills.manage")}
|
|
>
|
|
<Wrench className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("hermesMemory")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("hermes.memory.title")}
|
|
>
|
|
<Brain className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void openHermesWebUI()}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("hermes.webui.open")}
|
|
>
|
|
<LayoutDashboard className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("mcp")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("mcp.title")}
|
|
>
|
|
<McpIcon size={16} />
|
|
</Button>
|
|
</>
|
|
) : activeApp === "openclaw" ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("workspace")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("workspace.manage")}
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("openclawEnv")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("openclaw.env.title")}
|
|
>
|
|
<KeyRound className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("openclawTools")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("openclaw.tools.title")}
|
|
>
|
|
<Shield className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("openclawAgents")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("openclaw.agents.title")}
|
|
>
|
|
<Cpu className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("sessions")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("sessionManager.title")}
|
|
>
|
|
<History className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("skills")}
|
|
className={cn(
|
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
|
hasSkillsSupport
|
|
? "opacity-100 w-8 scale-100 px-2"
|
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
|
)}
|
|
title={t("skills.manage")}
|
|
>
|
|
<Wrench className="flex-shrink-0 w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("prompts")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("prompts.manage")}
|
|
>
|
|
<Book className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("sessions")}
|
|
className={cn(
|
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
|
hasSessionSupport
|
|
? "opacity-100 w-8 scale-100 px-2"
|
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
|
)}
|
|
title={t("sessionManager.title")}
|
|
>
|
|
<History className="flex-shrink-0 w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCurrentView("mcp")}
|
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
|
title={t("mcp.title")}
|
|
>
|
|
<McpIcon size={16} />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => setIsAddOpen(true)}
|
|
size="icon"
|
|
className={`ml-2 ${addActionButtonClass}`}
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
|
|
{isOpenClawView && openclawHealthWarnings.length > 0 && (
|
|
<OpenClawHealthBanner warnings={openclawHealthWarnings} />
|
|
)}
|
|
{renderContent()}
|
|
</main>
|
|
|
|
<AddProviderDialog
|
|
open={isAddOpen}
|
|
onOpenChange={setIsAddOpen}
|
|
appId={activeApp}
|
|
onSubmit={addProvider}
|
|
/>
|
|
|
|
<EditProviderDialog
|
|
open={Boolean(editingProvider)}
|
|
provider={effectiveEditingProvider}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setEditingProvider(null);
|
|
}
|
|
}}
|
|
onSubmit={handleEditProvider}
|
|
appId={activeApp}
|
|
isProxyTakeover={isCurrentAppTakeoverActive}
|
|
/>
|
|
|
|
{effectiveUsageProvider && (
|
|
<UsageScriptModal
|
|
key={effectiveUsageProvider.id}
|
|
provider={effectiveUsageProvider}
|
|
appId={activeApp}
|
|
isOpen={Boolean(usageProvider)}
|
|
onClose={() => setUsageProvider(null)}
|
|
onSave={(script) => {
|
|
if (usageProvider) {
|
|
void saveUsageScript(usageProvider, script);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={Boolean(confirmAction)}
|
|
title={
|
|
confirmAction?.action === "remove"
|
|
? t("confirm.removeProvider")
|
|
: t("confirm.deleteProvider")
|
|
}
|
|
message={
|
|
confirmAction
|
|
? confirmAction.action === "remove"
|
|
? t("confirm.removeProviderMessage", {
|
|
name: confirmAction.provider.name,
|
|
})
|
|
: t("confirm.deleteProviderMessage", {
|
|
name: confirmAction.provider.name,
|
|
})
|
|
: ""
|
|
}
|
|
onConfirm={() => void handleConfirmAction()}
|
|
onCancel={() => setConfirmAction(null)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={launchDashboardOpen}
|
|
title={t("hermes.webui.launchConfirmTitle")}
|
|
message={t("hermes.webui.launchConfirmMessage")}
|
|
confirmText={t("hermes.webui.launchConfirmAction")}
|
|
variant="info"
|
|
onConfirm={() => {
|
|
setLaunchDashboardOpen(false);
|
|
void (async () => {
|
|
try {
|
|
await hermesApi.launchDashboard();
|
|
toast.success(t("hermes.webui.launching"));
|
|
} catch (error) {
|
|
toast.error(t("hermes.webui.launchFailed"), {
|
|
description: extractErrorMessage(error) || undefined,
|
|
});
|
|
}
|
|
})();
|
|
}}
|
|
onCancel={() => setLaunchDashboardOpen(false)}
|
|
/>
|
|
|
|
<DeepLinkImportDialog />
|
|
<FirstRunNoticeDialog />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|