Merge branch 'main' into feat/gemini-proxy-integration

# Conflicts:
#	src-tauri/src/proxy/providers/claude.rs
#	src-tauri/src/proxy/sse.rs
#	src-tauri/src/services/stream_check.rs
This commit is contained in:
YoVinchen
2026-04-10 09:08:39 +08:00
134 changed files with 12934 additions and 1095 deletions
+20 -11
View File
@@ -40,7 +40,12 @@ import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { cn } from "@/lib/utils";
import { isWindows, isLinux } from "@/lib/platform";
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";
@@ -57,6 +62,7 @@ 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";
@@ -876,11 +882,13 @@ function App() {
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
>
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
{DRAG_BAR_HEIGHT > 0 && (
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
)}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
@@ -908,10 +916,10 @@ function App() {
<header
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
data-tauri-drag-region
{...DRAG_REGION_ATTR}
style={
{
WebkitAppRegion: "drag",
...DRAG_REGION_STYLE,
top: DRAG_BAR_HEIGHT,
height: HEADER_HEIGHT,
} as any
@@ -919,8 +927,8 @@ function App() {
>
<div
className="flex h-full items-center justify-between gap-2 px-6"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
{...DRAG_REGION_ATTR}
style={{ ...DRAG_REGION_STYLE } as any}
>
<div
className="flex items-center gap-1"
@@ -1035,7 +1043,7 @@ function App() {
)}
<div
ref={toolbarRef}
className="flex flex-1 min-w-0 overflow-x-hidden items-center"
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"
@@ -1347,6 +1355,7 @@ function App() {
/>
<DeepLinkImportDialog />
<FirstRunNoticeDialog />
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
import type { ProviderMeta } from "@/types";
import { useCodexOauthQuota } from "@/lib/query/subscription";
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
interface CodexOauthQuotaFooterProps {
meta?: ProviderMeta;
inline?: boolean;
/** 是否为当前激活的供应商 */
isCurrent?: boolean;
}
/**
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度 footer
*
* 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。
* 数据源切换为 cc-switch 自管的 OAuth token 而非 Codex CLI 凭据。
*/
const CodexOauthQuotaFooter: React.FC<CodexOauthQuotaFooterProps> = ({
meta,
inline = false,
isCurrent = false,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useCodexOauthQuota(meta, { enabled: true, autoQuery: isCurrent });
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={inline}
/>
);
};
export default CodexOauthQuotaFooter;
+189
View File
@@ -0,0 +1,189 @@
import React from "react";
import { RefreshCw, AlertCircle, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProviderMeta } from "@/types";
import { useCopilotQuota } from "@/lib/query/copilot";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { PROVIDER_TYPES } from "@/config/constants";
import {
TierBadge,
utilizationColor,
} from "@/components/SubscriptionQuotaFooter";
interface CopilotQuotaFooterProps {
meta?: ProviderMeta;
inline?: boolean;
/** 是否为当前激活的供应商 */
isCurrent?: boolean;
}
/** 格式化相对时间 */
function formatRelativeTime(
timestamp: number,
now: number,
t: (key: string, options?: { count?: number }) => string,
): string {
const diff = Math.floor((now - timestamp) / 1000);
if (diff < 60) return t("usage.justNow");
if (diff < 3600)
return t("usage.minutesAgo", { count: Math.floor(diff / 60) });
if (diff < 86400)
return t("usage.hoursAgo", { count: Math.floor(diff / 3600) });
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
}
const CopilotQuotaFooter: React.FC<CopilotQuotaFooterProps> = ({
meta,
inline = false,
isCurrent = false,
}) => {
const { t } = useTranslation();
const accountId = resolveManagedAccountId(
meta,
PROVIDER_TYPES.GITHUB_COPILOT,
);
const {
data: quota,
isFetching: loading,
refetch,
} = useCopilotQuota(accountId, { enabled: true, autoQuery: isCurrent });
const [now, setNow] = React.useState(Date.now());
React.useEffect(() => {
if (!quota?.queriedAt) return;
const interval = setInterval(() => setNow(Date.now()), 30000);
return () => clearInterval(interval);
}, [quota?.queriedAt]);
if (!quota) return null;
// API 调用失败
if (!quota.success) {
if (inline) {
return (
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
<AlertCircle size={12} />
<span>{quota.error || t("subscription.queryFailed")}</span>
</div>
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
);
}
return null;
}
const tiers = quota.tiers;
if (tiers.length === 0) return null;
if (inline) {
return (
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
<div className="flex items-center gap-2 justify-end">
{quota.plan && (
<span className="text-[10px] text-muted-foreground/70">
{quota.plan}
</span>
)}
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{quota.queriedAt
? formatRelativeTime(quota.queriedAt, now, t)
: t("usage.never", { defaultValue: "Never" })}
</span>
<button
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
<div className="flex items-center gap-2">
{tiers.map((tier) => (
<TierBadge key={tier.name} tier={tier} t={t} />
))}
</div>
</div>
);
}
// 展开模式
return (
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{quota.plan || t("subscription.title")}
</span>
<div className="flex items-center gap-2">
{quota.queriedAt && (
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(quota.queriedAt, now, t)}
</span>
)}
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
{tiers.map((tier) => {
const label = t("subscription.copilotPremium", {
defaultValue: "Premium",
});
return (
<div key={tier.name} className="flex items-center gap-3 text-xs">
<span
className="text-gray-500 dark:text-gray-400 min-w-0 font-medium"
style={{ width: "25%" }}
>
{label}
</span>
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
tier.utilization >= 90
? "bg-red-500"
: tier.utilization >= 70
? "bg-orange-500"
: "bg-green-500"
}`}
style={{
width: `${Math.min(tier.utilization, 100)}%`,
}}
/>
</div>
<span
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
>
{Math.round(tier.utilization)}%
</span>
</div>
);
})}
</div>
</div>
);
};
export default CopilotQuotaFooter;
+67
View File
@@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useSettingsQuery } from "@/lib/query";
import { settingsApi } from "@/lib/api";
/** 首次运行欢迎提示:仅当后端启动阶段保留 firstRunNoticeConfirmed 为空时弹出。 */
export function FirstRunNoticeDialog() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: settings } = useSettingsQuery();
// 后端启动时已经决定好要不要弹:条件不满足的话字段会立即被写成 true,
// 所以前端这里只需要判空即可——完全对齐 streamCheckConfirmed 等既有 flag 的模式。
const isOpen = settings != null && settings.firstRunNoticeConfirmed !== true;
const handleAcknowledge = async () => {
if (!settings) return;
try {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, firstRunNoticeConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
console.error("Failed to save firstRunNoticeConfirmed:", error);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) void handleAcknowledge();
}}
>
<DialogContent className="max-w-md" zIndex="top">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-blue-500" />
{t("firstRunNotice.title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 px-6 py-5">
<DialogDescription className="whitespace-pre-line leading-relaxed">
{t("firstRunNotice.bodyDefault")}
</DialogDescription>
<DialogDescription className="whitespace-pre-line leading-relaxed">
{t("firstRunNotice.bodyOfficial")}
</DialogDescription>
</div>
<DialogFooter>
<Button onClick={handleAcknowledge}>
{t("firstRunNotice.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+55 -9
View File
@@ -3,11 +3,21 @@ import { RefreshCw, AlertCircle, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
import { useSubscriptionQuota } from "@/lib/query/subscription";
import type { QuotaTier } from "@/types/subscription";
import type { QuotaTier, SubscriptionQuota } from "@/types/subscription";
interface SubscriptionQuotaFooterProps {
appId: AppId;
inline?: boolean;
isCurrent?: boolean;
}
interface SubscriptionQuotaViewProps {
quota: SubscriptionQuota | undefined;
loading: boolean;
refetch: () => void;
/** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */
appIdForExpiredHint: string;
inline?: boolean;
}
/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */
@@ -22,6 +32,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
gemini_flash_lite: "subscription.geminiFlashLite",
// Token Planfive_hour 已在上方官方映射中)
weekly_limit: "subscription.weeklyLimit",
// GitHub Copilot
premium: "subscription.copilotPremium",
};
/** 根据使用百分比返回颜色 class */
@@ -76,16 +88,22 @@ function formatRelativeTime(
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
}
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
appId,
/**
* 纯展示组件:渲染 SubscriptionQuota 的 5 种状态(not_found / parse_error /
* expired / API 失败 / 成功),支持 inline / expanded 两种布局。
*
* 数据源由调用方 hook 注入,方便不同的额度后端复用同一套渲染逻辑:
* - `SubscriptionQuotaFooter`CLI 凭据路径,by appId
* - `CodexOauthQuotaFooter`cc-switch 自管 OAuth 路径,by ChatGPT account
*/
export const SubscriptionQuotaView: React.FC<SubscriptionQuotaViewProps> = ({
quota,
loading,
refetch,
appIdForExpiredHint,
inline = false,
}) => {
const { t } = useTranslation();
const {
data: quota,
isFetching: loading,
refetch,
} = useSubscriptionQuota(appId, true);
// 定期更新相对时间显示
const [now, setNow] = React.useState(Date.now());
@@ -129,7 +147,7 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
<div>
<span className="font-medium">{t("subscription.expired")}</span>
<span className="ml-2 text-amber-500/70 dark:text-amber-400/70">
{t("subscription.expiredHint", { tool: appId })}
{t("subscription.expiredHint", { tool: appIdForExpiredHint })}
</span>
</div>
</div>
@@ -362,4 +380,32 @@ const TierBar: React.FC<{
);
};
/**
* CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据
* 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。
*/
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
appId,
inline = false,
isCurrent = false,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useSubscriptionQuota(appId, isCurrent, isCurrent);
if (!isCurrent) return null;
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint={appId}
inline={inline}
/>
);
};
export default SubscriptionQuotaFooter;
+115 -7
View File
@@ -98,6 +98,9 @@ const generatePresetTemplates = (
// Coding Plan 模板不需要脚本,使用专用 Rust 查询
[TEMPLATE_TYPES.TOKEN_PLAN]: "",
// 官方余额查询模板不需要脚本,使用专用 Rust 查询
[TEMPLATE_TYPES.BALANCE]: "",
});
// 模板名称国际化键映射
@@ -107,6 +110,7 @@ const TEMPLATE_NAME_KEYS: Record<string, string> = {
[TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI",
[TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot",
[TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan",
[TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance",
};
/** Coding Plan 供应商选项 */
@@ -124,6 +128,25 @@ const TOKEN_PLAN_PROVIDERS = [
},
] as const;
/** 官方余额查询供应商检测 */
const BALANCE_PROVIDERS = [
{ id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i },
{ id: "stepfun", label: "StepFun", pattern: /api\.stepfun\.(ai|com)/i },
{
id: "siliconflow",
label: "SiliconFlow",
pattern: /api\.siliconflow\.(cn|com)/i,
},
{ id: "openrouter", label: "OpenRouter", pattern: /openrouter\.ai/i },
{ id: "novita", label: "Novita AI", pattern: /api\.novita\.ai/i },
] as const;
/** 根据 Base URL 自动检测余额查询供应商 */
function detectBalanceProvider(baseUrl: string | undefined): boolean {
if (!baseUrl) return false;
return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl));
}
/** 根据 Base URL 自动检测 Coding Plan 供应商 */
function detectTokenPlanProvider(baseUrl: string | undefined): string | null {
if (!baseUrl) return null;
@@ -215,15 +238,28 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
language: "javascript" as const,
code: "",
timeout: 10,
autoQueryInterval: 5,
codingPlanProvider: autoDetected,
};
}
// 新配置:如果 URL 匹配官方余额查询供应商,自动初始化
if (detectBalanceProvider(providerCredentials.baseUrl)) {
return {
enabled: false,
language: "javascript" as const,
code: "",
timeout: 10,
autoQueryInterval: 5,
};
}
return {
enabled: false,
language: "javascript" as const,
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
timeout: 10,
autoQueryInterval: 5,
};
});
@@ -300,6 +336,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
if (detectTokenPlanProvider(providerCredentials.baseUrl)) {
return TEMPLATE_TYPES.TOKEN_PLAN;
}
// 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板
if (detectBalanceProvider(providerCredentials.baseUrl)) {
return TEMPLATE_TYPES.BALANCE;
}
// 默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_TYPES.GENERAL;
},
@@ -331,10 +371,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
const handleSave = () => {
// CopilotCoding Plan 模板不需要脚本验证
// CopilotCoding Plan、Balance 模板不需要脚本验证
if (
selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN &&
selectedTemplate !== TEMPLATE_TYPES.BALANCE
) {
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
@@ -354,6 +395,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
| "newapi"
| "github_copilot"
| "token_plan"
| "balance"
| undefined,
};
onSave(scriptWithTemplate);
@@ -363,6 +405,37 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// 官方余额查询模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.BALANCE) {
const config = provider.settingsConfig as Record<string, any>;
const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? "";
const apiKey: string =
config?.env?.ANTHROPIC_AUTH_TOKEN ??
config?.env?.ANTHROPIC_API_KEY ??
"";
const { subscriptionApi } = await import("@/lib/api/subscription");
const result = await subscriptionApi.getBalance(baseUrl, apiKey);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((d) => {
const name = d.planName ? `[${d.planName}] ` : "";
return `${name}${t("usage.remaining")} ${d.remaining?.toFixed(2)} ${d.unit || ""}`;
})
.join(", ");
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
{ duration: 5000 },
);
}
return;
}
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
const config = provider.settingsConfig as Record<string, any>;
@@ -558,6 +631,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
codingPlanProvider:
script.codingPlanProvider || autoDetected || "kimi",
});
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
// 官方余额查询模板不需要脚本,使用 Rust 原生查询
setScript({
...script,
code: "",
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
}
setSelectedTemplate(presetName);
}
@@ -746,6 +829,27 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
)}
{/* 官方余额查询模式:自动提示 */}
{selectedTemplate === TEMPLATE_TYPES.BALANCE && (
<div className="space-y-3 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.balanceHint")}
</p>
<div className="flex gap-2 flex-wrap">
{BALANCE_PROVIDERS.filter((bp) =>
bp.pattern.test(providerCredentials.baseUrl || ""),
).map((bp) => (
<span
key={bp.id}
className="inline-flex items-center px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium"
>
{bp.label}
</span>
))}
</div>
</div>
)}
{/* Coding Plan 模式:供应商选择 */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
<div className="space-y-3 border-t border-white/10 pt-3">
@@ -960,7 +1064,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
timeout:
e.target.value === ""
? ("" as unknown as number)
: Number(e.target.value),
})
}
onBlur={(e) =>
@@ -984,14 +1091,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
min={0}
max={1440}
value={
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 5
}
onChange={(e) =>
setScript({
...script,
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
autoQueryInterval:
e.target.value === ""
? ("" as unknown as number)
: Number(e.target.value),
})
}
onBlur={(e) =>
+23 -15
View File
@@ -3,7 +3,12 @@ import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isWindows, isLinux } from "@/lib/platform";
import {
isWindows,
isLinux,
DRAG_REGION_ATTR,
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { isTextEditableTarget } from "@/utils/domUtils";
interface FullScreenPanelProps {
@@ -82,24 +87,27 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Drag region - match App.tsx */}
<div
data-tauri-drag-region
style={
{
WebkitAppRegion: "drag",
height: DRAG_BAR_HEIGHT,
} as React.CSSProperties
}
/>
{/* Drag region - match App.tsx. Linux 上 DRAG_BAR_HEIGHT=0
直接跳过整个元素;macOS 保留 28px 拖拽占位。 */}
{DRAG_BAR_HEIGHT > 0 && (
<div
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"
data-tauri-drag-region
{...DRAG_REGION_ATTR}
style={
{
WebkitAppRegion: "drag",
...DRAG_REGION_STYLE,
backgroundColor: "hsl(var(--background))",
height: HEADER_HEIGHT,
} as React.CSSProperties
@@ -107,8 +115,8 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
>
<div
className="px-6 w-full flex items-center gap-4"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
{...DRAG_REGION_ATTR}
style={{ ...DRAG_REGION_STYLE } as React.CSSProperties}
>
<Button
type="button"
+30 -27
View File
@@ -246,34 +246,37 @@ export function ProviderActions({
<Copy className="h-4 w-4" />
</Button>
{onTest && (
<Button
size="icon"
variant="ghost"
onClick={onTest}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
className={iconButtonClass}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={onTest || undefined}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
className={cn(
iconButtonClass,
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
{onConfigureUsage && (
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage}
title={t("provider.configureUsage")}
className={iconButtonClass}
>
<BarChart3 className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage || undefined}
title={t("provider.configureUsage")}
className={cn(
iconButtonClass,
!onConfigureUsage &&
"opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<BarChart3 className="h-4 w-4" />
</Button>
{onOpenTerminal && (
<Button
+32 -4
View File
@@ -12,6 +12,9 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
import { PROVIDER_TYPES } from "@/config/constants";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
@@ -172,6 +175,11 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const isOfficial = isOfficialProvider(provider, appId);
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
const isCodexOauth =
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
@@ -348,8 +356,24 @@ export function ProviderCard({
<div className="flex items-center ml-auto min-w-0 gap-3">
<div className="ml-auto">
<div className="flex items-center gap-1">
{isOfficial ? (
<SubscriptionQuotaFooter appId={appId} inline={true} />
{isCopilot ? (
<CopilotQuotaFooter
meta={provider.meta}
inline={true}
isCurrent={isCurrent}
/>
) : isCodexOauth ? (
<CodexOauthQuotaFooter
meta={provider.meta}
inline={true}
isCurrent={isCurrent}
/>
) : isOfficial ? (
<SubscriptionQuotaFooter
appId={appId}
inline={true}
isCurrent={isCurrent}
/>
) : hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
@@ -405,10 +429,14 @@ export function ProviderCard({
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onTest={
onTest && !isOfficial ? () => onTest(provider) : undefined
onTest && !isOfficial && !isCopilot && !isCodexOauth
? () => onTest(provider)
: undefined
}
onConfigureUsage={
isOfficial ? undefined : () => onConfigureUsage(provider)
isOfficial || isCopilot || isCodexOauth
? undefined
: () => onConfigureUsage(provider)
}
onDelete={() => onDelete(provider)}
onRemoveFromConfig={
+1 -5
View File
@@ -348,11 +348,7 @@ export function ProviderList({
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={
appId !== "opencode" && appId !== "openclaw"
? handleTest
: undefined
}
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
@@ -28,6 +28,7 @@ import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
import { CopilotAuthSection } from "./CopilotAuthSection";
import { CodexOAuthSection } from "./CodexOAuthSection";
import {
copilotGetModels,
copilotGetModelsForAccount,
@@ -70,6 +71,12 @@ interface ClaudeFormFieldsProps {
/** GitHub 账号选择回调(多账号支持) */
onGitHubAccountSelect?: (accountId: string | null) => void;
// Codex OAuth (ChatGPT Plus/Pro)
isCodexOauthPreset?: boolean;
isCodexOauthAuthenticated?: boolean;
selectedCodexAccountId?: string | null;
onCodexAccountSelect?: (accountId: string | null) => void;
// Template Values
templateValueEntries: Array<[string, TemplateValueConfig]>;
templateValues: Record<string, TemplateValueConfig>;
@@ -134,6 +141,9 @@ export function ClaudeFormFields({
isCopilotAuthenticated,
selectedGitHubAccountId,
onGitHubAccountSelect,
isCodexOauthPreset,
selectedCodexAccountId,
onCodexAccountSelect,
templateValueEntries,
templateValues,
templatePresetName,
@@ -357,6 +367,14 @@ export function ClaudeFormFields({
/>
)}
{/* Codex OAuth 认证 (ChatGPT Plus/Pro) */}
{isCodexOauthPreset && (
<CodexOAuthSection
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
/>
)}
{/* API Key 输入框(非 OAuth 预设时显示) */}
{shouldShowApiKey && !usesOAuth && (
<ApiKeySection
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Save, Download, Loader2 } from "lucide-react";
import { Save, Download, Loader2, Package } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
@@ -100,9 +100,35 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<div className="rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/30 p-3 space-y-1.5">
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">
{t("commonConfig.guideTitle")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guidePurpose")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guideUsage")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guideReExtract")}
</p>
<p className="text-xs text-muted-foreground">
{t("commonConfig.guideReassurance")}
</p>
</div>
<p className="text-xs text-muted-foreground">
{t("codexConfig.commonConfigHint")}
</p>
{(!draftValue || draftValue.trim() === "") && (
<div className="flex flex-col items-center justify-center py-6 text-center text-muted-foreground">
<Package className="h-8 w-8 mb-2 opacity-40" />
<p className="text-sm font-medium">
{t("commonConfig.emptyTitle")}
</p>
<p className="text-xs mt-1">{t("commonConfig.emptyHint")}</p>
</div>
)}
<JsonEditor
value={draftValue}
@@ -0,0 +1,325 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Loader2,
LogOut,
Copy,
Check,
ExternalLink,
Plus,
X,
Sparkles,
User,
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
interface CodexOAuthSectionProps {
className?: string;
/** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
}
/**
* Codex OAuth 认证区块
*
* 通过 OpenAI Device Code 流程登录 ChatGPT Plus/Pro 账号,
* 用于将 Claude Code 请求反代到 Codex 后端 API。
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
selectedAccountId,
onAccountSelect,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const {
accounts,
defaultAccountId,
hasAnyAccount,
pollingState,
deviceCode,
error,
isPolling,
isAddingAccount,
isRemovingAccount,
isSettingDefaultAccount,
addAccount,
removeAccount,
setDefaultAccount,
cancelAuth,
logout,
} = useCodexOauth();
const copyUserCode = async () => {
if (deviceCode?.user_code) {
await copyText(deviceCode.user_code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleAccountSelect = (value: string) => {
onAccountSelect?.(value === "none" ? null : value);
};
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
removeAccount(accountId);
if (selectedAccountId === accountId) {
onAccountSelect?.(null);
}
};
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
{/* 账号选择器 */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.loggedInAccounts", "已登录账号")}
</Label>
<div className="space-y-1">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("codexOauth.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 未认证 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
</Button>
)}
{/* 已有账号 - 添加更多按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("codexOauth.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("codexOauth.waitingForAuth", "等待授权中...")}
</div>
<div className="text-center">
<p className="text-xs text-muted-foreground mb-1">
{t("codexOauth.enterCode", "在浏览器中输入以下代码:")}
</p>
<div className="flex items-center justify-center gap-2">
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
{deviceCode.user_code}
</code>
<Button
type="button"
size="icon"
variant="ghost"
onClick={copyUserCode}
title={t("codexOauth.copyCode", "复制代码")}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="text-center">
<a
href={deviceCode.verification_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
>
{deviceCode.verification_uri}
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelAuth}
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
<Button
type="button"
onClick={addAccount}
variant="outline"
size="sm"
>
{t("codexOauth.retry", "重试")}
</Button>
<Button
type="button"
onClick={cancelAuth}
variant="ghost"
size="sm"
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 注销所有账号 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("codexOauth.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
export default CodexOAuthSection;
@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, Download, Loader2 } from "lucide-react";
import { Save, Download, Loader2, Package } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
interface CommonConfigEditorProps {
@@ -298,11 +298,34 @@ export function CommonConfigEditor({
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<div className="rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/30 p-3 space-y-1.5">
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">
{t("commonConfig.guideTitle")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guidePurpose")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guideUsage")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guideReExtract")}
</p>
<p className="text-xs text-muted-foreground">
{t("commonConfig.guideReassurance")}
</p>
</div>
{(!commonConfigSnippet ||
commonConfigSnippet.trim() === "" ||
commonConfigSnippet.trim() === "{}") && (
<div className="flex flex-col items-center justify-center py-6 text-center text-muted-foreground">
<Package className="h-8 w-8 mb-2 opacity-40" />
<p className="text-sm font-medium">
{t("commonConfig.emptyTitle")}
</p>
<p className="text-xs mt-1">{t("commonConfig.emptyHint")}</p>
</div>
)}
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Save, Download, Loader2 } from "lucide-react";
import { Save, Download, Loader2, Package } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
@@ -96,12 +96,40 @@ export const GeminiCommonConfigModal: React.FC<
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<div className="rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/30 p-3 space-y-1.5">
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">
{t("commonConfig.guideTitle")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guidePurpose")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guideUsage")}
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-400/80">
{t("commonConfig.guideReExtract")}
</p>
<p className="text-xs text-muted-foreground">
{t("commonConfig.guideReassurance")}
</p>
</div>
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY",
})}
</p>
{(!draftValue ||
draftValue.trim() === "" ||
draftValue.trim() === "{}") && (
<div className="flex flex-col items-center justify-center py-6 text-center text-muted-foreground">
<Package className="h-8 w-8 mb-2 opacity-40" />
<p className="text-sm font-medium">
{t("commonConfig.emptyTitle")}
</p>
<p className="text-xs mt-1">{t("commonConfig.emptyHint")}</p>
</div>
)}
<JsonEditor
value={draftValue}
File diff suppressed because it is too large Load Diff
@@ -18,3 +18,4 @@ export { useOpencodeFormState } from "./useOpencodeFormState";
export { useOmoDraftState } from "./useOmoDraftState";
export { useOpenclawFormState } from "./useOpenclawFormState";
export { useCopilotAuth } from "./useCopilotAuth";
export { useCodexOauth } from "./useCodexOauth";
@@ -0,0 +1,10 @@
import { useManagedAuth } from "./useManagedAuth";
/**
* Codex OAuth (ChatGPT Plus/Pro) 认证 hook
*
* 复用通用 useManagedAuth,仅指定 provider 为 "codex_oauth"
*/
export function useCodexOauth() {
return useManagedAuth("codex_oauth");
}
+22 -3
View File
@@ -1,7 +1,9 @@
import { Github, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { CodexIcon } from "@/components/BrandIcons";
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
export function AuthCenterPanel() {
const { t } = useTranslation();
@@ -22,7 +24,7 @@ export function AuthCenterPanel() {
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.description", {
defaultValue:
"集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源,不再重复登录。",
"在 Claude Code 中使用您的其他订阅,请注意合规风险。",
})}
</p>
</div>
@@ -41,8 +43,7 @@ export function AuthCenterPanel() {
<h4 className="font-medium">GitHub Copilot</h4>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.copilotDescription", {
defaultValue:
"管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。",
defaultValue: "管理 GitHub Copilot 账号",
})}
</p>
</div>
@@ -50,6 +51,24 @@ export function AuthCenterPanel() {
<CopilotAuthSection />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<CodexIcon size={20} />
</div>
<div>
<h4 className="font-medium">ChatGPT (Codex OAuth)</h4>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.codexOauthDescription", {
defaultValue: "管理 ChatGPT 账号",
})}
</p>
</div>
</div>
<CodexOAuthSection />
</section>
</div>
);
}
+14 -21
View File
@@ -9,7 +9,6 @@ import {
ScrollText,
HardDriveDownload,
FlaskConical,
KeyRound,
} from "lucide-react";
import { toast } from "sonner";
import {
@@ -32,6 +31,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings";
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
@@ -44,6 +44,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import { useInstalledSkills } from "@/hooks/useSkills";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
@@ -96,6 +97,8 @@ export function SettingsPage({
resetStatus,
} = useImportExport({ onImportSuccess });
const { data: installedSkills } = useInstalledSkills();
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
@@ -225,9 +228,12 @@ export function SettingsPage({
settings={settings}
onChange={handleAutoSave}
/>
<WindowSettings
settings={settings}
onChange={handleAutoSave}
<SkillStorageLocationSettings
value={settings.skillStorageLocation ?? "cc_switch"}
installedCount={installedSkills?.length ?? 0}
onMigrated={(location) =>
updateSettings({ skillStorageLocation: location })
}
/>
<SkillSyncMethodSettings
value={settings.skillSyncMethod ?? "auto"}
@@ -235,6 +241,10 @@ export function SettingsPage({
handleAutoSave({ skillSyncMethod: method })
}
/>
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
<TerminalSettings
value={settings.preferredTerminal}
onChange={(terminal) =>
@@ -261,23 +271,6 @@ export function SettingsPage({
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div className="flex items-center gap-3 px-1">
<KeyRound className="h-5 w-5 text-primary" />
<div>
<h2 className="text-base font-semibold">
{t("settings.authCenter.heading", {
defaultValue: "认证中心",
})}
</h2>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.headingDescription", {
defaultValue:
"统一管理可跨应用复用的 OAuth 账号和默认认证来源。",
})}
</p>
</div>
</div>
<AuthCenterPanel />
</motion.div>
</TabsContent>
@@ -0,0 +1,165 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { skillsApi, type MigrationResult } from "@/lib/api/skills";
import type { SkillStorageLocation } from "@/types";
export interface SkillStorageLocationSettingsProps {
value: SkillStorageLocation;
installedCount: number;
onMigrated: (target: SkillStorageLocation) => void;
}
export function SkillStorageLocationSettings({
value,
installedCount,
onMigrated,
}: SkillStorageLocationSettingsProps) {
const { t } = useTranslation();
const [pendingTarget, setPendingTarget] =
useState<SkillStorageLocation | null>(null);
const [isMigrating, setIsMigrating] = useState(false);
const handleSelect = (target: SkillStorageLocation) => {
if (target === value) return;
if (installedCount > 0) {
setPendingTarget(target);
} else {
doMigrate(target);
}
};
const doMigrate = async (target: SkillStorageLocation) => {
setIsMigrating(true);
setPendingTarget(null);
try {
const result: MigrationResult = await skillsApi.migrateStorage(target);
if (result.errors.length > 0) {
toast.warning(
t("settings.skillStorage.migrationPartial", {
migrated: result.migratedCount,
errors: result.errors.length,
}),
);
} else {
toast.success(
t("settings.skillStorage.migrationSuccess", {
count: result.migratedCount,
}),
);
}
onMigrated(target);
} catch (error) {
toast.error(String(error));
} finally {
setIsMigrating(false);
}
};
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.skillStorage.title")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.skillStorage.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<StorageButton
active={value === "cc_switch"}
disabled={isMigrating}
onClick={() => handleSelect("cc_switch")}
>
{t("settings.skillStorage.ccSwitch")}
</StorageButton>
<StorageButton
active={value === "unified"}
disabled={isMigrating}
onClick={() => handleSelect("unified")}
>
{isMigrating && value !== "unified" ? (
<Loader2 size={14} className="mr-1 animate-spin" />
) : null}
{t("settings.skillStorage.unified")}
</StorageButton>
</div>
<p className="text-xs text-muted-foreground">
{value === "unified"
? t("settings.skillStorage.unifiedHint")
: t("settings.skillStorage.ccSwitchHint")}
</p>
{/* 迁移确认对话框 */}
<Dialog
open={pendingTarget !== null}
onOpenChange={(open) => {
if (!open) setPendingTarget(null);
}}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>{t("settings.skillStorage.confirmTitle")}</DialogTitle>
<DialogDescription>
{t("settings.skillStorage.confirmMessage", {
count: installedCount,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingTarget(null)}>
{t("common.cancel")}
</Button>
<Button onClick={() => pendingTarget && doMigrate(pendingTarget)}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
interface StorageButtonProps {
active: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}
function StorageButton({
active,
disabled,
onClick,
children,
}: StorageButtonProps) {
return (
<Button
type="button"
onClick={onClick}
disabled={disabled}
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"min-w-[96px]",
active
? "shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{children}
</Button>
);
}
+25 -6
View File
@@ -20,9 +20,15 @@ interface SkillCardProps {
skill: SkillCardSkill;
onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
installs?: number;
}
export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
export function SkillCard({
skill,
onInstall,
onUninstall,
installs,
}: SkillCardProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -81,6 +87,15 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
{skill.repoOwner}/{skill.repoName}
</Badge>
)}
{typeof installs === "number" && (
<Badge
variant="secondary"
className="shrink-0 text-[10px] px-1.5 py-0 h-4"
>
<Download className="h-2.5 w-2.5 mr-0.5" />
{installs.toLocaleString()}
</Badge>
)}
</div>
</div>
{skill.installed && (
@@ -93,11 +108,15 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
)}
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
<p className="text-sm text-muted-foreground/90 line-clamp-4 leading-relaxed">
{skill.description || t("skills.noDescription")}
</p>
</CardContent>
{skill.description ? (
<CardContent className="flex-1 pt-0">
<p className="text-sm text-muted-foreground/90 line-clamp-4 leading-relaxed">
{skill.description}
</p>
</CardContent>
) : (
<div className="flex-1" />
)}
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
{skill.readmeUrl && (
<Button
+279 -49
View File
@@ -1,4 +1,10 @@
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
import {
useState,
useMemo,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,7 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Search } from "lucide-react";
import { RefreshCw, Search, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
@@ -20,9 +26,14 @@ import {
useSkillRepos,
useAddSkillRepo,
useRemoveSkillRepo,
useSearchSkillsSh,
} from "@/hooks/useSkills";
import type { AppId } from "@/lib/api/types";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import type {
DiscoverableSkill,
SkillRepo,
SkillsShDiscoverableSkill,
} from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
@@ -34,9 +45,13 @@ export interface SkillsPageHandle {
openRepoManager: () => void;
}
type SearchSource = "repos" | "skillssh";
const SKILLSSH_PAGE_SIZE = 20;
/**
* Skills 发现面板
* 用于浏览和安装来自仓库的 Skills
* 用于浏览和安装来自仓库或 skills.sh 的 Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
@@ -48,6 +63,15 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
"all" | "installed" | "uninstalled"
>("all");
// skills.sh 搜索状态
const [searchSource, setSearchSource] = useState<SearchSource>("repos");
const [skillsShInput, setSkillsShInput] = useState("");
const [skillsShQuery, setSkillsShQuery] = useState("");
const [skillsShOffset, setSkillsShOffset] = useState(0);
const [accumulatedResults, setAccumulatedResults] = useState<
SkillsShDiscoverableSkill[]
>([]);
// currentApp 用于安装时的默认应用
const currentApp = initialApp;
@@ -61,6 +85,33 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const { data: installedSkills } = useInstalledSkills();
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
// skills.sh 搜索
const {
data: skillsShResult,
isLoading: loadingSkillsSh,
isFetching: fetchingSkillsSh,
} = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset);
// 当搜索结果返回时累积
useEffect(() => {
if (skillsShResult) {
if (skillsShOffset === 0) {
setAccumulatedResults(skillsShResult.skills);
} else {
setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]);
}
}
}, [skillsShResult, skillsShOffset]);
// 手动提交搜索
const handleSkillsShSearch = () => {
const trimmed = skillsShInput.trim();
if (trimmed.length < 2) return;
setSkillsShOffset(0);
setAccumulatedResults([]);
setSkillsShQuery(trimmed);
};
// Mutations
const installMutation = useInstallSkill();
const addRepoMutation = useAddSkillRepo();
@@ -110,7 +161,16 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
});
}, [discoverableSkills, installedKeys]);
const loading = loadingDiscoverable || fetchingDiscoverable;
// 检查 skills.sh 结果的安装状态
const isSkillsShInstalled = (skill: SkillsShDiscoverableSkill): boolean => {
const key = `${skill.directory.toLowerCase()}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`;
return installedKeys.has(key);
};
const loading =
searchSource === "repos"
? loadingDiscoverable || fetchingDiscoverable
: false;
useImperativeHandle(ref, () => ({
refresh: () => {
@@ -120,13 +180,36 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
openRepoManager: () => setRepoManagerOpen(true),
}));
// skills.sh 结果转为 DiscoverableSkill(复用现有安装流程)
const toDiscoverableSkill = (
s: SkillsShDiscoverableSkill,
): DiscoverableSkill => ({
key: s.key,
name: s.name,
description: "",
directory: s.directory,
repoOwner: s.repoOwner,
repoName: s.repoName,
repoBranch: s.repoBranch,
readmeUrl: s.readmeUrl,
});
const handleInstall = async (directory: string) => {
// 找到对应的 DiscoverableSkill
const skill = discoverableSkills?.find(
(s) =>
s.directory === directory ||
s.directory.split("/").pop() === directory,
);
let skill: DiscoverableSkill | undefined;
if (searchSource === "skillssh") {
const found = accumulatedResults.find((s) => s.directory === directory);
if (found) {
skill = toDiscoverableSkill(found);
}
} else {
skill = discoverableSkills?.find(
(s) =>
s.directory === directory ||
s.directory.split("/").pop() === directory,
);
}
if (!skill) {
toast.error(t("skills.notFound"));
return;
@@ -201,7 +284,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
}
};
// 过滤技能列表
// 过滤技能列表(仓库模式)
const filteredSkills = useMemo(() => {
// 按仓库筛选
const byRepo = skills.filter((skill) => {
@@ -232,35 +315,56 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
});
}, [skills, searchQuery, filterRepo, filterStatus]);
// 是否有更多 skills.sh 结果
const hasMoreSkillsSh =
skillsShResult && accumulatedResults.length < skillsShResult.totalCount;
// 无仓库时默认切换到 skills.sh
const effectiveSource =
searchSource === "repos" && skills.length === 0 && !loading
? "skillssh"
: searchSource;
return (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
{/* 搜索来源切换 + 搜索框 */}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
{/* 来源切换 */}
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1 shrink-0">
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
type="button"
size="sm"
variant={effectiveSource === "repos" ? "default" : "ghost"}
className={
effectiveSource === "repos"
? "shadow-sm min-w-[64px]"
: "text-muted-foreground hover:text-foreground hover:bg-muted min-w-[64px]"
}
onClick={() => setSearchSource("repos")}
>
{t("skills.addRepo")}
{t("skills.searchSource.repos")}
</Button>
<Button
type="button"
size="sm"
variant={effectiveSource === "skillssh" ? "default" : "ghost"}
className={
effectiveSource === "skillssh"
? "shadow-sm min-w-[80px]"
: "text-muted-foreground hover:text-foreground hover:bg-muted min-w-[80px]"
}
onClick={() => setSearchSource("skillssh")}
>
skills.sh
</Button>
</div>
) : (
<>
{/* 搜索框和筛选器 */}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
{effectiveSource === "repos" ? (
<>
{/* 仓库模式搜索框 */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@@ -345,29 +449,155 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
{t("skills.count", { count: filteredSkills.length })}
</p>
)}
</div>
</>
) : (
<>
{/* skills.sh 搜索框 */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("skills.skillssh.searchPlaceholder")}
value={skillsShInput}
onChange={(e) => setSkillsShInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSkillsShSearch();
}}
className="pl-9 pr-3"
/>
</div>
<Button
size="sm"
onClick={handleSkillsShSearch}
disabled={
skillsShInput.trim().length < 2 || fetchingSkillsSh
}
className="shrink-0"
>
{fetchingSkillsSh ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Search className="h-3.5 w-3.5 mr-1.5" />
)}
{t("skills.search")}
</Button>
</>
)}
</div>
{/* 技能列表或无结果提示 */}
{filteredSkills.length === 0 ? (
{/* 内容区域 */}
{effectiveSource === "repos" ? (
/* ===== 仓库模式 ===== */
loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)
) : (
/* ===== skills.sh 模式 ===== */
<>
{loadingSkillsSh && accumulatedResults.length === 0 ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-sm text-muted-foreground">
{t("skills.skillssh.loading")}
</span>
</div>
) : skillsShQuery.length < 2 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-sm text-muted-foreground">
{t("skills.skillssh.searchPlaceholder")}
</p>
</div>
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
{t("skills.skillssh.noResults", {
query: skillsShQuery,
})}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{accumulatedResults.map((skill) => {
const installed = isSkillsShInstalled(skill);
return (
<SkillCard
key={skill.key}
skill={{
...toDiscoverableSkill(skill),
installed,
}}
installs={skill.installs}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</div>
{/* 加载更多 + 底部信息 */}
<div className="mt-6 flex flex-col items-center gap-2">
{hasMoreSkillsSh && (
<Button
variant="outline"
size="sm"
disabled={fetchingSkillsSh}
onClick={() =>
setSkillsShOffset(
(prev) => prev + SKILLSSH_PAGE_SIZE,
)
}
>
{fetchingSkillsSh ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : null}
{t("skills.skillssh.loadMore")}
</Button>
)}
<p className="text-xs text-muted-foreground">
{t("skills.skillssh.poweredBy")}
</p>
</div>
</>
)}
</>
)}
+172 -7
View File
@@ -1,7 +1,14 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
import {
Sparkles,
Trash2,
ExternalLink,
RefreshCw,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
type ImportSkillSelection,
@@ -15,7 +22,10 @@ import {
useScanUnmanagedSkills,
useImportSkillsFromApps,
useInstallSkillsFromZip,
useCheckSkillUpdates,
useUpdateSkill,
type InstalledSkill,
type SkillUpdateInfo,
} from "@/hooks/useSkills";
import type { AppId } from "@/lib/api/types";
import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -44,6 +54,7 @@ export interface UnifiedSkillsPanelHandle {
openImport: () => void;
openInstallFromZip: () => void;
openRestoreFromBackup: () => void;
checkUpdates: () => void;
}
function formatSkillBackupDate(unixSeconds: number): string {
@@ -83,6 +94,23 @@ const UnifiedSkillsPanel = React.forwardRef<
useScanUnmanagedSkills();
const importMutation = useImportSkillsFromApps();
const installFromZipMutation = useInstallSkillsFromZip();
const {
data: skillUpdates,
refetch: checkUpdates,
isFetching: isCheckingUpdates,
} = useCheckSkillUpdates();
const updateSkillMutation = useUpdateSkill();
const [isUpdatingAll, setIsUpdatingAll] = useState(false);
const updatesMap = useMemo(() => {
const map: Record<string, SkillUpdateInfo> = {};
if (skillUpdates) {
for (const u of skillUpdates) {
map[u.id] = u;
}
}
return map;
}, [skillUpdates]);
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
@@ -191,6 +219,55 @@ const UnifiedSkillsPanel = React.forwardRef<
}
};
const handleCheckUpdates = async () => {
try {
const result = await checkUpdates();
const updates = result.data || [];
if (updates.length === 0) {
toast.success(t("skills.noUpdates"), { closeButton: true });
} else {
toast.info(t("skills.updatesFound", { count: updates.length }), {
closeButton: true,
});
}
} catch (error) {
toast.error(t("common.error"), { description: String(error) });
}
};
const handleUpdateSkill = async (skill: InstalledSkill) => {
try {
const updated = await updateSkillMutation.mutateAsync(skill.id);
toast.success(t("skills.updateSuccess", { name: updated.name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("skills.updateFailed"), { description: String(error) });
}
};
const handleUpdateAll = async () => {
if (!skillUpdates || skillUpdates.length === 0) return;
setIsUpdatingAll(true);
let successCount = 0;
for (const update of skillUpdates) {
try {
await updateSkillMutation.mutateAsync(update.id);
successCount++;
} catch (error) {
toast.error(t("skills.updateFailed"), {
description: `${update.name}: ${String(error)}`,
});
}
}
setIsUpdatingAll(false);
if (successCount > 0) {
toast.success(t("skills.updateAllSuccess", { count: successCount }), {
closeButton: true,
});
}
};
const handleOpenRestoreFromBackup = async () => {
setRestoreDialogOpen(true);
try {
@@ -256,15 +333,63 @@ const UnifiedSkillsPanel = React.forwardRef<
openImport: handleOpenImport,
openInstallFromZip: handleInstallFromZip,
openRestoreFromBackup: handleOpenRestoreFromBackup,
checkUpdates: handleCheckUpdates,
}));
return (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
<AppCountBar
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
counts={enabledCounts}
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex items-center justify-between">
<AppCountBar
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
counts={enabledCounts}
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex items-center gap-1.5">
<div
className="transition-all duration-300 ease-out overflow-hidden"
style={{
maxWidth:
skillUpdates && skillUpdates.length > 0 ? "200px" : "0px",
opacity: skillUpdates && skillUpdates.length > 0 ? 1 : 0,
}}
>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs gap-1 whitespace-nowrap"
onClick={handleUpdateAll}
disabled={isUpdatingAll || updateSkillMutation.isPending}
>
{isUpdatingAll ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
{isUpdatingAll
? t("skills.updatingAll")
: t("skills.updateAll", { count: skillUpdates?.length ?? 0 })}
</Button>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs gap-1"
onClick={handleCheckUpdates}
disabled={isCheckingUpdates || !skills || skills.length === 0}
>
{isCheckingUpdates ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
{isCheckingUpdates
? t("skills.checkingUpdates")
: t("skills.checkUpdates")}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
@@ -290,8 +415,14 @@ const UnifiedSkillsPanel = React.forwardRef<
<InstalledSkillListItem
key={skill.id}
skill={skill}
hasUpdate={!!updatesMap[skill.id]}
isUpdating={
updateSkillMutation.isPending &&
updateSkillMutation.variables === skill.id
}
onToggleApp={handleToggleApp}
onUninstall={() => handleUninstall(skill)}
onUpdate={() => handleUpdateSkill(skill)}
isLast={index === skills.length - 1}
/>
))}
@@ -339,15 +470,21 @@ UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
interface InstalledSkillListItemProps {
skill: InstalledSkill;
hasUpdate?: boolean;
isUpdating?: boolean;
onToggleApp: (id: string, app: AppId, enabled: boolean) => void;
onUninstall: () => void;
onUpdate?: () => void;
isLast?: boolean;
}
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
skill,
hasUpdate,
isUpdating,
onToggleApp,
onUninstall,
onUpdate,
isLast,
}) => {
const { t } = useTranslation();
@@ -387,6 +524,14 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<span className="text-xs text-muted-foreground/50 flex-shrink-0">
{sourceLabel}
</span>
{hasUpdate && (
<Badge
variant="outline"
className="shrink-0 text-[10px] px-1.5 py-0 h-4 border-amber-500 text-amber-600 dark:text-amber-400"
>
{t("skills.updateAvailable")}
</Badge>
)}
</div>
{skill.description && (
<p
@@ -404,7 +549,27 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div
className="flex-shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
style={hasUpdate ? { opacity: 1 } : undefined}
>
{hasUpdate && onUpdate && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-blue-500 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-500/10"
onClick={onUpdate}
disabled={isUpdating}
title={t("skills.update")}
>
{isUpdating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
</Button>
)}
<Button
type="button"
variant="ghost"
+124
View File
@@ -0,0 +1,124 @@
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import { usageKeys } from "@/lib/query/usage";
import { Database, FileText, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { toast } from "sonner";
interface DataSourceBarProps {
refreshIntervalMs: number;
}
const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
proxy: <Database className="h-3.5 w-3.5" />,
session_log: <FileText className="h-3.5 w-3.5" />,
codex_db: <Database className="h-3.5 w-3.5" />,
codex_session: <FileText className="h-3.5 w-3.5" />,
gemini_session: <FileText className="h-3.5 w-3.5" />,
};
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [syncing, setSyncing] = useState(false);
const { data: sources } = useQuery({
queryKey: [...usageKeys.all, "data-sources"],
queryFn: usageApi.getDataSourceBreakdown,
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
refetchIntervalInBackground: false,
});
const handleSync = async () => {
setSyncing(true);
try {
const result = await usageApi.syncSessionUsage();
if (result.imported > 0) {
toast.success(
t("usage.sessionSync.imported", {
count: result.imported,
defaultValue: "Imported {{count}} records from session logs",
}),
);
// Refresh all usage data
queryClient.invalidateQueries({ queryKey: usageKeys.all });
} else {
toast.info(
t("usage.sessionSync.upToDate", {
defaultValue: "Session logs are up to date",
}),
);
}
} catch {
toast.error(
t("usage.sessionSync.failed", {
defaultValue: "Session sync failed",
}),
);
} finally {
setSyncing(false);
}
};
if (!sources || sources.length === 0) {
return null;
}
const hasNonProxy = sources.some((s) => s.dataSource !== "proxy");
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground bg-muted/30 rounded-lg px-4 py-2">
<span className="font-medium text-foreground/70">
{t("usage.dataSources", { defaultValue: "Data Sources" })}:
</span>
<div className="flex items-center gap-3 flex-wrap">
{sources.map((source) => (
<div
key={source.dataSource}
className="flex items-center gap-1.5 bg-background/50 rounded-md px-2 py-1"
>
{DATA_SOURCE_ICONS[source.dataSource] ?? (
<Database className="h-3.5 w-3.5" />
)}
<span>
{t(`usage.dataSource.${source.dataSource}`, {
defaultValue: source.dataSource,
})}
</span>
<span className="font-mono font-medium text-foreground/80">
{source.requestCount.toLocaleString()}
</span>
</div>
))}
</div>
<div className="ml-auto">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleSync}
disabled={syncing}
title={t("usage.sessionSync.trigger", {
defaultValue: "Sync session logs",
})}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span className="ml-1">
{hasNonProxy
? t("usage.sessionSync.resync", { defaultValue: "Sync" })
: t("usage.sessionSync.import", {
defaultValue: "Import Sessions",
})}
</span>
</Button>
</div>
</div>
);
}
+6 -2
View File
@@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
interface ModelStatsTableProps {
appType?: string;
refreshIntervalMs: number;
}
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
export function ModelStatsTable({
appType,
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats({
const { data: stats, isLoading } = useModelStats(appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+3 -1
View File
@@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
interface ProviderStatsTableProps {
appType?: string;
refreshIntervalMs: number;
}
export function ProviderStatsTable({
appType,
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats({
const { data: stats, isLoading } = useProviderStats(appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+37 -4
View File
@@ -29,6 +29,7 @@ import {
} from "./format";
interface RequestLogTableProps {
appType?: string;
refreshIntervalMs: number;
}
@@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
export function RequestLogTable({
appType: dashboardAppType,
refreshIntervalMs,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
// When dashboard-level app filter is active (not "all"), override the local appType filter
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const { data: result, isLoading } = useRequestLogs({
filters: appliedFilters,
filters: effectiveFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS,
page,
@@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3">
<Select
value={draftFilters.appType || "all"}
value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
setDraftFilters({
...draftFilters,
appType: v === "all" ? undefined : v,
})
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.appType")} />
@@ -371,13 +386,16 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
<TableHead className="whitespace-nowrap">
{t("usage.status")}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("usage.source", { defaultValue: "Source" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={11}
colSpan={12}
className="text-center text-muted-foreground"
>
{t("usage.noData")}
@@ -506,6 +524,21 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
{log.statusCode}
</span>
</TableCell>
<TableCell>
{log.dataSource && log.dataSource !== "proxy" ? (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-indigo-100 text-indigo-800">
{t(`usage.dataSource.${log.dataSource}`, {
defaultValue: log.dataSource,
})}
</span>
) : (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600">
{t("usage.dataSource.proxy", {
defaultValue: "Proxy",
})}
</span>
)}
</TableCell>
</TableRow>
))
)}
+71 -6
View File
@@ -6,7 +6,8 @@ import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage";
import type { AppTypeFilter, TimeRange } from "@/types/usage";
import { useUsageSummary } from "@/lib/query/usage";
import { motion } from "framer-motion";
import {
BarChart3,
@@ -25,11 +26,21 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { cn } from "@/lib/utils";
import { fmtUsd, parseFiniteNumber } from "./format";
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"all",
"claude",
"codex",
"gemini",
];
export function UsageDashboard() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
@@ -46,6 +57,11 @@ export function UsageDashboard() {
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
// Summary data for the app filter bar
const { data: summaryData } = useUsageSummary(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
@@ -100,9 +116,49 @@ export function UsageDashboard() {
</Tabs>
</div>
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
{/* App type filter bar (replaces DataSourceBar) */}
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{APP_FILTER_OPTIONS.map((type) => (
<button
key={type}
type="button"
onClick={() => setAppType(type)}
className={cn(
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
appType === type
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
)}
>
{t(`usage.appFilter.${type}`)}
</button>
))}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{(summaryData?.totalRequests ?? 0).toLocaleString()}{" "}
{t("usage.requestsLabel")}
</span>
<span className="text-border">|</span>
<span>
{fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "}
{t("usage.costLabel")}
</span>
</div>
</div>
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageSummaryCards
days={days}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<UsageTrendChart
days={days}
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
@@ -129,15 +185,24 @@ export function UsageDashboard() {
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
<RequestLogTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
<ProviderStatsTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
<TabsContent value="models" className="mt-0">
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
<ModelStatsTable
appType={appType}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
</motion.div>
</Tabs>
+3 -1
View File
@@ -8,16 +8,18 @@ import { fmtUsd, parseFiniteNumber } from "./format";
interface UsageSummaryCardsProps {
days: number;
appType?: string;
refreshIntervalMs: number;
}
export function UsageSummaryCards({
days,
appType,
refreshIntervalMs,
}: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days, {
const { data: summary, isLoading } = useUsageSummary(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+3 -1
View File
@@ -20,15 +20,17 @@ import {
interface UsageTrendChartProps {
days: number;
appType?: string;
refreshIntervalMs: number;
}
export function UsageTrendChart({
days,
appType,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days, {
const { data: trends, isLoading } = useUsageTrends(days, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
+23 -1
View File
@@ -58,7 +58,8 @@ export interface ProviderPreset {
// 供应商类型标识(用于特殊供应商检测)
// - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证)
providerType?: "github_copilot";
// - "codex_oauth": OpenAI Codex via ChatGPT Plus/Pro 反代(需要 OAuth 认证)
providerType?: "github_copilot" | "codex_oauth";
// 是否需要 OAuth 认证(而非 API Key
requiresOAuth?: boolean;
@@ -734,6 +735,27 @@ export const providerPresets: ProviderPreset[] = [
icon: "github",
iconColor: "#000000",
},
{
name: "Codex (ChatGPT Plus/Pro)",
websiteUrl: "https://openai.com/chatgpt/pricing",
settingsConfig: {
env: {
// base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex
// 用户无需配置
ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex",
ANTHROPIC_MODEL: "gpt-5.4",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.4-mini",
ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.4",
ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.4",
},
},
category: "third_party",
apiFormat: "openai_responses",
providerType: "codex_oauth",
requiresOAuth: true,
icon: "openai",
iconColor: "#000000",
},
{
name: "Nvidia",
websiteUrl: "https://build.nvidia.com",
+2
View File
@@ -1,6 +1,7 @@
// Provider 类型常量
export const PROVIDER_TYPES = {
GITHUB_COPILOT: "github_copilot",
CODEX_OAUTH: "codex_oauth",
} as const;
// 用量脚本模板类型常量
@@ -10,6 +11,7 @@ export const TEMPLATE_TYPES = {
NEW_API: "newapi",
GITHUB_COPILOT: "github_copilot",
TOKEN_PLAN: "token_plan",
BALANCE: "balance",
} as const;
export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
+16
View File
@@ -0,0 +1,16 @@
import { useState, useEffect } from "react";
/**
* 返回一个延迟更新的值,在指定时间内无新变化后才更新。
* 用于搜索输入等场景,避免每次按键都触发请求。
*/
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
+2 -21
View File
@@ -200,27 +200,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
);
}
// 根据供应商类型显示不同的成功提示
if (
!proxyRequiredReason &&
activeApp === "claude" &&
provider.category !== "official" &&
(isCopilotProvider ||
provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "openai_responses")
) {
// OpenAI format provider: show proxy hint (skip if warning already shown)
toast.info(
isCopilotProvider
? t("notifications.copilotProxyHint")
: t("notifications.openAIFormatHint"),
{
duration: 5000,
closeButton: true,
},
);
} else {
// 普通供应商:显示切换成功
// 若已弹过 proxyRequired 警告则不再弹 success
if (!proxyRequiredReason) {
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
const isMultiProviderApp =
activeApp === "opencode" || activeApp === "openclaw";
+66
View File
@@ -10,6 +10,8 @@ import {
type DiscoverableSkill,
type ImportSkillSelection,
type InstalledSkill,
type SkillUpdateInfo,
type SkillsShSearchResult,
} from "@/lib/api/skills";
import type { AppId } from "@/lib/api/types";
@@ -283,6 +285,68 @@ export function useInstallSkillsFromZip() {
});
}
// ========== 更新检测 ==========
/**
* 检查 Skills 更新(手动触发)
*/
export function useCheckSkillUpdates() {
return useQuery({
queryKey: ["skills", "updates"],
queryFn: () => skillsApi.checkUpdates(),
enabled: false,
staleTime: 5 * 60 * 1000,
});
}
/**
* 更新单个 Skill
*/
export function useUpdateSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => skillsApi.updateSkill(id),
onSuccess: (updatedSkill) => {
queryClient.setQueryData<InstalledSkill[]>(
["skills", "installed"],
(oldData) => {
if (!oldData) return [updatedSkill];
return oldData.map((s) =>
s.id === updatedSkill.id ? updatedSkill : s,
);
},
);
queryClient.setQueryData<SkillUpdateInfo[]>(
["skills", "updates"],
(oldData) => {
if (!oldData) return oldData;
return oldData.filter((u) => u.id !== updatedSkill.id);
},
);
},
});
}
// ========== skills.sh 搜索 ==========
/**
* 搜索 skills.sh 公共目录
* 使用 300ms staleTime 和 keepPreviousData 实现平滑搜索体验
*/
export function useSearchSkillsSh(
query: string,
limit: number,
offset: number,
) {
return useQuery({
queryKey: ["skills", "skillssh", query, limit, offset],
queryFn: () => skillsApi.searchSkillsSh(query, limit, offset),
enabled: query.length >= 2,
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
});
}
// ========== 辅助类型 ==========
export type {
@@ -290,5 +354,7 @@ export type {
DiscoverableSkill,
ImportSkillSelection,
SkillBackupEntry,
SkillUpdateInfo,
SkillsShSearchResult,
AppId,
};
+113
View File
@@ -42,6 +42,12 @@
"enabled": "Enabled",
"notSet": "Not Set"
},
"firstRunNotice": {
"title": "Welcome to CC Switch",
"bodyDefault": "CC Switch lets you switch between multiple Claude Code / Codex / Gemini CLI providers with a single click. If you already had these tools configured, CC Switch has saved your existing setup as a provider named “default” so none of your previous configuration is lost.",
"bodyOfficial": "An “Official” preset is also included in the list — click it any time you want to switch back to the official default. CC Switch automatically backs up your current config to “default” before switching, so you can move back and forth freely. That's how CC Switch works 😊",
"confirm": "Got it"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
"show": "Show API Key",
@@ -51,6 +57,15 @@
"mustBeObject": "Configuration must be a JSON object, not an array or other type",
"invalidJson": "Invalid JSON format"
},
"commonConfig": {
"guideTitle": "What is a Common Config Snippet?",
"guidePurpose": "It lets you share non-sensitive settings (plugins, environment variables, etc.) across different providers. These settings won't be lost when you switch providers.",
"guideUsage": "How to use: ① Click \"Extract from Editor\" to save the common parts ② Check \"Write Common Config\" when creating a new provider",
"guideReExtract": "If you've newly installed plugins or hooks, re-extract the common config so they sync to other providers.",
"guideReassurance": "Don't worry — your original configuration is safely stored in the default provider and won't be lost.",
"emptyTitle": "No common config snippet yet",
"emptyHint": "Click \"Extract from Editor\" below to extract reusable parts from your current configuration"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "Write Common Config",
@@ -217,6 +232,11 @@
"title": "Enable Auto Sync",
"message": "When auto sync is enabled, every database change will be automatically uploaded to the WebDAV server.\n\nThis may result in significant network traffic. Please ensure your network and WebDAV service can handle frequent data transfers.",
"confirm": "I understand, enable"
},
"commonConfig": {
"title": "About Common Config",
"message": "The \"Common Config Snippet\" lets you share plugins, environment variables, and other settings across different providers — so they won't be lost when you switch.\n\nHow to use:\n① Edit a provider → click \"Edit Common Config\" → \"Extract from Editor\"\n② When creating a new provider, check \"Write Common Config\" (enabled by default)\n\nIf you've newly installed plugins or hooks, re-extract the common config to keep them in sync.",
"confirm": "Got it"
}
},
"settings": {
@@ -225,6 +245,13 @@
"tabGeneral": "General",
"tabAdvanced": "Advanced",
"tabProxy": "Proxy",
"authCenter": {
"title": "OAuth Authentication Center",
"description": "Use your other subscriptions in Claude Code — please be mindful of compliance risks.",
"beta": "Beta",
"copilotDescription": "Manage GitHub Copilot accounts",
"codexOauthDescription": "Manage ChatGPT accounts"
},
"advanced": {
"configDir": {
"title": "Configuration Directory",
@@ -478,6 +505,18 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI"
},
"skillStorage": {
"title": "Skill Storage Location",
"description": "Choose where CC Switch stores the master copies of your skills",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.",
"unifiedHint": "Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.",
"confirmTitle": "Migrate Skill Storage",
"confirmMessage": "{{count}} skill(s) will be moved to the new location. Continue?",
"migrationSuccess": "Successfully migrated {{count}} skill(s)",
"migrationPartial": "Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details."
},
"skillSync": {
"title": "Skill Sync Method",
"description": "Choose how to sync Skills files",
@@ -836,6 +875,27 @@
"migrationFailed": "Legacy auth migration failed: {{error}}",
"loadModelsFailed": "Failed to load Copilot models"
},
"codexOauth": {
"authStatus": "Auth status",
"notAuthenticated": "Not authenticated",
"loginWithChatGPT": "Sign in with ChatGPT",
"loginRequired": "Please sign in to ChatGPT first",
"waitingForAuth": "Waiting for authorization...",
"enterCode": "Enter the code in your browser:",
"accountCount": "{{count}} account(s)",
"selectAccount": "Select account",
"selectAccountPlaceholder": "Choose a ChatGPT account",
"useDefaultAccount": "Use default account",
"loggedInAccounts": "Logged in accounts",
"defaultAccount": "Default",
"selected": "Selected",
"removeAccount": "Remove account",
"setAsDefault": "Set as default",
"addAnotherAccount": "Add another account",
"logoutAll": "Logout all accounts",
"retry": "Retry",
"copyCode": "Copy code"
},
"endpointTest": {
"title": "API Endpoint Management",
"endpoints": "endpoints",
@@ -1009,6 +1069,31 @@
"unknownProvider": "Unknown Provider",
"stream": "Stream",
"nonStream": "Non-stream",
"source": "Source",
"requestsLabel": "requests",
"costLabel": "total cost",
"appFilter": {
"all": "All",
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"dataSources": "Data Sources",
"dataSource": {
"proxy": "Proxy",
"session_log": "Session Log",
"codex_db": "Codex DB",
"codex_session": "Codex Session",
"gemini_session": "Gemini Session"
},
"sessionSync": {
"trigger": "Sync session logs",
"import": "Import Sessions",
"resync": "Sync",
"imported": "Imported {{count}} records from session logs",
"upToDate": "Session logs are up to date",
"failed": "Session sync failed"
},
"totalRecords": "{{total}} records total",
"modelPricing": "Model Pricing",
"loadPricingError": "Failed to load pricing data",
@@ -1094,8 +1179,10 @@
"templateNewAPI": "NewAPI",
"templateCopilot": "GitHub Copilot",
"templateTokenPlan": "Token Plan",
"templateBalance": "Official",
"copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed",
"tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota",
"balanceHint": "Automatically uses the provider's API Key to query account balance",
"resetDate": "Reset date",
"premiumRequests": "Premium Requests",
"credentialsConfig": "Credentials",
@@ -1574,6 +1661,18 @@
"installFailed": "Failed to install",
"uninstallSuccess": "Skill {{name}} uninstalled",
"uninstallFailed": "Failed to uninstall",
"update": "Update",
"updating": "Updating...",
"updateAvailable": "Update",
"updateSuccess": "Skill {{name}} updated to latest version",
"updateFailed": "Failed to update",
"checkUpdates": "Check Updates",
"checkingUpdates": "Checking...",
"noUpdates": "All skills are up to date",
"updatesFound": "{{count}} skill(s) have updates available",
"updateAll": "Update All ({{count}})",
"updatingAll": "Updating...",
"updateAllSuccess": "Successfully updated {{count}} skill(s)",
"error": {
"skillNotFound": "Skill not found: {{directory}}",
"missingRepoInfo": "Missing repository info (owner or name)",
@@ -1627,6 +1726,19 @@
},
"search": "Search Skills",
"searchPlaceholder": "Search skill name or repo...",
"searchSource": {
"repos": "Repos",
"skillssh": "skills.sh"
},
"skillssh": {
"searchPlaceholder": "Search skills.sh (min 2 chars)...",
"installs": "{{count}} installs",
"loadMore": "Load More",
"loading": "Searching skills.sh...",
"noResults": "No skills found for \"{{query}}\"",
"error": "Failed to search skills.sh",
"poweredBy": "Powered by skills.sh"
},
"filter": {
"placeholder": "Filter by status",
"all": "All",
@@ -2292,6 +2404,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "Weekly",
"copilotPremium": "Premium",
"utilization": "{{value}}%",
"resetsIn": "Resets in {{time}}",
"extraUsage": "Extra Usage",
+113
View File
@@ -42,6 +42,12 @@
"enabled": "有効",
"notSet": "未設定"
},
"firstRunNotice": {
"title": "CC Switch へようこそ",
"bodyDefault": "CC Switch は Claude Code / Codex / Gemini CLI の複数プロバイダーをワンクリックで切り替えられるツールです。すでにこれらのツールを設定済みの場合、CC Switch は現在の設定を「default」という名前のプロバイダーとして自動的に保存するので、既存の設定が失われることはありません。",
"bodyOfficial": "リストには「Official(公式)」プリセットも用意されており、公式バージョンに戻したくなったらクリックするだけで切り替えられます。切り替える前に現在の設定は自動で default にバックアップされるので、自由に行き来できます。これが CC Switch の仕組みです 😊",
"confirm": "了解しました"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
"show": "API Key を表示",
@@ -51,6 +57,15 @@
"mustBeObject": "設定はオブジェクト形式の JSON で入力してください(配列や他の型は不可)",
"invalidJson": "JSON 形式が正しくありません"
},
"commonConfig": {
"guideTitle": "共通設定スニペットとは?",
"guidePurpose": "異なるプロバイダー間でプラグインや環境変数などの非機密設定を共有するための機能です。プロバイダーを切り替えてもこれらの設定は失われません。",
"guideUsage": "使い方:① 「編集内容から抽出」をクリックして共通部分を保存 ② 新規プロバイダー作成時に「共通設定を書き込む」をチェック",
"guideReExtract": "新しいプラグインや Hook をインストールした場合は、共通設定を再抽出して他のプロバイダーに同期してください。",
"guideReassurance": "ご安心ください:元の設定はデフォルトプロバイダーに安全に保存されており、失われることはありません。",
"emptyTitle": "共通設定スニペットがまだありません",
"emptyHint": "下の「編集内容から抽出」ボタンをクリックして、現在の設定から再利用可能な部分を抽出してください"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "共通設定を書き込む",
@@ -217,6 +232,11 @@
"title": "自動同期を有効にする",
"message": "自動同期を有効にすると、データベースの変更ごとに WebDAV サーバーへ自動アップロードされます。\n\nネットワークトラフィックが大幅に増加する可能性があります。ネットワーク環境と WebDAV サービスが頻繁なデータ転送に対応できることをご確認ください。",
"confirm": "理解しました、有効にする"
},
"commonConfig": {
"title": "共通設定について",
"message": "「共通設定スニペット」を使うと、プラグインや環境変数などの設定を異なるプロバイダー間で共有でき、切り替え時に失われることがありません。\n\n使い方:\n① プロバイダーを編集 →「共通設定を編集」→「編集内容から抽出」\n② 新規プロバイダー作成時に「共通設定を書き込む」をチェック(デフォルトでオン)\n\n新しいプラグインや Hook をインストールした場合は、共通設定を再抽出してください。",
"confirm": "わかりました"
}
},
"settings": {
@@ -225,6 +245,13 @@
"tabGeneral": "一般",
"tabAdvanced": "詳細",
"tabProxy": "プロキシ",
"authCenter": {
"title": "OAuth 認証センター",
"description": "Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。",
"beta": "Beta",
"copilotDescription": "GitHub Copilot アカウントを管理します",
"codexOauthDescription": "ChatGPT アカウントを管理します"
},
"advanced": {
"configDir": {
"title": "設定ディレクトリ",
@@ -478,6 +505,18 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI"
},
"skillStorage": {
"title": "スキル保存場所",
"description": "CC Switch がスキルのマスターコピーを保存するディレクトリを選択します",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。",
"unifiedHint": "スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。",
"confirmTitle": "スキル保存場所の移行",
"confirmMessage": "{{count}} 個のスキルを新しい場所に移動します。続行しますか?",
"migrationSuccess": "{{count}} 個のスキルを移行しました",
"migrationPartial": "{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。"
},
"skillSync": {
"title": "スキル同期方式",
"description": "スキルファイルの同期方法を選択",
@@ -836,6 +875,27 @@
"migrationFailed": "旧認証データの移行に失敗しました: {{error}}",
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました"
},
"codexOauth": {
"authStatus": "認証状態",
"notAuthenticated": "未認証",
"loginWithChatGPT": "ChatGPT でログイン",
"loginRequired": "先に ChatGPT にログインしてください",
"waitingForAuth": "認証を待機中...",
"enterCode": "ブラウザで以下のコードを入力してください:",
"accountCount": "{{count}} アカウント",
"selectAccount": "アカウントを選択",
"selectAccountPlaceholder": "ChatGPT アカウントを選択",
"useDefaultAccount": "デフォルトアカウントを使用",
"loggedInAccounts": "ログイン済みアカウント",
"defaultAccount": "デフォルト",
"selected": "選択中",
"removeAccount": "アカウントを削除",
"setAsDefault": "デフォルトに設定",
"addAnotherAccount": "別のアカウントを追加",
"logoutAll": "すべてのアカウントをログアウト",
"retry": "再試行",
"copyCode": "コードをコピー"
},
"endpointTest": {
"title": "API エンドポイント管理",
"endpoints": "エンドポイント",
@@ -1009,6 +1069,31 @@
"unknownProvider": "不明なプロバイダー",
"stream": "ストリーム",
"nonStream": "非ストリーム",
"source": "ソース",
"requestsLabel": "リクエスト",
"costLabel": "合計コスト",
"appFilter": {
"all": "すべて",
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"dataSources": "データソース",
"dataSource": {
"proxy": "プロキシ",
"session_log": "セッションログ",
"codex_db": "Codex DB",
"codex_session": "Codex セッション",
"gemini_session": "Gemini セッション"
},
"sessionSync": {
"trigger": "セッションログを同期",
"import": "セッションをインポート",
"resync": "同期",
"imported": "セッションログから {{count}} 件のレコードをインポートしました",
"upToDate": "セッションログは最新です",
"failed": "セッション同期に失敗しました"
},
"totalRecords": "全 {{total}} 件",
"modelPricing": "モデル料金",
"loadPricingError": "料金データの読み込みに失敗しました",
@@ -1094,8 +1179,10 @@
"templateNewAPI": "NewAPI",
"templateCopilot": "GitHub Copilot",
"templateTokenPlan": "Token Plan",
"templateBalance": "公式",
"copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要",
"tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ",
"balanceHint": "プロバイダーのAPI Keyを使用してアカウント残高を自動クエリ",
"resetDate": "リセット日",
"premiumRequests": "Premium リクエスト",
"credentialsConfig": "認証情報",
@@ -1574,6 +1661,18 @@
"installFailed": "インストールに失敗しました",
"uninstallSuccess": "スキル {{name}} をアンインストールしました",
"uninstallFailed": "アンインストールに失敗しました",
"update": "更新",
"updating": "更新中...",
"updateAvailable": "更新あり",
"updateSuccess": "スキル {{name}} を最新バージョンに更新しました",
"updateFailed": "更新に失敗しました",
"checkUpdates": "更新を確認",
"checkingUpdates": "確認中...",
"noUpdates": "すべてのスキルは最新です",
"updatesFound": "{{count}} 個のスキルに更新があります",
"updateAll": "すべて更新 ({{count}})",
"updatingAll": "更新中...",
"updateAllSuccess": "{{count}} 個のスキルを更新しました",
"error": {
"skillNotFound": "スキルが見つかりません: {{directory}}",
"missingRepoInfo": "リポジトリ情報(owner または name)が不足しています",
@@ -1627,6 +1726,19 @@
},
"search": "スキルを検索",
"searchPlaceholder": "スキル名またはリポジトリで検索...",
"searchSource": {
"repos": "リポジトリ",
"skillssh": "skills.sh"
},
"skillssh": {
"searchPlaceholder": "skills.sh を検索(2文字以上)...",
"installs": "{{count}} インストール",
"loadMore": "さらに読み込む",
"loading": "skills.sh を検索中...",
"noResults": "\"{{query}}\" に該当するスキルがありません",
"error": "skills.sh の検索に失敗しました",
"poweredBy": "Powered by skills.sh"
},
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
@@ -2292,6 +2404,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "週間",
"copilotPremium": "プレミアム",
"utilization": "{{value}}%",
"resetsIn": "{{time}}後にリセット",
"extraUsage": "超過使用量",
+114 -1
View File
@@ -42,6 +42,12 @@
"enabled": "已开启",
"notSet": "未设置"
},
"firstRunNotice": {
"title": "欢迎使用 CC Switch",
"bodyDefault": "CC Switch 可以帮你在 Claude Code / Codex / Gemini CLI 的多个供应商之间一键切换。如果你之前已经配置过这些工具,CC Switch 会自动把现有设置保存为名为 “default” 的供应商,保证你的配置不会丢失。",
"bodyOfficial": "列表里还预置了 “官方(Official)” 供应商,随时点一下即可切回官方默认配置。切换前 CC Switch 会自动把当前配置备份回 default,可以放心来回切。CC Switch 就是这样工作的 😊",
"confirm": "我知道了"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
"show": "显示API Key",
@@ -51,6 +57,15 @@
"mustBeObject": "配置必须是JSON对象,不能是数组或其他类型",
"invalidJson": "JSON格式错误"
},
"commonConfig": {
"guideTitle": "什么是通用配置片段?",
"guidePurpose": "用来在不同供应商之间共享插件、环境变量等非敏感配置。切换供应商时不会丢失这些设置。",
"guideUsage": "用法:① 点击「从编辑内容提取」保存通用部分 ② 新建供应商时勾选「写入通用配置」",
"guideReExtract": "如果您新安装了插件或 Hook,请重新提取一次通用配置,以便同步到其他供应商。",
"guideReassurance": "放心:您的原始配置已保存在默认供应商中,不会丢失。",
"emptyTitle": "还没有通用配置片段",
"emptyHint": "点击下方「从编辑内容提取」按钮,从当前配置中提取可复用的部分"
},
"claudeConfig": {
"configLabel": "Claude Code 配置 (JSON) *",
"writeCommonConfig": "写入通用配置",
@@ -217,6 +232,11 @@
"title": "开启自动同步",
"message": "开启自动同步后,每次数据库变更都会自动上传到 WebDAV 服务器。\n\n这可能会产生较高的网络流量消耗,请确保您的网络环境和 WebDAV 服务支持频繁的数据传输。",
"confirm": "我已了解,继续开启"
},
"commonConfig": {
"title": "关于通用配置",
"message": "「通用配置片段」可以在不同供应商之间共享插件、环境变量等配置,避免切换供应商时丢失这些设置。\n\n使用方法:\n① 编辑供应商时点击「编辑通用配置」→「从编辑内容提取」\n② 新建供应商时勾选「写入通用配置」(默认已勾选)\n\n如果您新安装了插件或 Hook,请重新提取一次通用配置。",
"confirm": "我知道了"
}
},
"settings": {
@@ -225,6 +245,13 @@
"tabGeneral": "通用",
"tabAdvanced": "高级",
"tabProxy": "代理",
"authCenter": {
"title": "OAuth 认证中心",
"description": "在 Claude Code 中使用您的其他订阅,请注意合规风险。",
"beta": "Beta",
"copilotDescription": "管理 GitHub Copilot 账号",
"codexOauthDescription": "管理 ChatGPT 账号"
},
"advanced": {
"configDir": {
"title": "配置文件目录",
@@ -478,8 +505,20 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI"
},
"skillStorage": {
"title": "Skills 存储位置",
"description": "选择 CC Switch 存放 Skills 主副本的目录",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。",
"unifiedHint": "技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。",
"confirmTitle": "迁移技能存储",
"confirmMessage": "将移动 {{count}} 个技能到新位置,是否继续?",
"migrationSuccess": "已成功迁移 {{count}} 个技能",
"migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志"
},
"skillSync": {
"title": "Skill 同步方式",
"title": "Skills 同步方式",
"description": "选择 Skills 的文件同步策略",
"symlink": "软连接",
"copy": "文件复制",
@@ -836,6 +875,27 @@
"migrationFailed": "旧认证数据迁移失败:{{error}}",
"loadModelsFailed": "加载 Copilot 模型列表失败"
},
"codexOauth": {
"authStatus": "认证状态",
"notAuthenticated": "未认证",
"loginWithChatGPT": "使用 ChatGPT 登录",
"loginRequired": "请先登录 ChatGPT 账号",
"waitingForAuth": "等待授权中...",
"enterCode": "请在浏览器中输入以下验证码:",
"accountCount": "{{count}} 个账号",
"selectAccount": "选择账号",
"selectAccountPlaceholder": "选择一个 ChatGPT 账号",
"useDefaultAccount": "使用默认账号",
"loggedInAccounts": "已登录账号",
"defaultAccount": "默认",
"selected": "已选中",
"removeAccount": "移除账号",
"setAsDefault": "设为默认",
"addAnotherAccount": "添加其他账号",
"logoutAll": "注销所有账号",
"retry": "重试",
"copyCode": "复制代码"
},
"endpointTest": {
"title": "请求地址管理",
"endpoints": "个端点",
@@ -1009,6 +1069,31 @@
"unknownProvider": "未知供应商",
"stream": "流",
"nonStream": "非流",
"source": "来源",
"requestsLabel": "次请求",
"costLabel": "总成本",
"appFilter": {
"all": "全部",
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"dataSources": "数据来源",
"dataSource": {
"proxy": "代理",
"session_log": "会话日志",
"codex_db": "Codex 数据库",
"codex_session": "Codex 会话日志",
"gemini_session": "Gemini 会话日志"
},
"sessionSync": {
"trigger": "同步会话日志",
"import": "导入会话",
"resync": "同步",
"imported": "从会话日志导入了 {{count}} 条记录",
"upToDate": "会话日志已是最新",
"failed": "会话同步失败"
},
"totalRecords": "共 {{total}} 条记录",
"modelPricing": "模型定价",
"loadPricingError": "加载定价数据失败",
@@ -1094,8 +1179,10 @@
"templateNewAPI": "NewAPI",
"templateCopilot": "GitHub Copilot",
"templateTokenPlan": "Token Plan",
"templateBalance": "官方",
"copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证",
"tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度",
"balanceHint": "自动使用供应商的 API Key 查询账户余额",
"resetDate": "重置日期",
"premiumRequests": "Premium 请求",
"credentialsConfig": "凭证配置",
@@ -1574,6 +1661,18 @@
"installFailed": "安装失败",
"uninstallSuccess": "技能 {{name}} 已卸载",
"uninstallFailed": "卸载失败",
"update": "更新",
"updating": "更新中...",
"updateAvailable": "可更新",
"updateSuccess": "技能 {{name}} 已更新到最新版本",
"updateFailed": "更新失败",
"checkUpdates": "检查更新",
"checkingUpdates": "检查中...",
"noUpdates": "所有技能已是最新版本",
"updatesFound": "发现 {{count}} 个技能有可用更新",
"updateAll": "全部更新 ({{count}})",
"updatingAll": "更新中...",
"updateAllSuccess": "已成功更新 {{count}} 个技能",
"error": {
"skillNotFound": "技能不存在:{{directory}}",
"missingRepoInfo": "缺少仓库信息(owner 或 name",
@@ -1627,6 +1726,19 @@
},
"search": "搜索技能",
"searchPlaceholder": "搜索技能名称或仓库名称...",
"searchSource": {
"repos": "仓库",
"skillssh": "skills.sh"
},
"skillssh": {
"searchPlaceholder": "搜索 skills.sh(至少 2 个字符)...",
"installs": "{{count}} 次安装",
"loadMore": "加载更多",
"loading": "正在搜索 skills.sh...",
"noResults": "未找到 \"{{query}}\" 相关技能",
"error": "搜索 skills.sh 失败",
"poweredBy": "由 skills.sh 提供"
},
"filter": {
"placeholder": "状态筛选",
"all": "全部",
@@ -2292,6 +2404,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每周",
"copilotPremium": "高级请求",
"utilization": "{{value}}%",
"resetsIn": "{{time}}后重置",
"extraUsage": "超额用量",
+1 -1
View File
@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
export type ManagedAuthProvider = "github_copilot";
export type ManagedAuthProvider = "github_copilot" | "codex_oauth";
export interface ManagedAuthAccount {
id: string;
+62
View File
@@ -25,6 +25,8 @@ export interface InstalledSkill {
readmeUrl?: string;
apps: SkillApps;
installedAt: number;
contentHash?: string;
updatedAt: number;
}
export interface SkillUninstallResult {
@@ -78,6 +80,40 @@ export interface Skill {
repoBranch?: string;
}
/** Skill 更新信息 */
export interface SkillUpdateInfo {
id: string;
name: string;
currentHash?: string;
remoteHash: string;
}
/** 存储位置迁移结果 */
export interface MigrationResult {
migratedCount: number;
skippedCount: number;
errors: string[];
}
/** skills.sh 可发现的技能 */
export interface SkillsShDiscoverableSkill {
key: string;
name: string;
directory: string;
repoOwner: string;
repoName: string;
repoBranch: string;
installs: number;
readmeUrl?: string;
}
/** skills.sh 搜索结果 */
export interface SkillsShSearchResult {
skills: SkillsShDiscoverableSkill[];
totalCount: number;
query: string;
}
/** 仓库配置 */
export interface SkillRepo {
owner: string;
@@ -149,6 +185,32 @@ export const skillsApi = {
return await invoke("discover_available_skills");
},
/** 检查 Skills 更新 */
async checkUpdates(): Promise<SkillUpdateInfo[]> {
return await invoke("check_skill_updates");
},
/** 更新单个 Skill */
async updateSkill(id: string): Promise<InstalledSkill> {
return await invoke("update_skill", { id });
},
/** 迁移 Skill 存储位置 */
async migrateStorage(
target: "cc_switch" | "unified",
): Promise<MigrationResult> {
return await invoke("migrate_skill_storage", { target });
},
/** 搜索 skills.sh 公共目录 */
async searchSkillsSh(
query: string,
limit: number,
offset: number,
): Promise<SkillsShSearchResult> {
return await invoke("search_skills_sh", { query, limit, offset });
},
// ========== 兼容旧 API ==========
/** 获取技能列表(兼容旧 API) */
+7
View File
@@ -4,9 +4,16 @@ import type { SubscriptionQuota } from "@/types/subscription";
export const subscriptionApi = {
getQuota: (tool: string): Promise<SubscriptionQuota> =>
invoke("get_subscription_quota", { tool }),
getCodexOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
invoke("get_codex_oauth_quota", { accountId }),
getCodingPlanQuota: (
baseUrl: string,
apiKey: string,
): Promise<SubscriptionQuota> =>
invoke("get_coding_plan_quota", { baseUrl, apiKey }),
getBalance: (
baseUrl: string,
apiKey: string,
): Promise<import("@/types").UsageResult> =>
invoke("get_balance", { baseUrl, apiKey }),
};
+19 -6
View File
@@ -9,6 +9,8 @@ import type {
ModelPricing,
ProviderLimitStatus,
PaginatedLogs,
SessionSyncResult,
DataSourceSummary,
} from "@/types/usage";
import type { UsageResult } from "@/types";
import type { AppId } from "./types";
@@ -48,23 +50,25 @@ export const usageApi = {
getUsageSummary: async (
startDate?: number,
endDate?: number,
appType?: string,
): Promise<UsageSummary> => {
return invoke("get_usage_summary", { startDate, endDate });
return invoke("get_usage_summary", { startDate, endDate, appType });
},
getUsageTrends: async (
startDate?: number,
endDate?: number,
appType?: string,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate });
return invoke("get_usage_trends", { startDate, endDate, appType });
},
getProviderStats: async (): Promise<ProviderStats[]> => {
return invoke("get_provider_stats");
getProviderStats: async (appType?: string): Promise<ProviderStats[]> => {
return invoke("get_provider_stats", { appType });
},
getModelStats: async (): Promise<ModelStats[]> => {
return invoke("get_model_stats");
getModelStats: async (appType?: string): Promise<ModelStats[]> => {
return invoke("get_model_stats", { appType });
},
getRequestLogs: async (
@@ -115,4 +119,13 @@ export const usageApi = {
): Promise<ProviderLimitStatus> => {
return invoke("check_provider_limits", { providerId, appType });
},
// Session usage sync
syncSessionUsage: async (): Promise<SessionSyncResult> => {
return invoke("sync_session_usage");
},
getDataSourceBreakdown: async (): Promise<DataSourceSummary[]> => {
return invoke("get_usage_data_sources");
},
};
+17
View File
@@ -29,3 +29,20 @@ export const isLinux = (): boolean => {
return false;
}
};
// Linux 上禁用所有 drag region,规避 Wayland 下 gtk_window_begin_move_drag
// 相关的窗口事件异常(Tauri #13440)。macOS 上保留原有拖动行为;Windows
// 项目原本就不依赖这个。
//
// 这些常量设计为通过 JSX 属性 spread 消费(`{...DRAG_REGION_ATTR}`),
// 因为 `data-tauri-drag-region` 是 wry 侧的 attribute 存在性检测,必须
// 完全不渲染属性才算禁用;空字符串或 "false" 仍会触发。
export const DRAG_REGION_ENABLED = !isLinux();
export const DRAG_REGION_ATTR: Record<string, unknown> = DRAG_REGION_ENABLED
? { "data-tauri-drag-region": true }
: {};
export const DRAG_REGION_STYLE: Record<string, unknown> = DRAG_REGION_ENABLED
? { WebkitAppRegion: "drag" }
: {};
+63
View File
@@ -0,0 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
import type { QuotaTier } from "@/types/subscription";
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
export interface CopilotQuota {
success: boolean;
plan: string | null;
resetDate: string | null;
tiers: QuotaTier[];
error: string | null;
queriedAt: number | null;
}
export interface UseCopilotQuotaOptions {
enabled?: boolean;
/** 是否启用自动轮询(5 分钟)与窗口 focus 重取 */
autoQuery?: boolean;
}
export function useCopilotQuota(
accountId: string | null,
options: UseCopilotQuotaOptions = {},
) {
const { enabled = true, autoQuery = false } = options;
return useQuery<CopilotQuota>({
queryKey: ["copilot", "quota", accountId ?? "default"],
queryFn: async (): Promise<CopilotQuota> => {
const usage = accountId
? await copilotGetUsageForAccount(accountId)
: await copilotGetUsage();
const premium = usage.quota_snapshots.premium_interactions;
const utilization =
premium.entitlement > 0
? ((premium.entitlement - premium.remaining) / premium.entitlement) *
100
: 0;
return {
success: true,
plan: usage.copilot_plan,
resetDate: usage.quota_reset_date,
tiers: [
{
name: "premium",
utilization,
resetsAt: usage.quota_reset_date,
},
],
error: null,
queriedAt: Date.now(),
};
},
enabled,
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
refetchIntervalInBackground: autoQuery,
refetchOnWindowFocus: autoQuery,
staleTime: REFETCH_INTERVAL,
retry: 1,
});
}
+44 -3
View File
@@ -1,16 +1,57 @@
import { useQuery } from "@tanstack/react-query";
import { subscriptionApi } from "@/lib/api/subscription";
import type { AppId } from "@/lib/api/types";
import type { ProviderMeta } from "@/types";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { PROVIDER_TYPES } from "@/config/constants";
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
export function useSubscriptionQuota(appId: AppId, enabled: boolean) {
export function useSubscriptionQuota(
appId: AppId,
enabled: boolean,
autoQuery = false,
) {
return useQuery({
queryKey: ["subscription", "quota", appId],
queryFn: () => subscriptionApi.getQuota(appId),
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
refetchInterval: REFETCH_INTERVAL,
refetchOnWindowFocus: true,
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
refetchIntervalInBackground: autoQuery,
refetchOnWindowFocus: autoQuery,
staleTime: REFETCH_INTERVAL,
retry: 1,
});
}
export interface UseCodexOauthQuotaOptions {
enabled?: boolean;
/** 是否启用自动轮询(5 分钟)与窗口 focus 重取 */
autoQuery?: boolean;
}
/**
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
*
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token
* 而不是 Codex CLI 的 ~/.codex/auth.json。
*
* Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
*/
export function useCodexOauthQuota(
meta: ProviderMeta | undefined,
options: UseCodexOauthQuotaOptions = {},
) {
const { enabled = true, autoQuery = false } = options;
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
return useQuery({
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
enabled,
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
refetchIntervalInBackground: autoQuery,
refetchOnWindowFocus: autoQuery,
staleTime: REFETCH_INTERVAL,
retry: 1,
});
+47 -28
View File
@@ -34,10 +34,14 @@ type RequestLogsKey = {
// Query keys
export const usageKeys = {
all: ["usage"] 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,
summary: (days: number, appType?: string) =>
[...usageKeys.all, "summary", days, appType ?? "all"] as const,
trends: (days: number, appType?: string) =>
[...usageKeys.all, "trends", days, appType ?? "all"] as const,
providerStats: (appType?: string) =>
[...usageKeys.all, "provider-stats", appType ?? "all"] as const,
modelStats: (appType?: string) =>
[...usageKeys.all, "model-stats", appType ?? "all"] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[
...usageKeys.all,
@@ -67,44 +71,59 @@ const getWindow = (days: number) => {
};
// Hooks
export function useUsageSummary(days: number, options?: UsageQueryOptions) {
export function useUsageSummary(
days: number,
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({
queryKey: usageKeys.summary(days),
queryKey: usageKeys.summary(days, appType),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
});
}
export function useUsageTrends(days: number, options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useProviderStats(options?: UsageQueryOptions) {
export function useUsageTrends(
days: number,
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({
queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
queryKey: usageKeys.trends(days, appType),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useModelStats(options?: UsageQueryOptions) {
export function useProviderStats(
appType?: string,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({
queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
queryKey: usageKeys.providerStats(appType),
queryFn: () => usageApi.getProviderStats(effectiveAppType),
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useModelStats(appType?: string, options?: UsageQueryOptions) {
const effectiveAppType = appType === "all" ? undefined : appType;
return useQuery({
queryKey: usageKeys.modelStats(appType),
queryFn: () => usageApi.getModelStats(effectiveAppType),
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
+1
View File
@@ -29,6 +29,7 @@ export const settingsSchema = z.object({
// Skill 同步设置
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
skillStorageLocation: z.enum(["cc_switch", "unified"]).optional(),
// WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取)
webdavSync: z
+9
View File
@@ -182,6 +182,9 @@ export interface ProviderMeta {
// Skill 同步方式
export type SkillSyncMethod = "auto" | "symlink" | "copy";
// Skill 存储位置
export type SkillStorageLocation = "cc_switch" | "unified";
// Claude API 格式类型
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
@@ -271,8 +274,12 @@ export interface Settings {
enableFailoverToggle?: boolean;
// User has confirmed the failover toggle first-run notice
failoverConfirmed?: boolean;
// User has confirmed the first-run welcome notice
firstRunNoticeConfirmed?: boolean;
// User has confirmed the auto-sync traffic warning
autoSyncConfirmed?: boolean;
// User has confirmed the common config first-run notice
commonConfigConfirmed?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
@@ -302,6 +309,8 @@ export interface Settings {
// ===== Skill 同步设置 =====
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
skillSyncMethod?: SkillSyncMethod;
// Skill 存储位置:cc_switch(默认)或 unified~/.agents/skills/
skillStorageLocation?: SkillStorageLocation;
// ===== WebDAV v2 同步设置 =====
webdavSync?: WebDavSyncSettings;
+16
View File
@@ -31,6 +31,20 @@ export interface RequestLog {
statusCode: number;
errorMessage?: string;
createdAt: number;
dataSource?: string;
}
export interface SessionSyncResult {
imported: number;
skipped: number;
filesScanned: number;
errors: string[];
}
export interface DataSourceSummary {
dataSource: string;
requestCount: number;
totalCostUsd: string;
}
export interface PaginatedLogs {
@@ -109,6 +123,8 @@ export interface ProviderLimitStatus {
export type TimeRange = "1d" | "7d" | "30d";
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
export interface StatsFilters {
timeRange: TimeRange;
providerId?: string;