Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector

# Conflicts:
#	src-tauri/src/services/provider/live.rs
#	src-tauri/src/services/proxy.rs
This commit is contained in:
Jason
2026-06-15 15:04:01 +08:00
143 changed files with 14006 additions and 4259 deletions
+31 -2
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@@ -7,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -18,7 +20,10 @@ interface ConfirmDialogProps {
cancelText?: string;
variant?: "destructive" | "info";
zIndex?: "base" | "nested" | "alert" | "top";
onConfirm: () => void;
/** 可选勾选项:提供 label 即显示,勾选状态经 onConfirm 参数回传 */
checkboxLabel?: string;
checkboxDefaultChecked?: boolean;
onConfirm: (checkboxChecked: boolean) => void;
onCancel: () => void;
}
@@ -30,10 +35,21 @@ export function ConfirmDialog({
cancelText,
variant = "destructive",
zIndex = "alert",
checkboxLabel,
checkboxDefaultChecked = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation();
const [checkboxChecked, setCheckboxChecked] = useState(
checkboxDefaultChecked,
);
useEffect(() => {
if (isOpen) {
setCheckboxChecked(checkboxDefaultChecked);
}
}, [isOpen, checkboxDefaultChecked]);
const IconComponent = variant === "info" ? Info : AlertTriangle;
const iconClass =
@@ -58,13 +74,26 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
{checkboxLabel ? (
<label className="flex cursor-pointer select-none items-start gap-2 px-6 pt-3">
<Checkbox
checked={checkboxChecked}
onCheckedChange={(value) => setCheckboxChecked(value === true)}
className="mt-0.5"
/>
<span className="text-sm leading-relaxed">{checkboxLabel}</span>
</label>
) : null}
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
<Button
variant={variant === "info" ? "default" : "destructive"}
onClick={onConfirm}
onClick={() =>
// 未渲染勾选框时不得回传 defaultChecked 残留值
onConfirm(checkboxLabel ? checkboxChecked : false)
}
>
{confirmText || t("common.confirm")}
</Button>
+15 -8
View File
@@ -331,6 +331,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [testing, setTesting] = useState(false);
// {{apiKey}}/{{baseUrl}} 实际注入值,镜像后端 resolve_script_credentials 的
// 优先级:脚本配置中的显式非空值优先(旧配置可能携带),否则回退供应商凭据。
const effectiveScriptCredentials = {
apiKey: script.apiKey?.trim() || providerCredentials.apiKey,
baseUrl:
script.baseUrl?.trim().replace(/\/+$/, "") || providerCredentials.baseUrl,
};
// 🔧 失焦时的验证(严格)- 仅确保有效整数
const validateTimeout = (value: string): number => {
const num = Number(value);
@@ -678,13 +686,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const preset = PRESET_TEMPLATES[presetName];
if (preset !== undefined) {
if (presetName === TEMPLATE_TYPES.CUSTOM) {
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
// 这样可以避免同源检查导致的问题
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
// 自定义模板没有凭证输入框:清空显式覆盖值后,测试与真实查询
// 都会在后端回退到供应商配置(Provider::resolve_usage_credentials),
// 与下方“支持的变量”区域展示的 {{apiKey}}/{{baseUrl}} 取值一致。
setScript({
...script,
code: preset,
// 清除凭证,用户可选择手动输入或保持空
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
@@ -886,9 +893,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{"{{baseUrl}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.baseUrl ? (
{effectiveScriptCredentials.baseUrl ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.baseUrl}
{effectiveScriptCredentials.baseUrl}
</code>
) : (
<span className="text-muted-foreground/50 italic">
@@ -903,11 +910,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{"{{apiKey}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.apiKey ? (
{effectiveScriptCredentials.apiKey ? (
<>
{showApiKey ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.apiKey}
{effectiveScriptCredentials.apiKey}
</code>
) : (
<code className="text-foreground/70 font-mono">
+3 -3
View File
@@ -1,4 +1,5 @@
import {
Activity,
BarChart3,
Check,
Copy,
@@ -8,7 +9,6 @@ import {
Play,
Plus,
Terminal,
TestTube2,
Trash2,
Zap,
} from "lucide-react";
@@ -310,7 +310,7 @@ export function ProviderActions({
variant="ghost"
onClick={onTest || undefined}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
title={t("provider.connectivityCheck", "检测连通")}
className={cn(
iconButtonClass,
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
@@ -319,7 +319,7 @@ export function ProviderActions({
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
<Activity className="h-4 w-4" />
)}
</Button>
+6 -8
View File
@@ -232,9 +232,6 @@ export function ProviderCard({
provider.meta?.apiFormat,
(provider.settingsConfig as Record<string, any>)?.config,
]);
const isClaudeThirdParty =
appId === "claude" && provider.category === "third_party";
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent
const shouldAutoQuery =
@@ -551,11 +548,12 @@ export function ProviderCard({
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onTest={
onTest &&
!isOfficial &&
!isCopilot &&
!isCodexOauth &&
!isClaudeThirdParty
// 连通检测对第三方/自定义/Copilot/Codex-OAuth 供应商开放(这些正是旧的
// 真实请求探测会误报、而可达性探测能正确处理的对象)。官方供应商
// (category === "official") 一律隐藏:它们 base_url 故意留空、走客户端
// 默认/OAuth 端点,cc-switch 没有可靠的探测目标(尤其 Claude Desktop
// 官方是原生 1P 模式,根本不在请求路径上)。
onTest && provider.category !== "official"
? () => onTest(provider)
: undefined
}
+3 -48
View File
@@ -45,8 +45,6 @@ import {
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api/settings";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -192,9 +190,6 @@ export function ProviderList({
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false);
const [pendingTestProvider, setPendingTestProvider] =
useState<Provider | null>(null);
const { data: claudeDesktopStatus } = useQuery({
queryKey: ["claudeDesktopStatus"],
queryFn: () => providersApi.getClaudeDesktopStatus(),
@@ -202,41 +197,14 @@ export function ProviderList({
refetchInterval: appId === "claude-desktop" ? 5000 : false,
});
// Query settings for streamCheckConfirmed flag
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsApi.get(),
});
// 连通性检查不发真实请求、无封号/计费风险,直接执行(无需确认弹窗)。
const handleTest = useCallback(
(provider: Provider) => {
if (!settings?.streamCheckConfirmed) {
setPendingTestProvider(provider);
setShowStreamCheckConfirm(true);
} else {
checkProvider(provider.id, provider.name);
}
checkProvider(provider.id, provider.name);
},
[checkProvider, settings?.streamCheckConfirmed],
[checkProvider],
);
const handleStreamCheckConfirm = async () => {
setShowStreamCheckConfirm(false);
try {
if (settings) {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, streamCheckConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save stream check confirmed:", error);
}
if (pendingTestProvider) {
checkProvider(pendingTestProvider.id, pendingTestProvider.name);
setPendingTestProvider(null);
}
};
// Import current live config as default provider
const queryClient = useQueryClient();
const importMutation = useMutation({
@@ -558,19 +526,6 @@ export function ProviderList({
) : (
renderProviderList()
)}
<ConfirmDialog
isOpen={showStreamCheckConfirm}
variant="info"
title={t("confirm.streamCheck.title")}
message={t("confirm.streamCheck.message")}
confirmText={t("confirm.streamCheck.confirm")}
onConfirm={() => void handleStreamCheckConfirm()}
onCancel={() => {
setShowStreamCheckConfirm(false);
setPendingTestProvider(null);
}}
/>
</div>
);
}
@@ -119,7 +119,7 @@ const CLAUDE_ROUTE_PREFIX = "claude-";
const ANTHROPIC_CLAUDE_ROUTE_PREFIX = "anthropic/claude-";
const LEGACY_ONE_M_MARKER = "[1m]";
const ROLE_ROUTE_IDS = CLAUDE_DESKTOP_ROLE_ROUTE_IDS;
const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "haiku"];
const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "fable", "haiku"];
function envString(
settingsConfig: Record<string, unknown> | undefined,
@@ -140,8 +140,10 @@ function clonePlainRecord(value: unknown): Record<string, unknown> {
function routeRoleFromId(route: string): RouteRole {
const normalized = route.trim().toLowerCase();
// 与后端 claude_role_keyword 同序(opus → haiku → fable → sonnet)。
if (normalized.includes("opus")) return "opus";
if (normalized.includes("haiku")) return "haiku";
if (normalized.includes("fable")) return "fable";
return "sonnet";
}
@@ -195,9 +197,10 @@ function initialRouteRows(
});
}
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Haiku 档。
// 把任意来源的 route 行按角色归类到固定槽(缺档留空),保证 UI 永远行、
// 用户不会漏配 Haiku 导致子 agent 找不到模型。
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Fable / Haiku 档。
// 把任意来源的 route 行按角色归类到固定槽(缺档留空),保证 UI 永远行、
// 用户不会漏配某档导致子 agent 找不到模型。
// fable 自 Desktop 1.12603.1+ 起被 fail-all 校验放行,可作为独立档位。)
function normalizeProxyRows(rows: RouteRow[]): RouteRow[] {
return ROLE_ORDER.map((role) => {
const match = rows.find(
@@ -223,7 +226,8 @@ function isClaudeSafeRoute(route: string) {
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
return ["sonnet-", "opus-", "haiku-"].some(
// 与后端 is_claude_safe_model_id 镜像;fable 自 Desktop 1.12603.1+ 起被校验放行。
return ["sonnet-", "opus-", "haiku-", "fable-"].some(
(prefix) =>
routeTail.startsWith(prefix) && routeTail.length > prefix.length,
);
@@ -577,9 +581,9 @@ export function ClaudeDesktopProviderForm({
.filter((route) => route.route || route.model);
if (mode === "proxy") {
// 固定档(Sonnet / Opus / Haiku),route_id 由 UI 生成、恒合法,
// 固定档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法,
// 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先),
// 对齐 Claude Code 的兜底,保证落库档齐全、子 agent 不会找不到 Haiku
// 对齐 Claude Code 的兜底,保证落库档齐全、子 agent 不会找不到模型
const primary = routeEntries.find((route) => route.model);
if (!primary) {
toast.error(
@@ -946,9 +950,22 @@ export function ClaudeDesktopProviderForm({
? t("claudeDesktop.routeRoleHaiku", {
defaultValue: "Haiku",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
: role === "fable"
? t("claudeDesktop.routeRoleFable", {
defaultValue: "Fable",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
// Haiku 档示范映射到轻量模型(flash),其余档映射到 pro;
// 两列占位联动,保持每行「菜单显示名 ↔ 实际请求模型」品牌一致。
const isHaikuRole = role === "haiku";
const labelPlaceholder = isHaikuRole
? "DeepSeek V4 Flash"
: "DeepSeek V4 Pro";
const modelPlaceholder = isHaikuRole
? "deepseek-v4-flash"
: "deepseek-v4-pro";
return (
<div
key={route.rowId}
@@ -964,7 +981,7 @@ export function ClaudeDesktopProviderForm({
labelOverride: event.target.value,
})
}
placeholder="DeepSeek V4 Pro"
placeholder={labelPlaceholder}
/>
<div className="flex gap-1">
<Input
@@ -972,7 +989,7 @@ export function ClaudeDesktopProviderForm({
onChange={(event) =>
updateRoute(index, { model: event.target.value })
}
placeholder="kimi-k2 / deepseek-chat"
placeholder={modelPlaceholder}
className="flex-1"
/>
{fetchedModels.length > 0 && (
@@ -47,6 +47,7 @@ import {
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import { CustomUserAgentField } from "./CustomUserAgentField";
import type {
ProviderCategory,
ClaudeApiFormat,
@@ -125,6 +126,8 @@ interface ClaudeFormFieldsProps {
defaultSonnetModelName: string;
defaultOpusModel: string;
defaultOpusModelName: string;
defaultFableModel: string;
defaultFableModelName: string;
onModelChange: (field: ClaudeModelEnvField, value: string) => void;
// Speed Test Endpoints
@@ -141,6 +144,10 @@ interface ClaudeFormFieldsProps {
// Full URL mode
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
// Local proxy User-Agent override
customUserAgent: string;
onCustomUserAgentChange: (value: string) => void;
}
export function ClaudeFormFields({
@@ -186,6 +193,8 @@ export function ClaudeFormFields({
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
onModelChange,
speedTestEndpoints,
apiFormat,
@@ -194,6 +203,8 @@ export function ClaudeFormFields({
onApiKeyFieldChange,
isFullUrl,
onFullUrlChange,
customUserAgent,
onCustomUserAgentChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
const hasAnyAdvancedValue = !!(
@@ -201,8 +212,10 @@ export function ClaudeFormFields({
defaultHaikuModel ||
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
customUserAgent
);
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
@@ -255,7 +268,7 @@ export function ClaudeFormFields({
const modelsUrl = matchedPreset?.modelsUrl;
setIsFetchingModels(true);
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl)
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl, customUserAgent)
.then((models) => {
setFetchedModels(models);
showModelFetchResult(models.length);
@@ -265,7 +278,7 @@ export function ClaudeFormFields({
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]);
}, [baseUrl, apiKey, isFullUrl, customUserAgent, showModelFetchResult, t]);
const handleFetchCopilotModels = useCallback(() => {
if (!isCopilotAuthenticated) {
@@ -491,7 +504,7 @@ export function ClaudeFormFields({
};
type ModelRoleRow = {
role: "sonnet" | "opus" | "haiku";
role: "sonnet" | "opus" | "fable" | "haiku";
label: string;
model: string;
displayName: string;
@@ -522,6 +535,16 @@ export function ClaudeFormFields({
inputId: "claudeDefaultOpusModel",
supportsOneM: true,
},
{
role: "fable",
label: t("providerForm.modelRoleFable", { defaultValue: "Fable" }),
model: defaultFableModel,
displayName: defaultFableModelName,
modelField: "ANTHROPIC_DEFAULT_FABLE_MODEL",
displayNameField: "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
inputId: "claudeDefaultFableModel",
supportsOneM: true,
},
{
role: "haiku",
label: t("providerForm.modelRoleHaiku", { defaultValue: "Haiku" }),
@@ -681,7 +704,6 @@ export function ClaudeFormFields({
/>
)}
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
{shouldShowModelSelector && (
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
<CollapsibleTrigger asChild>
@@ -795,6 +817,7 @@ export function ClaudeFormFields({
claudeModel ||
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
defaultHaikuModel;
if (value) {
for (const row of modelRoleRows) {
@@ -818,7 +841,8 @@ export function ClaudeFormFields({
!claudeModel &&
!defaultHaikuModel &&
!defaultSonnetModel &&
!defaultOpusModel
!defaultOpusModel &&
!defaultFableModel
}
className="h-7 gap-1"
>
@@ -945,10 +969,16 @@ export function ClaudeFormFields({
<p className="text-xs text-muted-foreground">
{t("providerForm.fallbackModelHint", {
defaultValue:
"仅在 Claude Code 请求没有明确落到 Sonnet、OpusHaiku 角色时使用;通常可以留空。",
"用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。",
})}
</p>
</div>
<CustomUserAgentField
id="claude-custom-user-agent"
value={customUserAgent}
onChange={onCustomUserAgentChange}
/>
</CollapsibleContent>
</Collapsible>
)}
+282 -212
View File
@@ -26,6 +26,8 @@ import {
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import { CustomUserAgentField } from "./CustomUserAgentField";
import { cn } from "@/lib/utils";
import type {
CodexApiFormat,
CodexCatalogModel,
@@ -79,6 +81,10 @@ interface CodexFormFieldsProps {
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
// Local proxy User-Agent override
customUserAgent: string;
onCustomUserAgentChange: (value: string) => void;
}
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
@@ -140,12 +146,13 @@ export function CodexFormFields({
catalogModels = [],
onCatalogModelsChange,
speedTestEndpoints,
customUserAgent,
onCustomUserAgentChange,
}: CodexFormFieldsProps) {
const { t } = useTranslation();
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const [reasoningExpanded, setReasoningExpanded] = useState(false);
const needsLocalRouting = apiFormat === "openai_chat";
const canEditCatalog = Boolean(onCatalogModelsChange);
const canEditReasoning = Boolean(onCodexChatReasoningChange);
@@ -154,6 +161,17 @@ export function CodexFormFields({
codexChatReasoning.supportsEffort === true;
const supportsEffort = codexChatReasoning.supportsEffort === true;
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting;
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => {
if (hasAnyAdvancedValue) {
setAdvancedExpanded(true);
}
}, [hasAnyAdvancedValue]);
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
catalogModels.map((m) => createCatalogRow(m)),
);
@@ -228,7 +246,13 @@ export function CodexFormFields({
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl)
fetchModelsForConfig(
codexBaseUrl,
codexApiKey,
isFullUrl,
undefined,
customUserAgent,
)
.then((models) => {
setFetchedModels(models);
if (models.length === 0) {
@@ -244,7 +268,7 @@ export function CodexFormFields({
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
}, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]);
const handleAddCatalogRow = useCallback(() => {
if (!onCatalogModelsChange) return;
@@ -350,42 +374,11 @@ export function CodexFormFields({
/>
)}
{shouldShowSpeedTest && (
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{needsLocalRouting
? t("codexConfig.localRoutingOnHint", {
defaultValue:
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
})
: t("codexConfig.localRoutingOffHint", {
defaultValue:
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
})}
</p>
</div>
<Switch
checked={needsLocalRouting}
onCheckedChange={handleLocalRoutingChange}
aria-label={t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
/>
</div>
</div>
)}
{needsLocalRouting && canEditReasoning && (
{/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
{category !== "official" && (
<Collapsible
open={reasoningExpanded}
onOpenChange={setReasoningExpanded}
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
className="rounded-lg border border-border-default p-4"
>
<CollapsibleTrigger asChild>
@@ -395,205 +388,282 @@ export function CodexFormFields({
size="sm"
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
>
{reasoningExpanded ? (
{advancedExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("codexConfig.reasoningSectionToggle", {
defaultValue: "思考能力(高级·通常自动识别)",
{t("providerForm.advancedOptionsToggle", {
defaultValue: "高级选项",
})}
</Button>
</CollapsibleTrigger>
{!reasoningExpanded && (
{!advancedExpanded && (
<p className="mt-1 ml-1 text-xs text-muted-foreground">
{t("codexConfig.reasoningSectionHint", {
{t("codexConfig.advancedSectionHint", {
defaultValue:
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。",
"包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。",
})}
</p>
)}
<CollapsibleContent className="space-y-3 pt-3">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
{/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */}
{shouldShowSpeedTest && (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{needsLocalRouting
? t("codexConfig.localRoutingOnHint", {
defaultValue:
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
})
: t("codexConfig.localRoutingOffHint", {
defaultValue:
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
})}
</p>
</div>
<Switch
checked={needsLocalRouting}
onCheckedChange={handleLocalRoutingChange}
aria-label={t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningModeHint", {
defaultValue:
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
})}
</p>
/>
</div>
<Switch
checked={supportsThinking}
onCheckedChange={handleReasoningThinkingChange}
aria-label={t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
/>
</div>
)}
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningEffortHint", {
defaultValue:
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
})}
</p>
</div>
<Switch
checked={supportsEffort}
onCheckedChange={handleReasoningEffortChange}
aria-label={t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
{needsLocalRouting && canEditReasoning && (
<div
className={cn(
"space-y-3",
shouldShowSpeedTest && "border-t border-border-default pt-3",
)}
>
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningGroupTitle", {
defaultValue: "思考能力",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningSectionHint", {
defaultValue:
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
})}
</p>
</div>
{/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */}
{needsLocalRouting && canEditCatalog && (
<div className="space-y-4 rounded-lg border border-border-default p-4">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<FormLabel>
{t("codexConfig.modelMappingTitle", {
defaultValue: "模型映射",
})}
</FormLabel>
{renderCatalogActionButtons(
handleAddCatalogRow,
t("codexConfig.addCatalogModel", {
defaultValue: "添加模型",
}),
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.modelMappingHint", {
defaultValue:
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
})}
</p>
</div>
{catalogRows.length > 0 && (
<div className="space-y-2">
{/* 列头:md+ 显示 */}
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
</span>
<span>
{t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
</span>
<span>
{t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
</span>
<span />
</div>
{catalogRows.map((row, index) => (
<div
key={row.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
>
<Input
value={row.displayName ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
displayName: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogDisplayNamePlaceholder",
{
defaultValue: "例如: DeepSeek V4 Flash",
},
)}
aria-label={t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningModeHint", {
defaultValue:
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
})}
</p>
</div>
<Switch
checked={supportsThinking}
onCheckedChange={handleReasoningThinkingChange}
aria-label={t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
/>
<div className="flex gap-1">
<Input
value={row.model}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: event.target.value,
})
}
placeholder={t("codexConfig.catalogModelPlaceholder", {
defaultValue: "例如: deepseek-v4-flash",
</div>
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
aria-label={t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningEffortHint", {
defaultValue:
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
handleUpdateCatalogRow(index, {
model: id,
displayName: row.displayName?.trim()
? row.displayName
: id,
})
}
/>
</p>
</div>
<Switch
checked={supportsEffort}
onCheckedChange={handleReasoningEffortChange}
aria-label={t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
/>
</div>
</div>
)}
<div
className={cn(
(shouldShowSpeedTest ||
(needsLocalRouting && canEditReasoning)) &&
"border-t border-border-default pt-3",
)}
>
<CustomUserAgentField
id="codex-custom-user-agent"
value={customUserAgent}
onChange={onCustomUserAgentChange}
/>
</div>
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
{needsLocalRouting && canEditCatalog && (
<div className="space-y-4 border-t border-border-default pt-3">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<FormLabel>
{t("codexConfig.modelMappingTitle", {
defaultValue: "模型映射",
})}
</FormLabel>
{renderCatalogActionButtons(
handleAddCatalogRow,
t("codexConfig.addCatalogModel", {
defaultValue: "添加模型",
}),
)}
</div>
<Input
type="number"
min={1}
inputMode="numeric"
value={row.contextWindow ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
contextWindow: event.target.value.replace(/[^\d]/g, ""),
})
}
placeholder={t("codexConfig.contextWindowPlaceholder", {
defaultValue: "例如: 128000",
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.modelMappingHint", {
defaultValue:
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
})}
aria-label={t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveCatalogRow(index)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</p>
</div>
))}
</div>
)}
</div>
{catalogRows.length > 0 && (
<div className="space-y-2">
{/* 列头:md+ 显示 */}
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
</span>
<span>
{t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
</span>
<span>
{t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
</span>
<span />
</div>
{catalogRows.map((row, index) => (
<div
key={row.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
>
<Input
value={row.displayName ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
displayName: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogDisplayNamePlaceholder",
{
defaultValue: "例如: DeepSeek V4 Flash",
},
)}
aria-label={t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
/>
<div className="flex gap-1">
<Input
value={row.model}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogModelPlaceholder",
{
defaultValue: "例如: deepseek-v4-flash",
},
)}
aria-label={t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
handleUpdateCatalogRow(index, {
model: id,
displayName: row.displayName?.trim()
? row.displayName
: id,
})
}
/>
)}
</div>
<Input
type="number"
min={1}
inputMode="numeric"
value={row.contextWindow ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
contextWindow: event.target.value.replace(
/[^\d]/g,
"",
),
})
}
placeholder={t(
"codexConfig.contextWindowPlaceholder",
{
defaultValue: "例如: 128000",
},
)}
aria-label={t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveCatalogRow(index)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
{/* 端点测速弹窗 - Codex */}
@@ -0,0 +1,96 @@
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { isValidUserAgentHeader } from "@/lib/userAgent";
import { USER_AGENT_PRESETS } from "@/config/userAgentPresets";
interface CustomUserAgentFieldProps {
/** 输入框的 id(用于 label htmlFor);两个表单需传入各自唯一值。 */
id: string;
value: string;
onChange: (value: string) => void;
}
/**
* 供应商级自定义 User-Agent 字段(Claude / Codex 表单共用)。
*
* 含标签 + 输入框 + 右侧预设下拉菜单 + 实时合法性提示。校验口径与后端
* `parse_custom_user_agent` 一致(见 `@/lib/userAgent`),非法时给非阻断红字提示
* (运行时仍会静默忽略)。
*/
export function CustomUserAgentField({
id,
value,
onChange,
}: CustomUserAgentFieldProps) {
const { t } = useTranslation();
const valid = isValidUserAgentHeader(value);
return (
<div className="space-y-2">
<FormLabel htmlFor={id}>
{t("providerForm.customUserAgent", {
defaultValue: "自定义 User-Agent",
})}
</FormLabel>
<div className="flex items-center gap-2">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Mozilla/5.0 ..."
autoComplete="off"
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" className="shrink-0 gap-1">
{t("providerForm.customUserAgentPresets", {
defaultValue: "预设",
})}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-64 overflow-y-auto z-[200]"
>
{USER_AGENT_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset}
onSelect={() => onChange(preset)}
className="font-mono text-xs"
>
{preset}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{valid ? (
<p className="text-xs text-muted-foreground">
{t("providerForm.customUserAgentHint", {
defaultValue:
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
})}
</p>
) : (
<p className="text-xs text-destructive">
{t("providerForm.customUserAgentInvalid", {
defaultValue:
"User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
})}
</p>
)}
</div>
);
}
@@ -61,7 +61,7 @@ export function ProviderAdvancedConfig({
<FlaskConical className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.testConfig", {
defaultValue: "模型测试配置",
defaultValue: "连通检测配置",
})}
</span>
</div>
@@ -106,31 +106,10 @@ export function ProviderAdvancedConfig({
<p className="text-sm text-muted-foreground">
{t("providerAdvanced.testConfigDesc", {
defaultValue:
"为此供应商配置单独的模型测试参数,不启用时使用全局配置。",
"为此供应商配置单独的连通检测参数(超时/阈值/重试),不启用时使用全局配置。",
})}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="test-model">
{t("providerAdvanced.testModel", {
defaultValue: "测试模型",
})}
</Label>
<Input
id="test-model"
value={testConfig.testModel || ""}
onChange={(e) =>
onTestConfigChange({
...testConfig,
testModel: e.target.value || undefined,
})
}
placeholder={t("providerAdvanced.testModelPlaceholder", {
defaultValue: "留空使用全局配置",
})}
disabled={!testConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-timeout">
{t("providerAdvanced.timeoutSecs", {
@@ -141,7 +120,7 @@ export function ProviderAdvancedConfig({
id="test-timeout"
type="number"
min={1}
max={300}
max={60}
value={testConfig.timeoutSecs || ""}
onChange={(e) =>
onTestConfigChange({
@@ -151,26 +130,7 @@ export function ProviderAdvancedConfig({
: undefined,
})
}
placeholder="45"
disabled={!testConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-prompt">
{t("providerAdvanced.testPrompt", {
defaultValue: "测试提示词",
})}
</Label>
<Input
id="test-prompt"
value={testConfig.testPrompt || ""}
onChange={(e) =>
onTestConfigChange({
...testConfig,
testPrompt: e.target.value || undefined,
})
}
placeholder="Who are you?"
placeholder="8"
disabled={!testConfig.enabled}
/>
</div>
@@ -208,7 +168,7 @@ export function ProviderAdvancedConfig({
id="max-retries"
type="number"
min={0}
max={10}
max={5}
value={testConfig.maxRetries ?? ""}
onChange={(e) =>
onTestConfigChange({
@@ -218,7 +178,7 @@ export function ProviderAdvancedConfig({
: undefined,
})
}
placeholder="2"
placeholder="1"
disabled={!testConfig.enabled}
/>
</div>
@@ -379,6 +379,7 @@ function ProviderFormFull({
);
setCodexFastMode(initialData?.meta?.codexFastMode ?? false);
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
}, [appId, initialData, supportsFullUrl]);
const defaultValues: ProviderFormData = useMemo(
@@ -472,6 +473,8 @@ function ProviderFormFull({
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
@@ -533,6 +536,9 @@ function ProviderFormFull({
useState<CodexChatReasoning>(
() => initialData?.meta?.codexChatReasoning ?? {},
);
const [customUserAgent, setCustomUserAgent] = useState<string>(
() => initialData?.meta?.customUserAgent ?? "",
);
const {
codexAuth,
@@ -1446,6 +1452,10 @@ function ProviderFormFull({
localCodexApiFormat === "openai_chat"
? normalizeCodexChatReasoningForSave(codexChatReasoning)
: undefined,
customUserAgent:
(appId === "claude" || appId === "codex") && category !== "official"
? customUserAgent.trim() || undefined
: undefined,
testConfig: testConfig.enabled ? testConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
@@ -2061,6 +2071,8 @@ function ProviderFormFull({
defaultSonnetModelName={defaultSonnetModelName}
defaultOpusModel={defaultOpusModel}
defaultOpusModelName={defaultOpusModelName}
defaultFableModel={defaultFableModel}
defaultFableModelName={defaultFableModelName}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
@@ -2069,6 +2081,8 @@ function ProviderFormFull({
onApiKeyFieldChange={handleApiKeyFieldChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
/>
)}
@@ -2106,6 +2120,8 @@ function ProviderFormFull({
catalogModels={codexCatalogModels}
onCatalogModelsChange={setCodexCatalogModels}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
/>
)}
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { Zap, Star, Layers, Settings2 } from "lucide-react";
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
@@ -16,7 +19,17 @@ import {
} from "@/config/universalProviderPresets";
import { ProviderIcon } from "@/components/ProviderIcon";
type AnyPreset =
type PresetTranslator = (key: string) => unknown;
export const PresetSortMode = {
Original: "original",
NameAsc: "nameAsc",
} as const;
export type PresetSortMode =
(typeof PresetSortMode)[keyof typeof PresetSortMode];
export type AnyPreset =
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
@@ -25,11 +38,73 @@ type AnyPreset =
| OpenClawProviderPreset
| HermesProviderPreset;
type PresetEntry = {
export type PresetEntry = {
id: string;
preset: AnyPreset;
};
export function getPresetDisplayName(
preset: AnyPreset,
t: PresetTranslator,
): string {
return preset.nameKey ? String(t(preset.nameKey)) : preset.name;
}
export function getPresetSearchText(
entry: PresetEntry,
t: PresetTranslator,
): string {
return [getPresetDisplayName(entry.preset, t), entry.preset.name]
.join(" ")
.toLowerCase();
}
export function filterPresetEntries(
entries: PresetEntry[],
query: string,
t: PresetTranslator,
): PresetEntry[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return entries;
}
return entries.filter((entry) =>
getPresetSearchText(entry, t).includes(normalizedQuery),
);
}
export function sortPresetEntries(
entries: PresetEntry[],
sortMode: PresetSortMode,
t: PresetTranslator,
): PresetEntry[] {
if (sortMode === PresetSortMode.Original) {
return [...entries];
}
return [...entries].sort((a, b) =>
getPresetDisplayName(a.preset, t).localeCompare(
getPresetDisplayName(b.preset, t),
),
);
}
export interface PresetVisibilityOptions {
query: string;
sortMode: PresetSortMode;
t: PresetTranslator;
}
export function getVisiblePresetEntries(
entries: PresetEntry[],
options: PresetVisibilityOptions,
): PresetEntry[] {
const { query, sortMode, t } = options;
return sortPresetEntries(filterPresetEntries(entries, query, t), sortMode, t);
}
interface ProviderPresetSelectorProps {
selectedPresetId: string | null;
presetEntries: PresetEntry[];
@@ -50,8 +125,42 @@ export function ProviderPresetSelector({
category,
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortMode, setSortMode] = useState<PresetSortMode>(
PresetSortMode.Original,
);
const searchContainerRef = useRef<HTMLDivElement>(null);
const getCategoryHint = (): React.ReactNode => {
// 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为
useEffect(() => {
if (!searchOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
searchContainerRef.current &&
!searchContainerRef.current.contains(event.target as Node)
) {
setSearchOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [searchOpen]);
const visiblePresetEntries = useMemo(
() =>
getVisiblePresetEntries(presetEntries, {
query: searchQuery,
sortMode,
t,
}),
[presetEntries, searchQuery, sortMode, t],
);
const getCategoryHint = (): ReactNode => {
switch (category) {
case "official":
return t("providerForm.officialHint", {
@@ -85,27 +194,47 @@ export function ProviderPresetSelector({
}
};
const renderPresetIcon = (preset: AnyPreset) => {
const iconType = preset.theme?.icon;
if (!iconType) return null;
const toggleSortMode = () => {
setSortMode((current) =>
current === PresetSortMode.Original
? PresetSortMode.NameAsc
: PresetSortMode.Original,
);
};
switch (iconType) {
case "claude":
return <ClaudeIcon size={14} />;
case "codex":
return <CodexIcon size={14} />;
case "gemini":
return <GeminiIcon size={14} />;
case "generic":
return <Zap size={14} />;
default:
return null;
const renderPresetIcon = (preset: AnyPreset) => {
if (preset.icon) {
return (
<ProviderIcon
icon={preset.icon}
name={preset.name}
color={preset.iconColor}
size={16}
className="flex-shrink-0"
/>
);
}
const iconType = preset.theme?.icon;
if (iconType) {
switch (iconType) {
case "claude":
return <ClaudeIcon size={14} />;
case "codex":
return <CodexIcon size={14} />;
case "gemini":
return <GeminiIcon size={14} />;
case "generic":
return <Zap size={14} />;
}
}
return <span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />;
};
const getPresetButtonClass = (isSelected: boolean, preset: AnyPreset) => {
const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
"inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full";
if (isSelected) {
if (preset.theme?.backgroundColor) {
@@ -130,21 +259,104 @@ export function ProviderPresetSelector({
return (
<div className="space-y-3">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div className="flex flex-wrap gap-2">
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div ref={searchContainerRef} className="flex items-center gap-2">
{searchOpen && (
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setSearchQuery("");
setSearchOpen(false);
}
}}
placeholder={t("providerPreset.searchPlaceholder", {
defaultValue: "Search presets...",
})}
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className="w-48 h-8"
autoFocus
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
aria-pressed={searchOpen}
onClick={() => {
setSearchOpen((v) => !v);
if (searchOpen) setSearchQuery("");
}}
title={t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
className={
searchOpen || searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
title={
sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})
}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
<button
type="button"
onClick={() => onPresetChange("custom")}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full ${
selectedPresetId === "custom"
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
{t("providerPreset.custom")}
<span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />
<span className="truncate">{t("providerPreset.custom")}</span>
</button>
{presetEntries.map((entry) => {
{visiblePresetEntries.length === 0 && (
<div className="col-span-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
{t("providerPreset.noSearchResults", {
defaultValue: "No matching presets.",
})}
</div>
)}
{visiblePresetEntries.map((entry) => {
const isSelected = selectedPresetId === entry.id;
const isPartner = entry.preset.isPartner;
const presetCategory = entry.preset.category ?? "others";
@@ -161,9 +373,9 @@ export function ProviderPresetSelector({
}
>
{renderPresetIcon(entry.preset)}
{entry.preset.nameKey
? t(entry.preset.nameKey)
: entry.preset.name}
<span className="truncate">
{getPresetDisplayName(entry.preset, t)}
</span>
{isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
@@ -176,20 +388,25 @@ export function ProviderPresetSelector({
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
title={t("universalProvider.hint", {
defaultValue:
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
})}
>
<ProviderIcon icon={preset.icon} name={preset.name} size={14} />
{preset.name}
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={14}
className="flex-shrink-0"
/>
<span className="truncate">{preset.name}</span>
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
@@ -199,15 +416,17 @@ export function ProviderPresetSelector({
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80"
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4" />
{t("universalProvider.manage", {
defaultValue: "管理",
})}
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</span>
</button>
)}
</div>
@@ -12,7 +12,9 @@ export type ClaudeModelEnvField =
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"
| "ANTHROPIC_DEFAULT_OPUS_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME";
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"
| "ANTHROPIC_DEFAULT_FABLE_MODEL"
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME";
export const CLAUDE_ONE_M_MARKER = "[1M]";
@@ -69,8 +71,28 @@ function parseModelsFromConfig(settingsConfig: string) {
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME
: stripClaudeOneMMarker(opus);
// 回填链镜像运行时映射链(fable → opus → default),保证 UI 展示
// 与代理实际转发的模型一致。
const fable =
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL
: opus;
const fableName =
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME
: stripClaudeOneMMarker(fable);
return { model, haiku, haikuName, sonnet, sonnetName, opus, opusName };
return {
model,
haiku,
haikuName,
sonnet,
sonnetName,
opus,
opusName,
fable,
fableName,
};
} catch {
return {
model: "",
@@ -80,6 +102,8 @@ function parseModelsFromConfig(settingsConfig: string) {
sonnetName: "",
opus: "",
opusName: "",
fable: "",
fableName: "",
};
}
}
@@ -106,6 +130,10 @@ export function useModelState({
const [defaultOpusModelName, setDefaultOpusModelName] = useState(
initial.opusName,
);
const [defaultFableModel, setDefaultFableModel] = useState(initial.fable);
const [defaultFableModelName, setDefaultFableModelName] = useState(
initial.fableName,
);
const isUserEditingRef = useRef(false);
const lastConfigRef = useRef(settingsConfig);
@@ -134,6 +162,8 @@ export function useModelState({
setDefaultSonnetModelName(parsed.sonnetName);
setDefaultOpusModel(parsed.opus);
setDefaultOpusModelName(parsed.opusName);
setDefaultFableModel(parsed.fable);
setDefaultFableModelName(parsed.fableName);
}, [settingsConfig]);
const handleModelChange = useCallback(
@@ -152,6 +182,10 @@ export function useModelState({
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value);
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME")
setDefaultOpusModelName(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL")
setDefaultFableModel(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME")
setDefaultFableModelName(value);
try {
const currentConfig = latestConfigRef.current
@@ -195,6 +229,10 @@ export function useModelState({
setDefaultOpusModel,
defaultOpusModelName,
setDefaultOpusModelName,
defaultFableModel,
setDefaultFableModel,
defaultFableModelName,
setDefaultFableModelName,
handleModelChange,
};
}
+151 -82
View File
@@ -32,10 +32,10 @@ import type {
ToolInstallationReport,
} from "@/lib/api/settings";
import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
import appIcon from "@/assets/icons/app-icon.png";
import fable5VerifiedBanner from "@/assets/fable5-verified.png";
import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import { extractErrorMessage } from "@/utils/errorUtils";
@@ -179,14 +179,51 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
hermes: "hermes",
};
// 工具版本探测代价高:每个工具一次 `--version` 子进程 + 一次 npm/github/pypi 网络请求。
// 设置页用 Radix Tabs,非激活 Tab 会被卸载——每次切回「关于」都重挂 AboutSection,若都
// 全量重查纯属浪费。用「模块级」缓存(生命周期 = JS 模块 = 应用会话,不随组件卸载销毁)
// 跨重挂存活:重挂时若缓存仍新鲜(距上次全量加载 < TTL)直接复用、跳过探测;超期或用户
// 手动「刷新」才强制重查。at = 最近一次「全量加载」完成时刻;单工具刷新(切 shell / 升级
// 后)只更新数据、不重置 at,避免一次局部刷新把整体 TTL 续命。
const TOOL_VERSIONS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 分钟
let toolVersionsCache: { data: ToolVersion[]; at: number } | null = null;
// 应用自身版本(getVersion,本地毫秒级、无网络)也缓存一份,纯为重挂时免去 loading 闪烁。
let appVersionCache: string | null = null;
// 把探测结果按 name 合并进已有列表:替换同名项、追加新项;空列表时直接采用新结果。
// 组件 state 与模块缓存共用同一套合并语义(单工具与全量探测都经此函数)。
function mergeToolVersions(
prev: ToolVersion[],
updated: ToolVersion[],
): ToolVersion[] {
if (prev.length === 0) return updated;
const byName = new Map(updated.map((t) => [t.name, t]));
const merged = prev.map((t) => byName.get(t.name) ?? t);
const existing = new Set(prev.map((t) => t.name));
for (const u of updated) {
if (!existing.has(u.name)) merged.push(u);
}
return merged;
}
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation();
const [version, setVersion] = useState<string | null>(null);
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
// 惰性初始化自模块缓存:重挂时首帧即渲染上次的值,避免 loading 闪烁;首次挂载缓存
// 为空则回退到原始初值(null / loading)。
const [version, setVersion] = useState<string | null>(() => appVersionCache);
const [isLoadingVersion, setIsLoadingVersion] = useState(
() => appVersionCache === null,
);
const [isDownloading, setIsDownloading] = useState(false);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
const [isLoadingTools, setIsLoadingTools] = useState(true);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>(
() => toolVersionsCache?.data ?? [],
);
// 有缓存(哪怕已超期)就先展示旧值、初始不 loading;超期时由挂载副作用触发后台
// 重查(stale-while-revalidate)。无缓存(首次)才从 loading 起步。
const [isLoadingTools, setIsLoadingTools] = useState(
() => toolVersionsCache === null,
);
const [toolActions, setToolActions] = useState<
Partial<Record<ToolName, ToolLifecycleAction>>
>({});
@@ -195,14 +232,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
);
const [showInstallCommands, setShowInstallCommands] = useState(false);
const {
hasUpdate,
updateInfo,
updateHandle,
checkUpdate,
resetDismiss,
isChecking,
} = useUpdate();
const { hasUpdate, updateInfo, checkUpdate, resetDismiss, isChecking } =
useUpdate();
const [wslShellByTool, setWslShellByTool] = useState<
Record<string, WslShellPreference>
@@ -264,16 +295,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
wslOverrides,
);
setToolVersions((prev) => {
if (prev.length === 0) return updated;
const byName = new Map(updated.map((t) => [t.name, t]));
const merged = prev.map((t) => byName.get(t.name) ?? t);
const existing = new Set(prev.map((t) => t.name));
for (const u of updated) {
if (!existing.has(u.name)) merged.push(u);
}
return merged;
});
setToolVersions((prev) => mergeToolVersions(prev, updated));
// 同步进模块缓存,供切 Tab 重挂时复用。时间戳沿用上次「全量加载」的(单工具
// 刷新不算全量、不重置 TTL);缓存为空时以 at=0 起步——0 是「尚未完成全量加载」
// 的过期哨兵,确保探测中途切走/切回时,残缺缓存被判过期而触发重查,而非把半套
// 数据当成完整结果复用。真实时间戳只由 loadAllToolVersions 的 finally 盖上。
toolVersionsCache = {
data: mergeToolVersions(toolVersionsCache?.data ?? [], updated),
at: toolVersionsCache?.at ?? 0,
};
// 返回刷新结果,调用方可据此判断版本是否真的探到(避免读 state 撞 stale closure)。
return updated;
@@ -291,21 +321,42 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
[],
);
const loadAllToolVersions = useCallback(async () => {
setIsLoadingTools(true);
try {
// Respect current UI overrides (shell / flag) when doing a full refresh.
const versions = await settingsApi.getToolVersions(
[...TOOL_NAMES],
wslShellByTool,
);
setToolVersions(versions);
} catch (error) {
console.error("[AboutSection] Failed to load tool versions", error);
} finally {
setIsLoadingTools(false);
}
}, [wslShellByTool]);
const loadAllToolVersions = useCallback(
async (options?: { force?: boolean }) => {
const force = options?.force ?? false;
// 命中新鲜缓存:切回「关于」Tab 触发的重挂直接复用上次结果,跳过 6 个 `--version`
// 子进程 + 6 个 latest 版本网络请求。手动「刷新」传 force 绕过缓存强制重查。
if (
!force &&
toolVersionsCache &&
Date.now() - toolVersionsCache.at < TOOL_VERSIONS_CACHE_TTL_MS
) {
setToolVersions(toolVersionsCache.data);
setIsLoadingTools(false);
return;
}
setIsLoadingTools(true);
try {
// 逐工具并发探测:每个工具一完成就合并进 toolVersions(并写模块缓存)、清掉自己
// 的 loadingTools 标志,对应卡片随即独立刷新——而非等全部探测完才一次性显示(后端
// 原本对 6 个工具串行 await,总耗时累加;并发后压成「最慢的那一个」)。refreshTool-
// Versions 已内建按 name 合并 + per-tool loading + try/catch 兜底(单工具失败返回 []
// 不拖累其余),故 Promise.all 永不 reject。Respect current shell/flag overrides.
await Promise.all(
TOOL_NAMES.map((toolName) =>
refreshToolVersions([toolName], wslShellByTool),
),
);
} finally {
// 全量探测结束:把缓存时间戳刷新为现在,标记「刚完成一次全量加载」、重置 TTL。
if (toolVersionsCache) {
toolVersionsCache = { ...toolVersionsCache, at: Date.now() };
}
setIsLoadingTools(false);
}
},
[wslShellByTool, refreshToolVersions],
);
const handleToolShellChange = async (toolName: ToolName, value: string) => {
const wslShell = value === "auto" ? null : value;
@@ -332,18 +383,20 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
useEffect(() => {
let active = true;
const load = async () => {
try {
const [appVersion] = await Promise.all([
getVersion(),
loadAllToolVersions(),
]);
// 本软件自身版本走本地调用(getVersion,无网络,毫秒级),与工具版本探测彼此独立。
// 之前两者被塞进同一个 Promise.all,导致 setVersion / setIsLoadingVersion 被压在
// 「全部工具检查完成」之后——图标下方的版本徽标因此要干等 6 个工具全检完才显示。
// 拆成两条独立链路:应用版本一拿到就立刻显示,工具探测各自渐进刷新,互不阻塞。
const loadAppVersion = async () => {
try {
const appVersion = await getVersion();
appVersionCache = appVersion;
if (active) {
setVersion(appVersion);
}
} catch (error) {
console.error("[AboutSection] Failed to load info", error);
console.error("[AboutSection] Failed to load app version", error);
if (active) {
setVersion(null);
}
@@ -354,7 +407,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
};
void load();
void loadAppVersion();
void loadAllToolVersions();
return () => {
active = false;
};
@@ -392,7 +446,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}, [t, updateInfo?.availableVersion, version]);
const handleCheckUpdate = useCallback(async () => {
if (hasUpdate && updateHandle) {
if (hasUpdate) {
if (isPortable) {
try {
await settingsApi.checkUpdates();
@@ -405,11 +459,16 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
setIsDownloading(true);
try {
resetDismiss();
await updateHandle.downloadAndInstall();
await relaunchApp();
const installed = await settingsApi.installUpdateAndRestart();
if (!installed) {
toast.success(t("settings.upToDate"), { closeButton: true });
}
} catch (error) {
console.error("[AboutSection] Update failed", error);
toast.error(t("settings.updateFailed"));
toast.error(t("settings.updateFailed"), {
description: extractErrorMessage(error) || undefined,
closeButton: true,
});
try {
await settingsApi.checkUpdates();
} catch (fallbackError) {
@@ -433,7 +492,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
console.error("[AboutSection] Check update failed", error);
toast.error(t("settings.checkUpdateFailed"));
}
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t]);
const handleCopyInstallCommands = useCallback(async () => {
try {
@@ -768,31 +827,39 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background/80">
<span className="text-muted-foreground">
{t("common.version")}
</span>
{isLoadingVersion ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="font-medium">{`v${displayVersion}`}</span>
)}
</Badge>
{isPortable && (
<Badge variant="secondary" className="gap-1.5">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
<div className="flex items-center gap-8">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background/80">
<span className="text-muted-foreground">
{t("common.version")}
</span>
{isLoadingVersion ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="font-medium">{`v${displayVersion}`}</span>
)}
</Badge>
)}
{isPortable && (
<Badge variant="secondary" className="gap-1.5">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
</Badge>
)}
</div>
</div>
<img
src={fable5VerifiedBanner}
alt="Fable 5 Verified"
className="h-16 w-auto shrink-0 select-none"
draggable={false}
/>
</div>
<div className="flex items-center gap-2">
@@ -908,7 +975,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
onClick={() => loadAllToolVersions()}
onClick={() => loadAllToolVersions({ force: true })}
disabled={isLoadingTools || isAnyBusy}
>
<RefreshCw
@@ -943,8 +1010,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const tool = toolVersionByName.get(toolName);
const appConfig = APP_ICON_MAP[TOOL_APP_IDS[toolName]];
const displayName = TOOL_DISPLAY_NAMES[toolName];
// 单卡片 loading 用「结果是否已到」而非「整批是否结束」驱动,实现渐进式刷新:
// - loadingTools[t]:本工具探测在途(首次加载或单工具刷新);
// - isLoadingTools && !has(t):整批进行中且该工具尚未返回——覆盖首帧/刷新时
// 未完成卡片的 loading 外观。某工具结果一落进 toolVersionshas(t) 即为 true
// 该卡片立刻脱离 loading(哪怕全局 isLoadingTools 还为 true),其它卡片不受影响。
const isToolVersionLoading =
isLoadingTools || Boolean(loadingTools[toolName]);
Boolean(loadingTools[toolName]) ||
(isLoadingTools && !toolVersionByName.has(toolName));
const isOutdated = isUpdateAvailable(
tool?.version,
tool?.latest_version,
@@ -1047,9 +1120,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<Select
value={wslShellByTool[toolName]?.wslShell || "auto"}
onValueChange={(v) => handleToolShellChange(toolName, v)}
disabled={
isLoadingTools || loadingTools[toolName] || isAnyBusy
}
disabled={isToolVersionLoading || isAnyBusy}
>
<SelectTrigger className="h-7 w-[82px] text-xs">
<SelectValue />
@@ -1068,9 +1139,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
onValueChange={(v) =>
handleToolShellFlagChange(toolName, v)
}
disabled={
isLoadingTools || loadingTools[toolName] || isAnyBusy
}
disabled={isToolVersionLoading || isAnyBusy}
>
<SelectTrigger className="h-7 w-[82px] text-xs">
<SelectValue />
+109 -2
View File
@@ -1,11 +1,18 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound } from "lucide-react";
import { History, KeyRound } from "lucide-react";
import { toast } from "sonner";
import type { SettingsFormState } from "@/hooks/useSettings";
import { ToggleRow } from "@/components/ui/toggle-row";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
interface CodexAuthSettingsProps {
settings: SettingsFormState;
onChange: (updates: Partial<SettingsFormState>) => void;
/** 返回 false(或 resolve 为 false)表示保存失败;其余返回值视为成功 */
onChange: (
updates: Partial<SettingsFormState>,
) => void | boolean | Promise<void | boolean>;
}
export function CodexAuthSettings({
@@ -13,6 +20,73 @@ export function CodexAuthSettings({
onChange,
}: CodexAuthSettingsProps) {
const { t } = useTranslation();
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
const [hasUnifyBackup, setHasUnifyBackup] = useState(false);
const handleUnifyHistoryChange = (checked: boolean) => {
if (checked) {
setShowEnableConfirm(true);
return;
}
// 先探测有无迁移备份,决定关闭弹窗是否提供"恢复备份"勾选
void settingsApi
.hasCodexUnifyHistoryBackup()
.catch(() => false)
.then((hasBackup) => {
setHasUnifyBackup(hasBackup);
setShowDisableConfirm(true);
});
};
const handleEnableConfirm = (migrateExisting: boolean) => {
setShowEnableConfirm(false);
void onChange({
unifyCodexSessionHistory: true,
unifyCodexMigrateExisting: migrateExisting,
});
};
// 备份探测可能落后于正在后台进行的迁移(刚勾选迁入就立刻关闭时,
// 备份尚未产出)。只要本轮勾选过"迁入既有会话",就必须提供恢复入口;
// 真正有没有账本交给后端 restore 的 skippedReason 判定。
const showRestoreOption =
hasUnifyBackup || (settings.unifyCodexMigrateExisting ?? false);
const handleDisableConfirm = async (restoreBackup: boolean) => {
setShowDisableConfirm(false);
const saved = await onChange({
unifyCodexSessionHistory: false,
unifyCodexMigrateExisting: false,
});
// 关闭保存失败时绝不还原:否则开关仍开着(live 仍统一路由),
// 已迁移会话却被翻回 openai 桶,历史被拆成两半。
if (saved === false) return;
// 不再以探测结果短路:还原命令会在迁移锁上排队,等到迁移落盘后
// 拿到完整账本;确实无账本时由 skippedReason 提示。
if (!restoreBackup) return;
try {
const result = await settingsApi.restoreCodexUnifiedHistory();
if (result.skippedReason) {
// unify_toggle_on:还原排队期间开关被重新开启,后端拒绝还原
toast.info(
result.skippedReason === "unify_toggle_on"
? t("settings.unifyCodexHistoryRestoreSkippedToggleOn")
: t("settings.unifyCodexHistoryRestoreNothing"),
);
return;
}
toast.success(
t("settings.unifyCodexHistoryRestoreCompleted", {
files: result.restoredJsonlFiles,
rows: result.restoredStateRows,
}),
);
} catch (error) {
console.error("Failed to restore codex unified history:", error);
toast.error(t("settings.unifyCodexHistoryRestoreFailed"));
}
};
return (
<section className="space-y-4">
@@ -30,6 +104,39 @@ export function CodexAuthSettings({
onChange({ preserveCodexOfficialAuthOnSwitch: value })
}
/>
<ToggleRow
icon={<History className="h-4 w-4 text-sky-500" />}
title={t("settings.unifyCodexSessionHistory")}
description={t("settings.unifyCodexSessionHistoryDescription")}
checked={settings.unifyCodexSessionHistory ?? false}
onCheckedChange={handleUnifyHistoryChange}
/>
<ConfirmDialog
isOpen={showEnableConfirm}
title={t("confirm.unifyCodexHistory.title")}
message={t("confirm.unifyCodexHistory.message")}
checkboxLabel={t("confirm.unifyCodexHistory.migrateExisting")}
confirmText={t("confirm.unifyCodexHistory.confirm")}
onConfirm={handleEnableConfirm}
onCancel={() => setShowEnableConfirm(false)}
/>
<ConfirmDialog
isOpen={showDisableConfirm}
title={t("confirm.unifyCodexHistoryOff.title")}
message={t("confirm.unifyCodexHistoryOff.message")}
checkboxLabel={
showRestoreOption
? t("confirm.unifyCodexHistoryOff.restoreBackup")
: undefined
}
checkboxDefaultChecked
confirmText={t("confirm.unifyCodexHistoryOff.confirm")}
onConfirm={(restoreBackup) => void handleDisableConfirm(restoreBackup)}
onCancel={() => setShowDisableConfirm(false)}
/>
</section>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ import type { SettingsFormState } from "@/hooks/useSettings";
interface ProxyTabContentProps {
settings: SettingsFormState;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
}
export function ProxyTabContent({
+17 -2
View File
@@ -163,19 +163,34 @@ export function SettingsPage({
// 通用设置即时保存(无需手动点击)
// 使用 autoSaveSettings 避免误触发系统 API(开机自启、Claude 插件等)
// 返回保存是否成功:需要在保存成功后追加动作的调用方(如统一会话历史
// 关闭后的备份还原)据此短路,其余调用方可忽略返回值。
const handleAutoSave = useCallback(
async (updates: Partial<SettingsFormState>) => {
if (!settings) return;
async (updates: Partial<SettingsFormState>): Promise<boolean> => {
if (!settings) return false;
// 乐观更新前捕获旧值:autoSaveSettings 发送的是全量表单状态,后端按
// diff 触发副作用(如统一会话开关的 live 重写与历史迁移)。保存失败
// 不回滚的话,失败的变更会滞留在表单里,被之后任意一次无关保存原样
// 重放,绕过确认弹窗。
const previousValues = Object.fromEntries(
Object.keys(updates).map((key) => [
key,
settings[key as keyof SettingsFormState],
]),
) as Partial<SettingsFormState>;
updateSettings(updates);
try {
await autoSaveSettings(updates);
return true;
} catch (error) {
console.error("[SettingsPage] Failed to autosave settings", error);
updateSettings(previousValues);
toast.error(
t("settings.saveFailedGeneric", {
defaultValue: "保存失败,请重试",
}),
);
return false;
}
},
[autoSaveSettings, settings, t, updateSettings],
+1 -1
View File
@@ -16,7 +16,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-[100] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+1 -1
View File
@@ -20,7 +20,7 @@ export function ToggleRow({
return (
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{icon}
</div>
<div className="space-y-1">
+1 -1
View File
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-[100] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+11 -3
View File
@@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage";
interface ModelStatsTableProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
export function ModelStatsTable({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: stats, isLoading } = useModelStats(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
+17 -78
View File
@@ -2,10 +2,9 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react";
import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import {
getStreamCheckConfig,
@@ -20,13 +19,9 @@ export function ModelTestConfigPanel() {
const [error, setError] = useState<string | null>(null);
// 使用字符串状态以支持完全清空数字输入框
const [config, setConfig] = useState({
timeoutSecs: "45",
maxRetries: "2",
timeoutSecs: "8",
maxRetries: "1",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.5@low",
geminiModel: "gemini-3.5-flash",
testPrompt: "Who are you?",
});
useEffect(() => {
@@ -42,10 +37,6 @@ export function ModelTestConfigPanel() {
timeoutSecs: String(data.timeoutSecs),
maxRetries: String(data.maxRetries),
degradedThresholdMs: String(data.degradedThresholdMs),
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
});
} catch (e) {
setError(String(e));
@@ -63,13 +54,9 @@ export function ModelTestConfigPanel() {
try {
setIsSaving(true);
const parsed: StreamCheckConfig = {
timeoutSecs: parseNum(config.timeoutSecs, 45),
maxRetries: parseNum(config.maxRetries, 2),
timeoutSecs: parseNum(config.timeoutSecs, 8),
maxRetries: parseNum(config.maxRetries, 1),
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
@@ -98,49 +85,16 @@ export function ModelTestConfigPanel() {
</Alert>
)}
{/* 测试模型配置 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("streamCheck.testModels")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
<Input
id="claudeModel"
value={config.claudeModel}
onChange={(e) =>
setConfig({ ...config, claudeModel: e.target.value })
}
placeholder="claude-3-5-haiku-latest"
/>
</div>
<div className="space-y-2">
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
<Input
id="codexModel"
value={config.codexModel}
onChange={(e) =>
setConfig({ ...config, codexModel: e.target.value })
}
placeholder="gpt-4o-mini"
/>
</div>
<div className="space-y-2">
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
<Input
id="geminiModel"
value={config.geminiModel}
onChange={(e) =>
setConfig({ ...config, geminiModel: e.target.value })
}
placeholder="gemini-1.5-flash"
/>
</div>
</div>
</div>
{/* 连通检测语义说明:可达 ≠ 配置正确 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{t("streamCheck.connectivityNote", {
defaultValue:
"连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。",
})}
</AlertDescription>
</Alert>
{/* 检查参数配置 */}
<div className="space-y-4">
@@ -153,8 +107,8 @@ export function ModelTestConfigPanel() {
<Input
id="timeoutSecs"
type="number"
min={10}
max={120}
min={2}
max={60}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({ ...config, timeoutSecs: e.target.value })
@@ -193,21 +147,6 @@ export function ModelTestConfigPanel() {
/>
</div>
</div>
{/* 检查提示词配置 */}
<div className="space-y-2">
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
<Textarea
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="Who are you?"
rows={2}
className="min-h-[60px]"
/>
</div>
</div>
<div className="flex justify-end">
+11 -3
View File
@@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage";
interface ProviderStatsTableProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
export function ProviderStatsTable({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: stats, isLoading } = useProviderStats(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -111,6 +111,28 @@ export function RequestDetailPanel({
{t("usage.model", "模型")}
</dt>
<dd className="font-mono">{request.model}</dd>
{request.requestModel &&
request.requestModel !== request.model && (
<>
<dt className="mt-1 text-muted-foreground">
{t("usage.requestModel", "请求模型")}
</dt>
<dd className="font-mono text-xs">
{request.requestModel}
</dd>
</>
)}
{request.pricingModel &&
request.pricingModel !== request.model && (
<>
<dt className="mt-1 text-muted-foreground">
{t("usage.pricingModel", "计价模型")}
</dt>
<dd className="font-mono text-xs">
{request.pricingModel}
</dd>
</>
)}
</div>
<div>
<dt className="text-muted-foreground">
+27 -127
View File
@@ -19,13 +19,12 @@ import {
} from "@/components/ui/select";
import { useRequestLogs } from "@/lib/query/usage";
import {
KNOWN_APP_TYPES,
getFreshInputTokens,
isUnpricedUsage,
type LogFilters,
type UsageRangeSelection,
} from "@/types/usage";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import {
fmtInt,
@@ -38,6 +37,8 @@ interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
onRangeChange?: (range: UsageRangeSelection) => void;
}
@@ -46,21 +47,29 @@ export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType,
providerName,
model,
refreshIntervalMs,
onRangeChange,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
// 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效);
// 这里只保留日志特有的状态码筛选。
const [statusCode, setStatusCode] = useState<number | undefined>(undefined);
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const effectiveFilters: LogFilters = {
appType:
dashboardAppType && dashboardAppType !== "all"
? dashboardAppType
: undefined,
providerName,
model,
statusCode,
};
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
@@ -80,37 +89,13 @@ export function RequestLogTable({
setPage(0);
}, [
dashboardAppType,
providerName,
model,
range.customEndDate,
range.customStartDate,
range.preset,
]);
const handleSearch = () => {
setAppliedFilters(draftFilters);
setPage(0);
};
const handleReset = () => {
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const applySelectFilter = <K extends keyof LogFilters>(
key: K,
value: LogFilters[K],
) => {
setDraftFilters((prev) => ({
...prev,
[key]: value,
}));
setAppliedFilters((prev) => ({
...prev,
[key]: value,
}));
setPage(0);
};
const handleGoToPage = () => {
const trimmed = pageInput.trim();
if (!/^\d+$/.test(trimmed)) return;
@@ -127,44 +112,16 @@ export function RequestLogTable({
<div className="space-y-4">
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-1.5">
{/* App type */}
<Select
value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
applySelectFilter("appType", v === "all" ? undefined : v)
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
{KNOWN_APP_TYPES.map((at) => (
<SelectItem key={at} value={at}>
{t(`usage.appFilter.${at}`, { defaultValue: at })}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status code */}
<Select
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
applySelectFilter(
"statusCode",
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
)
}
value={statusCode?.toString() || "all"}
onValueChange={(v) => {
const parsed = Number.parseInt(v, 10);
setStatusCode(
v === "all" || !Number.isFinite(parsed) ? undefined : parsed,
);
setPage(0);
}}
>
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
<SelectValue placeholder={t("usage.statusCode")} />
@@ -179,43 +136,6 @@ export function RequestLogTable({
</SelectContent>
</Select>
{/* Provider search */}
<div className="relative min-w-[140px] flex-1">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="h-8 bg-background pl-7 text-xs"
value={draftFilters.providerName || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{/* Model search */}
<div className="relative min-w-[120px] flex-1">
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="h-8 bg-background text-xs"
value={draftFilters.model || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{onRangeChange && (
<UsageDateRangePicker
selection={range}
@@ -223,26 +143,6 @@ export function RequestLogTable({
onApply={onRangeChange}
/>
)}
{/* Search & Reset (icon-only) */}
<Button
size="icon"
variant="default"
onClick={handleSearch}
className="h-8 w-8"
title={t("common.search")}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
onClick={handleReset}
className="h-8 w-8"
title={t("common.reset")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
+201 -36
View File
@@ -7,6 +7,7 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import {
KNOWN_APP_TYPES,
type AppType,
type AppTypeFilter,
type UsageRangeSelection,
} from "@/types/usage";
@@ -17,10 +18,18 @@ import {
Activity,
RefreshCw,
Coins,
LayoutGrid,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ProviderIcon } from "@/components/ProviderIcon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
import { usageKeys, useModelStats, useProviderStats } from "@/lib/query/usage";
import { useUsageEventBridge } from "@/hooks/useUsageEventBridge";
import {
Accordion,
@@ -37,25 +46,56 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
// 0 表示关闭自动刷新(refetchInterval=false
const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const;
// 与 AppSwitcher 的 appIconName 保持一致(codex 复用 openai 图标)
const APP_FILTER_ICON: Record<AppType, string> = {
claude: "claude",
codex: "openai",
gemini: "gemini",
opencode: "opencode",
};
// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all"
// 就会撞名(重复 value、选中即清空筛选)。动态选项统一加前缀编码隔离值域。
const DYNAMIC_OPTION_PREFIX = "v:";
const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`;
const decodeOptionValue = (value: string) =>
value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length);
export function UsageDashboard() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [providerName, setProviderName] = useState<string | undefined>(
undefined,
);
const [model, setModel] = useState<string | undefined>(undefined);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
// 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合;
// 切 Provider 同理清掉模型(模型选项随 Provider 级联)。
const changeAppType = (next: AppTypeFilter) => {
setAppType(next);
if (next !== appType) {
setProviderName(undefined);
setModel(undefined);
}
};
const changeProviderName = (next: string | undefined) => {
setProviderName(next);
if (next !== providerName) {
setModel(undefined);
}
};
// 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有
// usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听)
useUsageEventBridge();
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
const changeRefreshInterval = () => {
const currentIndex = refreshIntervalOptionsMs.indexOf(
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
);
const safeIndex = currentIndex >= 0 ? currentIndex : 3;
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex];
const changeRefreshInterval = (next: number) => {
setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
};
@@ -73,6 +113,45 @@ export function UsageDashboard() {
).toLocaleString(locale)}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
// 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响),
// 模型列表随所选 Provider 级联。两者都只列当前范围内真实有数据的条目。
// refetchInterval 必须跟随面板的刷新设置——未筛选时这两个查询与统计表共享
// query key,落下的话会以默认 30s 拖着同 key 查询一起轮询,"--" 形同虚设。
const optionsRefetch = {
refetchInterval:
refreshIntervalMs > 0 ? refreshIntervalMs : (false as const),
};
const { data: providerOptionsData } = useProviderStats(
range,
{ appType },
optionsRefetch,
);
const { data: modelOptionsData } = useModelStats(
range,
{ appType, providerName },
optionsRefetch,
);
const providerOptions = useMemo(() => {
const names = new Set<string>();
for (const stat of providerOptionsData ?? []) {
names.add(stat.providerName);
}
// 数据刷新后选中项可能掉出列表(如改了时间范围);补回去保证 Select
// 仍能渲染选中文案,用户看得见才能主动清除。
if (providerName) names.add(providerName);
return Array.from(names);
}, [providerOptionsData, providerName]);
const modelOptions = useMemo(() => {
const names = new Set<string>();
for (const stat of modelOptionsData ?? []) {
names.add(stat.model);
}
if (model) names.add(model);
return Array.from(names);
}, [modelOptionsData, model]);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
@@ -90,35 +169,111 @@ export function UsageDashboard() {
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center p-1 bg-muted/30 rounded-lg border border-border/50">
{APP_FILTER_OPTIONS.map((type) => (
<button
key={type}
type="button"
onClick={() => setAppType(type)}
className={cn(
"px-3 py-1.5 rounded-md text-sm font-medium transition-all",
appType === type
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
{t(`usage.appFilter.${type}`)}
</button>
))}
{APP_FILTER_OPTIONS.map((type) => {
const label = t(`usage.appFilter.${type}`);
return (
<button
key={type}
type="button"
onClick={() => changeAppType(type)}
title={label}
aria-label={label}
className={cn(
"flex h-8 items-center justify-center px-2.5 rounded-md transition-all",
appType === type
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
{type === "all" ? (
<LayoutGrid className="h-4 w-4" />
) : (
<ProviderIcon
icon={APP_FILTER_ICON[type]}
name={label}
size={16}
/>
)}
</button>
);
})}
</div>
<div className="flex items-center gap-2 ml-auto lg:ml-0">
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3 text-xs"
title={t("common.refresh", "刷新")}
onClick={changeRefreshInterval}
<Select
value={
providerName != null ? encodeOptionValue(providerName) : "all"
}
onValueChange={(v) => changeProviderName(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default [&>span]:min-w-0 [&>span]:truncate"
title={providerName ?? t("usage.filterBySource")}
>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
</Button>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-w-[280px]">
<SelectItem value="all">{t("usage.allSources")}</SelectItem>
{providerOptions.map((name) => (
<SelectItem
key={name}
value={encodeOptionValue(name)}
title={name}
className="[&>span]:min-w-0 [&>span]:truncate"
>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={model != null ? encodeOptionValue(model) : "all"}
onValueChange={(v) => setModel(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default [&>span]:min-w-0 [&>span]:truncate"
title={model ?? t("usage.filterByModel")}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-w-[280px]">
<SelectItem value="all">{t("usage.allModels")}</SelectItem>
{modelOptions.map((name) => (
<SelectItem
key={name}
value={encodeOptionValue(name)}
title={name}
className="[&>span]:min-w-0 [&>span]:truncate"
>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 ml-auto lg:ml-0">
<Select
value={String(refreshIntervalMs)}
onValueChange={(v) => changeRefreshInterval(Number(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default"
title={t("usage.refreshInterval")}
aria-label={t("usage.refreshInterval")}
>
<span className="flex items-center gap-2">
<RefreshCw className="h-3.5 w-3.5 shrink-0" />
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
{REFRESH_INTERVAL_OPTIONS_MS.map((ms) => (
<SelectItem key={ms} value={String(ms)}>
{ms > 0 ? `${ms / 1000}s` : t("usage.refreshOff")}
</SelectItem>
))}
</SelectContent>
</Select>
<UsageDateRangePicker
selection={range}
@@ -132,6 +287,8 @@ export function UsageDashboard() {
<UsageHero
range={range}
appType={appType === "all" ? undefined : appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
@@ -139,6 +296,8 @@ export function UsageDashboard() {
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
@@ -171,6 +330,8 @@ export function UsageDashboard() {
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
onRangeChange={setRange}
/>
@@ -180,6 +341,8 @@ export function UsageDashboard() {
<ProviderStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
@@ -188,6 +351,8 @@ export function UsageDashboard() {
<ModelStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
+11 -4
View File
@@ -1,6 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import {
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -273,10 +278,12 @@ export function UsageDateRangePicker({
<Button
type="button"
variant={selection.preset === "custom" ? "default" : "outline"}
className="justify-start gap-2"
className="h-9 w-[100px] justify-start gap-1.5 text-xs"
title={triggerLabel}
>
<CalendarDays className="h-4 w-4" />
<span className="truncate">{triggerLabel}</span>
<CalendarDays className="h-4 w-4 shrink-0" />
<span className="truncate flex-1">{triggerLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
+40 -6
View File
@@ -1,8 +1,11 @@
import { cloneElement, isValidElement } from "react";
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummaryByApp } from "@/lib/query/usage";
import { cn } from "@/lib/utils";
import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import {
Activity,
ArrowDownToLine,
@@ -30,6 +33,8 @@ import {
interface UsageHeroProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
@@ -47,8 +52,10 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
iconBg: "bg-amber-500/10",
},
codex: {
accent: "text-emerald-600 dark:text-emerald-400",
iconBg: "bg-emerald-500/10",
// OpenAI/Codex 走黑白单色调;中性灰在深浅模式都能透出方块底色,
// 不像纯黑 bg-black/10 在深色背景下会糊掉。
accent: "text-neutral-700 dark:text-neutral-300",
iconBg: "bg-neutral-500/10",
},
gemini: {
accent: "text-sky-600 dark:text-sky-400",
@@ -130,17 +137,44 @@ function deriveCacheWriteState(appTypes: string[]): CacheWriteState {
return "partial";
}
/**
* Hero "全部"退
* APP_ICON_MAP / cloneElement
* 20px titleTheme
*/
function AppGlyph({
appType,
accentClass,
}: {
appType?: string;
accentClass: string;
}) {
if (appType && appType in APP_ICON_MAP) {
const base = APP_ICON_MAP[appType as AppId].icon;
if (isValidElement<{ size?: number }>(base)) {
return cloneElement(base, { size: 20 });
}
}
return <Zap className={cn("h-5 w-5", accentClass)} />;
}
export function UsageHero({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: UsageHeroProps) {
const { t, i18n } = useTranslation();
const lang = getResolvedLang(i18n);
const { data, isLoading } = useUsageSummaryByApp(range, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data, isLoading } = useUsageSummaryByApp(
range,
{ providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
// No client-side filtering: Hero's totals must match the Trend/Logs/Stats
// below, which all go through the backend's full set of app_types. The
@@ -217,7 +251,7 @@ export function UsageHero({
titleTheme.iconBg,
)}
>
<Zap className={cn("h-5 w-5", titleTheme.accent)} />
<AppGlyph appType={appType} accentClass={titleTheme.accent} />
</div>
<div>
<div className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-0.5">
+11 -3
View File
@@ -24,6 +24,8 @@ interface UsageTrendChartProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
@@ -31,13 +33,19 @@ export function UsageTrendChart({
range,
rangeLabel,
appType,
providerName,
model,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: trends, isLoading } = useUsageTrends(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return (