feat(skill): add multi-app skill support for Claude/Codex (#365)

* feat(skill): add multi-app skill support for Claude/Codex/Gemini

- Add app-specific skill management with AppType prefix in skill keys
- Implement per-app skill tracking in database schema
- Add get_skills_for_app command to retrieve skills by application
- Update SkillsPage to support app-specific skill loading with initialApp prop
- Parse app parameter and validate against supported app types
- Maintain backward compatibility with default claude app

* fix(usage): reorder cache columns and prevent header text wrapping

- Swap cache read and cache write columns order
- Add whitespace-nowrap to all table headers to prevent text wrapping
- Improves table readability and layout consistency
This commit is contained in:
YoVinchen
2025-12-08 20:54:17 +08:00
committed by GitHub
parent 622a24ded4
commit 1fb2d5ed44
10 changed files with 298 additions and 83 deletions
+14 -17
View File
@@ -24,7 +24,6 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -65,7 +64,8 @@ function App() {
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// Skills 功能仅支持 Claude 和 Codex
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -291,6 +291,7 @@ function App() {
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
case "mcp":
@@ -478,21 +479,17 @@ function App() {
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<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",
isClaudeApp
? "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="h-4 w-4 flex-shrink-0" />
</Button>
{hasSkillsSupport && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
+14 -5
View File
@@ -19,11 +19,17 @@ import { RefreshCw, Search } from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
import {
skillsApi,
type Skill,
type SkillRepo,
type AppType,
} from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
onClose?: () => void;
initialApp?: AppType;
}
export interface SkillsPageHandle {
@@ -32,7 +38,7 @@ export interface SkillsPageHandle {
}
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose }, ref) => {
({ onClose: _onClose, initialApp = "claude" }, ref) => {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
@@ -42,11 +48,13 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
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();
const data = await skillsApi.getAll(selectedApp);
setSkills(data);
if (afterLoad) {
afterLoad(data);
@@ -84,6 +92,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(ref, () => ({
@@ -93,7 +102,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory);
await skillsApi.install(directory, selectedApp);
toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills();
} catch (error) {
@@ -122,7 +131,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory);
await skillsApi.uninstall(directory, selectedApp);
toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills();
} catch (error) {
+20 -14
View File
@@ -193,30 +193,36 @@ export function RequestLogTable() {
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.time", "时间")}</TableHead>
<TableHead>{t("usage.provider", "供应商")}</TableHead>
<TableHead className="min-w-[280px]">
<TableHead className="whitespace-nowrap">
{t("usage.time", "时间")}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("usage.provider", "供应商")}
</TableHead>
<TableHead className="min-w-[280px] whitespace-nowrap">
{t("usage.billingModel", "计费模型")}
</TableHead>
<TableHead className="text-right">
<TableHead className="text-right whitespace-nowrap">
{t("usage.inputTokens", "输入")}
</TableHead>
<TableHead className="text-right">
<TableHead className="text-right whitespace-nowrap">
{t("usage.outputTokens", "输出")}
</TableHead>
<TableHead className="text-right min-w-[90px]">
{t("usage.cacheCreationTokens", "缓存写入")}
</TableHead>
<TableHead className="text-right min-w-[90px]">
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheReadTokens", "缓存读取")}
</TableHead>
<TableHead className="text-right">
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens", "缓存写入")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.totalCost", "成本")}
</TableHead>
<TableHead className="text-center min-w-[140px]">
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
{t("usage.timingInfo", "用时/首字")}
</TableHead>
<TableHead>{t("usage.status", "状态")}</TableHead>
<TableHead className="whitespace-nowrap">
{t("usage.status", "状态")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -252,10 +258,10 @@ export function RequestLogTable() {
{log.outputTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
{log.cacheReadTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()}
{log.cacheCreationTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}
+20 -6
View File
@@ -19,17 +19,31 @@ export interface SkillRepo {
enabled: boolean;
}
export type AppType = "claude" | "codex" | "gemini";
export const skillsApi = {
async getAll(): Promise<Skill[]> {
return await invoke("get_skills");
async getAll(app: AppType = "claude"): Promise<Skill[]> {
if (app === "claude") {
return await invoke("get_skills");
}
return await invoke("get_skills_for_app", { app });
},
async install(directory: string): Promise<boolean> {
return await invoke("install_skill", { directory });
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
if (app === "claude") {
return await invoke("install_skill", { directory });
}
return await invoke("install_skill_for_app", { app, directory });
},
async uninstall(directory: string): Promise<boolean> {
return await invoke("uninstall_skill", { directory });
async uninstall(
directory: string,
app: AppType = "claude",
): Promise<boolean> {
if (app === "claude") {
return await invoke("uninstall_skill", { directory });
}
return await invoke("uninstall_skill_for_app", { app, directory });
},
async getRepos(): Promise<SkillRepo[]> {
+16 -12
View File
@@ -36,9 +36,10 @@ export const useAddProviderMutation = (appId: AppId) => {
toast.success(
t("notifications.providerAdded", {
defaultValue: "供应商已添加",
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
@@ -66,9 +67,10 @@ export const useUpdateProviderMutation = (appId: AppId) => {
toast.success(
t("notifications.updateSuccess", {
defaultValue: "供应商更新成功",
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
@@ -106,9 +108,10 @@ export const useDeleteProviderMutation = (appId: AppId) => {
toast.success(
t("notifications.deleteSuccess", {
defaultValue: "供应商已删除",
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
@@ -147,9 +150,10 @@ export const useSwitchProviderMutation = (appId: AppId) => {
t("notifications.switchSuccess", {
defaultValue: "切换供应商成功",
appName: t(`apps.${appId}`, { defaultValue: appId }),
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {