mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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、Opus 或 Haiku 角色时使用;通常可以留空。",
|
||||
"用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CustomUserAgentField
|
||||
id="claude-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 外观。某工具结果一落进 toolVersions,has(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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user