Merge origin/main into main

Resolved conflict in src-tauri/src/commands/misc.rs by combining imports from both sides.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
w0x7ce
2026-01-04 09:12:12 +08:00
70 changed files with 4593 additions and 1283 deletions
+102 -34
View File
@@ -13,6 +13,8 @@ import {
Wrench,
Server,
RefreshCw,
Search,
Download,
} from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -26,6 +28,7 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -41,6 +44,7 @@ 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 { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
@@ -51,6 +55,7 @@ type View =
| "settings"
| "prompts"
| "skills"
| "skillsDiscovery"
| "mcp"
| "agents"
| "universal";
@@ -73,25 +78,14 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
// 保存最后一个有效的 provider,用于动画退出期间显示内容
const lastUsageProviderRef = useRef<Provider | null>(null);
const lastEditingProviderRef = useRef<Provider | null>(null);
useEffect(() => {
if (usageProvider) {
lastUsageProviderRef.current = usageProvider;
}
}, [usageProvider]);
useEffect(() => {
if (editingProvider) {
lastEditingProviderRef.current = editingProvider;
}
}, [editingProvider]);
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
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";
@@ -117,8 +111,7 @@ function App() {
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
// Skills 功能仅支持 Claude 和 Codex
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
const hasSkillsSupport = true;
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -229,6 +222,35 @@ function App() {
checkMigration();
}, [t]);
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
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 () => {
@@ -421,10 +443,16 @@ function App() {
/>
);
case "skills":
return (
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
@@ -564,7 +592,11 @@ function App() {
<Button
variant="outline"
size="icon"
onClick={() => setCurrentView("providers")}
onClick={() =>
setCurrentView(
currentView === "skillsDiscovery" ? "skills" : "providers",
)
}
className="mr-2 rounded-lg"
>
<ArrowLeft className="w-4 h-4" />
@@ -574,6 +606,7 @@ function App() {
{currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")}
{currentView === "universal" &&
@@ -619,25 +652,60 @@ function App() {
>
{currentView === "prompts" && (
<Button
size="icon"
variant="ghost"
size="sm"
onClick={() => promptPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
title={t("prompts.add")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-5 h-5" />
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button>
)}
{currentView === "mcp" && (
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="w-5 h-5" />
</Button>
<>
<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?.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"
@@ -739,7 +807,7 @@ function App() {
<EditProviderDialog
open={Boolean(editingProvider)}
provider={lastEditingProviderRef.current}
provider={effectiveEditingProvider}
onOpenChange={(open) => {
if (!open) {
setEditingProvider(null);
@@ -750,9 +818,9 @@ function App() {
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/>
{lastUsageProviderRef.current && (
{effectiveUsageProvider && (
<UsageScriptModal
provider={lastUsageProviderRef.current}
provider={effectiveUsageProvider}
appId={activeApp}
isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)}
+20 -5
View File
@@ -12,6 +12,9 @@ interface FullScreenPanelProps {
footer?: React.ReactNode;
}
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
@@ -44,18 +47,30 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
{/* Drag region - match App.tsx */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
data-tauri-drag-region
style={{
WebkitAppRegion: "drag",
height: DRAG_BAR_HEIGHT,
} as React.CSSProperties}
/>
{/* Header - match App.tsx */}
<div
className="flex-shrink-0 flex items-center"
style={{
backgroundColor: "hsl(var(--background))",
height: HEADER_HEIGHT,
}}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<div className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4">
<Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
className="rounded-lg"
>
<ArrowLeft className="h-4 w-4" />
</Button>
+19 -2
View File
@@ -3,12 +3,11 @@ import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
import { useAllMcpServers, useToggleMcpApp, useDeleteMcpServer, useImportMcpFromApps } from "@/hooks/useMcp";
import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import { useDeleteMcpServer } from "@/hooks/useMcp";
import { Edit3, Trash2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets";
@@ -24,6 +23,7 @@ interface UnifiedMcpPanelProps {
*/
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
openImport: () => void;
}
const UnifiedMcpPanel = React.forwardRef<
@@ -44,6 +44,7 @@ const UnifiedMcpPanel = React.forwardRef<
const { data: serversMap, isLoading } = useAllMcpServers();
const toggleAppMutation = useToggleMcpApp();
const deleteServerMutation = useDeleteMcpServer();
const importMutation = useImportMcpFromApps();
// Convert serversMap to array for easier rendering
const serverEntries = useMemo((): Array<[string, McpServer]> => {
@@ -86,8 +87,24 @@ const UnifiedMcpPanel = React.forwardRef<
setIsFormOpen(true);
};
const handleImport = async () => {
try {
const count = await importMutation.mutateAsync();
if (count === 0) {
toast.success(t("mcp.unifiedPanel.noImportFound"), { closeButton: true });
} else {
toast.success(t("mcp.unifiedPanel.importSuccess", { count }), { closeButton: true });
}
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
openImport: handleImport,
}));
const handleDelete = (id: string) => {
+3 -3
View File
@@ -8,7 +8,7 @@ interface PromptToggleProps {
/**
* Toggle 开关组件(提示词专用)
* 启用时为色,禁用时为灰色
* 启用时为绿色,禁用时为灰色
*/
const PromptToggle: React.FC<PromptToggleProps> = ({
enabled,
@@ -23,8 +23,8 @@ const PromptToggle: React.FC<PromptToggleProps> = ({
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
@@ -222,9 +222,7 @@ export function ClaudeFormFields({
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
{t("providerForm.anthropicReasoningModel")}
</FormLabel>
<Input
id="reasoningModel"
@@ -233,9 +231,6 @@ export function ClaudeFormFields({
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
@@ -142,12 +142,13 @@ export function AutoFailoverConfigPanel({
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
maxRetries: parseInt(e.target.value) || 3,
})
}
maxRetries: isNaN(val) ? 0 : val,
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -168,12 +169,13 @@ export function AutoFailoverConfigPanel({
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5,
})
}
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -206,12 +208,13 @@ export function AutoFailoverConfigPanel({
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -232,12 +235,13 @@ export function AutoFailoverConfigPanel({
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
streamingIdleTimeout: isNaN(val) ? 0 : val,
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -258,12 +262,13 @@ export function AutoFailoverConfigPanel({
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
nonStreamingTimeout: isNaN(val) ? 0 : val,
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -293,12 +298,13 @@ export function AutoFailoverConfigPanel({
min="1"
max="10"
value={formData.circuitSuccessThreshold}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
circuitSuccessThreshold: parseInt(e.target.value) || 2,
})
}
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -319,12 +325,13 @@ export function AutoFailoverConfigPanel({
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -346,13 +353,13 @@ export function AutoFailoverConfigPanel({
max="100"
step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
circuitErrorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
})
}
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -373,12 +380,13 @@ export function AutoFailoverConfigPanel({
min="5"
max="100"
value={formData.circuitMinRequests}
onChange={(e) =>
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
circuitMinRequests: parseInt(e.target.value) || 10,
})
}
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
});
}}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
+1 -1
View File
@@ -312,7 +312,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-3 px-1">
{["claude", "codex", "gemini"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.name ?? toolName;
+2 -2
View File
@@ -12,13 +12,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { Skill, SkillRepo } from "@/lib/api/skills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repos: SkillRepo[];
skills: Skill[];
skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
}
+3 -3
View File
@@ -6,11 +6,11 @@ import { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Skill, SkillRepo } from "@/lib/api/skills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps {
repos: SkillRepo[];
skills: Skill[];
skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
onClose: () => void;
@@ -92,7 +92,7 @@ export function RepoManagerPanel({
{/* 添加仓库表单 */}
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
{t("skills.addRepo")}
</h3>
<div className="space-y-4">
<div>
+5 -3
View File
@@ -12,10 +12,12 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { Skill } from "@/lib/api/skills";
import type { DiscoverableSkill } from "@/lib/api/skills";
type SkillCardSkill = DiscoverableSkill & { installed: boolean };
interface SkillCardProps {
skill: Skill;
skill: SkillCardSkill;
onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
}
@@ -57,7 +59,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:shadow-lg group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
+101 -129
View File
@@ -1,10 +1,4 @@
import {
useState,
useEffect,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -20,15 +14,18 @@ import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import {
skillsApi,
type Skill,
type SkillRepo,
useDiscoverableSkills,
useInstalledSkills,
useInstallSkill,
useSkillRepos,
useAddSkillRepo,
useRemoveSkillRepo,
type AppType,
} from "@/lib/api/skills";
} from "@/hooks/useSkills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
onClose?: () => void;
initialApp?: AppType;
}
@@ -37,163 +34,138 @@ export interface SkillsPageHandle {
openRepoManager: () => void;
}
/**
* Skills 发现面板
* 用于浏览和安装来自仓库的 Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose, initialApp = "claude" }, ref) => {
({ initialApp = "claude" }, ref) => {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState<
"all" | "installed" | "uninstalled"
>("all");
// 使用 initialApp,不允许切换
const selectedApp = initialApp;
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll(selectedApp);
setSkills(data);
if (afterLoad) {
afterLoad(data);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// currentApp 用于安装时的默认应用
const currentApp = initialApp;
// 传入 "skills.loadFailed" 作为标题
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.loadFailed",
);
// Queries
const {
data: discoverableSkills,
isLoading: loadingDiscoverable,
isFetching: fetchingDiscoverable,
refetch: refetchDiscoverable,
} = useDiscoverableSkills();
const { data: installedSkills } = useInstalledSkills();
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
toast.error(title, {
description,
duration: 8000,
});
// Mutations
const installMutation = useInstallSkill();
const addRepoMutation = useAddSkillRepo();
const removeRepoMutation = useRemoveSkillRepo();
console.error("Load skills failed:", error);
} finally {
setLoading(false);
}
};
// 已安装的 directory 集合
const installedDirs = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
}, [installedSkills]);
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 为发现列表补齐 installed 状态,供 SkillCard 使用
const skills: DiscoverableSkillItem[] = useMemo(() => {
if (!discoverableSkills) return [];
return discoverableSkills.map((d) => {
const installName =
d.directory.split("/").pop()?.toLowerCase() ||
d.directory.toLowerCase();
return {
...d,
installed: installedDirs.has(installName),
};
});
}, [discoverableSkills, installedDirs]);
const loading = loadingDiscoverable || fetchingDiscoverable;
useImperativeHandle(ref, () => ({
refresh: () => loadSkills(),
refresh: () => {
refetchDiscoverable();
refetchRepos();
},
openRepoManager: () => setRepoManagerOpen(true),
}));
const handleInstall = async (directory: string) => {
// 找到对应的 DiscoverableSkill
const skill = discoverableSkills?.find(
(s) =>
s.directory === directory ||
s.directory.split("/").pop() === directory,
);
if (!skill) {
toast.error(t("skills.notFound"));
return;
}
try {
await skillsApi.install(directory, selectedApp);
toast.success(t("skills.installSuccess", { name: directory }), {
await installMutation.mutateAsync({
skill,
currentApp,
});
toast.success(t("skills.installSuccess", { name: skill.name }), {
closeButton: true,
});
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.installFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.installFailed",
);
toast.error(title, {
description,
duration: 10000, // 延长显示时间让用户看清
});
console.error("Install skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory, selectedApp);
toast.success(t("skills.uninstallSuccess", { name: directory }), {
closeButton: true,
});
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.uninstallFailed",
);
toast.error(title, {
description,
duration: 10000,
});
console.error("Install skill failed:", error);
}
};
console.error("Uninstall skill failed:", {
directory,
error,
message: errorMessage,
const handleUninstall = async (_directory: string) => {
// 在发现面板中,不支持卸载,需要在主面板中操作
toast.info(t("skills.uninstallInMainPanel"));
};
const handleAddRepo = async (repo: SkillRepo) => {
try {
await addRepoMutation.mutateAsync(repo);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
{ closeButton: true },
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
await Promise.all([loadRepos(), loadSkills()]);
try {
await removeRepoMutation.mutateAsync({ owner, name });
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
// 过滤技能列表
@@ -0,0 +1,417 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useInstalledSkills,
useToggleSkillApp,
useUninstallSkill,
useScanUnmanagedSkills,
useImportSkillsFromApps,
type InstalledSkill,
type AppType,
} from "@/hooks/useSkills";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
import { toast } from "sonner";
interface UnifiedSkillsPanelProps {
onOpenDiscovery: () => void;
}
/**
* 统一 Skills 管理面板
* v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端
*/
export interface UnifiedSkillsPanelHandle {
openDiscovery: () => void;
openImport: () => void;
}
const UnifiedSkillsPanel = React.forwardRef<
UnifiedSkillsPanelHandle,
UnifiedSkillsPanelProps
>(({ onOpenDiscovery }, ref) => {
const { t } = useTranslation();
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
// Queries and Mutations
const { data: skills, isLoading } = useInstalledSkills();
const toggleAppMutation = useToggleSkillApp();
const uninstallMutation = useUninstallSkill();
const { data: unmanagedSkills, refetch: scanUnmanaged } =
useScanUnmanagedSkills();
const importMutation = useImportSkillsFromApps();
// Count enabled skills per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0 };
if (!skills) return counts;
skills.forEach((skill) => {
if (skill.apps.claude) counts.claude++;
if (skill.apps.codex) counts.codex++;
if (skill.apps.gemini) counts.gemini++;
});
return counts;
}, [skills]);
const handleToggleApp = async (
id: string,
app: AppType,
enabled: boolean,
) => {
try {
await toggleAppMutation.mutateAsync({ id, app, enabled });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleUninstall = (skill: InstalledSkill) => {
setConfirmDialog({
isOpen: true,
title: t("skills.uninstall"),
message: t("skills.uninstallConfirm", { name: skill.name }),
onConfirm: async () => {
try {
await uninstallMutation.mutateAsync(skill.id);
setConfirmDialog(null);
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
},
});
};
const handleOpenImport = async () => {
try {
const result = await scanUnmanaged();
if (!result.data || result.data.length === 0) {
toast.success(t("skills.noUnmanagedFound"), { closeButton: true });
return;
}
setImportDialogOpen(true);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleImport = async (directories: string[]) => {
try {
const imported = await importMutation.mutateAsync(directories);
setImportDialogOpen(false);
toast.success(
t("skills.importSuccess", { count: imported.length }),
{ closeButton: true },
);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
React.useImperativeHandle(ref, () => ({
openDiscovery: onOpenDiscovery,
openImport: handleOpenImport,
}));
return (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("skills.apps.gemini")}: {enabledCounts.gemini}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
{t("skills.loading")}
</div>
) : !skills || skills.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<Sparkles size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("skills.noInstalled")}
</h3>
<p className="text-muted-foreground text-sm">
{t("skills.noInstalledDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{skills.map((skill) => (
<InstalledSkillListItem
key={skill.id}
skill={skill}
onToggleApp={handleToggleApp}
onUninstall={() => handleUninstall(skill)}
/>
))}
</div>
)}
</div>
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
{/* Import Dialog */}
{importDialogOpen && unmanagedSkills && (
<ImportSkillsDialog
skills={unmanagedSkills}
onImport={handleImport}
onClose={() => setImportDialogOpen(false)}
/>
)}
</div>
);
});
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
/**
* 已安装 Skill 列表项组件
*/
interface InstalledSkillListItemProps {
skill: InstalledSkill;
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
onUninstall: () => void;
}
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
skill,
onToggleApp,
onUninstall,
}) => {
const { t } = useTranslation();
const openDocs = async () => {
if (!skill.readmeUrl) return;
try {
await settingsApi.openExternal(skill.readmeUrl);
} catch {
// ignore
}
};
// 生成来源标签
const sourceLabel = useMemo(() => {
if (skill.repoOwner && skill.repoName) {
return `${skill.repoOwner}/${skill.repoName}`;
}
return t("skills.local");
}, [skill.repoOwner, skill.repoName, t]);
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:Skill 信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{skill.name}</h3>
{skill.readmeUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
className="h-6 px-2"
>
<ExternalLink size={14} />
</Button>
)}
</div>
{skill.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
)}
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-claude`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.claude")}
</label>
<Switch
id={`${skill.id}-claude`}
checked={skill.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "claude", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-codex`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.codex")}
</label>
<Switch
id={`${skill.id}-codex`}
checked={skill.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-gemini`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.gemini")}
</label>
<Switch
id={`${skill.id}-gemini`}
checked={skill.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "gemini", checked)
}
/>
</div>
</div>
{/* 右侧:删除按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onUninstall}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("skills.uninstall")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);
};
/**
* 导入 Skills 对话框
*/
interface ImportSkillsDialogProps {
skills: Array<{
directory: string;
name: string;
description?: string;
foundIn: string[];
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
}
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
skills,
onImport,
onClose,
}) => {
const { t } = useTranslation();
const [selected, setSelected] = useState<Set<string>>(
new Set(skills.map((s) => s.directory)),
);
const toggleSelect = (directory: string) => {
const newSelected = new Set(selected);
if (newSelected.has(directory)) {
newSelected.delete(directory);
} else {
newSelected.add(directory);
}
setSelected(newSelected);
};
const handleImport = () => {
onImport(Array.from(selected));
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t("skills.importDescription")}
</p>
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
{skills.map((skill) => (
<label
key={skill.directory}
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
checked={selected.has(skill.directory)}
onChange={() => toggleSelect(skill.directory)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{skill.name}</div>
{skill.description && (
<div className="text-sm text-muted-foreground line-clamp-1">
{skill.description}
</div>
)}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
</div>
</div>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={selected.size === 0}>
{t("skills.importSelected", { count: selected.size })}
</Button>
</div>
</div>
</div>
);
};
export default UnifiedSkillsPanel;
+34 -18
View File
@@ -62,6 +62,28 @@ export function RequestLogTable() {
});
};
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
const timestampToLocalDatetime = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// 将 datetime-local 格式转换为 Unix 时间戳
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
if (!datetime) return undefined;
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
if (datetime.length < 16) return undefined;
const timestamp = new Date(datetime).getTime();
// 验证是否为有效日期
if (isNaN(timestamp)) return undefined;
return Math.floor(timestamp / 1000);
};
const dateLocale =
i18n.language === "zh"
? "zh-CN"
@@ -153,19 +175,16 @@ export function RequestLogTable() {
className="h-8 w-[200px] bg-background"
value={
tempFilters.startDate
? new Date(tempFilters.startDate * 1000)
.toISOString()
.slice(0, 16)
? timestampToLocalDatetime(tempFilters.startDate)
: ""
}
onChange={(e) =>
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({
...tempFilters,
startDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
startDate: timestamp,
});
}}
/>
<span>-</span>
<Input
@@ -173,19 +192,16 @@ export function RequestLogTable() {
className="h-8 w-[200px] bg-background"
value={
tempFilters.endDate
? new Date(tempFilters.endDate * 1000)
.toISOString()
.slice(0, 16)
? timestampToLocalDatetime(tempFilters.endDate)
: ""
}
onChange={(e) =>
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({
...tempFilters,
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
endDate: timestamp,
});
}}
/>
</div>
+1 -7
View File
@@ -12,13 +12,7 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { startDate, endDate } = useMemo(() => {
const end = Math.floor(Date.now() / 1000);
const start = end - days * 24 * 60 * 60;
return { startDate: start, endDate: end };
}, [days]);
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
const { data: summary, isLoading } = useUsageSummary(days);
const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
+43 -19
View File
@@ -41,7 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
return {
rawDate: stat.date,
label: isToday
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
? pointDate.toLocaleString(dateLocale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: pointDate.toLocaleDateString(dateLocale, {
month: "2-digit",
day: "2-digit",
@@ -49,28 +54,13 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
hour: pointDate.getHours(),
inputTokens: stat.totalInputTokens,
outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost),
};
}) || [];
const hourlyData = (() => {
if (!isToday) return chartData;
const map = new Map<number, (typeof chartData)[number]>();
chartData.forEach((point) => {
map.set(point.hour ?? 0, point);
});
return Array.from({ length: 24 }, (_, hour) => {
const bucket = map.get(hour);
return {
label: `${hour.toString().padStart(2, "0")}:00`,
inputTokens: bucket?.inputTokens ?? 0,
outputTokens: bucket?.outputTokens ?? 0,
cost: bucket?.cost ?? 0,
};
});
})();
const displayData = isToday ? hourlyData : chartData;
const displayData = chartData;
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
@@ -131,6 +121,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
<linearGradient
id="colorCacheCreation"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2} />
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorCacheRead" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.2} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
@@ -182,6 +186,26 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
fill="url(#colorOutput)"
strokeWidth={2}
/>
<Area
yAxisId="tokens"
type="monotone"
dataKey="cacheCreationTokens"
name={t("usage.cacheCreationTokens", "缓存创建")}
stroke="#f97316"
fillOpacity={1}
fill="url(#colorCacheCreation)"
strokeWidth={2}
/>
<Area
yAxisId="tokens"
type="monotone"
dataKey="cacheReadTokens"
name={t("usage.cacheReadTokens", "缓存命中")}
stroke="#a855f7"
fillOpacity={1}
fill="url(#colorCacheRead)"
strokeWidth={2}
/>
<Area
yAxisId="cost"
type="monotone"
+20
View File
@@ -0,0 +1,20 @@
import { useRef } from "react";
/**
* 保存最后一个非 null/undefined 的值
* 用于 Dialog 关闭动画期间保持内容显示
*
* @param value 当前值
* @returns 当前值(如果有效)或最后一个有效值
*/
export function useLastValidValue<T>(value: T | null | undefined): T | null {
const ref = useRef<T | null>(null);
// 同步更新 ref(在渲染期间,不在 useEffect 中)
if (value != null) {
ref.current = value;
}
// 返回当前值或最后有效值
return value ?? ref.current;
}
+13
View File
@@ -59,3 +59,16 @@ export function useDeleteMcpServer() {
},
});
}
/**
* 从所有应用导入 MCP 服务器
*/
export function useImportMcpFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => mcpApi.importFromApps(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}
+151
View File
@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
skillsApi,
type AppType,
type DiscoverableSkill,
type InstalledSkill,
} from "@/lib/api/skills";
/**
* 查询所有已安装的 Skills
*/
export function useInstalledSkills() {
return useQuery({
queryKey: ["skills", "installed"],
queryFn: () => skillsApi.getInstalled(),
});
}
/**
* 发现可安装的 Skills(从仓库获取)
*/
export function useDiscoverableSkills() {
return useQuery({
queryKey: ["skills", "discoverable"],
queryFn: () => skillsApi.discoverAvailable(),
staleTime: Infinity, // 无限缓存,直到仓库变化时 invalidate
});
}
/**
* 安装 Skill
*/
export function useInstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skill,
currentApp,
}: {
skill: DiscoverableSkill;
currentApp: AppType;
}) => skillsApi.installUnified(skill, currentApp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* 卸载 Skill
*/
export function useUninstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => skillsApi.uninstallUnified(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* 切换 Skill 在特定应用的启用状态
*/
export function useToggleSkillApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
app,
enabled,
}: {
id: string;
app: AppType;
enabled: boolean;
}) => skillsApi.toggleApp(id, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
},
});
}
/**
* 扫描未管理的 Skills
*/
export function useScanUnmanagedSkills() {
return useQuery({
queryKey: ["skills", "unmanaged"],
queryFn: () => skillsApi.scanUnmanaged(),
enabled: false, // 手动触发
});
}
/**
* 从应用目录导入 Skills
*/
export function useImportSkillsFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (directories: string[]) => skillsApi.importFromApps(directories),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
},
});
}
/**
* 获取仓库列表
*/
export function useSkillRepos() {
return useQuery({
queryKey: ["skills", "repos"],
queryFn: () => skillsApi.getRepos(),
});
}
/**
* 添加仓库
*/
export function useAddSkillRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: skillsApi.addRepo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* 删除仓库
*/
export function useRemoveSkillRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ owner, name }: { owner: string; name: string }) =>
skillsApi.removeRepo(owner, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
// ========== 辅助类型 ==========
export type { InstalledSkill, DiscoverableSkill, AppType };
+37 -12
View File
@@ -343,7 +343,6 @@
"anthropicModel": "Main Model",
"anthropicSmallFastModel": "Fast Model",
"anthropicReasoningModel": "Reasoning Model (Thinking)",
"reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter Compatibility Mode",
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
"anthropicDefaultHaikuModel": "Default Haiku Model",
@@ -423,7 +422,7 @@
"cost": "Cost",
"perMillion": "(per million)",
"trends": "Usage Trends",
"rangeToday": "Today (hourly)",
"rangeToday": "Last 24 hours (hourly)",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"totalTokens": "Total Tokens",
@@ -436,8 +435,8 @@
"billingModel": "Billing Model",
"inputTokens": "Input",
"outputTokens": "Output",
"cacheReadTokens": "Cache Read",
"cacheCreationTokens": "Cache Write",
"cacheReadTokens": "Cache Hit",
"cacheCreationTokens": "Cache Creation",
"timingInfo": "Duration/TTFT",
"status": "Status",
"noData": "No data",
@@ -453,8 +452,8 @@
"displayName": "Display Name",
"inputCost": "Input Cost",
"outputCost": "Output Cost",
"cacheReadCost": "Cache Read",
"cacheWriteCost": "Cache Write",
"cacheReadCost": "Cache Hit",
"cacheWriteCost": "Cache Creation",
"deleteConfirmTitle": "Confirm Delete",
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
"queryFailed": "Query failed",
@@ -481,8 +480,8 @@
"timeRange": "Time Range",
"input": "Input",
"output": "Output",
"cacheWrite": "Write",
"cacheRead": "Read"
"cacheWrite": "Creation",
"cacheRead": "Hit"
},
"usageScript": {
"title": "Configure Usage Query",
@@ -565,6 +564,9 @@
},
"mcp": {
"title": "MCP Management",
"import": "Import",
"importExisting": "Import Existing",
"addMcp": "Add MCP",
"claudeTitle": "Claude Code MCP Management",
"codexTitle": "Codex MCP Management",
"geminiTitle": "Gemini MCP Management",
@@ -576,6 +578,8 @@
"deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.",
"noServers": "No servers yet",
"enabledApps": "Enabled Apps",
"noImportFound": "No MCP servers to import found. All servers are already managed by CC Switch.",
"importSuccess": "Successfully imported {{count}} MCP servers",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -793,8 +797,8 @@
},
"skills": {
"manage": "Skills",
"title": "Claude Skills Management",
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
"title": "Skills Management",
"description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"repoManager": "Repository Management",
@@ -870,7 +874,25 @@
"installed": "Installed",
"uninstalled": "Not installed"
},
"noResults": "No matching skills found"
"noResults": "No matching skills found",
"noInstalled": "No skills installed",
"noInstalledDescription": "Discover and install skills from repositories, or import existing skills",
"discover": "Discover Skills",
"import": "Import Existing",
"importDescription": "Select skills to import into CC Switch unified management",
"importSuccess": "Successfully imported {{count}} skills",
"importSelected": "Import Selected ({{count}})",
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
"foundIn": "Found in",
"local": "Local",
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.",
"uninstallInMainPanel": "Please uninstall skills from the main panel",
"notFound": "Skill not found",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"deeplink": {
"confirmImport": "Confirm Import Provider",
@@ -958,7 +980,10 @@
"clickToSelect": "Click to select icon"
},
"migration": {
"success": "Configuration migrated successfully"
"success": "Configuration migrated successfully",
"skillsSuccess": "Automatically imported {{count}} skill(s) into unified management",
"skillsFailed": "Failed to auto import skills",
"skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)."
},
"agents": {
"title": "Agents"
+37 -12
View File
@@ -343,7 +343,6 @@
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
"anthropicReasoningModel": "推論モデル(Thinking",
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 互換モード",
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
@@ -423,7 +422,7 @@
"cost": "コスト",
"perMillion": "(100万あたり)",
"trends": "利用トレンド",
"rangeToday": "今日 (時間別)",
"rangeToday": "直近24時間 (時間別)",
"rangeLast7Days": "過去7日間",
"rangeLast30Days": "過去30日間",
"totalTokens": "総トークン数",
@@ -436,8 +435,8 @@
"billingModel": "課金モデル",
"inputTokens": "入力",
"outputTokens": "出力",
"cacheReadTokens": "キャッシュ読取",
"cacheCreationTokens": "キャッシュ書込",
"cacheReadTokens": "キャッシュヒット",
"cacheCreationTokens": "キャッシュ作成",
"timingInfo": "応答時間/TTFT",
"status": "ステータス",
"noData": "データなし",
@@ -453,8 +452,8 @@
"displayName": "表示名",
"inputCost": "入力コスト",
"outputCost": "出力コスト",
"cacheReadCost": "キャッシュ読取",
"cacheWriteCost": "キャッシュ書込",
"cacheReadCost": "キャッシュヒット",
"cacheWriteCost": "キャッシュ作成",
"deleteConfirmTitle": "削除の確認",
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
"queryFailed": "照会に失敗しました",
@@ -481,8 +480,8 @@
"timeRange": "期間",
"input": "Input",
"output": "Output",
"cacheWrite": "Write",
"cacheRead": "Read"
"cacheWrite": "作成",
"cacheRead": "ヒット"
},
"usageScript": {
"title": "利用状況を設定",
@@ -565,6 +564,9 @@
},
"mcp": {
"title": "MCP 管理",
"import": "インポート",
"importExisting": "既存をインポート",
"addMcp": "MCPを追加",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
@@ -576,6 +578,8 @@
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ",
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -793,8 +797,8 @@
},
"skills": {
"manage": "Skills",
"title": "Claude スキル管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
"title": "Skills 管理",
"description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張",
"refresh": "更新",
"refreshing": "更新中...",
"repoManager": "リポジトリ管理",
@@ -870,7 +874,25 @@
"installed": "インストール済み",
"uninstalled": "未インストール"
},
"noResults": "一致するスキルが見つかりませんでした"
"noResults": "一致するスキルが見つかりませんでした",
"noInstalled": "インストールされたスキルがありません",
"noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください",
"discover": "スキルを発見",
"import": "既存をインポート",
"importDescription": "CC Switch 統合管理にインポートするスキルを選択してください",
"importSuccess": "{{count}} 件のスキルをインポートしました",
"importSelected": "選択をインポート ({{count}})",
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
"foundIn": "発見場所",
"local": "ローカル",
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。",
"uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください",
"notFound": "スキルが見つかりません",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"deeplink": {
"confirmImport": "プロバイダーのインポートを確認",
@@ -958,7 +980,10 @@
"clickToSelect": "クリックでアイコンを選択"
},
"migration": {
"success": "設定の移行が完了しました"
"success": "設定の移行が完了しました",
"skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました",
"skillsFailed": "スキルの自動インポートに失敗しました",
"skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。"
},
"agents": {
"title": "エージェント"
+37 -12
View File
@@ -343,7 +343,6 @@
"anthropicModel": "主模型",
"anthropicSmallFastModel": "快速模型",
"anthropicReasoningModel": "推理模型 (Thinking)",
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 兼容模式",
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
@@ -423,7 +422,7 @@
"cost": "成本",
"perMillion": "(每百万)",
"trends": "使用趋势",
"rangeToday": "今天 (按小时)",
"rangeToday": "过去 24 小时 (按小时)",
"rangeLast7Days": "过去 7 天",
"rangeLast30Days": "过去 30 天",
"totalTokens": "总 Token 数",
@@ -436,8 +435,8 @@
"billingModel": "计费模型",
"inputTokens": "输入",
"outputTokens": "输出",
"cacheReadTokens": "缓存读取",
"cacheCreationTokens": "缓存写入",
"cacheReadTokens": "缓存命中",
"cacheCreationTokens": "缓存创建",
"timingInfo": "用时/首字",
"status": "状态",
"noData": "暂无数据",
@@ -453,8 +452,8 @@
"displayName": "显示名称",
"inputCost": "输入成本",
"outputCost": "输出成本",
"cacheReadCost": "缓存读取",
"cacheWriteCost": "缓存写入",
"cacheReadCost": "缓存命中",
"cacheWriteCost": "缓存创建",
"deleteConfirmTitle": "确认删除",
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
"queryFailed": "查询失败",
@@ -481,8 +480,8 @@
"timeRange": "时间范围",
"input": "Input",
"output": "Output",
"cacheWrite": "Write",
"cacheRead": "Read"
"cacheWrite": "创建",
"cacheRead": "命中"
},
"usageScript": {
"title": "配置用量查询",
@@ -565,6 +564,9 @@
},
"mcp": {
"title": "MCP 管理",
"import": "导入",
"importExisting": "导入已有",
"addMcp": "添加MCP",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
@@ -576,6 +578,8 @@
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
"noServers": "暂无服务器",
"enabledApps": "启用的应用",
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -793,8 +797,8 @@
},
"skills": {
"manage": "Skills",
"title": "Claude Skills 管理",
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
"title": "Skills 管理",
"description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力",
"refresh": "刷新",
"refreshing": "刷新中...",
"repoManager": "仓库管理",
@@ -870,7 +874,25 @@
"installed": "已安装",
"uninstalled": "未安装"
},
"noResults": "未找到匹配的技能"
"noResults": "未找到匹配的技能",
"noInstalled": "暂无已安装的技能",
"noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能",
"discover": "发现技能",
"import": "导入已有",
"importDescription": "选择要导入到 CC Switch 统一管理的技能",
"importSuccess": "成功导入 {{count}} 个技能",
"importSelected": "导入已选 ({{count}})",
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
"foundIn": "发现于",
"local": "本地",
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。",
"uninstallInMainPanel": "请在主面板中卸载技能",
"notFound": "未找到技能",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"deeplink": {
"confirmImport": "确认导入供应商配置",
@@ -958,7 +980,10 @@
"clickToSelect": "点击选择图标"
},
"migration": {
"success": "配置迁移成功"
"success": "配置迁移成功",
"skillsSuccess": "已自动导入 {{count}} 个技能到统一管理",
"skillsFailed": "自动导入技能失败",
"skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。"
},
"agents": {
"title": "智能体"
+7
View File
@@ -119,4 +119,11 @@ export const mcpApi = {
): Promise<void> {
return await invoke("toggle_mcp_app", { serverId, app, enabled });
},
/**
* 从所有应用导入 MCP 服务器
*/
async importFromApps(): Promise<number> {
return await invoke("import_mcp_from_apps");
},
};
+102 -1
View File
@@ -1,5 +1,51 @@
import { invoke } from "@tauri-apps/api/core";
// ========== 类型定义 ==========
export type AppType = "claude" | "codex" | "gemini";
/** Skill 应用启用状态 */
export interface SkillApps {
claude: boolean;
codex: boolean;
gemini: boolean;
}
/** 已安装的 Skillv3.10.0+ 统一结构) */
export interface InstalledSkill {
id: string;
name: string;
description?: string;
directory: string;
repoOwner?: string;
repoName?: string;
repoBranch?: string;
readmeUrl?: string;
apps: SkillApps;
installedAt: number;
}
/** 可发现的 Skill(来自仓库) */
export interface DiscoverableSkill {
key: string;
name: string;
description: string;
directory: string;
readmeUrl?: string;
repoOwner: string;
repoName: string;
repoBranch: string;
}
/** 未管理的 Skill(用于导入) */
export interface UnmanagedSkill {
directory: string;
name: string;
description?: string;
foundIn: string[];
}
/** 技能对象(兼容旧 API) */
export interface Skill {
key: string;
name: string;
@@ -12,6 +58,7 @@ export interface Skill {
repoBranch?: string;
}
/** 仓库配置 */
export interface SkillRepo {
owner: string;
name: string;
@@ -19,9 +66,56 @@ export interface SkillRepo {
enabled: boolean;
}
export type AppType = "claude" | "codex" | "gemini";
// ========== API ==========
export const skillsApi = {
// ========== 统一管理 API (v3.10.0+) ==========
/** 获取所有已安装的 Skills */
async getInstalled(): Promise<InstalledSkill[]> {
return await invoke("get_installed_skills");
},
/** 安装 Skill(统一安装) */
async installUnified(
skill: DiscoverableSkill,
currentApp: AppType,
): Promise<InstalledSkill> {
return await invoke("install_skill_unified", { skill, currentApp });
},
/** 卸载 Skill(统一卸载) */
async uninstallUnified(id: string): Promise<boolean> {
return await invoke("uninstall_skill_unified", { id });
},
/** 切换 Skill 的应用启用状态 */
async toggleApp(
id: string,
app: AppType,
enabled: boolean,
): Promise<boolean> {
return await invoke("toggle_skill_app", { id, app, enabled });
},
/** 扫描未管理的 Skills */
async scanUnmanaged(): Promise<UnmanagedSkill[]> {
return await invoke("scan_unmanaged_skills");
},
/** 从应用目录导入 Skills */
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
return await invoke("import_skills_from_apps", { directories });
},
/** 发现可安装的 Skills(从仓库获取) */
async discoverAvailable(): Promise<DiscoverableSkill[]> {
return await invoke("discover_available_skills");
},
// ========== 兼容旧 API ==========
/** 获取技能列表(兼容旧 API) */
async getAll(app: AppType = "claude"): Promise<Skill[]> {
if (app === "claude") {
return await invoke("get_skills");
@@ -29,6 +123,7 @@ export const skillsApi = {
return await invoke("get_skills_for_app", { app });
},
/** 安装技能(兼容旧 API) */
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
if (app === "claude") {
return await invoke("install_skill", { directory });
@@ -36,6 +131,7 @@ export const skillsApi = {
return await invoke("install_skill_for_app", { app, directory });
},
/** 卸载技能(兼容旧 API) */
async uninstall(
directory: string,
app: AppType = "claude",
@@ -46,14 +142,19 @@ export const skillsApi = {
return await invoke("uninstall_skill_for_app", { app, directory });
},
// ========== 仓库管理 ==========
/** 获取仓库列表 */
async getRepos(): Promise<SkillRepo[]> {
return await invoke("get_skill_repos");
},
/** 添加仓库 */
async addRepo(repo: SkillRepo): Promise<boolean> {
return await invoke("add_skill_repo", { repo });
},
/** 删除仓库 */
async removeRepo(owner: string, name: string): Promise<boolean> {
return await invoke("remove_skill_repo", { owner, name });
},
+5 -2
View File
@@ -49,8 +49,11 @@ export const usageApi = {
return invoke("get_usage_summary", { startDate, endDate });
},
getUsageTrends: async (days: number): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { days });
getUsageTrends: async (
startDate?: number,
endDate?: number,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate });
},
getProviderStats: async (): Promise<ProviderStats[]> => {
+27 -6
View File
@@ -5,8 +5,7 @@ import type { LogFilters } from "@/types/usage";
// Query keys
export const usageKeys = {
all: ["usage"] as const,
summary: (startDate?: number, endDate?: number) =>
[...usageKeys.all, "summary", startDate, endDate] as const,
summary: (days: number) => [...usageKeys.all, "summary", days] as const,
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const,
@@ -19,18 +18,34 @@ export const usageKeys = {
[...usageKeys.all, "limits", providerId, appType] as const,
};
const getWindow = (days: number) => {
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - days * 24 * 60 * 60;
return { startDate, endDate };
};
// Hooks
export function useUsageSummary(startDate?: number, endDate?: number) {
export function useUsageSummary(days: number) {
return useQuery({
queryKey: usageKeys.summary(startDate, endDate),
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
queryKey: usageKeys.summary(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新
});
}
export function useUsageTrends(days: number) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => usageApi.getUsageTrends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}
@@ -38,6 +53,8 @@ export function useProviderStats() {
return useQuery({
queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}
@@ -45,6 +62,8 @@ export function useModelStats() {
return useQuery({
queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}
@@ -56,6 +75,8 @@ export function useRequestLogs(
return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}